From 2868a3b280ba159f49fe5633b2afef74e49e4bb5 Mon Sep 17 00:00:00 2001 From: RuffR1d3r <76077201+RuffR1d3r@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:40:08 +0200 Subject: [PATCH] fix(web): handle imperial altitude conversion in fixed position Fixes #1051 - Add unitConversion helpers (meters<->feet) with pure functions - Position form now converts display units (feet) to canonical meters on submit and back on display, handling browser location too - Fixes: 1025 ft now correctly stores as 312m and displays as 1024 ft instead of requiring manual meter entry while label says Feet Signed-off-by: RuffR1d3r <76077201+RuffR1d3r@users.noreply.github.com> --- .../PageComponents/Settings/Position.tsx | 28 +++++++++++--- .../web/src/core/utils/unitConversion.test.ts | 38 +++++++++++++++++++ apps/web/src/core/utils/unitConversion.ts | 16 ++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/core/utils/unitConversion.test.ts create mode 100644 apps/web/src/core/utils/unitConversion.ts diff --git a/apps/web/src/components/PageComponents/Settings/Position.tsx b/apps/web/src/components/PageComponents/Settings/Position.tsx index c61674695..63660fa77 100644 --- a/apps/web/src/components/PageComponents/Settings/Position.tsx +++ b/apps/web/src/components/PageComponents/Settings/Position.tsx @@ -16,6 +16,7 @@ import { import { useMyNodeAsProto } from "@core/hooks/useNodesAsProto.ts"; import { useToast } from "@core/hooks/useToast.ts"; import { useDevice } from "@core/stores"; +import { feetToMeters, metersToFeet } from "@core/utils/unitConversion.ts"; import { Protobuf } from "@meshtastic/sdk"; import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; import { LocateFixed } from "lucide-react"; @@ -36,6 +37,10 @@ function UseBrowserLocationButton() { const { setValue } = useFormContext(); const { toast } = useToast(); const { t } = useTranslation("config"); + const { getEffectiveConfig } = useDevice(); + const displayUnits = getEffectiveConfig("display")?.units; + const isImperial = + displayUnits === Protobuf.Config.Config_DisplayConfig_DisplayUnits.IMPERIAL; const [busy, setBusy] = useState(false); if (typeof navigator === "undefined" || !navigator.geolocation) { @@ -57,7 +62,10 @@ function UseBrowserLocationButton() { pos.coords.altitude !== null && !Number.isNaN(pos.coords.altitude) ) { - setValue("altitude", Math.round(pos.coords.altitude), { + const altitudeDisplay = isImperial + ? Math.round(metersToFeet(pos.coords.altitude)) + : Math.round(pos.coords.altitude); + setValue("altitude", altitudeDisplay, { shouldDirty: true, }); } @@ -113,8 +121,14 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { const currentPosition = myNode?.position; const displayUnits = getEffectiveConfig("display")?.units; + const isImperial = + displayUnits === Protobuf.Config.Config_DisplayConfig_DisplayUnits.IMPERIAL; const formValues = useMemo(() => { + const altitudeMeters = currentPosition?.altitude ?? 0; + const altitudeDisplay = isImperial + ? Math.round(metersToFeet(altitudeMeters)) + : altitudeMeters; return { ...config.position, ...effectivePosition, @@ -124,9 +138,9 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { longitude: currentPosition?.longitudeI ? currentPosition.longitudeI / 1e7 : undefined, - altitude: currentPosition?.altitude ?? 0, + altitude: altitudeDisplay, } as PositionValidation; - }, [config.position, effectivePosition, currentPosition]); + }, [config.position, effectivePosition, currentPosition, isImperial]); const onSubmit = (data: PositionValidation) => { const { @@ -149,13 +163,17 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { data.latitude !== undefined && data.longitude !== undefined ) { + const altitudeMeters = + data.altitude != null + ? Math.round(isImperial ? feetToMeters(data.altitude) : data.altitude) + : 0; const message = create(Protobuf.Admin.AdminMessageSchema, { payloadVariant: { case: "setFixedPosition", value: create(Protobuf.Mesh.PositionSchema, { latitudeI: Math.round(data.latitude * 1e7), longitudeI: Math.round(data.longitude * 1e7), - altitude: data.altitude || 0, + altitude: altitudeMeters, time: Math.floor(Date.now() / 1000), }), }, @@ -264,7 +282,7 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { : "Meters", }), properties: { - step: 0.0000001, + step: 1, suffix: displayUnits === Protobuf.Config.Config_DisplayConfig_DisplayUnits.IMPERIAL diff --git a/apps/web/src/core/utils/unitConversion.test.ts b/apps/web/src/core/utils/unitConversion.test.ts new file mode 100644 index 000000000..8652d5523 --- /dev/null +++ b/apps/web/src/core/utils/unitConversion.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { feetToMeters, metersToFeet } from "./unitConversion.ts"; + +describe("unitConversion", () => { + it("converts 1 meter to feet", () => { + expect(metersToFeet(1)).toBeCloseTo(3.28084, 5); + }); + + it("converts 1 foot to meters", () => { + expect(feetToMeters(1)).toBeCloseTo(0.3048, 5); + }); + + it("round-trips 312m ↔ 1024ft (issue #1051 case)", () => { + // 312m displayed as feet should be ~1024 ft + expect(Math.round(metersToFeet(312))).toBe(1024); + // 1025 ft stored as meters should be ~312m + expect(Math.round(feetToMeters(1025))).toBe(312); + }); + + it("round-trips within 1 ft for integer meters (firmware int32 loss)", () => { + for (const m of [0, 100, 312, 1000]) { + const ft = Math.round(metersToFeet(m)); + const m2 = Math.round(feetToMeters(ft)); + expect(Math.abs(m - m2)).toBeLessThanOrEqual(1); + } + }); + + it("handles zero", () => { + expect(metersToFeet(0)).toBe(0); + expect(feetToMeters(0)).toBe(0); + }); + + it("handles negative (below sea level, e.g., Dead Sea)", () => { + expect(metersToFeet(-430)).toBeCloseTo(-1410.76, 1); + expect(feetToMeters(-1410)).toBeCloseTo(-430, 0); + }); +}); diff --git a/apps/web/src/core/utils/unitConversion.ts b/apps/web/src/core/utils/unitConversion.ts new file mode 100644 index 000000000..21227d1a1 --- /dev/null +++ b/apps/web/src/core/utils/unitConversion.ts @@ -0,0 +1,16 @@ +/** + * Altitude unit conversion — pure functions, canonical is meters + * (firmware Position.altitude int32 meters per mesh_pb.ts). + * Display in feet when DisplayUnits is IMPERIAL. + */ + +export const FEET_PER_METER = 3.28084; +export const METERS_PER_FOOT = 0.3048; + +export function metersToFeet(meters: number): number { + return meters * FEET_PER_METER; +} + +export function feetToMeters(feet: number): number { + return feet * METERS_PER_FOOT; +}