Skip to content

Commit d31de41

Browse files
Yerazeclaude
andauthored
feat(meshcore): Analyzer Observer publisher service — Phase 2 (#4457) (#4468)
* docs: Phase 2 implementation spec for MeshCore observer publisher (#4457) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cgD5kSurLjdeVkZ6PcQD9 * feat(mqtt): add optional LWT + keepalive to MqttBrokerClient (WP2) Adds `will` and `keepalive` as optional MqttBrokerClientOptions per MESHCORE_OBSERVER_PHASE2_SPEC.md §3.4 (D-5), needed by the upcoming Analyzer Observer publisher to register a Last Will and Testament and match the LetsMesh broker's keepalive preset. `will` is forwarded verbatim and omitted entirely from the connect-options object when unset, so existing callers see byte-identical options; `keepalive` defaults to 15 (unchanged) and can be overridden. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cgD5kSurLjdeVkZ6PcQD9 * feat(meshcore): refine observer token result into 5 discriminated kinds (#4457 WP3) Adds ObserverTokenResult (ok/not_configured/no_key/key_rotated/mint_failed) and mintObserverTokenForSourceDetailed(sourceId) so Phase 2's publisher can distinguish why a token mint failed for its lastError surface. Reduces mintObserverTokenForSource to a two-line wrapper; every Phase 1 test for it passes unmodified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cgD5kSurLjdeVkZ6PcQD9 * feat(meshcore): add Analyzer Observer packet/status encoder (WP1, #4457) Pure, dependency-free encoder for the MeshCore Analyzer Observer MQTT wire contract (Phase 2 §3.1): parseObserverFrame (packed path_len decode, D-3), calculateMeshCorePacketHash (SHA-256 Packet::calculatePacketHash, D-2), buildObserverPacketPayload/buildObserverStatusPayload, and observerTopics. Golden-tested against fixed hex + fixed Date, including the named D-3 packed-vs-plain divergence and a cross-check against decodeMeshCorePacket on every fixture. Imports only node:crypto and the OtaPacketEvent type; nothing consumes it yet so the tree stays green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cgD5kSurLjdeVkZ6PcQD9 * feat(meshcore): add Analyzer Observer MQTT publisher service (WP4, #4457) One MeshCoreObserverPublisher per Companion source: mints an auth token (WP3 seam), opens a publish-only MqttBrokerClient (WP2's LWT/keepalive), and relays every ota_packet through WP1's encoder to meshcore/{REGION}/{PUBKEY}/{packets|status}. Never subscribes. Covers the full §6 failure matrix: no_key/key_rotated/mint_failed abort before connecting with no retry loop; auth rejections cool down at MAX_AUTH_FAILURES=5 without re-minting; backpressure drops packets while the socket is down instead of letting mqtt.js queue them offline; token renewal tears down and rebuilds the client with a fresh password/LWT (D-9), using a widened renewal window (RENEWAL_CHECK_MS + THRESHOLD) that closes the ~55-minute expiry hole a threshold-only check would leave. Nothing imports this module yet — WP5 wires it into meshcoreManager. * feat(meshcore): wire Analyzer Observer into manager lifecycle + status route (#4457 WP5) Adds startObserver()/stopObserver()/getObserverStatus() to MeshCoreManager, binding a MeshCoreObserverPublisher (WP4) to the manager's 'ota_packet' event via a bound listener that is added/removed idempotently. Hooked into all four lifecycle sites that already manage the Virtual Node server: connect() success, disconnect(), the unexpected-socket-drop path, and teardownTransportOnly(). getStatus() conditionally spreads an `observer` sub-object (new MeshCoreSourceStatus type) so JSON stays byte-identical for sources without one. GET /:id/status strips that sub-object for anonymous / non-nodes:read callers, since lastError can carry the broker hostname. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cgD5kSurLjdeVkZ6PcQD9 * feat(meshcore): reconfigureObserver hot-swap for Analyzer Observer (#4457 WP6) Adds MeshCoreManager.reconfigureObserver() (stop → re-normalize via observerConfigFromSource → restart-if-connected), a duck-typed sourceManagerRegistry.reconfigureObserver() passthrough mirroring reconfigureVirtualNode, and a PUT /:id branch that hot-swaps the publisher when only the `observer` config block changed, falling back to the existing full manager restart for any other change (or when the comparison can't decide). Deletes the Phase-1 TODO marking this insertion point. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cgD5kSurLjdeVkZ6PcQD9 * fix(meshcore-observer): use createRequire for package.json in ESM bundle Bare require() passed under Vitest's CJS interop but threw at runtime in the bundled ESM server — caught by dev-container deploy. Matches the newsService.ts pattern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cgD5kSurLjdeVkZ6PcQD9 * fix(meshcore-observer): don't double the 'v' prefix on firmware_version Live device reports ver='v1.15.0-dee3e26' — the observer status was publishing 'vv1.15.0…'. Caught in the Phase 2 live-broker E2E. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cgD5kSurLjdeVkZ6PcQD9 * fix(meshcore-observer): flush the graceful-stop offline status before closing the socket Live E2E found that the offline status published on graceful stop (spec §2.4 / E2E criterion 8) never reached the broker. MqttBrokerClient.publish() resolving only means mqtt.js accepted the packet, not that it hit the wire; disconnect() force-ends (end(true)) immediately, discarding the not-yet-flushed QoS-0 offline publish. - mqttBrokerClient.ts: disconnect(opts?: { flush?: boolean }) — default behavior is byte-identical (immediate end(true)). flush:true ends non-forcefully (end(false)) first, raced against a ~2s fallback that force-ends so a wedged/unreachable socket can never hang the promise. - meshcoreObserverPublisher.ts: stop() now calls disconnect({ flush: true }) after publishing offline. The renewal-rebuild and hard-stop-on-auth-failure paths keep the default forced disconnect — fast teardown is correct there. Adds disconnect() flush-option coverage to mqttBrokerClient.test.ts (default force=true pinned, flush:true uses force=false, fallback resolves the promise even when the graceful end callback never fires) and updates meshcoreObserverPublisher.test.ts's stop() test to assert force=false. * docs: record Phase 2 completion + deviations in observer epic plan (#4457) * docs(review): address PR #4468 review observations - reset authStopping/authFailures at start() so the hard-stop latch is never a permanent fuse (obs. 4) - .substr -> .slice in hexToBytesLocal (obs. 2) - 0xff-sentinel comment in calculateMeshCorePacketHash (obs. 1) - clarify reconfigureObserver's unconditional startObserver call (obs. 9) - add stop()-before-start() safety test (obs. 7) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cgD5kSurLjdeVkZ6PcQD9 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent b84a1e3 commit d31de41

17 files changed

Lines changed: 4370 additions & 51 deletions

docs/internal/dev-notes/MESHCORE_ANALYZER_OBSERVER_EPIC.md

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# MeshCore Analyzer Observer MQTT Output — Epic Plan (#4457)
22

3-
**Status:** Phase 1 complete (PR pending) — Phase 2 next
3+
**Status:** Phase 2 complete (PR pending) — Phase 3 (UI + docs) next
44
**Issue:** #4457 — publish packets heard by a MeshCore Companion source to a MeshCore Analyzer-compatible MQTT broker, so the node counts as an observer without a second app fighting over the serial port.
55
**Scope guard:** observation-only. MeshMonitor publishes; it never subscribes to or injects broker traffic into the mesh. The broker's admin-only `serial/commands` remote-serial feature is out of scope.
66

@@ -47,10 +47,10 @@ Upstream issue michaelhart/meshcore-mqtt-broker#9 has no reply. We built the con
4747
- **Exit:** config + key round-trip through the API with secrets redacted; full suite green; merged PR.
4848

4949
### Phase 2 — Observer publisher service
50-
- [ ] `meshcoreObserverPublisher` per source: `manager.on('ota_packet')`, analyzer-contract packet JSON (decoder lib for hash/decode/advert privacy), publish via `MqttBrokerClient` (wss), retained `/status` + LWT, token renewal, reconnect via coordinator.
51-
- [ ] Lifecycle: start/stop with manager + restart on config change; observer status in `getStatus()` (connected, publishes, lastPublishAt, lastError).
52-
- [ ] Tests with mocked broker; per-source isolation.
53-
- **Exit:** live end-to-end against local `meshcore-mqtt-broker` (Docker, `test` region): token auth accepted, packets + status seen by a subscriber; merged PR.
50+
- [x] `meshcoreObserverPublisher` per source: `manager.on('ota_packet')`, analyzer-contract packet JSON (hash/decode implemented ourselves, not the decoder lib — see deviations (a)/(b); no advert privacy filter — see deviation (c)), publish via `MqttBrokerClient` (ws/wss), retained `/status` + explicit graceful-offline (LWT alone is broker-filtered — see deviation (e)), token renewal, reconnect via coordinator.
51+
- [x] Lifecycle: start/stop with manager + restart on config change (plus the hot-swap follow-up, deviation (f)); observer status in `getStatus()` (connected, publishes, lastPublishAt, lastError).
52+
- [x] Tests with mocked broker; per-source isolation.
53+
- **Exit:** live end-to-end against local `meshcore-mqtt-broker` (Docker, `test` region): token auth accepted, packets + status seen by a subscriber; merged PR.**Done 2026-07-31**, run against a real companion (Yeraze MC Sandbox) instead of a synthetic feed; all 10 §8 criteria passed (see deviation (j)).
5454

5555
### Phase 3 — Frontend UI + docs
5656
- [ ] Observer fieldset in the MeshCore source modal: enable, broker URL, IATA, audience, fetch-key-from-device button + paste fallback, key-stored indicator.
@@ -71,3 +71,18 @@ Upstream issue michaelhart/meshcore-mqtt-broker#9 has no reply. We built the con
7171
- **Route-test harness gap.** `harness.grant()` collides with the `permissions` table's `UNIQUE(user_id, resource, sourceId)` index when granting read then write separately. Tests needing both use a local `grantReadWrite()` helper that writes one row with both flags set.
7272
- **Known gap (accepted).** A `meshcore_observer_keys` row orphans on source delete, same as `source_pki_keys` today. Cascade cleanup is deferred to its own change.
7373
- **Phase 2 seam.** `mintObserverTokenForSource()` is intentionally unrouted — Phase 2's publisher is its first consumer.
74+
75+
### Phase 2
76+
77+
- **(a)** The decoder library's `messageHash` (`@michaelhart/meshcore-decoder`'s `calculateMessageHash`) is a 32-bit djb2-style rolling hash, **not** `Packet::calculatePacketHash`. The analyzer contract's `hash` field is SHA-256-derived and 16 upper-hex chars, so we implement it ourselves rather than call the decoder lib (spec D-2).
78+
- **(b)** We decode the path/payload boundary with the **packed** `path_len` byte (`hashSize=(b>>6)+1`, `hopCount=b&0x3f`), not the reference's plain byte-count read. The two are byte-identical for 1-byte hash mode (effectively all real traffic); a named test pins the divergence for multi-byte hash mode, where the reference is simply wrong against firmware `Packet.h` (spec D-3).
79+
- **(c)** The reference publishes **no decoded advert fields** on the wire — its "advert opt-in privacy filter" (`name.endswith('^')`) has zero observable effect, since `format_packet_data()` never merges the advert decode into the payload. We match the wire: no advert fields, no filter implemented (spec D-4).
80+
- **(d)** Timestamps are emitted as UTC ISO-8601 with `Z` (`new Date().toISOString()`), a deliberate deviation from the reference's naive local `datetime.now().isoformat()` — naive local time is undisambiguatable and the broker itself parses timestamps with `new Date(...)` (spec D-8).
81+
- **(e)** The broker's stale-status filter suppresses the LWT on an ungraceful disconnect, so graceful stop publishes an explicit `offline` status before closing the socket (§2.4). This required a fix to `MqttBrokerClient.disconnect()`: the original force-end path discarded the queued offline publish, so a new `disconnect({flush: true})` (2s force-end fallback) is used, exclusively by `publisher.stop()`.
82+
- **(f)** `reconfigureObserver` hot-swap shipped (closes the Phase 1 follow-up) — toggling `observer.enabled` or its config no longer bounces the MeshCore radio link. Verified live: no reconnect logged for an observer-only config change.
83+
- **(g)** The reference's nameless-advert bug (`payload_value["name"]` unguarded, raising `KeyError`, swallowed by an outer `except`, degrading the packet to `route="U"`/`packet_type="0"`/`payload_len="0"`) is **not** replicated — our decode is total and never degrades a well-formed advert.
84+
- **(h)** The token renewal predicate subtracts the check interval as well as the expiry threshold, closing the reference's ~55-minute post-expiry hole (same three constants as the reference, different comparison — spec §3.2/§6).
85+
- **(i)** E2E against real hardware surfaced two live-only bugs invisible to the unit suite: a bare `require()` broke in the bundled ESM server (fixed via `createRequire`), and `firmware_version` doubled the `v` prefix because the device already reports its version with a leading `v` (fixed by not re-prepending it).
86+
- **(j)** E2E performed 2026-07-31 against a real companion (Yeraze MC Sandbox) and `meshcore-mqtt-broker` run from source (not a synthetic feed). All 10 §8 criteria passed: auth with correct audience; online status with retain-strip; packets with the full string-typed contract; hash independently verified; `path` present only on route D; no topic normalization; publisher never subscribes; graceful offline delivered after the flush fix; hot-swap with zero device bounce; bad audience produces a clean `lastError` and a single rejection with no reconnect storm.
87+
88+
**Superseded checklist text (Phase 2, above):** the Phase 2 checklist item's parenthetical "(decoder lib for hash/decode/advert privacy)" is superseded by deviations (a)-(c) — hash/decode are hand-rolled, not decoder-lib calls, and no advert privacy filter exists. Its "reconnect via coordinator" wording refers only to the `MqttBrokerClient`'s own reconnect backoff, not to observer-toggle behavior, which is covered separately by the hot-swap in deviation (f); see also Phase 1's now-closed "Restart hook" note above.

0 commit comments

Comments
 (0)