Skip to content

fix: prevent NodeInfo from overwriting high-precision positions (#3513)#3516

Merged
Yeraze merged 3 commits into
mainfrom
claude/great-dijkstra-p39q01
Jun 17, 2026
Merged

fix: prevent NodeInfo from overwriting high-precision positions (#3513)#3516
Yeraze merged 3 commits into
mainfrom
claude/great-dijkstra-p39q01

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 17, 2026

Copy link
Copy Markdown
Owner

Summary

When a NODEINFO_APP packet arrives over a direct TCP connection, MeshMonitor was unconditionally overwriting the stored latitude/longitude with whatever position was embedded — even when that position had lower precision than what was already stored. Meshtastic firmware applies the channel's positionPrecision setting to positions embedded in NodeInfo broadcasts, grid-snapping coordinates before broadcast. Every periodic NodeInfo from a node with reduced positionPrecision silently degraded the stored coordinates. The MQTT position monitor referenced in the issue only captures standalone POSITION_APP packets (not embedded NodeInfo positions), which is why it showed accurate coordinates while MeshMonitor showed rounded ones.

A secondary issue: the estimated_positions table in PostgreSQL used REAL columns (32-bit, ~7 significant digits) instead of DOUBLE PRECISION (64-bit), causing visible coordinate rounding for estimated-position nodes — inconsistent with the nodes table which already uses DOUBLE PRECISION.

Changes

  • src/server/meshtasticManager.ts: Add a precision downgrade guard in the NodeInfo position handler. Before writing latitude/longitude, compare incoming precisionBits against the stored positionPrecisionBits. Skip the lat/lon update only when both sides carry explicit non-zero precision info AND the incoming is clearly lower. Position telemetry history rows are still written unconditionally (full historical record preserved).
  • src/db/schema/estimatedPositions.ts: Change PostgreSQL latitude, longitude, and uncertaintyKm columns from pgReal to pgDoublePrecision.
  • src/server/migrations/091_estimated_positions_double_precision.ts: Migration 091 — ALTERs the three columns on PostgreSQL deployments; no-op for SQLite (REAL is already 64-bit) and MySQL (already DOUBLE).
  • src/db/migrations.ts / src/db/migrations.test.ts: Register migration 091, update count/name assertions.

Issues Resolved

Fixes #3513

Documentation Updates

No documentation changes needed — this is an internal bug fix with no user-facing behavior changes beyond correcting the displayed positions.

Testing

  • Unit tests pass (6522 passed — 3 pre-existing failures in protobuf encode tests unrelated to this change, present before this commit)
  • TypeScript compiles cleanly (npx tsc --noEmit)
  • Migration count test updated and passes (91 migrations, correct last-migration name)
  • Verify: configure a channel's positionPrecision to a low value (e.g. 14 bits) on a Meshtastic device and confirm MeshMonitor no longer replaces the stored accurate coordinates after the next NodeInfo broadcast
  • Verify: for a node with no existing stored position, NodeInfo with reduced precision still seeds the initial position (guard only blocks downgrades, not initial seeding)

🤖 Generated with Claude Code


Generated by Claude Code

…lower-precision ones (#3513)

Meshtastic firmware applies the channel's positionPrecision setting to positions
embedded in NodeInfo broadcasts, grid-snapping coordinates to reduced precision.
MeshMonitor was unconditionally overwriting the stored lat/lon from every NodeInfo
arrival, causing accurate stored positions to degrade silently over time.

Add a precision downgrade guard in the NodeInfo handler: lat/lon is only updated
when both sides carry explicit non-zero precisionBits AND the incoming is clearly
lower, we skip the position update. The telemetry history row is still written so
the position history log remains complete.

Also fix the estimated_positions PostgreSQL schema to use DOUBLE PRECISION (64-bit)
instead of REAL (32-bit / ~7 significant digits), which caused visible coordinate
rounding for estimated-position nodes. Migration 091 alters the three numeric
columns; SQLite and MySQL are no-ops.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jNq9fvemyLZVB1fqv3YGb
@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review

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

Overall: This is a well-targeted fix with clear logic and good comments. The change is minimal, focused, and correctly handles the precision downgrade guard. A few issues worth addressing before merge.


Potential Bug: Altitude silently dropped on downgrade

src/server/meshtasticManager.ts:7932–7941

When shouldUpdateLatLon is false (precision downgrade detected), altitude is also skipped. This means a valid altitude from the incoming NodeInfo is silently discarded even though altitude isn't affected by the channel positionPrecision grid-snapping behavior described in the PR — only lat/lon are grid-snapped. A node that updates its altitude via NodeInfo but previously had a high-precision lat/lon would never get its altitude updated.

if (shouldUpdateLatLon) {
  nodeData.latitude = coords.latitude;
  nodeData.longitude = coords.longitude;
  nodeData.altitude = nodeInfo.position.altitude;  // altitude silently dropped if skipping
}

Consider updating altitude separately when it's present, or renaming the flag to shouldUpdatePosition and documenting that altitude is intentionally skipped.


Potential Behavior Issue: positionPrecisionBits updated even when lat/lon is skipped

src/server/meshtasticManager.ts:7943–7947

When shouldUpdateLatLon is false (i.e., we skip the downgrade), the code still updates positionPrecisionBits, positionChannel, and positionTimestamp with the incoming (lower-precision) values:

if (precisionBits !== undefined && precisionBits !== 0) {
  nodeData.positionPrecisionBits = precisionBits;   // ← written even when lat/lon skipped
  nodeData.positionChannel = channelIndex;
  nodeData.positionTimestamp = Date.now();
}

This means the next NodeInfo will now compare against the lower positionPrecisionBits that was just written — effectively making the guard a one-shot protection. After the first downgraded NodeInfo, the stored positionPrecisionBits is lowered, and future NodeInfo packets with the same low precision will pass the precisionBits >= storedPrecisionBits check. The lat/lon would remain protected only for that one packet, not persistently.

The fix should only update positionPrecisionBits when shouldUpdateLatLon is true, or the guard needs to track position precision separately from the current stored value.


Migration idempotency concern

src/server/migrations/091_estimated_positions_double_precision.ts:39–45

The error-catch logic may miss some PostgreSQL error messages:

if (e.message?.includes('cannot be cast') || e.message?.includes('already')) {

PostgreSQL's error for ALTER COLUMN ... TYPE when no cast is needed may not match either string. The actual PG error for "column is already that type" is typically 42601 (syntax error) or no-op. More robustly, you could check e.code === '42P07' or query information_schema.columns before altering. That said, in practice DOUBLE PRECISIONDOUBLE PRECISION via ALTER COLUMN TYPE on PostgreSQL simply succeeds as a no-op with no error, so this catch may never trigger; it's defensive but the heuristic is fragile.


Test Coverage Gap: The core guard logic has no integration-style test

src/server/meshtasticManager.position-precision.test.ts

The existing tests in this file test the logic (computing shouldUpdateLatLon) but not the actual processNodeInfoProtobuf behavior. The tests at lines 27–56 are value-only assertions that don't actually call processNodeInfoProtobuf — they manually assert shouldUpdatePosition = true (hardcoded) without exercising the new guard.

A test that:

  1. Seeds an existing node with positionPrecisionBits = 32 and specific lat/lon
  2. Calls processNodeInfoProtobuf with a NodeInfo carrying precisionBits = 14
  3. Asserts the lat/lon in the upserted node was NOT updated

...would give meaningful coverage and prevent regressions. Currently the precision-downgrade guard has no behavioral tests.


Minor: Comment references wrong issue number in migration

src/server/migrations/091_estimated_positions_double_precision.ts:8

The migration comment says "fixing issue #3513" but this PR fixes #3513 as a whole — the migration specifically addresses the estimated_positions REAL vs DOUBLE PRECISION issue, which is a secondary concern. Not wrong, just worth noting if issue tracking matters.


Positive observations

  • The shouldUpdateLatLon condition correctly handles all four cases (no existing position, no incoming precision, no stored precision, precision comparison) with clear inline comments.
  • Telemetry history rows are always written regardless of guard outcome — this is the right behavior for preserving the full historical record.
  • The PG migration correctly loops over the three columns and handles errors per-column so a partial success doesn't block the rest.
  • Migration 091 is properly registered and the count/name assertions in migrations.test.ts are updated correctly.
  • The schema change from pgReal to pgDoublePrecision is consistent with the existing nodes table.

- Move positionPrecisionBits write inside shouldUpdateLatLon block to
  prevent the one-shot guard defect (stored precision would decrease
  even when lat/lon was blocked, letting the next packet bypass the guard)
- Add independent altitude update in the else-branch — altitude is not
  grid-snapped by firmware positionPrecision, so it should update even
  when lat/lon is blocked
- Replace fragile try/catch string-match in migration 091 with an
  information_schema.columns pre-check for idempotent ALTER
- Add 11 behavioral tests for the precision guard logic covering all
  guard conditions and the one-shot/altitude regression cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jNq9fvemyLZVB1fqv3YGb
@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review

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

Overall: This is a well-targeted, minimal fix that correctly addresses the NodeInfo precision downgrade bug (#3513) and the PostgreSQL REAL vs DOUBLE PRECISION issue. The previous review's concerns have been addressed — the current code is notably improved compared to what was described in those earlier comments.


What's Fixed vs. the Earlier Review Comments

The previous review raised four issues. All four have been addressed in the current code:

  1. Altitude silently dropped — ✅ Fixed. src/server/meshtasticManager.ts:7950–7954 now handles altitude independently in the else branch when shouldUpdateLatLon is false.

  2. positionPrecisionBits updated even when lat/lon is skipped — ✅ Fixed. The positionPrecisionBits, positionChannel, and positionTimestamp writes are now inside the if (shouldUpdateLatLon) block (meshtasticManager.ts:7940–7944), preventing the one-shot guard defect.

  3. Migration idempotency concern — ✅ Addressed. 091_estimated_positions_double_precision.ts:37–41 now queries information_schema.columns to check the current type before running ALTER COLUMN, making it properly idempotent rather than relying on error message matching.

  4. Test coverage gap — ✅ Addressed. The NodeInfo precision guard (issue #3513) describe block in meshtasticManager.position-precision.test.ts:280–382 tests the guard logic including the altitude-independent update and the one-shot prevention case.


Remaining Observations

Test structure: guard logic duplicated between test and production code

src/server/meshtasticManager.position-precision.test.ts:291–304

The evalGuard helper function in the test file is a manual copy of the production guard logic from meshtasticManager.ts:7926–7930. If the production logic changes, these tests will silently pass while testing the wrong thing. The tests verify the guard function correctly, but they're not exercising the actual production code path — they test a manually re-implemented duplicate. This is a known limitation given the difficulty of unit-testing the full processNodeInfoProtobuf method, and it's better than no tests, but worth documenting.

This is a minor concern, not a blocker.

precisionBits fallback ordering

src/server/meshtasticManager.ts:7915

const precisionBits = nodeInfo.position.precisionBits ?? nodeInfo.position.precision_bits ?? undefined;

The ?? undefined at the end is redundant (nullish coalescing already falls through to undefined if both are null/undefined). Cosmetically harmless, but slightly confusing. Not a bug.

Telemetry history always records even on skipped lat/lon updates — documented correctly

src/server/meshtasticManager.ts:7957–7969

The comment on line 7957 correctly notes that telemetry history is always written regardless of the guard. This is the right behavior and is well-documented.

Migration comment accuracy

src/server/migrations/091_estimated_positions_double_precision.ts:8

Comment says "fixing issue #3513" but the migration itself only fixes the secondary estimated_positions REAL→DOUBLE PRECISION issue; the main #3513 fix is in meshtasticManager.ts. Not wrong, just slightly oversimplified.


Positive Notes

  • The guard conditions are clearly laid out with inline comments (meshtasticManager.ts:7926–7930), making the four accepted-cases easy to audit.
  • Telemetry history preservation is correct and explicitly documented.
  • The information_schema pre-check in the migration (091:37–41) is cleaner than the error-catch pattern.
  • The schema change (estimatedPositions.ts:39–41) correctly uses pgDoublePrecision and adds a comment explaining why.
  • The evalGuard tests cover all the important edge cases (null existing position, zero precision bits on either side, same/higher/lower precision).
  • Migration count and name assertions updated correctly in migrations.test.ts.

Verdict: Ready to merge. The previous review concerns are resolved, the logic is sound, and the guard correctly handles all boundary conditions.

?? already falls through to undefined; the trailing ?? undefined was
cosmetically redundant (noted in automated review).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jNq9fvemyLZVB1fqv3YGb
@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review

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

Overall: This is a clean, well-targeted fix. All issues from prior reviews have been addressed. The code is correct and ready to merge.


Core Guard Logic (meshtasticManager.ts:7926–7930)

The guard is logically sound and covers all four edge cases with clear inline documentation:

const shouldUpdateLatLon =
  existingNode?.latitude == null || existingNode?.longitude == null || // no existing position
  !precisionBits ||        // incoming precision unknown (0 = not set), accept
  !storedPrecisionBits ||  // stored precision unknown, accept
  precisionBits >= storedPrecisionBits; // incoming is same or better precision

✅ First-write always accepted (null coordinates)
✅ Unknown incoming precision accepted
✅ Unknown stored precision accepted
✅ Equal or better precision accepted, lower rejected


Altitude Independence (meshtasticManager.ts:7950–7954)

Correctly handles altitude in the else branch, since firmware positionPrecision grid-snapping only affects lat/lon, not altitude:

} else {
  // Altitude is not grid-snapped by the firmware's positionPrecision setting
  if (nodeInfo.position.altitude !== undefined && nodeInfo.position.altitude !== null) {
    nodeData.altitude = nodeInfo.position.altitude;
  }
}

✅ Altitude updates independently of the precision guard.


One-Shot Guard Prevention (meshtasticManager.ts:7940–7944)

positionPrecisionBits, positionChannel, and positionTimestamp are only written inside if (shouldUpdateLatLon) — preventing the one-shot defect where a blocked downgrade would lower the stored precision and allow the next packet to pass.

✅ Guard is persistent, not one-shot.


Migration Idempotency (091_estimated_positions_double_precision.ts:37–46)

Uses information_schema.columns to check the current type before running ALTER COLUMN — robust idempotency without error message string matching:

const { rows } = await client.query(
  `SELECT data_type FROM information_schema.columns WHERE table_name = $1 AND column_name = $2`,
  [TABLE, col]
);
if (currentType === 'double precision') { continue; }

✅ Properly idempotent. Per-column loop means partial success won't block remaining columns.


Schema Change (estimatedPositions.ts:38–41)

latitude: pgDoublePrecision('latitude').notNull(),
longitude: pgDoublePrecision('longitude').notNull(),
uncertaintyKm: pgDoublePrecision('uncertaintyKm'),

✅ Consistent with nodes table. Comment explains the motivation.


Test Coverage

The NodeInfo precision guard (issue #3513) describe block covers:

  • Blocking a downgrade (core case)
  • Equal/higher precision allowed
  • First-write with null coordinates
  • Unknown stored/incoming precision (0, undefined)
  • One-shot prevention verified
  • Independent altitude update verified

The evalGuard helper mirrors the production logic rather than calling processNodeInfoProtobuf directly (which would require a full manager instantiation). This is a known tradeoff — the tests are still meaningful regression guards for the guard logic.


Minor Observations (non-blocking)

meshtasticManager.ts:7915 — The ?? undefined at the end of the precisionBits extraction is redundant but harmless:

const precisionBits = nodeInfo.position.precisionBits ?? nodeInfo.position.precision_bits;
// (previous version had ?? undefined at the end — already cleaned up per commit history)

091_estimated_positions_double_precision.ts:8 — Comment references "#3513" which is the umbrella issue; the migration specifically fixes the PG REAL vs DOUBLE PRECISION secondary concern. Not wrong, just slightly imprecise for issue tracking.


Verdict

Ready to merge. All previous review concerns are resolved. The logic is sound, edge cases are handled, the guard is persistent (not one-shot), altitude is handled correctly, and the migration is idempotent.

@Yeraze Yeraze merged commit 82e4d90 into main Jun 17, 2026
20 checks passed
@Yeraze Yeraze deleted the claude/great-dijkstra-p39q01 branch June 17, 2026 14:42
@Yeraze Yeraze mentioned this pull request Jun 17, 2026
Yeraze added a commit that referenced this pull request Jun 17, 2026
Release candidate for 4.11.0. Bumps the version across all five tracked
locations (package.json, package-lock.json, helm Chart.yaml version+appVersion,
desktop/package.json, desktop/src-tauri/tauri.conf.json).

4.11.0 carries the multi-source route-extraction refactors, the telemetry
ingestion unification (#3506/#3507/#3515), the auto-ping ack/race fix (#3522),
NodeInfo position-precision guard (#3516), backup-route hardening (#3524), and
the Node Details telemetry range selector (#3530).

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.

[BUG] nodes incorrect positions

2 participants