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])