Phase 1 of manual channel management

This commit is contained in:
Jack Kingsman
2026-03-12 14:01:00 -07:00
parent 22ca5410ee
commit 5c85a432c8
7 changed files with 45 additions and 8 deletions

View File

@@ -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.

View File

@@ -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")

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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