feat(protobufs): sync to firmware-current and consume workspace package (#1097)

* feat(protobufs): sync to firmware-current and consume workspace package

Sync the vendored .proto sources to firmware-current (v2.7.25+48), regenerate the v2 TS bindings, and consume the workspace @meshtastic/protobufs (workspace:*) in place of the stale JSR 2.7.20 — finishing the monorepo migration (core was already workspace:*).

Includes the one required breaking-change fix: admin nodedb_reset changed int32 to bool, so resetNodes() now sends value: true.

* build(protobufs): vendor generated bindings for workspace consumers

The package is consumed via workspace:* — its exports point at the TS source, which imports ./dist/meshtastic/*_pb.ts — so the generated output must exist at build time. CI builds web/core with no codegen step and the runners have no buf CLI, so the bindings are vendored here (kept gitignored; lint/format skip them). Regenerate with: pnpm --filter @meshtastic/protobufs gen

* fix(protobufs): clean script removes the actual generated output dir

buf writes bindings to packages/ts/dist, but clean was removing a non-existent root dist — so it never cleaned stale output. Addresses Copilot review feedback.

* refactor: move web app packages/web -> apps/web

Aligns the web app with the apps/web layout (matching the Vercel web-test Root Directory and the SDK-migration direction). Pure directory move plus root config: pnpm-workspace (adds apps/*), vitest projects, root tsconfig reference, and the pr/release-web/nightly workflows. vercel.json moved with the app. Build + 36 validation tests green.

* feat: config fields, module pages, key verification, telemetry capture

Incorporates the firmware-current feature work onto the protobuf foundation: new config fields (Display message bubbles; LoRa fem_lna_mode + serial_hal_only; Telemetry air_quality_screen_enabled); 4 new ModuleConfig pages (TrafficManagement, StatusMessage, TAK, RemoteHardware); the manual Key Verification flow (sendKeyVerification + ClientNotificationDialog stages + Verify Key button); live telemetry capture (nodeDB addDeviceMetrics) and admin hardening (toggleMutedNode, graceful PortNum default); plus the sdk-preview ConfigEditor demo and store/config tests. Build + lint + format + 131 tests green.

* chore: drop #1062 (unsaved-change-detection) to match upstream revert

#1062 was merged to main by accident (per @danditomaso) and is being reverted. Reverse-applied its diff here via 3-way so #1097 stays consistent with where main is headed, while keeping the feature changes layered on the same files (deviceStore/changeRegistry). Build + 131 tests + lint + format green.

* fix(nodes): clean up SNR display in node table and map popup

SNR is a ratio measured in dB, not dBm (which is absolute power); the
node table and map popup both mislabeled it and crammed three values
together: '0dBm/50%/50raw'. The trailing '%/raw' pair was the same
heuristic ((snr+10)*5) shown twice — once clamped, once not.

Render SNR in dB rounded to one decimal, color-coded by a 0-100%
signal-quality heuristic (green/yellow/red), with the quality percentage
as a muted secondary. Drop the redundant raw value. Adds unit.db; this
matches the existing SNRTooltip, which already renders dB.
This commit is contained in:
Ben Meadors
2026-06-15 13:23:28 -05:00
committed by GitHub
parent 7a8529b841
commit bbe9a0d5cd
695 changed files with 27038 additions and 786 deletions
@@ -0,0 +1,265 @@
import { type ChannelValidation, makeChannelSchema } from "@app/validation/channel.ts";
import { create } from "@bufbuild/protobuf";
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { createZodResolver } from "@components/Form/createZodResolver.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { fromByteArray, toByteArray } from "base64-js";
import cryptoRandomString from "crypto-random-string";
import { useEffect, useMemo, useRef, useState } from "react";
import { type DefaultValues, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
export interface SettingsPanelProps {
onFormInit: DynamicFormFormInit<ChannelValidation>;
channel: Protobuf.Channel.Channel;
}
export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
const { config, setChange, getChange, removeChange } = useDevice();
const { t } = useTranslation(["channels", "ui", "dialog"]);
const defaultConfig = channel;
const defaultValues = {
...defaultConfig,
...{
settings: {
...defaultConfig?.settings,
psk: fromByteArray(defaultConfig?.settings?.psk ?? new Uint8Array(0)),
moduleSettings: {
...defaultConfig?.settings?.moduleSettings,
positionPrecision:
defaultConfig?.settings?.moduleSettings?.positionPrecision === undefined
? 10
: defaultConfig?.settings?.moduleSettings?.positionPrecision,
},
},
},
};
const workingChannel = getChange({
type: "channel",
index: channel.index,
}) as Protobuf.Channel.Channel | undefined;
const effectiveConfig = workingChannel ?? channel;
const formValues = {
...effectiveConfig,
...{
settings: {
...effectiveConfig?.settings,
psk: fromByteArray(effectiveConfig?.settings?.psk ?? new Uint8Array(0)),
moduleSettings: {
...effectiveConfig?.settings?.moduleSettings,
positionPrecision:
effectiveConfig?.settings?.moduleSettings?.positionPrecision === undefined
? 10
: effectiveConfig?.settings?.moduleSettings?.positionPrecision,
},
},
},
};
const [preSharedDialogOpen, setPreSharedDialogOpen] = useState<boolean>(false);
const [byteCount, setBytes] = useState<number>(effectiveConfig?.settings?.psk.length ?? 16);
const ChannelValidationSchema = useMemo(() => {
return makeChannelSchema(byteCount);
}, [byteCount]);
const formMethods = useForm<ChannelValidation>({
mode: "onChange",
defaultValues: defaultValues as DefaultValues<ChannelValidation>,
resolver: createZodResolver(ChannelValidationSchema),
shouldFocusError: false,
resetOptions: { keepDefaultValues: true },
values: formValues as ChannelValidation,
});
const { setValue, trigger, handleSubmit, formState } = formMethods;
useEffect(() => {
onFormInit?.(formMethods);
}, [onFormInit, formMethods]);
// Since byteCount is an independent state, we need to use the effective value
// from the channel config to ensure the form updates when the setting changes
const effectiveByteCount = effectiveConfig.settings?.psk.length ?? 16;
const lastEffectiveRef = useRef<number>(effectiveByteCount);
useEffect(() => {
if (effectiveByteCount !== lastEffectiveRef.current) {
lastEffectiveRef.current = effectiveByteCount;
setBytes(effectiveByteCount);
trigger("settings.psk");
}
}, [effectiveByteCount, trigger]);
const onSubmit = (data: ChannelValidation) => {
if (!formState.isReady) {
return;
}
const payload = create(Protobuf.Channel.ChannelSchema, {
...data,
settings: {
...data.settings,
psk: toByteArray(data.settings.psk),
moduleSettings: create(Protobuf.Channel.ModuleSettingsSchema, {
...data.settings.moduleSettings,
positionPrecision: data.settings.moduleSettings.positionPrecision,
}),
},
});
if (deepCompareConfig(channel, payload, true)) {
removeChange({ type: "channel", index: channel.index });
return;
}
setChange({ type: "channel", index: channel.index }, payload, channel);
};
const preSharedKeyRegenerate = async () => {
const newPsk = btoa(
cryptoRandomString({
length: byteCount ?? 16,
type: "alphanumeric",
}),
);
setValue("settings.psk", newPsk, { shouldDirty: true });
setPreSharedDialogOpen(false);
const valid = await trigger("settings.psk");
if (valid) {
handleSubmit(onSubmit)(); // manually invoke form submit
}
};
const selectChangeEvent = (e: string) => {
const count = Number.parseInt(e, 10);
if (!Number.isNaN(count)) {
setBytes(count);
trigger("settings.psk");
}
};
return (
<>
<DynamicForm<ChannelValidation>
propMethods={formMethods}
onSubmit={onSubmit}
fieldGroups={[
{
label: t("settings.label"),
description: t("settings.description"),
fields: [
{
type: "select",
name: "role",
label: t("role.label"),
disabled: channel.index === 0,
description: t("role.description"),
properties: {
enumValue:
channel.index === 0
? { [t("role.options.primary")]: 1 }
: {
[t("role.options.disabled")]: 0,
[t("role.options.secondary")]: 2,
},
},
},
{
type: "passwordGenerator",
name: "settings.psk",
id: "channel-psk",
label: t("psk.label"),
description: t("psk.description"),
devicePSKBitCount: byteCount ?? 16,
selectChange: selectChangeEvent,
actionButtons: [
{
text: t("psk.generate"),
variant: "success",
onClick: () => setPreSharedDialogOpen(true),
},
],
hide: true,
properties: {
showPasswordToggle: true,
showCopyButton: true,
},
},
{
type: "text",
name: "settings.name",
label: t("name.label"),
description: t("name.description"),
},
{
type: "toggle",
name: "settings.uplinkEnabled",
label: t("uplinkEnabled.label"),
description: t("uplinkEnabled.description"),
},
{
type: "toggle",
name: "settings.downlinkEnabled",
label: t("downlinkEnabled.label"),
description: t("downlinkEnabled.description"),
},
{
type: "select",
name: "settings.moduleSettings.positionPrecision",
label: t("positionPrecision.label"),
description: t("positionPrecision.description"),
properties: {
enumValue:
config.display?.units === 0
? {
[t("positionPrecision.options.none")]: 0,
[t("positionPrecision.options.metric_km23")]: 10,
[t("positionPrecision.options.metric_km12")]: 11,
[t("positionPrecision.options.metric_km5_8")]: 12,
[t("positionPrecision.options.metric_km2_9")]: 13,
[t("positionPrecision.options.metric_km1_5")]: 14,
[t("positionPrecision.options.metric_m700")]: 15,
[t("positionPrecision.options.metric_m350")]: 16,
[t("positionPrecision.options.metric_m200")]: 17,
[t("positionPrecision.options.metric_m90")]: 18,
[t("positionPrecision.options.metric_m50")]: 19,
[t("positionPrecision.options.precise")]: 32,
}
: {
[t("positionPrecision.options.none")]: 0,
[t("positionPrecision.options.imperial_mi15")]: 10,
[t("positionPrecision.options.imperial_mi7_3")]: 11,
[t("positionPrecision.options.imperial_mi3_6")]: 12,
[t("positionPrecision.options.imperial_mi1_8")]: 13,
[t("positionPrecision.options.imperial_mi0_9")]: 14,
[t("positionPrecision.options.imperial_mi0_5")]: 15,
[t("positionPrecision.options.imperial_mi0_2")]: 16,
[t("positionPrecision.options.imperial_ft600")]: 17,
[t("positionPrecision.options.imperial_ft300")]: 18,
[t("positionPrecision.options.imperial_ft150")]: 19,
[t("positionPrecision.options.precise")]: 32,
},
},
},
],
},
]}
/>
<PkiRegenerateDialog
text={{
button: t("pkiRegenerateDialog.regenerate", { ns: "dialog" }),
title: t("pkiRegenerateDialog.title", { ns: "dialog" }),
description: t("pkiRegenerateDialog.description", { ns: "dialog" }),
}}
open={preSharedDialogOpen}
onOpenChange={() => setPreSharedDialogOpen(false)}
onSubmit={() => preSharedKeyRegenerate()}
/>
</>
);
};
@@ -0,0 +1,74 @@
import { Channel } from "@app/components/PageComponents/Channels/Channel";
import { Button } from "@components/UI/Button.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/UI/Tabs.tsx";
import { useDevice } from "@core/stores";
import type { Protobuf } from "@meshtastic/core";
import i18next from "i18next";
import { QrCodeIcon, UploadIcon } from "lucide-react";
import { Suspense, useMemo } from "react";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
interface ConfigProps {
onFormInit: <T extends object>(methods: UseFormReturn<T>) => void;
}
export const getChannelName = (channel: Protobuf.Channel.Channel) => {
return channel.settings?.name.length
? channel.settings?.name
: channel.index === 0
? i18next.t("page.broadcastLabel")
: i18next.t("page.channelIndex", {
ns: "channels",
index: channel.index,
});
};
export const Channels = ({ onFormInit }: ConfigProps) => {
const { channels, hasChannelChange, setDialogOpen } = useDevice();
const { t } = useTranslation("channels");
const allChannels = Array.from(channels.values());
const flags = useMemo(
() => new Map(allChannels.map((channel) => [channel.index, hasChannelChange(channel.index)])),
[allChannels, hasChannelChange],
);
return (
<Tabs defaultValue="channel_0">
<TabsList className="w-full dark:bg-slate-700">
{allChannels.map((channel) => (
<TabsTrigger
key={`channel_${channel.index}`}
value={`channel_${channel.index}`}
className="dark:text-white relative"
>
{getChannelName(channel)}
{flags.get(channel.index) && (
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25" />
<span className="relative inline-flex size-3 rounded-full bg-sky-500" />
</span>
)}
</TabsTrigger>
))}
<Button className="ml-auto mr-1 h-8" onClick={() => setDialogOpen("import", true)}>
<UploadIcon className="mr-2" size={14} />
{t("page.import")}
</Button>
<Button className=" h-8" onClick={() => setDialogOpen("QR", true)}>
<QrCodeIcon className="mr-2" size={14} />
{t("page.export")}
</Button>
</TabsList>
{allChannels.map((channel) => (
<TabsContent key={`channel_${channel.index}`} value={`channel_${channel.index}`}>
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
<Channel key={channel.index} onFormInit={onFormInit} channel={channel} />
</Suspense>
</TabsContent>
))}
</Tabs>
);
};
@@ -0,0 +1,33 @@
import { Button } from "@app/components/UI/Button";
import type { Connection } from "@app/core/stores/deviceStore/types";
export function ConnectionStatusBadge({ status }: { status: Connection["status"] }) {
let color = "";
let displayStatus = status;
switch (status) {
case "connected":
case "configured":
color = "bg-emerald-500";
displayStatus = "connected";
break;
case "connecting":
case "configuring":
color = "bg-amber-500";
break;
case "online":
color = "bg-blue-500";
break;
case "error":
color = "bg-red-500";
break;
default:
color = "bg-gray-400";
}
return (
<Button variant="subtle" className="inline-flex items-center gap-2">
<span className={`h-2.5 w-2.5 rounded-full ${color}`} aria-hidden="true" />
<span className="text-xs capitalize text-slate-500 dark:text-slate-400">{displayStatus}</span>
</Button>
);
}
@@ -0,0 +1,87 @@
import { hasPos, toLngLat } from "@core/utils/geo";
import type { Protobuf } from "@meshtastic/core";
import type { Feature, FeatureCollection } from "geojson";
import type { HeatmapLayerSpecification } from "maplibre-gl";
import { useMemo } from "react";
import { Layer, Source } from "react-map-gl/maplibre";
export type HeatmapMode = "density" | "snr";
export interface HeatmapLayerProps {
id: string;
filteredNodes: Protobuf.Mesh.NodeInfo[];
mode: HeatmapMode;
}
export const HeatmapLayer = ({ id, filteredNodes, mode }: HeatmapLayerProps) => {
const data: FeatureCollection = useMemo(() => {
const features: Feature[] = filteredNodes
.filter((node) => hasPos(node.position))
.filter((node) => mode !== "snr" || node.snr !== undefined)
.map((node) => ({
type: "Feature",
geometry: {
type: "Point",
coordinates: toLngLat(node.position),
},
properties: {
snr: node.snr,
name: node.user?.longName,
shortName: node.user?.shortName,
num: node.num,
},
}));
return {
type: "FeatureCollection",
features,
};
}, [filteredNodes, mode]);
const paintProps: HeatmapLayerSpecification["paint"] = useMemo(
() => ({
"heatmap-weight":
mode === "density" ? 1 : ["interpolate", ["linear"], ["get", "snr"], -20, 0, 10, 1],
"heatmap-intensity": ["interpolate", ["linear"], ["zoom"], 0, 1, 15, 3],
// Color ramp for heatmap. Domain is 0 (low) to 1 (high).
// Begin color ramp at 0-stop with a 0-transparancy color
// to create a blur-like effect.
"heatmap-color": [
"interpolate",
["linear"],
["heatmap-density"],
0,
"rgba(33,102,172,0)",
0.2,
"rgb(103,169,207)",
0.4,
"rgb(209,229,240)",
0.6,
"rgb(253,219,199)",
0.8,
"rgb(239,138,98)",
1,
"rgb(178,24,43)",
],
"heatmap-radius": ["interpolate", ["linear"], ["zoom"], 0, 2, 9, 20, 15, 30],
// Opacity 0.7 to be visible but not blocking
"heatmap-opacity": 0.7,
}),
[mode],
);
return (
<Source type="geojson" data={data}>
<Layer id={id} type="heatmap" paint={paintProps} />
<Layer
id={`${id}-interaction`}
type="circle"
paint={{
"circle-radius": 15,
"circle-opacity": 0,
"circle-stroke-opacity": 0,
}}
/>
</Source>
);
};
@@ -0,0 +1,171 @@
import {
fanOutOffsetsPx,
groupNodesByIdenticalCoords,
type PxOffset,
} from "@components/PageComponents/Map/cluster.ts";
import {
generatePrecisionCircles,
SourcePrecisionCircles,
} from "@components/PageComponents/Map/Layers/PrecisionLayer.tsx";
import { NodeMarker } from "@components/PageComponents/Map/Markers/NodeMarker.tsx";
import { StackBadge } from "@components/PageComponents/Map/Markers/StackBadge.tsx";
import { NodeDetail } from "@components/PageComponents/Map/Popups/NodeDetail.tsx";
import type { PopupState } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx";
import { PopupWrapper } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx";
import { useMapFitting } from "@core/hooks/useMapFitting";
import { useNodeDB } from "@core/stores";
import { hasPos, toLngLat } from "@core/utils/geo.ts";
import type { Protobuf } from "@meshtastic/core";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { MapRef } from "react-map-gl/maplibre";
export interface NodeMarkerProps {
mapRef: MapRef | undefined;
filteredNodes: Protobuf.Mesh.NodeInfo[];
myNode: Protobuf.Mesh.NodeInfo | undefined;
expandedCluster: string | undefined;
setExpandedCluster: (key: string | undefined) => void;
popupState: PopupState | undefined;
setPopupState: (state: PopupState | undefined) => void;
isVisible: boolean;
}
export const NodesLayer = ({
mapRef,
filteredNodes,
myNode,
expandedCluster,
setExpandedCluster,
popupState,
setPopupState,
isVisible,
}: NodeMarkerProps): React.ReactNode[] => {
const { t } = useTranslation("map");
const { hasNodeError } = useNodeDB();
const { focusLngLat } = useMapFitting(mapRef);
const selectedNode = useMemo(
() =>
popupState?.type !== "node"
? undefined
: (filteredNodes.find((node) => node.num === popupState.num) ?? undefined),
[popupState, filteredNodes],
);
const onMarkerClick = useCallback(
(num: number, offset: PxOffset, e: { originalEvent: MouseEvent }) => {
e.originalEvent?.stopPropagation();
setPopupState({ type: "node", num, offset });
const node = filteredNodes.find((node) => node.num === num) ?? undefined;
if (node) {
focusLngLat(toLngLat(node.position));
}
},
[filteredNodes, focusLngLat, setPopupState],
);
const clusters = groupNodesByIdenticalCoords(filteredNodes);
const rendered: React.ReactNode[] = [];
for (const [key, nodes] of clusters) {
if (!nodes.length || !nodes[0]?.position) {
continue;
}
const [lng, lat] = toLngLat(nodes[0].position);
const isExpanded = expandedCluster === key;
// Precompute pixel offsets for expanded state
const expandedOffsets = isExpanded ? fanOutOffsetsPx(nodes.length, key) : undefined;
// Always render all node markers in the cluster
for (const [i, node] of nodes.entries()) {
const isHead = i === 0;
rendered.push(
<NodeMarker
key={`node-${key}-${node.num}`}
id={node.num}
lng={lng}
lat={lat}
offset={expandedOffsets?.[i]}
label={node.user?.shortName ?? t("unknown.shortName")}
tooltipLabel={node.user?.longName ?? t("unknown.longName")}
hasError={hasNodeError(node.num)}
isFavorite={node.isFavorite ?? false}
isVisible={isVisible}
onClick={(num, e) => {
e.originalEvent?.stopPropagation();
if (!isExpanded && !isHead) {
// collapsed: tapping a buried marker expands the stack first
setExpandedCluster(key);
return;
}
onMarkerClick(num, expandedOffsets?.[i] ?? [0, 0], e);
}}
/>,
);
}
if (nodes.length > 1) {
rendered.push(
<StackBadge
key={`stack-badge-${key}`}
lng={lng}
lat={lat}
count={nodes.length - 1}
isVisible={isVisible && !isExpanded}
onClick={(e) => {
e.originalEvent?.stopPropagation();
setExpandedCluster(key);
}}
/>,
);
}
}
if (selectedNode) {
rendered.push(
<SourcePrecisionCircles
key={`precision-circles-selected-${selectedNode.num}`}
data={generatePrecisionCircles([selectedNode])}
id={`precisionCircles-selected-${selectedNode.num}`}
isVisible={true}
/>,
);
const [lng, lat] = toLngLat(selectedNode.position);
rendered.push(
<PopupWrapper
key={`popup-nodeinfo-${selectedNode.num}`}
lng={lng}
lat={lat}
offset={popupState?.type === "node" ? popupState.offset : [0, 0]}
onClose={() => setPopupState(undefined)}
>
<NodeDetail node={selectedNode} />
</PopupWrapper>,
);
}
if (myNode && hasPos(myNode.position)) {
const [lng, lat] = toLngLat(myNode.position);
rendered.push(
<NodeMarker
key={`node-${myNode.num}`}
id={myNode.num}
lng={lng}
lat={lat}
label={myNode.user?.shortName?.toString() ?? String(myNode.num)}
tooltipLabel={t("myNode.tooltip")}
hasError={false}
isFavorite={true}
onClick={(_, e) => onMarkerClick(myNode.num, [0, 0], e)}
/>,
);
}
return rendered;
};
@@ -0,0 +1,122 @@
import { getColorFromNodeNum, isLightColor } from "@app/core/utils/color";
import { precisionBitsToMeters, toLngLat } from "@core/utils/geo.ts";
import type { Protobuf } from "@meshtastic/core";
import { circle } from "@turf/turf";
import type { Feature, FeatureCollection, Polygon } from "geojson";
import { Layer, Source } from "react-map-gl/maplibre";
export interface PrecisionLayerProps {
id: string;
filteredNodes: Protobuf.Mesh.NodeInfo[];
isVisible: boolean;
}
type CircleProps = {
r: number;
g: number;
b: number;
a?: number;
sortKey: number;
};
export function generatePrecisionCircles(
filteredNodes: Protobuf.Mesh.NodeInfo[],
): FeatureCollection {
const unique = new Map<
string,
{
lng: number;
lat: number;
radiusM: number;
r: number;
g: number;
b: number;
a: number;
}
>();
for (const node of filteredNodes) {
if (node.position?.precisionBits === undefined || node.position.precisionBits === 0) {
continue;
}
const [lng, lat] = toLngLat(node.position);
const radiusM = precisionBitsToMeters(node.position?.precisionBits ?? 0);
const color = getColorFromNodeNum(node.num);
const isLight = isLightColor(color);
const key = `${lat},${lng}:${radiusM}`;
if (!unique.has(key)) {
unique.set(key, {
lng,
lat,
radiusM,
r: color.r,
g: color.g,
b: color.b,
a: isLight ? 0.3 : 0.2, // light colors need more alpha to be as visible
});
}
}
const items = Array.from(unique.values()).sort((a, b) => a.radiusM - b.radiusM);
const features: Feature<Polygon, CircleProps>[] = items.map(
({ lng, lat, radiusM, r, g, b, a }) => {
const feat = circle([lng, lat], radiusM, {
steps: 64,
units: "meters",
}) as Feature<Polygon, CircleProps>;
feat.properties = { r, g, b, a, sortKey: -radiusM };
return feat;
},
);
return { type: "FeatureCollection", features };
}
export const SourcePrecisionCircles = ({
data,
id,
isVisible,
}: {
data: FeatureCollection;
id: string;
isVisible: boolean;
}) => {
return (
<Source id={id} type="geojson" data={data}>
<Layer
id={`${id}-fill`}
type="fill"
layout={{ visibility: isVisible ? "visible" : "none" }}
paint={{
"fill-color": ["rgba", ["get", "r"], ["get", "g"], ["get", "b"], ["get", "a"]],
}}
/>
<Layer
id={`${id}-line`}
type="line"
layout={{ visibility: isVisible ? "visible" : "none" }}
paint={{
"line-color": ["rgba", 255, 255, 255, 0.5],
"line-width": 2,
}}
/>
</Source>
);
};
export const PrecisionLayer = ({
id,
filteredNodes,
isVisible,
}: PrecisionLayerProps): React.ReactNode => {
return (
<SourcePrecisionCircles
data={generatePrecisionCircles(filteredNodes)}
id={id}
isVisible={isVisible}
/>
);
};
@@ -0,0 +1,329 @@
import { Mono } from "@app/components/generic/Mono";
import { cn } from "@app/core/utils/cn";
import { getSignalColor } from "@app/core/utils/signalColor";
import type { VisibilityState } from "@components/PageComponents/Map/Tools/MapLayerTool";
import { useDevice } from "@core/stores";
import {
distanceMeters,
hasPos,
type LngLat,
lngLatToMercator,
mercatorToLngLat,
toLngLat,
} from "@core/utils/geo";
import type { Protobuf } from "@meshtastic/core";
import type { Feature, FeatureCollection } from "geojson";
import { useTranslation } from "react-i18next";
import { Layer, Source } from "react-map-gl/maplibre";
const ARC_SEGMENTS = 32;
const ARC_OFFSET = 0.01; // 1% of distance
const MIN_LEN = 1e-3; // meters
export interface SNRLayerProps {
id: string;
filteredNodes: Protobuf.Mesh.NodeInfo[];
myNode: Protobuf.Mesh.NodeInfo | undefined;
visibilityState: VisibilityState;
}
export interface SNRTooltipProps {
pos: { x: number; y: number };
snr: number;
from: string;
to: string;
}
type NeighborPlus = Protobuf.Mesh.Neighbor & {
num: number | undefined;
position: Protobuf.Mesh.Position | undefined;
};
type NeighborInfoPlus = Omit<Protobuf.Mesh.NeighborInfo, "neighbors"> & {
neighbors: NeighborPlus[];
};
type RemoteInfo = {
type: "remote";
node: Protobuf.Mesh.NodeInfo;
neighborInfo: NeighborInfoPlus;
};
type DirectInfo = {
type: "direct";
from: Protobuf.Mesh.NodeInfo;
to: Protobuf.Mesh.NodeInfo;
snr: number;
};
type NeighborInfos = RemoteInfo | DirectInfo;
type Pair = {
a: number;
b: number;
ab?: number; // SNR a->b
ba?: number; // SNR b->a
};
function arcSegment(aLngLat: LngLat, bLngLat: LngLat, curved: boolean): LngLat[] | undefined {
if (!curved && distanceMeters(aLngLat, bLngLat) < MIN_LEN) {
// Straight line
return [aLngLat, bLngLat];
}
const [ax, ay] = lngLatToMercator(aLngLat);
const [bx, by] = lngLatToMercator(bLngLat);
const dx = bx - ax,
dy = by - ay;
const len = Math.hypot(dx, dy);
if (len < MIN_LEN) {
return undefined;
}
const offsetMeters = curved ? len * ARC_OFFSET : 0;
// Unit direction A->B and its left-hand normal
const ux = dx / len,
uy = dy / len;
const nx = -uy,
ny = ux;
// Control point at the midpoint, offset to the left by offsetMeters
const mx = (ax + bx) * 0.5,
my = (ay + by) * 0.5;
const cx = mx + nx * offsetMeters,
cy = my + ny * offsetMeters;
const coords: LngLat[] = [];
for (let i = 0; i <= ARC_SEGMENTS; i++) {
const t = i / ARC_SEGMENTS;
const omt = 1 - t;
// Quadratic Bézier: B(t) = (1−t)^2*A + 2(1−t)t*C + t^2*B
const x = omt * omt * ax + 2 * omt * t * cx + t * t * bx;
const y = omt * omt * ay + 2 * omt * t * cy + t * t * by;
coords.push(mercatorToLngLat([x, y]));
}
return coords;
}
function upsertPair(
fromId: number,
toId: number,
snr: number,
pairs: Map<string, Pair>,
idToLngLat: Map<number, LngLat>,
): void {
if (fromId === toId || !idToLngLat.has(fromId) || !idToLngLat.has(toId)) {
return;
}
const a = Math.min(fromId, toId);
const b = Math.max(fromId, toId);
const key = `${a}-${b}`;
let pair = pairs.get(key); // pair might exist
if (!pair) {
pair = { a, b };
}
// Store best SNR for each direction
if (fromId === a) {
pair.ab = pair.ab && pair.ab > snr ? pair.ab : snr;
} else {
pair.ba = pair.ba && pair.ba > snr ? pair.ba : snr;
}
pairs.set(key, pair);
}
function makeFeature(
fromId: number,
toId: number,
fromPos: LngLat,
toPos: LngLat,
snr: number,
curved: boolean,
): Feature | undefined {
const segment = arcSegment(fromPos, toPos, curved);
if (!segment) {
return undefined;
}
return {
type: "Feature",
geometry: { type: "LineString", coordinates: segment },
properties: {
color: getSignalColor(snr),
snr,
from: fromId,
to: toId,
},
};
}
function pushIfFeature(
a: number,
b: number,
aPos: LngLat,
bPos: LngLat,
snr: number,
curved: boolean,
features: Feature[],
) {
const feat = makeFeature(a, b, aPos, bPos, snr, curved);
if (feat) {
features.push(feat);
}
}
function generateNeighborLines(neighborInfos: NeighborInfos[]): FeatureCollection {
// Collect positions for all referenced nodes, discard pairs with missing positions
const idToLngLat = new Map<number, LngLat>();
const ensure = (node?: Protobuf.Mesh.NodeInfo | NeighborPlus) => {
if (node?.num && hasPos(node.position) && !idToLngLat.has(node.num)) {
idToLngLat.set(node.num, toLngLat(node.position));
}
};
for (const info of neighborInfos) {
if (info.type === "remote") {
ensure(info.node);
for (const neighbor of info.neighborInfo.neighbors) {
ensure(neighbor);
}
} else {
ensure(info.from);
ensure(info.to);
}
}
// Coalesce into pairs
const pairs = new Map<string, Pair>();
for (const info of neighborInfos) {
if (info.type === "remote") {
// RemoteInfo object
const fromId = info.node.num;
for (const neighbor of info.neighborInfo.neighbors) {
upsertPair(fromId, neighbor.nodeId, neighbor.snr, pairs, idToLngLat);
}
} else {
// DirectInfo object
upsertPair(info.from.num, info.to.num, info.snr, pairs, idToLngLat);
}
}
// Generate features
const features: Feature[] = [];
for (const pair of pairs.values()) {
const aPos = idToLngLat.get(pair.a);
const bPos = idToLngLat.get(pair.b);
if (!aPos || !bPos) {
continue;
}
if (pair.ab && pair.ba) {
// both directions → two arcs
pushIfFeature(pair.a, pair.b, aPos, bPos, pair.ab, true, features);
pushIfFeature(pair.b, pair.a, bPos, aPos, pair.ba, true, features);
} else {
// only one direction → straight
if (pair.ab) {
pushIfFeature(pair.a, pair.b, aPos, bPos, pair.ab, false, features);
}
if (pair.ba) {
pushIfFeature(pair.b, pair.a, bPos, aPos, pair.ba, false, features);
}
}
}
return { type: "FeatureCollection", features };
}
export const SNRTooltip = ({ pos, snr, from, to }: Partial<SNRTooltipProps> = {}) => {
const { t } = useTranslation();
if (!pos) {
return undefined;
}
return (
<div
className={cn(
"absolute block p-2 px-3 text-sm bg-white dark:bg-slate-800 rounded-lg shadow",
"",
)}
style={{ left: `${pos.x + 5}px`, top: `${pos.y + 10}px` }}
aria-hidden={!pos}
>
<div>
<strong className="font-bold">{from ?? ""}</strong>
{to && (
<>
<span className="mx-1">⭢</span>
<strong className="font-bold">{to ?? ""}</strong>
</>
)}
</div>
<div>
SNR: <Mono>{snr?.toFixed?.(2) ?? t("unknown.shortName")}</Mono> dB
</div>
</div>
);
};
export const SNRLayer = ({
id,
filteredNodes,
myNode,
visibilityState,
}: SNRLayerProps): React.ReactNode => {
const { getNeighborInfo } = useDevice();
const remotePairs = visibilityState.remoteNeighbors
? filteredNodes.flatMap((node) => {
const neighborInfo = getNeighborInfo(node.num);
return neighborInfo
? [
{
type: "remote" as const,
node,
neighborInfo: {
...neighborInfo,
neighbors: neighborInfo.neighbors.map((n) => {
const node = filteredNodes.find((node) => node.num === n.nodeId);
return { ...n, num: node?.num, position: node?.position };
}),
},
} as const,
]
: [];
})
: [];
const directPairs =
visibilityState.directNeighbors && myNode
? filteredNodes
.filter((node) => node.hopsAway === 0 && node.num !== myNode.num)
.map((to) => ({
type: "direct" as const,
from: myNode,
to,
snr: to.snr ?? 0,
}))
: [];
const featureCollection = generateNeighborLines([...remotePairs, ...directPairs]);
return (
<Source type="geojson" data={featureCollection}>
<Layer
id={id}
type="line"
paint={{
"line-color": ["get", "color"],
"line-width": 5,
}}
/>
</Source>
);
};
@@ -0,0 +1,90 @@
import { NodeMarker } from "@components/PageComponents/Map/Markers/NodeMarker.tsx";
import type { PopupState } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx";
import { PopupWrapper } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx";
import { WaypointDetail } from "@components/PageComponents/Map/Popups/WaypointDetail.tsx";
import { useMapFitting } from "@core/hooks/useMapFitting";
import { useDevice, type WaypointWithMetadata } from "@core/stores";
import type { Protobuf } from "@meshtastic/core";
import { useCallback } from "react";
import type { MapRef } from "react-map-gl/maplibre";
export interface WaypointLayerProps {
mapRef: MapRef | undefined;
myNode: Protobuf.Mesh.NodeInfo | undefined;
isVisible: boolean;
popupState: PopupState | undefined;
setPopupState: (state: PopupState | undefined) => void;
}
import { toLngLat } from "@core/utils/geo.ts";
export const WaypointLayer = ({
mapRef,
myNode,
isVisible,
popupState,
setPopupState,
}: WaypointLayerProps): React.ReactNode[] => {
const { waypoints } = useDevice();
const { focusLngLat } = useMapFitting(mapRef);
const onMarkerClick = useCallback(
(waypoint: WaypointWithMetadata, e: { originalEvent: MouseEvent }) => {
e.originalEvent?.stopPropagation();
setPopupState({ type: "waypoint", waypoint });
if (waypoint.longitudeI && waypoint.latitudeI) {
focusLngLat(
toLngLat({
longitudeI: waypoint.longitudeI,
latitudeI: waypoint.latitudeI,
}),
);
}
},
[focusLngLat, setPopupState],
);
const rendered: React.ReactNode[] = [];
if (!isVisible) {
return rendered;
}
for (const waypoint of waypoints) {
const [lng, lat] = toLngLat({
latitudeI: waypoint.latitudeI,
longitudeI: waypoint.longitudeI,
});
rendered.push(
<NodeMarker
key={`waypoint-${waypoint.id}`}
id={waypoint.id}
lng={lng}
lat={lat}
label={String.fromCodePoint(waypoint.icon) ?? "📍"}
longLabel={waypoint.name}
avatarClassName="bg-amber-400 border-amber-500"
onClick={(_, e) => onMarkerClick(waypoint, e)}
/>,
);
}
if (popupState?.type === "waypoint") {
const [lng, lat] = toLngLat({
latitudeI: popupState.waypoint.latitudeI,
longitudeI: popupState.waypoint.longitudeI,
});
rendered.push(
<PopupWrapper
key={`popup-waypoint-${popupState.waypoint.id}`}
lng={lng}
lat={lat}
offset={[0, 25]}
onClose={() => setPopupState(undefined)}
>
<WaypointDetail waypoint={popupState.waypoint} myNode={myNode} onEdit={() => {}} />
</PopupWrapper>,
);
}
return rendered;
};
@@ -0,0 +1,105 @@
import { cn } from "@app/core/utils/cn";
import type { PxOffset } from "@components/PageComponents/Map/cluster.ts";
import { Avatar } from "@components/UI/Avatar.tsx";
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { memo } from "react";
import { Marker } from "react-map-gl/maplibre";
export const NodeMarker = memo(function NodeMarker({
id,
lng,
lat,
longLabel,
tooltipLabel,
hasError,
isFavorite,
offset,
avatarClassName,
isVisible = true,
onClick,
}: {
id: number;
lng: number;
lat: number;
label: string;
longLabel?: string;
tooltipLabel?: string;
hasError?: boolean;
isFavorite?: boolean;
offset?: PxOffset;
avatarClassName?: string;
isVisible?: boolean;
onClick: (id: number, e: { originalEvent: MouseEvent }) => void;
}) {
const [dx, dy] = offset ?? [0, 0];
const style = {
"--dx": `${dx}px`,
"--dy": `${dy}px`,
pointerEvents: "auto",
} as React.CSSProperties;
return (
<Marker
longitude={lng}
latitude={lat}
anchor="bottom"
style={{
top: "20px",
pointerEvents: "none",
display: isVisible ? "" : "none",
}}
>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="will-change-transform cursor-pointer animate-fan-out"
style={style}
onClick={(e) => onClick(id, { originalEvent: e.nativeEvent })}
>
<Avatar
nodeNum={id}
className={cn(
"border-[1.5px] border-slate-600 shadow-m shadow-slate-600",
avatarClassName,
)}
showError={hasError}
showFavorite={isFavorite}
/>
</button>
</TooltipTrigger>
<TooltipPortal>
{tooltipLabel && ( // only show tooltip if there's a label
<TooltipContent
className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs cursor-pointer"
onClick={(e) => onClick(id, { originalEvent: e.nativeEvent })}
>
{tooltipLabel}
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent>
)}
</TooltipPortal>
</Tooltip>
</TooltipProvider>
{longLabel && ( // only show label if there's a longLabel
<button
type="button"
className="absolute top-16 left-1/2 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded bg-white/70 px-2 py-0.5 text-xs text-slate-900 backdrop-blur-xs cursor-pointer"
style={style}
onClick={(e) => onClick(id, { originalEvent: e.nativeEvent })}
>
{longLabel}
</button>
)}
</Marker>
);
});
@@ -0,0 +1,27 @@
import { Marker } from "react-map-gl/maplibre";
type StackBadgeProps = {
lng: number;
lat: number;
count: number; // n = size-1
isVisible?: boolean;
onClick: (e: { originalEvent: MouseEvent }) => void;
};
export const StackBadge = ({ lng, lat, count, isVisible = true, onClick }: StackBadgeProps) => {
return (
<Marker
longitude={lng}
latitude={lat}
style={{ left: 18, top: -18, display: isVisible ? "" : "none" }}
>
<button
onClick={(e) => onClick({ originalEvent: e.nativeEvent })}
className="rounded-full bg-blue-600 text-white text-xs px-2 py-0.5 shadow ring-1 ring-black/20 active:bg-red-800"
type={"button"}
>
+{count}
</button>
</Marker>
);
};
@@ -0,0 +1,198 @@
import BatteryStatus from "@components/BatteryStatus.tsx";
import { Mono } from "@components/generic/Mono.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import { Separator } from "@components/UI/Separator.tsx";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { formatQuantity } from "@core/utils/string.ts";
import type { Protobuf as ProtobufType } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/core";
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipTrigger,
} from "@radix-ui/react-tooltip";
import { useNavigate } from "@tanstack/react-router";
import { Dot, LockIcon, LockOpenIcon, MessageSquareIcon, MountainSnow, Star } from "lucide-react";
import { useTranslation } from "react-i18next";
export interface NodeDetailProps {
node: ProtobufType.Mesh.NodeInfo;
}
export const NodeDetail = ({ node }: NodeDetailProps) => {
const navigate = useNavigate();
const { t } = useTranslation("nodes");
const name = node.user?.longName ?? t("unknown.shortName");
const shortName = node.user?.shortName ?? t("unknown.shortName");
const hwModel = node.user?.hwModel ?? 0;
const rawHardwareType = Protobuf.Mesh.HardwareModel[hwModel] as
| keyof typeof Protobuf.Mesh.HardwareModel
| undefined;
const hardwareType = rawHardwareType
? rawHardwareType === "UNSET"
? t("unset")
: rawHardwareType.replaceAll("_", " ")
: `${hwModel}`;
// SNR is in dB; derive a 0–100% quality heuristic (-10 dB → 0%, +10 dB → 100%).
const snrQuality = Math.min(Math.max(Math.round((node.snr + 10) * 5), 0), 100);
const snrTone =
snrQuality >= 67 ? "text-green-600" : snrQuality >= 34 ? "text-yellow-600" : "text-red-600";
function handleDirectMessage() {
navigate({ to: `/messages/direct/${node.num}` });
}
return (
<div className="p-1 text-slate-900">
<div className="flex gap-2">
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
<Avatar nodeNum={node.num} size="sm" />
<div
onFocusCapture={(e) => {
// Required to prevent DM tooltip auto-appearing on creation
e.stopPropagation();
}}
>
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
<LockIcon
className="text-green-600 mb-1.5"
size={12}
strokeWidth={3}
aria-label={t("nodeDetail.publicKeyEnabled.label")}
/>
) : (
<LockOpenIcon
className="text-yellow-500 mb-1.5"
size={12}
strokeWidth={3}
aria-label={t("nodeDetail.noPublicKey.label")}
/>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<MessageSquareIcon
size={14}
onClick={handleDirectMessage}
className="cursor-pointer hover:text-blue-500"
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
side="top"
align="center"
sideOffset={5}
>
{t("nodeDetail.directMessage.label", {
shortName,
})}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</TooltipProvider>
<Star
fill={node.isFavorite ? "black" : "none"}
size={15}
aria-label={
node.isFavorite ? t("nodeDetail.favorite.label") : t("nodeDetail.notFavorite.label")
}
/>
</div>
</div>
<div>
<Heading as="h5">{name}</Heading>
{hardwareType !== t("unset") && <Subtle>{hardwareType}</Subtle>}
{!!node.deviceMetrics?.batteryLevel && (
<BatteryStatus deviceMetrics={node.deviceMetrics} />
)}
<div className="flex gap-2 items-center">
{node.user?.shortName && <div>"{node.user?.shortName}"</div>}
{node.user?.id && <div>{node.user?.id}</div>}
</div>
<div
className="flex gap-1"
title={new Date(node.lastHeard * 1000).toLocaleString(navigator.language)}
>
<div>
{node.lastHeard > 0 && (
<div>
{t("nodeDetail.status.heard")} <TimeAgo timestamp={node.lastHeard * 1000} />
</div>
)}
</div>
{node.viaMqtt && (
<div style={{ color: "#660066" }} className="font-medium">
{t("nodeDetail.status.mqtt")}
</div>
)}
</div>
</div>
</div>
<Separator className="my-1" />
<div className="flex mt-2 text-sm">
<div className="flex items-center grow">
<div className="border-2 border-slate-900 rounded-sm px-0.5 mr-1">
{Number.isNaN(node.hopsAway) ? t("unit.hopsAway.unknown") : node.hopsAway}
</div>
<div>{node.hopsAway === 1 ? t("unit.hop.one") : t("unit.hop.plural")}</div>
</div>
{node.position?.altitude && (
<div className="flex items-center grow">
<MountainSnow
size={15}
className="ml-2 mr-1"
aria-label={t("nodeDetail.elevation.label")}
/>
<div>
{formatQuantity(node.position?.altitude, {
one: t("unit.meter.one"),
other: t("unit.meter.plural"),
})}
</div>
</div>
)}
</div>
<div className="flex mt-2">
{!!node.deviceMetrics?.channelUtilization && (
<div className="grow">
<div>{t("channelUtilization.short")}</div>
<Mono>{node.deviceMetrics?.channelUtilization.toPrecision(3)}%</Mono>
</div>
)}
{!!node.deviceMetrics?.airUtilTx && (
<div className="grow">
<div>{t("airtimeUtilization.short")}</div>
<Mono className="text-gray-500">{node.deviceMetrics?.airUtilTx.toPrecision(3)}%</Mono>
</div>
)}
</div>
{node.snr !== 0 && (
<div className="mt-2">
<div>{t("unit.snr")}</div>
<Mono className="flex items-center gap-1 text-xs">
<span className={snrTone}>
{Number(node.snr.toFixed(1))} {t("unit.db")}
</span>
<Dot className="text-gray-400" />
<span className="text-gray-500">{snrQuality}%</span>
</Mono>
</div>
)}
</div>
);
};
@@ -0,0 +1,38 @@
import type { PxOffset } from "@components/PageComponents/Map/cluster.ts";
import type { WaypointWithMetadata } from "@core/stores";
import { memo } from "react";
import { Popup } from "react-map-gl/maplibre";
export type PopupState =
| { type: "node"; num: number; offset: PxOffset }
| { type: "waypoint"; waypoint: WaypointWithMetadata };
export const PopupWrapper = memo(function SelectedNodePopup({
lng,
lat,
offset,
onClose,
children,
}: {
lng: number;
lat: number;
offset?: PxOffset;
onClose: () => void;
children: React.ReactNode;
}) {
return (
<Popup
anchor="top"
longitude={lng}
latitude={lat}
onClose={onClose}
className="w-full"
style={{
left: `${offset?.[0] ?? 0}px`,
top: `${(offset?.[1] ?? 0) + 22}px`,
}}
>
{children}
</Popup>
);
});
@@ -0,0 +1,177 @@
import { TimeAgo } from "@components/generic/TimeAgo";
import { Separator } from "@components/UI/Separator.tsx";
import type { WaypointWithMetadata } from "@core/stores";
import { useNodeDB } from "@core/stores";
import { bearingDegrees, distanceMeters, hasPos, toLngLat } from "@core/utils/geo";
import type { Protobuf } from "@meshtastic/core";
import {
ClockFadingIcon,
ClockPlusIcon,
CompassIcon,
MapPinnedIcon,
MoveHorizontalIcon,
NavigationIcon,
RotateCwIcon,
UserLockIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
interface WaypointDetailProps {
waypoint: WaypointWithMetadata;
myNode?: Protobuf.Mesh.NodeInfo;
onEdit: () => void;
}
export const WaypointDetail = ({ waypoint, myNode }: WaypointDetailProps) => {
const { t } = useTranslation("map");
const { getNode } = useNodeDB();
const waypointLngLat = toLngLat({
latitudeI: waypoint.latitudeI,
longitudeI: waypoint.longitudeI,
});
const distance = hasPos(myNode?.position)
? distanceMeters(toLngLat(myNode?.position), waypointLngLat)
: undefined;
const bearing = hasPos(myNode?.position)
? bearingDegrees(toLngLat(myNode?.position), waypointLngLat)
: undefined;
return (
<article
aria-labelledby={`wp-${waypoint.id}-title`}
className="flex flex-col gap-2 px-1 text-sm dark:text-slate-900"
>
<header className="flex items-center my-1 justify-between">
<h3
id={`wp-${waypoint.id}-title`}
className="flex items-center gap-2 font-semibold text-slate-900"
>
<span aria-hidden>{String.fromCodePoint(waypoint.icon) ?? "📍"}</span>
<span>{waypoint.name}</span>
</h3>
</header>
{waypoint.description && (
<p className="inline-flex items-center gap-1">{waypoint.description}</p>
)}
<Separator className="dark:bg-slate-200" role="separator" />
<section aria-label={t("waypointDetail.details")}>
<dl className="space-y-1.5">
{/* Coordinates */}
<div className="flex flex-wrap items-start gap-x-3">
<dt className="inline-flex items-top gap-2 text-slate-500 min-w-0">
<MapPinnedIcon size={14} aria-hidden className="mt-1" />
<span className="truncate">
{t("waypointDetail.longitude")}
<br />
{t("waypointDetail.latitude")}
</span>
</dt>
<dd className="ms-auto text-right">
<data value={waypointLngLat[0]}>{waypointLngLat[0]}</data>
<br />
<data value={waypointLngLat[1]}>{waypointLngLat[1]}</data>
</dd>
</div>
{/* Created */}
<div className="flex flex-wrap items-start gap-x-3">
<dt className="inline-flex items-center gap-2 text-slate-500 min-w-0">
<ClockPlusIcon size={14} aria-hidden />
<span className="truncate">{t("waypointDetail.createdDate")}</span>
</dt>
<dd className="ms-auto text-right">
<time dateTime={new Date(waypoint.metadata.created).toISOString()}>
<TimeAgo timestamp={waypoint.metadata.created} />
</time>
</dd>
</div>
{/* Updated */}
{waypoint.metadata.updated && (
<div className="flex flex-wrap items-start gap-x-3">
<dt className="inline-flex items-center gap-2 text-slate-500 min-w-0">
<RotateCwIcon size={14} aria-hidden />
<span className="truncate">{t("waypointDetail.updated")}</span>
</dt>
<dd className="ms-auto text-right">
<time dateTime={new Date(waypoint.metadata.updated).toISOString()}>
<TimeAgo timestamp={waypoint.metadata.updated} />
</time>
</dd>
</div>
)}
{/* Expires */}
{waypoint.expire !== 0 && (
<div className="flex flex-wrap items-start gap-x-3">
<dt className="inline-flex items-center gap-2 text-slate-500 min-w-0">
<ClockFadingIcon size={14} aria-hidden />
<span className="truncate">{t("waypointDetail.expires")}</span>
</dt>
<dd className="ms-auto text-right">
<time dateTime={new Date(waypoint.expire * 1000).toISOString()}>
<TimeAgo timestamp={waypoint.expire * 1000} />
</time>
</dd>
</div>
)}
{/* Distance */}
{distance != null && (
<div className="flex flex-wrap items-start gap-x-3">
<dt className="inline-flex items-center gap-2 text-slate-500 min-w-0">
<MoveHorizontalIcon size={14} aria-hidden />
<span className="truncate">{t("waypointDetail.distance")}</span>
</dt>
<dd className="ms-auto text-right">
<data value={Math.round(distance)}>
{Math.round(distance)}{" "}
{distance === 1 ? t("unit.meter.one") : t("unit.meter.plural")}
</data>
</dd>
</div>
)}
{/* Bearing */}
{bearing != null && (
<div className="flex flex-wrap items-start gap-x-3">
<dt className="inline-flex items-center gap-2 text-slate-500 min-w-0">
<CompassIcon size={14} aria-hidden />
<span className="truncate">{t("waypointDetail.bearing")}</span>
</dt>
<dd className="ms-auto text-right inline-flex items-center ">
<NavigationIcon
size={16}
aria-hidden
className="shrink-0 origin-center transition-transform mr-2"
style={{ transform: `rotate(${bearing - 45}deg)` }}
/>
<data value={Math.round(bearing)}>{Math.round(bearing)}</data>
<span aria-hidden>{t("unit.degree.suffix")}</span>
</dd>
</div>
)}
{/* Locked To */}
{waypoint.lockedTo != null && waypoint.lockedTo !== 0 && (
<div className="flex flex-wrap items-start gap-x-3">
<dt className="inline-flex items-center gap-2 text-slate-500 min-w-0">
<UserLockIcon size={14} aria-hidden />
<span className="truncate">{t("waypointDetail.lockedTo")}</span>
</dt>
<dd className="ms-auto text-right">
{getNode(waypoint.lockedTo)?.user?.longName ?? t("unknown.longName")}
</dd>
</div>
)}
</dl>
</section>
</article>
);
};
@@ -0,0 +1,180 @@
import type { HeatmapMode } from "@components/PageComponents/Map/Layers/HeatmapLayer.tsx";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "@components/UI/Popover.tsx";
import { cn } from "@core/utils/cn.ts";
import { LayersIcon } from "lucide-react";
import { type ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next";
export interface VisibilityState {
nodeMarkers: boolean;
directNeighbors: boolean;
remoteNeighbors: boolean;
positionPrecision: boolean;
traceroutes: boolean;
waypoints: boolean;
heatmap: boolean;
}
export const defaultVisibilityState: VisibilityState = {
nodeMarkers: true,
directNeighbors: false,
remoteNeighbors: false,
positionPrecision: false,
traceroutes: false,
waypoints: true,
heatmap: false,
};
interface MapLayerToolProps {
visibilityState: VisibilityState;
setVisibilityState: (state: VisibilityState) => void;
heatmapMode: HeatmapMode;
setHeatmapMode: (mode: HeatmapMode) => void;
}
interface CheckboxProps {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
className?: string;
}
const CheckboxItem = ({ label, checked, onChange, className }: CheckboxProps) => {
return (
<Checkbox
checked={checked}
onChange={onChange}
className={cn("flex items-center gap-2", className)}
>
<span className="dark:text-slate-200">{label}</span>
</Checkbox>
);
};
export function MapLayerTool({
visibilityState,
setVisibilityState,
heatmapMode,
setHeatmapMode,
}: MapLayerToolProps): ReactNode {
const { t } = useTranslation("map");
const enabledCount = useMemo(() => {
return Object.values(visibilityState).filter(Boolean).length;
}, [visibilityState]);
const handleCheckboxChange = (key: keyof VisibilityState) => {
if (key === "heatmap" && !visibilityState.heatmap) {
// If turning heatmap on, turn everything else off so the layer is visible
setVisibilityState({
nodeMarkers: false,
directNeighbors: false,
remoteNeighbors: false,
positionPrecision: false,
traceroutes: false,
waypoints: false,
heatmap: true,
});
} else {
setVisibilityState({
...visibilityState,
[key]: !visibilityState[key],
});
}
};
const layers = useMemo(
() => [
{ key: "nodeMarkers", label: t("layerTool.nodeMarkers") },
{ key: "waypoints", label: t("layerTool.waypoints") },
{ key: "directNeighbors", label: t("layerTool.directNeighbors") },
{ key: "remoteNeighbors", label: t("layerTool.remoteNeighbors") },
{ key: "positionPrecision", label: t("layerTool.positionPrecision") },
{ key: "heatmap", label: t("layerTool.heatmap") },
// { key: "traceroutes", label: t("layerTool.traceroutes") },
],
[t],
);
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"relative rounded align-center",
"w-[29px] px-1 py-1 shadow-l outline-[2px] outline-stone-600/20",
"bg-stone-50 hover:bg-stone-200 dark:bg-stone-200 dark:hover:bg-stone-300 ",
"text-slate-600 hover:text-slate-700 active:bg-slate-300",
"dark:text-slate-600 hover:dark:text-slate-700",
)}
aria-label={t("mapMenu.layersAria")}
>
<LayersIcon className="w-[21px]" />
{enabledCount > 0 && (
<span
className={cn(
"absolute -bottom-2 -right-2",
"min-w-4 h-4 px-[3px]",
"rounded-full text-[10px] leading-4",
"bg-blue-500 text-white",
"flex items-center justify-center",
"ring-2 ring-white dark:ring-stone-200",
)}
aria-hidden="true"
>
{enabledCount}
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent
className={cn("dark:text-slate-300 flex flex-col space-y-2 py-4")}
side={"bottom"}
align="end"
sideOffset={7}
>
{layers.map(({ key, label }) => (
<div key={key}>
<CheckboxItem
label={label}
checked={visibilityState[key as keyof VisibilityState]}
onChange={() => handleCheckboxChange(key as keyof VisibilityState)}
/>
{key === "heatmap" && visibilityState.heatmap && (
<div className="pl-6 pt-2 flex flex-col gap-1">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
className="accent-blue-500"
checked={heatmapMode === "density"}
onChange={() => setHeatmapMode("density")}
/>
<span className="text-sm dark:text-slate-300">{t("layerTool.density")}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
className="accent-blue-500"
checked={heatmapMode === "snr"}
onChange={() => setHeatmapMode("snr")}
/>
<span className="text-sm dark:text-slate-300">SNR</span>
</label>
</div>
)}
</div>
))}
{/*<CheckboxItem
key="traceroutes"
label={t("layerTool.traceroutes")}
checked={visibilityState.traceroutes}
onChange={(checked) => {
setVisibilityState({ ...visibilityState, traceroutes: checked });
}}
/>*/}
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,56 @@
import type { Protobuf } from "@meshtastic/core";
export type ClusterKey = string;
export type PxOffset = [number, number];
export function makeClusterKey(pos: Protobuf.Mesh.Position): ClusterKey {
return `${pos.latitudeI},${pos.longitudeI}`;
}
export function groupNodesByIdenticalCoords(
nodes: Protobuf.Mesh.NodeInfo[],
): Map<ClusterKey, Protobuf.Mesh.NodeInfo[]> {
const map = new Map<ClusterKey, Protobuf.Mesh.NodeInfo[]>();
for (const node of nodes) {
if (!node.position) {
continue;
}
const key = makeClusterKey(node.position);
const arr = map.get(key);
if (arr) {
arr.push(node);
} else {
map.set(key, [node]);
}
}
return map;
}
export function hashToAngle(key: string): number {
// djb2
let h = 5381;
for (let i = 0; i < key.length; i++) {
h = (h << 5) + h + key.charCodeAt(i);
}
// Map to [0, 2π)
return ((h >>> 0) % 360) * (Math.PI / 180);
}
export function fanOutOffsetsPx(size: number, key: string): Array<PxOffset> {
const R = 10 + 5 * size; // radius in pixels
const base = hashToAngle(key);
const out: Array<PxOffset> = [];
if (size === 1) {
return [[0, 0]];
}
for (let i = 0; i < size; i++) {
const theta = base + (i * 2 * Math.PI) / size;
const dx = R * Math.cos(theta);
const dy = R * Math.sin(theta);
out.push([dx, dy]);
}
return out;
}
@@ -0,0 +1,160 @@
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
import { Separator } from "@components/UI/Separator";
import { Skeleton } from "@components/UI/Skeleton.tsx";
import type { Message } from "@core/stores/messageStore/types.ts";
import type { TFunction } from "i18next";
import { InboxIcon } from "lucide-react";
import { Fragment, Suspense, useMemo } from "react";
import { useTranslation } from "react-i18next";
export interface ChannelChatProps {
messages?: Message[];
}
function toTs(d: Message["date"]): number {
return typeof d === "number" ? d : Date.parse(String(d));
}
function startOfLocalDay(ts: number): number {
const d = new Date(ts);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
function formatDateLabelFromDayKey(
dayKey: number,
t: TFunction<"common", undefined>,
fmt: Intl.DateTimeFormat,
): string {
const todayKey = startOfLocalDay(Date.now());
const yestKey = todayKey - 24 * 60 * 60 * 1000;
if (dayKey === todayKey) {
return t("unit.day.today"); // "Today" from common.json
}
if (dayKey === yestKey) {
return t("unit.day.yesterday"); // "Yesterday" from common.json
}
return fmt.format(new Date(dayKey));
}
type DayGroup = { dayKey: number; label: string; items: Message[] };
function groupMessagesByDay(
messages: Message[],
t: TFunction<"common", undefined>,
fmt: Intl.DateTimeFormat,
): DayGroup[] {
const out: DayGroup[] = [];
for (const msg of messages) {
const ts = toTs(msg.date);
const dayKey = startOfLocalDay(ts);
const last = out[out.length - 1];
if (last && last.dayKey === dayKey) {
last.items.push(msg);
} else {
out.push({
dayKey,
label: formatDateLabelFromDayKey(dayKey, t, fmt),
items: [msg],
});
}
}
return out;
}
const DateDelimiter = ({ label }: { label: string }) => (
<li aria-label={label}>
<div className="my-2 flex h-3 items-center justify-center">
<Separator className="bg-slate-100 dark:bg-slate-800" />
<div className="mx-5 whitespace-nowrap text-center text-xs text-slate-400">{label}</div>
<Separator className="bg-slate-100 dark:bg-slate-800" />
</div>
</li>
);
const MessageSkeleton = () => {
console.log("[ChannelChat] Showing MessageSkeleton (Suspense fallback)");
return (
<li className="group w-full py-2 relative list-none rounded-md">
<div className="grid grid-cols-[auto_1fr] gap-x-2">
<Skeleton className="size-8 rounded-full" />
<div className="flex flex-col gap-1.5 min-w-0">
<div className="flex items-center gap-1.5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-12" />
</div>
<Skeleton className="h-4 w-full" />
</div>
</div>
</li>
);
};
const EmptyState = () => {
const { t } = useTranslation("messages");
return (
<div className="flex flex-1 flex-col place-content-center place-items-center p-8 text-slate-500 dark:text-slate-400">
<InboxIcon className="mb-2 h-8 w-8" />
<span className="text-sm">{t("emptyState.text")}</span>
</div>
);
};
export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
const { i18n, t } = useTranslation();
const locale = useMemo(
() => i18n.language || (typeof navigator !== "undefined" ? navigator.language : "en-US"),
[i18n.language],
);
const dayLabelFmt = useMemo(
() =>
new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
}),
[locale],
);
// Sort messages by date in case they are stored out of order
const sorted = useMemo(
() => [...messages].sort((a, b) => toTs(b.date) - toTs(a.date)),
[messages],
);
const groups = useMemo(
() => groupMessagesByDay(sorted, t, dayLabelFmt),
[sorted, dayLabelFmt, t],
);
if (!messages.length) {
return (
<div className="flex flex-1 flex-col items-center justify-center">
<EmptyState />
</div>
);
}
return (
<ul className="flex flex-col-reverse flex-grow overflow-y-auto px-3 py-2">
{groups.map(({ dayKey, label, items }) => (
<Fragment key={dayKey}>
{/* Render messages first, then delimiter — with flex-col-reverse this shows the delimiter above that day's messages */}
{items.map((message) => (
<Suspense
key={message.messageId ?? `${message.from}-${message.date}`}
fallback={<MessageSkeleton />}
>
<MessageItem message={message} />
</Suspense>
))}
<DateDelimiter label={label} />
</Fragment>
))}
</ul>
);
};
@@ -0,0 +1,89 @@
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { cn } from "@core/utils/cn.ts";
import { Reply, SmilePlus } from "lucide-react";
import { useTranslation } from "react-i18next";
interface MessageActionsMenuProps {
onAddReaction?: () => void;
onReply?: () => void;
}
export const MessageActionsMenu = ({ onAddReaction, onReply }: MessageActionsMenuProps) => {
const { t } = useTranslation();
const hoverIconBarClass = cn(
"absolute top-2 right-2",
"flex items-center gap-x-1",
"bg-white dark:bg-zinc-800",
"border border-gray-200 dark:border-zinc-600",
"rounded-md shadow-sm p-1",
"opacity-0 group-hover:opacity-100",
"transition-opacity duration-100 ease-in-out",
"z-10",
);
const hoverIconButtonClass = cn(
"p-1 rounded",
"text-gray-500 dark:text-gray-400",
"hover:text-gray-700 dark:hover:text-gray-300",
"hover:bg-gray-100 dark:hover:bg-zinc-700",
"cursor-pointer",
);
const iconSizeClass = "size-4";
return (
<div className={cn(hoverIconBarClass)}>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={t("messages_actionsMenu_addReactionLabel")}
onClick={(e) => {
e.stopPropagation();
if (onAddReaction) {
onAddReaction();
}
}}
className={hoverIconButtonClass}
>
<SmilePlus className={iconSizeClass} aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs">
{t("messages_actionsMenu_addReactionLabel")}
<TooltipArrow className="fill-gray-800" />
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={t("messages_actionsMenu_replyLabel")}
onClick={(e) => {
e.stopPropagation();
if (onReply) {
onReply();
}
}}
className={hoverIconButtonClass}
>
<Reply className={iconSizeClass} aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs">
{t("messages_actionsMenu_replyLabel")}
<TooltipArrow className="fill-gray-800" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
};
@@ -0,0 +1,232 @@
import type { Types } from "@meshtastic/core";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MessageInput, type MessageInputProps } from "./MessageInput.tsx";
vi.mock("@components/UI/Button.tsx", () => ({
Button: vi.fn(({ type, className, children, onClick, onSubmit, ...rest }) => (
<button type={type} className={className} onClick={onClick} onSubmit={onSubmit} {...rest}>
{children}
</button>
)),
}));
vi.mock("@components/UI/Input.tsx", () => ({
Input: vi.fn(({ minLength, name, placeholder, value, onChange }) => (
<input
minLength={minLength}
name={name}
placeholder={placeholder}
value={value}
onChange={onChange}
data-testid="message-input-field"
/>
)),
}));
const mockSetDraft = vi.fn();
const mockGetDraft = vi.fn();
const mockClearDraft = vi.fn();
vi.mock("@core/stores", () => ({
CurrentDeviceContext: {
_currentValue: { deviceId: 1234 },
},
useMessages: vi.fn(() => ({
setDraft: mockSetDraft,
getDraft: mockGetDraft,
clearDraft: mockClearDraft,
})),
MessageState: {
Ack: "ack",
Waiting: "waiting",
Failed: "failed",
},
MessageType: {
Direct: "direct",
Broadcast: "broadcast",
},
}));
vi.mock("lucide-react", () => ({
SendIcon: vi.fn(() => <svg data-testid="send-icon" />),
}));
describe("MessageInput", () => {
const mockOnSend = vi.fn();
const defaultProps: MessageInputProps = {
onSend: mockOnSend,
to: 123,
maxBytes: 256,
};
beforeEach(() => {
vi.clearAllMocks();
mockGetDraft.mockReturnValue("");
});
const renderComponent = (props: Partial<MessageInputProps> = {}) => {
render(<MessageInput {...defaultProps} {...props} />);
};
it("should render the input field, byte counter, and send button", () => {
renderComponent();
expect(screen.getByTestId("message-input-field")).toBeInTheDocument();
expect(screen.getByTestId("byte-counter")).toBeInTheDocument();
expect(screen.getByRole("button")).toBeInTheDocument();
expect(screen.getByTestId("send-icon")).toBeInTheDocument();
});
it("should initialize with the draft from the store", () => {
const initialDraft = "Existing draft message";
mockGetDraft.mockImplementation((key) => {
return key === defaultProps.to ? initialDraft : "";
});
renderComponent();
expect(mockGetDraft).toHaveBeenCalledWith(defaultProps.to);
const expectedBytes = new Blob([initialDraft]).size;
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
`${expectedBytes}/${defaultProps.maxBytes}`,
);
});
it("should update input value, byte counter, and call setDraft on change within limits", () => {
renderComponent();
const inputElement = screen.getByTestId("message-input-field");
const testMessage = "Hello there!";
const expectedBytes = new Blob([testMessage]).size;
fireEvent.change(inputElement, { target: { value: testMessage } });
expect((inputElement as HTMLInputElement).value).toBe(testMessage);
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
`${expectedBytes}/${defaultProps.maxBytes}`,
);
expect(mockSetDraft).toHaveBeenCalledTimes(1);
expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, testMessage);
});
it("should NOT update input value or call setDraft if maxBytes is exceeded", () => {
const smallMaxBytes = 5;
renderComponent({ maxBytes: smallMaxBytes });
const inputElement = screen.getByTestId("message-input-field");
const initialValue = "12345";
const excessiveValue = "123456";
fireEvent.change(inputElement, { target: { value: initialValue } });
expect((inputElement as HTMLInputElement).value).toBe(initialValue);
expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, initialValue);
mockSetDraft.mockClear();
fireEvent.change(inputElement, { target: { value: excessiveValue } });
expect((inputElement as HTMLInputElement).value).toBe(initialValue);
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
`${smallMaxBytes}/${smallMaxBytes}`,
);
expect(mockSetDraft).not.toHaveBeenCalled();
});
it("should call onSend, clear input, reset byte counter, and call clearDraft on valid submit", async () => {
renderComponent();
const inputElement = screen.getByTestId("message-input-field");
const formElement = screen.getByRole("form");
const testMessage = "Send this message";
fireEvent.change(inputElement, { target: { value: testMessage } });
fireEvent.submit(formElement);
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledTimes(1);
expect(mockOnSend).toHaveBeenCalledWith(testMessage);
expect((inputElement as HTMLInputElement).value).toBe("");
expect(screen.getByTestId("byte-counter")).toHaveTextContent(`0/${defaultProps.maxBytes}`);
expect(mockClearDraft).toHaveBeenCalledTimes(1);
expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to);
});
});
it("should trim whitespace before calling onSend", async () => {
renderComponent();
const inputElement = screen.getByTestId("message-input-field");
const formElement = screen.getByRole("form");
const testMessageWithWhitespace = " Trim me! ";
const expectedTrimmedMessage = "Trim me!";
fireEvent.change(inputElement, {
target: { value: testMessageWithWhitespace },
});
fireEvent.submit(formElement);
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledTimes(1);
expect(mockOnSend).toHaveBeenCalledWith(expectedTrimmedMessage);
expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to);
});
});
it("should not call onSend or clearDraft if input is empty on submit", async () => {
renderComponent();
const inputElement = screen.getByTestId("message-input-field");
const formElement = screen.getByRole("form");
expect((inputElement as HTMLInputElement).value).toBe("");
fireEvent.submit(formElement);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
expect(mockOnSend).not.toHaveBeenCalled();
expect(mockClearDraft).not.toHaveBeenCalled();
});
it("should not call onSend or clearDraft if input contains only whitespace on submit", async () => {
renderComponent();
const inputElement = screen.getByTestId("message-input-field");
const formElement = screen.getByRole("form");
const whitespaceMessage = " \t ";
fireEvent.change(inputElement, { target: { value: whitespaceMessage } });
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage);
fireEvent.submit(formElement);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
expect(mockOnSend).not.toHaveBeenCalled();
expect(mockClearDraft).not.toHaveBeenCalled();
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage);
});
it("should work with broadcast destination for drafts", () => {
const broadcastDest: Types.Destination = "broadcast";
mockGetDraft.mockImplementation((key) => (key === broadcastDest ? "Broadcast draft" : ""));
renderComponent({ to: broadcastDest });
expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest);
expect((screen.getByTestId("message-input-field") as HTMLInputElement).value).toBe(
"Broadcast draft",
);
const inputElement = screen.getByTestId("message-input-field") as HTMLInputElement;
const formElement = screen.getByRole("form");
const newMessage = "New broadcast msg";
fireEvent.change(inputElement, { target: { value: newMessage } });
expect(mockSetDraft).toHaveBeenCalledWith(broadcastDest, newMessage);
fireEvent.submit(formElement);
expect(mockOnSend).toHaveBeenCalledWith(newMessage);
expect(mockClearDraft).toHaveBeenCalledWith(broadcastDest);
});
});
@@ -0,0 +1,81 @@
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useMessages } from "@core/stores";
import type { Types } from "@meshtastic/core";
import { SendIcon } from "lucide-react";
import { startTransition, useState } from "react";
import { useTranslation } from "react-i18next";
export interface MessageInputProps {
onSend: (message: string) => void;
to: Types.Destination;
maxBytes: number;
}
export const MessageInput = ({ onSend, to, maxBytes }: MessageInputProps) => {
const { setDraft, getDraft, clearDraft } = useMessages();
const { t } = useTranslation("messages");
const calculateBytes = (text: string) => new Blob([text]).size;
const initialDraft = getDraft(to);
const [localDraft, setLocalDraft] = useState(initialDraft);
const [messageBytes, setMessageBytes] = useState(() => calculateBytes(initialDraft));
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
const byteLength = calculateBytes(newValue);
if (byteLength <= maxBytes) {
setLocalDraft(newValue);
setMessageBytes(byteLength);
setDraft(to, newValue);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!localDraft.trim()) {
return;
}
// Reset bytes *before* sending (consider if onSend failure needs different handling)
setMessageBytes(0);
startTransition(() => {
onSend(localDraft.trim());
setLocalDraft("");
clearDraft(to);
});
};
return (
<div className="flex gap-2">
<form className="w-full" name="messageInput" onSubmit={handleSubmit}>
<div className="flex grow gap-1">
<label className="w-full" htmlFor="messageInput">
<Input
minLength={1}
name="messageInput"
placeholder={t("sendMessage.placeholder")}
autoComplete="off"
value={localDraft}
onChange={handleInputChange}
/>
</label>
<label
data-testid="byte-counter"
htmlFor="messageInput"
className="flex items-center w-20 p-1 text-sm place-content-end"
>
{messageBytes}/{maxBytes}
</label>
<Button type="submit" variant="default">
<SendIcon size={16} />
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,239 @@
import { Avatar } from "@components/UI/Avatar.tsx";
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { MessageState, useAppStore, useDevice, useNodeDB } from "@core/stores";
import type { Message } from "@core/stores/messageStore/types.ts";
import { cn } from "@core/utils/cn.ts";
import { type Protobuf, Types } from "@meshtastic/core";
import type { LucideIcon } from "lucide-react";
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
import { type ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next";
// Cache for pending promises
const myNodePromises = new Map<string, Promise<Protobuf.Mesh.NodeInfo>>();
// Hook that suspends when myNode is not available
function useSuspendingMyNode() {
const { getMyNode } = useNodeDB();
const selectedDeviceId = useAppStore((s) => s.selectedDeviceId);
const myNode = getMyNode();
if (!myNode) {
// Use the selected device ID to cache promises per device
const deviceKey = `device-${selectedDeviceId}`;
if (!myNodePromises.has(deviceKey)) {
const promise = new Promise<Protobuf.Mesh.NodeInfo>((resolve) => {
// Poll for myNode to become available
const checkInterval = setInterval(() => {
const node = getMyNode();
if (node) {
console.log("[MessageItem] myNode now available, resolving promise");
clearInterval(checkInterval);
myNodePromises.delete(deviceKey);
resolve(node);
}
}, 100);
setTimeout(() => {
clearInterval(checkInterval);
myNodePromises.delete(deviceKey);
}, 10000);
});
myNodePromises.set(deviceKey, promise);
}
// Throw the promise to trigger Suspense
throw myNodePromises.get(deviceKey);
}
return myNode;
}
// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // TODO: Uncomment when actions menu is implemented
interface MessageStatusInfo {
displayText: string;
icon: LucideIcon;
ariaLabel: string;
iconClassName?: string;
}
const StatusTooltip = ({
statusInfo,
children,
}: {
statusInfo: MessageStatusInfo;
children: ReactNode;
}) => (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
{statusInfo.displayText}
<TooltipArrow className="fill-slate-800" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
interface MessageItemProps {
message: Message;
}
export const MessageItem = ({ message }: MessageItemProps) => {
const { config } = useDevice();
const { getNode } = useNodeDB();
const { t, i18n } = useTranslation("messages");
// This will suspend if myNode is not available yet
const myNode = useSuspendingMyNode();
const myNodeNum = myNode.num;
const MESSAGE_STATUS_MAP = useMemo(
(): Record<MessageState, MessageStatusInfo> => ({
[MessageState.Ack]: {
displayText: t("deliveryStatus.delivered.displayText"),
icon: CheckCircle2,
ariaLabel: t("deliveryStatus.delivered.label"),
iconClassName: "text-green-500",
},
[MessageState.Waiting]: {
displayText: t("deliveryStatus.waiting.displayText"),
icon: CircleEllipsis,
ariaLabel: t("deliveryStatus.waiting.label"),
iconClassName: "text-slate-400",
},
[MessageState.Failed]: {
displayText: t("deliveryStatus.failed.displayText"),
icon: AlertCircle,
ariaLabel: t("deliveryStatus.failed.label"),
iconClassName: "text-red-500 dark:text-red-400",
},
}),
[t],
);
const UNKNOWN_STATUS = useMemo(
(): MessageStatusInfo => ({
displayText: t("deliveryStatus.unknown.displayText"),
icon: AlertCircle,
ariaLabel: t("deliveryStatus.unknown.label"),
iconClassName: "text-red-500 dark:text-red-400",
}),
[t],
);
const getMessageStatusInfo = useMemo(
() =>
(state: MessageState): MessageStatusInfo =>
MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS,
[MESSAGE_STATUS_MAP, UNKNOWN_STATUS],
);
const messageUser: Protobuf.Mesh.NodeInfo | null | undefined = useMemo(() => {
return message.from != null ? getNode(message.from) : null;
}, [getNode, message.from]);
const { displayName, isFavorite, nodeNum } = useMemo(() => {
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, "0");
const last4 = userIdHex.slice(-4);
const fallbackName = t("fallbackName", { last4 });
const longName = messageUser?.user?.longName;
const derivedShortName = messageUser?.user?.shortName || fallbackName;
const derivedDisplayName = longName || derivedShortName;
const isFavorite = messageUser?.num !== myNodeNum && messageUser?.isFavorite;
return {
displayName: derivedDisplayName,
shortName: derivedShortName,
isFavorite: isFavorite,
nodeNum: message.from,
};
}, [messageUser, message.from, t, myNodeNum]);
const messageStatusInfo = getMessageStatusInfo(message.state);
const StatusIconComponent = messageStatusInfo.icon;
const messageDate = useMemo(() => (message.date ? new Date(message.date) : null), [message.date]);
const locale = i18n.language;
const formattedTime = useMemo(
() =>
messageDate?.toLocaleTimeString(locale, {
hour: "numeric",
minute: "2-digit",
hour12: config?.display?.use12hClock ?? true,
}) ?? "",
[messageDate, locale, config?.display?.use12hClock],
);
const fullDateTime = useMemo(
() =>
messageDate?.toLocaleString(locale, {
dateStyle: "medium",
timeStyle: "short",
}) ?? "",
[messageDate, locale],
);
const isSender = myNodeNum !== undefined && message.from === myNodeNum;
const isOnPrimaryChannel = message.channel === Types.ChannelNumber.Primary; // Use the enum
const shouldShowStatusIcon = isSender && isOnPrimaryChannel;
const messageItemWrapperClass = cn(
"group w-full py-2 relative list-none",
"rounded-md",
"hover:bg-slate-300/15 dark:hover:bg-slate-600/20",
"transition-colors duration-100 ease-in-out",
);
const dateTextStyle = "text-xs text-slate-500 dark:text-slate-400";
return (
<li className={messageItemWrapperClass}>
<div className="grid grid-cols-[auto_1fr] gap-x-2">
<Avatar size="sm" nodeNum={nodeNum} className="pt-0.5" showFavorite={isFavorite} />
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-sm text-slate-900 dark:text-slate-100 truncate mr-1">
{displayName}
</span>
{messageDate && (
<time dateTime={messageDate.toISOString()} className={dateTextStyle}>
<span aria-hidden="true">{formattedTime}</span>
<span className="sr-only">{fullDateTime}</span>
</time>
)}
{shouldShowStatusIcon && (
<StatusTooltip statusInfo={messageStatusInfo}>
<span aria-label={messageStatusInfo.ariaLabel} role="img">
<StatusIconComponent
className={cn("size-4 shrink-0", messageStatusInfo.iconClassName)}
aria-hidden="true"
/>
</span>
</StatusTooltip>
)}
</div>
{message?.message && (
<div className="text-sm text-slate-800 dark:text-slate-200 whitespace-pre-wrap break-words">
{message.message}
</div>
)}
</div>
</div>
{/* Actions Menu Placeholder */}
{/* <div className="absolute top-1 right-1">
<MessageActionsMenu onReply={() => console.log("Reply")} />
</div> */}
</li>
);
};
@@ -0,0 +1,125 @@
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
import { useNodeDB } from "@core/stores";
import { mockNodeDBStore } from "@core/stores/nodeDBStore/nodeDBStore.mock.ts";
import { Protobuf } from "@meshtastic/core";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@core/stores");
describe("TraceRoute", () => {
const fromUser = {
user: {
$typeName: "meshtastic.User",
longName: "Source Node",
publicKey: new Uint8Array([1, 2, 3]),
shortName: "Source",
hwModel: 1,
macaddr: new Uint8Array([0x01, 0x02, 0x03, 0x04]),
id: "source-node",
isLicensed: false,
role: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
} as Protobuf.Mesh.NodeInfo["user"],
};
const toUser = {
user: {
$typeName: "meshtastic.User",
longName: "Destination Node",
publicKey: new Uint8Array([4, 5, 6]),
shortName: "Destination",
hwModel: 2,
macaddr: new Uint8Array([0x05, 0x06, 0x07, 0x08]),
id: "destination-node",
isLicensed: false,
role: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
} as Protobuf.Mesh.NodeInfo["user"],
};
const mockNodes = new Map<number, Protobuf.Mesh.NodeInfo>([
[
1,
{
num: 1,
user: { longName: "Node A", $typeName: "meshtastic.User" },
$typeName: "meshtastic.NodeInfo",
} as Protobuf.Mesh.NodeInfo,
],
[
2,
{
num: 2,
user: { longName: "Node B", $typeName: "meshtastic.User" },
$typeName: "meshtastic.NodeInfo",
} as Protobuf.Mesh.NodeInfo,
],
[
3,
{
num: 3,
user: { longName: "Node C", $typeName: "meshtastic.User" },
$typeName: "meshtastic.NodeInfo",
} as Protobuf.Mesh.NodeInfo,
],
]);
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(useNodeDB).mockReturnValue({
...mockNodeDBStore,
getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
return mockNodes.get(nodeNum);
},
});
});
it("renders the route to destination with SNR values", () => {
render(<TraceRoute from={fromUser} to={toUser} route={[1, 2]} snrTowards={[10, 20, 30]} />);
expect(screen.getByText("Source Node")).toBeInTheDocument();
expect(screen.getByText("Destination Node")).toBeInTheDocument();
expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getByText("Node B")).toBeInTheDocument();
expect(screen.getAllByText(/↓/)).toHaveLength(3);
expect(screen.getByText("↓ 10dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 20dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 30dBm")).toBeInTheDocument();
});
it("renders the route back when provided", () => {
render(
<TraceRoute
from={fromUser}
to={toUser}
route={[1]}
snrTowards={[15, 25]}
routeBack={[3]}
snrBack={[35, 45]}
/>,
);
// Check for the translated title
expect(screen.getByText("Route back:")).toBeInTheDocument();
// With route back, both names appear twice
expect(screen.getAllByText("Source Node")).toHaveLength(2);
expect(screen.getAllByText("Destination Node")).toHaveLength(2);
expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getByText("Node C")).toBeInTheDocument();
expect(screen.getByText("↓ 15dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 25dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 35dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 45dBm")).toBeInTheDocument();
});
it("renders '??' for missing SNR values", () => {
render(<TraceRoute from={fromUser} to={toUser} route={[1]} />);
expect(screen.getByText("Node A")).toBeInTheDocument();
// Check for translated '??' placeholder
expect(screen.getAllByText(/↓ \?\?dBm/)).toHaveLength(2);
});
});
@@ -0,0 +1,86 @@
import { useNodeDB } from "@core/stores";
import type { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
type NodeUser = Pick<Protobuf.Mesh.NodeInfo, "user">;
export interface TraceRouteProps {
from: NodeUser;
to: NodeUser;
route: Array<number>;
routeBack?: Array<number>;
snrTowards?: Array<number>;
snrBack?: Array<number>;
}
interface RoutePathProps {
title: string;
from: NodeUser;
to: NodeUser;
path: number[];
snr?: number[];
}
const RoutePath = ({ title, from, to, path, snr }: RoutePathProps) => {
const { getNode } = useNodeDB();
const { t } = useTranslation();
return (
<span
id={title}
className="ml-4 border-l-2 pl-2 border-l-slate-900 text-slate-900 dark:text-slate-100 dark:border-l-slate-100"
>
<p className="font-semibold">{title}</p>
<p>{from?.user?.longName}</p>
<p>
↓ {snr?.[0] ?? t("unknown.num")}
{t("unit.dbm")}
</p>
{path.map((hop, i) => (
<span key={getNode(hop)?.num ?? hop}>
<p>
{getNode(hop)?.user?.longName ??
`${t("unknown.longName")} (!${numberToHexUnpadded(hop)})`}
</p>
<p>
↓ {snr?.[i + 1] ?? t("unknown.num")}
{t("unit.dbm")}
</p>
</span>
))}
<p>{to?.user?.longName}</p>
</span>
);
};
export const TraceRoute = ({
from,
to,
route,
routeBack,
snrTowards,
snrBack,
}: TraceRouteProps) => {
const { t } = useTranslation("dialog");
return (
<div className="ml-5 flex">
<RoutePath
title={t("traceRoute.routeToDestination")}
to={to}
from={from}
path={route}
snr={snrTowards}
/>
{routeBack && routeBack.length > 0 && (
<RoutePath
title={t("traceRoute.routeBack")}
to={from}
from={to}
path={routeBack}
snr={snrBack}
/>
)}
</div>
);
};
@@ -0,0 +1,80 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type AmbientLightingValidation,
AmbientLightingValidationSchema,
} from "@app/validation/moduleConfig/ambientLighting.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { useTranslation } from "react-i18next";
interface AmbientLightingModuleConfigProps {
onFormInit: DynamicFormFormInit<AmbientLightingValidation>;
}
export const AmbientLighting = ({ onFormInit }: AmbientLightingModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "ambientLighting" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: AmbientLightingValidation) => {
if (deepCompareConfig(moduleConfig.ambientLighting, data, true)) {
removeChange({ type: "moduleConfig", variant: "ambientLighting" });
return;
}
setChange(
{ type: "moduleConfig", variant: "ambientLighting" },
data,
moduleConfig.ambientLighting,
);
};
return (
<DynamicForm<AmbientLightingValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={AmbientLightingValidationSchema}
defaultValues={moduleConfig.ambientLighting}
values={getEffectiveModuleConfig("ambientLighting")}
fieldGroups={[
{
label: t("ambientLighting.title"),
description: t("ambientLighting.description"),
fields: [
{
type: "toggle",
name: "ledState",
label: t("ambientLighting.ledState.label"),
description: t("ambientLighting.ledState.description"),
},
{
type: "number",
name: "current",
label: t("ambientLighting.current.label"),
description: t("ambientLighting.current.description"),
},
{
type: "number",
name: "red",
label: t("ambientLighting.red.label"),
description: t("ambientLighting.red.description"),
},
{
type: "number",
name: "green",
label: t("ambientLighting.green.label"),
description: t("ambientLighting.green.description"),
},
{
type: "number",
name: "blue",
label: t("ambientLighting.blue.label"),
description: t("ambientLighting.blue.description"),
},
],
},
]}
/>
);
};
@@ -0,0 +1,89 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import { type AudioValidation, AudioValidationSchema } from "@app/validation/moduleConfig/audio.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface AudioModuleConfigProps {
onFormInit: DynamicFormFormInit<AudioValidation>;
}
export const Audio = ({ onFormInit }: AudioModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "audio" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: AudioValidation) => {
if (deepCompareConfig(moduleConfig.audio, data, true)) {
removeChange({ type: "moduleConfig", variant: "audio" });
return;
}
setChange({ type: "moduleConfig", variant: "audio" }, data, moduleConfig.audio);
};
return (
<DynamicForm<AudioValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={AudioValidationSchema}
defaultValues={moduleConfig.audio}
values={getEffectiveModuleConfig("audio")}
fieldGroups={[
{
label: t("audio.title"),
description: t("audio.description"),
fields: [
{
type: "toggle",
name: "codec2Enabled",
label: t("audio.codec2Enabled.label"),
description: t("audio.codec2Enabled.description"),
},
{
type: "number",
name: "pttPin",
label: t("audio.pttPin.label"),
description: t("audio.pttPin.description"),
},
{
type: "select",
name: "bitrate",
label: t("audio.bitrate.label"),
description: t("audio.bitrate.description"),
properties: {
enumValue: Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud,
},
},
{
type: "number",
name: "i2sWs",
label: t("audio.i2sWs.label"),
description: t("audio.i2sWs.description"),
},
{
type: "number",
name: "i2sSd",
label: t("audio.i2sSd.label"),
description: t("audio.i2sSd.description"),
},
{
type: "number",
name: "i2sDin",
label: t("audio.i2sDin.label"),
description: t("audio.i2sDin.description"),
},
{
type: "number",
name: "i2sSck",
label: t("audio.i2sSck.label"),
description: t("audio.i2sSck.description"),
},
],
},
]}
/>
);
};
@@ -0,0 +1,123 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type CannedMessageValidation,
CannedMessageValidationSchema,
} from "@app/validation/moduleConfig/cannedMessage.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface CannedMessageModuleConfigProps {
onFormInit: DynamicFormFormInit<CannedMessageValidation>;
}
export const CannedMessage = ({ onFormInit }: CannedMessageModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "cannedMessage" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: CannedMessageValidation) => {
if (deepCompareConfig(moduleConfig.cannedMessage, data, true)) {
removeChange({ type: "moduleConfig", variant: "cannedMessage" });
return;
}
setChange({ type: "moduleConfig", variant: "cannedMessage" }, data, moduleConfig.cannedMessage);
};
return (
<DynamicForm<CannedMessageValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={CannedMessageValidationSchema}
defaultValues={moduleConfig.cannedMessage}
values={getEffectiveModuleConfig("cannedMessage")}
fieldGroups={[
{
label: t("cannedMessage.title"),
description: t("cannedMessage.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("cannedMessage.moduleEnabled.label"),
description: t("cannedMessage.moduleEnabled.description"),
},
{
type: "toggle",
name: "rotary1Enabled",
label: t("cannedMessage.rotary1Enabled.label"),
description: t("cannedMessage.rotary1Enabled.description"),
},
{
type: "number",
name: "inputbrokerPinA",
label: t("cannedMessage.inputbrokerPinA.label"),
description: t("cannedMessage.inputbrokerPinA.description"),
},
{
type: "number",
name: "inputbrokerPinB",
label: t("cannedMessage.inputbrokerPinB.label"),
description: t("cannedMessage.inputbrokerPinB.description"),
},
{
type: "number",
name: "inputbrokerPinPress",
label: t("cannedMessage.inputbrokerPinPress.label"),
description: t("cannedMessage.inputbrokerPinPress.description"),
},
{
type: "select",
name: "inputbrokerEventCw",
label: t("cannedMessage.inputbrokerEventCw.label"),
description: t("cannedMessage.inputbrokerEventCw.description"),
properties: {
enumValue: Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar,
},
},
{
type: "select",
name: "inputbrokerEventCcw",
label: t("cannedMessage.inputbrokerEventCcw.label"),
description: t("cannedMessage.inputbrokerEventCcw.description"),
properties: {
enumValue: Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar,
},
},
{
type: "select",
name: "inputbrokerEventPress",
label: t("cannedMessage.inputbrokerEventPress.label"),
description: t("cannedMessage.inputbrokerEventPress.description"),
properties: {
enumValue: Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar,
},
},
{
type: "toggle",
name: "updown1Enabled",
label: t("cannedMessage.updown1Enabled.label"),
description: t("cannedMessage.updown1Enabled.description"),
},
{
type: "text",
name: "allowInputSource",
label: t("cannedMessage.allowInputSource.label"),
description: t("cannedMessage.allowInputSource.description"),
},
{
type: "toggle",
name: "sendBell",
label: t("cannedMessage.sendBell.label"),
description: t("cannedMessage.sendBell.description"),
},
],
},
]}
/>
);
};
@@ -0,0 +1,141 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type DetectionSensorValidation,
DetectionSensorValidationSchema,
} from "@app/validation/moduleConfig/detectionSensor.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface DetectionSensorModuleConfigProps {
onFormInit: DynamicFormFormInit<DetectionSensorValidation>;
}
export const DetectionSensor = ({ onFormInit }: DetectionSensorModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "detectionSensor" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: DetectionSensorValidation) => {
if (deepCompareConfig(moduleConfig.detectionSensor, data, true)) {
removeChange({ type: "moduleConfig", variant: "detectionSensor" });
return;
}
setChange(
{ type: "moduleConfig", variant: "detectionSensor" },
data,
moduleConfig.detectionSensor,
);
};
return (
<DynamicForm<DetectionSensorValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={DetectionSensorValidationSchema}
defaultValues={moduleConfig.detectionSensor}
values={getEffectiveModuleConfig("detectionSensor")}
fieldGroups={[
{
label: t("detectionSensor.title"),
description: t("detectionSensor.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("detectionSensor.enabled.label"),
description: t("detectionSensor.enabled.description"),
},
{
type: "number",
name: "minimumBroadcastSecs",
label: t("detectionSensor.minimumBroadcastSecs.label"),
description: t("detectionSensor.minimumBroadcastSecs.description"),
properties: {
suffix: t("unit.second.plural"),
},
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "stateBroadcastSecs",
label: t("detectionSensor.stateBroadcastSecs.label"),
description: t("detectionSensor.stateBroadcastSecs.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "sendBell",
label: t("detectionSensor.sendBell.label"),
description: t("detectionSensor.sendBell.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "text",
name: "name",
label: t("detectionSensor.name.label"),
description: t("detectionSensor.name.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "monitorPin",
label: t("detectionSensor.monitorPin.label"),
description: t("detectionSensor.monitorPin.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "select",
name: "detectionTriggerType",
label: t("detectionSensor.detectionTriggerType.label"),
description: t("detectionSensor.detectionTriggerType.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
enumValue: Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig_TriggerType,
},
},
{
type: "toggle",
name: "usePullup",
label: t("detectionSensor.usePullup.label"),
description: t("detectionSensor.usePullup.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
],
},
]}
/>
);
};
@@ -0,0 +1,215 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type ExternalNotificationValidation,
ExternalNotificationValidationSchema,
} from "@app/validation/moduleConfig/externalNotification.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { useTranslation } from "react-i18next";
interface ExternalNotificationModuleConfigProps {
onFormInit: DynamicFormFormInit<ExternalNotificationValidation>;
}
export const ExternalNotification = ({ onFormInit }: ExternalNotificationModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "externalNotification" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: ExternalNotificationValidation) => {
if (deepCompareConfig(moduleConfig.externalNotification, data, true)) {
removeChange({ type: "moduleConfig", variant: "externalNotification" });
return;
}
setChange(
{ type: "moduleConfig", variant: "externalNotification" },
data,
moduleConfig.externalNotification,
);
};
return (
<DynamicForm<ExternalNotificationValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={ExternalNotificationValidationSchema}
defaultValues={moduleConfig.externalNotification}
values={getEffectiveModuleConfig("externalNotification")}
fieldGroups={[
{
label: t("externalNotification.title"),
description: t("externalNotification.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("externalNotification.enabled.label"),
description: t("externalNotification.enabled.description"),
},
{
type: "number",
name: "outputMs",
label: t("externalNotification.outputMs.label"),
description: t("externalNotification.outputMs.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
suffix: t("unit.millisecond.suffix"),
},
},
{
type: "number",
name: "output",
label: t("externalNotification.output.label"),
description: t("externalNotification.output.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "outputVibra",
label: t("externalNotification.outputVibra.label"),
description: t("externalNotification.outputVibra.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "outputBuzzer",
label: t("externalNotification.outputBuzzer.label"),
description: t("externalNotification.outputBuzzer.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "active",
label: t("externalNotification.active.label"),
description: t("externalNotification.active.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "alertMessage",
label: t("externalNotification.alertMessage.label"),
description: t("externalNotification.alertMessage.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "alertMessageVibra",
label: t("externalNotification.alertMessageVibra.label"),
description: t("externalNotification.alertMessageVibra.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "alertMessageBuzzer",
label: t("externalNotification.alertMessageBuzzer.label"),
description: t("externalNotification.alertMessageBuzzer.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "alertBell",
label: t("externalNotification.alertBell.label"),
description: t("externalNotification.alertBell.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "alertBellVibra",
label: t("externalNotification.alertBellVibra.label"),
description: t("externalNotification.alertBellVibra.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "alertBellBuzzer",
label: t("externalNotification.alertBellBuzzer.label"),
description: t("externalNotification.alertBellBuzzer.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "usePwm",
label: t("externalNotification.usePwm.label"),
description: t("externalNotification.usePwm.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "nagTimeout",
label: t("externalNotification.nagTimeout.label"),
description: t("externalNotification.nagTimeout.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "useI2sAsBuzzer",
label: t("externalNotification.useI2sAsBuzzer.label"),
description: t("externalNotification.useI2sAsBuzzer.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
],
},
]}
/>
);
};
@@ -0,0 +1,232 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import { type MqttValidation, MqttValidationSchema } from "@app/validation/moduleConfig/mqtt.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface MqttModuleConfigProps {
onFormInit: DynamicFormFormInit<MqttValidation>;
}
export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "mqtt" });
const { config, moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: MqttValidation) => {
const payload = {
...data,
mapReportSettings: create(
Protobuf.ModuleConfig.ModuleConfig_MapReportSettingsSchema,
data.mapReportSettings,
),
};
if (deepCompareConfig(moduleConfig.mqtt, payload, true)) {
removeChange({ type: "moduleConfig", variant: "mqtt" });
return;
}
setChange({ type: "moduleConfig", variant: "mqtt" }, payload, moduleConfig.mqtt);
};
const populateDefaultValues = (
cfg: Protobuf.ModuleConfig.ModuleConfig_MQTTConfig | undefined,
) => {
return cfg
? {
...cfg,
mapReportSettings: cfg.mapReportSettings ?? {
publishIntervalSecs: 0,
positionPrecision: 10,
},
}
: undefined;
};
return (
<DynamicForm<MqttValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={MqttValidationSchema}
defaultValues={populateDefaultValues(moduleConfig.mqtt)}
values={populateDefaultValues(getEffectiveModuleConfig("mqtt"))}
fieldGroups={[
{
label: t("mqtt.title"),
description: t("mqtt.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("mqtt.enabled.label"),
description: t("mqtt.enabled.description"),
},
{
type: "text",
name: "address",
label: t("mqtt.address.label"),
description: t("mqtt.address.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "text",
name: "username",
label: t("mqtt.username.label"),
description: t("mqtt.username.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "password",
name: "password",
label: t("mqtt.password.label"),
description: t("mqtt.password.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "encryptionEnabled",
label: t("mqtt.encryptionEnabled.label"),
description: t("mqtt.encryptionEnabled.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "jsonEnabled",
label: t("mqtt.jsonEnabled.label"),
description: t("mqtt.jsonEnabled.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "tlsEnabled",
label: t("mqtt.tlsEnabled.label"),
description: t("mqtt.tlsEnabled.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "text",
name: "root",
label: t("mqtt.root.label"),
description: t("mqtt.root.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "proxyToClientEnabled",
label: t("mqtt.proxyToClientEnabled.label"),
description: t("mqtt.proxyToClientEnabled.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "mapReportingEnabled",
label: t("mqtt.mapReportingEnabled.label"),
description: t("mqtt.mapReportingEnabled.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "mapReportSettings.publishIntervalSecs",
label: t("mqtt.mapReportSettings.publishIntervalSecs.label"),
description: t("mqtt.mapReportSettings.publishIntervalSecs.description"),
properties: {
suffix: t("unit.second.plural"),
},
disabledBy: [
{
fieldName: "enabled",
},
{
fieldName: "mapReportingEnabled",
},
],
},
{
type: "select",
name: "mapReportSettings.positionPrecision",
label: t("mqtt.mapReportSettings.positionPrecision.label"),
description: t("mqtt.mapReportSettings.positionPrecision.description"),
properties: {
enumValue:
config.display?.units === 0
? {
[t("mqtt.mapReportSettings.positionPrecision.options.metric_km23")]: 10,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_km12")]: 11,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_km5_8")]: 12,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_km2_9")]: 13,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_km1_5")]: 14,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_m700")]: 15,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_m350")]: 16,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_m200")]: 17,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_m90")]: 18,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_m50")]: 19,
}
: {
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi15")]: 10,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi7_3")]: 11,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi3_6")]: 12,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi1_8")]: 13,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_9")]: 14,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_5")]: 15,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_2")]: 16,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_ft600")]: 17,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_ft300")]: 18,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_ft150")]: 19,
},
},
disabledBy: [
{
fieldName: "enabled",
},
{
fieldName: "mapReportingEnabled",
},
],
},
],
},
]}
/>
);
};
@@ -0,0 +1,67 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type NeighborInfoValidation,
NeighborInfoValidationSchema,
} from "@app/validation/moduleConfig/neighborInfo.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { useTranslation } from "react-i18next";
interface NeighborInfoModuleConfigProps {
onFormInit: DynamicFormFormInit<NeighborInfoValidation>;
}
export const NeighborInfo = ({ onFormInit }: NeighborInfoModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "neighborInfo" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: NeighborInfoValidation) => {
if (deepCompareConfig(moduleConfig.neighborInfo, data, true)) {
removeChange({ type: "moduleConfig", variant: "neighborInfo" });
return;
}
setChange({ type: "moduleConfig", variant: "neighborInfo" }, data, moduleConfig.neighborInfo);
};
return (
<DynamicForm<NeighborInfoValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={NeighborInfoValidationSchema}
defaultValues={moduleConfig.neighborInfo}
values={getEffectiveModuleConfig("neighborInfo")}
fieldGroups={[
{
label: t("neighborInfo.title"),
description: t("neighborInfo.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("neighborInfo.enabled.label"),
description: t("neighborInfo.enabled.description"),
},
{
type: "number",
name: "updateInterval",
label: t("neighborInfo.updateInterval.label"),
description: t("neighborInfo.updateInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
disabledBy: [
{
fieldName: "enabled",
},
],
},
],
},
]}
/>
);
};
@@ -0,0 +1,89 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type PaxcounterValidation,
PaxcounterValidationSchema,
} from "@app/validation/moduleConfig/paxcounter.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { useTranslation } from "react-i18next";
interface PaxcounterModuleConfigProps {
onFormInit: DynamicFormFormInit<PaxcounterValidation>;
}
export const Paxcounter = ({ onFormInit }: PaxcounterModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "paxcounter" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: PaxcounterValidation) => {
if (deepCompareConfig(moduleConfig.paxcounter, data, true)) {
removeChange({ type: "moduleConfig", variant: "paxcounter" });
return;
}
setChange({ type: "moduleConfig", variant: "paxcounter" }, data, moduleConfig.paxcounter);
};
return (
<DynamicForm<PaxcounterValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={PaxcounterValidationSchema}
defaultValues={moduleConfig.paxcounter}
values={getEffectiveModuleConfig("paxcounter")}
fieldGroups={[
{
label: t("paxcounter.title"),
description: t("paxcounter.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("paxcounter.enabled.label"),
description: t("paxcounter.enabled.description"),
},
{
type: "number",
name: "paxcounterUpdateInterval",
label: t("paxcounter.paxcounterUpdateInterval.label"),
description: t("paxcounter.paxcounterUpdateInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "wifiThreshold",
label: t("paxcounter.wifiThreshold.label"),
description: t("paxcounter.wifiThreshold.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "bleThreshold",
label: t("paxcounter.bleThreshold.label"),
description: t("paxcounter.bleThreshold.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
],
},
]}
/>
);
};
@@ -0,0 +1,79 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type RangeTestValidation,
RangeTestValidationSchema,
} from "@app/validation/moduleConfig/rangeTest.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { useTranslation } from "react-i18next";
interface RangeTestModuleConfigProps {
onFormInit: DynamicFormFormInit<RangeTestValidation>;
}
export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "rangeTest" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: RangeTestValidation) => {
if (deepCompareConfig(moduleConfig.rangeTest, data, true)) {
removeChange({ type: "moduleConfig", variant: "rangeTest" });
return;
}
setChange({ type: "moduleConfig", variant: "rangeTest" }, data, moduleConfig.rangeTest);
};
return (
<DynamicForm<RangeTestValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={RangeTestValidationSchema}
defaultValues={moduleConfig.rangeTest}
values={getEffectiveModuleConfig("rangeTest")}
fieldGroups={[
{
label: t("rangeTest.title"),
description: t("rangeTest.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("rangeTest.enabled.label"),
description: t("rangeTest.enabled.description"),
},
{
type: "number",
name: "sender",
label: t("rangeTest.sender.label"),
description: t("rangeTest.sender.description"),
properties: {
suffix: t("unit.second.plural"),
},
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "save",
label: t("rangeTest.save.label"),
description: t("rangeTest.save.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
],
},
]}
/>
);
};
@@ -0,0 +1,63 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type RemoteHardwareValidation,
RemoteHardwareValidationSchema,
} from "@app/validation/moduleConfig/remoteHardware.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { useTranslation } from "react-i18next";
interface RemoteHardwareModuleConfigProps {
onFormInit: DynamicFormFormInit<RemoteHardwareValidation>;
}
export const RemoteHardware = ({ onFormInit }: RemoteHardwareModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "remoteHardware" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: RemoteHardwareValidation) => {
if (deepCompareConfig(moduleConfig.remoteHardware, data, true)) {
removeChange({ type: "moduleConfig", variant: "remoteHardware" });
return;
}
setChange(
{ type: "moduleConfig", variant: "remoteHardware" },
data,
moduleConfig.remoteHardware,
);
};
return (
<DynamicForm<RemoteHardwareValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={RemoteHardwareValidationSchema}
defaultValues={moduleConfig.remoteHardware}
values={getEffectiveModuleConfig("remoteHardware")}
fieldGroups={[
{
label: t("remoteHardware.title"),
description: t("remoteHardware.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("remoteHardware.enabled.label"),
description: t("remoteHardware.enabled.description"),
},
{
type: "toggle",
name: "allowUndefinedPinAccess",
label: t("remoteHardware.allowUndefinedPinAccess.label"),
description: t("remoteHardware.allowUndefinedPinAccess.description"),
},
],
},
]}
/>
);
};
@@ -0,0 +1,138 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type SerialValidation,
SerialValidationSchema,
} from "@app/validation/moduleConfig/serial.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface SerialModuleConfigProps {
onFormInit: DynamicFormFormInit<SerialValidation>;
}
export const Serial = ({ onFormInit }: SerialModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "serial" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: SerialValidation) => {
if (deepCompareConfig(moduleConfig.serial, data, true)) {
removeChange({ type: "moduleConfig", variant: "serial" });
return;
}
setChange({ type: "moduleConfig", variant: "serial" }, data, moduleConfig.serial);
};
return (
<DynamicForm<SerialValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={SerialValidationSchema}
defaultValues={moduleConfig.serial}
values={getEffectiveModuleConfig("serial")}
fieldGroups={[
{
label: t("serial.title"),
description: t("serial.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("serial.enabled.label"),
description: t("serial.enabled.description"),
},
{
type: "toggle",
name: "echo",
label: t("serial.echo.label"),
description: t("serial.echo.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "rxd",
label: t("serial.rxd.label"),
description: t("serial.rxd.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "txd",
label: t("serial.txd.label"),
description: t("serial.txd.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "select",
name: "baud",
label: t("serial.baud.label"),
description: t("serial.baud.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
enumValue: Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud,
},
},
{
type: "number",
name: "timeout",
label: t("serial.timeout.label"),
description: t("serial.timeout.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "select",
name: "mode",
label: t("serial.mode.label"),
description: t("serial.mode.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
enumValue: Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode,
formatEnumName: true,
},
},
{
type: "toggle",
name: "overrideConsoleSerialPort",
label: t("serial.overrideConsoleSerialPort.label"),
description: t("serial.overrideConsoleSerialPort.description"),
},
],
},
]}
/>
);
};
@@ -0,0 +1,59 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type StatusMessageValidation,
StatusMessageValidationSchema,
} from "@app/validation/moduleConfig/statusMessage.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { useTranslation } from "react-i18next";
interface StatusMessageModuleConfigProps {
onFormInit: DynamicFormFormInit<StatusMessageValidation>;
}
export const StatusMessage = ({ onFormInit }: StatusMessageModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "statusmessage" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: StatusMessageValidation) => {
if (deepCompareConfig(moduleConfig.statusmessage, data, true)) {
removeChange({ type: "moduleConfig", variant: "statusmessage" });
return;
}
setChange({ type: "moduleConfig", variant: "statusmessage" }, data, moduleConfig.statusmessage);
};
return (
<DynamicForm<StatusMessageValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={StatusMessageValidationSchema}
defaultValues={moduleConfig.statusmessage}
values={getEffectiveModuleConfig("statusmessage")}
fieldGroups={[
{
label: t("statusMessage.title"),
description: t("statusMessage.description"),
fields: [
{
type: "text",
name: "nodeStatus",
label: t("statusMessage.nodeStatus.label"),
description: t("statusMessage.nodeStatus.description"),
properties: {
fieldLength: {
max: 200,
showCharacterCount: true,
},
},
},
],
},
]}
/>
);
};
@@ -0,0 +1,100 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type StoreForwardValidation,
StoreForwardValidationSchema,
} from "@app/validation/moduleConfig/storeForward.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { useTranslation } from "react-i18next";
interface StoreForwardModuleConfigProps {
onFormInit: DynamicFormFormInit<StoreForwardValidation>;
}
export const StoreForward = ({ onFormInit }: StoreForwardModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "storeForward" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: StoreForwardValidation) => {
if (deepCompareConfig(moduleConfig.storeForward, data, true)) {
removeChange({ type: "moduleConfig", variant: "storeForward" });
return;
}
setChange({ type: "moduleConfig", variant: "storeForward" }, data, moduleConfig.storeForward);
};
return (
<DynamicForm<StoreForwardValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={StoreForwardValidationSchema}
defaultValues={moduleConfig.storeForward}
values={getEffectiveModuleConfig("storeForward")}
fieldGroups={[
{
label: t("storeForward.title"),
description: t("storeForward.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("storeForward.enabled.label"),
description: t("storeForward.enabled.description"),
},
{
type: "toggle",
name: "heartbeat",
label: t("storeForward.heartbeat.label"),
description: t("storeForward.heartbeat.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "records",
label: t("storeForward.records.label"),
description: t("storeForward.records.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
suffix: t("unit.record.plural"),
},
},
{
type: "number",
name: "historyReturnMax",
label: t("storeForward.historyReturnMax.label"),
description: t("storeForward.historyReturnMax.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "historyReturnWindow",
label: t("storeForward.historyReturnWindow.label"),
description: t("storeForward.historyReturnWindow.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
],
},
]}
/>
);
};
@@ -0,0 +1,65 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import { type TakValidation, TakValidationSchema } from "@app/validation/moduleConfig/tak.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface TakModuleConfigProps {
onFormInit: DynamicFormFormInit<TakValidation>;
}
export const Tak = ({ onFormInit }: TakModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "tak" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: TakValidation) => {
if (deepCompareConfig(moduleConfig.tak, data, true)) {
removeChange({ type: "moduleConfig", variant: "tak" });
return;
}
setChange({ type: "moduleConfig", variant: "tak" }, data, moduleConfig.tak);
};
return (
<DynamicForm<TakValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={TakValidationSchema}
defaultValues={moduleConfig.tak}
values={getEffectiveModuleConfig("tak")}
fieldGroups={[
{
label: t("tak.title"),
description: t("tak.description"),
fields: [
{
type: "select",
name: "team",
label: t("tak.team.label"),
description: t("tak.team.description"),
properties: {
enumValue: Protobuf.ATAK.Team,
formatEnumName: true,
},
},
{
type: "select",
name: "role",
label: t("tak.role.label"),
description: t("tak.role.description"),
properties: {
enumValue: Protobuf.ATAK.MemberRole,
formatEnumName: true,
},
},
],
},
]}
/>
);
};
@@ -0,0 +1,119 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type TelemetryValidation,
TelemetryValidationSchema,
} from "@app/validation/moduleConfig/telemetry.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { useTranslation } from "react-i18next";
interface TelemetryModuleConfigProps {
onFormInit: DynamicFormFormInit<TelemetryValidation>;
}
export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "telemetry" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: TelemetryValidation) => {
if (deepCompareConfig(moduleConfig.telemetry, data, true)) {
removeChange({ type: "moduleConfig", variant: "telemetry" });
return;
}
setChange({ type: "moduleConfig", variant: "telemetry" }, data, moduleConfig.telemetry);
};
return (
<DynamicForm<TelemetryValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={TelemetryValidationSchema}
defaultValues={moduleConfig.telemetry}
values={getEffectiveModuleConfig("telemetry")}
fieldGroups={[
{
label: t("telemetry.title"),
description: t("telemetry.description"),
fields: [
{
type: "number",
name: "deviceUpdateInterval",
label: t("telemetry.deviceUpdateInterval.label"),
description: t("telemetry.deviceUpdateInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "number",
name: "environmentUpdateInterval",
label: t("telemetry.environmentUpdateInterval.label"),
description: t("telemetry.environmentUpdateInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "toggle",
name: "environmentMeasurementEnabled",
label: t("telemetry.environmentMeasurementEnabled.label"),
description: t("telemetry.environmentMeasurementEnabled.description"),
},
{
type: "toggle",
name: "environmentScreenEnabled",
label: t("telemetry.environmentScreenEnabled.label"),
description: t("telemetry.environmentScreenEnabled.description"),
},
{
type: "toggle",
name: "environmentDisplayFahrenheit",
label: t("telemetry.environmentDisplayFahrenheit.label"),
description: t("telemetry.environmentDisplayFahrenheit.description"),
},
{
type: "toggle",
name: "airQualityEnabled",
label: t("telemetry.airQualityEnabled.label"),
description: t("telemetry.airQualityEnabled.description"),
},
{
type: "number",
name: "airQualityInterval",
label: t("telemetry.airQualityInterval.label"),
description: t("telemetry.airQualityInterval.description"),
},
{
type: "toggle",
name: "airQualityScreenEnabled",
label: t("telemetry.airQualityScreenEnabled.label"),
description: t("telemetry.airQualityScreenEnabled.description"),
},
{
type: "toggle",
name: "powerMeasurementEnabled",
label: t("telemetry.powerMeasurementEnabled.label"),
description: t("telemetry.powerMeasurementEnabled.description"),
},
{
type: "number",
name: "powerUpdateInterval",
label: t("telemetry.powerUpdateInterval.label"),
description: t("telemetry.powerUpdateInterval.description"),
},
{
type: "toggle",
name: "powerScreenEnabled",
label: t("telemetry.powerScreenEnabled.label"),
description: t("telemetry.powerScreenEnabled.description"),
},
],
},
]}
/>
);
};
@@ -0,0 +1,141 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type TrafficManagementValidation,
TrafficManagementValidationSchema,
} from "@app/validation/moduleConfig/trafficManagement.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { useTranslation } from "react-i18next";
interface TrafficManagementModuleConfigProps {
onFormInit: DynamicFormFormInit<TrafficManagementValidation>;
}
export const TrafficManagement = ({ onFormInit }: TrafficManagementModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "trafficManagement" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: TrafficManagementValidation) => {
if (deepCompareConfig(moduleConfig.trafficManagement, data, true)) {
removeChange({ type: "moduleConfig", variant: "trafficManagement" });
return;
}
setChange(
{ type: "moduleConfig", variant: "trafficManagement" },
data,
moduleConfig.trafficManagement,
);
};
return (
<DynamicForm<TrafficManagementValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={TrafficManagementValidationSchema}
defaultValues={moduleConfig.trafficManagement}
values={getEffectiveModuleConfig("trafficManagement")}
fieldGroups={[
{
label: t("trafficManagement.title"),
description: t("trafficManagement.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("trafficManagement.enabled.label"),
description: t("trafficManagement.enabled.description"),
},
{
type: "toggle",
name: "positionDedupEnabled",
label: t("trafficManagement.positionDedupEnabled.label"),
description: t("trafficManagement.positionDedupEnabled.description"),
},
{
type: "number",
name: "positionPrecisionBits",
label: t("trafficManagement.positionPrecisionBits.label"),
description: t("trafficManagement.positionPrecisionBits.description"),
},
{
type: "number",
name: "positionMinIntervalSecs",
label: t("trafficManagement.positionMinIntervalSecs.label"),
description: t("trafficManagement.positionMinIntervalSecs.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "toggle",
name: "nodeinfoDirectResponse",
label: t("trafficManagement.nodeinfoDirectResponse.label"),
description: t("trafficManagement.nodeinfoDirectResponse.description"),
},
{
type: "number",
name: "nodeinfoDirectResponseMaxHops",
label: t("trafficManagement.nodeinfoDirectResponseMaxHops.label"),
description: t("trafficManagement.nodeinfoDirectResponseMaxHops.description"),
},
{
type: "toggle",
name: "rateLimitEnabled",
label: t("trafficManagement.rateLimitEnabled.label"),
description: t("trafficManagement.rateLimitEnabled.description"),
},
{
type: "number",
name: "rateLimitWindowSecs",
label: t("trafficManagement.rateLimitWindowSecs.label"),
description: t("trafficManagement.rateLimitWindowSecs.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "number",
name: "rateLimitMaxPackets",
label: t("trafficManagement.rateLimitMaxPackets.label"),
description: t("trafficManagement.rateLimitMaxPackets.description"),
},
{
type: "toggle",
name: "dropUnknownEnabled",
label: t("trafficManagement.dropUnknownEnabled.label"),
description: t("trafficManagement.dropUnknownEnabled.description"),
},
{
type: "number",
name: "unknownPacketThreshold",
label: t("trafficManagement.unknownPacketThreshold.label"),
description: t("trafficManagement.unknownPacketThreshold.description"),
},
{
type: "toggle",
name: "exhaustHopTelemetry",
label: t("trafficManagement.exhaustHopTelemetry.label"),
description: t("trafficManagement.exhaustHopTelemetry.description"),
},
{
type: "toggle",
name: "exhaustHopPosition",
label: t("trafficManagement.exhaustHopPosition.label"),
description: t("trafficManagement.exhaustHopPosition.description"),
},
{
type: "toggle",
name: "routerPreserveHops",
label: t("trafficManagement.routerPreserveHops.label"),
description: t("trafficManagement.routerPreserveHops.description"),
},
],
},
]}
/>
);
};
@@ -0,0 +1,75 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type BluetoothValidation,
BluetoothValidationSchema,
} from "@app/validation/config/bluetooth.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface BluetoothConfigProps {
onFormInit: DynamicFormFormInit<BluetoothValidation>;
}
export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => {
useWaitForConfig({ configCase: "bluetooth" });
const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
const { t } = useTranslation("config");
const onSubmit = (data: BluetoothValidation) => {
if (deepCompareConfig(config.bluetooth, data, true)) {
removeChange({ type: "config", variant: "bluetooth" });
return;
}
setChange({ type: "config", variant: "bluetooth" }, data, config.bluetooth);
};
return (
<DynamicForm<BluetoothValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={BluetoothValidationSchema}
defaultValues={config.bluetooth}
values={getEffectiveConfig("bluetooth")}
fieldGroups={[
{
label: t("bluetooth.title"),
description: t("bluetooth.description"),
notes: t("bluetooth.note"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("bluetooth.enabled.label"),
description: t("bluetooth.enabled.description"),
},
{
type: "select",
name: "mode",
label: t("bluetooth.pairingMode.label"),
description: t("bluetooth.pairingMode.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
enumValue: Protobuf.Config.Config_BluetoothConfig_PairingMode,
formatEnumName: true,
},
},
{
type: "number",
name: "fixedPin",
label: t("bluetooth.pin.label"),
description: t("bluetooth.pin.description"),
},
],
},
]}
/>
);
};
@@ -0,0 +1,119 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import { type DeviceValidation, DeviceValidationSchema } from "@app/validation/config/device.ts";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface DeviceConfigProps {
onFormInit: DynamicFormFormInit<DeviceValidation>;
}
export const Device = ({ onFormInit }: DeviceConfigProps) => {
useWaitForConfig({ configCase: "device" });
const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
const { t } = useTranslation("config");
const { validateRoleSelection } = useUnsafeRolesDialog();
const onSubmit = (data: DeviceValidation) => {
if (deepCompareConfig(config.device, data, true)) {
removeChange({ type: "config", variant: "device" });
return;
}
setChange({ type: "config", variant: "device" }, data, config.device);
};
return (
<DynamicForm<DeviceValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={DeviceValidationSchema}
defaultValues={config.device}
values={getEffectiveConfig("device")}
fieldGroups={[
{
label: t("device.title"),
description: t("device.description"),
fields: [
{
type: "select",
name: "role",
label: t("device.role.label"),
description: t("device.role.description"),
validate: validateRoleSelection,
properties: {
enumValue: Protobuf.Config.Config_DeviceConfig_Role,
formatEnumName: true,
},
},
{
type: "number",
name: "buttonGpio",
label: t("device.buttonPin.label"),
description: t("device.buttonPin.description"),
},
{
type: "number",
name: "buzzerGpio",
label: t("device.buzzerPin.label"),
description: t("device.buzzerPin.description"),
},
{
type: "select",
name: "rebroadcastMode",
label: t("device.rebroadcastMode.label"),
description: t("device.rebroadcastMode.description"),
properties: {
enumValue: Protobuf.Config.Config_DeviceConfig_RebroadcastMode,
formatEnumName: true,
},
},
{
type: "number",
name: "nodeInfoBroadcastSecs",
label: t("device.nodeInfoBroadcastInterval.label"),
description: t("device.nodeInfoBroadcastInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "toggle",
name: "doubleTapAsButtonPress",
label: t("device.doubleTapAsButtonPress.label"),
description: t("device.doubleTapAsButtonPress.description"),
},
{
type: "toggle",
name: "disableTripleClick",
label: t("device.disableTripleClick.label"),
description: t("device.disableTripleClick.description"),
},
{
type: "text",
name: "tzdef",
label: t("device.posixTimezone.label"),
description: t("device.posixTimezone.description"),
properties: {
fieldLength: {
max: 64,
currentValueLength: getEffectiveConfig("device")?.tzdef?.length,
showCharacterCount: true,
},
},
},
{
type: "toggle",
name: "ledHeartbeatDisabled",
label: t("device.ledHeartbeatDisabled.label"),
description: t("device.ledHeartbeatDisabled.description"),
},
],
},
]}
/>
);
};
@@ -0,0 +1,139 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import { type DisplayValidation, DisplayValidationSchema } from "@app/validation/config/display.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface DisplayConfigProps {
onFormInit: DynamicFormFormInit<DisplayValidation>;
}
export const Display = ({ onFormInit }: DisplayConfigProps) => {
useWaitForConfig({ configCase: "display" });
const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
const { t } = useTranslation("config");
const onSubmit = (data: DisplayValidation) => {
if (deepCompareConfig(config.display, data, true)) {
removeChange({ type: "config", variant: "display" });
return;
}
setChange({ type: "config", variant: "display" }, data, config.display);
};
return (
<DynamicForm<DisplayValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={DisplayValidationSchema}
defaultValues={config.display}
values={getEffectiveConfig("display")}
fieldGroups={[
{
label: t("display.title"),
description: t("display.description"),
fields: [
{
type: "number",
name: "screenOnSecs",
label: t("display.screenTimeout.label"),
description: t("display.screenTimeout.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
// TODO: This field is deprecated since protobufs 2.7.4 and only has UNUSED=0 value.
// GPS format has been moved to DeviceUIConfig.gps_format with proper enum values (DEC, DMS, UTM, MGRS, OLC, OSGR, MLS).
// This should be removed once DeviceUI settings are implemented.
// See: packages/protobufs/meshtastic/device_ui.proto
{
type: "select",
name: "gpsFormat",
label: t("display.gpsDisplayUnits.label"),
description: t("display.gpsDisplayUnits.description"),
properties: {
enumValue: Protobuf.Config.Config_DisplayConfig_DeprecatedGpsCoordinateFormat,
},
},
{
type: "number",
name: "autoScreenCarouselSecs",
label: t("display.carouselDelay.label"),
description: t("display.carouselDelay.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "toggle",
name: "compassNorthTop",
label: t("display.compassNorthTop.label"),
description: t("display.compassNorthTop.description"),
},
{
type: "toggle",
name: "use12hClock",
label: t("display.twelveHourClock.label"),
description: t("display.twelveHourClock.description"),
},
{
type: "toggle",
name: "flipScreen",
label: t("display.flipScreen.label"),
description: t("display.flipScreen.description"),
},
{
type: "select",
name: "units",
label: t("display.displayUnits.label"),
description: t("display.displayUnits.description"),
properties: {
enumValue: Protobuf.Config.Config_DisplayConfig_DisplayUnits,
formatEnumName: true,
},
},
{
type: "select",
name: "oled",
label: t("display.oledType.label"),
description: t("display.oledType.description"),
properties: {
enumValue: Protobuf.Config.Config_DisplayConfig_OledType,
},
},
{
type: "select",
name: "displaymode",
label: t("display.displayMode.label"),
description: t("display.displayMode.description"),
properties: {
enumValue: Protobuf.Config.Config_DisplayConfig_DisplayMode,
formatEnumName: true,
},
},
{
type: "toggle",
name: "headingBold",
label: t("display.headingBold.label"),
description: t("display.headingBold.description"),
},
{
type: "toggle",
name: "wakeOnTapOrMotion",
label: t("display.wakeOnTapOrMotion.label"),
description: t("display.wakeOnTapOrMotion.description"),
},
{
type: "toggle",
name: "enableMessageBubbles",
label: t("display.enableMessageBubbles.label"),
description: t("display.enableMessageBubbles.description"),
},
],
},
]}
/>
);
};
@@ -0,0 +1,218 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import { type LoRaValidation, LoRaValidationSchema } from "@app/validation/config/lora.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface LoRaConfigProps {
onFormInit: DynamicFormFormInit<LoRaValidation>;
}
export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
useWaitForConfig({ configCase: "lora" });
const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
const { t } = useTranslation("config");
const onSubmit = (data: LoRaValidation) => {
if (deepCompareConfig(config.lora, data, true)) {
removeChange({ type: "config", variant: "lora" });
return;
}
setChange({ type: "config", variant: "lora" }, data, config.lora);
};
return (
<DynamicForm<LoRaValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={LoRaValidationSchema}
defaultValues={config.lora}
values={getEffectiveConfig("lora")}
fieldGroups={[
{
label: t("lora.title"),
description: t("lora.description"),
fields: [
{
type: "select",
name: "region",
label: t("lora.region.label"),
description: t("lora.region.description"),
properties: {
enumValue: Protobuf.Config.Config_LoRaConfig_RegionCode,
},
},
{
type: "select",
name: "hopLimit",
label: t("lora.hopLimit.label"),
description: t("lora.hopLimit.description"),
properties: {
enumValue: { 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7 },
},
},
{
type: "number",
name: "channelNum",
label: t("lora.frequencySlot.label"),
description: t("lora.frequencySlot.description"),
},
{
type: "toggle",
name: "ignoreMqtt",
label: t("lora.ignoreMqtt.label"),
description: t("lora.ignoreMqtt.description"),
},
{
type: "toggle",
name: "configOkToMqtt",
label: t("lora.okToMqtt.label"),
description: t("lora.okToMqtt.description"),
},
],
},
{
label: t("lora.waveformSettings.label"),
description: t("lora.waveformSettings.description"),
fields: [
{
type: "toggle",
name: "usePreset",
label: t("lora.usePreset.label"),
description: t("lora.usePreset.description"),
},
{
type: "select",
name: "modemPreset",
label: t("lora.modemPreset.label"),
description: t("lora.modemPreset.description"),
disabledBy: [
{
fieldName: "usePreset",
},
],
properties: {
enumValue: Protobuf.Config.Config_LoRaConfig_ModemPreset,
formatEnumName: true,
},
},
{
type: "number",
name: "bandwidth",
label: t("lora.bandwidth.label"),
description: t("lora.bandwidth.description"),
disabledBy: [
{
fieldName: "usePreset",
invert: true,
},
],
properties: {
suffix: t("unit.kilohertz"),
},
},
{
type: "number",
name: "spreadFactor",
label: t("lora.spreadingFactor.label"),
description: t("lora.spreadingFactor.description"),
disabledBy: [
{
fieldName: "usePreset",
invert: true,
},
],
properties: {
suffix: t("unit.cps"),
},
},
{
type: "number",
name: "codingRate",
label: t("lora.codingRate.label"),
description: t("lora.codingRate.description"),
disabledBy: [
{
fieldName: "usePreset",
invert: true,
},
],
},
],
},
{
label: t("lora.radioSettings.label"),
description: t("lora.radioSettings.description"),
fields: [
{
type: "toggle",
name: "txEnabled",
label: t("lora.transmitEnabled.label"),
description: t("lora.transmitEnabled.description"),
},
{
type: "number",
name: "txPower",
label: t("lora.transmitPower.label"),
description: t("lora.transmitPower.description"),
properties: {
suffix: t("unit.dbm"),
},
},
{
type: "toggle",
name: "overrideDutyCycle",
label: t("lora.overrideDutyCycle.label"),
description: t("lora.overrideDutyCycle.description"),
},
{
type: "number",
name: "frequencyOffset",
label: t("lora.frequencyOffset.label"),
description: t("lora.frequencyOffset.description"),
properties: {
suffix: t("unit.hertz"),
},
},
{
type: "toggle",
name: "sx126xRxBoostedGain",
label: t("lora.boostedRxGain.label"),
description: t("lora.boostedRxGain.description"),
},
{
type: "number",
name: "overrideFrequency",
label: t("lora.overrideFrequency.label"),
description: t("lora.overrideFrequency.description"),
properties: {
suffix: t("unit.megahertz"),
step: 0.001,
},
},
{
type: "select",
name: "femLnaMode",
label: t("lora.femLnaMode.label"),
description: t("lora.femLnaMode.description"),
properties: {
enumValue: Protobuf.Config.Config_LoRaConfig_FEM_LNA_Mode,
formatEnumName: true,
},
},
{
type: "toggle",
name: "serialHalOnly",
label: t("lora.serialHalOnly.label"),
description: t("lora.serialHalOnly.description"),
},
],
},
]}
/>
);
};
@@ -0,0 +1,222 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import { type NetworkValidation, NetworkValidationSchema } from "@app/validation/config/network.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { convertIntToIpAddress, convertIpAddressToInt } from "@core/utils/ip.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface NetworkConfigProps {
onFormInit: DynamicFormFormInit<NetworkValidation>;
}
export const Network = ({ onFormInit }: NetworkConfigProps) => {
useWaitForConfig({ configCase: "network" });
const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
const { t } = useTranslation("config");
const networkConfig = getEffectiveConfig("network");
const onSubmit = (data: NetworkValidation) => {
const payload = {
...data,
ipv4Config: create(Protobuf.Config.Config_NetworkConfig_IpV4ConfigSchema, {
ip: convertIpAddressToInt(data.ipv4Config?.ip ?? ""),
gateway: convertIpAddressToInt(data.ipv4Config?.gateway ?? ""),
subnet: convertIpAddressToInt(data.ipv4Config?.subnet ?? ""),
dns: convertIpAddressToInt(data.ipv4Config?.dns ?? ""),
}),
};
if (deepCompareConfig(config.network, payload, true)) {
removeChange({ type: "config", variant: "network" });
return;
}
setChange({ type: "config", variant: "network" }, payload, config.network);
};
return (
<DynamicForm<NetworkValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={NetworkValidationSchema}
defaultValues={{
...config.network,
ipv4Config: {
ip: convertIntToIpAddress(config.network?.ipv4Config?.ip ?? 0),
gateway: convertIntToIpAddress(config.network?.ipv4Config?.gateway ?? 0),
subnet: convertIntToIpAddress(config.network?.ipv4Config?.subnet ?? 0),
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
},
enabledProtocols:
config.network?.enabledProtocols ??
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
}}
values={
{
...networkConfig,
ipv4Config: {
ip: convertIntToIpAddress(networkConfig?.ipv4Config?.ip ?? 0),
gateway: convertIntToIpAddress(networkConfig?.ipv4Config?.gateway ?? 0),
subnet: convertIntToIpAddress(networkConfig?.ipv4Config?.subnet ?? 0),
dns: convertIntToIpAddress(networkConfig?.ipv4Config?.dns ?? 0),
},
enabledProtocols:
networkConfig?.enabledProtocols ??
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
} as NetworkValidation
}
fieldGroups={[
{
label: t("network.title"),
description: t("network.description"),
notes: t("network.note"),
fields: [
{
type: "toggle",
name: "wifiEnabled",
label: t("network.wifiEnabled.label"),
description: t("network.wifiEnabled.description"),
},
{
type: "text",
name: "wifiSsid",
label: t("network.ssid.label"),
description: t("network.ssid.label"),
disabledBy: [
{
fieldName: "wifiEnabled",
},
],
},
{
type: "password",
name: "wifiPsk",
label: t("network.psk.label"),
description: t("network.psk.description"),
disabledBy: [
{
fieldName: "wifiEnabled",
},
],
},
],
},
{
label: t("network.ethernetConfigSettings.label"),
description: t("network.ethernetConfigSettings.description"),
fields: [
{
type: "toggle",
name: "ethEnabled",
label: t("network.ethernetEnabled.label"),
description: t("network.ethernetEnabled.description"),
},
],
},
{
label: t("network.ipConfigSettings.label"),
description: t("network.ipConfigSettings.description"),
fields: [
{
type: "select",
name: "addressMode",
label: t("network.addressMode.label"),
description: t("network.addressMode.description"),
properties: {
enumValue: Protobuf.Config.Config_NetworkConfig_AddressMode,
},
},
{
type: "text",
name: "ipv4Config.ip",
label: t("network.ip.label"),
description: t("network.ip.description"),
disabledBy: [
{
fieldName: "addressMode",
selector: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
},
],
},
{
type: "text",
name: "ipv4Config.gateway",
label: t("network.gateway.label"),
description: t("network.gateway.description"),
disabledBy: [
{
fieldName: "addressMode",
selector: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
},
],
},
{
type: "text",
name: "ipv4Config.subnet",
label: t("network.subnet.label"),
description: t("network.subnet.description"),
disabledBy: [
{
fieldName: "addressMode",
selector: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
},
],
},
{
type: "text",
name: "ipv4Config.dns",
label: t("network.dns.label"),
description: t("network.dns.description"),
disabledBy: [
{
fieldName: "addressMode",
selector: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
},
],
},
],
},
{
label: t("network.udpConfigSettings.label"),
description: t("network.udpConfigSettings.description"),
fields: [
{
type: "select",
name: "enabledProtocols",
label: t("network.meshViaUdp.label"),
properties: {
enumValue: Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
formatEnumName: true,
},
},
],
},
{
label: t("network.ntpConfigSettings.label"),
description: t("network.ntpConfigSettings.description"),
fields: [
{
type: "text",
name: "ntpServer",
label: t("network.ntpServer.label"),
},
],
},
{
label: t("network.rsyslogConfigSettings.label"),
description: t("network.rsyslogConfigSettings.description"),
fields: [
{
type: "text",
name: "rsyslogServer",
label: t("network.rsyslogServer.label"),
},
],
},
]}
/>
);
};
@@ -0,0 +1,270 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type PositionValidation,
PositionValidationSchema,
} from "@app/validation/config/position.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { type FlagName, usePositionFlags } from "@core/hooks/usePositionFlags.ts";
import { useDevice, useNodeDB } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
interface PositionConfigProps {
onFormInit: DynamicFormFormInit<PositionValidation>;
}
export const Position = ({ onFormInit }: PositionConfigProps) => {
useWaitForConfig({ configCase: "position" });
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) => {
// 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)) {
removeChange({ type: "config", variant: "position" });
configResult = undefined;
} else {
configResult = 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(
(name: string) => {
return toggleFlag(name as FlagName);
},
[toggleFlag],
);
return (
<DynamicForm<PositionValidation>
onSubmit={(data) => {
data.positionFlags = flagsValue;
return onSubmit(data);
}}
onFormInit={onFormInit}
validationSchema={PositionValidationSchema}
defaultValues={config.position}
values={formValues}
fieldGroups={[
{
label: t("position.title"),
description: t("position.description"),
fields: [
{
type: "toggle",
name: "positionBroadcastSmartEnabled",
label: t("position.smartPositionEnabled.label"),
description: t("position.smartPositionEnabled.description"),
},
{
type: "select",
name: "gpsMode",
label: t("position.gpsMode.label"),
description: t("position.gpsMode.description"),
properties: {
enumValue: Protobuf.Config.Config_PositionConfig_GpsMode,
},
},
{
type: "toggle",
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",
name: "positionFlags",
value: activeFlags,
isChecked: (name: string) => activeFlags?.includes(name as FlagName) ?? false,
onValueChange: onPositonFlagChange,
label: t("position.positionFlags.label"),
placeholder: t("position.flags.placeholder"),
description: t("position.positionFlags.description"),
properties: {
enumValue: getAllFlags(),
},
},
{
type: "number",
name: "rxGpio",
label: t("position.receivePin.label"),
description: t("position.receivePin.description"),
},
{
type: "number",
name: "txGpio",
label: t("position.transmitPin.label"),
description: t("position.transmitPin.description"),
},
{
type: "number",
name: "gpsEnGpio",
label: t("position.enablePin.label"),
description: t("position.enablePin.description"),
},
],
},
{
label: t("position.intervalsSettings.label"),
description: t("position.intervalsSettings.description"),
fields: [
{
type: "number",
name: "positionBroadcastSecs",
label: t("position.broadcastInterval.label"),
description: t("position.broadcastInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "number",
name: "gpsUpdateInterval",
label: t("position.gpsUpdateInterval.label"),
description: t("position.gpsUpdateInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "number",
name: "broadcastSmartMinimumDistance",
label: t("position.smartPositionMinDistance.label"),
description: t("position.smartPositionMinDistance.description"),
disabledBy: [
{
fieldName: "positionBroadcastSmartEnabled",
},
],
},
{
type: "number",
name: "broadcastSmartMinimumIntervalSecs",
label: t("position.smartPositionMinInterval.label"),
description: t("position.smartPositionMinInterval.description"),
disabledBy: [
{
fieldName: "positionBroadcastSmartEnabled",
},
],
},
],
},
]}
/>
);
};
@@ -0,0 +1,115 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import { type PowerValidation, PowerValidationSchema } from "@app/validation/config/power.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { useTranslation } from "react-i18next";
interface PowerConfigProps {
onFormInit: DynamicFormFormInit<PowerValidation>;
}
export const Power = ({ onFormInit }: PowerConfigProps) => {
useWaitForConfig({ configCase: "power" });
const { setChange, config, getEffectiveConfig, removeChange } = useDevice();
const { t } = useTranslation("config");
const onSubmit = (data: PowerValidation) => {
if (deepCompareConfig(config.power, data, true)) {
removeChange({ type: "config", variant: "power" });
return;
}
setChange({ type: "config", variant: "power" }, data, config.power);
};
return (
<DynamicForm<PowerValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={PowerValidationSchema}
defaultValues={config.power}
values={getEffectiveConfig("power")}
fieldGroups={[
{
label: t("power.powerConfigSettings.label"),
description: t("power.powerConfigSettings.description"),
fields: [
{
type: "toggle",
name: "isPowerSaving",
label: t("power.powerSavingEnabled.label"),
description: t("power.powerSavingEnabled.description"),
},
{
type: "number",
name: "onBatteryShutdownAfterSecs",
label: t("power.shutdownOnBatteryDelay.label"),
description: t("power.shutdownOnBatteryDelay.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "number",
name: "adcMultiplierOverride",
label: t("power.adcMultiplierOverride.label"),
description: t("power.adcMultiplierOverride.description"),
properties: {
step: 0.0001,
},
},
{
type: "number",
name: "waitBluetoothSecs",
label: t("power.noConnectionBluetoothDisabled.label"),
description: t("power.noConnectionBluetoothDisabled.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "number",
name: "deviceBatteryInaAddress",
label: t("power.ina219Address.label"),
description: t("power.ina219Address.description"),
},
],
},
{
label: t("power.sleepSettings.label"),
description: t("power.sleepSettings.description"),
fields: [
{
type: "number",
name: "sdsSecs",
label: t("power.superDeepSleepDuration.label"),
description: t("power.superDeepSleepDuration.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "number",
name: "lsSecs",
label: t("power.lightSleepDuration.label"),
description: t("power.lightSleepDuration.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "number",
name: "minWakeSecs",
label: t("power.minimumWakeTime.label"),
description: t("power.minimumWakeTime.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
],
},
]}
/>
);
};
@@ -0,0 +1,286 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type ParsedSecurity,
type RawSecurity,
RawSecuritySchema,
} from "@app/validation/config/security.ts";
import { ManagedModeDialog } from "@components/Dialog/ManagedModeDialog.tsx";
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { createZodResolver } from "@components/Form/createZodResolver.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
import { fromByteArray, toByteArray } from "base64-js";
import { useEffect, useState } from "react";
import { type DefaultValues, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
interface SecurityConfigProps {
onFormInit: DynamicFormFormInit<RawSecurity>;
}
export const Security = ({ onFormInit }: SecurityConfigProps) => {
useWaitForConfig({ configCase: "security" });
const { config, setChange, setDialogOpen, getEffectiveConfig, removeChange } = useDevice();
const { t } = useTranslation("config");
const defaultConfig = config.security;
const defaultValues = {
...defaultConfig,
...{
privateKey: fromByteArray(defaultConfig?.privateKey ?? new Uint8Array(0)),
publicKey: fromByteArray(defaultConfig?.publicKey ?? new Uint8Array(0)),
adminKey: [
fromByteArray(defaultConfig?.adminKey?.at(0) ?? new Uint8Array(0)),
fromByteArray(defaultConfig?.adminKey?.at(1) ?? new Uint8Array(0)),
fromByteArray(defaultConfig?.adminKey?.at(2) ?? new Uint8Array(0)),
],
},
};
const effectiveConfig = getEffectiveConfig("security");
const formValues = {
...effectiveConfig,
...{
privateKey: fromByteArray(effectiveConfig?.privateKey ?? new Uint8Array(0)),
publicKey: fromByteArray(effectiveConfig?.publicKey ?? new Uint8Array(0)),
adminKey: [
fromByteArray(effectiveConfig?.adminKey?.at(0) ?? new Uint8Array(0)),
fromByteArray(effectiveConfig?.adminKey?.at(1) ?? new Uint8Array(0)),
fromByteArray(effectiveConfig?.adminKey?.at(2) ?? new Uint8Array(0)),
],
},
};
const formMethods = useForm<RawSecurity>({
mode: "onChange",
defaultValues: defaultValues as DefaultValues<RawSecurity>,
resolver: createZodResolver(RawSecuritySchema),
shouldFocusError: false,
resetOptions: { keepDefaultValues: true },
values: formValues as RawSecurity,
});
const { setValue, trigger, handleSubmit, formState } = formMethods;
useEffect(() => {
onFormInit?.(formMethods);
}, [onFormInit, formMethods]);
const [privateKeyDialogOpen, setPrivateKeyDialogOpen] = useState<boolean>(false);
const [managedModeDialogOpen, setManagedModeDialogOpen] = useState<boolean>(false);
const onSubmit = (data: RawSecurity) => {
if (!formState.isReady) {
return;
}
const payload: ParsedSecurity = {
...data,
privateKey: toByteArray(data.privateKey),
publicKey: toByteArray(data.publicKey),
adminKey: [
toByteArray(data.adminKey.at(0) ?? ""),
toByteArray(data.adminKey.at(1) ?? ""),
toByteArray(data.adminKey.at(2) ?? ""),
],
};
if (deepCompareConfig(config.security, payload, true)) {
removeChange({ type: "config", variant: "security" });
return;
}
setChange({ type: "config", variant: "security" }, payload, config.security);
};
const pkiRegenerate = () => {
const privateKey = getX25519PrivateKey();
updatePublicKey(fromByteArray(privateKey));
};
const updatePublicKey = async (privateKey: string) => {
try {
const publicKey = fromByteArray(getX25519PublicKey(toByteArray(privateKey)));
setValue("privateKey", privateKey, { shouldDirty: true });
setValue("publicKey", publicKey, { shouldDirty: true });
setPrivateKeyDialogOpen(false);
} catch {
setValue("privateKey", privateKey, { shouldDirty: true });
}
const valid = await trigger(["privateKey", "publicKey"]);
if (valid) {
handleSubmit(onSubmit)(); // manually invoke form submit
}
};
const bits = [
{
text: t("security.256bit"),
value: "32",
key: "bit256",
},
];
return (
<>
<DynamicForm<RawSecurity>
propMethods={formMethods}
onSubmit={onSubmit}
fieldGroups={[
{
label: t("security.title"),
description: t("security.description"),
fields: [
{
type: "passwordGenerator",
id: "pskInput",
name: "privateKey",
label: t("security.privateKey.label"),
description: t("security.privateKey.description"),
bits,
devicePSKBitCount: 32,
hide: true,
inputChange: (e: React.ChangeEvent<HTMLInputElement>) => {
updatePublicKey(e.target.value);
},
actionButtons: [
{
text: t("button.generate"),
onClick: () => setPrivateKeyDialogOpen(true),
variant: "success",
},
{
text: t("button.backupKey"),
onClick: () => setDialogOpen("pkiBackup", true),
variant: "subtle",
},
],
properties: {
showCopyButton: true,
showPasswordToggle: true,
},
},
{
type: "text",
name: "publicKey",
label: t("security.publicKey.label"),
disabled: true,
description: t("security.publicKey.description"),
properties: {
showCopyButton: true,
},
},
],
},
{
label: t("security.adminSettings.label"),
description: t("security.adminSettings.description"),
fields: [
{
type: "passwordGenerator",
name: "adminKey.0",
id: "adminKey0Input",
label: t("security.primaryAdminKey.label"),
description: t("security.primaryAdminKey.description"),
bits,
devicePSKBitCount: 32,
actionButtons: [],
disabledBy: [{ fieldName: "adminChannelEnabled", invert: true }],
properties: {
showCopyButton: true,
},
},
{
type: "passwordGenerator",
name: "adminKey.1",
id: "adminKey1Input",
label: t("security.secondaryAdminKey.label"),
description: t("security.secondaryAdminKey.description"),
bits,
devicePSKBitCount: 32,
actionButtons: [],
disabledBy: [{ fieldName: "adminChannelEnabled", invert: true }],
properties: {
showCopyButton: true,
},
},
{
type: "passwordGenerator",
name: "adminKey.2",
id: "adminKey2Input",
label: t("security.tertiaryAdminKey.label"),
description: t("security.tertiaryAdminKey.description"),
bits,
devicePSKBitCount: 32,
actionButtons: [],
disabledBy: [{ fieldName: "adminChannelEnabled", invert: true }],
properties: {
showCopyButton: true,
},
},
{
type: "toggle",
name: "isManaged",
label: t("security.managed.label"),
description: t("security.managed.description"),
inputChange: (checked) => {
if (checked) {
setManagedModeDialogOpen(true);
}
setValue("isManaged", false);
},
},
{
type: "toggle",
name: "adminChannelEnabled",
label: t("security.adminChannelEnabled.label"),
description: t("security.adminChannelEnabled.description"),
},
],
},
{
label: t("security.loggingSettings.label"),
description: t("security.loggingSettings.description"),
fields: [
{
type: "toggle",
name: "debugLogApiEnabled",
label: t("security.enableDebugLogApi.label"),
description: t("security.enableDebugLogApi.description"),
},
{
type: "toggle",
name: "serialEnabled",
label: t("security.serialOutputEnabled.label"),
description: t("security.serialOutputEnabled.description"),
},
],
},
]}
/>
<PkiRegenerateDialog
text={{
button: t("button.regenerate"),
title: t("pkiRegenerate.title"),
description: t("pkiRegenerate.description"),
}}
open={privateKeyDialogOpen}
onOpenChange={() => setPrivateKeyDialogOpen((prev) => !prev)}
onSubmit={pkiRegenerate}
/>
<ManagedModeDialog
open={managedModeDialogOpen}
onOpenChange={() => setManagedModeDialogOpen((prev) => !prev)}
onSubmit={() => {
setValue("isManaged", true);
setManagedModeDialogOpen(false);
}}
/>
</>
);
};
@@ -0,0 +1,103 @@
import { type UserValidation, UserValidationSchema } from "@app/validation/config/user.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice, useNodeDB } from "@core/stores";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface UserConfigProps {
onFormInit: DynamicFormFormInit<UserValidation>;
}
export const User = ({ onFormInit }: UserConfigProps) => {
const { hardware, getChange, connection } = useDevice();
const { getNode } = useNodeDB();
const { t } = useTranslation("config");
const myNode = getNode(hardware.myNodeNum);
const defaultUser = myNode?.user ?? {
id: "",
longName: "",
shortName: "",
isLicensed: false,
};
// Get working copy from change registry
const workingUser = getChange({ type: "user" }) as Protobuf.Mesh.User | undefined;
const effectiveUser = workingUser ?? defaultUser;
const onSubmit = (data: UserValidation) => {
connection?.setOwner(
create(Protobuf.Mesh.UserSchema, {
...data,
}),
);
};
return (
<DynamicForm<UserValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={UserValidationSchema}
defaultValues={{
longName: defaultUser.longName,
shortName: defaultUser.shortName,
isLicensed: defaultUser.isLicensed,
isUnmessageable: false,
}}
values={{
longName: effectiveUser.longName,
shortName: effectiveUser.shortName,
isLicensed: effectiveUser.isLicensed,
isUnmessageable: false,
}}
fieldGroups={[
{
label: t("user.title"),
description: t("user.description"),
fields: [
{
type: "text",
name: "longName",
label: t("user.longName.label"),
description: t("user.longName.description"),
properties: {
fieldLength: {
min: 1,
max: 40,
showCharacterCount: true,
},
},
},
{
type: "text",
name: "shortName",
label: t("user.shortName.label"),
description: t("user.shortName.description"),
properties: {
fieldLength: {
min: 2,
max: 4,
showCharacterCount: true,
},
},
},
{
type: "toggle",
name: "isUnmessageable",
label: t("user.isUnmessageable.label"),
description: t("user.isUnmessageable.description"),
},
{
type: "toggle",
name: "isLicensed",
label: t("user.isLicensed.label"),
description: t("user.isLicensed.description"),
},
],
},
]}
/>
);
};