Skip to content

fix(meshtastic): resolve position-packet channel from decryption context, not raw hash (#3682)#3694

Merged
Yeraze merged 1 commit into
mainfrom
fix/position-packet-channel-3682
Jun 24, 2026
Merged

fix(meshtastic): resolve position-packet channel from decryption context, not raw hash (#3682)#3694
Yeraze merged 1 commit into
mainfrom
fix/position-packet-channel-3682

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 24, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #3682. Position packets received on a private secondary channel displayed as "channel N" where N was a raw numeric hash (e.g. 39), instead of the correct channel slot/name. Text messages on the same channel displayed the correct channel.

Root cause

Channel resolution differed by packet type in src/server/meshtasticManager.ts:

  • TEXT_MESSAGE_APP: the dispatch threads the decryption context (decryptedBy / decryptedChannelId) into processTextMessageProtobuf, which resolves the channel slot from the Channel Database entry the packet was decrypted on — either a matching device-channel slot (same PSK + name) or CHANNEL_DB_OFFSET + decryptedChannelId.
  • POSITION_APP: the dispatch dropped that context, and processPositionMessageProtobuf read meshPacket.channel directly. That field is the on-wire LoRa channel hash (xorHash(name) ^ xorHash(psk)), not a channel slot index — so for a server-decrypted secondary channel it surfaced as a meaningless number (the "39").

Fix

  • Extracted the text path's channel-resolution logic into a shared resolveBroadcastChannelIndex(meshPacket, context) helper, mirroring processTextMessageProtobuf exactly (device-channel PSK+name match preference, then CHANNEL_DB_OFFSET + decryptedChannelId).
  • Threaded the decryption context (decryptedBy / decryptedChannelId) into processPositionMessageProtobuf from the POSITION_APP dispatch case.
  • Position telemetry/node positionChannel now store the resolved channel slot, consistent with text messages on the same channel.
  • Raw-hash fallback preserved: when there is no server-decryption context (unencrypted / primary / node-decrypted packets), the helper returns meshPacket.channel exactly as before.
  • Per-source scoping is unchanged (channel-database lookups remain global-by-design; channels.getAllChannels(this.sourceId) stays source-scoped).

Test

New src/server/meshtasticManager.positionChannel.test.ts (7 tests):

  • resolveBroadcastChannelIndex: server-decrypted → CHANNEL_DB_OFFSET + dbId (not the raw hash); device-channel slot preferred on PSK+name match; raw-hash fallback for no-context / node-decrypted; default 0 when channel undefined.
  • processPositionMessageProtobuf: telemetry channel is the resolved slot (not the raw hash) when server-decrypted; raw meshPacket.channel for unencrypted/primary.

Full Vitest suite: success, 7377 passed, 0 failed, 0 failed suites. tsc --noEmit: no new errors in touched files.

🤖 Generated with Claude Code

…ext, not raw hash (#3682)

Position packets received on a private secondary channel displayed as
"channel N" where N was a raw numeric hash (e.g. 39) instead of the
correct channel slot/name. Text messages on the same channel displayed
correctly.

Root cause: channel resolution differed by packet type. The
TEXT_MESSAGE_APP dispatch threads the decryption context
(decryptedBy/decryptedChannelId) into processTextMessageProtobuf, which
resolves the channel slot from the Channel Database entry the packet was
decrypted on (CHANNEL_DB_OFFSET + dbId, or a matching device-channel
slot). The POSITION_APP dispatch dropped that context, and
processPositionMessageProtobuf read meshPacket.channel directly — that
field is the on-wire LoRa channel hash (xorHash(name) ^ xorHash(psk)),
NOT a slot index, so it surfaced as a meaningless number.

Fix: extract the text path's channel-resolution logic into a shared
resolveBroadcastChannelIndex() helper and thread the same decryption
context into processPositionMessageProtobuf. Server-decrypted packets now
resolve to the same channel slot as text on that channel; the raw
meshPacket.channel fallback is preserved for unencrypted/primary/
node-decrypted packets. Per-source scoping is unchanged.

Adds meshtasticManager.positionChannel.test.ts covering the helper
(server-decrypted -> offset+dbId, device-channel match, unencrypted/
node-decrypted/undefined fallbacks) and the full handler (telemetry
channel = resolved slot, not the raw hash).

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

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: fix(meshtastic): resolve position-packet channel from decryption context (#3682)

  • Gather context and read changed files
  • Review code quality and best practices
  • Check for potential bugs
  • Evaluate test coverage
  • Post final review

Summary

This is a well-scoped, correctness fix for a real channel-resolution inconsistency. The root cause analysis in the PR description is accurate and matches the code. Overall the change is solid; a few observations below.


Code Quality

resolveBroadcastChannelIndex (meshtasticManager.ts:6081)

The helper is a clean extraction and mirrors the text-message path faithfully. One observation: the inline logic for the server-decryption path in processTextMessageProtobuf (lines 5781–5810) is now a near-identical copy of resolveBroadcastChannelIndex. The helper was introduced for positions but the text path was left as-is. This is technically fine (both produce the same result), but it leaves two diverging copies that could drift in future. Consider a follow-up to refactor processTextMessageProtobuf to also call resolveBroadcastChannelIndex for the broadcast case — that would make "text and position use the exact same logic" true by construction, not just by comment.

Context spreading (meshtasticManager.ts:5537–5540)

await this.processPositionMessageProtobuf(meshPacket, processedPayload as any, {
  ...context,
  decryptedBy,
  decryptedChannelId: decryptedChannelId ?? undefined,
});

The ...context spread here is slightly surprising — context at this point is a ProcessingContext object built earlier in the dispatch loop. If context already carries decryptedBy/decryptedChannelId fields (from an earlier iteration or outer call), the explicit overrides below the spread will win, so it's safe. But documenting why ...context is spread (rather than just constructing { decryptedBy, decryptedChannelId } directly as the text-message call does on line 5526–5530) would help future readers. Compare with the text path:

// TEXT_MESSAGE_APP (line 5526):
{ decryptedBy, decryptedChannelId: decryptedChannelId ?? undefined }
// POSITION_APP (line 5537):
{ ...context, decryptedBy, decryptedChannelId: decryptedChannelId ?? undefined }

The asymmetry is minor but worth noting.

resolveBroadcastChannelIndex return type

The method returns Promise<number>. The fallback path return meshPacket.channel !== undefined ? meshPacket.channel : 0 returns a number from the raw protobuf field. In protobuf-js, numeric fields on unset messages can be 0 (the protobuf default), so the !== undefined check and the 0 default are consistent — this is fine.


Potential Bugs

No issues found. The logic correctly handles all four cases:

  1. Server-decrypted, device channel match → device slot (correct)
  2. Server-decrypted, no device match → CHANNEL_DB_OFFSET + dbId (correct, fixes Position packets from secondary channels display as 'channel 39' instead of correct channel #3682)
  3. Node-decrypted / no context → meshPacket.channel (preserved existing behavior)
  4. meshPacket.channel === undefined0 (safe default)

The TELEMETRY_APP, PAXCOUNTER_APP, and NEIGHBORINFO_APP dispatch cases (lines 5549–5558) still do not thread decryptedBy/decryptedChannelId to their handlers. If those handlers also read meshPacket.channel for display, they could have the same latent issue on a secondary channel. This is out of scope for this PR but worth tracking.


Test Coverage

The 7 tests in meshtasticManager.positionChannel.test.ts are well-structured and cover the important cases:

  • ✅ Raw-hash is not used when server-decrypted
  • ✅ Device-channel preference (PSK + name match)
  • ✅ No-context fallback (unencrypted/primary)
  • ✅ Node-decrypted fallback
  • meshPacket.channel === undefined default
  • ✅ End-to-end processPositionMessageProtobuf for server-decrypted case
  • ✅ End-to-end processPositionMessageProtobuf for unencrypted/primary case

One gap: there's no test asserting the positionChannel field on the upsertNode call (line 6286) also receives the resolved index rather than the raw hash. The existing tests check insertTelemetry rows (latitude/longitude) but upsertNode is separately called with positionChannel: channelIndex. Adding a upsertNode assertion (or at least a comment explaining it's covered by the same channelIndex variable) would close that coverage gap.


Performance

resolveBroadcastChannelIndex always awaits two DB calls (getByIdAsync + getAllChannels) when decryptedBy === 'server'. This matches the existing text-message path exactly, so no regression. The lookups are on small tables (channel database entries and device channels) and are already present in the text path, so this is acceptable.


Security

No concerns. The fix is purely a display/storage correctness issue. Channel decryption itself is unchanged; this only affects which slot index is stored after decryption succeeds.


Verdict

Approved with minor suggestions. The fix is correct, well-tested, and doesn't introduce regressions. The two suggestions above (refactor text path to call the shared helper; add a upsertNode assertion) are non-blocking follow-ups.

@Yeraze Yeraze merged commit 7f90a46 into main Jun 24, 2026
19 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.

Position packets from secondary channels display as 'channel 39' instead of correct channel

1 participant