Skip to content

fix(position): surface per-fix SNR/hops in position history for direct hears (#3590)#3593

Merged
Yeraze merged 2 commits into
mainfrom
fix/3590-position-snr
Jun 21, 2026
Merged

fix(position): surface per-fix SNR/hops in position history for direct hears (#3590)#3593
Yeraze merged 2 commits into
mainfrom
fix/3590-position-snr

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #3590.

When a node was heard directly (zero hops), SNR was present in the packet monitor but never appeared in the map position-history tooltip. There were two root causes:

1. Read path stripped the per-fix SNR/hops

The /api/nodes/:nodeId/position-history handler (used by the map history trail) grouped the per-metric telemetry rows into per-fix position objects but never read the rxSnr / hopStart / hopLimit columns that the position handler stamps onto the lat/lon rows (added in migration 089 for #3492). The frontend tooltip (mapHelpers.tsx) was already wired to render Heard directly (0 hops) + SNR — the data simply never reached it.

The grouping logic is extracted into a pure pivotPositionHistory() helper (src/server/utils/positionHistoryPivot.ts) that now carries SNR/hops through, treating the firmware -128 "no SNR" sentinel as absent.

2. Write path dropped a legitimate 0 dB SNR

processPositionMessageProtobuf recorded SNR with a meshPacket.rxSnr && meshPacket.rxSnr !== 0 truthiness guard, which silently dropped an SNR of exactly 0.0 dB — common for a node heard directly. This is replaced with the != null && !== -128 sentinel check already used by the central packet path (processMeshPacket), so directly-heard packets (including 0 dB) correctly update both the per-fix position metadata and the node's last-known SNR. The per-fix posRxSnr is likewise normalized so -128 is never written.

Note: the node's last-known SNR was already updated for all packet types (including position packets) via the central processMeshPacket path using the correct sentinel; this change makes the position handler's own node upsert consistent rather than relying on it being a no-op.

Tests

  • New src/server/utils/positionHistoryPivot.test.ts (10 cases) covering: zero-hop SNR capture, the 0 dB preservation (the core bug), the -128 sentinel stripping, null/undefined SNR, field omission when absent, incomplete-fix filtering, oldest-first sort, and metadata coming from either the lat or lon row.
  • Full Vitest suite: 7022 passed, 0 failed.
  • New/changed files lint clean. tsc adds 0 new errors (pre-existing unrelated errors on main are unchanged).

🤖 Generated with Claude Code

…t hears (#3590)

The map position-history tooltip never showed SNR for nodes heard directly
(zero hops), even though the packet monitor showed SNR was present.

Two issues:

1. The `/api/nodes/:nodeId/position-history` handler grouped lat/lon telemetry
   rows but dropped the per-fix `rxSnr`/`hopStart`/`hopLimit` columns (added in
   migration 089 for #3492). The frontend tooltip was already wired to render
   "Heard directly (0 hops)" + SNR, but the data never reached it. The pivot
   logic is extracted into `pivotPositionHistory()` and now carries SNR/hops
   through, treating the firmware -128 "no SNR" sentinel as absent.

2. The POSITION_APP handler recorded SNR with a `snr && snr !== 0` truthiness
   guard, which silently dropped a legitimate 0.0 dB SNR — common for a node
   heard directly. Switched to the `!= null && !== -128` sentinel check used by
   the central packet path, so direct hears (incl. 0 dB) update both the per-fix
   position metadata and the node's last-known SNR.

Adds unit tests for the pivot covering the 0 dB case, the -128 sentinel, and
field omission.

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

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review — fix(position): surface per-fix SNR/hops in position history (#3590)

  • Gather context and understand the request
  • Read changed files
  • Review code quality and best practices
  • Check test coverage
  • Post final review

This is a clean, well-scoped bug fix. The root-cause analysis in the PR description is accurate and complete. A few notes:

Strengths

Extraction of pivotPositionHistory() (positionHistoryPivot.ts) is the right call — the grouping logic was previously inline in the route handler and is now testable in isolation. The interface definitions (PivotTelemetryRow, PivotedPosition) are appropriately minimal, and the sentinel constant NO_SNR_SENTINEL = -128 is exported (reusable and testable by name rather than magic number).

The sentinel check fix (rawPosRxSnr != null && rawPosRxSnr !== -128 instead of the old truthiness guard) is correct. The PR description accurately explains why rxSnr && rxSnr !== 0 is wrong for 0 dB.

Test coverage (10 cases in positionHistoryPivot.test.ts) is thorough for the new helper: the 0 dB preservation test directly targets the regression, sentinel stripping, null handling, key-omission, sorting, incomplete-fix filtering, and metadata sourced from either lat or lon row are all covered.


Issues Found

1. Inconsistent fix: snr_local telemetry at line 6418 still uses the old truthiness guard

src/server/meshtasticManager.ts:6418:

// OLD pattern — still present in processMeshPacket for snr_local telemetry
if (meshPacket.rxSnr && meshPacket.rxSnr !== 0) {
  nodeData.snr = meshPacket.rxSnr;
  // ... also inserts snr_local telemetry row

This is the snr_local telemetry path inside processMeshPacket (the handler for all packet types, not just position packets). It will still silently drop a 0 dB SNR when recording the snr_local telemetry series (shown in the SNR telemetry chart). The fix applied to the position handler (processPositionMessageProtobuf) and the node upsert within it is correct, but this parallel path is still broken for the same class of SNR. Whether or not this affects the symptom described in issue #3590, it's a latent inconsistency.

2. Minor: altitude and optional fields are not omitted when absent (minor shape inconsistency)

positionHistoryPivot.ts:101-112: altitude, groundSpeed, groundTrack are spread unconditionally (they will appear as undefined keys in the output object if absent), while snr/hopStart/hopLimit are correctly omitted via ...(pos.x !== undefined && { x: pos.x }). This is cosmetically inconsistent with the SNR/hops handling but unlikely to affect runtime behavior since undefined JSON keys are dropped by JSON.stringify. Not a bug — just noting the inconsistency for future maintainability.

3. sinceTimestamp: 0 is passed for "no cutoff" instead of undefined

src/server/server.ts:1326-1344:

const cutoffTime = hoursParam ? Date.now() - hoursParam * 60 * 60 * 1000 : 0;
// ...
const positionTelemetry = await databaseService.getPositionTelemetryByNodeAsync(nodeId, 1500, cutoffTime);

cutoffTime = 0 is passed when no hours param is given. The repository uses gte(telemetry.timestamp, sinceTimestamp) when defined. For SQLite this is benign (all timestamps are > 0). However, in the repo signature sinceTimestamp?: number, passing 0 is not the same as omitting it — rows at exactly timestamp 0 would be excluded. This is pre-existing behavior and not introduced by this PR, but it's worth noting if the position history route is being touched anyway.


Summary

The core bug fix is correct and the new pivotPositionHistory helper is a solid improvement. The primary concern is the remaining snr_local truthiness guard at line 6418 which will continue to drop 0 dB SNR for the node's SNR telemetry history chart even after this PR lands. Everything else is minor or pre-existing.

The central processMeshPacket snr_local telemetry path still used a
truthiness guard (`rxSnr && rxSnr !== 0`) which silently dropped a
legitimate 0 dB SNR from a directly-heard node, contradicting the
issue's "any directly-heard packet of any type should update the node's
last-known SNR". Switch to the firmware-sentinel check (`!= null &&
!== -128`) already used in the position handler. Addresses claude-review
finding on PR #3593.

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

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review — fix(position): surface per-fix SNR/hops in position history (#3590)

  • Gather context and understand the request
  • Read changed files
  • Review code quality and best practices
  • Check test coverage
  • Post final review

This is a well-scoped, two-commit fix that correctly addresses both root causes described in the PR. The prior automated review raised several concerns — here is an updated assessment after reading the actual code on the branch.

What was previously flagged vs. what's actually there

Issue 1 (previous review): snr_local truthiness guard at line 6418RESOLVED in this PR.

The second commit (0801981) already fixed this. meshtasticManager.ts:6420 now reads:

if (meshPacket.rxSnr != null && meshPacket.rxSnr !== -128) {

with an explanatory comment referencing #3590. The old rxSnr && rxSnr !== 0 guard is gone. Both the snr_local telemetry path and the position handler now use the correct sentinel check.

Issue 2 (previous review): Optional fields not omitted when absent — still present, but truly cosmetic. altitude, groundSpeed, groundTrack spread as undefined in the output object (lines 105–107 of positionHistoryPivot.ts) while snr/hopStart/hopLimit use the conditional spread pattern. Since undefined keys are stripped by JSON.stringify, this has no runtime impact. Noted only for consistency.

Issue 3 (previous review): sinceTimestamp: 0 — pre-existing; not introduced here.


Remaining minor observations

1. {SNR} auto-responder template token at line 11708 still uses a 0-check:

const snrValue = (rxSnr !== undefined && rxSnr !== null && rxSnr !== 0)
  ? rxSnr.toFixed(1)
  : 'N/A';

This is in the auto-responder message templating path, not in the telemetry write path, so it is unrelated to the bug being fixed. However it will display N/A instead of 0.0 for a 0 dB SNR in auto-responder messages. This is not a regression introduced by this PR (it was pre-existing), but it's a third location with the same pattern that was missed.

2. rx_snr / rx_start snake_case fallbacks in processPositionMessageProtobuf:

const rawPosRxSnr = meshPacket.rxSnr ?? (meshPacket as any).rx_snr ?? undefined;
const posHopStart = meshPacket.hopStart ?? (meshPacket as any).hop_start ?? undefined;

These snake_case fallbacks suggest some callers pass un-camelCased protobuf objects. The getPositionTelemetryByNodeAsync return type should already be camelCased from Drizzle, so these are defensive guards against raw protobuf. That's fine, just slightly surprising — worth a brief comment if not already covered.

3. getPositionTelemetryByNodeAsync signature for sinceTimestamp = 0 (pre-existing, not introduced here): If the repository filters with gte(telemetry.timestamp, sinceTimestamp) when sinceTimestamp is defined, passing 0 is subtly different from omitting it (excludes any row at exact epoch 0, which is impossible in practice). No action needed in this PR.


Positive notes

  • pivotPositionHistory() extraction is the right architectural call. The pivot logic is now isolated, pure, and independently testable — a significant improvement over inline route handler code.
  • Test suite is thorough: the 0 dB regression test directly exercises the exact failure mode. Sentinel stripping, null/undefined handling, key-omission, sort order, incomplete-fix filtering, and metadata from either row are all covered. 10 cases for a 116-line utility is good density.
  • NO_SNR_SENTINEL exported from the pivot module rather than scattered as a magic number — the constant is reusable and the test references it by name.
  • Both affected paths (position write + snr_local write) are now consistently using != null && !== -128 as intended.

Summary

The core fix is correct and complete. The previous automated review's primary concern (the snr_local guard) was already addressed by the second commit before this review ran. The remaining observations are either cosmetic, pre-existing, or out of scope for this PR. No blocking issues.

@Yeraze Yeraze merged commit fb33805 into main Jun 21, 2026
20 checks passed
@Yeraze Yeraze deleted the fix/3590-position-snr branch June 21, 2026 04:12
Yeraze added a commit to TheWISPRer/meshmonitor that referenced this pull request Jun 21, 2026
Resolve migration-number collision: renumber the dead-drop migration
094_create_dead_drop -> 095_create_dead_drop (keeping main's
094_add_meshcore_node_favorite). Update migrations.ts registry,
migrations.test.ts (count 95, last=create_dead_drop), and the migration's
internal Postgres/MySQL export names. meshtasticManager.ts auto-merged
cleanly (main's Yeraze#3593/Yeraze#3598/Yeraze#3600 changes + the mailbox dispatch coexist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011JEaCGwY9Wz8jeV4e22GW4
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 history: SNR not captured/displayed when node heard directly (zero hops)

1 participant