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 @@ - - - - Created with Fabric.js 4.6.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;