Skip to content

fix(position): ground_speed is km/h, not m/s — fixes ~3.6× speed inflation (#3797)#3800

Merged
Yeraze merged 3 commits into
mainfrom
fix/ground-speed-kmh-not-ms
Jun 26, 2026
Merged

fix(position): ground_speed is km/h, not m/s — fixes ~3.6× speed inflation (#3797)#3800
Yeraze merged 3 commits into
mainfrom
fix/ground-speed-kmh-not-ms

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 26, 2026

Copy link
Copy Markdown
Owner

Summary

Position speeds (history popups, node popups, telemetry) were over-reported by ~3.6×: a node reporting ground_speed = 90 displayed as 324 km/h. The root cause is a unit mismatch — Meshtastic's ground_speed is km/h on the wire, but MeshMonitor treated it as m/s and multiplied by 3.6.

Verification — the field is km/h, not m/s

The protobuf comment says m/s, but that comment is wrong; the firmware transmits km/h. The GPS path sets the field in exactly one place, using the TinyGPS++ km/h accessor:

// src/gps/GPS.cpp — GPS::lookForLocation()
p.ground_speed = reader.speed.kmph();   // TinyGPS++ .kmph() → km/h

https://github.com/meshtastic/firmware/blob/a4b2021782d3e323b8fdc6da166f4305c53c6181/src/gps/GPS.cpp#L1824

PositionModule copies it through with no conversion:
https://github.com/meshtastic/firmware/blob/a4b2021782d3e323b8fdc6da166f4305c53c6181/src/modules/PositionModule.cpp#L260

So 90 on the wire = 90 km/h. Our convertSpeed() did value * 3.6 (m/s→km/h) ⇒ 324 km/h.

The original ticket guessed the field is cm/s (~100× error). That's also incorrect: the real factor is 3.6× and the unit is km/h. The ticket's proposed ÷100 fix would wrongly render a genuine 90 km/h as 3.2 km/h. I've also opened an upstream doc fix: meshtastic/protobufs#963.

Changes

  • src/utils/speedConversion.tsconvertSpeed() now treats its input as km/h: metric passes through, imperial applies only the km/h→mph factor (drops the bogus ×3.6).
  • src/server/meshtasticManager.ts (×2) — stored ground_speed telemetry unit relabeled m/skm/h (the value was always km/h, so existing rows display correctly via convertSpeedno migration needed).
  • src/pages/UnifiedTelemetryPage.tsx — telemetry unit label m/skm/h.
  • src/utils/mapHelpers.tsx, src/contexts/MapContext.tsx — corrected misleading "m/s" comments.
  • src/utils/mapHelpers.test.tsx — rewrote the convertSpeed suite for km/h semantics + a [BUG] Position history velocity inflated ~100× — ground_speed protobuf is cm/s, not m/s #3797 regression (90 → 90, not 324).

Issues Resolved

Fixes #3797. Relates to #3791 (Bug 3, inflated position-segment velocity).

Documentation Updates

  • CHANGELOG.md — Fixed entry under [Unreleased].

Testing

  • Unit tests pass (full suite: 7660 passed, 0 failed, 0 suite failures)
  • TypeScript compiles cleanly (no new errors in changed files)
  • convertSpeed regression: 90 → 90 km/h (metric), 100 → 62.1 mph (imperial), 0 → 0
  • Reviewer: open a node's position history, click a fix — Speed should read a sane value (raw wire value as km/h), not ~3.6× inflated

🤖 Generated with Claude Code

Yeraze and others added 2 commits June 26, 2026 15:40
…ation (#3797)

Meshtastic's Position.ground_speed is km/h on the wire: the firmware writes it
from TinyGPS++ reader.speed.kmph() (GPS::lookForLocation), despite the
mesh.proto comment saying "m/s". MeshMonitor treated it as m/s and multiplied
by 3.6 in convertSpeed(), inflating every reported speed ~3.6x — a wire value
of 90 displayed as 324 km/h.

(The ticket's premise that the field is cm/s / ~100x is also wrong; the real
factor is 3.6x and the unit is km/h. Verified against the firmware GPS path.)

- convertSpeed() now treats its input as km/h: metric passes through, imperial
  applies only the km/h->mph factor (drops the bogus x3.6).
- Stored ground_speed telemetry unit relabeled 'm/s' -> 'km/h' (the value was
  always km/h, so legacy rows display correctly via convertSpeed — no migration).
- Telemetry page unit label and code comments corrected.
- convertSpeed tests rewritten for km/h semantics + a #3797 regression (90 -> 90).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011JEaCGwY9Wz8jeV4e22GW4
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 26, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: fix(position): ground_speed is km/h, not m/s

  • Read changed files
  • Analyze code quality and logic
  • Check test coverage
  • Post review feedback

This is a clean, well-researched fix. The root cause analysis (tracing to GPS.cpp line 1824 and reader.speed.kmph()) is compelling, and the PR correctly identifies that the previous ×3.6 multiplier was inverting the wrong assumption. Below is my review.


Core fix — src/utils/speedConversion.ts

Excellent. The new implementation is minimal and correct:

export function convertSpeed(kilometersPerHour: number, distanceUnit: string): { speed: number; unit: string } {
  const speed = distanceUnit === 'mi' ? kilometersPerHour * 0.621371 : kilometersPerHour;
  const unit = distanceUnit === 'mi' ? 'mph' : 'km/h';
  return { speed: parseFloat(speed.toFixed(1)), unit };
}

Minor observation: The parameter is now named kilometersPerHour, which is semantically accurate and self-documenting — no issues here. The 0.621371 constant is the standard km/h→mph conversion factor.

Potential edge case (low priority): convertSpeed doesn't guard against negative values. ground_speed should always be ≥ 0, but if a corrupted packet delivers a negative value, the function will return a negative speed. The existing groundSpeed > 0 guards in meshtasticManager.ts (lines 6245, 8242) already filter out zeroes before storage, so this isn't a bug in the current flow — just something to be aware of if convertSpeed is ever called from another path.


src/server/meshtasticManager.ts (lines 6243, 8245)

Both telemetry storage sites correctly update the unit label to 'km/h'. The comment at line 6242–6243 is clear and accurate. No issues.


src/utils/mapHelpers.tsx (line 447–448) and src/contexts/MapContext.tsx (line 11)

Comments and interface JSDoc are updated to reflect the correct unit. The PositionHistoryItem.groundSpeed field is now documented as // km/h (firmware emits ground_speed via TinyGPS++ .kmph(); see #3797). Good.

One minor note: generateHeadingAwarePath at line 327 still has @param speed Ground speed in m/s (affects control point distance) in its JSDoc, which wasn't changed in this PR. That parameter comment is now misleading since groundSpeed passed in is in km/h. Worth a follow-up (not a blocker).


src/pages/UnifiedTelemetryPage.tsx (line 207)

TYPE_UNITS['ground_speed'] changed from 'm/s' to 'km/h'. Correct — this is the display-only unit label on the telemetry card, and the stored value (the raw wire km/h) now has an accurate label. No issues.


src/utils/mapHelpers.test.tsx — test coverage

The convertSpeed suite is well-written:

  • Pass-through for metric ✓
  • km/h→mph conversion with tolerance ✓
  • Zero speed ✓
  • Regression test explicitly naming #3797 and asserting 90 → 90
  • High speed (highway) ✓
  • Walking speed ✓
  • Metric/imperial consistency ✓

One suggestion: Consider adding a test for convertSpeed with an unrecognized distanceUnit value (e.g., 'ft' or an empty string). Currently it falls through to the kilometersPerHour pass-through (since the only branch is === 'mi'), which is fine, but an explicit test would document that contract.


No migration needed — correct

Existing ground_speed rows already store the raw wire value (km/h). Since the buggy ×3.6 was applied at display time (in convertSpeed), the stored values are correct and no backfill is required. The PR's reasoning here is sound.


Summary

Area Status
Root cause analysis ✅ Thorough, backed by firmware source
convertSpeed logic ✅ Correct
Storage unit labels ✅ Fixed
Display unit labels ✅ Fixed
Test coverage ✅ Good, with regression test
Migration impact ✅ None needed
generateHeadingAwarePath JSDoc @param speed ⚠️ Still says "m/s" — minor follow-up

Recommend merging. The JSDoc @param speed in generateHeadingAwarePath is a nit worth fixing in a follow-up; it doesn't affect behavior.


…3797 review)

Addresses Claude review note: the @param speed JSDoc still said m/s. The value
passed (item.groundSpeed) has always been the raw km/h wire value, so this is a
doc-only correction with no behavior change.

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

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: fix(position): ground_speed is km/h, not m/s

  • Read changed files
  • Analyze code quality and logic
  • Check test coverage
  • Post review feedback

This is a well-researched, clean fix. The root cause analysis (tracing to GPS.cpp line 1824 and reader.speed.kmph()) is compelling and the previous ×3.6 multiplier was inverting the wrong assumption. The JSDoc nit from the previous review has been resolvedgenerateHeadingAwarePath at line 327 now correctly says "Ground speed in km/h (affects control point distance; see #3797)".


Core fix — src/utils/speedConversion.ts

Correct and minimal. The implementation is self-documenting and the parameter rename to kilometersPerHour makes the contract explicit:

export function convertSpeed(kilometersPerHour: number, distanceUnit: string): { speed: number; unit: string } {
  const speed = distanceUnit === 'mi' ? kilometersPerHour * 0.621371 : kilometersPerHour;
  const unit = distanceUnit === 'mi' ? 'mph' : 'km/h';
  return { speed: parseFloat(speed.toFixed(1)), unit };
}

Minor observation (non-blocking): convertSpeed has no guard against negative values. ground_speed should always be ≥ 0 from the firmware, and the existing groundSpeed > 0 checks in meshtasticManager.ts filter out zeroes at storage time. No action needed in this PR — just noting it for future callers.


src/server/meshtasticManager.ts

Both telemetry storage sites correctly update the unit label to 'km/h'. No issues.


src/contexts/MapContext.tsx (line 11) and src/utils/mapHelpers.tsx (line 327)

  • PositionHistoryItem.groundSpeed JSDoc updated: // km/h (firmware emits ground_speed via TinyGPS++ .kmph(); see #3797)
  • generateHeadingAwarePath @param speed updated: Ground speed in km/h (affects control point distance; see #3797)

The JSDoc nit flagged in the previous review is now resolved. No issues.


src/pages/UnifiedTelemetryPage.tsx

TYPE_UNITS['ground_speed'] changed from 'm/s' to 'km/h'. Correct — the stored value was always km/h; this just fixes the display label. No issues.


Test coverage — src/utils/mapHelpers.test.tsx

The convertSpeed suite (lines 317–366) is thorough:

Test Status
Metric pass-through (36 → 36 km/h)
km/h → mph conversion (100 → 62.1)
Zero speed
Regression #3797 (90 → 90, not 324)
High speed / highway
Walking speed
Metric/imperial consistency

One optional addition (non-blocking): An explicit test for an unrecognized distanceUnit (e.g. '' or 'ft') would document that it falls through to the metric pass-through. The behavior is correct today since the only branch is === 'mi', but a test would pin the contract.


No migration needed — correct

The bug was entirely at display time: convertSpeed applied ×3.6 at render. Stored values are the raw wire km/h values and are correct. No backfill required.


Summary

Area Status
Root cause analysis ✅ Thorough, backed by firmware source
convertSpeed logic ✅ Correct
Storage unit labels ✅ Fixed
Display unit labels ✅ Fixed
generateHeadingAwarePath JSDoc ✅ Fixed (resolved from previous review)
Test coverage ✅ Good, with regression test
Migration impact ✅ None needed

Recommend merging. All previous review nits have been addressed.


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] Position history velocity inflated ~100× — ground_speed protobuf is cm/s, not m/s

1 participant