Add monorepo support to project (#682)
* feat: add monorepo support * fix: correct relatedProjects structure in vercel.json * update github actions with monorepo support * removed legacy dep * moved jsr config into web package
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
import { makeChannelSchema } from "@app/validation/channel.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { fromByteArray, toByteArray } from "base64-js";
|
||||
import cryptoRandomString from "crypto-random-string";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
|
||||
import { infer as zodInfer } from "zod/v4";
|
||||
|
||||
export interface SettingsPanelProps {
|
||||
channel: Protobuf.Channel.Channel;
|
||||
}
|
||||
|
||||
export const Channel = ({ channel }: SettingsPanelProps) => {
|
||||
const { config, connection, addChannel } = useDevice();
|
||||
const { t } = useTranslation(["channels", "ui", "dialog"]);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [preSharedDialogOpen, setPreSharedDialogOpen] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
const [pass, setPass] = useState<string>(
|
||||
fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
|
||||
);
|
||||
const [byteCount, setBytes] = useState<number>(
|
||||
channel?.settings?.psk.length ?? 16,
|
||||
);
|
||||
|
||||
const ChannelValidationSchema = useMemo(
|
||||
() => {
|
||||
return makeChannelSchema(byteCount);
|
||||
},
|
||||
[byteCount],
|
||||
);
|
||||
|
||||
type ChannelValidation = zodInfer<typeof ChannelValidationSchema>;
|
||||
|
||||
const onSubmit = (data: ChannelValidation) => {
|
||||
const channel = create(Protobuf.Channel.ChannelSchema, {
|
||||
...data,
|
||||
settings: {
|
||||
...data.settings,
|
||||
psk: toByteArray(pass),
|
||||
moduleSettings: create(Protobuf.Channel.ModuleSettingsSchema, {
|
||||
...data.settings.moduleSettings,
|
||||
positionPrecision: data.settings.moduleSettings.positionPrecision,
|
||||
}),
|
||||
},
|
||||
});
|
||||
connection?.setChannel(channel).then(() => {
|
||||
console.debug(t("toast.savedChannel.title", {
|
||||
ns: "ui",
|
||||
channelName: channel.settings?.name,
|
||||
}));
|
||||
toast({
|
||||
title: t("toast.savedChannel.title", {
|
||||
ns: "ui",
|
||||
channelName: channel.settings?.name,
|
||||
}),
|
||||
});
|
||||
addChannel(channel);
|
||||
});
|
||||
};
|
||||
|
||||
const preSharedKeyRegenerate = () => {
|
||||
const newPsk = btoa(
|
||||
cryptoRandomString({
|
||||
length: byteCount ?? 0,
|
||||
type: "alphanumeric",
|
||||
}),
|
||||
);
|
||||
setPass(newPsk);
|
||||
|
||||
setPreSharedDialogOpen(false);
|
||||
};
|
||||
|
||||
const preSharedClickEvent = () => {
|
||||
setPreSharedDialogOpen(true);
|
||||
};
|
||||
|
||||
const inputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPass(e.currentTarget?.value);
|
||||
};
|
||||
|
||||
const selectChangeEvent = (e: string) => {
|
||||
const count = Number.parseInt(e);
|
||||
setBytes(count);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DynamicForm<ChannelValidation>
|
||||
onSubmit={onSubmit}
|
||||
submitType="onSubmit"
|
||||
validationSchema={ChannelValidationSchema}
|
||||
hasSubmitButton
|
||||
defaultValues={{
|
||||
...channel,
|
||||
...{
|
||||
settings: {
|
||||
...channel?.settings,
|
||||
psk: pass,
|
||||
moduleSettings: {
|
||||
...channel?.settings?.moduleSettings,
|
||||
positionPrecision:
|
||||
channel?.settings?.moduleSettings?.positionPrecision ===
|
||||
undefined
|
||||
? 10
|
||||
: channel?.settings?.moduleSettings?.positionPrecision,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
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 ?? 0,
|
||||
inputChange: inputChangeEvent,
|
||||
selectChange: selectChangeEvent,
|
||||
actionButtons: [
|
||||
{
|
||||
text: t("psk.generate"),
|
||||
variant: "success",
|
||||
onClick: preSharedClickEvent,
|
||||
},
|
||||
],
|
||||
hide: true,
|
||||
properties: {
|
||||
value: pass,
|
||||
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,85 @@
|
||||
import {
|
||||
type BluetoothValidation,
|
||||
BluetoothValidationSchema,
|
||||
} from "@app/validation/config/bluetooth.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
interface BluetoothConfigProps {
|
||||
onFormInit: DynamicFormFormInit<BluetoothValidation>;
|
||||
}
|
||||
export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => {
|
||||
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
const onSubmit = (data: BluetoothValidation) => {
|
||||
if (deepCompareConfig(config.bluetooth, data, true)) {
|
||||
removeWorkingConfig("bluetooth");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingConfig(
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "bluetooth",
|
||||
value: data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<BluetoothValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={BluetoothValidationSchema}
|
||||
formId="Config_BluetoothConfig"
|
||||
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,37 @@
|
||||
import {
|
||||
useDevice,
|
||||
ValidConfigType,
|
||||
ValidModuleConfigType,
|
||||
} from "@core/stores/deviceStore.ts";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function ConfigSuspender({
|
||||
configCase,
|
||||
moduleConfigCase,
|
||||
children,
|
||||
}: {
|
||||
configCase?: ValidConfigType;
|
||||
moduleConfigCase?: ValidModuleConfigType;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { config, moduleConfig } = useDevice();
|
||||
|
||||
let cfg = undefined;
|
||||
if (configCase) {
|
||||
cfg = config[configCase];
|
||||
} else if (moduleConfigCase) {
|
||||
cfg = moduleConfig[moduleConfigCase];
|
||||
} else {
|
||||
return children;
|
||||
}
|
||||
|
||||
const [ready, setReady] = useState(() => cfg !== undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (cfg !== undefined) setReady(true);
|
||||
}, [cfg]);
|
||||
|
||||
if (!ready) throw new Promise(() => {}); // triggers suspense fallback
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
type DeviceValidation,
|
||||
DeviceValidationSchema,
|
||||
} from "@app/validation/config/device.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
interface DeviceConfigProps {
|
||||
onFormInit: DynamicFormFormInit<DeviceValidation>;
|
||||
}
|
||||
export const Device = ({ onFormInit }: DeviceConfigProps) => {
|
||||
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
const { validateRoleSelection } = useUnsafeRolesDialog();
|
||||
|
||||
const onSubmit = (data: DeviceValidation) => {
|
||||
if (deepCompareConfig(config.device, data, true)) {
|
||||
removeWorkingConfig("device");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingConfig(
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "device",
|
||||
value: data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<DeviceValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={DeviceValidationSchema}
|
||||
formId="Config_DeviceConfig"
|
||||
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,144 @@
|
||||
import {
|
||||
type DisplayValidation,
|
||||
DisplayValidationSchema,
|
||||
} from "@app/validation/config/display.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
interface DisplayConfigProps {
|
||||
onFormInit: DynamicFormFormInit<DisplayValidation>;
|
||||
}
|
||||
export const Display = ({ onFormInit }: DisplayConfigProps) => {
|
||||
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
const onSubmit = (data: DisplayValidation) => {
|
||||
if (deepCompareConfig(config.display, data, true)) {
|
||||
removeWorkingConfig("display");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingConfig(
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "display",
|
||||
value: data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<DisplayValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={DisplayValidationSchema}
|
||||
formId="Config_DisplayConfig"
|
||||
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"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "gpsFormat",
|
||||
label: t("display.gpsDisplayUnits.label"),
|
||||
description: t("display.gpsDisplayUnits.description"),
|
||||
properties: {
|
||||
enumValue:
|
||||
Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat,
|
||||
},
|
||||
},
|
||||
{
|
||||
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"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
type LoRaValidation,
|
||||
LoRaValidationSchema,
|
||||
} from "@app/validation/config/lora.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
interface LoRaConfigProps {
|
||||
onFormInit: DynamicFormFormInit<LoRaValidation>;
|
||||
}
|
||||
export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
|
||||
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
const onSubmit = (data: LoRaValidation) => {
|
||||
if (deepCompareConfig(config.lora, data, true)) {
|
||||
removeWorkingConfig("lora");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingConfig(
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "lora",
|
||||
value: data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<LoRaValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={LoRaValidationSchema}
|
||||
formId="Config_LoRaConfig"
|
||||
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.megahertz"),
|
||||
},
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,248 @@
|
||||
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/deviceStore.ts";
|
||||
import {
|
||||
convertIntToIpAddress,
|
||||
convertIpAddressToInt,
|
||||
} from "@core/utils/ip.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
interface NetworkConfigProps {
|
||||
onFormInit: DynamicFormFormInit<NetworkValidation>;
|
||||
}
|
||||
export const Network = ({ onFormInit }: NetworkConfigProps) => {
|
||||
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
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)) {
|
||||
removeWorkingConfig("network");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingConfig(
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "network",
|
||||
value: payload,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
return (
|
||||
<DynamicForm<NetworkValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={NetworkValidationSchema}
|
||||
formId="Config_NetworkConfig"
|
||||
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,174 @@
|
||||
import {
|
||||
type FlagName,
|
||||
usePositionFlags,
|
||||
} from "@core/hooks/usePositionFlags.ts";
|
||||
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 { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
interface PositionConfigProps {
|
||||
onFormInit: DynamicFormFormInit<PositionValidation>;
|
||||
}
|
||||
export const Position = ({ onFormInit }: PositionConfigProps) => {
|
||||
const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
|
||||
getEffectiveConfig("position")?.positionFlags ?? 0,
|
||||
);
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
const onSubmit = (data: PositionValidation) => {
|
||||
if (deepCompareConfig(config.position, data, true)) {
|
||||
removeWorkingConfig("position");
|
||||
return;
|
||||
}
|
||||
|
||||
return setWorkingConfig(
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "position",
|
||||
value: { ...data, positionFlags: flagsValue },
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
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}
|
||||
formId="Config_PositionConfig"
|
||||
defaultValues={config.position}
|
||||
values={getEffectiveConfig("position")}
|
||||
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"),
|
||||
},
|
||||
{
|
||||
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,129 @@
|
||||
import {
|
||||
type PowerValidation,
|
||||
PowerValidationSchema,
|
||||
} from "@app/validation/config/power.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
interface PowerConfigProps {
|
||||
onFormInit: DynamicFormFormInit<PowerValidation>;
|
||||
}
|
||||
export const Power = ({ onFormInit }: PowerConfigProps) => {
|
||||
const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
const onSubmit = (data: PowerValidation) => {
|
||||
if (deepCompareConfig(config.power, data, true)) {
|
||||
removeWorkingConfig("power");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingConfig(
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "power",
|
||||
value: data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<PowerValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={PowerValidationSchema}
|
||||
formId="Config_PowerConfig"
|
||||
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,313 @@
|
||||
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
|
||||
import { ManagedModeDialog } from "@components/Dialog/ManagedModeDialog.tsx";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
|
||||
import {
|
||||
type ParsedSecurity,
|
||||
type RawSecurity,
|
||||
RawSecuritySchema,
|
||||
} from "@app/validation/config/security.ts";
|
||||
import { useEffect, useState } from "react";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { fromByteArray, toByteArray } from "base64-js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type DefaultValues, useForm } from "react-hook-form";
|
||||
import { createZodResolver } from "@components/Form/createZodResolver.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
interface SecurityConfigProps {
|
||||
onFormInit: DynamicFormFormInit<RawSecurity>;
|
||||
}
|
||||
export const Security = ({ onFormInit }: SecurityConfigProps) => {
|
||||
const {
|
||||
config,
|
||||
setWorkingConfig,
|
||||
setDialogOpen,
|
||||
getEffectiveConfig,
|
||||
removeWorkingConfig,
|
||||
} = useDevice();
|
||||
|
||||
const { removeError } = useAppStore();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
const securityConfig = getEffectiveConfig("security");
|
||||
const defaultValues = {
|
||||
...securityConfig,
|
||||
...{
|
||||
privateKey: fromByteArray(
|
||||
securityConfig?.privateKey ?? new Uint8Array(0),
|
||||
),
|
||||
publicKey: fromByteArray(
|
||||
securityConfig?.publicKey ?? new Uint8Array(0),
|
||||
),
|
||||
adminKey: [
|
||||
fromByteArray(
|
||||
securityConfig?.adminKey?.at(0) ?? new Uint8Array(0),
|
||||
),
|
||||
fromByteArray(
|
||||
securityConfig?.adminKey?.at(1) ?? new Uint8Array(0),
|
||||
),
|
||||
fromByteArray(
|
||||
securityConfig?.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 },
|
||||
});
|
||||
const { setValue, 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)) {
|
||||
removeWorkingConfig("security");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingConfig(
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "security",
|
||||
value: payload,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const pkiRegenerate = () => {
|
||||
const privateKey = getX25519PrivateKey();
|
||||
updatePublicKey(fromByteArray(privateKey));
|
||||
};
|
||||
|
||||
const updatePublicKey = (privateKey: string) => {
|
||||
try {
|
||||
const publicKey = fromByteArray(
|
||||
getX25519PublicKey(toByteArray(privateKey)),
|
||||
);
|
||||
setValue("privateKey", privateKey);
|
||||
setValue("publicKey", publicKey);
|
||||
|
||||
removeError("privateKey");
|
||||
removeError("publicKey");
|
||||
setPrivateKeyDialogOpen(false);
|
||||
} catch (_e) {
|
||||
setValue("privateKey", privateKey);
|
||||
}
|
||||
};
|
||||
|
||||
const bits = [
|
||||
{
|
||||
text: t("security.256bit"),
|
||||
value: "32",
|
||||
key: "bit256",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DynamicForm<RawSecurity>
|
||||
propMethods={formMethods}
|
||||
onSubmit={onSubmit}
|
||||
formId="Config_SecurityConfig"
|
||||
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,
|
||||
hide: true,
|
||||
actionButtons: [],
|
||||
disabledBy: [
|
||||
{ fieldName: "adminChannelEnabled", invert: true },
|
||||
],
|
||||
properties: {
|
||||
showCopyButton: true,
|
||||
showPasswordToggle: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "passwordGenerator",
|
||||
name: "adminKey.1",
|
||||
id: "adminKey1Input",
|
||||
label: t("security.secondaryAdminKey.label"),
|
||||
description: t("security.secondaryAdminKey.description"),
|
||||
bits,
|
||||
devicePSKBitCount: 32,
|
||||
hide: true,
|
||||
actionButtons: [],
|
||||
disabledBy: [
|
||||
{ fieldName: "adminChannelEnabled", invert: true },
|
||||
],
|
||||
properties: {
|
||||
showCopyButton: true,
|
||||
showPasswordToggle: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "passwordGenerator",
|
||||
name: "adminKey.2",
|
||||
id: "adminKey2Input",
|
||||
label: t("security.tertiaryAdminKey.label"),
|
||||
description: t("security.tertiaryAdminKey.description"),
|
||||
bits,
|
||||
devicePSKBitCount: 32,
|
||||
hide: true,
|
||||
actionButtons: [],
|
||||
disabledBy: [
|
||||
{ fieldName: "adminChannelEnabled", invert: true },
|
||||
],
|
||||
properties: {
|
||||
showCopyButton: true,
|
||||
showPasswordToggle: 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,92 @@
|
||||
import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Mono } from "@components/generic/Mono.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { subscribeAll } from "@core/subscriptions.ts";
|
||||
import { randId } from "@core/utils/randId.ts";
|
||||
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
|
||||
import { MeshDevice } from "@meshtastic/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const BLE = (
|
||||
{ closeDialog }: TabElementProps,
|
||||
) => {
|
||||
const [connectionInProgress, setConnectionInProgress] = useState(false);
|
||||
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
|
||||
const { addDevice } = useDeviceStore();
|
||||
const messageStore = useMessageStore();
|
||||
const { setSelectedDevice } = useAppStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const updateBleDeviceList = useCallback(async (): Promise<void> => {
|
||||
setBleDevices(await navigator.bluetooth.getDevices());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateBleDeviceList();
|
||||
}, [updateBleDeviceList]);
|
||||
|
||||
const onConnect = async (bleDevice: BluetoothDevice) => {
|
||||
const id = randId();
|
||||
const transport = await TransportWebBluetooth.createFromDevice(bleDevice);
|
||||
const device = addDevice(id);
|
||||
const connection = new MeshDevice(transport, id);
|
||||
connection.configure();
|
||||
setSelectedDevice(id);
|
||||
device.addConnection(connection);
|
||||
subscribeAll(device, connection, messageStore);
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset
|
||||
className="flex w-full flex-col gap-2 p-4"
|
||||
disabled={connectionInProgress}
|
||||
>
|
||||
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
|
||||
{bleDevices.map((device) => (
|
||||
<Button
|
||||
key={device.id}
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setConnectionInProgress(true);
|
||||
onConnect(device);
|
||||
}}
|
||||
>
|
||||
{device.name}
|
||||
</Button>
|
||||
))}
|
||||
{bleDevices.length === 0 && (
|
||||
<Mono className="m-auto select-none">
|
||||
{t("newDeviceDialog.bluetoothConnection.noDevicesPaired")}
|
||||
</Mono>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={async () => {
|
||||
await navigator.bluetooth
|
||||
.requestDevice({
|
||||
filters: [{ services: [TransportWebBluetooth.ServiceUuid] }],
|
||||
})
|
||||
.then((device) => {
|
||||
const exists = bleDevices.findIndex((d) => d.id === device.id);
|
||||
if (exists === -1) {
|
||||
setBleDevices(bleDevices.concat(device));
|
||||
}
|
||||
}).catch((error: Error) => {
|
||||
console.error("Error requesting device:", error);
|
||||
setConnectionInProgress(false);
|
||||
}).finally(() => {
|
||||
setConnectionInProgress(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>{t("newDeviceDialog.bluetoothConnection.newDeviceButton")}</span>
|
||||
</Button>
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
|
||||
import { MeshDevice } from "@meshtastic/core";
|
||||
import { TransportHTTP } from "@meshtastic/transport-http";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@core/stores/appStore.ts", () => ({
|
||||
useAppStore: vi.fn(() => ({ setSelectedDevice: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock("@core/stores/deviceStore.ts", () => ({
|
||||
useDeviceStore: vi.fn(() => ({
|
||||
addDevice: vi.fn(() => ({ addConnection: vi.fn() })),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@core/utils/randId.ts", () => ({
|
||||
randId: vi.fn(() => "mock-id"),
|
||||
}));
|
||||
|
||||
vi.mock("@meshtastic/transport-http", () => ({
|
||||
TransportHTTP: {
|
||||
create: vi.fn(() => Promise.resolve({})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@meshtastic/core", () => ({
|
||||
MeshDevice: vi.fn(() => ({
|
||||
configure: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("HTTP Component", () => {
|
||||
it("renders correctly", () => {
|
||||
render(<HTTP closeDialog={vi.fn()} />);
|
||||
expect(screen.getByText("IP Address/Hostname")).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText("Use HTTPS")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows input field to be updated", () => {
|
||||
render(<HTTP closeDialog={vi.fn()} />);
|
||||
const inputField = screen.getByRole("textbox");
|
||||
fireEvent.change(inputField, { target: { value: "meshtastic.local" } });
|
||||
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles HTTPS switch and updates prefix", () => {
|
||||
render(<HTTP closeDialog={vi.fn()} />);
|
||||
|
||||
const switchInput = screen.getByRole("switch");
|
||||
expect(screen.getByText("http://")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(switchInput);
|
||||
expect(screen.getByText("https://")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(switchInput);
|
||||
expect(switchInput).not.toBeChecked();
|
||||
expect(screen.getByText("http://")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enables HTTPS toggle when location protocol is https", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { protocol: "https:" },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
render(<HTTP closeDialog={vi.fn()} />);
|
||||
|
||||
const switchInput = screen.getByRole("switch");
|
||||
expect(switchInput).toBeChecked();
|
||||
|
||||
expect(screen.getByText("https://")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.skip("submits form and triggers connection process", async () => {
|
||||
const closeDialog = vi.fn();
|
||||
render(<HTTP closeDialog={closeDialog} />);
|
||||
const button = screen.getByRole("button", { name: "Connect" });
|
||||
expect(button).not.toBeDisabled();
|
||||
|
||||
try {
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => {
|
||||
expect(button).toBeDisabled();
|
||||
expect(closeDialog).toBeCalled();
|
||||
expect(TransportHTTP.create).toBeCalled();
|
||||
expect(MeshDevice).toBeCalled();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { TabElementProps } from "@components/Dialog/NewDeviceDialog.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { Link } from "@components/UI/Typography/Link.tsx";
|
||||
import { Label } from "@components/UI/Label.tsx";
|
||||
import { Switch } from "@components/UI/Switch.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { subscribeAll } from "@core/subscriptions.ts";
|
||||
import { randId } from "@core/utils/randId.ts";
|
||||
import { MeshDevice } from "@meshtastic/core";
|
||||
import { TransportHTTP } from "@meshtastic/transport-http";
|
||||
import { useState } from "react";
|
||||
import { useController, useForm } from "react-hook-form";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface FormData {
|
||||
ip: string;
|
||||
tls: boolean;
|
||||
}
|
||||
|
||||
export const HTTP = (
|
||||
{ closeDialog }: TabElementProps,
|
||||
) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const [connectionInProgress, setConnectionInProgress] = useState(false);
|
||||
const isURLHTTPS = location.protocol === "https:";
|
||||
|
||||
const { addDevice } = useDeviceStore();
|
||||
const messageStore = useMessageStore();
|
||||
const { setSelectedDevice } = useAppStore();
|
||||
|
||||
const { control, handleSubmit, register } = useForm<FormData>({
|
||||
defaultValues: {
|
||||
ip: ["client.meshtastic.org", "localhost"].includes(
|
||||
globalThis.location.hostname,
|
||||
)
|
||||
? "meshtastic.local"
|
||||
: globalThis.location.host,
|
||||
tls: isURLHTTPS ? true : false,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
field: { value: tlsValue, onChange: setTLS },
|
||||
} = useController({ name: "tls", control });
|
||||
|
||||
const [connectionError, setConnectionError] = useState<
|
||||
{ host: string; secure: boolean } | null
|
||||
>(null);
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
setConnectionInProgress(true);
|
||||
setConnectionError(null);
|
||||
|
||||
try {
|
||||
const id = randId();
|
||||
const transport = await TransportHTTP.create(data.ip, data.tls);
|
||||
const device = addDevice(id);
|
||||
const connection = new MeshDevice(transport, id);
|
||||
connection.configure();
|
||||
setSelectedDevice(id);
|
||||
device.addConnection(connection);
|
||||
subscribeAll(device, connection, messageStore);
|
||||
closeDialog();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error("Connection error:", error);
|
||||
}
|
||||
// Capture all connection errors regardless of type
|
||||
setConnectionError({ host: data.ip, secure: data.tls });
|
||||
setConnectionInProgress(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<form className="flex w-full flex-col gap-2 p-4" onSubmit={onSubmit}>
|
||||
<fieldset
|
||||
className="flex flex-col gap-2"
|
||||
style={{ minHeight: "12rem" }}
|
||||
disabled={connectionInProgress}
|
||||
>
|
||||
<div>
|
||||
<Label>{t("newDeviceDialog.httpConnection.label")}</Label>
|
||||
<Input
|
||||
prefix={tlsValue
|
||||
? `${t("newDeviceDialog.https")}://`
|
||||
: `${t("newDeviceDialog.http")}://`}
|
||||
placeholder={t("newDeviceDialog.httpConnection.placeholder")}
|
||||
className="text-slate-900 dark:text-slate-100"
|
||||
{...register("ip")}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Switch
|
||||
onCheckedChange={setTLS}
|
||||
disabled={isURLHTTPS}
|
||||
checked={isURLHTTPS || tlsValue}
|
||||
{...register("tls")}
|
||||
/>
|
||||
<Label>{t("newDeviceDialog.useHttps")}</Label>
|
||||
</div>
|
||||
|
||||
{connectionError && (
|
||||
<div className="mt-2 mb-2 p-3 rounded-md bg-amber-100 border border-amber-300 dark:bg-amber-100 dark:border-amber-300">
|
||||
<div className="flex gap-2 items-start">
|
||||
<AlertTriangle
|
||||
className="shrink-0 mt-0.5 text-amber-600 dark:text-amber-600"
|
||||
size={20}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-800">
|
||||
{t("newDeviceDialog.connectionFailedAlert.title")}
|
||||
</p>
|
||||
<p className="text-xs mt-1 text-amber-700 dark:text-amber-700">
|
||||
{t("newDeviceDialog.connectionFailedAlert.descriptionPrefix")}
|
||||
{connectionError.secure &&
|
||||
t("newDeviceDialog.connectionFailedAlert.httpsHint")}
|
||||
{t("newDeviceDialog.connectionFailedAlert.openLinkPrefix")}
|
||||
<Link
|
||||
href={`${
|
||||
connectionError.secure
|
||||
? t("newDeviceDialog.https")
|
||||
: t("newDeviceDialog.http")
|
||||
}://${connectionError.host}`}
|
||||
className="underline font-medium text-amber-800 dark:text-amber-800"
|
||||
>
|
||||
{`${
|
||||
connectionError.secure
|
||||
? t("newDeviceDialog.https")
|
||||
: t("newDeviceDialog.http")
|
||||
}://${connectionError.host}`}
|
||||
</Link>{" "}
|
||||
{t("newDeviceDialog.connectionFailedAlert.openLinkSuffix")}
|
||||
{connectionError.secure
|
||||
? t(
|
||||
"newDeviceDialog.connectionFailedAlert.acceptTlsWarningSuffix",
|
||||
)
|
||||
: ""}.{" "}
|
||||
<Link
|
||||
href="https://meshtastic.org/docs/software/web-client/#http"
|
||||
className="underline font-medium text-amber-800 dark:text-amber-800"
|
||||
>
|
||||
{t("newDeviceDialog.connectionFailedAlert.learnMoreLink")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
>
|
||||
<span>
|
||||
{connectionInProgress
|
||||
? t("newDeviceDialog.connecting")
|
||||
: t("newDeviceDialog.connect")}
|
||||
</span>
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Mono } from "@components/generic/Mono.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { subscribeAll } from "@core/subscriptions.ts";
|
||||
import { randId } from "@core/utils/randId.ts";
|
||||
import { MeshDevice } from "@meshtastic/core";
|
||||
import { TransportWebSerial } from "@meshtastic/transport-web-serial";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||
|
||||
export const Serial = (
|
||||
{ closeDialog }: TabElementProps,
|
||||
) => {
|
||||
const [connectionInProgress, setConnectionInProgress] = useState(false);
|
||||
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
|
||||
const { addDevice } = useDeviceStore();
|
||||
const messageStore = useMessageStore();
|
||||
const { setSelectedDevice } = useAppStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const updateSerialPortList = useCallback(async () => {
|
||||
setSerialPorts(await navigator.serial.getPorts());
|
||||
}, []);
|
||||
|
||||
navigator.serial.addEventListener("connect", () => {
|
||||
updateSerialPortList();
|
||||
});
|
||||
navigator.serial.addEventListener("disconnect", () => {
|
||||
updateSerialPortList();
|
||||
});
|
||||
useEffect(() => {
|
||||
updateSerialPortList();
|
||||
}, [updateSerialPortList]);
|
||||
|
||||
const onConnect = async (port: SerialPort) => {
|
||||
const id = randId();
|
||||
const device = addDevice(id);
|
||||
setSelectedDevice(id);
|
||||
const transport = await TransportWebSerial.createFromPort(port);
|
||||
const connection = new MeshDevice(transport, id);
|
||||
connection.configure();
|
||||
device.addConnection(connection);
|
||||
subscribeAll(device, connection, messageStore);
|
||||
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset
|
||||
className="flex w-full flex-col gap-2 p-4"
|
||||
disabled={connectionInProgress}
|
||||
>
|
||||
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
|
||||
{serialPorts.map((port, index) => {
|
||||
const { usbProductId, usbVendorId } = port.getInfo();
|
||||
const vendor = usbVendorId ?? t("unknown.shortName");
|
||||
const product = usbProductId ?? t("unknown.shortName");
|
||||
return (
|
||||
<Button
|
||||
key={`${vendor}-${product}-${index}`}
|
||||
disabled={port.readable !== null}
|
||||
variant="default"
|
||||
onClick={async () => {
|
||||
setConnectionInProgress(true);
|
||||
await onConnect(port);
|
||||
// No need to setConnectionInProgress(false) here as closeDialog() unmounts.
|
||||
}}
|
||||
>
|
||||
{t("newDeviceDialog.serialConnection.deviceIdentifier", {
|
||||
index: index,
|
||||
vendorId: vendor,
|
||||
productId: product,
|
||||
})}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{serialPorts.length === 0 && (
|
||||
<Mono className="m-auto select-none">
|
||||
{t("newDeviceDialog.serialConnection.noDevicesPaired")}
|
||||
</Mono>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={async () => {
|
||||
await navigator.serial.requestPort().then((port) => {
|
||||
setSerialPorts(serialPorts.concat(port));
|
||||
// No need to setConnectionInProgress(false) here if requestPort is quick
|
||||
}).catch((error: Error) => {
|
||||
console.error("Error requesting port:", error);
|
||||
}).finally(() => {
|
||||
setConnectionInProgress(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>{t("newDeviceDialog.serialConnection.newDeviceButton")}</span>
|
||||
</Button>
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Separator } from "@components/UI/Seperator.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 { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import { Mono } from "@components/generic/Mono.tsx";
|
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import type { Protobuf as ProtobufType } from "@meshtastic/core";
|
||||
import {
|
||||
Dot,
|
||||
LockIcon,
|
||||
LockOpenIcon,
|
||||
MessageSquareIcon,
|
||||
MountainSnow,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@radix-ui/react-tooltip";
|
||||
import BatteryStatus from "@components/BatteryStatus.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
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}`;
|
||||
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 text={shortName} 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("node_detail_public_key_enabled_aria_label")}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<LockOpenIcon
|
||||
className="text-yellow-500 mb-1.5"
|
||||
size={12}
|
||||
strokeWidth={3}
|
||||
aria-label={t("node_detail_no_public_key_aria_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.hops.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("node_detail_elevation_aria_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("nodeDetail.channelUtilization")}</div>
|
||||
<Mono>
|
||||
{node.deviceMetrics?.channelUtilization.toPrecision(3)}%
|
||||
</Mono>
|
||||
</div>
|
||||
)}
|
||||
{!!node.deviceMetrics?.airUtilTx && (
|
||||
<div className="grow">
|
||||
<div>{t("nodeDetail.airTxUtilization")}</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 text-xs text-gray-500">
|
||||
{node.snr}
|
||||
{t("unit.dbm")}
|
||||
<Dot />
|
||||
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%
|
||||
<Dot />
|
||||
{(node.snr + 10) * 5}
|
||||
{t("unit.raw")}
|
||||
</Mono>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import { Message } from "@core/stores/messageStore/types.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface ChannelChatProps {
|
||||
messages?: Message[];
|
||||
}
|
||||
|
||||
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) => {
|
||||
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 ">
|
||||
{messages?.map((message) => (
|
||||
<MessageItem
|
||||
key={message.messageId ?? `${message.from}-${message.date}`}
|
||||
message={message}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
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)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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,254 @@
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MessageInput, MessageInputProps } from "./MessageInput.tsx";
|
||||
import { Types } from "@meshtastic/core";
|
||||
|
||||
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((
|
||||
{ autoFocus, minLength, name, placeholder, value, onChange },
|
||||
) => (
|
||||
<input
|
||||
autoFocus={autoFocus}
|
||||
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/messageStore", () => ({
|
||||
useMessageStore: 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,88 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import type { Types } from "@meshtastic/core";
|
||||
import { SendIcon } from "lucide-react";
|
||||
import { startTransition, useState } from "react";
|
||||
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||
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 } = useMessageStore();
|
||||
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">
|
||||
<Input
|
||||
autoFocus
|
||||
minLength={1}
|
||||
name="messageInput"
|
||||
placeholder={t("sendMessage.placeholder")}
|
||||
autoComplete="off"
|
||||
value={localDraft}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label
|
||||
data-testid="byte-counter"
|
||||
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,209 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipArrow,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@components/UI/Tooltip.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import {
|
||||
MessageState,
|
||||
useMessageStore,
|
||||
} from "@core/stores/messageStore/index.ts";
|
||||
import { Protobuf, Types } from "@meshtastic/core";
|
||||
import { Message } from "@core/stores/messageStore/types.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
// 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 { getNode } = useDevice();
|
||||
const { getMyNodeNum } = useMessageStore();
|
||||
const { t, i18n } = useTranslation("messages");
|
||||
|
||||
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 myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]);
|
||||
|
||||
const { displayName, shortName, isFavorite } = 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,
|
||||
};
|
||||
}, [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: true,
|
||||
}) ?? "",
|
||||
[messageDate, locale],
|
||||
);
|
||||
|
||||
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"
|
||||
text={shortName}
|
||||
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,138 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { mockDeviceStore } from "@core/stores/deviceStore.mock.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
|
||||
vi.mock("@core/stores/deviceStore");
|
||||
|
||||
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(useDevice).mockReturnValue({
|
||||
...mockDeviceStore,
|
||||
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,88 @@
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
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 } = useDevice();
|
||||
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("traceRoute.nodeUnknownPrefix")}${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,94 @@
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import {
|
||||
type AmbientLightingValidation,
|
||||
AmbientLightingValidationSchema,
|
||||
} from "@app/validation/moduleConfig/ambientLighting.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
interface AmbientLightingModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<AmbientLightingValidation>;
|
||||
}
|
||||
|
||||
export const AmbientLighting = (
|
||||
{ onFormInit }: AmbientLightingModuleConfigProps,
|
||||
) => {
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
getEffectiveModuleConfig,
|
||||
removeWorkingModuleConfig,
|
||||
} = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: AmbientLightingValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.ambientLighting, data, true)) {
|
||||
removeWorkingModuleConfig("ambientLighting");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingModuleConfig(
|
||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "ambientLighting",
|
||||
value: data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<AmbientLightingValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={AmbientLightingValidationSchema}
|
||||
formId="ModuleConfig_AmbientLightingConfig"
|
||||
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,108 @@
|
||||
import {
|
||||
type AudioValidation,
|
||||
AudioValidationSchema,
|
||||
} from "@app/validation/moduleConfig/audio.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
interface AudioModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<AudioValidation>;
|
||||
}
|
||||
|
||||
export const Audio = ({ onFormInit }: AudioModuleConfigProps) => {
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
getEffectiveModuleConfig,
|
||||
removeWorkingModuleConfig,
|
||||
} = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: AudioValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.audio, data, true)) {
|
||||
removeWorkingModuleConfig("audio");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingModuleConfig(
|
||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "audio",
|
||||
value: data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<AudioValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={AudioValidationSchema}
|
||||
formId="ModuleConfig_AudioConfig"
|
||||
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,142 @@
|
||||
import {
|
||||
type CannedMessageValidation,
|
||||
CannedMessageValidationSchema,
|
||||
} from "@app/validation/moduleConfig/cannedMessage.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
interface CannedMessageModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<CannedMessageValidation>;
|
||||
}
|
||||
|
||||
export const CannedMessage = (
|
||||
{ onFormInit }: CannedMessageModuleConfigProps,
|
||||
) => {
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
getEffectiveModuleConfig,
|
||||
removeWorkingModuleConfig,
|
||||
} = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: CannedMessageValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.cannedMessage, data, true)) {
|
||||
removeWorkingModuleConfig("cannedMessage");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingModuleConfig(
|
||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "cannedMessage",
|
||||
value: data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<CannedMessageValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={CannedMessageValidationSchema}
|
||||
formId="ModuleConfig_CannedMessageConfig"
|
||||
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,158 @@
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import {
|
||||
type DetectionSensorValidation,
|
||||
DetectionSensorValidationSchema,
|
||||
} from "@app/validation/moduleConfig/detectionSensor.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
interface DetectionSensorModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<DetectionSensorValidation>;
|
||||
}
|
||||
|
||||
export const DetectionSensor = (
|
||||
{ onFormInit }: DetectionSensorModuleConfigProps,
|
||||
) => {
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
getEffectiveModuleConfig,
|
||||
removeWorkingModuleConfig,
|
||||
} = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: DetectionSensorValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.detectionSensor, data, true)) {
|
||||
removeWorkingModuleConfig("detectionSensor");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingModuleConfig(
|
||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "detectionSensor",
|
||||
value: data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<DetectionSensorValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={DetectionSensorValidationSchema}
|
||||
formId="ModuleConfig_DetectionSensorConfig"
|
||||
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,234 @@
|
||||
import {
|
||||
type ExternalNotificationValidation,
|
||||
ExternalNotificationValidationSchema,
|
||||
} from "@app/validation/moduleConfig/externalNotification.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
interface ExternalNotificationModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<ExternalNotificationValidation>;
|
||||
}
|
||||
|
||||
export const ExternalNotification = (
|
||||
{ onFormInit }: ExternalNotificationModuleConfigProps,
|
||||
) => {
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
getEffectiveModuleConfig,
|
||||
removeWorkingModuleConfig,
|
||||
} = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: ExternalNotificationValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.externalNotification, data, true)) {
|
||||
removeWorkingModuleConfig("externalNotification");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingModuleConfig(
|
||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "externalNotification",
|
||||
value: data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<ExternalNotificationValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={ExternalNotificationValidationSchema}
|
||||
formId="ModuleConfig_ExternalNotificationConfig"
|
||||
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,292 @@
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
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 { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
interface MqttModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<MqttValidation>;
|
||||
}
|
||||
|
||||
export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
|
||||
const {
|
||||
config,
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
getEffectiveModuleConfig,
|
||||
removeWorkingModuleConfig,
|
||||
} = 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)) {
|
||||
removeWorkingModuleConfig("mqtt");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingModuleConfig(
|
||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "mqtt",
|
||||
value: payload,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
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}
|
||||
formId="ModuleConfig_MqttConfig"
|
||||
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,82 @@
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import {
|
||||
type NeighborInfoValidation,
|
||||
NeighborInfoValidationSchema,
|
||||
} from "@app/validation/moduleConfig/neighborInfo.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
interface NeighborInfoModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<NeighborInfoValidation>;
|
||||
}
|
||||
|
||||
export const NeighborInfo = ({ onFormInit }: NeighborInfoModuleConfigProps) => {
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
getEffectiveModuleConfig,
|
||||
removeWorkingModuleConfig,
|
||||
} = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: NeighborInfoValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.neighborInfo, data, true)) {
|
||||
removeWorkingModuleConfig("neighborInfo");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingModuleConfig(
|
||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "neighborInfo",
|
||||
value: data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<NeighborInfoValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={NeighborInfoValidationSchema}
|
||||
formId="ModuleConfig_NeighborInfoConfig"
|
||||
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,104 @@
|
||||
import {
|
||||
type PaxcounterValidation,
|
||||
PaxcounterValidationSchema,
|
||||
} from "@app/validation/moduleConfig/paxcounter.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
interface PaxcounterModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<PaxcounterValidation>;
|
||||
}
|
||||
|
||||
export const Paxcounter = ({ onFormInit }: PaxcounterModuleConfigProps) => {
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
getEffectiveModuleConfig,
|
||||
removeWorkingModuleConfig,
|
||||
} = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: PaxcounterValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.paxcounter, data, true)) {
|
||||
removeWorkingModuleConfig("paxcounter");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingModuleConfig(
|
||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "paxcounter",
|
||||
value: data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<PaxcounterValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={PaxcounterValidationSchema}
|
||||
formId="ModuleConfig_PaxcounterConfig"
|
||||
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,93 @@
|
||||
import {
|
||||
type RangeTestValidation,
|
||||
RangeTestValidationSchema,
|
||||
} from "@app/validation/moduleConfig/rangeTest.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
interface RangeTestModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<RangeTestValidation>;
|
||||
}
|
||||
|
||||
export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => {
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
getEffectiveModuleConfig,
|
||||
removeWorkingModuleConfig,
|
||||
} = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: RangeTestValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.rangeTest, data, true)) {
|
||||
removeWorkingModuleConfig("rangeTest");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingModuleConfig(
|
||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "rangeTest",
|
||||
value: data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<RangeTestValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={RangeTestValidationSchema}
|
||||
formId="ModuleConfig_RangeTestConfig"
|
||||
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,154 @@
|
||||
import {
|
||||
type SerialValidation,
|
||||
SerialValidationSchema,
|
||||
} from "@app/validation/moduleConfig/serial.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
interface SerialModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<SerialValidation>;
|
||||
}
|
||||
|
||||
export const Serial = ({ onFormInit }: SerialModuleConfigProps) => {
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
getEffectiveModuleConfig,
|
||||
removeWorkingModuleConfig,
|
||||
} = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: SerialValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.serial, data, true)) {
|
||||
removeWorkingModuleConfig("serial");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingModuleConfig(
|
||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "serial",
|
||||
value: data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<SerialValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={SerialValidationSchema}
|
||||
formId="ModuleConfig_SerialConfig"
|
||||
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,115 @@
|
||||
import {
|
||||
type StoreForwardValidation,
|
||||
StoreForwardValidationSchema,
|
||||
} from "@app/validation/moduleConfig/storeForward.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
interface StoreForwardModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<StoreForwardValidation>;
|
||||
}
|
||||
|
||||
export const StoreForward = ({ onFormInit }: StoreForwardModuleConfigProps) => {
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
getEffectiveModuleConfig,
|
||||
removeWorkingModuleConfig,
|
||||
} = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: StoreForwardValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.storeForward, data, true)) {
|
||||
removeWorkingModuleConfig("storeForward");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingModuleConfig(
|
||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "storeForward",
|
||||
value: data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<StoreForwardValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={StoreForwardValidationSchema}
|
||||
formId="ModuleConfig_StoreForwardConfig"
|
||||
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,132 @@
|
||||
import {
|
||||
type TelemetryValidation,
|
||||
TelemetryValidationSchema,
|
||||
} from "@app/validation/moduleConfig/telemetry.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
interface TelemetryModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<TelemetryValidation>;
|
||||
}
|
||||
|
||||
export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => {
|
||||
const {
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
getEffectiveModuleConfig,
|
||||
removeWorkingModuleConfig,
|
||||
} = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: TelemetryValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.telemetry, data, true)) {
|
||||
removeWorkingModuleConfig("telemetry");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingModuleConfig(
|
||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "telemetry",
|
||||
value: data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<TelemetryValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={TelemetryValidationSchema}
|
||||
formId="ModuleConfig_TelemetryConfig"
|
||||
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: "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"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user