Skip to content

feat(meshcore): define a contact's forwarding path by repeater name#3550

Merged
Yeraze merged 2 commits into
mainfrom
feat/meshcore-define-path-by-name
Jun 19, 2026
Merged

feat(meshcore): define a contact's forwarding path by repeater name#3550
Yeraze merged 2 commits into
mainfrom
feat/meshcore-define-path-by-name

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 19, 2026

Copy link
Copy Markdown
Owner

Summary

MeshCore lets a user manually define a contact's forwarding path (the out_path — the ordered list of repeater hops a message takes). MeshMonitor already had the backend for this, but the UI was a raw comma-separated hex input hidden behind an off-by-default meshcoreAdvancedPathEdit toggle. This makes it a first-class, on-by-default feature with a name-aware editor that mirrors the MeshCore app's flow.

What changed

Name-based path editor (MeshCoreContactDetailPanel):

  • "Define Path…" modal builds the route by picking repeaters/room servers by name from a dropdown, in order from your node outward.
  • Ordered hop list with reorder (↑/↓) and remove (✕).
  • Pre-fills from the contact's existing out_path, resolving each hop byte back to a repeater name (or "Unknown (0x..)" for repeaters you haven't met).
  • Raw-hex byte fallback for hops to unknown repeaters.
  • Emits the same comma-separated hex chain the existing PUT .../out-path route accepts — no backend protocol change.

New helper (src/utils/meshcorePath.ts): pure functions mapping repeaters ↔ hops. A hop is the first byte of the repeater's public key (MeshCore's default 1-byte path hash). Handles hash collisions and unknown hops. Unit-tested.

Enabled by default (toggle removed):

  • Dropped the meshcoreAdvancedPathEdit 403 gate from the PUT .../out-path route.
  • Removed the panel's advancedPathEditEnabled gate and the DM view's toggle fetch.
  • Removed the Settings toggle UI and the meshcoreAdvancedPathEdit setting key entirely.
  • Now gated only by nodes:write + a Companion device (unchanged access model otherwise).

Testing

  • meshcorePath unit suite (parse/join/resolve, name resolution + collisions).
  • Panel tests: build-a-path-by-name → save the right hex chain; pre-populate + resolve an existing path to names.
  • Route + settings-persistence tests updated (the 403-gate test removed).
  • Full Vitest suite green; tsc clean for touched files.
  • Deployed to the dev container and verified against real MeshCore hardware.

Not in this PR

The backend stack (set_out_path bridge → meshcore.js setContactPathCMD_ADD_UPDATE_CONTACT, and the manager's setContactOutPath) already existed — this is the UX + access-model change on top.

A separate, pre-existing issue (not touched here): the MeshCore DM contact list is sourced from getContacts() (the device's in-memory companion contact list) rather than the richer observed-node list, which can show a sparse/unnamed set on some sources. To be investigated in a follow-up PR.

🤖 Generated with Claude Code

…efault

MeshCore lets you manually define a contact's forwarding path. The previous
"Edit Path…" was raw comma-separated hex behind the off-by-default
`meshcoreAdvancedPathEdit` toggle. This makes it a first-class, on-by-default
feature with a name-aware editor.

- New src/utils/meshcorePath.ts: pure helpers mapping repeaters ↔ path hops
  (a hop is the first byte of the repeater's public key), with name resolution
  (incl. 1-byte-hash collisions and unknown hops). Unit tested.
- MeshCoreContactDetailPanel: "Define Path…" modal now builds the route by
  picking repeaters/room servers by name (ordered list with ↑/↓/✕), pre-fills
  from the existing out_path resolving each hop to a name, and keeps a raw-hex
  byte fallback for repeaters not yet known. Emits the same comma-separated hex
  chain the API already accepts.
- Un-gated: removed the meshcoreAdvancedPathEdit 403 check from the
  PUT .../out-path route, the panel's advancedPathEditEnabled gate, the
  DirectMessagesView toggle fetch, and the SettingsTab toggle + the setting key
  itself. Now gated only by nodes:write + Companion device.
- Tests: meshcorePath suite; panel tests (build-by-name save, pre-populate +
  resolve); route/settings tests updated (403-gate test removed).

The backend (set_out_path bridge → meshcore.js setContactPath → CMD_ADD_UPDATE
_CONTACT) and the manager's setContactOutPath were already in place; this is the
UX + access change on top.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(meshcore): define a contact's forwarding path by repeater name

Tasks:

  • Gather context and understand the PR
  • Review meshcorePath.ts utility
  • Review MeshCoreContactDetailPanel.tsx changes
  • Review route and settings changes
  • Review test coverage
  • Post final review summary

Overall this is a well-scoped, clean PR. The core path-resolution logic is solid and the UX improvement is meaningful. A few issues worth addressing before merge:

Bugs / Correctness

hopByteForKey is fragile for non-1-byte hash widths (src/utils/meshcorePath.ts:33-35)

export function hopByteForKey(publicKey: string): PathHop {
  return publicKey.slice(0, 2).toLowerCase();
}

This assumes MeshCore always uses a 1-byte path hash (2 hex chars = 1 byte). The server-side backend at meshcoreNativeBackend.ts already handles a variable hopHashBytes (1–4 bytes, packed in outPathLenRaw >> 6). If a device is using 2-byte hashes, the picker will silently map to the wrong hop byte. This may be intentional scope-limiting ("default 1-byte path hash width"), but the assumption should be documented clearly — and ideally the picker should warn or disable itself when the connected device isn't using 1-byte hashes.

key prop collision in hop list (MeshCoreContactDetailPanel.tsx:899)

key={`${byte}-${i}`}

Using ${byte}-${i} is safe, but since the same byte can appear multiple times (e.g. two hops through the same repeater), ${byte} alone would collide. The index suffix saves this, but the index-only form would also suffice and is simpler. No real bug here, but worth noting.

editorHops.length > 64 vs >= 64 (MeshCoreContactDetailPanel.tsx:299 and 332)
The addHop guard at line 299 uses >= 64 (prevents adding a 65th hop), but the save validation at line 332 uses > 64 (allows exactly 65 hops through before erroring). This is an off-by-one: a user could save a 65-hop path if they somehow bypassed the button disable. Both should use >= 64 (or > 64 with max 64 in the error message corrected to max 65). The comment and the route both say "0..64 hops" (max 64), so the guard at save time should be >= 64 to be consistent.

Actually re-reading: the route allows parsed.length > 64 → reject, meaning 64 bytes is valid. So > 64 in save is correct and the add-button guard at >= 64 is one-off: it prevents adding hop 64 when 63 are present. Both should use >= 64 for addHop and > 64 for save, OR both > 63 / > 64. Recommend checking alignment between the two guards.

Missing Mock: SourceContext in Panel Tests

MeshCoreContactDetailPanel calls useSource() (line 109), but MeshCoreContactDetailPanel.test.tsx never mocks ../../contexts/SourceContext. This likely works today because useSource() defaults gracefully (returns { sourceId: null }), but if useSource() ever throws on missing provider context, these tests will silently break. Other test files in the codebase consistently mock SourceContext (e.g. MeshCoreSourcePage.test.tsx). Recommend adding:

vi.mock('../../contexts/SourceContext', () => ({
  useSource: () => ({ sourceId: 'test-source', sourceName: 'Test' }),
}));

Code Quality

Long conditional in the actions card (MeshCoreContactDetailPanel.tsx:526-528)

{(canShowResetButton || canShowShareButton || canShowEditButton || ...) &&
  (canShowShareButton || canShowEditButton || ...) && (

The double-condition is hard to follow and the two sides overlap significantly. Consider extracting a hasAnyAction boolean computed earlier in the component.

Inline styles throughout the modal
The modal dialog at lines 833–996 is 160 lines of inline style={} props. The rest of the codebase uses CSS modules / class names (.btn-secondary, .node-detail-card, etc). The inline styles aren't wrong, but they make the modal harder to theme and maintain. This is pre-existing style to some extent, but it's worth flagging given the size of the new modal.

window.setTimeout instead of setTimeout (MeshCoreContactDetailPanel.tsx:370, 418)
window.setTimeout works in browsers but deviates from the rest of the codebase's style and fails in server-side/test environments without window. The other pre-existing call sites use window.setTimeout too — so this is consistent with the existing pattern, just worth noting for future cleanup.

Test Coverage — What's Good

  • meshcorePath.test.ts covers parse/join, round-trip, hop-byte extraction, repeater filtering + sorting, and all three resolve cases (known, collision, unknown).
  • Panel tests cover build-by-name → correct hex saved, pre-populate from existing path, and name-resolution of unknown hops.
  • Route/settings persistence tests correctly updated for the removed 403 gate.

Test Coverage — Gaps

  • No test for addCustomHop (the raw-hex fallback input). A user entering an invalid byte (e.g. "zz") should see the error message; a valid entry should add the hop.
  • No test for the reorder (↑/↓) buttons.
  • No test for the "cancel closes modal without saving" flow.

These are nice-to-haves rather than blockers.

Security

The route correctly gates on requireAuth() + requirePermission('nodes', 'write'). The parseHexPathChain input validation on the server side is independent of (and more strict than) the client-side parsePathHops — good layering. No concerns here.

Summary

Category Finding
Bug Off-by-one in addHop vs handleSaveEditor 64-hop guard
Bug hopByteForKey assumes 1-byte hash width — needs a doc comment or guard
Test gap Missing SourceContext mock in panel tests
Test gap No coverage for custom hex input / reorder buttons
Style Double-OR condition for actions card visibility could be simplified

The off-by-one in the hop guard and the missing SourceContext mock are the most important to address before merge. The rest is lower priority.

…3550)

Claude review feedback on the define-path PR:
- Add the SourceContext mock to the panel tests (matches other suites; the
  panel calls useSource()).
- Document hopByteForKey's 1-byte-path-hash assumption and that multi-byte
  hash widths are out of scope.
- Add coverage for the reorder (↑) button and the custom-hex-byte input
  (invalid rejected, valid added + saved).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Yeraze

Yeraze commented Jun 19, 2026

Copy link
Copy Markdown
Owner Author

Thanks for the review — addressed in d84cbc75:

  • SourceContext mock → added to the panel tests.
  • hopByteForKey 1-byte hash assumption → documented the limitation (multi-byte path-hash widths are out of scope; 1-byte is the default and norm).
  • Test gaps → added coverage for the reorder (↑) button and the custom-hex-byte input (invalid rejected, valid added + saved).

Two I'm declining, with reasons:

  • "Off-by-one" addHop >=64 vs save >64 — not a bug: 64 hops is the valid max (the route rejects parsed.length > 64). addHop's >= 64 blocks adding the 65th; the save-time > 64 is defense-in-depth that only triggers if the button guard were bypassed. The two are consistent (max 64). The review itself walked this back mid-paragraph.
  • key prop collision in the hop list — the key is ${byte}-${i}, which already includes the position index, so duplicate hop bytes don't collide.

@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(meshcore): define a contact's forwarding path by repeater name

Tasks:

  • Gather context and understand the PR
  • Review meshcorePath.ts utility and tests
  • Review MeshCoreContactDetailPanel.tsx changes
  • Review route and settings changes
  • Review test coverage (including new tests from d84cbc7)
  • Post final review summary

This is a clean, well-scoped PR that upgrades the path editor from a hidden raw-hex toggle to a first-class name-aware feature. The items raised in the prior review have been addressed. Here's the updated assessment:

What was addressed since last review ✓

  • SourceContext mock added (src/components/MeshCore/MeshCoreContactDetailPanel.test.tsx:24-26) — correctly uses { sourceId: 'test-source', sourceName: 'Test' }.
  • hopByteForKey 1-byte hash assumption documented at src/utils/meshcorePath.ts:32-41 — comment explains the scope limitation clearly and when to revisit.
  • Reorder test added (test.tsx:156-175) — covers the button scenario and correct re-ordered hex chain sent on save.
  • Custom hex input test added (test.tsx:177-195) — tests both invalid byte rejection and valid byte add + save.

Remaining observations

Off-by-one consistency (non-blocking — author declined, reasoning is sound)

The addHop guard (>= 64, MeshCoreContactDetailPanel.tsx:299) prevents adding hop 65; the save-time guard (> 64, line 332) rejects paths longer than 64 bytes. The route itself rejects parsed.length > 64 (line 663 of meshcoreRoutes.ts), so 64 is the valid max. These two guards are semantically consistent — the prior review walked this back too. No action needed.

Stale warning box always shows (minor UX issue)

The amber "Manual paths bypass auto-discovery…" warning in the dialog (MeshCoreContactDetailPanel.tsx:877-883) has role="alert" and is rendered unconditionally inside the modal. This means it fires as an ARIA live region announcement every time the modal opens, even when the user is just viewing or clearing the path. Consider rendering it as a static <div> without role="alert" (or only with the role when the user actually adds/changes a hop).

hopOptions selector allows duplicate hops silently

When a repeater appears in the dropdown and is selected twice (e.g. to create a loop), the path editor accepts it without any visual indicator. MeshCore firmware probably rejects or silently ignores loops, but there's no client-side warning. Low priority — real-world paths rarely loop — but worth a follow-up.

Missing "cancel closes modal" test (nice-to-have)

The previous review flagged this; it was not added. The modal cancel button (MeshCoreContactDetailPanel.tsx:979) calls setEditorOpen(false) directly without going through handleSaveEditor, so onSetOutPath would never be called on cancel. A test asserting this behavior would confirm the guard. Not a blocker.

role="alert" on the warning box vs the error box

MeshCoreContactDetailPanel.tsx:877 (the permanent yellow warning) and line 971 (the validation error box) both use role="alert". The validation error box is the right use of role="alert" (it appears conditionally in response to user action). The permanent warning should be demoted to a plain <div>.

Code quality

  • Pure utility functions (meshcorePath.ts): clean, well-typed, unit-tested. The resolveHop collision label ${name} (+N) is a nice touch.
  • Route hardening: parseHexPathChain on the server is stricter than the client-side parsePathHops (rejects invalid tokens rather than silently dropping them), which is the right layering.
  • Settings removal is clean — meshcoreAdvancedPathEdit is gone from settings.ts, SettingsTab.tsx, the route guard, and the DM view. No zombie references found.
  • Inline styles in the modal remain (pre-existing pattern in this component), but the modal is self-contained and isolated.

Test coverage summary

Scenario Covered
Parse/join/normalize path hops
Round-trip
Repeater filter + sort
Name fallback (unnamed repeater)
Hop resolution: known / collision / unknown
Build path by name → correct hex saved
Reorder hops (↑ button)
Custom hex input: invalid rejected / valid added
Pre-populate from existing out_path
Route: auth, pubkey validation, hex validation, oversize, 409, 200
Cancel modal without saving ✗ (nice-to-have)
Duplicate hop warning ✗ (nice-to-have)

Security

No concerns. Route correctly requires requireAuth() + requirePermission('nodes', 'write'). Input is validated server-side independently of client-side normalization.

Summary

The two items from the prior review that were marked as action items (SourceContext mock, hopByteForKey doc comment) are both properly addressed. The role="alert" misuse on the permanent warning box is a minor accessibility issue worth fixing, but not a merge blocker. Everything else is clean.

Verdict: ready to merge with the optional role="alert" fix if desired.


@Yeraze Yeraze merged commit e5ec0d5 into main Jun 19, 2026
20 checks passed
@Yeraze Yeraze deleted the feat/meshcore-define-path-by-name branch June 19, 2026 14:03
@Yeraze Yeraze mentioned this pull request Jun 19, 2026
Yeraze added a commit that referenced this pull request Jun 19, 2026
Finalize the 4.11.0 release (from 4.11.0-rc2) and bring documentation up to
date with everything new since 4.10.

Version bump across all five files: package.json, package-lock.json,
helm/meshmonitor/Chart.yaml, desktop/src-tauri/tauri.conf.json,
desktop/package.json.

Documentation:
- CHANGELOG: finalize the Unreleased section as [4.11.0], adding the
  previously-missing entries — headline MeshCore virtual node (#3540),
  MeshCore path-by-repeater-name (#3550), MeshCore map icons + filtering
  (#3563), per-node Hide from Map (#3565), telemetry time-range selector
  (#3530), newer AirQualityMetrics fields (#3517), UI/map unification
  (#3561), plus the bug-fix and documentation changes since 4.10.4.
- virtual-node.md: document the new MeshCore Virtual Node alongside the
  Meshtastic one (default port 5000, per-source enablement, admin-command
  safety, MeshCore app setup, troubleshooting) + a two-variants intro note.
- configuration/index.md, docs/index.md, README.md: mention the MeshCore
  Virtual Node in the Virtual Node feature blurbs.
- CLAUDE.md: refresh stale version header (4.10.0 -> 4.11.0) and migration
  count (84 -> 92, latest 092_add_hide_from_map_to_nodes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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