Skip to content

feat(meshcore): show the scope/region a received message was sent with (#3742 Phase 2)#3762

Merged
Yeraze merged 2 commits into
mainfrom
feat/3742-meshcore-scope-display
Jun 25, 2026
Merged

feat(meshcore): show the scope/region a received message was sent with (#3742 Phase 2)#3762
Yeraze merged 2 commits into
mainfrom
feat/3742-meshcore-scope-display

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Phase 2 of #3742 (builds on the merged Phase 1 hop/route display, #3750). Per the reporter (m0urs): surface the scope/region a received MeshCore message was sent under — so users can spot senders using no scope (a common newcomer issue in DE region-gated meshes) and reply in the same scope to improve delivery.

How it works

A scope is not carried as plaintext. A TRANSPORT-route packet carries a 16-bit transport_code_1 that is a content-keyed HMAC, not a static hash of the name:

key  = SHA-256("#" + name)[:16]
code = LE16( HMAC-SHA256(key, [payloadTypeByte, ...payload])[:2] )   # clamp 0->1, 0xFFFF->0xFFFE

So a code can't be reversed to a name. Instead we recompute the code for each scope name the source knows about (per-channel scopes + the default scope) over this packet's payload and match — the exact approach in the firmware authors' own Home-Assistant integration (meshcore-ha's match_flood_scope). Validated against firmware ground-truth vectors.

Changes

  • meshcoreScopeResolve.ts (new, pure) — scopeTransportKey / computeTransportCode / resolveMessageScope, on top of the existing decodeMeshCorePacket. 10 unit tests against firmware vectors incl. the reserved-value clamp.
  • Native backend — carries the OTA raw_hex through the existing inbound correlation buffer (pendingTxtMsgPath, the same path {ROUTE}/{SNR} already ride) onto contact_message/channel_message. Pure field plumbing, no logic.
  • Manager — resolves scope against a cached knownScopes set (refreshed on connect + any channel/default-scope change), stamps scopeCode/scopeName, persists + maps back on boot.
  • Schema/migration 106 (all 3 backends, idempotent) — scopeCode (int) + scopeName (text), nullable.
  • UI — a scope line under received messages:
    • 🌐 no scope — sent unscoped (scopeCode = 0)
    • 🔒 muenchen — resolved to a known scope
    • 🔒 #a3f2 — scoped, but not one of the user's known scopes (raw code hex)

scopeCode semantics: 0 = sent unscoped, >0 = the transport code, NULL = no scope info (room posts, serial backend, uncorrelated packet).

Tests

Resolver crypto (10), manager wiring raw_hex→knownScopes→message (4), repo round-trip incl. the 0 unscoped sentinel and NULL-omitted rows, migration registry → 106. Existing MeshCore suite (116) still green. Full suite 7574 passed, 0 failed; tsc + build clean.

🤖 Generated with Claude Code

#3742 Phase 2)

Phase 2 of #3742 (reporter m0urs): surface the scope/region a received MeshCore
message was sent under, so users can spot unscoped senders and reply in-scope.

A scope isn't carried as plaintext — a TRANSPORT-route packet carries a 16-bit
transport_code_1 that is an HMAC keyed by the scope's transport key over the
packet's own payload. So a code can't be reversed to a name; instead we recompute
the code for each scope name we know about and match (exactly what the firmware
authors' meshcore-ha integration does):
  key  = SHA-256("#" + name)[:16]
  code = LE16(HMAC-SHA256(key, [payloadType, ...payload])[:2])  (0->1 / 0xFFFF->0xFFFE)

- New pure resolver `meshcoreScopeResolve.ts` on top of the existing
  `decodeMeshCorePacket` — validated against firmware ground-truth vectors.
- Native backend carries the OTA `raw_hex` through the existing inbound
  correlation buffer (pendingTxtMsgPath) onto contact_message/channel_message.
- Manager resolves scope against a cached known-scope set (per-channel scopes +
  default scope, refreshed on connect and on any scope change) and stamps
  scopeCode/scopeName. Migration 106 adds the two columns (all 3 backends).
- UI renders under received messages: "🌐 no scope" (unscoped), "🔒 muenchen"
  (resolved), or "🔒 #a3f2" (scoped but not a known scope).

scopeCode semantics: 0 = sent unscoped, >0 = the transport code, NULL = no scope
info (room posts, serial backend, uncorrelated). Tests: resolver crypto (10),
manager wiring (4), repo round-trip incl. the 0-sentinel. Full suite 7574 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(meshcore): show the scope/region a received message was sent with

  • Read and analyze changed files
  • Review crypto/scope resolution logic
  • Review backend/manager changes
  • Review schema/migration
  • Review UI changes
  • Review test coverage
  • Post final review

Overall Assessment

This is a well-engineered feature. The crypto derivation mirrors the authoritative firmware algorithm precisely, the plumbing is clean, and the test coverage is thorough. A few items worth noting:


Crypto / meshcoreScopeResolve.ts

Strengths:

  • The HMAC derivation in computeTransportCode (src/server/meshcoreScopeResolve.ts:52-61) exactly mirrors the firmware's TransportKey::calcTransportCode, including the two reserved-value clamps.
  • resolveMessageScope never throws — it degrades gracefully to null/null on any decode failure, which is the right contract for a hot message path.
  • The Iterable<string> parameter type for candidateNames is a nice design choice that avoids materializing an intermediate array.

Minor observations:

  1. Linear scope scan (src/server/meshcoreScopeResolve.ts:92-98): For each incoming message, the code iterates all known scope names and recomputes an HMAC per candidate. With a small scope set (typical: 1–5) this is fine, but there is no upper-bound guard if knownScopes grows very large. Not a real concern today, but worth a comment noting the expected scale.

  2. ROUTE_TYPE_TRANSPORT_DIRECT = 0x03 is included in the isTransport check. The PR description only mentions TRANSPORT_FLOOD. If the firmware never actually sends contact_message/channel_message events via DIRECT transport, this branch is dead code. It's harmless, but a brief comment citing where in the firmware this constant comes from would help future maintainers (e.g. TransportRoute::Direct).


Native Backend / meshcoreNativeBackend.ts

Strength: The raw_hex is buffered into pendingTxtMsgPath (src/server/meshcoreNativeBackend.ts:456) alongside the hops/SNR on the LogRxData path — the exact same correlation buffer that Phase 1 used for {ROUTE}/{SNR}. This is the correct and minimal change.

Observation: consumedPath?.rawHex (src/server/meshcoreNativeBackend.ts:538 and 555) forwards the hex to the manager as data.raw_hex. If LogRxData arrives after the text message event (e.g. due to ordering on a busy serial link), the correlation window will miss the raw_hex. This is the same limitation that exists for the Phase 1 hop/route display — acceptable and inherent to the OTA correlation design, but worth a note so a future contributor knows why scope is null on fast-arriving messages.


Manager / meshcoreManager.ts

Strengths:

  • knownScopes is refreshed on connect and on any channel/default-scope change (refreshKnownScopes, line 2568). The failure path correctly leaves the previous cache in place with a debug log rather than breaking the connection.
  • Scope fields are persisted on write (scopeCode, scopeName) and mapped back on boot DB load (lines 701-702 and 1793-1794).

Issues to consider:

  1. getDefaultScope() not shown — is it async-safe? refreshKnownScopes awaits this.getDefaultScope() at line 2576. If this method hits the device/settings and can race with an in-flight reconnect, there's a potential stale-scope window. This is probably fine given the cache update is best-effort, but worth confirming that getDefaultScope() doesn't rely on a live device connection.

  2. knownScopes is not refreshed when the default scope changes via settings unless there's a trigger I didn't see. If a user updates the default scope setting, refreshKnownScopes needs to be called — or previously-correlated messages will be misidentified as "unknown scope" until the next connect. Consider adding a call in the settings-update handler if one exists.

  3. Race on contact_message vs channel_message — both event handlers call resolveMessageScope(data.raw_hex, this.knownScopes) synchronously at the time the event fires (lines 1016, 1049). This is correct: knownScopes is a snapshot Set at the time of the call. No issue here.


Schema / Migration 106

Strengths:

  • Idempotent on all three backends. SQLite uses try/catch for duplicate column, PostgreSQL uses IF NOT EXISTS, MySQL uses information_schema check.
  • scopeCode = 0 sentinel (unscoped) is handled correctly: stored as integer 0, not NULL, so the UI can distinguish "unscoped" from "no scope info".
  • Schema definition (src/db/schema/meshcoreMessages.ts:41-43) correctly uses nullable integer/text.

Minor: The migration comment says "The meshcore_messages table uses camelCase columns on every backend" — this is a helpful and accurate note.


UI / MeshCoreMessageStream.tsx

Strengths:

  • The rendering condition !outgoing && typeof m.scopeCode === 'number' (src/components/MeshCore/MeshCoreMessageStream.tsx:303) correctly excludes outgoing messages and NULL/undefined values, matching the scopeCode = null → no info semantic.
  • The three display cases (unscoped 🌐, known scope 🔒 <name>, unknown scope 🔒 #<hex>) are clearly mapped.
  • #${m.scopeCode.toString(16).padStart(4, '0')} (line 312) correctly zero-pads to 4 hex digits for the unknown-scope display.

Minor observation: The scope line currently renders below the route/hop-count line. If a message has neither hopCount nor scopeCode, no extra lines appear. If it has only scopeCode, the scope line renders without the route line above it. The styling seems intentional but it might be worth checking the CSS class mc-message-scope has independent top spacing so it doesn't look orphaned.


Test Coverage

Excellent. The test suite covers:

  • 3 scopeTransportKey vectors with known hex digests
  • computeTransportCode firmware vectors (including payload-dependence and reserved-value clamping with 64 iterations)
  • resolveMessageScope: scoped match, unknown scope (raw code), unscoped route, blank/whitespace candidates, null/empty/undefined hex, truncated transport packet
  • Manager integration: known scope, unknown scope, unscoped packet, missing raw_hex
  • Repository round-trip: scoped with name, unscoped sentinel 0, unknown code without name, missing (NULL)
  • Migration count/name/sequence assertions

One gap: There's no test asserting that refreshKnownScopes is actually called on connect or on channel config change. The manager wiring tests use (m as any).knownScopes = new Set(...) to seed the cache directly, which tests resolution correctly but doesn't exercise the auto-refresh path. A test that verifies refreshKnownScopes is invoked on connect() (or mocks the dependencies and asserts knownScopes is populated) would close this gap.


Verdict

The implementation is solid. The crypto is correct (validated against firmware vectors), the plumbing follows established patterns from Phase 1, all three DB backends are covered, and the edge cases (NULL, 0 sentinel, unknown code, truncated packet) are explicitly tested. The items above are minor observations, not blockers. The PR is ready to merge.

Comment-only follow-up to the PR #3762 review (no behavior change):
- cite the firmware/meshcore.js route-type enum for the TRANSPORT_FLOOD/DIRECT
  scoped-route check, and note both transport variants are treated as scoped.
- note the candidate scan is one HMAC per known scope (realistically 1–5).
- note the raw_hex correlation shares Phase 1's adjacency-buffer ordering
  limitation (scope resolves null if LogRxData arrives after its recv event).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
@Yeraze

Yeraze commented Jun 25, 2026

Copy link
Copy Markdown
Owner Author

Thanks — verdict noted (ready to merge). Triage:

Manager #2 (refresh on default-scope change) — already handled. The POST /config/default-scope route calls manager.setDefaultScope() (meshcoreRoutes.ts:665), which is the only writer of meshcoreDefaultScope and where I call refreshKnownScopes(). Per-channel scope changes go through setChannel()updateChannelScope() (its only caller), also hooked. So both scope-change paths refresh the cache.

Manager #1 (getDefaultScope async-safe) — yes. It only reads databaseService.settings.getSettingForSource; no live device dependency, so no reconnect race.

#3 (resolve race) — agreed, no issue (snapshot Set).

Doc-comment suggestions — applied in 5af29db: cited the firmware route-type enum for the TRANSPORT_FLOOD/DIRECT check, noted the candidate scan scale (1–5), and noted the raw_hex correlation shares Phase 1's adjacency-buffer ordering limitation. No behavior change.

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(meshcore): show the scope/region a received message was sent with

  • Read and analyze changed files
  • Review crypto/scope resolution logic
  • Review backend/manager changes
  • Review schema/migration
  • Review UI changes
  • Review test coverage
  • Post final review

Overall Assessment

This is a well-structured implementation. The crypto derivation correctly mirrors the authoritative firmware algorithm, the plumbing follows the Phase 1 pattern cleanly, and the test coverage is thorough. The previous review's open items were all resolved in the follow-up commit. No blockers — the PR is ready to merge.


Crypto / meshcoreScopeResolve.ts

Strengths:

  • computeTransportCode (src/server/meshcoreScopeResolve.ts:57-66) correctly implements key = SHA-256("#"+name)[:16]HMAC-SHA256(key, [payloadType, ...payload])LE16 read → clamp. Both reserved-value clamps (0 → 1, 0xFFFF → 0xFFFE) are present.
  • resolveMessageScope never throws — decode failures produce {null, null}, keeping the message stream intact.
  • The updated comment at line 97-99 now explains the expected scale (1–5 candidates), addressing the previous reviewer's note.
  • The ROUTE_TYPE_TRANSPORT_FLOOD/DIRECT comment (lines 24-28) now cites the firmware enum, also addressing the previous note.

Minor observation: ROUTE_TYPE_TRANSPORT_FLOOD = 0x00 and ROUTE_TYPE_TRANSPORT_DIRECT = 0x03 — the gap in the enum (0x01 = FLOOD, 0x02 = DIRECT are the non-transport variants) is consistent with meshcore.js's Packet.ROUTE_TYPE_*. The comment explains this correctly. No issue.


Native Backend / meshcoreNativeBackend.ts

Correct and minimal. raw_hex is captured into pendingTxtMsgPath at line 460, then forwarded as data.raw_hex on both contact_message (line 542) and channel_message (line 559). The correlation-ordering limitation is now explicitly documented (lines 455-459), matching the Phase 1 note pattern. The room-post path correctly does not forward rawHex (it consumes-and-discards the buffer at line 505), preventing cross-message leakage.


Manager / meshcoreManager.ts

refreshKnownScopes wiring (lines 568, 711, 1590, 2558, 2568-2582):

  • Called on connect (line 711) — ✓
  • Called in setDefaultScope (line 2558) — ✓
  • Called in setChannel when scope changes (line 1590) — ✓
  • Best-effort failure handling: exception leaves previous cache intact (line 2579-2581) — ✓

Scope stamping on messages:

  • contact_message handler (line 1016): resolveMessageScope(data.raw_hex, this.knownScopes) — ✓
  • channel_message handler (line 1049): same pattern — ✓
  • Both store scope.scopeCode and scope.scopeName on the message object (lines 1027-1028, 1060-1061).

Boot-time DB load (lines 701-702): scopeCode and scopeName are mapped back from DB rows, completing the round-trip.

No issues found.


Schema / Migration 106

All three backends covered and idempotent:

  • SQLite: try/catch per column (duplicate column guard) — ✓
  • PostgreSQL: ADD COLUMN IF NOT EXISTS — ✓
  • MySQL: information_schema.COLUMNS existence check before ALTER TABLE — ✓

Sentinel semantics: scopeCode = 0 (unscoped) vs NULL (no info) is well-documented in the migration header and schema comment. The distinction is meaningful and tested.

Schema consistency: All three schema variants (meshcoreMessagesSqlite, meshcoreMessagesPostgres, meshcoreMessagesMysql) include both scopeCode and scopeName as nullable columns with matching types. ✓


UI / MeshCoreMessageStream.tsx

Rendering guard !outgoing && typeof m.scopeCode === 'number' (line 303) correctly:

  • Excludes outgoing messages (no scope info on sends)
  • Handles the null/undefined case (no line shown when no info)
  • Handles the 0 sentinel (renders 🌐 no scope) via the === 0 branch (line 308)

Unknown scope display #${m.scopeCode.toString(16).padStart(4, '0')} (line 312) — correctly zero-pads the 16-bit code to 4 hex digits.

CSS (line 716 in MeshCorePage.css): mc-message-scope has margin-top: 0.1rem (slightly less than mc-message-route's 0.15rem at line 715). Both are font-size: 0.72rem monospace — consistent styling. The scope line won't look orphaned if hopCount is absent, since the margin is self-contained.


Test Coverage

Crypto tests (meshcoreScopeResolve.test.ts): 3 key derivation vectors, 3 transport code vectors, payload-dependence assertion, 64-iteration reserved-value clamp exhaustive check, 6 resolveMessageScope scenarios including truncated packet. Solid.

Manager integration tests (meshcoreManager.scopeResolve.test.ts): 4 cases — known scope, unknown scope, unscoped route, missing raw_hex. The direct (m as any).knownScopes = new Set(...) injection is appropriate for unit-testing the handler logic independently of the refresh path.

Repository round-trip tests (added to meshcore.test.ts): Tests scopeCode=30479+name, scopeCode=0+null (sentinel), scopeCode=12345+null (unknown code), and null/null (omitted). The explicit comment on line 279 (// 0 = known-unscoped sentinel, must round-trip (not coerced to null)) is exactly the right guard for future regressions.

Migration tests (migrations.test.ts): Count updated to 106, last-migration name/number assertions updated. ✓

One gap (low priority): No test exercises the refreshKnownScopes auto-call on connect() or setChannel(). The manager integration tests seed knownScopes directly, which is fine for testing resolution. A future test could mock databaseService.channels.getAllChannels and assert knownScopes is populated after connect(), but this is not a blocker — the wiring is straightforward and covered by the integration path.


Verdict

Implementation is correct, well-tested, and follows all established patterns. The previous review's observations have been addressed. Ready to merge.

View job

@Yeraze Yeraze merged commit 7bcb6ed into main Jun 25, 2026
16 checks passed
@Yeraze Yeraze deleted the feat/3742-meshcore-scope-display branch June 25, 2026 18:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant