diff --git a/app/models.py b/app/models.py index 42efecc..ccee2e3 100644 --- a/app/models.py +++ b/app/models.py @@ -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") diff --git a/app/routers/repeaters.py b/app/routers/repeaters.py index 2eaedfe..59e82f3 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -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) diff --git a/frontend/src/components/repeater/RepeaterRadioSettingsPane.tsx b/frontend/src/components/repeater/RepeaterRadioSettingsPane.tsx index a187e87..0ffbe04 100644 --- a/frontend/src/components/repeater/RepeaterRadioSettingsPane.tsx +++ b/frontend/src/components/repeater/RepeaterRadioSettingsPane.tsx @@ -74,6 +74,11 @@ export function RadioSettingsPane({ /> + {/* 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 && ( + + )} diff --git a/frontend/src/test/repeaterDashboard.test.tsx b/frontend/src/test/repeaterDashboard.test.tsx index a865149..989f1c1 100644 --- a/frontend/src/test/repeaterDashboard.test.tsx +++ b/frontend/src/test/repeaterDashboard.test.tsx @@ -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', }; diff --git a/frontend/src/test/useRepeaterDashboard.test.ts b/frontend/src/test/useRepeaterDashboard.test.ts index 2164893..ae4b35a 100644 --- a/frontend/src/test/useRepeaterDashboard.test.ts +++ b/frontend/src/test/useRepeaterDashboard.test.ts @@ -336,6 +336,7 @@ describe('useRepeaterDashboard', () => { radio: null, tx_power: null, airtime_factor: null, + duty_cycle_limit: null, repeat_enabled: null, flood_max: null, }); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b102e95..611aa40 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; } diff --git a/tests/test_repeater_routes.py b/tests/test_repeater_routes.py index acc1004..04b0128 100644 --- a/tests/test_repeater_routes.py +++ b/tests/test_repeater_routes.py @@ -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])