Skip to content

Commit 9b80d5e

Browse files
committed
feat: add fixed position coordinate picker with map interface
- Added new FixedPositionPicker component with interactive map and coordinate inputs - Enhanced position settings UI to allow manual coordinate entry and map-based selection - Added success/error toast notifications for position updates - Updated translations to include new fixed position coordinate picker labels and messages - Added support for requesting current position updates from device - Fixed queue status logging to properly stringify
1 parent f375911 commit 9b80d5e

File tree

7 files changed

+306
-10
lines changed

7 files changed

+306
-10
lines changed

packages/core/src/utils/transform/decodePacket.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ export const decodePacket = (device: MeshDevice) =>
194194
case "queueStatus": {
195195
device.log.trace(
196196
Types.Emitter[Types.Emitter.HandleFromRadio],
197-
`🚧 Received Queue Status: ${decodedMessage.payloadVariant.value}`,
197+
`🚧 Received Queue Status: ${JSON.stringify(decodedMessage.payloadVariant.value)}`,
198198
);
199199

200200
device.events.onQueueStatus.dispatch(

packages/web/public/i18n/locales/en/config.json

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,42 @@
283283
},
284284
"fixedPosition": {
285285
"description": "Don't report GPS position, but a manually-specified one",
286-
"label": "Fixed Position"
286+
"label": "Fixed Position",
287+
"coordinates": {
288+
"title": "Fixed Position Coordinates",
289+
"description": "Manually specify the coordinates for your fixed position. These coordinates will be broadcast instead of GPS data."
290+
},
291+
"latitude": {
292+
"label": "Latitude",
293+
"description": "Decimal degrees (e.g., 37.7749)"
294+
},
295+
"longitude": {
296+
"label": "Longitude",
297+
"description": "Decimal degrees (e.g., -122.4194)"
298+
},
299+
"altitude": {
300+
"label": "Altitude",
301+
"description": "Meters above sea level (optional)"
302+
},
303+
"map": {
304+
"hint": "Click on the map to set coordinates, or enter them manually below",
305+
"accuracy": "Note: Device position may be slightly offset due to firmware rounding or GPS interference. For best accuracy, disable GPS mode."
306+
},
307+
"setButton": "Set Fixed Position",
308+
"requestButton": "Request Position Update",
309+
"requestSent": {
310+
"title": "Position Update Requested",
311+
"description": "The device will broadcast its current position shortly."
312+
},
313+
"error": {
314+
"title": "Invalid Coordinates",
315+
"invalidCoordinates": "Please enter valid latitude and longitude values.",
316+
"notEnabled": "Please enable the 'Fixed Position' toggle first, then save the configuration before setting coordinates."
317+
},
318+
"success": {
319+
"title": "Fixed Position Set",
320+
"description": "Your fixed position coordinates have been sent to the device."
321+
}
287322
},
288323
"gpsMode": {
289324
"description": "Configure whether device GPS is Enabled, Disabled, or Not Present",

packages/web/src/components/Form/DynamicForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export interface BaseFormBuilderProps<T> {
3333
disabled?: boolean;
3434
disabledBy?: DisabledBy<T>[];
3535
label: string;
36-
description?: string;
36+
description?: React.ReactNode;
3737
notes?: string;
3838
validationText?: string;
3939
properties?: Record<string, unknown>;

packages/web/src/components/Form/FormWrapper.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Label } from "@components/UI/Label.tsx";
33
export interface FieldWrapperProps {
44
label: string;
55
fieldName: string;
6-
description?: string;
6+
description?: React.ReactNode;
77
disabled?: boolean;
88
children?: React.ReactNode;
99
valid?: boolean;

packages/web/src/components/Map.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ interface MapProps {
1515
onMouseMove?: (event: MapLayerMouseEvent) => void;
1616
onClick?: (event: MapLayerMouseEvent) => void;
1717
interactiveLayerIds?: string[];
18+
initialViewState?: {
19+
latitude?: number;
20+
longitude?: number;
21+
zoom?: number;
22+
};
1823
}
1924

2025
export const BaseMap = ({
@@ -23,6 +28,7 @@ export const BaseMap = ({
2328
onClick,
2429
onMouseMove,
2530
interactiveLayerIds,
31+
initialViewState,
2632
}: MapProps) => {
2733
const { theme } = useTheme();
2834
const { t } = useTranslation("map");
@@ -67,7 +73,7 @@ export const BaseMap = ({
6773
maxPitch={0}
6874
dragRotate={false}
6975
touchZoomRotate={false}
70-
initialViewState={{
76+
initialViewState={initialViewState ?? {
7177
zoom: 1.8,
7278
latitude: 35,
7379
longitude: 0,
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { Button } from "@components/UI/Button.tsx";
2+
import { Input } from "@components/UI/Input.tsx";
3+
import { BaseMap } from "@components/Map.tsx";
4+
import { Marker } from "react-map-gl/maplibre";
5+
import { MapPin } from "lucide-react";
6+
import { useCallback, useState } from "react";
7+
import { useTranslation } from "react-i18next";
8+
import { create } from "@bufbuild/protobuf";
9+
import { Protobuf } from "@meshtastic/core";
10+
import { useToast } from "@core/hooks/useToast.ts";
11+
12+
interface FixedPositionPickerProps {
13+
currentPosition?: {
14+
latitudeI?: number;
15+
longitudeI?: number;
16+
altitude?: number;
17+
};
18+
isEnabled: boolean;
19+
onSetPosition: (message: Protobuf.Admin.AdminMessage) => void;
20+
onRequestUpdate: (message: Protobuf.Admin.AdminMessage) => void;
21+
}
22+
23+
export const FixedPositionPicker = ({
24+
currentPosition,
25+
isEnabled,
26+
onSetPosition,
27+
onRequestUpdate,
28+
}: FixedPositionPickerProps) => {
29+
const { t } = useTranslation("config");
30+
const { toast } = useToast();
31+
32+
// State for fixed position inputs (in degrees, not integer format)
33+
const [latitude, setLatitude] = useState<string>(
34+
currentPosition?.latitudeI ? String(currentPosition.latitudeI / 1e7) : ""
35+
);
36+
const [longitude, setLongitude] = useState<string>(
37+
currentPosition?.longitudeI ? String(currentPosition.longitudeI / 1e7) : ""
38+
);
39+
const [altitude, setAltitude] = useState<string>(
40+
currentPosition?.altitude ? String(currentPosition.altitude) : ""
41+
);
42+
43+
const handleMapClick = useCallback((event: any) => {
44+
const { lng, lat } = event.lngLat;
45+
setLatitude(lat.toFixed(7));
46+
setLongitude(lng.toFixed(7));
47+
}, []);
48+
49+
const handleRequestPosition = useCallback(() => {
50+
const message = create(Protobuf.Admin.AdminMessageSchema, {
51+
payloadVariant: {
52+
case: "getOwnerRequest",
53+
value: true,
54+
},
55+
});
56+
57+
onRequestUpdate(message);
58+
59+
toast({
60+
title: t("position.fixedPosition.requestSent.title"),
61+
description: t("position.fixedPosition.requestSent.description"),
62+
});
63+
}, [onRequestUpdate, toast, t]);
64+
65+
const handleSetFixedPosition = useCallback(() => {
66+
const lat = parseFloat(latitude);
67+
const lon = parseFloat(longitude);
68+
const alt = parseInt(altitude);
69+
70+
if (isNaN(lat) || isNaN(lon)) {
71+
toast({
72+
title: t("position.fixedPosition.error.title"),
73+
description: t("position.fixedPosition.error.invalidCoordinates"),
74+
variant: "destructive",
75+
});
76+
return;
77+
}
78+
79+
// Check if fixedPosition is enabled in config
80+
if (!isEnabled) {
81+
toast({
82+
title: t("position.fixedPosition.error.title"),
83+
description: t("position.fixedPosition.error.notEnabled"),
84+
variant: "destructive",
85+
});
86+
return;
87+
}
88+
89+
// Convert degrees to integer format (multiply by 1e7)
90+
const latitudeI = Math.round(lat * 1e7);
91+
const longitudeI = Math.round(lon * 1e7);
92+
93+
console.log("Setting fixed position:", {
94+
latitude: lat,
95+
longitude: lon,
96+
altitude: isNaN(alt) ? 0 : alt,
97+
latitudeI,
98+
longitudeI,
99+
});
100+
101+
const message = create(Protobuf.Admin.AdminMessageSchema, {
102+
payloadVariant: {
103+
case: "setFixedPosition",
104+
value: create(Protobuf.Mesh.PositionSchema, {
105+
latitudeI,
106+
longitudeI,
107+
altitude: isNaN(alt) ? 0 : alt,
108+
time: Math.floor(Date.now() / 1000),
109+
}),
110+
},
111+
});
112+
113+
onSetPosition(message);
114+
115+
toast({
116+
title: t("position.fixedPosition.success.title"),
117+
description: t("position.fixedPosition.success.description", {
118+
lat: lat.toFixed(6),
119+
lon: lon.toFixed(6),
120+
}),
121+
});
122+
}, [latitude, longitude, altitude, isEnabled, onSetPosition, toast, t]);
123+
124+
return (
125+
<div className="mt-4 space-y-3 p-4 border rounded-md bg-muted/50">
126+
{/* Map Picker */}
127+
<div className="w-full h-64 rounded-md overflow-hidden border">
128+
<BaseMap
129+
onClick={handleMapClick}
130+
initialViewState={{
131+
latitude: latitude
132+
? parseFloat(latitude)
133+
: currentPosition?.latitudeI
134+
? currentPosition.latitudeI / 1e7
135+
: 0,
136+
longitude: longitude
137+
? parseFloat(longitude)
138+
: currentPosition?.longitudeI
139+
? currentPosition.longitudeI / 1e7
140+
: 0,
141+
zoom: (latitude && longitude) || currentPosition?.latitudeI ? 13 : 1.8,
142+
}}
143+
>
144+
{latitude && longitude && (
145+
<>
146+
<Marker
147+
longitude={parseFloat(longitude)}
148+
latitude={parseFloat(latitude)}
149+
anchor="bottom"
150+
>
151+
<MapPin className="w-8 h-8 text-red-500 fill-red-500/20" />
152+
</Marker>
153+
</>
154+
)}
155+
</BaseMap>
156+
</div>
157+
<p className="text-xs text-muted-foreground text-center">
158+
{t("position.fixedPosition.map.hint")}
159+
</p>
160+
161+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
162+
<div className="space-y-1.5">
163+
<label htmlFor="latitude" className="text-xs font-medium">
164+
{t("position.fixedPosition.latitude.label")}
165+
</label>
166+
<Input
167+
id="latitude"
168+
type="number"
169+
step="any"
170+
placeholder="37.7749"
171+
value={latitude}
172+
onChange={(e) => setLatitude(e.target.value)}
173+
className="h-8 text-sm"
174+
/>
175+
</div>
176+
177+
<div className="space-y-1.5">
178+
<label htmlFor="longitude" className="text-xs font-medium">
179+
{t("position.fixedPosition.longitude.label")}
180+
</label>
181+
<Input
182+
id="longitude"
183+
type="number"
184+
step="any"
185+
placeholder="-122.4194"
186+
value={longitude}
187+
onChange={(e) => setLongitude(e.target.value)}
188+
className="h-8 text-sm"
189+
/>
190+
</div>
191+
192+
<div className="space-y-1.5">
193+
<label htmlFor="altitude" className="text-xs font-medium">
194+
{t("position.fixedPosition.altitude.label")}
195+
</label>
196+
<Input
197+
id="altitude"
198+
type="number"
199+
placeholder="100"
200+
value={altitude}
201+
onChange={(e) => setAltitude(e.target.value)}
202+
className="h-8 text-sm"
203+
/>
204+
</div>
205+
</div>
206+
207+
<div className="flex gap-2 flex-wrap">
208+
<Button
209+
onClick={handleSetFixedPosition}
210+
variant="outline"
211+
size="sm"
212+
className="flex-1 sm:flex-none"
213+
>
214+
{t("position.fixedPosition.setButton")}
215+
</Button>
216+
<Button
217+
onClick={handleRequestPosition}
218+
variant="subtle"
219+
size="sm"
220+
className="flex-1 sm:flex-none"
221+
>
222+
{t("position.fixedPosition.requestButton")}
223+
</Button>
224+
</div>
225+
</div>
226+
);
227+
};

0 commit comments

Comments
 (0)