mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Docs & tests
This commit is contained in:
17
AGENTS.md
17
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user