diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..ebda5dac --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@buf:registry=https://buf.build/gen/npm/v1 \ No newline at end of file diff --git a/README.md b/README.md index a4c85b47..86d7b382 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Install the dependencies. pnpm i ``` -Start the developtment server: +Start the development server: ```bash pnpm dev diff --git a/package.json b/package.json index 7eb28e1e..0d535b7b 100644 --- a/package.json +++ b/package.json @@ -21,77 +21,80 @@ "dependencies": { "@emeraldpay/hashicon-react": "^0.5.2", "@hookform/error-message": "^2.0.1", - "@hookform/resolvers": "^2.9.11", - "@meshtastic/meshtasticjs": "2.0.20-5", - "@preact/signals-react": "^1.2.2", - "@radix-ui/react-accordion": "^1.1.0", - "@radix-ui/react-checkbox": "^1.0.2", - "@radix-ui/react-dialog": "^1.0.2", - "@radix-ui/react-dropdown-menu": "^2.0.3", - "@radix-ui/react-label": "^2.0.0", - "@radix-ui/react-menubar": "^1.0.1", - "@radix-ui/react-popover": "^1.0.4", - "@radix-ui/react-scroll-area": "^1.0.2", - "@radix-ui/react-select": "^1.2.0", - "@radix-ui/react-separator": "^1.0.1", - "@radix-ui/react-switch": "^1.0.1", - "@radix-ui/react-tabs": "^1.0.2", - "@radix-ui/react-toast": "^1.1.2", - "@radix-ui/react-tooltip": "^1.0.4", + "@hookform/resolvers": "^3.1.0", + "@meshtastic/meshtasticjs": "2.1.12-0", + "@preact/signals-react": "^1.3.2", + "@radix-ui/react-accordion": "^1.1.1", + "@radix-ui/react-checkbox": "^1.0.3", + "@radix-ui/react-dialog": "^1.0.3", + "@radix-ui/react-dropdown-menu": "^2.0.4", + "@radix-ui/react-label": "^2.0.1", + "@radix-ui/react-menubar": "^1.0.2", + "@radix-ui/react-popover": "^1.0.5", + "@radix-ui/react-scroll-area": "^1.0.3", + "@radix-ui/react-select": "^1.2.1", + "@radix-ui/react-separator": "^1.0.2", + "@radix-ui/react-switch": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.3", + "@radix-ui/react-toast": "^1.1.3", + "@radix-ui/react-tooltip": "^1.0.5", "@tailwindcss/typography": "^0.5.9", + "@toit/esptool.js": "^0.12.3", "@turf/turf": "^6.5.0", "base64-js": "^1.5.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", - "class-variance-authority": "^0.4.0", + "class-variance-authority": "^0.6.0", "clsx": "^1.2.1", - "cmdk": "^0.1.22", + "cmdk": "^0.2.0", + "esptool-js": "^0.2.1", "geodesy": "^2.4.0", - "immer": "^9.0.19", - "lucide-react": "^0.115.0", + "immer": "^10.0.2", + "jszip": "^3.10.1", + "lucide-react": "^0.220.0", "mapbox-gl": "npm:empty-npm-package@^1.0.0", - "maplibre-gl": "2.4.0", + "maplibre-gl": "3.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-hook-form": "^7.43.2", - "react-map-gl": "^7.0.21", - "react-qrcode-logo": "^2.8.0", + "react-hook-form": "^7.43.9", + "react-map-gl": "^7.0.23", + "react-qrcode-logo": "^2.9.0", "rfc4648": "^1.5.2", - "tailwind-merge": "^1.10.0", + "tailwind-merge": "^1.12.0", "tailwindcss-animate": "^1.0.5", - "timeago-react": "^3.0.5", - "zustand": "4.3.3" + "timeago-react": "^3.0.6", + "zustand": "4.3.8" }, "devDependencies": { "@tailwindcss/forms": "^0.5.3", - "@types/chrome": "^0.0.217", + "@types/chrome": "^0.0.236", "@types/geodesy": "^2.2.3", - "@types/node": "^18.14.1", - "@types/react": "^18.0.28", - "@types/react-dom": "^18.0.11", + "@types/node": "^20.2.3", + "@types/react": "^18.2.6", + "@types/react-dom": "^18.2.4", "@types/w3c-web-serial": "^1.0.3", - "@types/web-bluetooth": "^0.0.16", - "@typescript-eslint/eslint-plugin": "^5.53.0", - "@typescript-eslint/parser": "^5.53.0", - "@vitejs/plugin-react": "^3.1.0", - "autoprefixer": "^10.4.13", - "eslint": "^8.34.0", - "eslint-config-prettier": "^8.6.0", - "eslint-import-resolver-typescript": "^3.5.3", + "@types/web-bluetooth": "^0.0.17", + "@typescript-eslint/eslint-plugin": "^5.59.7", + "@typescript-eslint/parser": "^5.59.7", + "@vitejs/plugin-react": "^4.0.0", + "autoprefixer": "^10.4.14", + "eslint": "^8.41.0", + "eslint-config-prettier": "^8.8.0", + "eslint-import-resolver-typescript": "^3.5.5", "eslint-plugin-import": "^2.27.5", "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", "gzipper": "^7.2.0", - "postcss": "^8.4.21", - "prettier": "^2.8.4", - "prettier-plugin-tailwindcss": "^0.2.3", + "postcss": "^8.4.23", + "prettier": "^2.8.8", + "prettier-plugin-tailwindcss": "^0.3.0", "rollup-plugin-visualizer": "^5.9.0", - "tailwindcss": "^3.2.7", - "tar": "^6.1.13", - "tslib": "^2.5.0", - "typescript": "^4.9.5", - "vite": "^4.1.4", + "tailwindcss": "^3.3.2", + "tar": "^6.1.15", + "tslib": "^2.5.2", + "typescript": "^5.0.4", + "vite": "^4.3.8", "vite-plugin-environment": "^1.1.3", - "vite-plugin-pwa": "^0.14.4" + "vite-plugin-pwa": "^0.15.0" } } diff --git a/src/App.tsx b/src/App.tsx index 6ead6622..2c954604 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,23 +1,81 @@ +import { useState } from "react"; + import { MapProvider } from "react-map-gl"; -import { useAppStore } from "@core/stores/appStore.js"; + import { DeviceWrapper } from "@app/DeviceWrapper.js"; import { PageRouter } from "@app/PageRouter.js"; import { CommandPalette } from "@components/CommandPalette.js"; +import { Dashboard } from "@components/Dashboard.js"; import { DeviceSelector } from "@components/DeviceSelector.js"; import { DialogManager } from "@components/Dialog/DialogManager.js"; -import { Dashboard } from "@components/Dashboard.js"; -import { useDeviceStore } from "@core/stores/deviceStore.js"; -import { ThemeController } from "@components/generic/ThemeController.js"; import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.js"; +import { ThemeController } from "@components/generic/ThemeController.js"; import { Toaster } from "@components/Toaster.js"; +import { useAppStore } from "@core/stores/appStore.js"; +import { useDeviceStore } from "@core/stores/deviceStore.js"; +import { ISerialConnection } from "@meshtastic/meshtasticjs"; + +import { subscribeAll } from "./core/subscriptions"; +import { randId } from "./core/utils/randId"; + +let ensureOnce = false; export const App = (): JSX.Element => { - const { getDevice } = useDeviceStore(); - const { selectedDevice, setConnectDialogOpen, connectDialogOpen } = - useAppStore(); + const { getDevice, getDevices, removeDevice } = useDeviceStore(); + const { addDevice } = useDeviceStore(); + const { + selectedDevice, + setConnectDialogOpen, + connectDialogOpen, + setSelectedDevice + } = useAppStore(); + const [initialized, setInitialized] = useState(false); const device = getDevice(selectedDevice); + const onConnect = async (port: SerialPort) => { + const id = randId(); + const device = addDevice(id); + const connection = new ISerialConnection(id); + console.log("conn"); + await connection + .connect({ + port, + baudRate: undefined, + concurrentLogOutput: true + }) + .catch((e: Error) => console.log(`Unable to Connect: ${e.message}`)); + device.addConnection(connection); + subscribeAll(device, connection); + }; + const connectToAll = async () => { + const dev = await navigator.serial.getPorts(); + + navigator.serial.onconnect = (ev) => { + const port = ev.target as SerialPort; + if (port.readable === null) onConnect(port); + }; + navigator.serial.ondisconnect = (ev) => { + const port = ev.target as SerialPort; + const device = getDevices().filter( + (d) => d.connection?.connType == "serial" + ); + const d = device.find( + (d) => (d.connection! as ISerialConnection).getCurrentPort() == port + ); + if (!d) return; + d.connection!.disconnect(); + removeDevice(d.id ?? 0); + if (selectedDevice == d.id) setSelectedDevice(0); + }; + dev.filter((d) => d.readable === null).forEach((d) => onConnect(d)); + }; + if (!initialized && !ensureOnce) { + connectToAll(); + setInitialized(true); + ensureOnce = true; + } + return ( {
-
+
{device ? (
diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index d92e1f23..37d0984d 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -1,32 +1,31 @@ import { useEffect } from "react"; -import { useAppStore } from "@core/stores/appStore.js"; -import { useDevice, useDeviceStore } from "@core/stores/deviceStore.js"; + import { useCommandState } from "cmdk"; -import { Hashicon } from "@emeraldpay/hashicon-react"; import { - LucideIcon, + ArrowLeftRightIcon, + BoxSelectIcon, + BugIcon, + EraserIcon, + FactoryIcon, + LayersIcon, + LayoutIcon, LinkIcon, - TrashIcon, + LucideIcon, MapIcon, + MessageSquareIcon, MoonIcon, + PaletteIcon, PlusIcon, PowerIcon, - EraserIcon, + QrCodeIcon, RefreshCwIcon, - FactoryIcon, - ArrowLeftRightIcon, - BugIcon, SettingsIcon, SmartphoneIcon, - MessageSquareIcon, - QrCodeIcon, - LayersIcon, - PaletteIcon, + TrashIcon, UsersIcon, - LayoutIcon, - XCircleIcon, - BoxSelectIcon + XCircleIcon } from "lucide-react"; + import { CommandDialog, CommandEmpty, @@ -35,6 +34,9 @@ import { CommandItem, CommandList } from "@components/UI/Command.js"; +import { useAppStore } from "@core/stores/appStore.js"; +import { useDevice, useDeviceStore } from "@core/stores/deviceStore.js"; +import { Hashicon } from "@emeraldpay/hashicon-react"; export interface Group { label: string; diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 82895647..747de98e 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -1,29 +1,33 @@ -import { useAppStore } from "@app/core/stores/appStore.js"; -import { Button } from "@components/UI/Button.js"; -import { - PlusIcon, - ListPlusIcon, - UsersIcon, - MapPinIcon, - CalendarIcon, - BluetoothIcon, - UsbIcon, - NetworkIcon -} from "lucide-react"; -import { Subtle } from "@components/UI/Typography/Subtle.js"; -import { H3 } from "@components/UI/Typography/H3.js"; -import { useDeviceStore } from "@app/core/stores/deviceStore.js"; -import { useMemo } from "react"; +import { useState } from "react"; + +import { ConfigPreset, useAppStore } from "@app/core/stores/appStore.js"; +import { DeviceConfig } from "@app/pages/Config/DeviceConfig"; import { Separator } from "@components/UI/Seperator.js"; +import { H3 } from "@components/UI/Typography/H3.js"; +import { Subtle } from "@components/UI/Typography/Subtle.js"; -export const Dashboard = () => { - const { setConnectDialogOpen } = useAppStore(); - const { getDevices } = useDeviceStore(); +import { ConfigList } from "./PageComponents/Flasher/ConfigList"; +import { DeviceList } from "./PageComponents/Flasher/DeviceList"; +import { ConfigTabs } from "@app/pages/Config/ConfigTabs"; +import { FlashSettings } from "./PageComponents/Flasher/FlashSettings"; +import type { FlashState } from "@app/core/flashing/Flasher"; - const devices = useMemo(() => getDevices(), [getDevices]); +export const Dashboard = () => { + let { configPresetRoot, configPresetSelected, overallFlashingState } = + useAppStore(); + const getTotalConfigCount = (c: ConfigPreset): number => + c.children + .map((child) => getTotalConfigCount(child)) + .reduce((prev, cur) => prev + cur, c.count); + const [totalConfigCount, setTotalConfigCount] = useState( + configPresetRoot.getTotalConfigCount() + ); + const [deviceSelectedToFlash, setDeviceSelectedToFlash] = useState( + new Array(100).fill({ progress: 1, state: "doFlash" }) + ); return ( -
+

Connected Devices

@@ -33,68 +37,30 @@ export const Dashboard = () => { -
- {devices.length ? ( -
    - {devices.map((device) => { - return ( -
  • -
    -
    -

    - {device.nodes.get(device.hardware.myNodeNum)?.user - ?.longName ?? "UNK"} -

    -
    - {device.connection?.connType === "ble" && ( - <> - - BLE - - )} - {device.connection?.connType === "serial" && ( - <> - - Serial - - )} - {device.connection?.connType === "http" && ( - <> - - Network - - )} -
    -
    -
    -
    -
    -
    -
    -
  • - ); - })} -
- ) : ( -
- -

No Devices

- Connect atleast one device to get started - +
+
+
+ + + setTotalConfigCount(totalConfigCount + diff) + } + />
- )} + +
+
+ +
); diff --git a/src/components/DeviceSelector.tsx b/src/components/DeviceSelector.tsx index d93274a0..718262ac 100644 --- a/src/components/DeviceSelector.tsx +++ b/src/components/DeviceSelector.tsx @@ -28,7 +28,7 @@ export const DeviceSelector = (): JSX.Element => { return (
))} {hasSubmitButton && } diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx index 6a5164e9..e2c7f417 100644 --- a/src/components/Form/FormWrapper.tsx +++ b/src/components/Form/FormWrapper.tsx @@ -1,22 +1,37 @@ import { Label } from "../UI/Label.js"; import { ErrorMessage } from "@hookform/error-message"; +import { Switch } from "../UI/Switch.js"; export interface FieldWrapperProps { label: string; description?: string; disabled?: boolean; + enableSwitchEnabled?: boolean; + onEnableSwitchChanged?: (value: boolean) => void; children?: React.ReactNode; } export const FieldWrapper = ({ label, description, + enableSwitchEnabled, + onEnableSwitchChanged, children }: FieldWrapperProps): JSX.Element => (
- +
+ + {enableSwitchEnabled !== undefined && ( +
+ +
+ )} +

{description}

diff --git a/src/components/PageComponents/Config/Bluetooth.tsx b/src/components/PageComponents/Config/Bluetooth.tsx index c2b4cacc..d1c911cd 100644 --- a/src/components/PageComponents/Config/Bluetooth.tsx +++ b/src/components/PageComponents/Config/Bluetooth.tsx @@ -1,26 +1,48 @@ +import type { ConfigPreset } from "@app/core/stores/appStore"; import type { BluetoothValidation } from "@app/validation/config/bluetooth.js"; -import { useDevice } from "@core/stores/deviceStore.js"; +import { DynamicForm, EnableSwitchData } from "@components/Form/DynamicForm.js"; +import { useConfig, useDevice } from "@core/stores/deviceStore.js"; import { Protobuf } from "@meshtastic/meshtasticjs"; -import { DynamicForm } from "@components/Form/DynamicForm.js"; export const Bluetooth = (): JSX.Element => { - const { config, setWorkingConfig } = useDevice(); - - const onSubmit = (data: BluetoothValidation) => { - setWorkingConfig( - new Protobuf.Config({ - payloadVariant: { - case: "bluetooth", - value: data + const config = useConfig(); + const enableSwitch: EnableSwitchData | undefined = config.overrideValues + ? { + getEnabled(name) { + return config.overrideValues![name] ?? false; + }, + setEnabled(name, value) { + config.overrideValues![name] = value; } - }) - ); - }; + } + : undefined; + const isPresetConfig = !("id" in config); + const { setWorkingConfig } = !isPresetConfig + ? useDevice() + : { setWorkingConfig: undefined }; + const setConfig: (data: BluetoothValidation) => void = isPresetConfig + ? (data) => { + config.config.bluetooth = new Protobuf.Config_BluetoothConfig(data); + (config as ConfigPreset).saveConfigTree(); + } + : (data) => { + setWorkingConfig!( + new Protobuf.Config({ + payloadVariant: { + case: "bluetooth", + value: data + } + }) + ); + }; + + const onSubmit = setConfig; return ( onSubmit={onSubmit} - defaultValues={config.bluetooth} + defaultValues={config.config.bluetooth} + enableSwitch={enableSwitch} fieldGroups={[ { label: "Bluetooth Settings", @@ -37,11 +59,6 @@ export const Bluetooth = (): JSX.Element => { name: "mode", label: "Pairing mode", description: "Pin selection behaviour.", - disabledBy: [ - { - fieldName: "enabled" - } - ], properties: { enumValue: Protobuf.Config_BluetoothConfig_PairingMode, formatEnumName: true @@ -52,17 +69,6 @@ export const Bluetooth = (): JSX.Element => { name: "fixedPin", label: "Pin", description: "Pin to use when pairing", - disabledBy: [ - { - fieldName: "mode", - selector: - Protobuf.Config_BluetoothConfig_PairingMode.FIXED_PIN, - invert: true - }, - { - fieldName: "enabled" - } - ], properties: {} } ] diff --git a/src/components/PageComponents/Config/Device.tsx b/src/components/PageComponents/Config/Device.tsx index 7396b4a7..abc539c3 100644 --- a/src/components/PageComponents/Config/Device.tsx +++ b/src/components/PageComponents/Config/Device.tsx @@ -1,26 +1,48 @@ +import type { ConfigPreset } from "@app/core/stores/appStore"; import type { DeviceValidation } from "@app/validation/config/device.js"; -import { useDevice } from "@core/stores/deviceStore.js"; +import { DynamicForm, EnableSwitchData } from "@components/Form/DynamicForm.js"; +import { useConfig, useDevice } from "@core/stores/deviceStore.js"; import { Protobuf } from "@meshtastic/meshtasticjs"; -import { DynamicForm } from "@components/Form/DynamicForm.js"; export const Device = (): JSX.Element => { - const { config, setWorkingConfig } = useDevice(); - - const onSubmit = (data: DeviceValidation) => { - setWorkingConfig( - new Protobuf.Config({ - payloadVariant: { - case: "device", - value: data + const config = useConfig(); + const enableSwitch: EnableSwitchData | undefined = config.overrideValues + ? { + getEnabled(name) { + return config.overrideValues![name] ?? false; + }, + setEnabled(name, value) { + config.overrideValues![name] = value; } - }) - ); - }; + } + : undefined; + const isPresetConfig = !("id" in config); + const { setWorkingConfig } = !isPresetConfig + ? useDevice() + : { setWorkingConfig: undefined }; + const setConfig: (data: DeviceValidation) => void = isPresetConfig + ? (data) => { + config.config.device = new Protobuf.Config_DeviceConfig(data); + (config as ConfigPreset).saveConfigTree(); + } + : (data) => { + setWorkingConfig!( + new Protobuf.Config({ + payloadVariant: { + case: "device", + value: data + } + }) + ); + }; + + const onSubmit = setConfig; return ( onSubmit={onSubmit} - defaultValues={config.device} + defaultValues={config.config.device} + enableSwitch={enableSwitch} fieldGroups={[ { label: "Device Settings", @@ -79,6 +101,19 @@ export const Device = (): JSX.Element => { properties: { suffix: "Seconds" } + }, + { + type: "toggle", + name: "doubleTapAsButtonPress", + label: "Double Tap as Button Press", + description: + "Require a double tap of the button to send a button press" + }, + { + type: "toggle", + name: "isManaged", + label: "Managed", + description: "Is this device managed by an external application" } ] } diff --git a/src/components/PageComponents/Config/Display.tsx b/src/components/PageComponents/Config/Display.tsx index 44b9bd38..7f1db799 100644 --- a/src/components/PageComponents/Config/Display.tsx +++ b/src/components/PageComponents/Config/Display.tsx @@ -1,26 +1,48 @@ +import type { ConfigPreset } from "@app/core/stores/appStore"; import type { DisplayValidation } from "@app/validation/config/display.js"; -import { useDevice } from "@core/stores/deviceStore.js"; +import { DynamicForm, EnableSwitchData } from "@components/Form/DynamicForm.js"; +import { useConfig, useDevice } from "@core/stores/deviceStore.js"; import { Protobuf } from "@meshtastic/meshtasticjs"; -import { DynamicForm } from "@components/Form/DynamicForm.js"; export const Display = (): JSX.Element => { - const { config, setWorkingConfig } = useDevice(); - - const onSubmit = (data: DisplayValidation) => { - setWorkingConfig( - new Protobuf.Config({ - payloadVariant: { - case: "display", - value: data + const config = useConfig(); + const enableSwitch: EnableSwitchData | undefined = config.overrideValues + ? { + getEnabled(name) { + return config.overrideValues![name] ?? false; + }, + setEnabled(name, value) { + config.overrideValues![name] = value; } - }) - ); - }; + } + : undefined; + const isPresetConfig = !("id" in config); + const { setWorkingConfig } = !isPresetConfig + ? useDevice() + : { setWorkingConfig: undefined }; + const setConfig: (data: DisplayValidation) => void = isPresetConfig + ? (data) => { + config.config.display = new Protobuf.Config_DisplayConfig(data); + (config as ConfigPreset).saveConfigTree(); + } + : (data) => { + setWorkingConfig!( + new Protobuf.Config({ + payloadVariant: { + case: "display", + value: data + } + }) + ); + }; + + const onSubmit = setConfig; return ( onSubmit={onSubmit} - defaultValues={config.display} + defaultValues={config.config.display} + enableSwitch={enableSwitch} fieldGroups={[ { label: "Display Settings", @@ -96,6 +118,12 @@ export const Display = (): JSX.Element => { name: "headingBold", label: "Bold Heading", description: "Bolden the heading text" + }, + { + type: "toggle", + name: "wakeOnTapOrMotion", + label: "Wake on Tap or Motion", + description: "Wake the device on tap or motion" } ] } diff --git a/src/components/PageComponents/Config/LoRa.tsx b/src/components/PageComponents/Config/LoRa.tsx index 9ddc2197..5b6cd8ac 100644 --- a/src/components/PageComponents/Config/LoRa.tsx +++ b/src/components/PageComponents/Config/LoRa.tsx @@ -1,26 +1,48 @@ +import type { ConfigPreset } from "@app/core/stores/appStore"; import type { LoRaValidation } from "@app/validation/config/lora.js"; -import { useDevice } from "@core/stores/deviceStore.js"; +import { DynamicForm, EnableSwitchData } from "@components/Form/DynamicForm.js"; +import { useConfig, useDevice } from "@core/stores/deviceStore.js"; import { Protobuf } from "@meshtastic/meshtasticjs"; -import { DynamicForm } from "@components/Form/DynamicForm.js"; export const LoRa = (): JSX.Element => { - const { config, setWorkingConfig } = useDevice(); - - const onSubmit = (data: LoRaValidation) => { - setWorkingConfig( - new Protobuf.Config({ - payloadVariant: { - case: "lora", - value: data + const config = useConfig(); + const enableSwitch: EnableSwitchData | undefined = config.overrideValues + ? { + getEnabled(name) { + return config.overrideValues![name] ?? false; + }, + setEnabled(name, value) { + config.overrideValues![name] = value; } - }) - ); - }; + } + : undefined; + const isPresetConfig = !("id" in config); + const { setWorkingConfig } = !isPresetConfig + ? useDevice() + : { setWorkingConfig: undefined }; + const setConfig: (data: LoRaValidation) => void = isPresetConfig + ? (data) => { + config.config.lora = new Protobuf.Config_LoRaConfig(data); + (config as ConfigPreset).saveConfigTree(); + } + : (data) => { + setWorkingConfig!( + new Protobuf.Config({ + payloadVariant: { + case: "lora", + value: data + } + }) + ); + }; + + const onSubmit = setConfig; return ( onSubmit={onSubmit} - defaultValues={config.lora} + defaultValues={config.config.lora} + enableSwitch={enableSwitch} fieldGroups={[ { label: "Mesh Settings", @@ -64,11 +86,6 @@ export const LoRa = (): JSX.Element => { name: "modemPreset", label: "Modem Preset", description: "Modem preset to use", - disabledBy: [ - { - fieldName: "usePreset" - } - ], properties: { enumValue: Protobuf.Config_LoRaConfig_ModemPreset, formatEnumName: true @@ -79,12 +96,6 @@ export const LoRa = (): JSX.Element => { name: "bandwidth", label: "Bandwidth", description: "Channel bandwidth in MHz", - disabledBy: [ - { - fieldName: "usePreset", - invert: true - } - ], properties: { suffix: "MHz" } @@ -95,12 +106,6 @@ export const LoRa = (): JSX.Element => { label: "Spreading Factor", description: "Indicates the number of chirps per symbol", - disabledBy: [ - { - fieldName: "usePreset", - invert: true - } - ], properties: { suffix: "CPS" } @@ -109,13 +114,7 @@ export const LoRa = (): JSX.Element => { type: "number", name: "codingRate", label: "Coding Rate", - description: "The denominator of the coding rate", - disabledBy: [ - { - fieldName: "usePreset", - invert: true - } - ] + description: "The denominator of the coding rate" } ] }, diff --git a/src/components/PageComponents/Config/Network.tsx b/src/components/PageComponents/Config/Network.tsx index 28b95674..6be28759 100644 --- a/src/components/PageComponents/Config/Network.tsx +++ b/src/components/PageComponents/Config/Network.tsx @@ -1,31 +1,48 @@ +import type { ConfigPreset } from "@app/core/stores/appStore"; import type { NetworkValidation } from "@app/validation/config/network.js"; -import { useDevice } from "@core/stores/deviceStore.js"; +import { DynamicForm, EnableSwitchData } from "@components/Form/DynamicForm.js"; +import { useConfig, useDevice } from "@core/stores/deviceStore.js"; import { Protobuf } from "@meshtastic/meshtasticjs"; -import { DynamicForm } from "@components/Form/DynamicForm.js"; export const Network = (): JSX.Element => { - const { config, setWorkingConfig } = useDevice(); - - const onSubmit = (data: NetworkValidation) => { - setWorkingConfig( - new Protobuf.Config({ - payloadVariant: { - case: "network", - value: { - ...data, - ipv4Config: new Protobuf.Config_NetworkConfig_IpV4Config( - data.ipv4Config - ) - } + const config = useConfig(); + const enableSwitch: EnableSwitchData | undefined = config.overrideValues + ? { + getEnabled(name) { + return config.overrideValues![name] ?? false; + }, + setEnabled(name, value) { + config.overrideValues![name] = value; } - }) - ); - }; + } + : undefined; + const isPresetConfig = !("id" in config); + const { setWorkingConfig } = !isPresetConfig + ? useDevice() + : { setWorkingConfig: undefined }; + const setConfig: (data: NetworkValidation) => void = isPresetConfig + ? (data) => { + config.config.network = new Protobuf.Config_NetworkConfig(data); + (config as ConfigPreset).saveConfigTree(); + } + : (data) => { + setWorkingConfig!( + new Protobuf.Config({ + payloadVariant: { + case: "network", + value: data + } + }) + ); + }; + + const onSubmit = setConfig; return ( onSubmit={onSubmit} - defaultValues={config.network} + defaultValues={config.config.network} + enableSwitch={enableSwitch} fieldGroups={[ { label: "WiFi Config", @@ -41,23 +58,13 @@ export const Network = (): JSX.Element => { type: "text", name: "wifiSsid", label: "SSID", - description: "Network name", - disabledBy: [ - { - fieldName: "wifiEnabled" - } - ] + description: "Network name" }, { type: "password", name: "wifiPsk", label: "PSK", - description: "Network password", - disabledBy: [ - { - fieldName: "wifiEnabled" - } - ] + description: "Network password" } ] }, @@ -90,49 +97,25 @@ export const Network = (): JSX.Element => { type: "text", name: "ipv4Config.ip", label: "IP", - description: "IP Address", - disabledBy: [ - { - fieldName: "addressMode", - selector: Protobuf.Config_NetworkConfig_AddressMode.DHCP - } - ] + description: "IP Address" }, { type: "text", name: "ipv4Config.gateway", label: "Gateway", - description: "Default Gateway", - disabledBy: [ - { - fieldName: "addressMode", - selector: Protobuf.Config_NetworkConfig_AddressMode.DHCP - } - ] + description: "Default Gateway" }, { type: "text", name: "ipv4Config.subnet", label: "Subnet", - description: "Subnet Mask", - disabledBy: [ - { - fieldName: "addressMode", - selector: Protobuf.Config_NetworkConfig_AddressMode.DHCP - } - ] + description: "Subnet Mask" }, { type: "text", name: "ipv4Config.dns", label: "DNS", - description: "DNS Server", - disabledBy: [ - { - fieldName: "addressMode", - selector: Protobuf.Config_NetworkConfig_AddressMode.DHCP - } - ] + description: "DNS Server" } ] }, diff --git a/src/components/PageComponents/Config/Position.tsx b/src/components/PageComponents/Config/Position.tsx index bdb65d32..999b9228 100644 --- a/src/components/PageComponents/Config/Position.tsx +++ b/src/components/PageComponents/Config/Position.tsx @@ -1,26 +1,48 @@ +import type { ConfigPreset } from "@app/core/stores/appStore"; import type { PositionValidation } from "@app/validation/config/position.js"; -import { useDevice } from "@core/stores/deviceStore.js"; +import { DynamicForm, EnableSwitchData } from "@components/Form/DynamicForm.js"; +import { useConfig, useDevice } from "@core/stores/deviceStore.js"; import { Protobuf } from "@meshtastic/meshtasticjs"; -import { DynamicForm } from "@components/Form/DynamicForm.js"; export const Position = (): JSX.Element => { - const { config, nodes, hardware, setWorkingConfig } = useDevice(); - - const onSubmit = (data: PositionValidation) => { - setWorkingConfig( - new Protobuf.Config({ - payloadVariant: { - case: "position", - value: data + const config = useConfig(); + const enableSwitch: EnableSwitchData | undefined = config.overrideValues + ? { + getEnabled(name) { + return config.overrideValues![name] ?? false; + }, + setEnabled(name, value) { + config.overrideValues![name] = value; } - }) - ); - }; + } + : undefined; + const isPresetConfig = !("id" in config); + const { setWorkingConfig } = !isPresetConfig + ? useDevice() + : { setWorkingConfig: undefined }; + const setConfig: (data: PositionValidation) => void = isPresetConfig + ? (data) => { + config.config.position = new Protobuf.Config_PositionConfig(data); + (config as ConfigPreset).saveConfigTree(); + } + : (data) => { + setWorkingConfig!( + new Protobuf.Config({ + payloadVariant: { + case: "position", + value: data + } + }) + ); + }; + + const onSubmit = setConfig; return ( onSubmit={onSubmit} - defaultValues={config.position} + defaultValues={config.config.position} + enableSwitch={enableSwitch} fieldGroups={[ { label: "Position settings", @@ -90,6 +112,20 @@ export const Position = (): JSX.Element => { name: "gpsAttemptTime", label: "Fix Attempt Duration", description: "How long the device will try to get a fix for" + }, + { + type: "number", + name: "broadcastSmartMinimumDistance", + label: "Minimum Distance for Smart Position", + description: + "Minimum distance to move before sending a position update when Smart Position is enabled" + }, + { + type: "number", + name: "broadcastSmartMinimumIntervalSecs", + label: "Minimum Interval for Smart Position", + description: + "Minimum time to wait before sending a position update when Smart Position is enabled" } ] } diff --git a/src/components/PageComponents/Config/Power.tsx b/src/components/PageComponents/Config/Power.tsx index a18f75fd..a26015c2 100644 --- a/src/components/PageComponents/Config/Power.tsx +++ b/src/components/PageComponents/Config/Power.tsx @@ -1,26 +1,48 @@ +import type { ConfigPreset } from "@app/core/stores/appStore"; import type { PowerValidation } from "@app/validation/config/power.js"; -import { useDevice } from "@core/stores/deviceStore.js"; +import { DynamicForm, EnableSwitchData } from "@components/Form/DynamicForm.js"; +import { useConfig, useDevice } from "@core/stores/deviceStore.js"; import { Protobuf } from "@meshtastic/meshtasticjs"; -import { DynamicForm } from "@components/Form/DynamicForm.js"; export const Power = (): JSX.Element => { - const { config, setWorkingConfig } = useDevice(); - - const onSubmit = (data: PowerValidation) => { - setWorkingConfig( - new Protobuf.Config({ - payloadVariant: { - case: "power", - value: data + const config = useConfig(); + const enableSwitch: EnableSwitchData | undefined = config.overrideValues + ? { + getEnabled(name) { + return config.overrideValues![name] ?? false; + }, + setEnabled(name, value) { + config.overrideValues![name] = value; } - }) - ); - }; + } + : undefined; + const isPresetConfig = !("id" in config); + const { setWorkingConfig } = !isPresetConfig + ? useDevice() + : { setWorkingConfig: undefined }; + const setConfig: (data: PowerValidation) => void = isPresetConfig + ? (data) => { + config.config.power = new Protobuf.Config_PowerConfig(data); + (config as ConfigPreset).saveConfigTree(); + } + : (data) => { + setWorkingConfig!( + new Protobuf.Config({ + payloadVariant: { + case: "power", + value: data + } + }) + ); + }; + + const onSubmit = setConfig; return ( onSubmit={onSubmit} - defaultValues={config.power} + defaultValues={config.config.power} + enableSwitch={enableSwitch} fieldGroups={[ { label: "Power Config", diff --git a/src/components/PageComponents/Connect/Serial.tsx b/src/components/PageComponents/Connect/Serial.tsx index bcfc8d7a..c1ce861c 100644 --- a/src/components/PageComponents/Connect/Serial.tsx +++ b/src/components/PageComponents/Connect/Serial.tsx @@ -29,7 +29,6 @@ export const Serial = (): JSX.Element => { const onConnect = async (port: SerialPort) => { const id = randId(); const device = addDevice(id); - setSelectedDevice(id); const connection = new ISerialConnection(id); await connection .connect({ diff --git a/src/components/PageComponents/Flasher/ConfigList.tsx b/src/components/PageComponents/Flasher/ConfigList.tsx new file mode 100644 index 00000000..ff38b606 --- /dev/null +++ b/src/components/PageComponents/Flasher/ConfigList.tsx @@ -0,0 +1,226 @@ +import { ConfigSelectButton } from "@app/components/UI/ConfigSelectButton"; +import { useToast } from "@app/core/hooks/useToast"; +import { ConfigPreset, useAppStore } from "@app/core/stores/appStore"; +import { + PlusIcon, + Edit3Icon, + Trash2Icon, + UploadIcon, + DownloadIcon +} from "lucide-react"; +import { useState } from "react"; + +export const ConfigList = ({ + rootConfig, + setTotalConfigCountDiff +}: { + rootConfig: ConfigPreset; + setTotalConfigCountDiff: (val: number) => void; +}) => { + const { + configPresetRoot, + setConfigPresetRoot, + configPresetSelected, + setConfigPresetSelected, + overallFlashingState + } = useAppStore(); + const [editSelected, setEditSelected] = useState(false); + const { toast } = useToast(); + if (configPresetSelected === undefined) { + setConfigPresetSelected(configPresetRoot); + return
; + } + const disabled = overallFlashingState.state == "busy"; + + return ( +
+
+
+ + + +
+
+ + +
+
+ +
+ {rootConfig && ( + setTotalConfigCountDiff(diff)} + onEditDone={(val) => { + configPresetSelected.name = val; + setEditSelected(false); + configPresetSelected.saveConfigTree(); + }} + disabled={disabled} + /> + )} +
+
+ ); +}; + +const ConfigEntry = ({ + config, + configPresetSelected, + setConfigPresetSelected, + editSelected, + onEditDone, + onConfigCountChanged, + disabled +}: { + config: ConfigPreset; + configPresetSelected: ConfigPreset; + setConfigPresetSelected: (selection: ConfigPreset) => void; + editSelected: boolean; + onEditDone: (value: string) => void; + onConfigCountChanged: (val: number, diff: number) => void; + disabled: boolean; +}) => { + const [configCount, setConfigCount] = useState(config.count); + return ( +
+ { + const diff = value - config.count; + config.count = value; + setConfigCount(value); + onConfigCountChanged(value, diff); + }} + value={configCount} + editing={editSelected && config == configPresetSelected} + onClick={() => setConfigPresetSelected(config)} + onChangeDone={onEditDone} + disabled={disabled} + /> +
+ {config.children.map((c) => ( + + ))} +
+
+ ); +}; diff --git a/src/components/PageComponents/Flasher/DeviceList.tsx b/src/components/PageComponents/Flasher/DeviceList.tsx new file mode 100644 index 00000000..219e2bce --- /dev/null +++ b/src/components/PageComponents/Flasher/DeviceList.tsx @@ -0,0 +1,207 @@ +import { Button } from "@app/components/UI/Button"; +import { H3 } from "@app/components/UI/Typography/H3"; +import { Subtle } from "@app/components/UI/Typography/Subtle"; +import type { FlashState } from "@app/core/flashing/Flasher"; +import { type ConfigPreset, useAppStore } from "@app/core/stores/appStore"; +import { useDeviceStore, type Device } from "@app/core/stores/deviceStore"; +import { + PlusIcon, + ListPlusIcon, + BluetoothIcon, + UsbIcon, + NetworkIcon, + UsersIcon +} from "lucide-react"; +import { useState } from "react"; +import { FlashSettings } from "./FlashSettings"; + +export const DeviceList = ({ + rootConfig, + deviceSelectedToFlash, + setDeviceSelectedToFlash +}: { + rootConfig: ConfigPreset; + deviceSelectedToFlash: FlashState[]; + setDeviceSelectedToFlash: React.Dispatch>; +}) => { + const { setConnectDialogOpen, overallFlashingState } = useAppStore(); + const { getDevices } = useDeviceStore(); + const devices = getDevices(); + const allConfigs = rootConfig.getAll(); + const configQueue: string[] = []; + for (const c in allConfigs) { + const config = allConfigs[c]; + for (let i = 0; i < config.count; i++) { + configQueue.push(config.name); + } + } + const configMap = new Map(); + devices + .filter((d) => d.flashState.state == "doFlash") + .forEach((d) => configMap.set(d, configQueue.shift())); + + return ( +
+ {devices.length ? ( +
+ Select all devices to flash: +
    + {devices.map((device, index) => { + const state = deviceSelectedToFlash[index]; + return ( + { + if (overallFlashingState.state == "busy") return; + const newState: FlashState = + state.state == "doFlash" + ? { progress: 0, state: "doNotFlash" } + : { progress: 1, state: "doFlash" }; + deviceSelectedToFlash[index] = newState; + device.setFlashState(newState); + setDeviceSelectedToFlash(deviceSelectedToFlash); + }} + progressText={state} + /> + ); + })} + { +
    + +
    + } +
+
+ ) : ( +
+ +

No Devices

+ Connect atleast one device to get started + +
+ )} +
+ ); +}; + +const DeviceSetupEntry = ({ + device, + configName, + toggleSelectedToFlash, + progressText +}: { + device: Device; + configName?: string; + toggleSelectedToFlash: () => void; + progressText: FlashState; +}) => { + const selectedToFlash = progressText.state == "doFlash"; + const buttonCaption = selectedToFlash + ? configName ?? "Unassigned" + : deviceStateToText(progressText); + const buttonStyle = deviceStateToStyle( + progressText, + configName !== undefined + ); + + return ( +
  • +
    +
    +
    +

    + {device.nodes.get(device.hardware.myNodeNum)?.user?.longName ?? + ""} +

    +
    +
    + +
    +
    +
    +
  • + ); +}; + +function deviceStateToText(state: FlashState) { + switch (state.state) { + case "doNotFlash": + return "Skip"; + case "doFlash": + return "Selected"; + case "preparing": + return "Preparing..."; + case "erasing": + return "Erasing..."; + case "flashing": + return `Flashing... (${(state.progress * 100).toFixed(1)}%)`; + case "config": + return "Configuring..."; + case "done": + return "Completed"; + case "aborted": + return "Cancelled"; + case "failed": + return "Failed"; + default: + return state.state; + } +} + +function deviceStateToStyle( + state: FlashState, + configAssigned: boolean +): React.CSSProperties { + switch (state.state) { + case "failed": + return { + color: "red", + borderColor: "red" + }; + case "done": + return { + color: "green", + borderColor: "green" + }; + case "doNotFlash": + return { + color: "gray", + borderColor: "gray" + }; + case "doFlash": + if (!configAssigned) { + return { + color: "var(--textPrimary)", + borderColor: "gray", + background: `dimgray` + }; + } + default: + return { + color: "var(--textPrimary)", + borderColor: "var(--accentMuted)", + background: `linear-gradient(90deg, var(--accentMuted) ${ + state.progress * 100 + }%, transparent ${state.progress * 100}%)` + }; + } +} diff --git a/src/components/PageComponents/Flasher/FlashSettings.tsx b/src/components/PageComponents/Flasher/FlashSettings.tsx new file mode 100644 index 00000000..f122437d --- /dev/null +++ b/src/components/PageComponents/Flasher/FlashSettings.tsx @@ -0,0 +1,412 @@ +import { Button } from "@app/components/UI/Button"; +import { + type FlashState, + setup, + type OverallFlashingState, + nextBatch, + cancel, + uploadCustomFirmware +} from "@app/core/flashing/Flasher"; +import { useToast } from "@app/core/hooks/useToast"; +import { type ConfigPreset, useAppStore } from "@app/core/stores/appStore"; +import { Device, useDeviceStore } from "@app/core/stores/deviceStore"; +import { Label } from "../../UI/Label"; +import { Switch } from "../../UI/Switch"; +import { ArrowDownCircleIcon, RefreshCwIcon, XIcon } from "lucide-react"; +import { useState } from "react"; +import { + SelectItem, + SelectSeparator, + Select, + SelectTrigger, + SelectValue, + SelectContent +} from "../../UI/Select"; +import { isStoredInDb } from "@app/core/flashing/FirmwareDb"; + +export const FlashSettings = ({ + deviceSelectedToFlash, + setDeviceSelectedToFlash, + totalConfigCount +}: { + deviceSelectedToFlash: FlashState[]; + setDeviceSelectedToFlash: React.Dispatch>; + totalConfigCount: number; +}) => { + const [fullFlash, setFullFlash] = useState(false); + const { + overallFlashingState, + setOverallFlashingState, + selectedFirmware, + selectedDeviceModel, + firmwareList, + setFirmwareList, + configPresetRoot + } = useAppStore(); + const firmware = firmwareList.find((f) => f.id == selectedFirmware); + const { toast } = useToast(); + const { getDevices } = useDeviceStore(); + const devices = getDevices(); + const cancelButtonVisible = overallFlashingState.state != "idle"; + + return ( +
    +
    + +
    + + +
    +
    +
    + +
    + {deviceSelectedToFlash.filter((d) => d).length > 0 && ( + + )} +
    + {cancelButtonVisible && ( + + )} +
    +
    + ); +}; + +const FirmwareSelection = () => { + const { + firmwareRefreshing, + setFirmwareRefreshing, + firmwareList, + setFirmwareList, + selectedFirmware, + setSelectedFirmware, + overallFlashingState + } = useAppStore(); + const isBusy = firmwareRefreshing || overallFlashingState.state == "busy"; + const { toast } = useToast(); + + let selectItems = [ + + {"Latest stable version"} + , + + ]; + let selection = selectedFirmware; + if (firmwareRefreshing) { + selectItems = [ + + {"Updating firmware list..."} + + ]; + selection = "updating"; + } else if (firmwareList.length == 0) { + selectItems.push( + + {"(Press update button to get version list)"} + + ); + } else { + const versions = firmwareList.map((f, index) => ( + + {f.isPreRelease ? ( +
    + {`(${f.name})`}{" "} + {f.inLocalDb ? [] : []} +
    + ) : ( +
    + {f.name} {f.inLocalDb ? [] : []} +
    + )} +
    + )); + selectItems.push(...versions); + } + selectItems.push( + + {"< Load custom firmware >"} + + ); + + return ( +
    + + +
    + ); +}; + +export type FirmwareVersion = { + name: string; + tag: string; + id: string; + inLocalDb: boolean; + isPreRelease: boolean; +}; + +interface FirmwareGithubRelease { + name: string; + tag_name: string; + prerelease: boolean; + assets: { + name: string; + id: string; + }[]; +} + +type DeviceModel = { + displayName: string; + name: string; + vendorId: number; + productId: number; +}; + +// This is still missing some vendor and product IDs that could not be determined +export const deviceModels: DeviceModel[] = [ + { + displayName: "Heltec v1", + name: "heltec-v1", + vendorId: -1, + productId: -1 + }, + { + displayName: "Heltec v2.0", + name: "heltec-v2.0", + vendorId: -1, + productId: -1 + }, + { + displayName: "Heltec v2.1", + name: "heltec-v2.1", + vendorId: -1, + productId: -1 + }, + { + displayName: "T-Beam v0.7", + name: "tbeam0.7", + vendorId: -1, + productId: -1 + }, + { + displayName: "T-Beam", + name: "tbeam", + vendorId: -1, + productId: -1 + }, + { + displayName: "T-Lora v1", + name: "tlora-v1", + vendorId: -1, + productId: -1 + }, + { + displayName: "T-Lora v1.3", + name: "tlora-v1_3", + vendorId: -1, + productId: -1 + }, + { + displayName: "T-Lora v2", + name: "tlora-v2", + vendorId: -1, + productId: -1 + }, + { + displayName: "T-Lora v2.1-1.6", + name: "tlora-v2-1-1.6", + vendorId: 6790, + productId: 21972 + } +]; + +const DeviceModelSelection = () => { + const { selectedDeviceModel, setSelectedDeviceModel, overallFlashingState } = + useAppStore(); + + let selectItems = [ + + {"Auto-detect device model"} + , + + ]; + selectItems.push( + ...deviceModels.map((d) => ( + + {d.displayName} + + )) + ); + + return ( +
    + +
    + ); +}; + +function stateToText(state: OverallFlashingState, progress?: number) { + switch (state) { + case "idle": + return "Flash"; + case "downloading": + return progress + ? `Downloading firmware... (${(progress * 100).toFixed(1)} %)` + : "Downloading firmware..."; + case "busy": + return "In Progress..."; + case "waiting": + return "Continue"; + default: + state; + } +} + +async function loadFirmwareList(): Promise { + const releases: FirmwareGithubRelease[] = await ( + await fetch("https://github.com/repos/meshtastic/firmware/releases") + ).json(); + console.log(releases); + const firmwareDescriptions = await Promise.all( + releases.map(async (r) => { + const id = r.assets.find((a) => a.name.startsWith("firmware"))!.id; + if (id === undefined) return undefined; + const tag = r.tag_name.substring(1); // remove leading "v" + return { + name: r.name.replace("Meshtastic Firmware ", ""), + tag: tag, + id: id, + isPreRelease: r.prerelease, + inLocalDb: await isStoredInDb(tag) + }; + }) + ); + return firmwareDescriptions.filter( + (r) => r !== undefined + ) as FirmwareVersion[]; +} diff --git a/src/components/PageComponents/ModuleConfig/Audio.tsx b/src/components/PageComponents/ModuleConfig/Audio.tsx index 1995d77a..550c98ad 100644 --- a/src/components/PageComponents/ModuleConfig/Audio.tsx +++ b/src/components/PageComponents/ModuleConfig/Audio.tsx @@ -1,26 +1,45 @@ import type { AudioValidation } from "@app/validation/moduleConfig/audio.js"; -import { useDevice } from "@core/stores/deviceStore.js"; +import { useConfig, useDevice } from "@core/stores/deviceStore.js"; import { Protobuf } from "@meshtastic/meshtasticjs"; -import { DynamicForm } from "@components/Form/DynamicForm.js"; +import { DynamicForm, EnableSwitchData } from "@components/Form/DynamicForm.js"; +import type { ConfigPreset } from "@app/core/stores/appStore"; export const Audio = (): JSX.Element => { - const { moduleConfig, setWorkingModuleConfig } = useDevice(); - - const onSubmit = (data: AudioValidation) => { - setWorkingModuleConfig( - new Protobuf.ModuleConfig({ - payloadVariant: { - case: "audio", - value: data + const config = useConfig(); + const enableSwitch: EnableSwitchData | undefined = config.overrideValues + ? { + getEnabled(name) { + return config.overrideValues![name] ?? false; + }, + setEnabled(name, value) { + config.overrideValues![name] = value; } - }) - ); - }; + } + : undefined; + const isPresetConfig = !("id" in config); + const setConfig: (data: AudioValidation) => void = isPresetConfig + ? (data) => { + config.moduleConfig.audio = new Protobuf.ModuleConfig_AudioConfig(data); + (config as ConfigPreset).saveConfigTree(); + } + : (data) => { + useDevice().setWorkingModuleConfig( + new Protobuf.ModuleConfig({ + payloadVariant: { + case: "audio", + value: data + } + }) + ); + }; + + const onSubmit = setConfig; return ( onSubmit={onSubmit} - defaultValues={moduleConfig.audio} + defaultValues={config.moduleConfig.audio} + enableSwitch={enableSwitch} fieldGroups={[ { label: "Audio Settings", diff --git a/src/components/PageComponents/ModuleConfig/CannedMessage.tsx b/src/components/PageComponents/ModuleConfig/CannedMessage.tsx index 15788a5d..3a9eac86 100644 --- a/src/components/PageComponents/ModuleConfig/CannedMessage.tsx +++ b/src/components/PageComponents/ModuleConfig/CannedMessage.tsx @@ -1,26 +1,46 @@ import type { CannedMessageValidation } from "@app/validation/moduleConfig/cannedMessage.js"; -import { useDevice } from "@core/stores/deviceStore.js"; +import { useConfig, useDevice } from "@core/stores/deviceStore.js"; import { Protobuf } from "@meshtastic/meshtasticjs"; -import { DynamicForm } from "@components/Form/DynamicForm.js"; +import { DynamicForm, EnableSwitchData } from "@components/Form/DynamicForm.js"; +import type { ConfigPreset } from "@app/core/stores/appStore"; export const CannedMessage = (): JSX.Element => { - const { moduleConfig, setWorkingModuleConfig } = useDevice(); - - const onSubmit = (data: CannedMessageValidation) => { - setWorkingModuleConfig( - new Protobuf.ModuleConfig({ - payloadVariant: { - case: "cannedMessage", - value: data + const config = useConfig(); + const enableSwitch: EnableSwitchData | undefined = config.overrideValues + ? { + getEnabled(name) { + return config.overrideValues![name] ?? false; + }, + setEnabled(name, value) { + config.overrideValues![name] = value; } - }) - ); - }; + } + : undefined; + const isPresetConfig = !("id" in config); + const setConfig: (data: CannedMessageValidation) => void = isPresetConfig + ? (data) => { + config.moduleConfig.cannedMessage = + new Protobuf.ModuleConfig_CannedMessageConfig(data); + (config as ConfigPreset).saveConfigTree(); + } + : (data) => { + useDevice().setWorkingModuleConfig( + new Protobuf.ModuleConfig({ + payloadVariant: { + case: "cannedMessage", + value: data + } + }) + ); + }; + + const onSubmit = setConfig; return ( onSubmit={onSubmit} - defaultValues={moduleConfig.cannedMessage} + defaultValues={config.moduleConfig.cannedMessage} + enableSwitch={enableSwitch} fieldGroups={[ { label: "Canned Message Settings", diff --git a/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx b/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx index 655af9b4..237fe1b0 100644 --- a/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx +++ b/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx @@ -1,26 +1,47 @@ import type { ExternalNotificationValidation } from "@app/validation/moduleConfig/externalNotification.js"; -import { useDevice } from "@core/stores/deviceStore.js"; +import { useConfig, useDevice } from "@core/stores/deviceStore.js"; import { Protobuf } from "@meshtastic/meshtasticjs"; -import { DynamicForm } from "@components/Form/DynamicForm.js"; +import { DynamicForm, EnableSwitchData } from "@components/Form/DynamicForm.js"; +import type { ConfigPreset } from "@app/core/stores/appStore"; export const ExternalNotification = (): JSX.Element => { - const { moduleConfig, setWorkingModuleConfig } = useDevice(); - - const onSubmit = (data: ExternalNotificationValidation) => { - setWorkingModuleConfig( - new Protobuf.ModuleConfig({ - payloadVariant: { - case: "externalNotification", - value: data + const config = useConfig(); + const enableSwitch: EnableSwitchData | undefined = config.overrideValues + ? { + getEnabled(name) { + return config.overrideValues![name] ?? false; + }, + setEnabled(name, value) { + config.overrideValues![name] = value; + } + } + : undefined; + const isPresetConfig = !("id" in config); + const setConfig: (data: ExternalNotificationValidation) => void = + isPresetConfig + ? (data) => { + config.moduleConfig.externalNotification = + new Protobuf.ModuleConfig_ExternalNotificationConfig(data); + (config as ConfigPreset).saveConfigTree(); } - }) - ); - }; + : (data) => { + useDevice().setWorkingModuleConfig( + new Protobuf.ModuleConfig({ + payloadVariant: { + case: "externalNotification", + value: data + } + }) + ); + }; + + const onSubmit = setConfig; return ( onSubmit={onSubmit} - defaultValues={moduleConfig.externalNotification} + defaultValues={config.moduleConfig.externalNotification} + enableSwitch={enableSwitch} fieldGroups={[ { label: "External Notification Settings", @@ -37,12 +58,6 @@ export const ExternalNotification = (): JSX.Element => { name: "outputMs", label: "Output MS", description: "Output MS", - - disabledBy: [ - { - fieldName: "enabled" - } - ], properties: { suffix: "ms" } @@ -51,134 +66,74 @@ export const ExternalNotification = (): JSX.Element => { type: "number", name: "output", label: "Output", - description: "Output", - disabledBy: [ - { - fieldName: "enabled" - } - ] + description: "Output" }, { type: "number", name: "outputVibra", label: "Output Vibrate", - description: "Output Vibrate", - disabledBy: [ - { - fieldName: "enabled" - } - ] + description: "Output Vibrate" }, { type: "number", name: "outputBuzzer", label: "Output Buzzer", - description: "Output Buzzer", - disabledBy: [ - { - fieldName: "enabled" - } - ] + description: "Output Buzzer" }, { type: "toggle", name: "active", label: "Active", - description: "Active", - disabledBy: [ - { - fieldName: "enabled" - } - ] + description: "Active" }, { type: "toggle", name: "alertMessage", label: "Alert Message", - description: "Alert Message", - disabledBy: [ - { - fieldName: "enabled" - } - ] + description: "Alert Message" }, { type: "toggle", name: "alertMessageVibra", label: "Alert Message Vibrate", - description: "Alert Message Vibrate", - disabledBy: [ - { - fieldName: "enabled" - } - ] + description: "Alert Message Vibrate" }, { type: "toggle", name: "alertMessageBuzzer", label: "Alert Message Buzzer", - description: "Alert Message Buzzer", - disabledBy: [ - { - fieldName: "enabled" - } - ] + description: "Alert Message Buzzer" }, { type: "toggle", name: "alertBell", label: "Alert Bell", description: - "Should an alert be triggered when receiving an incoming bell?", - disabledBy: [ - { - fieldName: "enabled" - } - ] + "Should an alert be triggered when receiving an incoming bell?" }, { type: "toggle", name: "alertBellVibra", label: "Alert Bell Vibrate", - description: "Alert Bell Vibrate", - disabledBy: [ - { - fieldName: "enabled" - } - ] + description: "Alert Bell Vibrate" }, { type: "toggle", name: "alertBellBuzzer", label: "Alert Bell Buzzer", - description: "Alert Bell Buzzer", - disabledBy: [ - { - fieldName: "enabled" - } - ] + description: "Alert Bell Buzzer" }, { type: "toggle", name: "usePwm", label: "Use PWM", - description: "Use PWM", - disabledBy: [ - { - fieldName: "enabled" - } - ] + description: "Use PWM" }, { type: "number", name: "nagTimeout", label: "Nag Timeout", - description: "Nag Timeout", - disabledBy: [ - { - fieldName: "enabled" - } - ] + description: "Nag Timeout" } ] } diff --git a/src/components/PageComponents/ModuleConfig/MQTT.tsx b/src/components/PageComponents/ModuleConfig/MQTT.tsx index 1c638638..14b977b2 100644 --- a/src/components/PageComponents/ModuleConfig/MQTT.tsx +++ b/src/components/PageComponents/ModuleConfig/MQTT.tsx @@ -1,26 +1,45 @@ import type { MQTTValidation } from "@app/validation/moduleConfig/mqtt.js"; import { Protobuf } from "@meshtastic/meshtasticjs"; -import { DynamicForm } from "@components/Form/DynamicForm.js"; -import { useDevice } from "@app/core/stores/deviceStore.js"; +import { DynamicForm, EnableSwitchData } from "@components/Form/DynamicForm.js"; +import { useConfig, useDevice } from "@app/core/stores/deviceStore.js"; +import type { ConfigPreset } from "@app/core/stores/appStore"; export const MQTT = (): JSX.Element => { - const { moduleConfig, setWorkingModuleConfig } = useDevice(); - - const onSubmit = (data: MQTTValidation) => { - setWorkingModuleConfig( - new Protobuf.ModuleConfig({ - payloadVariant: { - case: "mqtt", - value: data + const config = useConfig(); + const enableSwitch: EnableSwitchData | undefined = config.overrideValues + ? { + getEnabled(name) { + return config.overrideValues![name] ?? false; + }, + setEnabled(name, value) { + config.overrideValues![name] = value; } - }) - ); - }; + } + : undefined; + const isPresetConfig = !("id" in config); + const setConfig: (data: MQTTValidation) => void = isPresetConfig + ? (data) => { + config.moduleConfig.mqtt = new Protobuf.ModuleConfig_MQTTConfig(data); + (config as ConfigPreset).saveConfigTree(); + } + : (data) => { + useDevice().setWorkingModuleConfig( + new Protobuf.ModuleConfig({ + payloadVariant: { + case: "mqtt", + value: data + } + }) + ); + }; + + const onSubmit = setConfig; return ( onSubmit={onSubmit} - defaultValues={moduleConfig.mqtt} + defaultValues={config.moduleConfig.mqtt} + enableSwitch={enableSwitch} fieldGroups={[ { label: "MQTT Settings", @@ -37,34 +56,19 @@ export const MQTT = (): JSX.Element => { name: "address", label: "MQTT Server Address", description: - "MQTT server address to use for default/custom servers", - disabledBy: [ - { - fieldName: "enabled" - } - ] + "MQTT server address to use for default/custom servers" }, { type: "text", name: "username", label: "MQTT Username", - description: "MQTT username to use for default/custom servers", - disabledBy: [ - { - fieldName: "enabled" - } - ] + description: "MQTT username to use for default/custom servers" }, { type: "password", name: "password", label: "MQTT Password", - description: "MQTT password to use for default/custom servers", - disabledBy: [ - { - fieldName: "enabled" - } - ] + description: "MQTT password to use for default/custom servers" }, { type: "toggle", @@ -87,6 +91,18 @@ export const MQTT = (): JSX.Element => { fieldName: "enabled" } ] + }, + { + type: "toggle", + name: "tlsEnabled", + label: "TLS Enabled", + description: "Enable or disable TLS", + }, + { + type: "text", + name: "root", + label: "Root", + description: "Root topic to publish/subscribe to", } ] } diff --git a/src/components/PageComponents/ModuleConfig/RangeTest.tsx b/src/components/PageComponents/ModuleConfig/RangeTest.tsx index 3d5916bd..debe36d1 100644 --- a/src/components/PageComponents/ModuleConfig/RangeTest.tsx +++ b/src/components/PageComponents/ModuleConfig/RangeTest.tsx @@ -1,26 +1,46 @@ import type { RangeTestValidation } from "@app/validation/moduleConfig/rangeTest.js"; -import { useDevice } from "@core/stores/deviceStore.js"; +import { useConfig, useDevice } from "@core/stores/deviceStore.js"; import { Protobuf } from "@meshtastic/meshtasticjs"; -import { DynamicForm } from "@components/Form/DynamicForm.js"; +import { DynamicForm, EnableSwitchData } from "@components/Form/DynamicForm.js"; +import type { ConfigPreset } from "@app/core/stores/appStore"; export const RangeTest = (): JSX.Element => { - const { moduleConfig, setWorkingModuleConfig } = useDevice(); - - const onSubmit = (data: RangeTestValidation) => { - setWorkingModuleConfig( - new Protobuf.ModuleConfig({ - payloadVariant: { - case: "rangeTest", - value: data + const config = useConfig(); + const enableSwitch: EnableSwitchData | undefined = config.overrideValues + ? { + getEnabled(name) { + return config.overrideValues![name] ?? false; + }, + setEnabled(name, value) { + config.overrideValues![name] = value; } - }) - ); - }; + } + : undefined; + const isPresetConfig = !("id" in config); + const setConfig: (data: RangeTestValidation) => void = isPresetConfig + ? (data) => { + config.moduleConfig.rangeTest = + new Protobuf.ModuleConfig_RangeTestConfig(data); + (config as ConfigPreset).saveConfigTree(); + } + : (data) => { + useDevice().setWorkingModuleConfig( + new Protobuf.ModuleConfig({ + payloadVariant: { + case: "rangeTest", + value: data + } + }) + ); + }; + + const onSubmit = setConfig; return ( onSubmit={onSubmit} defaultValues={moduleConfig.rangeTest} + enableSwitch={enableSwitch} fieldGroups={[ { label: "Range Test Settings", @@ -36,23 +56,13 @@ export const RangeTest = (): JSX.Element => { type: "number", name: "sender", label: "Message Interval", - description: "How long to wait between sending test packets", - disabledBy: [ - { - fieldName: "enabled" - } - ] + description: "How long to wait between sending test packets" }, { type: "toggle", name: "save", label: "Save CSV to storage", - description: "ESP32 Only", - disabledBy: [ - { - fieldName: "enabled" - } - ] + description: "ESP32 Only" } ] } diff --git a/src/components/PageComponents/ModuleConfig/Serial.tsx b/src/components/PageComponents/ModuleConfig/Serial.tsx index f1b44ba9..a829aadf 100644 --- a/src/components/PageComponents/ModuleConfig/Serial.tsx +++ b/src/components/PageComponents/ModuleConfig/Serial.tsx @@ -1,26 +1,47 @@ import type { SerialValidation } from "@app/validation/moduleConfig/serial.js"; -import { useDevice } from "@core/stores/deviceStore.js"; +import { useConfig, useDevice } from "@core/stores/deviceStore.js"; import { Protobuf } from "@meshtastic/meshtasticjs"; -import { DynamicForm } from "@components/Form/DynamicForm.js"; +import { DynamicForm, EnableSwitchData } from "@components/Form/DynamicForm.js"; +import type { ConfigPreset } from "@app/core/stores/appStore"; export const Serial = (): JSX.Element => { - const { moduleConfig, setWorkingModuleConfig } = useDevice(); - - const onSubmit = (data: SerialValidation) => { - setWorkingModuleConfig( - new Protobuf.ModuleConfig({ - payloadVariant: { - case: "serial", - value: data + const config = useConfig(); + const enableSwitch: EnableSwitchData | undefined = config.overrideValues + ? { + getEnabled(name) { + return config.overrideValues![name] ?? false; + }, + setEnabled(name, value) { + config.overrideValues![name] = value; } - }) - ); - }; + } + : undefined; + const isPresetConfig = !("id" in config); + const setConfig: (data: SerialValidation) => void = isPresetConfig + ? (data) => { + config.moduleConfig.serial = new Protobuf.ModuleConfig_SerialConfig( + data + ); + (config as ConfigPreset).saveConfigTree(); + } + : (data) => { + useDevice().setWorkingModuleConfig( + new Protobuf.ModuleConfig({ + payloadVariant: { + case: "serial", + value: data + } + }) + ); + }; + + const onSubmit = setConfig; return ( onSubmit={onSubmit} - defaultValues={moduleConfig.serial} + defaultValues={config.moduleConfig.serial} + enableSwitch={enableSwitch} fieldGroups={[ { label: "Serial Settings", @@ -37,46 +58,25 @@ export const Serial = (): JSX.Element => { name: "echo", label: "Echo", description: - "Any packets you send will be echoed back to your device", - disabledBy: [ - { - fieldName: "enabled" - } - ] + "Any packets you send will be echoed back to your device" }, { type: "number", name: "rxd", label: "Receive Pin", - description: "Set the GPIO pin to the RXD pin you have set up.", - disabledBy: [ - { - fieldName: "enabled" - } - ] + description: "Set the GPIO pin to the RXD pin you have set up." }, { type: "number", name: "txd", label: "Transmit Pin", - description: "Set the GPIO pin to the TXD pin you have set up.", - disabledBy: [ - { - fieldName: "enabled" - } - ] + description: "Set the GPIO pin to the TXD pin you have set up." }, { type: "select", name: "baud", label: "Baud Rate", description: "The serial baud rate", - - disabledBy: [ - { - fieldName: "enabled" - } - ], properties: { enumValue: Protobuf.ModuleConfig_SerialConfig_Serial_Baud } @@ -88,11 +88,6 @@ export const Serial = (): JSX.Element => { description: "Seconds to wait before we consider your packet as 'done'", - disabledBy: [ - { - fieldName: "enabled" - } - ], properties: { suffix: "Seconds" } @@ -102,12 +97,6 @@ export const Serial = (): JSX.Element => { name: "mode", label: "Mode", description: "Select Mode", - - disabledBy: [ - { - fieldName: "enabled" - } - ], properties: { enumValue: Protobuf.ModuleConfig_SerialConfig_Serial_Mode, formatEnumName: true diff --git a/src/components/PageComponents/ModuleConfig/StoreForward.tsx b/src/components/PageComponents/ModuleConfig/StoreForward.tsx index 8b80ecc0..53adadf4 100644 --- a/src/components/PageComponents/ModuleConfig/StoreForward.tsx +++ b/src/components/PageComponents/ModuleConfig/StoreForward.tsx @@ -1,26 +1,45 @@ import type { StoreForwardValidation } from "@app/validation/moduleConfig/storeForward.js"; -import { useDevice } from "@core/stores/deviceStore.js"; +import { useConfig, useDevice } from "@core/stores/deviceStore.js"; import { Protobuf } from "@meshtastic/meshtasticjs"; -import { DynamicForm } from "@components/Form/DynamicForm.js"; +import { DynamicForm, EnableSwitchData } from "@components/Form/DynamicForm.js"; +import type { ConfigPreset } from "@app/core/stores/appStore"; export const StoreForward = (): JSX.Element => { - const { moduleConfig, setWorkingModuleConfig } = useDevice(); - - const onSubmit = (data: StoreForwardValidation) => { - setWorkingModuleConfig( - new Protobuf.ModuleConfig({ - payloadVariant: { - case: "storeForward", - value: data + const config = useConfig(); + const enableSwitch: EnableSwitchData | undefined = config.overrideValues + ? { + getEnabled(name) { + return config.overrideValues![name] ?? false; + }, + setEnabled(name, value) { + config.overrideValues![name] = value; } - }) - ); - }; + } + : undefined; + const isPresetConfig = !("id" in config); + const setConfig: (data: StoreForwardValidation) => void = isPresetConfig + ? (data) => { + config.moduleConfig.storeForward = + new Protobuf.ModuleConfig_StoreForwardConfig(data); + (config as ConfigPreset).saveConfigTree(); + } + : (data) => { + useDevice().setWorkingModuleConfig( + new Protobuf.ModuleConfig({ + payloadVariant: { + case: "storeForward", + value: data + } + }) + ); + }; + const onSubmit = setConfig; return ( onSubmit={onSubmit} defaultValues={moduleConfig.storeForward} + enableSwitch={enableSwitch} fieldGroups={[ { label: "Store & Forward Settings", diff --git a/src/components/PageComponents/ModuleConfig/Telemetry.tsx b/src/components/PageComponents/ModuleConfig/Telemetry.tsx index 3420506f..d10e5091 100644 --- a/src/components/PageComponents/ModuleConfig/Telemetry.tsx +++ b/src/components/PageComponents/ModuleConfig/Telemetry.tsx @@ -1,26 +1,46 @@ import type { TelemetryValidation } from "@app/validation/moduleConfig/telemetry.js"; -import { useDevice } from "@core/stores/deviceStore.js"; +import { useConfig, useDevice } from "@core/stores/deviceStore.js"; import { Protobuf } from "@meshtastic/meshtasticjs"; -import { DynamicForm } from "@components/Form/DynamicForm.js"; +import { DynamicForm, EnableSwitchData } from "@components/Form/DynamicForm.js"; +import type { ConfigPreset } from "@app/core/stores/appStore"; export const Telemetry = (): JSX.Element => { - const { moduleConfig, setWorkingModuleConfig } = useDevice(); - - const onSubmit = (data: TelemetryValidation) => { - setWorkingModuleConfig( - new Protobuf.ModuleConfig({ - payloadVariant: { - case: "telemetry", - value: data + const config = useConfig(); + const enableSwitch: EnableSwitchData | undefined = config.overrideValues + ? { + getEnabled(name) { + return config.overrideValues![name] ?? false; + }, + setEnabled(name, value) { + config.overrideValues![name] = value; } - }) - ); - }; + } + : undefined; + const isPresetConfig = !("id" in config); + const setConfig: (data: TelemetryValidation) => void = isPresetConfig + ? (data) => { + config.moduleConfig.telemetry = + new Protobuf.ModuleConfig_TelemetryConfig(data); + (config as ConfigPreset).saveConfigTree(); + } + : (data) => { + useDevice().setWorkingModuleConfig( + new Protobuf.ModuleConfig({ + payloadVariant: { + case: "telemetry", + value: data + } + }) + ); + }; + + const onSubmit = setConfig; return ( onSubmit={onSubmit} - defaultValues={moduleConfig.telemetry} + defaultValues={config.moduleConfig.telemetry} + enableSwitch={enableSwitch} fieldGroups={[ { label: "Telemetry Settings", @@ -61,6 +81,18 @@ export const Telemetry = (): JSX.Element => { name: "environmentDisplayFahrenheit", label: "Display Fahrenheit", description: "Display temp in Fahrenheit" + }, + { + type: "toggle", + name: "airQualityEnabled", + label: "Air Quality Enabled", + description: "Enable Air Quality Telemetry" + }, + { + type: "number", + name: "airQualityInterval", + label: "Air Quality Interval", + description: "How often to send Air Quality Metrics" } ] } diff --git a/src/components/UI/Command.tsx b/src/components/UI/Command.tsx index abd0fed1..91b1062a 100644 --- a/src/components/UI/Command.tsx +++ b/src/components/UI/Command.tsx @@ -90,7 +90,7 @@ const CommandGroup = React.forwardRef< void; + editing: boolean; + onClick?: () => void; + onChangeDone?: (value: string) => void; + disabled: boolean; +} + +export const ConfigSelectButton = ({ + label, + active, + value, + setValue, + editing, + onClick, + onChangeDone, + disabled +}: ConfigSelectButtonProps): JSX.Element => ( +
    + +
    {value}
    + +
    + +
    +); diff --git a/src/components/UI/Dialog.tsx b/src/components/UI/Dialog.tsx index bf6f950f..fc21de96 100644 --- a/src/components/UI/Dialog.tsx +++ b/src/components/UI/Dialog.tsx @@ -52,7 +52,7 @@ const DialogContent = React.forwardRef< {...props} > {children} - + Close diff --git a/src/components/UI/DropdownMenu.tsx b/src/components/UI/DropdownMenu.tsx index 1c2ac084..00dc753a 100644 --- a/src/components/UI/DropdownMenu.tsx +++ b/src/components/UI/DropdownMenu.tsx @@ -25,7 +25,7 @@ const DropdownMenuSubTrigger = React.forwardRef< ( )} (({ className, ...props }, ref) => ( diff --git a/src/components/UI/Tabs.tsx b/src/components/UI/Tabs.tsx index 9497fa1a..99f68f6b 100644 --- a/src/components/UI/Tabs.tsx +++ b/src/components/UI/Tabs.tsx @@ -12,7 +12,7 @@ const TabsList = React.forwardRef< ( - + {children} ); diff --git a/src/components/generic/Table/index.tsx b/src/components/generic/Table/index.tsx index 71d96ce1..6742f6ce 100755 --- a/src/components/generic/Table/index.tsx +++ b/src/components/generic/Table/index.tsx @@ -20,7 +20,7 @@ export const Table = ({ headings, rows }: TableProps): JSX.Element => { ((resolve, reject) => { + const db = indexedDB.open("firmwares"); + db.onsuccess = () => { + resolve(db.result); + }; + db.onupgradeneeded = (ev) => { + const objStore = db.result.createObjectStore("files"); + objStore.transaction.oncomplete = () => resolve(db.result); + }; + }); +} + +export async function isStoredInDb(firmwareTag: string): Promise { + const dbs = await indexedDB.databases(); + if (dbs.find((db) => db.name == "firmwares") === undefined) return false; + return new Promise((resolve, reject) => { + const db = indexedDB.open("firmwares"); + db.onsuccess = () => { + if (!db.result.objectStoreNames.contains("files")) resolve(false); + const objStore = db.result + .transaction("files", "readonly") + .objectStore("files"); + const transaction = objStore.getKey(firmwareTag); + transaction.onsuccess = () => resolve(transaction.result !== undefined); + transaction.onerror = () => resolve(false); + }; + }); +} + +export async function storeInDb(firmware: FirmwareVersion, file: ArrayBuffer) { + const db = await openDb(); + return new Promise((resolve, reject) => { + const fileStore = db.transaction("files", "readwrite").objectStore("files"); + const addOp = fileStore.add(file, firmware.tag); + fileStore.transaction.oncomplete = () => { + console.log("Successfully stored firmware in DB."); + resolve(); + }; + fileStore.transaction.onerror = reject; + }); +} + +export async function loadFromDb(firmware: FirmwareVersion) { + const db = await openDb(); + return new Promise((resolve, reject) => { + const objStore = db.transaction("files", "readonly").objectStore("files"); + const transaction = objStore.get(firmware.tag); + transaction.onsuccess = () => { + resolve(transaction.result as ArrayBuffer); + }; + transaction.onerror = reject; + }); +} + +export async function deleteFromDb(firmware: FirmwareVersion) { + const db = await openDb(); + return new Promise((resolve, reject) => { + if (!db.objectStoreNames.contains("files")) { + resolve(); + return; + } + const objStore = db.transaction("files", "readonly").objectStore("files"); + const transaction = objStore.delete(firmware.tag); + transaction.onsuccess = () => resolve; + transaction.onerror = reject; + }); +} diff --git a/src/core/flashing/Flasher.ts b/src/core/flashing/Flasher.ts new file mode 100644 index 00000000..76c818bc --- /dev/null +++ b/src/core/flashing/Flasher.ts @@ -0,0 +1,467 @@ +import JSZip from "jszip"; + +import { ISerialConnection, Protobuf } from "@meshtastic/meshtasticjs"; +import { EspLoader } from "@toit/esptool.js"; + +import type { ConfigPreset } from "../stores/appStore"; +import type { Device } from "../stores/deviceStore"; +import { + FirmwareVersion, + deviceModels +} from "@app/components/PageComponents/Flasher/FlashSettings"; +import { storeInDb, loadFromDb } from "./FirmwareDb"; +import * as esptooljs from "esptool-js"; + +type DeviceFlashingState = + | "doNotFlash" + | "doFlash" + | "idle" + | "preparing" + | "erasing" + | "flashing" + | "config" + | "done" + | "aborted" + | "failed"; +export type OverallFlashingState = "idle" | "downloading" | "busy" | "waiting"; + +type OverallFlashingCallback = ( + flashState: OverallFlashingState, + progress?: number +) => void; + +let dataSections: { [index: string]: Uint8Array }; +let zipFile: JSZip; +let configQueue: { + config: Protobuf.LocalConfig; + moduleConfig: Protobuf.LocalModuleConfig; +}[] = []; +let firmwareToUse: FirmwareVersion; +let operations: FlashOperation[] = []; +let callback: OverallFlashingCallback; +let selectedDeviceModel: string; +let fullFlash: boolean; + +export async function setup( + configs: ConfigPreset[], + deviceModelName: string, + firmware: FirmwareVersion, + forceFullFlash: boolean, + overallCallback: OverallFlashingCallback +) { + dataSections = {}; + selectedDeviceModel = deviceModelName; + callback = overallCallback; + console.log(`Firmware to use: ${firmware?.name} - ${firmware?.id}`); + firmwareToUse = firmware; + fullFlash = forceFullFlash; + await loadFirmware(); + for (const c in configs) { + const config = configs[c]; + for (let i = 0; i < config.count; i++) { + configQueue.push({ + config: config.config, + moduleConfig: config.moduleConfig + }); + } + } +} + +export async function nextBatch( + devices: Device[], + flashStates: FlashState[], + deviceCallback: (flashState: FlashOperation) => void +) { + callback("busy"); + devices = devices.filter((d, i) => flashStates[i].state == "doFlash"); + flashStates = flashStates.filter((f) => f.state == "doFlash"); + if (devices.length > configQueue.length) { + console.warn("Too many devices!"); + devices = devices.slice(0, configQueue.length); + } + operations = devices.map( + (dev, index) => + new FlashOperation( + dev, + configQueue[index].config, + configQueue[index].moduleConfig, + deviceCallback + ) + ); + operations.forEach((o, i) => (o.state = flashStates[i])); + configQueue = configQueue.slice(operations.length); + console.log(`New config queue count: ${configQueue.length}`); + + Promise.allSettled(operations.map((op) => op.flashAndConfigDevice())).then( + (p) => handleFlashingDone() + ); +} + +export function cancel() { + operations.forEach((o) => o.cancel()); + callback("idle"); +} + +function handleFlashingDone() { + if (configQueue.length == 0) callback("idle"); + else callback("waiting"); +} + +export async function uploadCustomFirmware() { + //@ts-ignore + const promise: Promise = window.showOpenFilePicker({ + types: [ + { description: "ZIP file", accept: { "application/zip": [".zip"] } } + ] + }); + const fileHandle: FileSystemFileHandle | undefined = await promise.then( + (f) => f[0], + () => undefined + ); + if (fileHandle == undefined) return undefined; + try { + const file = await fileHandle.getFile(); + const content = await file.arrayBuffer(); + const firmwareDesc: FirmwareVersion = { + id: "custom_" + file.name, + name: file.name, + inLocalDb: true, + isPreRelease: false, + tag: "custom_" + file.name + }; + storeInDb(firmwareDesc, content); + return firmwareDesc; + } catch { + console.error("Insufficient permission to access file."); + } + return undefined; +} + +async function getZipFile() { + if (firmwareToUse.inLocalDb) { + const storedZip = await loadFromDb(firmwareToUse); + if (storedZip !== undefined) return storedZip; + } + + const zip = await fetch(`/firmware/${firmwareToUse.id}`); + const zipClone = zip.clone(); + const contentLength = zip.headers.get("content-length"); + const totalLength = contentLength ? parseInt(contentLength) : undefined; + const reader = zip.body!.getReader(); + let bytesLoaded = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + bytesLoaded += value!.byteLength; + callback( + "downloading", + totalLength ? bytesLoaded / totalLength : undefined + ); + } + const content = await zipClone.arrayBuffer(); + storeInDb(firmwareToUse, content); + return content; +} + +async function loadFirmware() { + console.log("Loading firmware"); + + const zip = await getZipFile(); + zipFile = await JSZip.loadAsync(zip); +} + +async function getSection(name: string): Promise { + if (!(name in dataSections)) { + const dataSection = await zipFile.file(name)?.async("uint8array"); + if (dataSection === undefined) { + console.warn(`Firmware file ${name} not found.`); + return undefined; + } + dataSections[name] = dataSection; + } + return dataSections[name]; +} + +async function getFirmwareSections(deviceModel: string, update: boolean) { + if (update) { + const updateFilename = `firmware-${deviceModel}-${firmwareToUse.tag}-update.bin`; + const mainUpdate = await getSection(updateFilename); + if (mainUpdate !== undefined) { + return [{ offset: 0x10000, data: mainUpdate }]; + } + } + const filename = `firmware-${deviceModel}-${firmwareToUse.tag}.bin`; + const main = await getSection(filename); + const bleoata = await getSection("bleota.bin"); + const littlefs = await getSection(`littlefs-${firmwareToUse.tag}.bin`); + if (main === undefined || bleoata === undefined || littlefs === undefined) + throw "Missing firmware files."; + + return [ + { offset: 0, data: main }, + { offset: 0x260000, data: bleoata }, + { offset: 0x300000, data: littlefs } + ]; +} + +function autoDetectDeviceModel(port: SerialPort) { + const info = port.getInfo(); + return deviceModels.find( + (d) => info.usbVendorId == d.vendorId && info.usbProductId == d.productId + )?.name; +} + +export class FlashOperation { + public state: FlashState = { progress: 0, state: "idle" }; + public errorReason?: string; + private loader?: esptooljs.ESPLoader; + private isCancelled: boolean; + + public constructor( + public device: Device, + public config: Protobuf.LocalConfig, + public moduleConfig: Protobuf.LocalModuleConfig, + public callback: (operation: FlashOperation) => void + ) {} + + public setState( + state: DeviceFlashingState, + progress = 0, + errorReason: string | undefined = undefined + ) { + if (this.isCancelled) return; + console.log( + `${ + this.device.nodes.get(this.device.hardware.myNodeNum)?.user?.longName ?? + "" + } flash state: ${state}` + ); + this.state = { state, progress }; + this.errorReason = errorReason; + this.callback(this); + } + + public async flashAndConfigDevice() { + try { + await this.flash(); + await this.setConfig(); + this.setState("done"); + } catch (e) { + this.setState("failed", 0, e as string); + throw e; + } + } + + private async flash() { + const installedVersion = this.device.hardware.firmwareVersion; + console.log(`Installed firmware verson: ${installedVersion}`); + const update = + !fullFlash && + this.device.nodes.get(this.device.hardware.myNodeNum) !== undefined; + if (update && installedVersion == firmwareToUse.tag) return; + + const port = await ( + this.device.connection! as ISerialConnection + ).disconnect(); + if (port === undefined) throw "Port unavailable"; + const deviceModel = + selectedDeviceModel == "auto" + ? autoDetectDeviceModel(port) + : selectedDeviceModel; + if (deviceModel === undefined) throw "Could not detect device model"; + const info = port.getInfo(); + console.log( + `Device info: vendor ${info.usbVendorId}, product ${info.usbProductId}` + ); + const sections = await getFirmwareSections(deviceModel, update); + + // ----------- + + try { + const transport = new esptooljs.Transport(port); + this.loader = new esptooljs.ESPLoader(transport, 115200); + const loader = this.loader; + this.setState("preparing"); + await loader.main_fn(); + if (sections.length > 1) { + this.setState("erasing"); + await loader.erase_flash(); + } + const totalLength = sections.reduce( + (p, c) => p + c.data.byteLength, + 0 + ); + let bytesFlashed = 0; + let lastIndex = 0; + + const files = await Promise.all( + sections.map(async (s) => { + const fileReader = new FileReader(); + const blob = new Blob([s.data]); + const content = await new Promise((resolve, reject) => { + fileReader.onloadend = (e) => resolve(fileReader.result as string); + fileReader.onerror = (e) => reject(fileReader.result as string); + fileReader.readAsBinaryString(blob); + }); + return { data: content, address: s.offset }; + }) + ); + + await loader.write_flash( + files, + "keep", + undefined, + undefined, + false, + true, + (index, written, total) => { + if (this.isCancelled) throw "Cancelled"; + if (index != lastIndex) { + bytesFlashed += sections[lastIndex].data.byteLength; + lastIndex = index; + } + // I don't know what kind of weird size esploader computes but it doesn't match ours + const bytesThisSegment = + (written / total) * sections[index].data.byteLength; + console.log( + `FLASHING PROGRESS ${ + bytesFlashed + written + } / ${totalLength} ... ${bytesFlashed} | ${written} | ${total} | ${index}` + ); + this.setState( + "flashing", + (bytesFlashed + bytesThisSegment) / totalLength + ); + } + ); + } catch (e) { + throw e; + } finally { + await this.loader?.flash_finish(); + } + + this.setState("config"); + await port!.setSignals({ requestToSend: true }); + await new Promise((r) => setTimeout(r, 100)); + await port!.setSignals({ requestToSend: false }); + await port!.close(); + const connection = this.device.connection! as ISerialConnection; + //@ts-ignore + connection.preventLock = false; + debugger; + await connection.connect({ + port, + baudRate: undefined, + concurrentLogOutput: true + }); + await new Promise((r) => setTimeout(r, 5000)); + } + + private async setConfig() { + if (this.isCancelled) return; + this.setState("config"); + const connection = this.device.connection! as ISerialConnection; + + await connection.setConfig( + new Protobuf.Config({ + payloadVariant: { case: "device", value: this.config.device! } + }) + ); + await connection.setConfig( + new Protobuf.Config({ + payloadVariant: { case: "position", value: this.config.position! } + }) + ); + await connection.setConfig( + new Protobuf.Config({ + payloadVariant: { case: "power", value: this.config.power! } + }) + ); + await connection.setConfig( + new Protobuf.Config({ + payloadVariant: { case: "network", value: this.config.network! } + }) + ); + await connection.setConfig( + new Protobuf.Config({ + payloadVariant: { case: "display", value: this.config.display! } + }) + ); + await connection.setConfig( + new Protobuf.Config({ + payloadVariant: { case: "lora", value: this.config.lora! } + }) + ); + await connection.setConfig( + new Protobuf.Config({ + payloadVariant: { case: "bluetooth", value: this.config.bluetooth! } + }) + ); + + await connection.setModuleConfig( + new Protobuf.ModuleConfig({ + payloadVariant: { case: "mqtt", value: this.moduleConfig.mqtt! } + }) + ); + await connection.setModuleConfig( + new Protobuf.ModuleConfig({ + payloadVariant: { case: "serial", value: this.moduleConfig.serial! } + }) + ); + await connection.setModuleConfig( + new Protobuf.ModuleConfig({ + payloadVariant: { + case: "externalNotification", + value: this.moduleConfig.externalNotification! + } + }) + ), + await connection.setModuleConfig( + new Protobuf.ModuleConfig({ + payloadVariant: { + case: "storeForward", + value: this.moduleConfig.storeForward! + } + }) + ), + await connection.setModuleConfig( + new Protobuf.ModuleConfig({ + payloadVariant: { + case: "telemetry", + value: this.moduleConfig.telemetry! + } + }) + ), + await connection.setModuleConfig( + new Protobuf.ModuleConfig({ + payloadVariant: { + case: "cannedMessage", + value: this.moduleConfig.cannedMessage! + } + }) + ), + await connection.setModuleConfig( + new Protobuf.ModuleConfig({ + payloadVariant: { case: "audio", value: this.moduleConfig.audio! } + }) + ); + + // We won't get an answer if serial output has been disabled in the new config + if (!this.config.device!.serialEnabled) return; + await connection + .commitEditSettings() + .then(() => console.log("FLASHER: Config saved")); + } + + public async cancel() { + this.setState("aborted"); + this.isCancelled = true; + } +} + +export interface FlashState { + state: DeviceFlashingState; + progress: number; +} diff --git a/src/core/stores/appStore.ts b/src/core/stores/appStore.ts index d0ef9bf2..a4473914 100644 --- a/src/core/stores/appStore.ts +++ b/src/core/stores/appStore.ts @@ -1,6 +1,11 @@ import { produce } from "immer"; import { create } from "zustand"; +import { Protobuf } from "@meshtastic/meshtasticjs"; + +import type { OverallFlashingState } from "../flashing/Flasher"; +import type { FirmwareVersion } from "@app/components/PageComponents/Flasher/FlashSettings"; + export interface RasterSource { enabled: boolean; title: string; @@ -17,6 +22,256 @@ export type accentColor = | "purple" | "pink"; +export class ConfigPreset { + public children: ConfigPreset[] = []; + public count: number = 0; + public overrideValues: { [fieldName: string]: boolean } | undefined; + + public constructor( + public name: string, + public parent?: ConfigPreset, + public config = ConfigPreset.createDefaultConfig(), + public moduleConfig = ConfigPreset.createDefaultModuleConfig() + ) { + if (parent) { + // Root config should not be overridable + this.overrideValues = {}; + } + } + + public saveConfigTree() { + if (this.parent) this.parent.saveConfigTree(); + else localStorage.setItem("PresetConfigs", this.getConfigJSON()); + } + + public exportConfigTree() { + const blob = new Blob([this.getConfigJSON()], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const elem = document.createElement("a"); + elem.setAttribute("href", url); + elem.setAttribute("download", "ConfigPresets.json"); + document.body.appendChild(elem); + elem.click(); + document.body.removeChild(elem); + URL.revokeObjectURL(url); + } + + public getTotalConfigCount(): number { + return this.children + .map((child) => child.getTotalConfigCount()) + .reduce((prev, cur) => prev + cur, this.count); + } + + public getAll(): ConfigPreset[] { + const configs: ConfigPreset[] = [this]; + this.children.forEach((c) => configs.push(...c.getAll())); + return configs; + } + + private getConfigJSON(): string { + const replacer = (key: string, value: any) => { + if (key == "parent" || key == "count") return undefined; + return value; + }; + return JSON.stringify(this, replacer); + } + + public getFinalConfig(): Protobuf.LocalConfig { + const config = new Protobuf.LocalConfig(); + config.device = new Protobuf.Config_DeviceConfig(); + config.position = new Protobuf.Config_PositionConfig(); + config.power = new Protobuf.Config_PowerConfig(); + config.network = new Protobuf.Config_NetworkConfig(); + config.display = new Protobuf.Config_DisplayConfig(); + Object.entries(config).forEach(([sectionKey, value]) => { + if (sectionKey == "version") return; + Object.keys(value).forEach((key) => { + (value as any)[key] = this.getConfigValue( + sectionKey as keyof Protobuf.LocalConfig, + key + ); + }); + }); + return config; + } + + private getConfigValue( + sectionKey: keyof Protobuf.LocalConfig, + key: string + ): any { + if (this.parent !== undefined && !this.overrideValues![key]) + return this.parent.getConfigValue(sectionKey, key); + const conf = this.config[sectionKey]; + + return (conf as any)[key]; + } + + public static tryFromJson(json: string): ConfigPreset | undefined { + debugger; + try { + const rootPreset = JSON.parse(json, (key: string, value: any) => { + if (key == "" || !isNaN(Number(key))) { + // Create new ConfigPreset object to ensure that the member functions are not undefined. + const preset = new ConfigPreset( + value.name, + undefined, + value.config, + value.moduleConfig + ); + preset.overrideValues = value.overrideValues; + preset.children = value.children; + preset.children.forEach((c) => { + c.parent = preset; + if (c.overrideValues === undefined) c.overrideValues = {}; + }); + return preset; + } else if (key == "config") { + return Protobuf.LocalConfig.fromJson(value); + } else if (key == "moduleConfig") { + return Protobuf.LocalModuleConfig.fromJson(value); + } + return value; + }); + return rootPreset; + } catch { + return undefined; + } + } + + public static loadOrCreate(): ConfigPreset { + const storedConfigs = localStorage.getItem("PresetConfigs"); + if (storedConfigs !== null) { + const rootPreset = this.tryFromJson(storedConfigs); + if (rootPreset !== undefined) return rootPreset; + } + return new ConfigPreset("Default"); + } + + public static async importConfigTree() { + //@ts-ignore + const promise: Promise = window.showOpenFilePicker({ + types: [ + { description: "JSON file", accept: { "application/json": [".json"] } } + ] + }); + const fileHandle: FileSystemFileHandle | undefined = await promise.then( + (f) => f[0], + () => undefined + ); + const file = await fileHandle?.getFile(); + const content = await file?.arrayBuffer(); + if (content === undefined) return undefined; + const json = new TextDecoder().decode(content); + const newRoot = this.tryFromJson(json); + if (newRoot === undefined) throw ""; + return newRoot; + } + + public restoreChildConnections() { + this.children.forEach((c) => { + c.parent = this; + c.restoreChildConnections(); + }); + } + + private static createDefaultConfig(): Protobuf.LocalConfig { + return new Protobuf.LocalConfig({ + device: new Protobuf.Config_DeviceConfig({ + serialEnabled: true, + nodeInfoBroadcastSecs: 10800 + }), + position: new Protobuf.Config_PositionConfig({ + positionBroadcastSmartEnabled: true, + gpsEnabled: true, + rxGpio: 15, + txGpio: 13, + positionBroadcastSecs: 900, + gpsUpdateInterval: 120, + gpsAttemptTime: 900 + }), + power: new Protobuf.Config_PowerConfig({ + waitBluetoothSecs: 60, + meshSdsTimeoutSecs: 7200, + sdsSecs: 4294967295, + lsSecs: 300, + minWakeSecs: 10 + }), + network: new Protobuf.Config_NetworkConfig({ + ntpServer: "0.pool.ntp.org" + }), + display: new Protobuf.Config_DisplayConfig({ + screenOnSecs: 600 + }), + lora: new Protobuf.Config_LoRaConfig({ + hopLimit: 3, + usePreset: true, + txEnabled: true, + txPower: 30 + }), + bluetooth: new Protobuf.Config_BluetoothConfig({ + enabled: true, + fixedPin: 123456 + }) + }); + } + + private static createDefaultModuleConfig(): Protobuf.LocalModuleConfig { + return new Protobuf.LocalModuleConfig({ + mqtt: new Protobuf.ModuleConfig_MQTTConfig({ + address: "mqtt.meshtastic.org", + username: "meshdev", + password: "large4cats" + }), + serial: new Protobuf.ModuleConfig_SerialConfig({}), + externalNotification: + new Protobuf.ModuleConfig_ExternalNotificationConfig({}), + storeForward: new Protobuf.ModuleConfig_StoreForwardConfig({}), + rangeTest: new Protobuf.ModuleConfig_RangeTestConfig({}), + telemetry: new Protobuf.ModuleConfig_TelemetryConfig({ + deviceUpdateInterval: 900, + environmentUpdateInterval: 900 + }), + cannedMessage: new Protobuf.ModuleConfig_CannedMessageConfig({}), + audio: new Protobuf.ModuleConfig_AudioConfig({}) + }); + } + + public shallowClone() { + const clone = new ConfigPreset( + this.name, + this.parent, + this.config, + this.moduleConfig + ); + clone.children = this.children; + clone.count = this.count; + clone.overrideValues = this.overrideValues; + return clone; + } +} + +function loadFirmwareListFromStorage(): FirmwareVersion[] { + const list = localStorage.getItem("firmwareList") as string | undefined; + if (list === undefined) return []; + try { + const json = JSON.parse(list) as FirmwareVersion[]; + if ( + json.every( + (o) => + "name" in o && + "inLocalDb" in o && + "id" in o && + "tag" in o && + "isPreRelease" in o + ) + ) + return json; + else return []; + } catch { + return []; + } +} + interface AppState { selectedDevice: number; devices: { @@ -28,6 +283,17 @@ interface AppState { darkMode: boolean; accent: accentColor; connectDialogOpen: boolean; + configPresetRoot: ConfigPreset; + configPresetSelected: ConfigPreset | undefined; + overallFlashingState: { + state: OverallFlashingState; + progress?: number; + }; + firmwareRefreshing: boolean; + firmwareList: FirmwareVersion[]; + selectedFirmware: string; + selectedDeviceModel: string; + fullFlash: boolean; setRasterSources: (sources: RasterSource[]) => void; addRasterSource: (source: RasterSource) => void; @@ -40,6 +306,17 @@ interface AppState { setDarkMode: (enabled: boolean) => void; setAccent: (color: accentColor) => void; setConnectDialogOpen: (open: boolean) => void; + setConfigPresetRoot: (root: ConfigPreset) => void; + setConfigPresetSelected: (selection: ConfigPreset) => void; + setOverallFlashingState: (state: { + state: OverallFlashingState; + progress?: number; + }) => void; + setFirmwareRefreshing: (state: boolean) => void; + setFirmwareList: (state: FirmwareVersion[]) => void; + setSelectedFirmware: (state: string) => void; + setSelectedDeviceModel: (state: string) => void; + setFullFlash: (state: boolean) => void; } export const useAppStore = create()((set) => ({ @@ -48,9 +325,18 @@ export const useAppStore = create()((set) => ({ currentPage: "messages", rasterSources: [], commandPaletteOpen: false, - darkMode: window.matchMedia('(prefers-color-scheme: dark)').matches, + darkMode: window.matchMedia("(prefers-color-scheme: dark)").matches, accent: "orange", connectDialogOpen: false, + configPresetRoot: ConfigPreset.loadOrCreate(), + configPresetSelected: undefined, + overallFlashingState: { state: "idle" }, + firmwareDownloadProgress: undefined, + firmwareRefreshing: false, + firmwareList: loadFirmwareListFromStorage(), + selectedFirmware: "latest", + selectedDeviceModel: "auto", + fullFlash: false, setRasterSources: (sources: RasterSource[]) => { set( @@ -112,5 +398,66 @@ export const useAppStore = create()((set) => ({ draft.connectDialogOpen = open; }) ); + }, + setConfigPresetRoot: (root: ConfigPreset) => { + set( + produce((draft) => { + draft.configPresetRoot = root; + }) + ); + }, + setConfigPresetSelected: (selection: ConfigPreset) => { + console.log(`${selection.name} has been selected.`); + set( + produce((draft) => { + draft.configPresetSelected = selection; + }) + ); + }, + setOverallFlashingState: (state: { + state: OverallFlashingState; + progress?: number; + }) => { + set( + produce((draft) => { + draft.overallFlashingState = state; + }) + ); + }, + setFirmwareRefreshing: (state: boolean) => { + set( + produce((draft) => { + draft.firmwareRefreshing = state; + }) + ); + }, + setFirmwareList: (state: FirmwareVersion[]) => { + set( + produce((draft) => { + localStorage.setItem("firmwareList", JSON.stringify(state)); + draft.firmwareList = state; + }) + ); + }, + setSelectedFirmware: (state: string) => { + set( + produce((draft) => { + draft.selectedFirmware = state; + }) + ); + }, + setSelectedDeviceModel(state: string) { + set( + produce((draft) => { + draft.selectedDeviceModel = state; + }) + ); + }, + setFullFlash(state: boolean) { + set( + produce((draft) => { + draft.fullFlash = state; + }) + ); } })); diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts index d481e4ad..d6db8e7e 100644 --- a/src/core/stores/deviceStore.ts +++ b/src/core/stores/deviceStore.ts @@ -4,7 +4,9 @@ import { produce } from "immer"; import { create } from "zustand"; import { Protobuf, Types } from "@meshtastic/meshtasticjs"; -import { channel } from "diagnostics_channel"; + +import type { FlashState } from "../flashing/Flasher"; +import { useAppStore } from "./appStore"; export type Page = "messages" | "map" | "config" | "channels" | "peers"; @@ -43,6 +45,7 @@ export interface Device { broadcast: Map; }; connection?: Types.ConnectionType; + flashState: FlashState; activePage: Page; activePeer: number; waypoints: Protobuf.Waypoint[]; @@ -67,6 +70,7 @@ export interface Device { setActivePage: (page: Page) => void; setActivePeer: (peer: number) => void; setPendingSettingsChanges: (state: boolean) => void; + setFlashState: (state: FlashState) => void; addChannel: (channel: Protobuf.Channel) => void; addWaypoint: (waypoint: Protobuf.Waypoint) => void; addNodeInfo: (nodeInfo: Protobuf.NodeInfo) => void; @@ -121,6 +125,7 @@ export const useDeviceStore = create((set, get) => ({ broadcast: new Map() }, connection: undefined, + flashState: { state: "doFlash", progress: 0 }, activePage: "messages", activePeer: 0, waypoints: [], @@ -332,6 +337,16 @@ export const useDeviceStore = create((set, get) => ({ }) ); }, + setFlashState: (state) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (device) { + device.flashState = state; + } + }) + ); + }, addChannel: (channel: Protobuf.Channel) => { set( produce((draft) => { @@ -580,3 +595,18 @@ export const useDevice = (): Device => { } return context; }; + +interface ConfigProvider { + config: Protobuf.LocalConfig; + moduleConfig: Protobuf.LocalModuleConfig; + overrideValues?: { [fieldName: string]: boolean }; +} + +export const useConfig = (): ConfigProvider => { + const context = useContext(DeviceContext); + if (context == undefined) { + const { configPresetRoot, configPresetSelected } = useAppStore(); + return configPresetSelected ?? configPresetRoot; + } + return context; +}; diff --git a/src/pages/Config/ConfigTabs.tsx b/src/pages/Config/ConfigTabs.tsx new file mode 100644 index 00000000..4ddac272 --- /dev/null +++ b/src/pages/Config/ConfigTabs.tsx @@ -0,0 +1,48 @@ +import { Fragment, useContext } from "react"; +import { Network } from "@components/PageComponents/Config/Network.js"; +import { Bluetooth } from "@components/PageComponents/Config/Bluetooth.js"; +import { Device } from "@components/PageComponents/Config/Device.js"; +import { Display } from "@components/PageComponents/Config/Display.js"; +import { LoRa } from "@components/PageComponents/Config/LoRa.js"; +import { Position } from "@components/PageComponents/Config/Position.js"; +import { Power } from "@components/PageComponents/Config/Power.js"; +import { DeviceContext, useDevice } from "@core/stores/deviceStore.js"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger +} from "@components/UI/Tabs.js"; +import { DeviceConfig } from "./DeviceConfig"; +import { ModuleConfig } from "./ModuleConfig"; + +export const ConfigTabs = (): JSX.Element => { + const tabs = [ + { + label: "Device", + element: DeviceConfig, + count: 0 + }, + { + label: "Module", + element: ModuleConfig + } + ]; + + return ( + + + {tabs.map((tab) => ( + + {tab.label} + + ))} + + {tabs.map((tab) => ( + + + + ))} + + ); +}; diff --git a/src/pages/Config/DeviceConfig.tsx b/src/pages/Config/DeviceConfig.tsx index f16ff8f8..6a629792 100644 --- a/src/pages/Config/DeviceConfig.tsx +++ b/src/pages/Config/DeviceConfig.tsx @@ -1,4 +1,4 @@ -import { Fragment } from "react"; +import { Fragment, useContext } from "react"; import { Network } from "@components/PageComponents/Config/Network.js"; import { Bluetooth } from "@components/PageComponents/Config/Bluetooth.js"; import { Device } from "@components/PageComponents/Config/Device.js"; @@ -6,7 +6,7 @@ import { Display } from "@components/PageComponents/Config/Display.js"; import { LoRa } from "@components/PageComponents/Config/LoRa.js"; import { Position } from "@components/PageComponents/Config/Position.js"; import { Power } from "@components/PageComponents/Config/Power.js"; -import { useDevice } from "@core/stores/deviceStore.js"; +import { DeviceContext, useDevice } from "@core/stores/deviceStore.js"; import { Tabs, TabsContent, @@ -15,7 +15,7 @@ import { } from "@components/UI/Tabs.js"; export const DeviceConfig = (): JSX.Element => { - const { hardware } = useDevice(); + const device = useContext(DeviceContext); const tabs = [ { @@ -34,7 +34,7 @@ export const DeviceConfig = (): JSX.Element => { { label: "Network", element: Network, - disabled: !hardware.hasWifi + disabled: device && !device.hardware.hasWifi }, { label: "Display", diff --git a/src/pages/Config/ModuleConfig.tsx b/src/pages/Config/ModuleConfig.tsx index f44fb817..fa752242 100644 --- a/src/pages/Config/ModuleConfig.tsx +++ b/src/pages/Config/ModuleConfig.tsx @@ -7,7 +7,6 @@ import { RangeTest } from "@components/PageComponents/ModuleConfig/RangeTest.js" import { Serial } from "@components/PageComponents/ModuleConfig/Serial.js"; import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.js"; import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.js"; -import { useDevice } from "@app/core/stores/deviceStore.js"; import { Tabs, TabsContent, @@ -16,8 +15,6 @@ import { } from "@components/UI/Tabs.js"; export const ModuleConfig = (): JSX.Element => { - const { workingModuleConfig, connection } = useDevice(); - const tabs = [ { label: "MQTT", diff --git a/src/pages/Peers.tsx b/src/pages/Peers.tsx index 4528bc76..31d059c6 100644 --- a/src/pages/Peers.tsx +++ b/src/pages/Peers.tsx @@ -30,11 +30,12 @@ export const PeersPage = (): JSX.Element => { rows={filteredNodes.map((node) => [ ,

    - {node.user?.longName ?? (node.user?.macaddr - ? `Meshtastic ${base16 - .stringify(node.user?.macaddr.subarray(4, 6) ?? []) - .toLowerCase()}` - : `UNK: ${node.num}`)} + {node.user?.longName ?? + (node.user?.macaddr + ? `Meshtastic ${base16 + .stringify(node.user?.macaddr.subarray(4, 6) ?? []) + .toLowerCase()}` + : `UNK: ${node.num}`)}

    , {Protobuf.HardwareModel[node.user?.hwModel ?? 0]}, diff --git a/src/validation/config/device.ts b/src/validation/config/device.ts index fb389b23..5fe2aac0 100644 --- a/src/validation/config/device.ts +++ b/src/validation/config/device.ts @@ -25,4 +25,10 @@ export class DeviceValidation @IsInt() nodeInfoBroadcastSecs: number; + + @IsBoolean() + doubleTapAsButtonPress: boolean; + + @IsBoolean() + isManaged: boolean; } diff --git a/src/validation/config/display.ts b/src/validation/config/display.ts index 33f15a70..91571839 100644 --- a/src/validation/config/display.ts +++ b/src/validation/config/display.ts @@ -31,4 +31,7 @@ export class DisplayValidation @IsBoolean() headingBold: boolean; + + @IsBoolean() + wakeOnTapOrMotion: boolean; } diff --git a/src/validation/config/position.ts b/src/validation/config/position.ts index a807ad96..0318b218 100644 --- a/src/validation/config/position.ts +++ b/src/validation/config/position.ts @@ -1,4 +1,4 @@ -import { IsBoolean, IsInt, IsNumber } from "class-validator"; +import { IsBoolean, IsIn, IsInt, IsNumber } from "class-validator"; import type { Protobuf } from "@meshtastic/meshtasticjs"; @@ -32,4 +32,10 @@ export class PositionValidation @IsInt() txGpio: number; + + @IsInt() + broadcastSmartMinimumDistance: number; + + @IsInt() + broadcastSmartMinimumIntervalSecs: number; } diff --git a/src/validation/moduleConfig/mqtt.ts b/src/validation/moduleConfig/mqtt.ts index b0df717e..09dd18fb 100644 --- a/src/validation/moduleConfig/mqtt.ts +++ b/src/validation/moduleConfig/mqtt.ts @@ -23,4 +23,10 @@ export class MQTTValidation @IsBoolean() jsonEnabled: boolean; + + @IsBoolean() + tlsEnabled: boolean; + + @Length(0, 30) + root: string; } diff --git a/src/validation/moduleConfig/telemetry.ts b/src/validation/moduleConfig/telemetry.ts index 42245f97..b74120b2 100644 --- a/src/validation/moduleConfig/telemetry.ts +++ b/src/validation/moduleConfig/telemetry.ts @@ -20,4 +20,10 @@ export class TelemetryValidation @IsBoolean() environmentDisplayFahrenheit: boolean; + + @IsBoolean() + airQualityEnabled: boolean; + + @IsInt() + airQualityInterval: number; } diff --git a/tsconfig.json b/tsconfig.json index eb0e774c..ff57d3fd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,6 +29,7 @@ "strictNullChecks": true, "types": ["vite/client", "node"], "importsNotUsedAsValues": "error", + "ignoreDeprecations": "5.0", "strictPropertyInitialization": false, "experimentalDecorators": true } diff --git a/vite.config.ts b/vite.config.ts index a9e641b8..0ad586ba 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,7 +3,6 @@ import { resolve } from "path"; import { visualizer } from "rollup-plugin-visualizer"; import { defineConfig } from "vite"; import EnvironmentPlugin from "vite-plugin-environment"; -import { VitePWA } from "vite-plugin-pwa"; import react from "@vitejs/plugin-react"; @@ -43,5 +42,21 @@ export default defineConfig({ "@core": resolve(__dirname, "./src/core"), "@layouts": resolve(__dirname, "./src/layouts") } + }, + server: { + proxy: { + // Firmware must be downloaded through this server as a proxy. + // It can't be downloaded directly because of GitHub's CORS policy. + "^/firmware/.*": { + target: + "https://github.com/repos/meshtastic/firmware/releases/assets/", + changeOrigin: true, + followRedirects: true, + rewrite: (path) => path.replace(/^\/firmware/, ""), + headers: { + Accept: "application/octet-stream" + } + } + } } });