feat(meshcore): virtual node — connect the MeshCore app to a node through MeshMonitor over WiFi (#3535)#3540
Conversation
…3535) Expose a MeshCore node that MeshMonitor already manages as a virtual node the MeshCore mobile app can connect to over WiFi/TCP. Phase 0 brings the app to "connected, identity shown, empty mailbox"; reads are synthesized from the manager's local-node state and nothing is forwarded to the real node yet (Phase 2). Structure mirrors the proven Meshtastic virtualNodeServer.ts. - meshcoreCompanionCodec.ts: the device end of the companion frame protocol — frame parse/build (0x3c app→node / 0x3e node→app, u16 LE length), command decode, and response encoders (SelfInfo, CurrTime, DeviceInfo, ContactsStart/ EndOfContacts, NoMoreMessages, Ok, Err) derived by inverting meshcore.js's decoders. Pure protocol + unit conversions, no DB/IO. - meshcoreVirtualNodeServer.ts: per-source TCP server. AppStart→SelfInfo (from getLocalNode), GetDeviceTime→CurrTime, DeviceQuery→DeviceInfo, GetContacts→ empty, SyncNextMessage→NoMoreMessages, unsupported→Err(UnsupportedCmd). Per-client framing buffers, audit logging, inactivity cleanup. - meshcoreManager.ts: virtualNode config field; start after local-node refresh, stop on disconnect; isVirtualNodeServerRunning(). - meshcoreRegistry.ts: parse sources.config.virtualNode into runtime config (default port 5000, distinct from the node's 4403 companion port). - DashboardPage.tsx: "Virtual Node" fieldset in the MeshCore source form (enable toggle + listening port) with save-path validation + plumbing. Tests: codec encoders round-trip through meshcore.js's OWN decoders (deviceless fidelity check); server handshake driven over a real loopback socket. Full Vitest suite green (0 failures). Remaining exit criterion — real app over WiFi — needs hardware. Design: docs/internal/dev-notes/MESHCORE_VIRTUAL_NODE_DESIGN.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ive RX (#3535) Observed the real meshcore-flutter command stream against the Phase 0 server: DeviceQuery → AppStart → GetBatteryVoltage → SetDeviceTime → GetContacts → GetChannel → SyncNextMessage → SetFloodScope(54). Phase 0 returned empty/Err for the data-bearing commands, so the app showed an empty node list, no channels, and gave up. Phase 1 answers them from MeshMonitor's mirrored state. Codec: add encoders (round-tripped through meshcore.js's own decoders) for Contact(3), ChannelInfo(18), BatteryVoltage(12), ContactMsgRecv(7), ChannelMsgRecv(8), and the MsgWaiting(0x83) push; decode GetChannel's index; add hexToBytes / toEpochSeconds helpers and the full CommandCodes table. Server: - GetContacts → ContactsStart · N×Contact · EndOfContacts from getContacts() (out-path hop bytes + OUT_PATH_UNKNOWN handling, name, position, timestamps). - GetChannel(idx) → ChannelInfo from the synced channels table (base64 psk → 16-byte secret), Err(NotFound) past the last slot so the app stops iterating. - GetBatteryVoltage → BatteryVoltage from the local node telemetry. - SetFloodScope → Ok (read-only no-op) so the app doesn't treat it as fatal. - Live RX: subscribe to the manager's 'message' event; enqueue per-client and push MsgWaiting, drained via SyncNextMessage as ContactMsgRecv/ChannelMsgRecv. Self-originated messages are filtered so sends aren't echoed back. Per-client queue seeds empty (no history replay → no dupes on reconnect). - Per-command logging (◀ cmd) retained for field debugging. Also: allow virtualNode config on meshcore sources (sourceRoutes gate previously meshtastic_tcp only) + regression test; expose port 5000 in docker-compose.dev. Sends (SendTxtMsg/SendChannelTxtMsg → real node) remain Phase 2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#3535) The app's send (SendChannelTxtMsg, and SendTxtMsg for DMs) hit the Phase-1 Err default. Phase 2 forwards them to the physical node via the manager. Codec: decode SendTxtMsg ([txtType][attempt][senderTs:u32][pubKeyPrefix:6][text]) and SendChannelTxtMsg ([txtType][channelIdx][senderTs:u32][text]); add encodeSent(result, expectedAckCrc, estTimeout) — round-tripped through meshcore.js's decoder. Server: - SendChannelTxtMsg → manager.sendMessage(text, undefined, channelIdx) → Sent(0). - SendTxtMsg → resolve the 6-byte key prefix against getContacts() to the full contact key → manager.sendMessage(text, fullKey) → Sent(0); Err(NotFound) if the prefix matches no known contact. - Node-side failure / exception → Err(BadState). Messaging is not admin-gated. expectedAckCrc is 0 for now (no synchronous CRC from the proxied send); the SendConfirmed(0x82) delivery push is a later refinement. Config writes remain Phase 3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…them sent (#3535) Testing surfaced channel messages forwarding to the node but never showing as sent in the app. Root cause: the companion protocol uses different ack responses per send type — meshcore.js's sendChannelTextMessage awaits Ok(0) (fire-and-forget broadcast) while sendTextMessage (DM) awaits Sent(6) (carries the ack CRC). Phase 2 replied Sent(6) to channel sends, so the app's send promise stayed pending forever. Channel send → Ok(0); DM send keeps Sent(6). Test updated accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Incoming replies (e.g. an auto-responder's "🤖 Copy") reached the VN source but
never showed in the app. Root cause: the manager tags channel messages with the
synthetic `channel-N` marker in toPublicKey for messages we SENT but in
fromPublicKey for messages we RECEIVED (toPublicKey is unset on RX). The VN only
checked toPublicKey, so incoming channel messages were mis-encoded as a
ContactMsgRecv with a garbage key prefix (hexToBytes('channel-1')) and the app
dropped them.
- encodeIncomingMessage: detect the channel marker in fromPublicKey OR
toPublicKey, so received channel messages encode as ChannelMsgRecv.
- handleIncomingMessage: also skip our own channel transmissions heard back
over the air (fromName === local node name) so the app — which shows channel
sends optimistically — doesn't get a duplicate.
Tests: incoming ChannelMsgRecv delivery + own-name echo suppression.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…3535) Drop the high-frequency per-command (◀ cmd) and per-read (contacts/channel) traces to debug; keep lifecycle + send-forward logs at info. Update the design doc to mark Phases 0-2 implemented/verified and record the two protocol subtleties found in testing (channel→Ok vs DM→Sent; channel-N marker field by direction). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @Yeraze's task —— View job PR Review: MeshCore Virtual Node (Phases 0–2)Tasks:
Overall, this is a high-quality implementation with clear documentation, strong test coverage, and good separation of concerns. The codec/server split is clean and mirrors the existing Meshtastic VN architecture. Here are my findings: Bugs / Potential Issues1. Double import of
import databaseService from '../services/database.js'; // line 4
import databaseServiceDefault from '../services/database.js'; // line 6Line 4 is used only for 2.
head.writeInt8(info.firmwareVer | 0, 1);If 3.
const row = channels.find((ch) => ch.id === channelIdx);If the DB auto-increments 4. The PR description correctly notes that channel sends should reply
This is correct, but worth highlighting that if 5. Race:
socket.on('close', () => this.handleClientDisconnect(clientId));
socket.on('error', (error) => {
logger.error(…);
this.handleClientDisconnect(clientId);
});An error event is followed by a close event on a socket. 6.
client.socket.destroy();
this.handleClientDisconnect(clientId);
Design / Code Quality7.
Buffer.from(clean.length % 2 === 0 ? clean : clean.slice(0, -1), 'hex').copy(out, 0, 0, 32);Stripping the last nibble for an odd-length key is lossy and silent. The typical input is a 64-char hex string; odd-length would only happen from malformed data. A debug log or zero-fill for the trailing byte would make bad-key cases easier to diagnose. 8.
expectFrames(n: number): Promise<Buffer[]> {
return Promise.all(Array.from({ length: n }, () => new Promise<Buffer>((resolve) => this.waiters.push(resolve))));
}If frames arrive before 9. No test for The 10. Channel echo suppression uses
if (this.isChannelMessage(msg) && local?.name && msg.fromName === local.name) return;Node names are user-configurable strings. If two nodes on the same channel share a name, one node's messages will be incorrectly suppressed. The PR acknowledges this is the only available signal since channel packets carry no sender key, and it's documented in the design doc. A comment here pointing to the design doc limitation would help future maintainers. Security11. No rate-limiting on client connections The server accepts unlimited TCP connections (only bounded by OS limits). A malicious client on the local network could exhaust file descriptors by opening thousands of connections. A configurable 12.
Test CoverageTest coverage is good overall. Specific gaps:
Minor
Summary: The dual-import on line 6 and the |
The skill stopped at green CI. Add a review-feedback phase: gather automated (Claude Code Review bot) + human review comments, triage each concern (fix / apply / reply-why-not / escalate), close the loop on threads, and re-verify CI after any fix. Updates the report template (Review Feedback + Open Items) and rules (green CI isn't the finish line; never silently ignore a concern). Both the commands/ and skills/ copies kept in sync. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @Yeraze's task —— View job PR Review: MeshCore Virtual Node (Phases 0–2)Tasks:
Overall, this is a well-structured implementation with clean separation between the codec and server layers, strong round-trip test coverage, and thoughtful documentation of protocol subtleties. The architecture mirrors the existing Meshtastic VN cleanly. Below are my findings ordered by severity. Bugs / Issues That Can Cause Failures1. const row = channels.find((ch) => ch.id === channelIdx);
2. Duplicate import databaseService from '../services/database.js'; // line 4
import databaseServiceDefault from '../services/database.js'; // line 6Line 4 ( 3. head.writeInt8(info.firmwareVer | 0, 1);If Design / Code Quality4. cfg.virtualNode = { enabled: true, port: vnPort, allowAdminCommands: false };The form state 5. Duplicate-disconnect race in client.socket.destroy();
this.handleClientDisconnect(clientId);
6. Similarly, When a socket error occurs, Node.js emits 7. Echo suppression uses node name equality ( if (this.isChannelMessage(msg) && local?.name && msg.fromName === local.name) return;Two nodes with the same name on the same channel will have one's messages silently suppressed. The design doc acknowledges this is the only available signal — a brief inline comment pointing to this limitation would help future maintainers. 8. Buffer.from(clean.length % 2 === 0 ? clean : clean.slice(0, -1), 'hex').copy(out, 0, 0, 32);Stripping the last nibble for malformed input is silent and lossy. A debug log here would make bad-key cases easier to diagnose in the field. 9. There's no comment explaining where 8000 ms comes from (firmware default? app expectation?). A brief comment would help. Security10. No limit on concurrent client connections The server accepts unlimited TCP connections. A client on the local network could exhaust file descriptors by opening many connections. A small configurable Test Coverage Gaps
Minor
Summary: The channel-lookup bug (item 1) and the duplicate import (item 2) are the highest-priority fixes. The |
Finalize the 4.11.0 release (from 4.11.0-rc2) and bring documentation up to date with everything new since 4.10. Version bump across all five files: package.json, package-lock.json, helm/meshmonitor/Chart.yaml, desktop/src-tauri/tauri.conf.json, desktop/package.json. Documentation: - CHANGELOG: finalize the Unreleased section as [4.11.0], adding the previously-missing entries — headline MeshCore virtual node (#3540), MeshCore path-by-repeater-name (#3550), MeshCore map icons + filtering (#3563), per-node Hide from Map (#3565), telemetry time-range selector (#3530), newer AirQualityMetrics fields (#3517), UI/map unification (#3561), plus the bug-fix and documentation changes since 4.10.4. - virtual-node.md: document the new MeshCore Virtual Node alongside the Meshtastic one (default port 5000, per-source enablement, admin-command safety, MeshCore app setup, troubleshooting) + a two-variants intro note. - configuration/index.md, docs/index.md, README.md: mention the MeshCore Virtual Node in the Virtual Node feature blurbs. - CLAUDE.md: refresh stale version header (4.10.0 -> 4.11.0) and migration count (84 -> 92, latest 092_add_hide_from_map_to_nodes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Implements a MeshCore virtual node (#3535): MeshMonitor exposes a MeshCore node it already manages as a TCP companion endpoint, so the MeshCore mobile app can connect to it over WiFi and use the node as if it were local — see contacts/channels, and send/receive messages — when the phone has no direct BLE/USB range.
This is the device end of the MeshCore companion protocol. Since MeshMonitor already holds the node's single companion slot (via
meshcore.jsas a client), the server synthesizes reads from mirrored state and forwards writes to the real node — structurally parallel to the existing MeshtasticvirtualNodeServer.ts, with MeshCore's binary frame format instead of protobuf.Verified end-to-end against the real
meshcore-flutterapp over WiFi: connect → identity → 94 contacts + channels → channel send → auto-responder reply received back in the app.What's included (Phases 0–2)
AppStart→SelfInfo,DeviceQuery→DeviceInfo,GetDeviceTime→CurrTime,SetDeviceTime→Ok.GetContacts(real contacts),GetChannel→ChannelInfo,GetBatteryVoltage,SetFloodScope→Ok(no-op), and live incoming messages via theMsgWaitingpush →SyncNextMessage→ContactMsgRecv/ChannelMsgRecv.SendChannelTxtMsg/SendTxtMsgforwarded to the node; DM key-prefixes resolved to full contact keys.DashboardPage); config persisted insources.config.virtualNodeand parsed inmeshcoreRegistry. ThesourceRoutesvalidation gate now allowsmeshcore(not justmeshtastic_tcp).meshcoreManagerstarts the server after the local node is known and stops it on disconnect.Files
meshcoreCompanionCodec.ts(pure protocol: frame parse/build, command decode, response/push encoders),meshcoreVirtualNodeServer.ts(per-source TCP server), + tests for both, +sourceRoutes.virtualNode.test.ts.meshcoreManager.ts,meshcoreRegistry.ts,routes/sourceRoutes.ts,pages/DashboardPage.tsx,docker-compose.dev.yml(exposes dev port 5000).docs/internal/dev-notes/MESHCORE_VIRTUAL_NODE_DESIGN.md.Testing
tscclean.Two protocol subtleties found in live testing (documented + regression-tested)
Ok(0), DM send acksSent(6)— replyingSentto a channel send hangs the app's send promise.channel-Nmarker is intoPublicKeyfor sent messages butfromPublicKeyfor received — incoming channel replies must be detected in both fields or they get mis-encoded as a DM and dropped.Not included (Phase 3 — follow-up)
SetRadioParams,SetAdvertName, …).expectedAckCrcthatsendMessagecurrently logs but discards; relaying it would let theSendConfirmedpush light up the app's delivered tick. DMs currently show sent but not delivered.Notes
sourceId.🤖 Generated with Claude Code