Commit d31de41
* 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
File tree
- docs/internal/dev-notes
- src/server
- routes
- services
- transports
Lines changed: 20 additions & 5 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | | - | |
| 3 | + | |
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
| |||
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
50 | | - | |
51 | | - | |
52 | | - | |
53 | | - | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
54 | 54 | | |
55 | 55 | | |
56 | 56 | | |
| |||
71 | 71 | | |
72 | 72 | | |
73 | 73 | | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
0 commit comments