mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-30 21:43:14 +02:00
Add duty cycle display to repeaters. Closes #308.
This commit is contained in:
@@ -602,6 +602,15 @@ class RepeaterRadioSettingsResponse(BaseModel):
|
||||
radio: str | None = Field(default=None, description="Radio settings (freq,bw,sf,cr)")
|
||||
tx_power: str | None = Field(default=None, description="TX power in dBm")
|
||||
airtime_factor: str | None = Field(default=None, description="Airtime factor")
|
||||
duty_cycle_limit: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Configured duty-cycle limit as a percentage string (e.g. '25.0%'), derived "
|
||||
"by firmware from airtime_factor (100/(af+1)). This is the configured ceiling, "
|
||||
"not the current measured duty cycle. Only available on firmware >= 1.15; None "
|
||||
"on older nodes that don't support 'get dutycycle'."
|
||||
),
|
||||
)
|
||||
repeat_enabled: str | None = Field(default=None, description="Repeat mode enabled")
|
||||
flood_max: str | None = Field(default=None, description="Max flood hops")
|
||||
|
||||
|
||||
@@ -345,10 +345,20 @@ async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsRespo
|
||||
("get radio", "radio"),
|
||||
("get tx", "tx_power"),
|
||||
("get af", "airtime_factor"),
|
||||
("get dutycycle", "duty_cycle_limit"),
|
||||
("get repeat", "repeat_enabled"),
|
||||
("get flood.max", "flood_max"),
|
||||
],
|
||||
)
|
||||
# `get dutycycle` only exists on firmware >= 1.15. Older nodes fall through to
|
||||
# the generic unknown-config handler and reply "??: dutycycle" (or an ERROR
|
||||
# string), which extract_response_text passes back verbatim. Drop those so the
|
||||
# field reads as unsupported rather than surfacing the sentinel to the UI.
|
||||
dc = results.get("duty_cycle_limit")
|
||||
if dc is not None:
|
||||
dc = dc.strip()
|
||||
if dc.startswith("??") or dc.lower().startswith("error"):
|
||||
results["duty_cycle_limit"] = None
|
||||
return RepeaterRadioSettingsResponse(**results)
|
||||
|
||||
|
||||
|
||||
@@ -74,6 +74,11 @@ export function RadioSettingsPane({
|
||||
/>
|
||||
<KvRow label="TX Power" value={data.tx_power != null ? `${data.tx_power} dBm` : '—'} />
|
||||
<KvRow label="Airtime Factor" value={data.airtime_factor ?? '—'} />
|
||||
{/* Duty cycle limit is firmware >= 1.15 only; omit the row entirely on
|
||||
older nodes rather than showing an empty placeholder. */}
|
||||
{data.duty_cycle_limit != null && (
|
||||
<KvRow label="Duty Cycle Limit" value={data.duty_cycle_limit} />
|
||||
)}
|
||||
<KvRow label="Repeat Mode" value={data.repeat_enabled ?? '—'} />
|
||||
<KvRow label="Max Flood Hops" value={data.flood_max ?? '—'} />
|
||||
</div>
|
||||
|
||||
@@ -499,6 +499,7 @@ describe('RepeaterDashboard', () => {
|
||||
radio: '910.5250244,62.5,7,5',
|
||||
tx_power: '20',
|
||||
airtime_factor: '0',
|
||||
duty_cycle_limit: '100.0%',
|
||||
repeat_enabled: '1',
|
||||
flood_max: '3',
|
||||
};
|
||||
|
||||
@@ -336,6 +336,7 @@ describe('useRepeaterDashboard', () => {
|
||||
radio: null,
|
||||
tx_power: null,
|
||||
airtime_factor: null,
|
||||
duty_cycle_limit: null,
|
||||
repeat_enabled: null,
|
||||
flood_max: null,
|
||||
});
|
||||
|
||||
@@ -489,6 +489,9 @@ export interface RepeaterRadioSettingsResponse {
|
||||
radio: string | null;
|
||||
tx_power: string | null;
|
||||
airtime_factor: string | null;
|
||||
// Configured duty-cycle limit (e.g. "25.0%"), firmware-derived from airtime_factor.
|
||||
// Only present on firmware >= 1.15; null on older nodes.
|
||||
duty_cycle_limit: string | null;
|
||||
repeat_enabled: string | null;
|
||||
flood_max: string | null;
|
||||
}
|
||||
|
||||
@@ -994,12 +994,13 @@ class TestRepeaterRadioSettings:
|
||||
mc = _mock_mc()
|
||||
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
|
||||
|
||||
# Build responses for all 6 commands
|
||||
# Build responses for all 7 commands
|
||||
responses = [
|
||||
"v2.1.0", # ver
|
||||
"915.0,250,7,5", # get radio
|
||||
"20", # get tx
|
||||
"0", # get af
|
||||
"100.0%", # get dutycycle (af=0 -> 100/(0+1) = 100.0%)
|
||||
"1", # get repeat
|
||||
"3", # get flood.max
|
||||
]
|
||||
@@ -1023,9 +1024,44 @@ class TestRepeaterRadioSettings:
|
||||
assert response.radio == "915.0,250,7,5"
|
||||
assert response.tx_power == "20"
|
||||
assert response.airtime_factor == "0"
|
||||
assert response.duty_cycle_limit == "100.0%"
|
||||
assert response.repeat_enabled == "1"
|
||||
assert response.flood_max == "3"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dutycycle_unsupported_on_old_firmware(self, test_db):
|
||||
"""Pre-1.15 nodes reply with the unknown-config sentinel; treat as None."""
|
||||
mc = _mock_mc()
|
||||
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
|
||||
|
||||
responses = [
|
||||
"v1.14.0", # ver
|
||||
"915.0,250,7,5", # get radio
|
||||
"20", # get tx
|
||||
"3", # get af
|
||||
"??: dutycycle", # get dutycycle — unknown-config fallthrough on old fw
|
||||
"1", # get repeat
|
||||
"3", # get flood.max
|
||||
]
|
||||
get_msg_results = [
|
||||
_radio_result(
|
||||
EventType.CONTACT_MSG_RECV,
|
||||
{"pubkey_prefix": KEY_A[:12], "text": text, "txt_type": 1},
|
||||
)
|
||||
for text in responses
|
||||
]
|
||||
mc.commands.get_msg = AsyncMock(side_effect=get_msg_results)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||
):
|
||||
response = await repeater_radio_settings(KEY_A)
|
||||
|
||||
assert response.airtime_factor == "3"
|
||||
assert response.duty_cycle_limit is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_failure(self, test_db):
|
||||
mc = _mock_mc()
|
||||
@@ -1039,9 +1075,10 @@ class TestRepeaterRadioSettings:
|
||||
no_msgs = _radio_result(EventType.NO_MORE_MSGS)
|
||||
mc.commands.get_msg = AsyncMock(side_effect=[first_response] + [no_msgs] * 50)
|
||||
|
||||
# Provide clock ticks: first command succeeds quickly, others expire
|
||||
# Provide clock ticks: first command succeeds quickly, others expire.
|
||||
# 6 commands follow the initial success (ver), so 6 expiry windows.
|
||||
clock_ticks = [0.0, 0.1] # First fetch succeeds
|
||||
for i in range(5):
|
||||
for i in range(6):
|
||||
base = 100.0 * (i + 1)
|
||||
clock_ticks.extend([base, base + 5.0, base + 11.0])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user