feat: add fixed position coordinate picker (#909)
* chore: remove unused logo SVG files * feat: add interactive fixed position picker with map interface - Created new FixedPositionPicker component with clickable map for setting device coordinates - Added form field type for fixed position picker that appears when fixedPosition toggle is enabled - Implemented position request functionality to retrieve current device location * feat: display altitude unit based on user's display settings - Added dynamic altitude unit (Meters/Feet) that respects the user's imperial/metric display preference - Updated altitude field description to show the appropriate unit instead of hardcoded "Meters" * refactor: replace any type with MapLayerMouseEvent in map click handler * refactor: improve accessibility and code quality in FixedPositionPicker - Replace hardcoded IDs with useId() hook for proper accessibility - Use Number.isNaN() instead of isNaN() for more reliable type checking - Add radix parameter to parseInt() and remove unnecessary fragment wrapper * refactor: simplify fixed position picker integration - Removed dedicated FixedPositionPicker form field type in favor of toggle's additionalContent prop - Moved FixedPositionPicker to render conditionally within toggle field instead of as separate dynamic field - Streamlined form field types by eliminating FixedPositionPickerFieldProps * style: format code with consistent line breaks and import ordering * refactor: simplify fixed position picker container styling * feat: disable fixed position toggle when GPS is enabled * refactor: use ComponentRef instead of ElementRef in Switch component * refactor: replace interactive map picker with inline coordinate fields for fixed position - Removed FixedPositionPicker component with map interface - Added latitude, longitude, and altitude fields directly to position form - Moved coordinate validation into PositionValidationSchema with proper min/max bounds - Updated translation strings to include coordinate ranges and improved altitude description - Coordinates now sent via setFixedPosition admin message on form submit when fixedPosition is enabled * refactor: simplify toggle field by removing additionalContent prop and unused field spreading - Removed additionalContent prop and its JSDoc documentation from ToggleFieldProps - Removed rendering of additionalContent below toggle switch - Cleaned up Controller render function by removing unused rest spread operator - Renamed field destructuring to controllerField for clarity * refactor: improve fixed position handling and add position broadcast request - Restructure onSubmit to save config before sending admin message - Add position broadcast request after setting fixed position to immediately update display - Add comprehensive debug logging throughout submission flow - Extract coordinate exclusion logic earlier in submission process for clarity - Add 1 second delay before requesting position broadcast to allow fixed position processing * feat: add max length constraint to latitude and longitude fields - Set fieldLength.max to 10 for both latitude and longitude inputs - Prevents excessive decimal precision while maintaining 7 decimal places (±1.1cm accuracy)
This commit is contained in:
@@ -283,7 +283,19 @@
|
|||||||
},
|
},
|
||||||
"fixedPosition": {
|
"fixedPosition": {
|
||||||
"description": "Don't report GPS position, but a manually-specified one",
|
"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": {
|
"gpsMode": {
|
||||||
"description": "Configure whether device GPS is Enabled, Disabled, or Not Present",
|
"description": "Configure whether device GPS is Enabled, Disabled, or Not Present",
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
version="1.1"
|
|
||||||
width="512"
|
|
||||||
height="512"
|
|
||||||
viewBox="0 0 512 512"
|
|
||||||
xml:space="preserve"
|
|
||||||
>
|
|
||||||
<desc>Created with Fabric.js 4.6.0</desc>
|
|
||||||
<defs> </defs>
|
|
||||||
<g transform="matrix(1 0 0 1 256 256)" id="xYQ9Gk9Jwpgj_HMOXB3F_">
|
|
||||||
<path
|
|
||||||
style="stroke: rgb(213, 130, 139); stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(103, 234, 148); fill-rule: nonzero; opacity: 1"
|
|
||||||
vector-effect="non-scaling-stroke"
|
|
||||||
transform=" translate(-256, -256)"
|
|
||||||
d="M 0 0 L 512 0 L 512 512 L 0 512 z"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<g transform="matrix(1.79 0 0 1.79 313.74 258.36)" id="1xBsk2n9FZp60Rz1O-ceJ">
|
|
||||||
<path
|
|
||||||
style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: round; stroke-miterlimit: 2; fill: rgb(44, 45, 60); fill-rule: evenodd; opacity: 1"
|
|
||||||
vector-effect="non-scaling-stroke"
|
|
||||||
transform=" translate(-250.97, -362.41)"
|
|
||||||
d="M 250.908 330.267 L 193.126 415.005 L 180.938 406.694 L 244.802 313.037 C 246.174 311.024 248.453 309.819 250.889 309.816 C 253.326 309.814 255.606 311.015 256.982 313.026 L 320.994 406.536 L 308.821 414.869 L 250.908 330.267 Z"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<g transform="matrix(1.81 0 0 1.81 145 256.15)" id="KxN7E9YpbyPgz0S4z4Cl6">
|
|
||||||
<path
|
|
||||||
style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: round; stroke-miterlimit: 2; fill: rgb(44, 45, 60); fill-rule: evenodd; opacity: 1"
|
|
||||||
vector-effect="non-scaling-stroke"
|
|
||||||
transform=" translate(-115.14, -528.06)"
|
|
||||||
d="M 87.642 581.398 L 154.757 482.977 L 142.638 474.713 L 75.523 573.134 L 87.642 581.398 Z"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,26 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
viewBox="0 0 100 55"
|
|
||||||
version="1.1"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
xml:space="preserve"
|
|
||||||
xmlns:serif="http://www.serif.com/"
|
|
||||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
|
|
||||||
>
|
|
||||||
<g transform="matrix(0.802386,0,0,0.460028,-421.748,-122.127)">
|
|
||||||
<g transform="matrix(0.579082,0,0,1.01004,460.975,-39.6867)">
|
|
||||||
<path
|
|
||||||
d="M250.908,330.267L193.126,415.005L180.938,406.694L244.802,313.037C246.174,311.024 248.453,309.819 250.889,309.816C253.326,309.814 255.606,311.015 256.982,313.026L320.994,406.536L308.821,414.869L250.908,330.267Z"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<g transform="matrix(0.582378,0,0,1.01579,485.019,-211.182)">
|
|
||||||
<path
|
|
||||||
d="M87.642,581.398L154.757,482.977L142.638,474.713L75.523,573.134L87.642,581.398Z"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,28 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
viewBox="0 0 100 55"
|
|
||||||
version="1.1"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
xml:space="preserve"
|
|
||||||
xmlns:serif="http://www.serif.com/"
|
|
||||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
|
|
||||||
>
|
|
||||||
<g transform="matrix(0.802386,0,0,0.460028,-421.748,-122.127)">
|
|
||||||
<g transform="matrix(0.579082,0,0,1.01004,460.975,-39.6867)">
|
|
||||||
<path
|
|
||||||
d="M250.908,330.267L193.126,415.005L180.938,406.694L244.802,313.037C246.174,311.024 248.453,309.819 250.889,309.816C253.326,309.814 255.606,311.015 256.982,313.026L320.994,406.536L308.821,414.869L250.908,330.267Z"
|
|
||||||
style="fill: white"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<g transform="matrix(0.582378,0,0,1.01579,485.019,-211.182)">
|
|
||||||
<path
|
|
||||||
d="M87.642,581.398L154.757,482.977L142.638,474.713L75.523,573.134L87.642,581.398Z"
|
|
||||||
style="fill: white"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -15,6 +15,11 @@ interface MapProps {
|
|||||||
onMouseMove?: (event: MapLayerMouseEvent) => void;
|
onMouseMove?: (event: MapLayerMouseEvent) => void;
|
||||||
onClick?: (event: MapLayerMouseEvent) => void;
|
onClick?: (event: MapLayerMouseEvent) => void;
|
||||||
interactiveLayerIds?: string[];
|
interactiveLayerIds?: string[];
|
||||||
|
initialViewState?: {
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
zoom?: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BaseMap = ({
|
export const BaseMap = ({
|
||||||
@@ -23,6 +28,7 @@ export const BaseMap = ({
|
|||||||
onClick,
|
onClick,
|
||||||
onMouseMove,
|
onMouseMove,
|
||||||
interactiveLayerIds,
|
interactiveLayerIds,
|
||||||
|
initialViewState,
|
||||||
}: MapProps) => {
|
}: MapProps) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const { t } = useTranslation("map");
|
const { t } = useTranslation("map");
|
||||||
@@ -67,11 +73,13 @@ export const BaseMap = ({
|
|||||||
maxPitch={0}
|
maxPitch={0}
|
||||||
dragRotate={false}
|
dragRotate={false}
|
||||||
touchZoomRotate={false}
|
touchZoomRotate={false}
|
||||||
initialViewState={{
|
initialViewState={
|
||||||
zoom: 1.8,
|
initialViewState ?? {
|
||||||
latitude: 35,
|
zoom: 1.8,
|
||||||
longitude: 0,
|
latitude: 35,
|
||||||
}}
|
longitude: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
style={{ filter: darkMode ? "brightness(0.9)" : undefined }}
|
style={{ filter: darkMode ? "brightness(0.9)" : undefined }}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
interactiveLayerIds={interactiveLayerIds}
|
interactiveLayerIds={interactiveLayerIds}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
type PositionValidation,
|
type PositionValidation,
|
||||||
PositionValidationSchema,
|
PositionValidationSchema,
|
||||||
} from "@app/validation/config/position.ts";
|
} from "@app/validation/config/position.ts";
|
||||||
|
import { create } from "@bufbuild/protobuf";
|
||||||
import {
|
import {
|
||||||
DynamicForm,
|
DynamicForm,
|
||||||
type DynamicFormFormInit,
|
type DynamicFormFormInit,
|
||||||
@@ -11,10 +12,10 @@ import {
|
|||||||
type FlagName,
|
type FlagName,
|
||||||
usePositionFlags,
|
usePositionFlags,
|
||||||
} from "@core/hooks/usePositionFlags.ts";
|
} from "@core/hooks/usePositionFlags.ts";
|
||||||
import { useDevice } from "@core/stores";
|
import { useDevice, useNodeDB } from "@core/stores";
|
||||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface PositionConfigProps {
|
interface PositionConfigProps {
|
||||||
@@ -23,24 +24,85 @@ interface PositionConfigProps {
|
|||||||
export const Position = ({ onFormInit }: PositionConfigProps) => {
|
export const Position = ({ onFormInit }: PositionConfigProps) => {
|
||||||
useWaitForConfig({ configCase: "position" });
|
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(
|
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
|
||||||
getEffectiveConfig("position")?.positionFlags ?? 0,
|
getEffectiveConfig("position")?.positionFlags ?? 0,
|
||||||
);
|
);
|
||||||
const { t } = useTranslation("config");
|
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 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<typeof setChange> | undefined;
|
||||||
if (deepCompareConfig(config.position, payload, true)) {
|
if (deepCompareConfig(config.position, payload, true)) {
|
||||||
removeChange({ type: "config", variant: "position" });
|
removeChange({ type: "config", variant: "position" });
|
||||||
return;
|
configResult = undefined;
|
||||||
|
} else {
|
||||||
|
configResult = setChange(
|
||||||
|
{ type: "config", variant: "position" },
|
||||||
|
payload,
|
||||||
|
config.position,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return setChange(
|
// Then handle position coordinates via admin message if fixedPosition is enabled
|
||||||
{ type: "config", variant: "position" },
|
if (
|
||||||
payload,
|
data.fixedPosition &&
|
||||||
config.position,
|
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(
|
const onPositonFlagChange = useCallback(
|
||||||
@@ -59,7 +121,7 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
|
|||||||
onFormInit={onFormInit}
|
onFormInit={onFormInit}
|
||||||
validationSchema={PositionValidationSchema}
|
validationSchema={PositionValidationSchema}
|
||||||
defaultValues={config.position}
|
defaultValues={config.position}
|
||||||
values={getEffectiveConfig("position")}
|
values={formValues}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("position.title"),
|
label: t("position.title"),
|
||||||
@@ -85,6 +147,75 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
|
|||||||
name: "fixedPosition",
|
name: "fixedPosition",
|
||||||
label: t("position.fixedPosition.label"),
|
label: t("position.fixedPosition.label"),
|
||||||
description: t("position.fixedPosition.description"),
|
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",
|
type: "multiSelect",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as SwitchPrimitives from "@radix-ui/react-switch";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
const Switch = React.forwardRef<
|
const Switch = React.forwardRef<
|
||||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
React.ComponentRef<typeof SwitchPrimitives.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SwitchPrimitives.Root
|
<SwitchPrimitives.Root
|
||||||
@@ -21,6 +21,7 @@ const Switch = React.forwardRef<
|
|||||||
/>
|
/>
|
||||||
</SwitchPrimitives.Root>
|
</SwitchPrimitives.Root>
|
||||||
));
|
));
|
||||||
|
|
||||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||||
|
|
||||||
export { Switch };
|
export { Switch };
|
||||||
|
|||||||
@@ -25,12 +25,16 @@ export type ValidModuleConfigType =
|
|||||||
| "detectionSensor"
|
| "detectionSensor"
|
||||||
| "paxcounter";
|
| "paxcounter";
|
||||||
|
|
||||||
|
// Admin message types that can be queued
|
||||||
|
export type ValidAdminMessageType = "setFixedPosition" | "other";
|
||||||
|
|
||||||
// Unified config change key type
|
// Unified config change key type
|
||||||
export type ConfigChangeKey =
|
export type ConfigChangeKey =
|
||||||
| { type: "config"; variant: ValidConfigType }
|
| { type: "config"; variant: ValidConfigType }
|
||||||
| { type: "moduleConfig"; variant: ValidModuleConfigType }
|
| { type: "moduleConfig"; variant: ValidModuleConfigType }
|
||||||
| { type: "channel"; index: Types.ChannelNumber }
|
| { type: "channel"; index: Types.ChannelNumber }
|
||||||
| { type: "user" };
|
| { type: "user" }
|
||||||
|
| { type: "adminMessage"; variant: ValidAdminMessageType; id: string };
|
||||||
|
|
||||||
// Serialized key for Map storage
|
// Serialized key for Map storage
|
||||||
export type ConfigChangeKeyString = string;
|
export type ConfigChangeKeyString = string;
|
||||||
@@ -61,6 +65,8 @@ export function serializeKey(key: ConfigChangeKey): ConfigChangeKeyString {
|
|||||||
return `channel:${key.index}`;
|
return `channel:${key.index}`;
|
||||||
case "user":
|
case "user":
|
||||||
return "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
|
* Reverse operation for type-safe retrieval
|
||||||
*/
|
*/
|
||||||
export function deserializeKey(keyStr: ConfigChangeKeyString): ConfigChangeKey {
|
export function deserializeKey(keyStr: ConfigChangeKeyString): ConfigChangeKey {
|
||||||
const [type, variant] = keyStr.split(":");
|
const parts = keyStr.split(":");
|
||||||
|
const type = parts[0];
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "config":
|
case "config":
|
||||||
return { type: "config", variant: variant as ValidConfigType };
|
return { type: "config", variant: parts[1] as ValidConfigType };
|
||||||
case "moduleConfig":
|
case "moduleConfig":
|
||||||
return {
|
return {
|
||||||
type: "moduleConfig",
|
type: "moduleConfig",
|
||||||
variant: variant as ValidModuleConfigType,
|
variant: parts[1] as ValidModuleConfigType,
|
||||||
};
|
};
|
||||||
case "channel":
|
case "channel":
|
||||||
return {
|
return {
|
||||||
type: "channel",
|
type: "channel",
|
||||||
index: Number(variant) as Types.ChannelNumber,
|
index: Number(parts[1]) as Types.ChannelNumber,
|
||||||
};
|
};
|
||||||
case "user":
|
case "user":
|
||||||
return { type: "user" };
|
return { type: "user" };
|
||||||
|
case "adminMessage":
|
||||||
|
return {
|
||||||
|
type: "adminMessage",
|
||||||
|
variant: parts[1] as ValidAdminMessageType,
|
||||||
|
id: parts[2] ?? "",
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown key type: ${type}`);
|
throw new Error(`Unknown key type: ${type}`);
|
||||||
}
|
}
|
||||||
@@ -218,3 +231,30 @@ export function getAllChannelChanges(registry: ChangeRegistry): ChangeEntry[] {
|
|||||||
}
|
}
|
||||||
return changes;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
import type { ChangeRegistry, ConfigChangeKey } from "./changeRegistry.ts";
|
import type { ChangeRegistry, ConfigChangeKey } from "./changeRegistry.ts";
|
||||||
import {
|
import {
|
||||||
createChangeRegistry,
|
createChangeRegistry,
|
||||||
|
getAdminMessageChangeCount,
|
||||||
|
getAllAdminMessages,
|
||||||
getAllChannelChanges,
|
getAllChannelChanges,
|
||||||
getAllConfigChanges,
|
getAllConfigChanges,
|
||||||
getAllModuleConfigChanges,
|
getAllModuleConfigChanges,
|
||||||
@@ -146,6 +148,9 @@ export interface Device extends DeviceData {
|
|||||||
getAllConfigChanges: () => Protobuf.Config.Config[];
|
getAllConfigChanges: () => Protobuf.Config.Config[];
|
||||||
getAllModuleConfigChanges: () => Protobuf.ModuleConfig.ModuleConfig[];
|
getAllModuleConfigChanges: () => Protobuf.ModuleConfig.ModuleConfig[];
|
||||||
getAllChannelChanges: () => Protobuf.Channel.Channel[];
|
getAllChannelChanges: () => Protobuf.Channel.Channel[];
|
||||||
|
queueAdminMessage: (message: Protobuf.Admin.AdminMessage) => void;
|
||||||
|
getAllQueuedAdminMessages: () => Protobuf.Admin.AdminMessage[];
|
||||||
|
getAdminMessageChangeCount: () => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface deviceState {
|
export interface deviceState {
|
||||||
@@ -940,6 +945,59 @@ function deviceFactory(
|
|||||||
.map((entry) => entry.value as Protobuf.Channel.Channel)
|
.map((entry) => entry.value as Protobuf.Channel.Channel)
|
||||||
.filter((c): c is Protobuf.Channel.Channel => c !== undefined);
|
.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<PrivateDeviceState>((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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { deviceRoute, moduleRoute, radioRoute } from "@app/routes";
|
import { deviceRoute, moduleRoute, radioRoute } from "@app/routes";
|
||||||
|
import { toBinary } from "@bufbuild/protobuf";
|
||||||
import { PageLayout } from "@components/PageLayout.tsx";
|
import { PageLayout } from "@components/PageLayout.tsx";
|
||||||
import { Sidebar } from "@components/Sidebar.tsx";
|
import { Sidebar } from "@components/Sidebar.tsx";
|
||||||
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.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 { useToast } from "@core/hooks/useToast.ts";
|
||||||
import { useDevice } from "@core/stores";
|
import { useDevice } from "@core/stores";
|
||||||
import { cn } from "@core/utils/cn.ts";
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { DeviceConfig } from "@pages/Settings/DeviceConfig.tsx";
|
import { DeviceConfig } from "@pages/Settings/DeviceConfig.tsx";
|
||||||
import { ModuleConfig } from "@pages/Settings/ModuleConfig.tsx";
|
import { ModuleConfig } from "@pages/Settings/ModuleConfig.tsx";
|
||||||
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
||||||
@@ -27,6 +29,7 @@ const ConfigPage = () => {
|
|||||||
getAllConfigChanges,
|
getAllConfigChanges,
|
||||||
getAllModuleConfigChanges,
|
getAllModuleConfigChanges,
|
||||||
getAllChannelChanges,
|
getAllChannelChanges,
|
||||||
|
getAllQueuedAdminMessages,
|
||||||
connection,
|
connection,
|
||||||
clearAllChanges,
|
clearAllChanges,
|
||||||
setConfig,
|
setConfig,
|
||||||
@@ -35,6 +38,7 @@ const ConfigPage = () => {
|
|||||||
getConfigChangeCount,
|
getConfigChangeCount,
|
||||||
getModuleConfigChangeCount,
|
getModuleConfigChangeCount,
|
||||||
getChannelChangeCount,
|
getChannelChangeCount,
|
||||||
|
getAdminMessageChangeCount,
|
||||||
} = useDevice();
|
} = useDevice();
|
||||||
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@@ -49,6 +53,7 @@ const ConfigPage = () => {
|
|||||||
const configChangeCount = getConfigChangeCount();
|
const configChangeCount = getConfigChangeCount();
|
||||||
const moduleConfigChangeCount = getModuleConfigChangeCount();
|
const moduleConfigChangeCount = getModuleConfigChangeCount();
|
||||||
const channelChangeCount = getChannelChangeCount();
|
const channelChangeCount = getChannelChangeCount();
|
||||||
|
const adminMessageChangeCount = getAdminMessageChangeCount();
|
||||||
|
|
||||||
const sections = useMemo(
|
const sections = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -121,6 +126,7 @@ const ConfigPage = () => {
|
|||||||
const channelChanges = getAllChannelChanges();
|
const channelChanges = getAllChannelChanges();
|
||||||
const configChanges = getAllConfigChanges();
|
const configChanges = getAllConfigChanges();
|
||||||
const moduleConfigChanges = getAllModuleConfigChanges();
|
const moduleConfigChanges = getAllModuleConfigChanges();
|
||||||
|
const adminMessages = getAllQueuedAdminMessages();
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
channelChanges.map((channel) =>
|
channelChanges.map((channel) =>
|
||||||
@@ -165,6 +171,19 @@ const ConfigPage = () => {
|
|||||||
await connection?.commitEditSettings();
|
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) => {
|
channelChanges.forEach((newChannel) => {
|
||||||
addChannel(newChannel);
|
addChannel(newChannel);
|
||||||
});
|
});
|
||||||
@@ -206,6 +225,7 @@ const ConfigPage = () => {
|
|||||||
connection,
|
connection,
|
||||||
getAllModuleConfigChanges,
|
getAllModuleConfigChanges,
|
||||||
getAllChannelChanges,
|
getAllChannelChanges,
|
||||||
|
getAllQueuedAdminMessages,
|
||||||
formMethods,
|
formMethods,
|
||||||
addChannel,
|
addChannel,
|
||||||
setConfig,
|
setConfig,
|
||||||
@@ -244,7 +264,8 @@ const ConfigPage = () => {
|
|||||||
const hasDrafts =
|
const hasDrafts =
|
||||||
getConfigChangeCount() > 0 ||
|
getConfigChangeCount() > 0 ||
|
||||||
getModuleConfigChangeCount() > 0 ||
|
getModuleConfigChangeCount() > 0 ||
|
||||||
getChannelChangeCount() > 0;
|
getChannelChangeCount() > 0 ||
|
||||||
|
adminMessageChangeCount > 0;
|
||||||
const hasPending = hasDrafts || rhfState.isDirty;
|
const hasPending = hasDrafts || rhfState.isDirty;
|
||||||
const buttonOpacity = hasPending ? "opacity-100" : "opacity-0";
|
const buttonOpacity = hasPending ? "opacity-100" : "opacity-0";
|
||||||
const saveDisabled = isSaving || !rhfState.isValid || !hasPending;
|
const saveDisabled = isSaving || !rhfState.isValid || !hasPending;
|
||||||
@@ -312,7 +333,7 @@ const ConfigPage = () => {
|
|||||||
label={activeSection?.label ?? ""}
|
label={activeSection?.label ?? ""}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
>
|
>
|
||||||
<ActiveComponent onFormInit={onFormInit} />
|
{ActiveComponent && <ActiveComponent onFormInit={onFormInit} />}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export const PositionValidationSchema = z.object({
|
|||||||
broadcastSmartMinimumIntervalSecs: z.coerce.number().int().min(0),
|
broadcastSmartMinimumIntervalSecs: z.coerce.number().int().min(0),
|
||||||
gpsEnGpio: z.coerce.number().int().min(0),
|
gpsEnGpio: z.coerce.number().int().min(0),
|
||||||
gpsMode: GpsModeEnum,
|
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<typeof PositionValidationSchema>;
|
export type PositionValidation = z.infer<typeof PositionValidationSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user