diff --git a/packages/web/public/i18n/locales/en/config.json b/packages/web/public/i18n/locales/en/config.json
index ca59a850..5c62b99b 100644
--- a/packages/web/public/i18n/locales/en/config.json
+++ b/packages/web/public/i18n/locales/en/config.json
@@ -283,7 +283,19 @@
},
"fixedPosition": {
"description": "Don't report GPS position, but a manually-specified one",
- "label": "Fixed Position"
+ "label": "Fixed Position",
+ "latitude": {
+ "label": "Latitude",
+ "description": "Decimal degrees between -90 and 90 (e.g., 37.7749)"
+ },
+ "longitude": {
+ "label": "Longitude",
+ "description": "Decimal degrees between -180 and 180 (e.g., -122.4194)"
+ },
+ "altitude": {
+ "label": "Altitude",
+ "description": "Optional — enter the altitude in {{unit}} above sea level (e.g., 100). Leave blank if unknown or add extra height for antennas/masts."
+ }
},
"gpsMode": {
"description": "Configure whether device GPS is Enabled, Disabled, or Not Present",
diff --git a/packages/web/public/logo.svg b/packages/web/public/logo.svg
deleted file mode 100644
index 2d4a4fb6..00000000
--- a/packages/web/public/logo.svg
+++ /dev/null
@@ -1,41 +0,0 @@
-
-
-
diff --git a/packages/web/public/logo_black.svg b/packages/web/public/logo_black.svg
deleted file mode 100644
index 3568d300..00000000
--- a/packages/web/public/logo_black.svg
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
diff --git a/packages/web/public/logo_white.svg b/packages/web/public/logo_white.svg
deleted file mode 100644
index 7c5417ed..00000000
--- a/packages/web/public/logo_white.svg
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
diff --git a/packages/web/src/components/Map.tsx b/packages/web/src/components/Map.tsx
index c0ced881..0300a96c 100644
--- a/packages/web/src/components/Map.tsx
+++ b/packages/web/src/components/Map.tsx
@@ -15,6 +15,11 @@ interface MapProps {
onMouseMove?: (event: MapLayerMouseEvent) => void;
onClick?: (event: MapLayerMouseEvent) => void;
interactiveLayerIds?: string[];
+ initialViewState?: {
+ latitude?: number;
+ longitude?: number;
+ zoom?: number;
+ };
}
export const BaseMap = ({
@@ -23,6 +28,7 @@ export const BaseMap = ({
onClick,
onMouseMove,
interactiveLayerIds,
+ initialViewState,
}: MapProps) => {
const { theme } = useTheme();
const { t } = useTranslation("map");
@@ -67,11 +73,13 @@ export const BaseMap = ({
maxPitch={0}
dragRotate={false}
touchZoomRotate={false}
- initialViewState={{
- zoom: 1.8,
- latitude: 35,
- longitude: 0,
- }}
+ initialViewState={
+ initialViewState ?? {
+ zoom: 1.8,
+ latitude: 35,
+ longitude: 0,
+ }
+ }
style={{ filter: darkMode ? "brightness(0.9)" : undefined }}
locale={locale}
interactiveLayerIds={interactiveLayerIds}
diff --git a/packages/web/src/components/PageComponents/Settings/Position.tsx b/packages/web/src/components/PageComponents/Settings/Position.tsx
index 74e0a92e..f8ab0ab2 100644
--- a/packages/web/src/components/PageComponents/Settings/Position.tsx
+++ b/packages/web/src/components/PageComponents/Settings/Position.tsx
@@ -3,6 +3,7 @@ import {
type PositionValidation,
PositionValidationSchema,
} from "@app/validation/config/position.ts";
+import { create } from "@bufbuild/protobuf";
import {
DynamicForm,
type DynamicFormFormInit,
@@ -11,10 +12,10 @@ import {
type FlagName,
usePositionFlags,
} from "@core/hooks/usePositionFlags.ts";
-import { useDevice } from "@core/stores";
+import { useDevice, useNodeDB } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
-import { useCallback } from "react";
+import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
interface PositionConfigProps {
@@ -23,24 +24,85 @@ interface PositionConfigProps {
export const Position = ({ onFormInit }: PositionConfigProps) => {
useWaitForConfig({ configCase: "position" });
- const { setChange, config, getEffectiveConfig, removeChange } = useDevice();
+ const {
+ setChange,
+ config,
+ getEffectiveConfig,
+ removeChange,
+ queueAdminMessage,
+ } = useDevice();
+ const { getMyNode } = useNodeDB();
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
getEffectiveConfig("position")?.positionFlags ?? 0,
);
const { t } = useTranslation("config");
+ const myNode = getMyNode();
+ const currentPosition = myNode?.position;
+
+ const effectiveConfig = getEffectiveConfig("position");
+ const displayUnits = getEffectiveConfig("display")?.units;
+
+ const formValues = useMemo(() => {
+ return {
+ ...config.position,
+ ...effectiveConfig,
+ // Include current position coordinates if available
+ latitude: currentPosition?.latitudeI
+ ? currentPosition.latitudeI / 1e7
+ : undefined,
+ longitude: currentPosition?.longitudeI
+ ? currentPosition.longitudeI / 1e7
+ : undefined,
+ altitude: currentPosition?.altitude ?? 0,
+ } as PositionValidation;
+ }, [config.position, effectiveConfig, currentPosition]);
+
const onSubmit = (data: PositionValidation) => {
- const payload = { ...data, positionFlags: flagsValue };
+ // Exclude position coordinates from config payload (they're handled via admin message)
+ const {
+ latitude: _latitude,
+ longitude: _longitude,
+ altitude: _altitude,
+ ...configData
+ } = data;
+ const payload = { ...configData, positionFlags: flagsValue };
+
+ // Save config first
+ let configResult: ReturnType | undefined;
if (deepCompareConfig(config.position, payload, true)) {
removeChange({ type: "config", variant: "position" });
- return;
+ configResult = undefined;
+ } else {
+ configResult = setChange(
+ { type: "config", variant: "position" },
+ payload,
+ config.position,
+ );
}
- return setChange(
- { type: "config", variant: "position" },
- payload,
- config.position,
- );
+ // Then handle position coordinates via admin message if fixedPosition is enabled
+ if (
+ data.fixedPosition &&
+ data.latitude !== undefined &&
+ data.longitude !== undefined
+ ) {
+ 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,
+ time: Math.floor(Date.now() / 1000),
+ }),
+ },
+ });
+
+ queueAdminMessage(message);
+ }
+
+ return configResult;
};
const onPositonFlagChange = useCallback(
@@ -59,7 +121,7 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
onFormInit={onFormInit}
validationSchema={PositionValidationSchema}
defaultValues={config.position}
- values={getEffectiveConfig("position")}
+ values={formValues}
fieldGroups={[
{
label: t("position.title"),
@@ -85,6 +147,75 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
name: "fixedPosition",
label: t("position.fixedPosition.label"),
description: t("position.fixedPosition.description"),
+ disabledBy: [
+ {
+ fieldName: "gpsMode",
+ selector:
+ Protobuf.Config.Config_PositionConfig_GpsMode.ENABLED,
+ },
+ ],
+ },
+ // Position coordinate fields (only shown when fixedPosition is enabled)
+ {
+ type: "number",
+ name: "latitude",
+ label: t("position.fixedPosition.latitude.label"),
+ description: `${t("position.fixedPosition.latitude.description")} (Max 7 decimal precision)`,
+ properties: {
+ step: 0.0000001,
+ suffix: "Degrees",
+ fieldLength: {
+ max: 10,
+ },
+ },
+ disabledBy: [
+ {
+ fieldName: "fixedPosition",
+ },
+ ],
+ },
+ {
+ type: "number",
+ name: "longitude",
+ label: t("position.fixedPosition.longitude.label"),
+ description: `${t("position.fixedPosition.longitude.description")} (Max 7 decimal precision)`,
+ properties: {
+ step: 0.0000001,
+ suffix: "Degrees",
+ fieldLength: {
+ max: 10,
+ },
+ },
+ disabledBy: [
+ {
+ fieldName: "fixedPosition",
+ },
+ ],
+ },
+ {
+ type: "number",
+ name: "altitude",
+ label: t("position.fixedPosition.altitude.label"),
+ description: t("position.fixedPosition.altitude.description", {
+ unit:
+ displayUnits ===
+ Protobuf.Config.Config_DisplayConfig_DisplayUnits.IMPERIAL
+ ? "Feet"
+ : "Meters",
+ }),
+ properties: {
+ step: 0.0000001,
+ suffix:
+ displayUnits ===
+ Protobuf.Config.Config_DisplayConfig_DisplayUnits.IMPERIAL
+ ? "Feet"
+ : "Meters",
+ },
+ disabledBy: [
+ {
+ fieldName: "fixedPosition",
+ },
+ ],
},
{
type: "multiSelect",
diff --git a/packages/web/src/components/UI/Switch.tsx b/packages/web/src/components/UI/Switch.tsx
index 343e8d52..d99e2166 100644
--- a/packages/web/src/components/UI/Switch.tsx
+++ b/packages/web/src/components/UI/Switch.tsx
@@ -3,7 +3,7 @@ import * as SwitchPrimitives from "@radix-ui/react-switch";
import * as React from "react";
const Switch = React.forwardRef<
- React.ElementRef,
+ React.ComponentRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
+
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
diff --git a/packages/web/src/core/stores/deviceStore/changeRegistry.ts b/packages/web/src/core/stores/deviceStore/changeRegistry.ts
index 76de523e..844ed8db 100644
--- a/packages/web/src/core/stores/deviceStore/changeRegistry.ts
+++ b/packages/web/src/core/stores/deviceStore/changeRegistry.ts
@@ -25,12 +25,16 @@ export type ValidModuleConfigType =
| "detectionSensor"
| "paxcounter";
+// Admin message types that can be queued
+export type ValidAdminMessageType = "setFixedPosition" | "other";
+
// Unified config change key type
export type ConfigChangeKey =
| { type: "config"; variant: ValidConfigType }
| { type: "moduleConfig"; variant: ValidModuleConfigType }
| { type: "channel"; index: Types.ChannelNumber }
- | { type: "user" };
+ | { type: "user" }
+ | { type: "adminMessage"; variant: ValidAdminMessageType; id: string };
// Serialized key for Map storage
export type ConfigChangeKeyString = string;
@@ -61,6 +65,8 @@ export function serializeKey(key: ConfigChangeKey): ConfigChangeKeyString {
return `channel:${key.index}`;
case "user":
return "user";
+ case "adminMessage":
+ return `adminMessage:${key.variant}:${key.id}`;
}
}
@@ -68,23 +74,30 @@ export function serializeKey(key: ConfigChangeKey): ConfigChangeKeyString {
* Reverse operation for type-safe retrieval
*/
export function deserializeKey(keyStr: ConfigChangeKeyString): ConfigChangeKey {
- const [type, variant] = keyStr.split(":");
+ const parts = keyStr.split(":");
+ const type = parts[0];
switch (type) {
case "config":
- return { type: "config", variant: variant as ValidConfigType };
+ return { type: "config", variant: parts[1] as ValidConfigType };
case "moduleConfig":
return {
type: "moduleConfig",
- variant: variant as ValidModuleConfigType,
+ variant: parts[1] as ValidModuleConfigType,
};
case "channel":
return {
type: "channel",
- index: Number(variant) as Types.ChannelNumber,
+ index: Number(parts[1]) as Types.ChannelNumber,
};
case "user":
return { type: "user" };
+ case "adminMessage":
+ return {
+ type: "adminMessage",
+ variant: parts[1] as ValidAdminMessageType,
+ id: parts[2] ?? "",
+ };
default:
throw new Error(`Unknown key type: ${type}`);
}
@@ -218,3 +231,30 @@ export function getAllChannelChanges(registry: ChangeRegistry): ChangeEntry[] {
}
return changes;
}
+
+/**
+ * Get all admin message changes as an array
+ */
+export function getAllAdminMessages(registry: ChangeRegistry): ChangeEntry[] {
+ const changes: ChangeEntry[] = [];
+ for (const entry of registry.changes.values()) {
+ if (entry.key.type === "adminMessage") {
+ changes.push(entry);
+ }
+ }
+ return changes;
+}
+
+/**
+ * Get count of admin message changes
+ */
+export function getAdminMessageChangeCount(registry: ChangeRegistry): number {
+ let count = 0;
+ for (const keyStr of registry.changes.keys()) {
+ const key = deserializeKey(keyStr);
+ if (key.type === "adminMessage") {
+ count++;
+ }
+ }
+ return count;
+}
diff --git a/packages/web/src/core/stores/deviceStore/index.ts b/packages/web/src/core/stores/deviceStore/index.ts
index 5b04caea..789ecb2d 100644
--- a/packages/web/src/core/stores/deviceStore/index.ts
+++ b/packages/web/src/core/stores/deviceStore/index.ts
@@ -12,6 +12,8 @@ import {
import type { ChangeRegistry, ConfigChangeKey } from "./changeRegistry.ts";
import {
createChangeRegistry,
+ getAdminMessageChangeCount,
+ getAllAdminMessages,
getAllChannelChanges,
getAllConfigChanges,
getAllModuleConfigChanges,
@@ -146,6 +148,9 @@ export interface Device extends DeviceData {
getAllConfigChanges: () => Protobuf.Config.Config[];
getAllModuleConfigChanges: () => Protobuf.ModuleConfig.ModuleConfig[];
getAllChannelChanges: () => Protobuf.Channel.Channel[];
+ queueAdminMessage: (message: Protobuf.Admin.AdminMessage) => void;
+ getAllQueuedAdminMessages: () => Protobuf.Admin.AdminMessage[];
+ getAdminMessageChangeCount: () => number;
}
export interface deviceState {
@@ -940,6 +945,59 @@ function deviceFactory(
.map((entry) => entry.value as Protobuf.Channel.Channel)
.filter((c): c is Protobuf.Channel.Channel => c !== undefined);
},
+
+ queueAdminMessage: (message: Protobuf.Admin.AdminMessage) => {
+ // Generate a unique ID for this admin message
+ const messageId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
+
+ // Determine the variant type
+ const variant =
+ message.payloadVariant.case === "setFixedPosition"
+ ? "setFixedPosition"
+ : "other";
+
+ set(
+ produce((draft) => {
+ const device = draft.devices.get(id);
+ if (!device) {
+ return;
+ }
+
+ const keyStr = serializeKey({
+ type: "adminMessage",
+ variant,
+ id: messageId,
+ });
+
+ device.changeRegistry.changes.set(keyStr, {
+ key: { type: "adminMessage", variant, id: messageId },
+ value: message,
+ timestamp: Date.now(),
+ });
+ }),
+ );
+ },
+
+ getAllQueuedAdminMessages: () => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return [];
+ }
+
+ const changes = getAllAdminMessages(device.changeRegistry);
+ return changes
+ .map((entry) => entry.value as Protobuf.Admin.AdminMessage)
+ .filter((m): m is Protobuf.Admin.AdminMessage => m !== undefined);
+ },
+
+ getAdminMessageChangeCount: () => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return 0;
+ }
+
+ return getAdminMessageChangeCount(device.changeRegistry);
+ },
};
}
diff --git a/packages/web/src/pages/Settings/index.tsx b/packages/web/src/pages/Settings/index.tsx
index ffd17614..13b1175d 100644
--- a/packages/web/src/pages/Settings/index.tsx
+++ b/packages/web/src/pages/Settings/index.tsx
@@ -1,4 +1,5 @@
import { deviceRoute, moduleRoute, radioRoute } from "@app/routes";
+import { toBinary } from "@bufbuild/protobuf";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
@@ -6,6 +7,7 @@ import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { useDevice } from "@core/stores";
import { cn } from "@core/utils/cn.ts";
+import { Protobuf } from "@meshtastic/core";
import { DeviceConfig } from "@pages/Settings/DeviceConfig.tsx";
import { ModuleConfig } from "@pages/Settings/ModuleConfig.tsx";
import { useNavigate, useRouterState } from "@tanstack/react-router";
@@ -27,6 +29,7 @@ const ConfigPage = () => {
getAllConfigChanges,
getAllModuleConfigChanges,
getAllChannelChanges,
+ getAllQueuedAdminMessages,
connection,
clearAllChanges,
setConfig,
@@ -35,6 +38,7 @@ const ConfigPage = () => {
getConfigChangeCount,
getModuleConfigChangeCount,
getChannelChangeCount,
+ getAdminMessageChangeCount,
} = useDevice();
const [isSaving, setIsSaving] = useState(false);
@@ -49,6 +53,7 @@ const ConfigPage = () => {
const configChangeCount = getConfigChangeCount();
const moduleConfigChangeCount = getModuleConfigChangeCount();
const channelChangeCount = getChannelChangeCount();
+ const adminMessageChangeCount = getAdminMessageChangeCount();
const sections = useMemo(
() => [
@@ -121,6 +126,7 @@ const ConfigPage = () => {
const channelChanges = getAllChannelChanges();
const configChanges = getAllConfigChanges();
const moduleConfigChanges = getAllModuleConfigChanges();
+ const adminMessages = getAllQueuedAdminMessages();
await Promise.all(
channelChanges.map((channel) =>
@@ -165,6 +171,19 @@ const ConfigPage = () => {
await connection?.commitEditSettings();
}
+ // Send queued admin messages after configs are committed
+ if (adminMessages.length > 0) {
+ await Promise.all(
+ adminMessages.map((message) =>
+ connection?.sendPacket(
+ toBinary(Protobuf.Admin.AdminMessageSchema, message),
+ Protobuf.Portnums.PortNum.ADMIN_APP,
+ "self",
+ ),
+ ),
+ );
+ }
+
channelChanges.forEach((newChannel) => {
addChannel(newChannel);
});
@@ -206,6 +225,7 @@ const ConfigPage = () => {
connection,
getAllModuleConfigChanges,
getAllChannelChanges,
+ getAllQueuedAdminMessages,
formMethods,
addChannel,
setConfig,
@@ -244,7 +264,8 @@ const ConfigPage = () => {
const hasDrafts =
getConfigChangeCount() > 0 ||
getModuleConfigChangeCount() > 0 ||
- getChannelChangeCount() > 0;
+ getChannelChangeCount() > 0 ||
+ adminMessageChangeCount > 0;
const hasPending = hasDrafts || rhfState.isDirty;
const buttonOpacity = hasPending ? "opacity-100" : "opacity-0";
const saveDisabled = isSaving || !rhfState.isValid || !hasPending;
@@ -312,7 +333,7 @@ const ConfigPage = () => {
label={activeSection?.label ?? ""}
actions={actions}
>
-
+ {ActiveComponent && }
);
};
diff --git a/packages/web/src/validation/config/position.ts b/packages/web/src/validation/config/position.ts
index c7506b64..b901a667 100644
--- a/packages/web/src/validation/config/position.ts
+++ b/packages/web/src/validation/config/position.ts
@@ -15,6 +15,9 @@ export const PositionValidationSchema = z.object({
broadcastSmartMinimumIntervalSecs: z.coerce.number().int().min(0),
gpsEnGpio: z.coerce.number().int().min(0),
gpsMode: GpsModeEnum,
+ latitude: z.coerce.number().min(-90).max(90).optional(),
+ longitude: z.coerce.number().min(-180).max(180).optional(),
+ altitude: z.coerce.number().optional(),
});
export type PositionValidation = z.infer;