Skip to content

fix(meshcore): populate discovered repeater names via ANON_REQ OWNER, no admin login (#3820)#3825

Merged
Yeraze merged 2 commits into
mainfrom
fix/3820-refresh-known-nameless-contact
Jun 27, 2026
Merged

fix(meshcore): populate discovered repeater names via ANON_REQ OWNER, no admin login (#3820)#3825
Yeraze merged 2 commits into
mainfrom
fix/3820-refresh-known-nameless-contact

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 27, 2026

Copy link
Copy Markdown
Owner

Problem

A MeshCore repeater added via Discover Repeaters showed as "Unknown" in the node list and stayed that way — even after a zero-hop advert — until an admin login. Reopen of #3756. The reporter confirmed the official iOS/Windows app shows names from both Discover and adverts, so this is a MeshMonitor gap, not a protocol limitation.

Root cause (firmware-verified)

NODE_DISCOVER_RESP carries no name (fixed buffer: type+snr+tag+pubkey), so discovery pre-creates a nameless device contact. The repeater's later zero-hop advert does carry the name and the firmware does store it — but because the contact already exists, the firmware emits a pubkey-only 0x80 advert (not the named 0x8A new-advert), so MeshMonitor's contact_advertised event has no name. And a zero-hop repeater may not advert for a long time (observed >30 min on live hardware), so the name can be absent indefinitely. Admin login only "worked" because opening the panel triggers a contacts/refresh.

The decisive find: a repeater answers an unauthenticated ANON_REQ OWNER (CMD_SEND_ANON_REQ sub-type 0x02) with node_name\nowner_info (firmware simple_repeater/MyMesh.cpp:handleAnonOwnerReq) — the same anon-request transport and isRouteDirect() gating as the region discovery shipped in #3743/#3765. So we can pull a discovered repeater's real name without admin credentials. Full analysis: docs/internal/dev-notes/MESHCORE_UNNAMED_NODE_3820_PLAN.md.

Fix (two complementary layers)

  1. Active owner-fetch (primary). New request_owner bridge command (mirrors request_regions) + fetchOwnerName() in the manager: install a zero-hop direct out_path (firmware drops flooded OWNER reqs), issue ANON_REQ OWNER, write the returned name onto the contact. The user-facing POST /discover opts in (fetchNames=true); the internal region-discovery sweep does not. So Discover Repeaters now returns names in seconds, no admin login, no advert required — matching the app.
  2. Advert-refresh (zero-airtime complement). Moves the schedulePathRefresh (get_contacts re-read) out of the !wasKnown gate so a known-but-nameless contact also re-reads on the nameless 0x80 advert. Catches names from repeaters that do advert, with no round-trip. (Carries a comment: relies on discovery storing lastAdvert≈0 so the advert isn't replay-dropped firmware-side.)

Testing

  • New tests: meshcoreManager.ownerName.test.ts (out_path-before-request ordering, failure handling, non-companion guard) and the advert-refresh regression in meshcoreManager.newNodeRefresh.test.ts; updated meshcoreRoutes.test.ts for the fetchNames opt-in. Full Vitest suite green (7698 passed, 0 failed).
  • Live on hardware: delete "Yeraze Repeater" → Discover Repeaters → name populates as "Yeraze Repeater" within the ~12s discovery window, no admin login. Logs: Owner-name fetched for 661b0674…: "Yeraze Repeater" (#3820).

Fixes #3820

🤖 Generated with Claude Code

…aters get their name (#3820)

A repeater added via "Discover Repeaters" stays "Unknown" in the node list
until an unrelated refresh (e.g. opening its admin panel) happens to run —
even after it sends a zero-hop advert.

Root cause (firmware-verified): NODE_DISCOVER_RESP carries no name, so
discovery pre-creates a *nameless* device contact. The repeater's later
zero-hop advert DOES carry the name and the firmware DOES store it on the
device — but because the contact already exists, the firmware pushes a
pubkey-only 0x80 advert (not the full 0x8A new-advert record), so MeshMonitor's
contact_advertised event arrives with no adv_name. The device-record re-read
(schedulePathRefresh -> get_contacts) was gated behind `if (!wasKnown)`, so it
never ran for the already-known stub, leaving it "Unknown".

Fix: move the missing-name/type refresh trigger out of the `!wasKnown` branch so
it also fires for known-but-nameless contacts. Zero airtime — firmware drops
nameless adverts, so every contact_advertised event means the device just
stored a real name; the refresh is a local debounced get_contacts read and stops
re-firing once the name is pulled. #3756's `||`-not-`??` guard can only protect
a name already held; it cannot acquire one never pulled, so it was insufficient
for the fresh-discovery case.

Plan/analysis: docs/internal/dev-notes/MESHCORE_UNNAMED_NODE_3820_PLAN.md

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 27, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: fix(meshcore): refresh known-but-nameless contacts (#3820)

Tasks:

  • Gather context and understand the request
  • Read meshcoreManager.ts changes
  • Read test file changes
  • Post review feedback

Summary

This is a clean, well-reasoned fix. The root cause diagnosis is thorough, the change is surgical, and the test coverage is solid.


Code Quality

src/server/meshcoreManager.ts (lines ~1167–1195)

The old logic:

if (!wasKnown) {
  void this.notifyNewNodeDiscovered(updated);
}
// ... schedule refresh only for new nodes (implicitly)

The new logic correctly separates the "notify of new discovery" concern from the "refresh if name/type is missing" concern:

if (!wasKnown) {
  void this.notifyNewNodeDiscovered(updated);
}
// refresh fires for known-but-nameless contacts too
if (!updated.advName || updated.advType === undefined) {
  this.schedulePathRefresh(publicKey);
}

This is the right separation. The two concerns were conflated before, and the fix correctly decouples them.

The comment block at lines ~1175–1192 is excellent — it documents the firmware behavior assumptions, the lastAdvert≈0 reliance, and flags the specific future risk precisely. This level of documentation for a non-obvious firmware interaction is exactly what's needed here.


Potential Issues

1. advType === undefined check (minor)

The condition !updated.advName || updated.advType === undefined uses strict undefined equality for advType. This is correct given the spread pattern (advType: data.adv_type ?? existing.advType), but worth noting that if advType is ever null (e.g., from a DB round-trip), this guard would not catch it. Probably not a real concern given the in-memory contact map, but worth being aware of.

2. Re-triggering on every nameless advert (acceptable trade-off)

Until refreshContacts() completes and updates this.contacts, a flurry of pubkey-only adverts within the debounce window could all evaluate !updated.advName as true. The debounce in schedulePathRefresh correctly coalesces these into a single get_contacts call, so this is not a bug — just confirming the existing debounce mechanism handles it correctly (it does).

3. The lastAdvert≈0 dependency (acknowledged)

The PR correctly calls out this assumption in a NOTE comment. The risk is real: if discovery ever stores a non-zero lastAdvert, the firmware's replay guard would suppress the subsequent advert, contact_advertised would never fire, and the name would never populate. No action needed here — it's documented and flagged appropriately.


Test Coverage

The new test case at line 139 ('refreshes an already-known but NAMELESS contact so its name populates (#3820)') directly exercises the regression:

  • Seeds a known-but-nameless stub in this.contacts
  • Fires a pubkey-only contact_advertised event
  • Asserts exactly one get_contacts call fires after the debounce
  • Asserts the name and type populate correctly

This is a precise regression test. The existing three tests remain intact (new node with only pubkey, complete advert skips refresh, already-known-and-named skips refresh), ensuring no regression in those paths.

One minor gap: there's no test verifying that a known-but-nameless contact receiving multiple pubkey-only adverts within the debounce window still results in exactly one get_contacts call (coalescing). This is covered implicitly by the debounce mechanism, but an explicit test would be belt-and-suspenders. Not blocking.


Verdict

Approve. The fix is correct, minimal, and well-documented. Test coverage directly addresses the regression. The lastAdvert≈0 assumption is explicitly called out in code comments. No blocking issues.

… OWNER (#3820)

"Discover Repeaters" left repeaters as "Unknown" because NODE_DISCOVER_RESP
carries no name and a zero-hop repeater may not advert for a long time
(observed >30 min on live hardware), so the advert-refresh path (prior commit)
could wait indefinitely.

Firmware (ripplebiz/MeshCore simple_repeater `handleAnonOwnerReq`) answers an
UNAUTHENTICATED ANON_REQ OWNER (CMD_SEND_ANON_REQ sub-type 0x02) with
"node_name\nowner_info" — the same anon-request transport, and the same
isRouteDirect() gating, as the region discovery shipped in #3743/#3765. So we
can pull a discovered repeater's real name without admin login.

- meshcoreNativeBackend: new `request_owner` bridge command — builds the
  [57][pubkey][0x02][0x00] frame, awaits the 0x8C BinaryResponse (serialized via
  runExclusiveRadioOp, mirroring request_regions), parses clock + first line of
  "node_name\nowner_info".
- meshcoreManager: new `fetchOwnerName()` — installs a zero-hop direct out_path
  (firmware drops flooded OWNER reqs), issues request_owner, writes the name onto
  the contact and broadcasts it. discoverNodes() gains an opt-in `fetchNames`
  flag that fetches names for nameless repeaters/room-servers after the sweep.
- meshcoreRoutes: the user-facing POST /discover opts in (fetchNames=true); the
  internal region-discovery sweep does not (it has its own per-repeater pass).

Verified live: delete "Yeraze Repeater" -> Discover Repeaters -> name populates
as "Yeraze Repeater" within the discovery window (~12 s), no admin login, no
advert required. The advert-refresh (Option A) remains as a zero-airtime
complement for repeaters that do advert.

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 27, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: fix(meshcore): refresh known-but-nameless contacts (#3820)

Tasks:

  • Gather context and understand the request
  • Read meshcoreManager.ts changes
  • Read test file changes
  • Read meshcoreNativeBackend.ts changes
  • Read meshcoreRoutes changes
  • Post review feedback

Summary

A clean, well-scoped fix with two complementary strategies: a passive path (refresh on advert for known-but-nameless contacts) and an active path (ANON_REQ OWNER during discovery). The root cause analysis is thorough and accurate. The code quality is high overall.


Core Fix (meshcoreManager.ts lines 1167–1195)

Correct separation of concerns. The !wasKnown gate for notifyNewNodeDiscovered is correctly preserved, while the name/type refresh trigger is correctly decoupled from it:

if (!wasKnown) {
  void this.notifyNewNodeDiscovered(updated);
}
if (!updated.advName || updated.advType === undefined) {
  this.schedulePathRefresh(publicKey);
}

The comment block (lines 1176–1192) is excellent — it documents the firmware relay behavior, the lastAdvert≈0 reliance, and flags the specific future risk precisely.

Minor observation: The advType === undefined check uses strict equality. If advType ever comes back as null from a DB round-trip and gets spread into existing, this guard won't catch it. The in-memory contact map makes this unlikely in practice, but worth being aware of.


Active Name Fetch (fetchOwnerName + request_owner)

The fetchOwnerName method is well-structured and defensive:

  • Correctly installs a zero-hop direct out_path first (firmware gates OWNER response on isRouteDirect())
  • Best-effort: swallows all failures with a debug log, keeping the passive advert path as fallback
  • Response parsing strips non-printable chars as defense against stray binary payloads

Potential issue — request_owner and BinaryResponse listener:

c.on(K.PushCodes.BinaryResponse, onResp);  // persistent until cleanup

This uses c.on() (not c.once()), which is correct since a Sent must arrive first. But the onResp guard only filters on sentReceived:

const onResp = (r: any) => {
  if (!sentReceived) return;  // only after Sent
  cleanup();
  resolve(r?.responseData ?? new Uint8Array());
};

This is the same pattern as request_regions and is correct for this serialized (exclusive radio op) context. ✓

Potential concern — no tag matching on BinaryResponse. The request_owner handler accepts the first BinaryResponse that arrives after Sent, with no tag validation (same as request_regions). The runExclusiveRadioOp serialization prevents this from being a real bug in the normal case, but if another push (e.g. a telemetry reply from a different node) arrives between Sent and the OWNER reply, it could be misidentified. This is an inherent limitation of the tagless ANON_REQ protocol and is called out in the existing comment. ✓


Discovery Integration (discoverNodes + route)

The fetchNames opt-in parameter (default false) is the right design choice:

  • Internal calls (region discovery sweep) don't pay the per-node OWNER fetch cost
  • User-facing "Discover" action opts in with fetchNames=true

The sequential await loop at lines 2606–2614 is intentionally serial (one ANON_REQ at a time, matching the exclusive radio op constraint). This is correct.

The guard isRepeaterish && !contact?.advName (line 2611) is correct — only fetches names for nameless repeaters/room servers. Chat nodes don't support handleAnonOwnerReq, so they'd time out at 15s each. Good that they're excluded.


Test Coverage

meshcoreManager.newNodeRefresh.test.ts — The new test at line 139 directly exercises the regression:

  • Seeds a known-but-nameless stub
  • Fires a pubkey-only advert event
  • Asserts exactly one get_contacts fires after debounce
  • Asserts name and type populate correctly ✓

meshcoreManager.ownerName.test.ts — Three solid cases:

  • Happy path: set_out_path before request_owner, name applied to contact ✓
  • Failure path: owner request fails, contact stays unnamed, no throw ✓
  • Non-companion guard: returns null immediately without sending any commands ✓

Gap (minor, non-blocking): No test verifying the isRepeaterish && !contact?.advName guard in discoverNodes — i.e., that a chat node discovered alongside a repeater doesn't get a fetchOwnerName call (which would time out on real hardware). The route test mocks discoverNodes wholesale, so this path isn't exercised. The logic is simple enough that it's low risk, but worth a note.


meshcoreRoutes.test.ts Changes

The mock update adding fetchOwnerName to the stub manager is correct and necessary. No issues.


Verdict

Approve. The fix is correct, minimal, and well-documented. The two-path strategy (passive advert refresh + active ANON_REQ) is appropriately layered. Test coverage directly addresses the regression. No blocking issues.

@Yeraze Yeraze changed the title fix(meshcore): refresh known-but-nameless contacts so discovered repeaters get their name (#3820) fix(meshcore): populate discovered repeater names via ANON_REQ OWNER, no admin login (#3820) Jun 27, 2026
@Yeraze Yeraze merged commit 693b9c5 into main Jun 27, 2026
20 checks passed
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.

[BUG] MeshCore: Unnamed node in the node list [reopen]

1 participant