diff --git a/app/AGENTS.md b/app/AGENTS.md index 2672334..69a3494 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -98,6 +98,7 @@ app/ - Packet `path_len` values are hop counts, not byte counts. - Hop width comes from the packet or radio `path_hash_mode`: `0` = 1-byte, `1` = 2-byte, `2` = 3-byte. +- Channel slot count comes from firmware-reported `DEVICE_INFO.max_channels`; do not hardcode `40` when scanning/offloading channel slots. - Contacts persist `out_path_hash_mode` in the database so contact sync and outbound DM routing reuse the exact stored mode instead of inferring from path bytes. - Contacts may also persist `route_override_path`, `route_override_len`, and `route_override_hash_mode`. `Contact.to_radio_dict()` gives these override fields precedence over learned `last_path*`, while advert processing still updates the learned route for telemetry/fallback. - `contact_advert_paths` identity is `(public_key, path_hex, path_len)` because the same hex bytes can represent different routes at different hop widths. diff --git a/app/radio.py b/app/radio.py index a6a4bb0..d71c8a8 100644 --- a/app/radio.py +++ b/app/radio.py @@ -129,6 +129,7 @@ class RadioManager: self._setup_lock: asyncio.Lock | None = None self._setup_in_progress: bool = False self._setup_complete: bool = False + self.max_channels: int = 40 self.path_hash_mode: int = 0 self.path_hash_mode_supported: bool = False @@ -366,6 +367,7 @@ class RadioManager: await self._disable_meshcore_auto_reconnect(mc) self._meshcore = None self._setup_complete = False + self.max_channels = 40 self.path_hash_mode = 0 self.path_hash_mode_supported = False logger.debug("Radio disconnected") diff --git a/app/radio_sync.py b/app/radio_sync.py index 5fe1672..2131643 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -33,6 +33,8 @@ from app.websocket import broadcast_error logger = logging.getLogger(__name__) +DEFAULT_MAX_CHANNELS = 40 + def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]: """Return key contact fields for sync failure diagnostics.""" @@ -98,6 +100,20 @@ async def upsert_channel_from_radio_slot(payload: dict, *, on_radio: bool) -> st return key_hex +def get_radio_channel_limit(max_channels: int | None = None) -> int: + """Return the effective channel-slot limit for the connected firmware.""" + discovered = getattr(radio_manager, "max_channels", DEFAULT_MAX_CHANNELS) + try: + limit = max(1, int(discovered)) + except (TypeError, ValueError): + limit = DEFAULT_MAX_CHANNELS + + if max_channels is not None: + return min(limit, max(1, int(max_channels))) + + return limit + + # Message poll task handle _message_poll_task: asyncio.Task | None = None @@ -285,7 +301,7 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict: return {"synced": synced, "removed": removed} -async def sync_and_offload_channels(mc: MeshCore) -> dict: +async def sync_and_offload_channels(mc: MeshCore, max_channels: int | None = None) -> dict: """ Sync channels from radio to database, then clear them from radio. Returns counts of synced and cleared channels. @@ -294,8 +310,10 @@ async def sync_and_offload_channels(mc: MeshCore) -> dict: cleared = 0 try: - # Check all 40 channel slots - for idx in range(40): + channel_limit = get_radio_channel_limit(max_channels) + + # Check all available channel slots for this firmware variant + for idx in range(channel_limit): result = await mc.commands.get_channel(idx) if result.type != EventType.CHANNEL_INFO: diff --git a/app/services/radio_lifecycle.py b/app/services/radio_lifecycle.py index 1261043..4071830 100644 --- a/app/services/radio_lifecycle.py +++ b/app/services/radio_lifecycle.py @@ -78,17 +78,25 @@ async def run_post_connect_setup(radio_manager) -> None: return await _original_handle_rx(data) reader.handle_rx = _capture_handle_rx + radio_manager.max_channels = 40 radio_manager.path_hash_mode = 0 radio_manager.path_hash_mode_supported = False try: device_query = await mc.commands.send_device_query() + if device_query and "max_channels" in device_query.payload: + radio_manager.max_channels = max( + 1, int(device_query.payload["max_channels"]) + ) if device_query and "path_hash_mode" in device_query.payload: radio_manager.path_hash_mode = device_query.payload["path_hash_mode"] radio_manager.path_hash_mode_supported = True elif _captured_frame: - # Raw-frame fallback: byte 1 = fw_ver, byte 81 = path_hash_mode + # Raw-frame fallback: + # byte 1 = fw_ver, byte 3 = max_channels, byte 81 = path_hash_mode raw = _captured_frame[-1] fw_ver = raw[1] if len(raw) > 1 else 0 + if fw_ver >= 3 and len(raw) >= 4: + radio_manager.max_channels = max(1, raw[3]) if fw_ver >= 10 and len(raw) >= 82: radio_manager.path_hash_mode = raw[81] radio_manager.path_hash_mode_supported = True @@ -106,8 +114,9 @@ async def run_post_connect_setup(radio_manager) -> None: logger.info("Path hash mode: %d (supported)", radio_manager.path_hash_mode) else: logger.debug("Firmware does not report path_hash_mode") + logger.info("Max channel slots: %d", radio_manager.max_channels) except Exception as exc: - logger.debug("Failed to query path_hash_mode: %s", exc) + logger.debug("Failed to query device info capabilities: %s", exc) finally: reader.handle_rx = _original_handle_rx diff --git a/tests/test_radio.py b/tests/test_radio.py index a1ab39b..88994cb 100644 --- a/tests/test_radio.py +++ b/tests/test_radio.py @@ -475,6 +475,7 @@ class TestManualDisconnectCleanup: mock_mc.connection_manager = connection_manager rm._meshcore = mock_mc rm._setup_complete = True + rm.max_channels = 8 rm.path_hash_mode = 2 rm.path_hash_mode_supported = True @@ -486,6 +487,7 @@ class TestManualDisconnectCleanup: assert reconnect_task is not None and reconnect_task.cancelled() assert rm.meshcore is None assert rm.is_setup_complete is False + assert rm.max_channels == 40 assert rm.path_hash_mode == 0 assert rm.path_hash_mode_supported is False diff --git a/tests/test_radio_lifecycle_service.py b/tests/test_radio_lifecycle_service.py index d93e472..4ce2f69 100644 --- a/tests/test_radio_lifecycle_service.py +++ b/tests/test_radio_lifecycle_service.py @@ -99,7 +99,9 @@ class TestRunPostConnectSetup: initial_mc.start_auto_message_fetching = AsyncMock() replacement_mc = MagicMock() - replacement_mc.commands.send_device_query = AsyncMock(return_value=None) + replacement_mc.commands.send_device_query = AsyncMock( + return_value=MagicMock(payload={"max_channels": 8}) + ) replacement_mc.commands.set_flood_scope = AsyncMock(return_value=None) replacement_mc._reader = MagicMock() replacement_mc._reader.handle_rx = AsyncMock() @@ -110,6 +112,7 @@ class TestRunPostConnectSetup: radio_manager._setup_lock = None radio_manager._setup_in_progress = False radio_manager._setup_complete = False + radio_manager.max_channels = 40 radio_manager.path_hash_mode = 0 radio_manager.path_hash_mode_supported = False @@ -141,3 +144,4 @@ class TestRunPostConnectSetup: mock_sync_time.assert_awaited_once_with(replacement_mc) replacement_mc.start_auto_message_fetching.assert_awaited_once() initial_mc.start_auto_message_fetching.assert_not_called() + assert radio_manager.max_channels == 8 diff --git a/tests/test_radio_sync.py b/tests/test_radio_sync.py index aa4f44d..ab84550 100644 --- a/tests/test_radio_sync.py +++ b/tests/test_radio_sync.py @@ -1037,7 +1037,7 @@ class TestSyncAndOffloadChannels: @pytest.mark.asyncio async def test_iterates_all_40_channel_slots(self): - """All 40 channel slots are checked.""" + """All firmware-reported channel slots are checked.""" from app.radio_sync import sync_and_offload_channels empty_result = MagicMock() @@ -1045,10 +1045,11 @@ class TestSyncAndOffloadChannels: mock_mc = MagicMock() mock_mc.commands.get_channel = AsyncMock(return_value=empty_result) + radio_manager.max_channels = 8 result = await sync_and_offload_channels(mock_mc) - assert mock_mc.commands.get_channel.call_count == 40 + assert mock_mc.commands.get_channel.call_count == 8 assert result["synced"] == 0 assert result["cleared"] == 0