diff --git a/docs/MESHCORE.md b/docs/MESHCORE.md index ea5ecb3..8e904e8 100644 --- a/docs/MESHCORE.md +++ b/docs/MESHCORE.md @@ -39,7 +39,7 @@ Without `MESHCORE_UPLOAD_ENABLED`, `STORAGE_API_*` is ignored. When `MESHCORE_UPLOAD_ENABLED=true` and `STORAGE_API_*` are set, the bot also: 1. **On connect** — reads the device channel table once (`meshcore.commands.get_channel`) and `POST`s the same snapshot to `/api/meshcore/feeders/{prefix}/mc-channel-sync/` on **each** configured API (`STORAGE_API_ROOT` and optional `STORAGE_API_2_ROOT` when `MESHCORE_UPLOAD_ENABLED=true`). Uses `STORAGE_API_2_TOKEN` if set, otherwise the primary `STORAGE_API_TOKEN`. -2. **WebSocket** — connects to `ws/nodes/?api_key=…` (URL derived from `STORAGE_API_ROOT` when `MESHFLOW_WS_URL` is unset). MeshCore feeders automatically append `feeder_pubkey_prefix` from the device pubkey after connect (no env var). Used for UI **apply to radio** (`apply_mc_channel_config`): the REST endpoint only dispatches to the bot over WS; the bot writes the device via `set_channel`, then re-syncs to the API. +2. **WebSocket** — connects to `ws/nodes/?api_key=…` (URL derived from `STORAGE_API_ROOT` when `MESHFLOW_WS_URL` is unset). MeshCore feeders automatically append `feeder_pubkey_prefix` from the device pubkey after connect (no env var). Used for UI **apply to radio** (`apply_mc_channel_config`): the REST endpoint only dispatches to the bot over WS; the bot **clears any device slot not in the apply payload**, writes each listed slot via `set_channel`, then re-syncs to the API ([#130](https://github.com/pskillen/meshflow-bot/issues/130)). Traceroute commands remain Meshtastic-only; MC feeders ignore `traceroute` WS messages. diff --git a/src/meshcore/channels.py b/src/meshcore/channels.py index b6c5ccb..be22319 100644 --- a/src/meshcore/channels.py +++ b/src/meshcore/channels.py @@ -14,6 +14,8 @@ DEFAULT_MAX_CHANNEL_SCAN = 16 APPLY_READBACK_DELAY_S = 2.0 +# Companion protocol: delete channel = SET_CHANNEL with empty name + all-zero secret. +CLEAR_CHANNEL_SECRET = bytes(16) def _normalize_region_scope(value: Any) -> str | None: @@ -152,8 +154,50 @@ async def read_device_channels( return merge_channel_region_scopes(channels, scope_hints) +async def _clear_device_channel_slot(meshcore: MeshCore, idx: int) -> bool: + """ + Remove a channel slot (companion SET_CHANNEL with empty name). + + meshcore_py 2.3.x has no del_channel; pass explicit zero secret so the + library does not derive one from the empty name hash. + """ + evt = await meshcore.commands.set_channel(idx, "", CLEAR_CHANNEL_SECRET) + if evt.type == EventType.ERROR: + logger.warning("clear_channel(%s) failed: %s", idx, evt.payload) + return False + logger.info("MeshCore channel [%s] cleared (not in apply payload)", idx) + return True + + +async def clear_unlisted_device_channels( + meshcore: MeshCore, + desired_channels: list[dict], + *, + max_channels: int = DEFAULT_MAX_CHANNEL_SCAN, +) -> None: + """Clear device slots that have a name but are absent from the apply payload.""" + desired_indices = { + int(ch["mc_channel_idx"]) + for ch in desired_channels + if ch.get("mc_channel_idx") is not None + } + existing = await read_device_channels( + meshcore, max_channels=max_channels, scope_hints=None + ) + for row in existing: + idx = int(row["mc_channel_idx"]) + if idx not in desired_indices: + await _clear_device_channel_slot(meshcore, idx) + + async def apply_device_channels(meshcore: MeshCore, channels: list[dict]) -> None: - """Write channel list to device (UI push path).""" + """ + Write channel list to device (UI push path). + + Clears any currently configured slot not listed in ``channels``, then writes + each payload row so the radio matches the Meshflow feeder mirror layout. + """ + await clear_unlisted_device_channels(meshcore, channels) for ch in channels: idx = int(ch["mc_channel_idx"]) name = str(ch.get("name") or f"channel {idx}") diff --git a/test/meshcore/test_channels.py b/test/meshcore/test_channels.py index e2198a4..864cad5 100644 --- a/test/meshcore/test_channels.py +++ b/test/meshcore/test_channels.py @@ -6,13 +6,19 @@ from unittest.mock import AsyncMock, MagicMock, patch from meshcore.events import Event, EventType -from src.meshcore.channels import (_channel_entry_from_info, - apply_device_channels, log_device_channels, - log_labeled_channel_config, - merge_channel_region_scopes, - read_device_channels, snapshot_sync_body, - verify_apply_channels, - warn_apply_readback_mismatches) +from src.meshcore.channels import ( + CLEAR_CHANNEL_SECRET, + _channel_entry_from_info, + apply_device_channels, + clear_unlisted_device_channels, + log_device_channels, + log_labeled_channel_config, + merge_channel_region_scopes, + read_device_channels, + snapshot_sync_body, + verify_apply_channels, + warn_apply_readback_mismatches, +) def test_channel_entry_public(): @@ -167,22 +173,78 @@ def test_apply_device_channels_sets_flood_scope() -> None: return_value=Event(EventType.CHANNEL_INFO, {}, {}) ) mc.commands.set_flood_scope = AsyncMock(return_value=Event(EventType.OK, {}, {})) - asyncio.run( - apply_device_channels( - mc, - [ - { - "mc_channel_idx": 1, - "name": "galloway", - "mc_channel_type": "HASHTAG", - "region_scope": "sample-west", - }, - ], + with patch( + "src.meshcore.channels.read_device_channels", + new_callable=AsyncMock, + return_value=[], + ): + asyncio.run( + apply_device_channels( + mc, + [ + { + "mc_channel_idx": 1, + "name": "galloway", + "mc_channel_type": "HASHTAG", + "region_scope": "sample-west", + }, + ], + ) ) - ) mc.commands.set_flood_scope.assert_awaited_once_with("sample-west") +def test_clear_unlisted_device_channels() -> None: + mc = MagicMock() + mc.commands.set_channel = AsyncMock(return_value=Event(EventType.OK, {}, {})) + existing = [ + {"mc_channel_idx": 0, "name": "Public", "mc_channel_type": "PUBLIC"}, + {"mc_channel_idx": 6, "name": "glasgow", "mc_channel_type": "HASHTAG"}, + ] + desired = [{"mc_channel_idx": 0, "name": "Public", "mc_channel_type": "PUBLIC"}] + + with patch( + "src.meshcore.channels.read_device_channels", + new_callable=AsyncMock, + return_value=existing, + ): + asyncio.run(clear_unlisted_device_channels(mc, desired)) + + mc.commands.set_channel.assert_awaited_once_with(6, "", CLEAR_CHANNEL_SECRET) + + +def test_apply_device_channels_clears_stale_slots_before_write() -> None: + mc = MagicMock() + mc.commands.set_channel = AsyncMock(return_value=Event(EventType.OK, {}, {})) + mc.commands.set_flood_scope = AsyncMock(return_value=Event(EventType.OK, {}, {})) + existing = [ + {"mc_channel_idx": 0, "name": "Public", "mc_channel_type": "PUBLIC"}, + {"mc_channel_idx": 6, "name": "glasgow", "mc_channel_type": "HASHTAG"}, + ] + desired = [ + {"mc_channel_idx": 0, "name": "Public", "mc_channel_type": "PUBLIC"}, + { + "mc_channel_idx": 3, + "name": "glasgow", + "mc_channel_type": "HASHTAG", + "region_scope": "gla", + }, + ] + + with patch( + "src.meshcore.channels.read_device_channels", + new_callable=AsyncMock, + return_value=existing, + ): + asyncio.run(apply_device_channels(mc, desired)) + + calls = mc.commands.set_channel.await_args_list + assert calls[0].args == (6, "", CLEAR_CHANNEL_SECRET) + assert calls[1].args[0] == 0 + assert calls[2].args[0] == 3 + assert calls[2].args[1] == "#glasgow" + + def test_log_labeled_channel_config_desired(caplog) -> None: import logging