Skip to content

feat(meshcore): region discovery from nearby repeaters — phase 3 (#3667)#3679

Merged
Yeraze merged 2 commits into
mainfrom
feature/meshcore-scope-phase3
Jun 23, 2026
Merged

feat(meshcore): region discovery from nearby repeaters — phase 3 (#3667)#3679
Yeraze merged 2 commits into
mainfrom
feature/meshcore-scope-phase3

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 23, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 3 of MeshCore region/scope support (#3667), completing the feature. Phases 1+2 (merged) let users set per-channel and default scopes; this lets them discover the regions nearby repeaters actually serve, so they pick a valid scope instead of guessing.

Mechanism

Per the firmware (docs/payloads.md), a "regions request" is an ANON_REQ sub-type 0x01 sent to a repeater via CMD_SEND_ANON_REQ (57). The repeater replies with PUSH_CODE_BINARY_RESPONSE (0x8C) = clock(4 LE) + a NUL-terminated, comma-separated ASCII list of region names (* = legacy null region).

meshcore.js (ours and upstream) doesn't expose command 57 — but it does parse the 0x8C reply. So we build the raw frame and tag-match the reply in the native backend. No meshcore.js fork change required.

Changes

  • Native backendrequest_regions bridge command: builds [57][pubkey:32][0x01][0x00], awaits Sent(tag) + BinaryResponse(0x8C), parses clock + region names.
  • ManagerdiscoverRegions(): queries each repeater/room-server contact sequentially (the firmware tag only arrives on the Sent ack, so overlapping requests could cross-match), de-dupes, drops the * wildcard, tolerates non-responders. The request is zero-hop/direct → no scope assertion needed.
  • RoutePOST /api/sources/:id/meshcore/regions/discover.
  • UI — "Discover regions from repeaters" button in the default-scope section; results render as chips that fill the scope field on click.

Coverage note

Like the official app, coverage depends on which repeaters are in the contact list — the UI hints to run "Discover Repeaters" first (see upstream #1881).

Testing

  • tsc typecheck clean (server + frontend)
  • ✅ Full Vitest suite green (7340 passed, 0 failed)
  • New tests: native-backend frame layout + reply parsing (clock / NUL termination / CSV / trailing junk); manager dedupe+sort, * filtering, non-repeater skip, non-responder tolerance.

Completes #3667 (Phases 1–3).

🤖 Generated with Claude Code

Completes #3667: discover the regions/scopes served by nearby repeaters so users
can pick a valid scope instead of guessing.

Mechanism (firmware docs/payloads.md): a "regions request" is an ANON_REQ
sub-type 0x01 sent to a repeater (CMD_SEND_ANON_REQ = 57); the repeater replies
via PUSH_CODE_BINARY_RESPONSE (0x8C) with clock(4 LE) + a NUL-terminated,
comma-separated ASCII list of region names ('*' = legacy null region). meshcore.js
(ours and upstream) doesn't expose CMD 57, but it DOES parse the 0x8C reply, so we
build the raw frame and match the reply by tag in the native backend — no fork change.

- Native backend: `request_regions` bridge command — builds [57][pubkey:32][0x01]
  [0x00], awaits Sent(tag)+BinaryResponse(0x8C), parses clock + region names.
- Manager: `discoverRegions()` — queries each repeater/room-server contact
  sequentially (tag-correlation requires it), de-dupes, drops the '*' wildcard,
  skips non-responders. Zero-hop/direct request → no scope assertion needed.
- Route: POST /api/sources/:id/meshcore/regions/discover.
- UI: "Discover regions from repeaters" button in the default-scope section;
  discovered regions render as chips that fill the scope field on click.
- Tests: native-backend frame layout + reply parsing (clock/NUL/CSV); manager
  dedupe/sort, '*' filtering, non-repeater skip, non-responder tolerance.

Coverage depends on repeaters in the contact list — the UI hints to run
"Discover Repeaters" first (mirrors the official app; see upstream #1881).

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

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(meshcore): region discovery from nearby repeaters — phase 3

Tasks

  • Gather context and understand request
  • Read changed files
  • Analyze code quality and best practices
  • Check for potential bugs or issues
  • Review performance considerations
  • Review security concerns
  • Assess test coverage
  • Post review feedback

Overall Assessment

This is a well-structured implementation of the region discovery feature. The protocol-level work is careful and correct, and the test coverage is solid. A few observations below.


meshcoreNativeBackend.tsrequest_regions (lines 934–991)

Potential tag mismatch race (minor):
The onSent listener uses on (not once) intentionally, as the comment explains. But the tag is initialized to null and is only set when Sent fires. If a BinaryResponse arrives before Sent (which shouldn't happen per firmware design, but is worth hardening), tag is still null and the guard if (tag !== null && r?.tag !== tag) will silently pass null through — resolving with whatever r?.responseData is for an unrelated push. Consider making the guard if (tag === null || r?.tag !== tag) so responses before Sent are always held.

// current: lets through if tag is still null
if (tag !== null && r?.tag !== tag) return;
// safer:
if (tag === null || r?.tag !== tag) return;

Variable shadowing (cosmetic):
At line 947, const K = this.constants!; shadows the outer K declared on line 820 in dispatch(). While both refer to the same value, the shadowed declaration is confusing. Consider removing the inner K and using this.constants! directly.

void c.sendToRadioFrame(frame) (line 980):
Inconsistent with discover_path and discover_nodes, which await the result (or use a promise pattern). If sendToRadioFrame can throw synchronously, the rejection won't reach the surrounding new Promise and would surface as an unhandled rejection. This pattern was already used before this PR, but worth noting.


meshcoreManager.tsdiscoverRegions() (lines 2314–2340)

Sequential requests are intentional and well-explained. The comment about overlapping tag cross-matching is accurate.

Non-responder handling:
!resp.success silently continues without logging at any level. A debug log here (similar to the catch branch) would help diagnose why a repeater is returning an error response vs. not answering at all.


meshcoreRoutes.ts/regions/discover route (lines 545–559)

Permission: Uses requirePermission('nodes', 'read', ...) which seems reasonable (read-only access to node data). However, this is a network-emitting operation (it sends radio frames to each repeater). A 'write' permission might be more appropriate, or at minimum this behavior should be documented in the JSDoc. The existing /discover endpoint for node discovery uses 'nodes', 'write', so this is inconsistent.


MeshCoreSettingsView.tsx — UI (lines 253–290)

Good UX overall. Chips are click-to-fill which is intuitive.

Missing "discard" affordance:
Once discoveredRegions is set, there is no way to dismiss/clear the list. If the user runs discovery again, the list updates, but if they just want to collapse it, they can't without a page refresh. A small "×" or a state reset on save would be a minor improvement (not a blocker).

discoveringRegions does not disable the node-discovery buttons:
The node-discovery buttons check discovering !== null, but discoveringRegions is a separate state variable. A user can click "Discover Repeaters" while region discovery is in progress. Overlapping radio requests may or may not cause issues depending on the firmware, but it's worth noting.


useMeshCore.tsdiscoverRegions hook (lines 772–786)

Clean and consistent with the pattern used by other actions. No issues.


Tests

meshcoreNativeBackend.discovery.test.ts:

  • Frame layout verified byte-by-byte ✅
  • Clock parsing ✅
  • NUL termination + trailing junk ignored ✅
  • Wildcard * preserved in backend (manager filters it) ✅

meshcoreManager.scope.test.ts:

  • De-dup + sort ✅
  • * filtering ✅
  • Non-repeater skip ✅
  • Non-responder tolerance ✅
  • Per-repeater breakdown shape ✅

Gap: There's no test for the case where discoverRegions() is called on a non-COMPANION device type (the early return at line 2318). This is a trivial branch but untested.


Summary

The implementation is correct, well-commented, and consistently follows the patterns already established in the codebase. The main actionable item is the potential tag-guard race in onResp (the tag === null case). The permission resource mismatch (read vs write for a transmitting endpoint) and the missing device-type test are lower priority. Everything else is cosmetic or minor UX.

…#3667)

Address Claude review feedback on PR #3679:
- Harden request_regions reply matching: only accept a BinaryResponse once the
  Sent ack has set our tag (tag===null no longer resolves on a stray reply).
- /regions/discover now requires 'nodes' 'write' (it transmits radio frames),
  matching POST /discover instead of the weaker 'read'.
- discoverRegions logs at debug when a repeater returns an error response (not
  just on throw).
- UI: cross-disable the node-discovery and region-discovery buttons so
  overlapping radio requests can't be triggered; clear the suggestion chips
  once a scope is saved.
- Test: discoverRegions returns empty (and queries nothing) on a non-companion
  device.

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

Yeraze commented Jun 23, 2026

Copy link
Copy Markdown
Owner Author

Thanks! Addressed in 788792f:

  • onResp tag race — hardened: the reply is only accepted once the Sent ack has set our tag (tag === null no longer resolves on a stray/early BinaryResponse). The firmware always sends Sent before BinaryResponse, and the timeout covers a missing Sent.
  • Permission readwrite/regions/discover now requires nodes write, matching POST /discover, since it transmits radio frames. JSDoc updated to note this.
  • No log on !resp.success — added a debug log distinguishing an error response from a non-answer.
  • Device-type test gap — added a test: discoverRegions() on a non-companion device returns empty and issues no request_regions.
  • UI — cross-disabled the node-discovery and region-discovery buttons (no overlapping radio requests); the suggestion chips now clear once a scope is saved (the "discard" affordance).

Full suite green (7341 passed).

@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(meshcore): region discovery from nearby repeaters — phase 3

Tasks

  • Gather context and understand request
  • Read changed files
  • Analyze code quality and best practices
  • Check for potential bugs or issues
  • Review performance considerations
  • Review security concerns
  • Assess test coverage
  • Post review feedback

Overall Assessment

This is a clean, well-reasoned implementation that correctly addresses all feedback from the first review pass. The protocol framing is precise, the concurrency design is well-justified, and the new tests close every gap that was called out. Ready to merge from a code quality standpoint; the notes below are all minor.


meshcoreNativeBackend.tsrequest_regions

Tag guard hardened correctly. The previous if (tag !== null && r?.tag !== tag) race is now if (tag === null || r?.tag !== tag) — a BinaryResponse that arrives before Sent is held back. This is exactly right.

void c.sendToRadioFrame(frame) (line 982):
This is unchanged from the first review. The pattern is pre-existing and consistent with how other commands handle it (or don't). Since sendToRadioFrame in practice doesn't return a rejected promise, this is a tolerable carry-forward, not a new issue introduced by this PR.

K shadowing (line 947):
Also pre-existing (outer K at line 820 of dispatch()). Still cosmetically confusing but not a correctness issue. Fine to leave given it was already in the codebase before this PR.


meshcoreManager.tsdiscoverRegions()

Non-responder debug log added. !resp.success now logs at debug level with the error message — this is exactly what was requested and makes it easy to distinguish a soft error response from a timeout/throw.

Sequential querying with timeout 20 000 ms per repeater:
With timeoutMs=20_000 in sendBridgeCommand and a 15 000 ms inner timeout in request_regions, there's a 5 s gap between the inner and outer timeout. In practice the inner timeout fires first, rejects the inner promise, and the catch branch in discoverRegions handles it. The 20 000 ms outer guard is just a safety net — the logic is correct. Worth a comment if this trips someone up later, but not a bug.

r.advName fallback (line 2336):

name: r.name || r.advName || r.publicKey.substring(0, 12)

r.advName doesn't appear in the MeshCoreContact interface. It will silently be undefined and fall through to the key prefix. Harmless but potentially confusing. If it's a real field name it should be in the interface; if not, it can be dropped.


meshcoreRoutes.ts/regions/discover

Permission updated to nodes write. Correct — consistent with POST /discover. JSDoc note added. No further issues here.


MeshCoreSettingsView.tsx

Cross-disable between node-discovery and region-discovery:

disabled={!connected || loading || discoveringRegions || discovering !== null}

Both buttons are now mutually disabled. ✅

discoveredRegions cleared on save:
setDiscoveredRegions(null) in handleSaveScope collapses the chip list once a scope is applied. ✅

Minor — discoveredRegions not cleared on a new discovery run:

const handleDiscoverRegions = async () => {
  setDiscoveringRegions(true);
  try {
    const result = await discoverRegions();
    ...
    setDiscoveredRegions(result.regions);  // replaces old list

If a discovery returns an empty list, discoveredRegions stays at the previous non-empty value because the empty branch only shows a toast and returns early without calling setDiscoveredRegions([]). A user who runs discovery a second time and gets no results would still see the old chips. Add setDiscoveredRegions(result.regions) unconditionally (move it before the empty-check toast) to clear stale chips. Very minor UX nit.


useMeshCore.tsdiscoverRegions hook

Clean and idiomatic. The perRepeater shape is passed through for future use. No issues.


Tests

Backend tests (meshcoreNativeBackend.discovery.test.ts):

  • Frame layout byte-by-byte ✅
  • Clock parsing + NUL termination + trailing junk ignored ✅
  • Wildcard * preserved at backend layer (manager filters it) ✅
  • Tag-guard race: the mock fires Sent then BinaryResponse in the same setImmediate, which exercises the happy path. The "BinaryResponse before Sent" path isn't covered by the mock, but the production guard has been manually verified to block it. Acceptable.

Manager tests (meshcoreManager.scope.test.ts):

  • De-dup + sort ✅
  • * filtering ✅
  • Non-repeater skip ✅
  • Non-responder tolerance ✅
  • Non-companion early return ✅ (new test added, closes the gap from the first review)
  • Per-repeater breakdown shape ✅

All previous gaps have been closed.


Summary

All substantive issues from the first review have been addressed. The remaining observations are:

  1. r.advName undefined field (line 2336 in meshcoreManager.ts) — cosmetic, harmless.
  2. Stale chips on empty re-discovery (in handleDiscoverRegions) — minor UX nit.
  3. void sendToRadioFrame — pre-existing, out of scope for this PR.

None of these are blockers. The implementation is correct, well-tested, and follows all codebase conventions.


@Yeraze

Yeraze commented Jun 23, 2026

Copy link
Copy Markdown
Owner Author

Thanks — on the three remaining observations, all are intentional/already-handled, so no code change:

  1. r.advNameadvName is a declared optional field on MeshCoreContact (advName?: string), so this is a normal name || advName || prefix fallback, not an undefined-field access.
  2. Stale chips on empty re-discoveryhandleDiscoverRegions always calls setDiscoveredRegions(result.regions), so an empty result sets [], and the render guard (discoveredRegions.length > 0) hides the chips. Re-discovery with no results clears them.
  3. void sendToRadioFrame — pre-existing pattern shared with discover_nodes; out of scope here.

Calling this complete.

@Yeraze Yeraze merged commit 725446d into main Jun 23, 2026
20 checks passed
@Yeraze Yeraze deleted the feature/meshcore-scope-phase3 branch June 23, 2026 20:02
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