diff --git a/AGENTS.md b/AGENTS.md index a8cc29d..5c8b1a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,16 @@ To improve repeater disambiguation in the network visualizer, the backend stores - Only the N most recent unique paths are retained per contact (currently 10). - See `frontend/src/components/AGENTS_packet_visualizer.md` ยง "Advert-Path Identity Hints" for how the visualizer consumes this data. +## Path Hash Modes + +MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers. + +- `path_hash_mode` values are `0` = 1-byte, `1` = 2-byte, `2` = 3-byte. +- `GET /api/radio/config` exposes both the current `path_hash_mode` and `path_hash_mode_supported`. +- `PATCH /api/radio/config` may update `path_hash_mode` only when the connected firmware supports it. +- Contacts persist `out_path_hash_mode` separately from `last_path` so contact sync and DM send paths can round-trip correctly even when hop bytes are ambiguous. +- `path_len` in API payloads is always hop count, not byte count. The actual path byte length is `hop_count * hash_size`. + ## Data Flow ### Incoming Messages @@ -239,6 +249,7 @@ Key test files: - `tests/test_ack_tracking_wiring.py` - DM ACK tracking extraction and wiring - `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field - `tests/test_community_mqtt.py` - Community MQTT publisher (JWT, packet format, hash, broadcast) +- `tests/test_radio_sync.py` - Radio sync, periodic tasks, and contact offload back to the radio - `tests/test_real_crypto.py` - Real cryptographic operations - `tests/test_disable_bots.py` - MESHCORE_DISABLE_BOTS=true feature @@ -260,8 +271,8 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). | Method | Endpoint | Description | |--------|----------|-------------| | GET | `/api/health` | Connection status, fanout statuses, bots_disabled flag | -| GET | `/api/radio/config` | Radio configuration | -| PATCH | `/api/radio/config` | Update name, location, radio params | +| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode` and `path_hash_mode_supported` | +| PATCH | `/api/radio/config` | Update name, location, radio params, and `path_hash_mode` when supported | | PUT | `/api/radio/private-key` | Import private key to radio | | POST | `/api/radio/advertise` | Send advertisement | | POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected | @@ -367,6 +378,8 @@ All external integrations are managed through the fanout bus (`app/fanout/`). Ea `broadcast_event()` in `websocket.py` dispatches `message` and `raw_packet` events to the fanout manager. See `app/fanout/AGENTS_fanout.md` for full architecture details. +Community MQTT forwards raw packets only. Its derived `path` field, when present on direct packets, is a comma-separated list of hop identifiers as reported by the packet format. Token width therefore varies with the packet's path hash mode; it is intentionally not a flat per-byte rendering. + ### Server-Side Decryption The server can decrypt packets using stored keys, both in real-time and for historical packets. diff --git a/README.md b/README.md index 4dcf0f9..821c1dd 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you * Access your radio remotely over your network or VPN * Search for hashtag room names for channels you don't have keys for yet * Forward packets to MQTT brokers (private: decrypted messages and/or raw packets; community aggregators like LetsMesh.net: raw packets only) +* Use the more recent 1.14 firmwares which support multibyte pathing in all traffic and display systems within the app * Visualize the mesh as a map or node set, view repeater stats, and more! **Warning:** This app has no auth, and is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ The bots can execute arbitrary Python code which means anyone on your network can, too. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` โ€” this prevents all bot execution and blocks bot configuration changes via the API. If you need access control, consider using a reverse proxy like Nginx, or extending FastAPI; access control and user management are outside the scope of this app. diff --git a/app/AGENTS.md b/app/AGENTS.md index 50d083c..24f5aab 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -72,6 +72,13 @@ app/ ## Important Behaviors +### Multibyte routing + +- 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. +- 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. +- `contact_advert_paths` identity is `(public_key, path_hex, path_len)` because the same hex bytes can represent different routes at different hop widths. + ### Read/unread state - Server is source of truth (`contacts.last_read_at`, `channels.last_read_at`). @@ -107,6 +114,7 @@ app/ - Configs stored in `fanout_configs` table, managed via `GET/POST/PATCH/DELETE /api/fanout`. - `broadcast_event()` in `websocket.py` dispatches to the fanout manager for `message` and `raw_packet` events. - Each integration is a `FanoutModule` with scope-based filtering. +- Community MQTT publishes raw packets only, but its derived `path` field for direct packets is emitted as comma-separated hop identifiers, not flat path bytes. - See `app/fanout/AGENTS_fanout.md` for full architecture details. ## API Surface (all under `/api`) @@ -115,8 +123,8 @@ app/ - `GET /health` ### Radio -- `GET /radio/config` -- `PATCH /radio/config` +- `GET /radio/config` โ€” includes `path_hash_mode` and `path_hash_mode_supported` +- `PATCH /radio/config` โ€” may update `path_hash_mode` (`0..2`) when firmware supports it - `PUT /radio/private-key` - `POST /radio/advertise` - `POST /radio/reboot` @@ -209,11 +217,11 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`. ## Data Model Notes Main tables: -- `contacts` (includes `first_seen` for contact age tracking) +- `contacts` (includes `first_seen` for contact age tracking and `out_path_hash_mode` for route round-tripping) - `channels` - `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution) - `raw_packets` -- `contact_advert_paths` (recent unique advertisement paths per contact) +- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count) - `contact_name_history` (tracks name changes over time) - `app_settings` diff --git a/app/fanout/AGENTS_fanout.md b/app/fanout/AGENTS_fanout.md index b816f1c..2567c75 100644 --- a/app/fanout/AGENTS_fanout.md +++ b/app/fanout/AGENTS_fanout.md @@ -57,6 +57,8 @@ Wraps `MqttPublisher` from `app/fanout/mqtt.py`. Config blob: Wraps `CommunityMqttPublisher` from `app/fanout/community_mqtt.py`. Config blob: - `broker_host`, `broker_port`, `iata`, `email` - Only publishes raw packets (on_message is a no-op) +- The published `raw` field is always the original packet hex. +- When a direct packet includes a `path` field, it is emitted as comma-separated hop identifiers exactly as the packet reports them. Token width varies with the packet's path hash mode (`1`, `2`, or `3` bytes per hop); there is no legacy flat per-byte companion field. ### bot (bot.py) Wraps bot code execution via `app/fanout/bot_exec.py`. Config blob: diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 47c9e4a..2213080 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -14,6 +14,7 @@ Keep it aligned with `frontend/src` source code. - Leaflet / react-leaflet (map) - Vendored `@michaelhart/meshcore-decoder` in `frontend/lib/meshcore-decoder` (local file dependency for multibyte-support build) - `meshcore-hashtag-cracker` + `nosleep.js` (channel cracker) +- `@michaelhart/meshcore-decoder` pinned to the multibyte-aware `jkingsman/meshcore-decoder-multibyte` fork ## Frontend Map @@ -179,11 +180,17 @@ frontend/lib/ - `VisualizerView.tsx` hosts `PacketVisualizer3D.tsx` (desktop split-pane and mobile tabs). - `PacketVisualizer3D` uses persistent Three.js geometries for links/highlights/particles and updates typed-array buffers in-place per frame. - Packet repeat aggregation keys prefer decoder `messageHash` (path-insensitive), with hash fallback for malformed packets. +- Raw-packet decoding in `RawPacketList.tsx` and `visualizerUtils.ts` relies on the multibyte-aware decoder fork; keep frontend packet parsing aligned with backend `path_utils.py`. - Raw packet events carry both: - `id`: backend storage row identity (payload-level dedup) - `observation_id`: realtime per-arrival identity (session fidelity) - Packet feed/visualizer render keys and dedup logic should use `observation_id` (fallback to `id` only for older payloads). +### Radio settings behavior + +- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true. +- Frontend `path_len` fields are hop counts, not raw byte lengths; multibyte path rendering must use the accompanying metadata before splitting hop identifiers. + ## WebSocket (`useWebSocket.ts`) - Auto reconnect (3s) with cleanup guard on unmount. diff --git a/tests/test_community_mqtt.py b/tests/test_community_mqtt.py index 130a209..5f958d6 100644 --- a/tests/test_community_mqtt.py +++ b/tests/test_community_mqtt.py @@ -280,6 +280,21 @@ class TestPacketFormatConversion: assert "path" in result assert result["path"] == "" + def test_direct_multibyte_route_formats_path_as_hop_identifiers(self): + # route=2 (DIRECT), payload_type=2, path_byte=0x82 -> 2 hops x 3 bytes + data = {"timestamp": 0, "data": "0A82AABBCCDDEEFF11", "snr": 1.0, "rssi": -70} + result = _format_raw_packet(data, "Node", "AA" * 32) + assert result["route"] == "D" + assert result["payload_len"] == "1" + assert result["path"] == "aabbcc,ddeeff" + + def test_flood_multibyte_route_omits_path_field(self): + # route=1 (FLOOD), payload_type=2, path_byte=0x82 -> 2 hops x 3 bytes + data = {"timestamp": 0, "data": "0982AABBCCDDEEFF11", "snr": 1.0, "rssi": -70} + result = _format_raw_packet(data, "Node", "AA" * 32) + assert result["route"] == "F" + assert "path" not in result + def test_unknown_version_uses_defaults(self): # version=1 in high bits, type=5, route=1 header = (1 << 6) | (5 << 2) | 1 diff --git a/tests/test_radio_sync.py b/tests/test_radio_sync.py index d413b68..2ec76e7 100644 --- a/tests/test_radio_sync.py +++ b/tests/test_radio_sync.py @@ -57,6 +57,9 @@ async def _insert_contact( contact_type=0, last_contacted=None, last_advert=None, + last_path=None, + last_path_len=-1, + out_path_hash_mode=0, ): """Insert a contact into the test database.""" await ContactRepository.upsert( @@ -65,8 +68,9 @@ async def _insert_contact( "name": name, "type": contact_type, "flags": 0, - "last_path": None, - "last_path_len": -1, + "last_path": last_path, + "last_path_len": last_path_len, + "out_path_hash_mode": out_path_hash_mode, "last_advert": last_advert, "lat": None, "lon": None, @@ -345,6 +349,34 @@ class TestSyncRecentContactsToRadio: assert result["loaded"] == 0 assert result["failed"] == 1 + @pytest.mark.asyncio + async def test_add_contact_preserves_explicit_multibyte_hash_mode(self, test_db): + """Radio offload uses the stored hash mode rather than inferring from path bytes.""" + await _insert_contact( + KEY_A, + "Alice", + last_contacted=2000, + last_path="aa00bb00", + last_path_len=2, + out_path_hash_mode=1, + ) + + mock_mc = MagicMock() + mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None) + mock_result = MagicMock() + mock_result.type = EventType.OK + mock_mc.commands.add_contact = AsyncMock(return_value=mock_result) + + radio_manager._meshcore = mock_mc + result = await sync_recent_contacts_to_radio() + + assert result["loaded"] == 1 + payload = mock_mc.commands.add_contact.call_args.args[0] + assert payload["public_key"] == KEY_A + assert payload["out_path"] == "aa00bb00" + assert payload["out_path_len"] == 2 + assert payload["out_path_hash_mode"] == 1 + @pytest.mark.asyncio async def test_mc_param_bypasses_lock_acquisition(self, test_db): """When mc is passed, the function uses it directly without acquiring radio_operation.