diff --git a/apps/web/public/i18n/locales/en/common.json b/apps/web/public/i18n/locales/en/common.json index 49c01e22d..47f8f4689 100644 --- a/apps/web/public/i18n/locales/en/common.json +++ b/apps/web/public/i18n/locales/en/common.json @@ -57,6 +57,7 @@ "kilohertz": "kHz", "raw": "raw", "meter": { "one": "Meter", "plural": "Meters", "suffix": "m" }, + "foot": { "one": "Foot", "plural": "Feet", "suffix": "ft" }, "kilometer": { "one": "Kilometer", "plural": "Kilometers", "suffix": "km" }, "minute": { "one": "Minute", "plural": "Minutes" }, "hour": { "one": "Hour", "plural": "Hours" }, diff --git a/apps/web/src/components/PageComponents/Settings/Position.test.tsx b/apps/web/src/components/PageComponents/Settings/Position.test.tsx new file mode 100644 index 000000000..f32fef73f --- /dev/null +++ b/apps/web/src/components/PageComponents/Settings/Position.test.tsx @@ -0,0 +1,247 @@ +import { create } from "@bufbuild/protobuf"; +import { Protobuf } from "@meshtastic/sdk"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Position } from "./Position.tsx"; + +// Integration/component test for meshtastic/web#1051. +// +// The fixed-position altitude field is labelled in Feet when the display units +// are Imperial, but the firmware/protobuf store altitude as integer meters. +// This test renders the real form (DynamicForm + react-hook-form + +// the zod resolver) and asserts the conversion happens at the edges: +// - form load (meters -> feet for display when Imperial) +// - form submit (feet -> meters into the queued setFixedPosition admin msg) +// - browser geolocation fill (meters -> feet for display when Imperial) +// +// The only external seam we assert on is the admin message handed to the +// config editor's queueAdminMessage(), which is exactly what the firmware +// receives. + +// --- Mutable per-test state (read lazily by the mock factories below) -------- +// Names are prefixed with `mock` so vitest's vi.mock hoisting whitelist allows +// the factories to reference them. +let mockDisplayUnits: Protobuf.Config.Config_DisplayConfig_DisplayUnits; +let mockPositionConfig: Protobuf.Config.Config_PositionConfig; +let mockMyNode: Protobuf.Mesh.NodeInfo | undefined; + +const mockSetRadioSection = vi.fn(); +const mockQueueAdminMessage = vi.fn(); +const mockToast = vi.fn(); +const mockGetCurrentPosition = vi.fn(); + +// A minimal signal-shaped object; the mocked useSignal just returns `.value`. +const mockEditor = { + radio: { value: {}, peek: () => ({}), subscribe: () => () => {} }, + setRadioSection: mockSetRadioSection, + queueAdminMessage: mockQueueAdminMessage, +}; + +const mockGetEffectiveConfig = (configCase: string) => { + if (configCase === "display") { + return { units: mockDisplayUnits }; + } + if (configCase === "position") { + return mockPositionConfig; + } + return undefined; +}; + +// useWaitForConfig throws a suspense promise until config is present; the render +// path here supplies config directly, so it is a no-op in tests. +vi.mock("@app/core/hooks/useWaitForConfig", () => ({ + useWaitForConfig: () => {}, +})); + +vi.mock("@core/stores", () => ({ + useDevice: () => ({ + config: { position: mockPositionConfig }, + getEffectiveConfig: mockGetEffectiveConfig, + }), +})); + +vi.mock("@meshtastic/sdk-react", () => ({ + useConfigEditor: () => mockEditor, + useSignal: (signal: { value?: unknown }) => signal?.value ?? {}, +})); + +vi.mock("@core/hooks/useNodesAsProto.ts", () => ({ + useMyNodeAsProto: () => mockMyNode, +})); + +vi.mock("@core/hooks/useToast.ts", () => ({ + useToast: () => ({ toast: mockToast }), +})); + +// --- Helpers ----------------------------------------------------------------- +const IMPERIAL = Protobuf.Config.Config_DisplayConfig_DisplayUnits.IMPERIAL; +const METRIC = Protobuf.Config.Config_DisplayConfig_DisplayUnits.METRIC; + +// San Francisco, so the fixed-position submit path (lat/lng defined) is taken. +const LATITUDE_I = 377_749_000; +const LONGITUDE_I = -1_224_194_000; + +interface SetupOptions { + units: Protobuf.Config.Config_DisplayConfig_DisplayUnits; + altitudeMeters: number; +} + +function setup({ units, altitudeMeters }: SetupOptions) { + mockDisplayUnits = units; + // A full, valid PositionConfig (defaults for every schema field) with a + // fixed position enabled so the altitude field is editable and the submit + // takes the setFixedPosition path. + mockPositionConfig = create(Protobuf.Config.Config_PositionConfigSchema, { + fixedPosition: true, + }); + mockMyNode = create(Protobuf.Mesh.NodeInfoSchema, { + num: 1, + position: create(Protobuf.Mesh.PositionSchema, { + latitudeI: LATITUDE_I, + longitudeI: LONGITUDE_I, + altitude: altitudeMeters, + }), + }); +} + +function renderPosition() { + return render( {}} />); +} + +function altitudeInput(container: HTMLElement): HTMLInputElement { + const input = container.querySelector("#altitude"); + if (!input) { + throw new Error("altitude field not found"); + } + return input; +} + +function lastQueuedPosition(): Protobuf.Mesh.Position { + const message = mockQueueAdminMessage.mock.calls.at( + -1, + )?.[0] as Protobuf.Admin.AdminMessage; + expect(message.payloadVariant.case).toBe("setFixedPosition"); + return message.payloadVariant.value as Protobuf.Mesh.Position; +} + +describe("Position (altitude unit conversion, issue #1051)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + // Remove any geolocation override so the button does not leak into other + // tests (the component renders it only when navigator.geolocation exists). + if ("geolocation" in navigator) { + Reflect.deleteProperty(navigator, "geolocation"); + } + }); + + it("Imperial: loads a 312 m altitude as ~1024 ft in the field", () => { + setup({ units: IMPERIAL, altitudeMeters: 312 }); + const { container } = renderPosition(); + + // metersToFeet(312) = 1023.62 -> rounded 1024 + expect(altitudeInput(container).value).toBe("1024"); + // The field is labelled in Feet, not Meters. + expect(screen.getByText("Feet")).toBeInTheDocument(); + expect(screen.queryByText("Meters")).not.toBeInTheDocument(); + }); + + it("Imperial: submitting 1025 ft queues setFixedPosition with altitude 312 m", async () => { + setup({ units: IMPERIAL, altitudeMeters: 312 }); + const { container } = renderPosition(); + + // DynamicForm submits on change (submitType defaults to "onChange"). + fireEvent.change(altitudeInput(container), { target: { value: "1025" } }); + + await waitFor(() => expect(mockQueueAdminMessage).toHaveBeenCalled()); + + const position = lastQueuedPosition(); + // feetToMeters(1025) = 312.42 -> rounded 312 (int32 meters), NOT 1025. + expect(position.altitude).toBe(312); + // Lat/lng round-trip untouched (still integer degrees * 1e7). + expect(position.latitudeI).toBe(LATITUDE_I); + expect(position.longitudeI).toBe(LONGITUDE_I); + }); + + it("Metric: submitting 312 queues setFixedPosition with altitude 312 m unchanged", async () => { + // Start from a different metric altitude so the change event fires. + setup({ units: METRIC, altitudeMeters: 100 }); + const { container } = renderPosition(); + + // Metric field shows raw meters, no conversion. + expect(altitudeInput(container).value).toBe("100"); + expect(screen.getByText("Meters")).toBeInTheDocument(); + + fireEvent.change(altitudeInput(container), { target: { value: "312" } }); + + await waitFor(() => expect(mockQueueAdminMessage).toHaveBeenCalled()); + + expect(lastQueuedPosition().altitude).toBe(312); + }); + + it("Imperial: browser geolocation fills the field in feet (100 m -> 328 ft)", async () => { + setup({ units: IMPERIAL, altitudeMeters: 312 }); + + // Geolocation reports meters; component must convert to feet for display. + mockGetCurrentPosition.mockImplementation((success: PositionCallback) => + success({ + coords: { + latitude: 37.7749, + longitude: -122.4194, + altitude: 100, + accuracy: 5, + altitudeAccuracy: null, + heading: null, + speed: null, + }, + } as GeolocationPosition), + ); + Object.defineProperty(navigator, "geolocation", { + configurable: true, + value: { getCurrentPosition: mockGetCurrentPosition }, + }); + + const { container } = renderPosition(); + + // Sanity: starts at the loaded 312 m -> 1024 ft. + expect(altitudeInput(container).value).toBe("1024"); + + fireEvent.click( + screen.getByRole("button", { name: /use browser location/i }), + ); + + // metersToFeet(100) = 328.08 -> rounded 328 + await waitFor(() => expect(altitudeInput(container).value).toBe("328")); + expect(mockGetCurrentPosition).toHaveBeenCalledTimes(1); + }); + + it("clearing the altitude field does not queue a setFixedPosition", async () => { + // Regression coverage requested by CodeRabbit. Clearing a number field + // yields NaN (FormInput does Number.parseFloat("").toString() === "NaN"), + // which fails altitude's `z.coerce.number().optional()` validation. Because + // DynamicForm submits via handleSubmit(onSubmit) on change, an invalid form + // never calls onSubmit, so nothing is queued. The `Number.isFinite(...) ? ... + // : 0` guard in onSubmit is thus defensive and unreachable through the form. + setup({ units: METRIC, altitudeMeters: 100 }); + const { container } = renderPosition(); + + // Baseline: a valid edit queues exactly one setFixedPosition, proving the + // form is wired up and submits on change. + fireEvent.change(altitudeInput(container), { target: { value: "150" } }); + await waitFor(() => expect(mockQueueAdminMessage).toHaveBeenCalledTimes(1)); + expect(lastQueuedPosition().altitude).toBe(150); + + // Clear the field -> NaN -> invalid -> must NOT queue anything. + fireEvent.change(altitudeInput(container), { target: { value: "" } }); + + // Recover with another valid edit and synchronize on its value landing. + // Then assert exactly two queues total (150 then 200): if the clear had + // queued, the count would be three. Synchronizing on the settled value + // (not a bare negative assertion) keeps this non-racy. + fireEvent.change(altitudeInput(container), { target: { value: "200" } }); + await waitFor(() => expect(lastQueuedPosition().altitude).toBe(200)); + expect(mockQueueAdminMessage).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/components/PageComponents/Settings/Position.tsx b/apps/web/src/components/PageComponents/Settings/Position.tsx index c61674695..8f466468f 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/geo.ts"; import { Protobuf } from "@meshtastic/sdk"; import { useConfigEditor, useSignal } from "@meshtastic/sdk-react"; import { LocateFixed } from "lucide-react"; @@ -32,7 +33,7 @@ interface PositionConfigProps { * via navigator.geolocation and writes lat/lng/altitude into the form. * No-op without a geolocation API (e.g. insecure context). */ -function UseBrowserLocationButton() { +function UseBrowserLocationButton({ isImperial }: { isImperial: boolean }) { const { setValue } = useFormContext(); const { toast } = useToast(); const { t } = useTranslation("config"); @@ -57,9 +58,14 @@ function UseBrowserLocationButton() { pos.coords.altitude !== null && !Number.isNaN(pos.coords.altitude) ) { - setValue("altitude", Math.round(pos.coords.altitude), { - shouldDirty: true, - }); + // Geolocation altitude is in meters; show feet when Imperial. + setValue( + "altitude", + isImperial + ? Math.round(metersToFeet(pos.coords.altitude)) + : Math.round(pos.coords.altitude), + { shouldDirty: true }, + ); } }, (err) => { @@ -113,8 +119,12 @@ 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(() => { + // Firmware stores altitude in meters; the field shows feet when Imperial. + const altitudeMeters = currentPosition?.altitude ?? 0; return { ...config.position, ...effectivePosition, @@ -124,9 +134,11 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { longitude: currentPosition?.longitudeI ? currentPosition.longitudeI / 1e7 : undefined, - altitude: currentPosition?.altitude ?? 0, + altitude: isImperial + ? Math.round(metersToFeet(altitudeMeters)) + : altitudeMeters, } as PositionValidation; - }, [config.position, effectivePosition, currentPosition]); + }, [config.position, effectivePosition, currentPosition, isImperial]); const onSubmit = (data: PositionValidation) => { const { @@ -149,13 +161,24 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { data.latitude !== undefined && data.longitude !== undefined ) { + // The altitude field is in feet when Imperial; the protobuf expects + // integer meters, so convert back and round. Guard against a + // cleared/NaN field. + const altitudeInput = + typeof data.altitude === "number" && Number.isFinite(data.altitude) + ? data.altitude + : 0; + const altitude = isImperial + ? Math.round(feetToMeters(altitudeInput)) + : Math.round(altitudeInput); + 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, time: Math.floor(Date.now() / 1000), }), }, @@ -220,7 +243,7 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { { label: t("position.deviceGps.label"), description: t("position.deviceGps.description"), - footer: , + footer: , fields: [ { type: "toggle", @@ -257,19 +280,15 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { name: "altitude", label: t("position.fixedPosition.altitude.label"), description: t("position.fixedPosition.altitude.description", { - unit: - displayUnits === - Protobuf.Config.Config_DisplayConfig_DisplayUnits.IMPERIAL - ? "Feet" - : "Meters", + unit: isImperial + ? t("unit.foot.plural") + : t("unit.meter.plural"), }), properties: { step: 0.0000001, - suffix: - displayUnits === - Protobuf.Config.Config_DisplayConfig_DisplayUnits.IMPERIAL - ? "Feet" - : "Meters", + suffix: isImperial + ? t("unit.foot.plural") + : t("unit.meter.plural"), }, disabledBy: [{ fieldName: "fixedPosition" }], }, diff --git a/apps/web/src/core/utils/geo.test.ts b/apps/web/src/core/utils/geo.test.ts new file mode 100644 index 000000000..41773fe65 --- /dev/null +++ b/apps/web/src/core/utils/geo.test.ts @@ -0,0 +1,35 @@ +import { + feetToMeters, + METERS_PER_FOOT, + metersToFeet, +} from "@core/utils/geo.ts"; +import { describe, expect, it } from "vitest"; + +// Regression test for meshtastic/web#1051: +// The fixed-position altitude field is labelled "Feet" when the display units +// are Imperial, but the firmware stores altitude as an integer number of +// meters. The value entered in feet must be converted to meters before it is +// written to the protobuf. +describe("altitude unit conversion (geo)", () => { + it("uses the exact meters-per-foot factor", () => { + expect(METERS_PER_FOOT).toBe(0.3048); + }); + + it("converts feet to meters", () => { + expect(feetToMeters(1025)).toBeCloseTo(312.42, 2); + expect(feetToMeters(0)).toBe(0); + }); + + it("converts meters to feet", () => { + expect(metersToFeet(312)).toBeCloseTo(1023.62, 2); + }); + + it("stores 1025 ft as 312 m (rounded to int32 meters) — issue #1051", () => { + // A user entering 1025 in the feet-labelled field must not store 1025 m. + expect(Math.round(feetToMeters(1025))).toBe(312); + }); + + it("round-trips back to the ~1024 ft the reporter observed", () => { + expect(Math.round(metersToFeet(312))).toBe(1024); + }); +}); diff --git a/apps/web/src/core/utils/geo.ts b/apps/web/src/core/utils/geo.ts index a3017bd94..1439e7a3d 100644 --- a/apps/web/src/core/utils/geo.ts +++ b/apps/web/src/core/utils/geo.ts @@ -7,6 +7,19 @@ export type Bounds = [[number, number], [number, number]]; const INT_DEG = 1e7; const EARTH_RADIUS = 6378137; +/** + * Firmware/protobuf store altitude as an integer number of meters + * (mesh.proto: `optional int32 altitude = 3`). The Position config UI shows + * altitude in feet when the display units are set to Imperial, so we convert + * at the edges (form load / form submit). + */ +export const METERS_PER_FOOT = 0.3048; + +export const feetToMeters = (feet: number): number => feet * METERS_PER_FOOT; + +export const metersToFeet = (meters: number): number => + meters / METERS_PER_FOOT; + export const toLngLat = (position?: { latitudeI?: number; longitudeI?: number;