Browse Source

Merge branch 'meshtastic:master' into traceroute

pull/211/head
Hunter Thornsberry 2 years ago
committed by GitHub
parent
commit
f3fbe75c66
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 9
      .vscode/settings.json
  2. 7
      biome.json
  3. 69
      package.json
  4. 7016
      pnpm-lock.yaml
  5. 4
      postcss.config.cjs
  6. 4
      src/components/CommandPalette.tsx
  7. 2
      src/components/DeviceSelector.tsx
  8. 2
      src/components/Dialog/DialogManager.tsx
  9. 36
      src/components/Dialog/QRDialog.tsx
  10. 2
      src/components/Dialog/RebootDialog.tsx
  11. 4
      src/components/Dialog/RemoveNodeDialog.tsx
  12. 2
      src/components/Dialog/ShutdownDialog.tsx
  13. 21
      src/components/Form/DynamicForm.tsx
  14. 15
      src/components/Form/DynamicFormField.tsx
  15. 2
      src/components/Form/FormInput.tsx
  16. 4
      src/components/Form/FormSelect.tsx
  17. 4
      src/components/Form/FormToggle.tsx
  18. 52
      src/components/PageComponents/Channel.tsx
  19. 2
      src/components/PageComponents/Config/LoRa.tsx
  20. 2
      src/components/PageComponents/Connect/BLE.tsx
  21. 22
      src/components/PageComponents/Connect/HTTP.tsx
  22. 31
      src/components/PageComponents/Connect/Serial.tsx
  23. 5
      src/components/PageComponents/Messages/ChannelChat.tsx
  24. 6
      src/components/PageComponents/ModuleConfig/DetectionSensor.tsx
  25. 48
      src/components/PageComponents/ModuleConfig/MQTT.tsx
  26. 2
      src/components/PageComponents/ModuleConfig/NeighborInfo.tsx
  27. 3
      src/components/PageComponents/ModuleConfig/Paxcounter.tsx
  28. 2
      src/components/PageLayout.tsx
  29. 22
      src/components/Sidebar.tsx
  30. 10
      src/components/Toaster.tsx
  31. 2
      src/components/UI/Button.tsx
  32. 2
      src/components/UI/Toast.tsx
  33. 62
      src/components/generic/Table/index.tsx
  34. 36
      src/core/hooks/useToast.ts
  35. 8
      src/core/stores/appStore.ts
  36. 4
      src/core/stores/deviceStore.ts
  37. 2
      src/core/subscriptions.ts
  38. 2
      src/core/utils/cn.ts
  39. 5
      src/pages/Config/DeviceConfig.tsx
  40. 2
      src/pages/Config/ModuleConfig.tsx
  41. 36
      src/pages/Map.tsx
  42. 60
      src/pages/Nodes.tsx
  43. 8
      src/validation/config/device.ts
  44. 3
      src/validation/config/display.ts
  45. 8
      src/validation/config/position.ts
  46. 10
      src/validation/moduleConfig/mqtt.ts
  47. 6
      src/validation/moduleConfig/paxcounter.ts
  48. 6
      vercel.json
  49. 4
      vite.config.ts

9
.vscode/settings.json

@ -1,4 +1,7 @@
{ {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true "editor.codeActionsOnSave": {
} "quickfix.biome": "explicit"
},
"editor.formatOnSave": true
}

7
biome.json

@ -1,10 +1,11 @@
{ {
"$schema": "https://biomejs.dev/schemas/1.6.3/schema.json", "$schema": "https://biomejs.dev/schemas/1.7.3/schema.json",
"organizeImports": { "organizeImports": {
"enabled": true "enabled": true
}, },
"files": { "files": {
"ignoreUnknown": true "ignoreUnknown": true,
"ignore": ["vercel.json"]
}, },
"vcs": { "vcs": {
"enabled": true, "enabled": true,
@ -20,7 +21,7 @@
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"all": true "recommended": true
} }
} }
} }

69
package.json

@ -6,8 +6,9 @@
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"build": "tsc && vite build", "build": "tsc && pnpm check && vite build ",
"check": "biome check .", "check": "biome check .",
"check:fix": "pnpm check --write",
"preview": "vite preview", "preview": "vite preview",
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ $(ls ./dist/output/)" "package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ $(ls ./dist/output/)"
}, },
@ -20,64 +21,64 @@
}, },
"homepage": "https://meshtastic.org", "homepage": "https://meshtastic.org",
"dependencies": { "dependencies": {
"@bufbuild/protobuf": "^1.8.0", "@bufbuild/protobuf": "^1.10.0",
"@emeraldpay/hashicon-react": "^0.5.2", "@emeraldpay/hashicon-react": "^0.5.2",
"@meshtastic/js": "2.3.4-0", "@meshtastic/js": "2.3.7-0",
"@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.1.0",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-menubar": "^1.0.4", "@radix-ui/react-menubar": "^1.1.1",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.1.1",
"@turf/turf": "^6.5.0", "@turf/turf": "^6.5.0",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.0", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"immer": "^10.0.4", "immer": "^10.1.1",
"lucide-react": "^0.363.0", "lucide-react": "^0.363.0",
"mapbox-gl": "npm:empty-npm-package@^1.0.0", "mapbox-gl": "npm:empty-npm-package@^1.0.0",
"maplibre-gl": "4.1.2", "maplibre-gl": "4.1.2",
"react": "^18.2.0", "react": "^18.3.1",
"react-dom": "^18.2.0", "react-dom": "^18.3.1",
"react-hook-form": "^7.51.2", "react-hook-form": "^7.52.0",
"react-map-gl": "7.1.7", "react-map-gl": "7.1.7",
"react-qrcode-logo": "^2.9.0", "react-qrcode-logo": "^2.10.0",
"rfc4648": "^1.5.3", "rfc4648": "^1.5.3",
"tailwind-merge": "^2.2.2", "tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"timeago-react": "^3.0.6", "timeago-react": "^3.0.6",
"zustand": "4.5.2" "zustand": "4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.6.3", "@biomejs/biome": "^1.8.2",
"@buf/meshtastic_protobufs.bufbuild_es": "1.8.0-20240325205556-b11811405eea.2", "@buf/meshtastic_protobufs.bufbuild_es": "1.10.0-20240613143006-244927bc441a.1",
"@types/chrome": "^0.0.263", "@types/chrome": "^0.0.263",
"@types/node": "^20.11.30", "@types/node": "^20.14.9",
"@types/react": "^18.2.73", "@types/react": "^18.3.3",
"@types/react-dom": "^18.2.23", "@types/react-dom": "^18.3.0",
"@types/w3c-web-serial": "^1.0.6", "@types/w3c-web-serial": "^1.0.6",
"@types/web-bluetooth": "^0.0.20", "@types/web-bluetooth": "^0.0.20",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"gzipper": "^7.2.0", "gzipper": "^7.2.0",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"rollup-plugin-visualizer": "^5.12.0", "rollup-plugin-visualizer": "^5.12.0",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.4",
"tar": "^6.2.1", "tar": "^6.2.1",
"tslib": "^2.6.2", "tslib": "^2.6.3",
"typescript": "^5.4.3", "typescript": "^5.5.2",
"vite": "^5.2.6", "vite": "^5.3.1",
"vite-plugin-environment": "^1.1.3" "vite-plugin-environment": "^1.1.3"
} }
} }

7016
pnpm-lock.yaml

File diff suppressed because it is too large

4
postcss.config.cjs

@ -1,6 +1,6 @@
module.exports = { module.exports = {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {} autoprefixer: {},
} },
}; };

4
src/components/CommandPalette.tsx

@ -19,7 +19,7 @@ import {
LayersIcon, LayersIcon,
LayoutIcon, LayoutIcon,
LinkIcon, LinkIcon,
LucideIcon, type LucideIcon,
MapIcon, MapIcon,
MessageSquareIcon, MessageSquareIcon,
MoonIcon, MoonIcon,
@ -350,7 +350,7 @@ export const CommandPalette = (): JSX.Element => {
window.addEventListener("keydown", handleKeydown); window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown); return () => window.removeEventListener("keydown", handleKeydown);
}, []); }, [setCommandPaletteOpen]);
return ( return (
<CommandDialog <CommandDialog

2
src/components/DeviceSelector.tsx

@ -9,8 +9,8 @@ import {
LanguagesIcon, LanguagesIcon,
MoonIcon, MoonIcon,
PlusIcon, PlusIcon,
SunIcon,
SearchIcon, SearchIcon,
SunIcon,
} from "lucide-react"; } from "lucide-react";
export const DeviceSelector = (): JSX.Element => { export const DeviceSelector = (): JSX.Element => {

2
src/components/Dialog/DialogManager.tsx

@ -1,9 +1,9 @@
import { RemoveNodeDialog } from "@app/components/Dialog/RemoveNodeDialog.js";
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.js"; import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.js";
import { ImportDialog } from "@components/Dialog/ImportDialog.js"; import { ImportDialog } from "@components/Dialog/ImportDialog.js";
import { QRDialog } from "@components/Dialog/QRDialog.js"; import { QRDialog } from "@components/Dialog/QRDialog.js";
import { RebootDialog } from "@components/Dialog/RebootDialog.js"; import { RebootDialog } from "@components/Dialog/RebootDialog.js";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.js"; import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.js";
import { RemoveNodeDialog } from "@app/components/Dialog/RemoveNodeDialog.js"
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
export const DialogManager = (): JSX.Element => { export const DialogManager = (): JSX.Element => {

36
src/components/Dialog/QRDialog.tsx

@ -9,10 +9,10 @@ import {
} from "@components/UI/Dialog.js"; } from "@components/UI/Dialog.js";
import { Input } from "@components/UI/Input.js"; import { Input } from "@components/UI/Input.js";
import { Label } from "@components/UI/Label.js"; import { Label } from "@components/UI/Label.js";
import { Protobuf, Types } from "@meshtastic/js"; import { Protobuf, type Types } from "@meshtastic/js";
import { fromByteArray } from "base64-js"; import { fromByteArray } from "base64-js";
import { ClipboardIcon } from "lucide-react"; import { ClipboardIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { QRCode } from "react-qrcode-logo"; import { QRCode } from "react-qrcode-logo";
export interface QRDialogProps { export interface QRDialogProps {
@ -32,7 +32,7 @@ export const QRDialog = ({
const [qrCodeUrl, setQrCodeUrl] = useState<string>(""); const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
const [qrCodeAdd, setQrCodeAdd] = useState<boolean>(); const [qrCodeAdd, setQrCodeAdd] = useState<boolean>();
const allChannels = Array.from(channels.values()); const allChannels = useMemo(() => Array.from(channels.values()), [channels]);
useEffect(() => { useEffect(() => {
const channelsToEncode = allChannels const channelsToEncode = allChannels
@ -50,8 +50,10 @@ export const QRDialog = ({
.replace(/\+/g, "-") .replace(/\+/g, "-")
.replace(/\//g, "_"); .replace(/\//g, "_");
setQrCodeUrl(`https://meshtastic.org/e/#${base64}${qrCodeAdd ? "?add=true" : ""}`); setQrCodeUrl(
}, [channels, selectedChannels, qrCodeAdd, loraConfig]); `https://meshtastic.org/e/#${base64}${qrCodeAdd ? "?add=true" : ""}`,
);
}, [allChannels, selectedChannels, qrCodeAdd, loraConfig]);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@ -97,18 +99,26 @@ export const QRDialog = ({
</div> </div>
<div className="flex justify-center"> <div className="flex justify-center">
<button <button
type="button" type="button"
className={ "border-black border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 " + (qrCodeAdd ? "focus:ring-green-800 bg-green-800 text-white" : "focus:ring-slate-400 bg-slate-400 hover:bg-green-600") } className={`border-black border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 ${
qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
onClick={() => setQrCodeAdd(true)} onClick={() => setQrCodeAdd(true)}
> >
Add Channels Add Channels
</button> </button>
<button <button
type="button" type="button"
className={ "border-black border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 " + (!qrCodeAdd ? "focus:ring-green-800 bg-green-800 text-white" : "focus:ring-slate-400 bg-slate-400 hover:bg-green-600") } className={`border-black border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 ${
!qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
onClick={() => setQrCodeAdd(false)} onClick={() => setQrCodeAdd(false)}
> >
Replace Channels Replace Channels
</button> </button>
</div> </div>
</div> </div>

2
src/components/Dialog/RebootDialog.tsx

@ -37,7 +37,7 @@ export const RebootDialog = ({
<Input <Input
type="number" type="number"
value={time} value={time}
onChange={(e) => setTime(parseInt(e.target.value))} onChange={(e) => setTime(Number.parseInt(e.target.value))}
action={{ action={{
icon: ClockIcon, icon: ClockIcon,
onClick() { onClick() {

4
src/components/Dialog/RemoveNodeDialog.tsx

@ -44,7 +44,9 @@ export const RemoveNodeDialog = ({
</form> </form>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}>Remove</Button> <Button variant="destructive" onClick={() => onSubmit()}>
Remove
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

2
src/components/Dialog/ShutdownDialog.tsx

@ -38,7 +38,7 @@ export const ShutdownDialog = ({
<Input <Input
type="number" type="number"
value={time} value={time}
onChange={(e) => setTime(parseInt(e.target.value))} onChange={(e) => setTime(Number.parseInt(e.target.value))}
suffix="Minutes" suffix="Minutes"
/> />
<Button <Button

21
src/components/Form/DynamicForm.tsx

@ -1,17 +1,17 @@
import { import {
DynamicFormField, DynamicFormField,
FieldProps, type FieldProps,
} from "@components/Form/DynamicFormField.js"; } from "@components/Form/DynamicFormField.js";
import { FieldWrapper } from "@components/Form/FormWrapper.js"; import { FieldWrapper } from "@components/Form/FormWrapper.js";
import { Button } from "@components/UI/Button.js"; import { Button } from "@components/UI/Button.js";
import { H4 } from "@components/UI/Typography/H4.js"; import { H4 } from "@components/UI/Typography/H4.js";
import { Subtle } from "@components/UI/Typography/Subtle.js"; import { Subtle } from "@components/UI/Typography/Subtle.js";
import { import {
Control, type Control,
DefaultValues, type DefaultValues,
FieldValues, type FieldValues,
Path, type Path,
SubmitHandler, type SubmitHandler,
useForm, useForm,
} from "react-hook-form"; } from "react-hook-form";
@ -26,7 +26,7 @@ export interface BaseFormBuilderProps<T> {
disabledBy?: DisabledBy<T>[]; disabledBy?: DisabledBy<T>[];
label: string; label: string;
description?: string; description?: string;
properties?: {}; properties?: Record<string, unknown>;
} }
export interface GenericFormElementProps<T extends FieldValues, Y> { export interface GenericFormElementProps<T extends FieldValues, Y> {
@ -94,9 +94,12 @@ export function DynamicForm<T extends FieldValues>({
</div> </div>
{fieldGroup.fields.map((field) => ( {fieldGroup.fields.map((field) => (
<FieldWrapper label={field.label} description={field.description}> <FieldWrapper
key={field.label}
label={field.label}
description={field.description}
>
<DynamicFormField <DynamicFormField
key={field.label}
field={field} field={field}
control={control} control={control}
disabled={isDisabled(field.disabledBy)} disabled={isDisabled(field.disabledBy)}

15
src/components/Form/DynamicFormField.tsx

@ -1,6 +1,15 @@
import { GenericInput, InputFieldProps } from "@components/Form/FormInput.js"; import {
import { SelectFieldProps, SelectInput } from "@components/Form/FormSelect.js"; GenericInput,
import { ToggleFieldProps, ToggleInput } from "@components/Form/FormToggle.js"; type InputFieldProps,
} from "@components/Form/FormInput.js";
import {
type SelectFieldProps,
SelectInput,
} from "@components/Form/FormSelect.js";
import {
type ToggleFieldProps,
ToggleInput,
} from "@components/Form/FormToggle.js";
import type { Control, FieldValues } from "react-hook-form"; import type { Control, FieldValues } from "react-hook-form";
export type FieldProps<T> = export type FieldProps<T> =

2
src/components/Form/FormInput.tsx

@ -4,7 +4,7 @@ import type {
} from "@components/Form/DynamicForm.js"; } from "@components/Form/DynamicForm.js";
import { Input } from "@components/UI/Input.js"; import { Input } from "@components/UI/Input.js";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Controller, FieldValues } from "react-hook-form"; import { Controller, type FieldValues } from "react-hook-form";
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> { export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
type: "text" | "number" | "password"; type: "text" | "number" | "password";

4
src/components/Form/FormSelect.tsx

@ -9,7 +9,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@components/UI/Select.js"; } from "@components/UI/Select.js";
import { Controller, FieldValues } from "react-hook-form"; import { Controller, type FieldValues } from "react-hook-form";
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> { export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "select" | "multiSelect"; type: "select" | "multiSelect";
@ -40,7 +40,7 @@ export function SelectInput<T extends FieldValues>({
: []; : [];
return ( return (
<Select <Select
onValueChange={(e) => onChange(parseInt(e))} onValueChange={(e) => onChange(Number.parseInt(e))}
disabled={disabled} disabled={disabled}
value={value?.toString()} value={value?.toString()}
{...remainingProperties} {...remainingProperties}

4
src/components/Form/FormToggle.tsx

@ -3,8 +3,8 @@ import type {
GenericFormElementProps, GenericFormElementProps,
} from "@components/Form/DynamicForm.js"; } from "@components/Form/DynamicForm.js";
import { Switch } from "@components/UI/Switch.js"; import { Switch } from "@components/UI/Switch.js";
import { ChangeEvent } from "react"; import type { ChangeEvent } from "react";
import { Controller, FieldValues } from "react-hook-form"; import { Controller, type FieldValues } from "react-hook-form";
export interface ToggleFieldProps<T> extends BaseFormBuilderProps<T> { export interface ToggleFieldProps<T> extends BaseFormBuilderProps<T> {
type: "toggle"; type: "toggle";

52
src/components/PageComponents/Channel.tsx

@ -1,4 +1,4 @@
import type{ ChannelValidation } from "@app/validation/channel.js"; import type { ChannelValidation } from "@app/validation/channel.js";
import { DynamicForm } from "@components/Form/DynamicForm.js"; import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useToast } from "@core/hooks/useToast.js"; import { useToast } from "@core/hooks/useToast.js";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
@ -20,8 +20,12 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
...data.settings, ...data.settings,
psk: toByteArray(data.settings.psk ?? ""), psk: toByteArray(data.settings.psk ?? ""),
moduleSettings: { moduleSettings: {
positionPrecision: data.settings.positionEnabled ? data.settings.preciseLocation ? 32 : data.settings.positionPrecision : 0, positionPrecision: data.settings.positionEnabled
} ? data.settings.preciseLocation
? 32
: data.settings.positionPrecision
: 0,
},
}, },
}); });
connection?.setChannel(channel).then(() => { connection?.setChannel(channel).then(() => {
@ -43,9 +47,16 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
settings: { settings: {
...channel?.settings, ...channel?.settings,
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)), psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
positionEnabled: channel?.settings?.moduleSettings?.positionPrecision != undefined && channel?.settings?.moduleSettings?.positionPrecision > 0, positionEnabled:
preciseLocation: channel?.settings?.moduleSettings?.positionPrecision == 32, channel?.settings?.moduleSettings?.positionPrecision !==
positionPrecision: channel?.settings?.moduleSettings?.positionPrecision == undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision undefined &&
channel?.settings?.moduleSettings?.positionPrecision > 0,
preciseLocation:
channel?.settings?.moduleSettings?.positionPrecision === 32,
positionPrecision:
channel?.settings?.moduleSettings?.positionPrecision === undefined
? 10
: channel?.settings?.moduleSettings?.positionPrecision,
}, },
}, },
}} }}
@ -111,9 +122,32 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
description: description:
"If not sharing precise location, position shared on channel will be accurate within this distance", "If not sharing precise location, position shared on channel will be accurate within this distance",
properties: { properties: {
enumValue: config.display?.units == 0 ? enumValue:
{ "Within 23 km":10, "Within 12 km":11, "Within 5.8 km":12, "Within 2.9 km":13, "Within 1.5 km":14, "Within 700 m":15, "Within 350 m":16, "Within 200 m":17, "Within 90 m":18, "Within 50 m":19 } : config.display?.units === 0
{ "Within 15 miles":10, "Within 7.3 miles":11, "Within 3.6 miles":12, "Within 1.8 miles":13, "Within 0.9 miles":14, "Within 0.5 miles":15, "Within 0.2 miles":16, "Within 600 feet":17, "Within 300 feet":18, "Within 150 feet":19 } ? {
"Within 23 km": 10,
"Within 12 km": 11,
"Within 5.8 km": 12,
"Within 2.9 km": 13,
"Within 1.5 km": 14,
"Within 700 m": 15,
"Within 350 m": 16,
"Within 200 m": 17,
"Within 90 m": 18,
"Within 50 m": 19,
}
: {
"Within 15 miles": 10,
"Within 7.3 miles": 11,
"Within 3.6 miles": 12,
"Within 1.8 miles": 13,
"Within 0.9 miles": 14,
"Within 0.5 miles": 15,
"Within 0.2 miles": 16,
"Within 600 feet": 17,
"Within 300 feet": 18,
"Within 150 feet": 19,
},
}, },
}, },
], ],

2
src/components/PageComponents/Config/LoRa.tsx

@ -41,7 +41,7 @@ export const LoRa = (): JSX.Element => {
label: "Hop Limit", label: "Hop Limit",
description: "Maximum number of hops", description: "Maximum number of hops",
properties: { properties: {
enumValue: {1:1, 2:2, 3:3, 4:4, 5:5, 6:6, 7:7} enumValue: { 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7 },
}, },
}, },
{ {

2
src/components/PageComponents/Connect/BLE.tsx

@ -1,4 +1,4 @@
import { TabElementProps } from "@app/components/Dialog/NewDeviceDialog"; import type { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import { Button } from "@components/UI/Button.js"; import { Button } from "@components/UI/Button.js";
import { Mono } from "@components/generic/Mono.js"; import { Mono } from "@components/generic/Mono.js";
import { useAppStore } from "@core/stores/appStore.js"; import { useAppStore } from "@core/stores/appStore.js";

22
src/components/PageComponents/Connect/HTTP.tsx

@ -1,4 +1,4 @@
import { TabElementProps } from "@app/components/Dialog/NewDeviceDialog"; import type { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import { Button } from "@components/UI/Button.js"; import { Button } from "@components/UI/Button.js";
import { Input } from "@components/UI/Input.js"; import { Input } from "@components/UI/Input.js";
import { Label } from "@components/UI/Label.js"; import { Label } from "@components/UI/Label.js";
@ -8,6 +8,7 @@ import { useDeviceStore } from "@core/stores/deviceStore.js";
import { subscribeAll } from "@core/subscriptions.js"; import { subscribeAll } from "@core/subscriptions.js";
import { randId } from "@core/utils/randId.js"; import { randId } from "@core/utils/randId.js";
import { HttpConnection } from "@meshtastic/js"; import { HttpConnection } from "@meshtastic/js";
import { useState } from "react";
import { Controller, useForm, useWatch } from "react-hook-form"; import { Controller, useForm, useWatch } from "react-hook-form";
export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => { export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
@ -19,7 +20,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
}>({ }>({
defaultValues: { defaultValues: {
ip: ["client.meshtastic.org", "localhost"].includes( ip: ["client.meshtastic.org", "localhost"].includes(
window.location.hostname window.location.hostname,
) )
? "meshtastic.local" ? "meshtastic.local"
: window.location.hostname, : window.location.hostname,
@ -33,10 +34,13 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
defaultValue: location.protocol === "https:", defaultValue: location.protocol === "https:",
}); });
const [connectionInProgress, setConnectionInProgress] = useState(false);
const onSubmit = handleSubmit(async (data) => { const onSubmit = handleSubmit(async (data) => {
setConnectionInProgress(true);
const id = randId(); const id = randId();
const device = addDevice(id); const device = addDevice(id);
setSelectedDevice(id);
const connection = new HttpConnection(id); const connection = new HttpConnection(id);
// TODO: Promise never resolves // TODO: Promise never resolves
await connection.connect({ await connection.connect({
@ -44,9 +48,10 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
fetchInterval: 2000, fetchInterval: 2000,
tls: data.tls, tls: data.tls,
}); });
setSelectedDevice(id);
device.addConnection(connection); device.addConnection(connection);
subscribeAll(device, connection); subscribeAll(device, connection);
closeDialog(); closeDialog();
}); });
@ -58,6 +63,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
// label="IP Address/Hostname" // label="IP Address/Hostname"
prefix={tlsEnabled ? "https://" : "http://"} prefix={tlsEnabled ? "https://" : "http://"}
placeholder="000.000.000.000 / meshtastic.local" placeholder="000.000.000.000 / meshtastic.local"
disabled={connectionInProgress}
{...register("ip")} {...register("ip")}
/> />
<Controller <Controller
@ -69,7 +75,9 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
<Switch <Switch
// label="Use TLS" // label="Use TLS"
// description="Description" // description="Description"
disabled={location.protocol === "https:"} disabled={
location.protocol === "https:" || connectionInProgress
}
checked={value} checked={value}
{...rest} {...rest}
/> />
@ -77,8 +85,8 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
)} )}
/> />
</div> </div>
<Button type="submit"> <Button type="submit" disabled={connectionInProgress}>
<span>Connect</span> <span>{connectionInProgress ? "Connecting..." : "Connect"}</span>
</Button> </Button>
</form> </form>
); );

31
src/components/PageComponents/Connect/Serial.tsx

@ -1,4 +1,4 @@
import { TabElementProps } from "@app/components/Dialog/NewDeviceDialog"; import type { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import { Button } from "@components/UI/Button.js"; import { Button } from "@components/UI/Button.js";
import { Mono } from "@components/generic/Mono.js"; import { Mono } from "@components/generic/Mono.js";
import { useAppStore } from "@core/stores/appStore.js"; import { useAppStore } from "@core/stores/appStore.js";
@ -48,19 +48,22 @@ export const Serial = ({ closeDialog }: TabElementProps): JSX.Element => {
return ( return (
<div className="flex w-full flex-col gap-2 p-4"> <div className="flex w-full flex-col gap-2 p-4">
<div className="flex h-48 flex-col gap-2 overflow-y-auto"> <div className="flex h-48 flex-col gap-2 overflow-y-auto">
{serialPorts.map((port, index) => ( {serialPorts.map((port, index) => {
<Button const { usbProductId, usbVendorId } = port.getInfo();
key={index} return (
disabled={port.readable !== null} <Button
onClick={async () => { key={`${usbVendorId ?? "UNK"}-${usbProductId ?? "UNK"}-${index}`}
await onConnect(port); disabled={port.readable !== null}
}} onClick={async () => {
> await onConnect(port);
{`# ${index} - ${port.getInfo().usbVendorId ?? "UNK"} - ${ }}
port.getInfo().usbProductId ?? "UNK" >
}`} {`# ${index} - ${usbVendorId ?? "UNK"} - ${
</Button> usbProductId ?? "UNK"
))} }`}
</Button>
);
})}
{serialPorts.length === 0 && ( {serialPorts.length === 0 && (
<Mono className="m-auto select-none">No devices paired yet.</Mono> <Mono className="m-auto select-none">No devices paired yet.</Mono>
)} )}

5
src/components/PageComponents/Messages/ChannelChat.tsx

@ -1,5 +1,8 @@
import { Subtle } from "@app/components/UI/Typography/Subtle.js"; import { Subtle } from "@app/components/UI/Typography/Subtle.js";
import { MessageWithState, useDevice } from "@app/core/stores/deviceStore.js"; import {
type MessageWithState,
useDevice,
} from "@app/core/stores/deviceStore.js";
import { Message } from "@components/PageComponents/Messages/Message.js"; import { Message } from "@components/PageComponents/Messages/Message.js";
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.js"; import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.js";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.js"; import { MessageInput } from "@components/PageComponents/Messages/MessageInput.js";

6
src/components/PageComponents/ModuleConfig/DetectionSensor.tsx

@ -38,9 +38,9 @@ export const DetectionSensor = (): JSX.Element => {
label: "Minimum Broadcast Seconds", label: "Minimum Broadcast Seconds",
description: description:
"The interval in seconds of how often we can send a message to the mesh when a state change is detected", "The interval in seconds of how often we can send a message to the mesh when a state change is detected",
properties: { properties: {
suffix: "Seconds", suffix: "Seconds",
}, },
disabledBy: [ disabledBy: [
{ {
fieldName: "enabled", fieldName: "enabled",

48
src/components/PageComponents/ModuleConfig/MQTT.tsx

@ -4,14 +4,20 @@ import { DynamicForm } from "@components/Form/DynamicForm.js";
import { Protobuf } from "@meshtastic/js"; import { Protobuf } from "@meshtastic/js";
export const MQTT = (): JSX.Element => { export const MQTT = (): JSX.Element => {
const { moduleConfig, setWorkingModuleConfig } = useDevice(); const { config, moduleConfig, setWorkingModuleConfig } = useDevice();
const onSubmit = (data: MqttValidation) => { const onSubmit = (data: MqttValidation) => {
setWorkingModuleConfig( setWorkingModuleConfig(
new Protobuf.ModuleConfig.ModuleConfig({ new Protobuf.ModuleConfig.ModuleConfig({
payloadVariant: { payloadVariant: {
case: "mqtt", case: "mqtt",
value: data, value: {
...data,
mapReportSettings:
new Protobuf.ModuleConfig.ModuleConfig_MapReportSettings(
data.mapReportSettings,
),
},
}, },
}), }),
); );
@ -70,7 +76,8 @@ export const MQTT = (): JSX.Element => {
type: "toggle", type: "toggle",
name: "encryptionEnabled", name: "encryptionEnabled",
label: "Encryption Enabled", label: "Encryption Enabled",
description: "Enable or disable MQTT encryption", description:
"Enable or disable MQTT encryption. Note: All messages are sent to the MQTT broker unencrypted if this option is not enabled, even when your uplink channels have encryption keys set. This includes position data.",
disabledBy: [ disabledBy: [
{ {
fieldName: "enabled", fieldName: "enabled",
@ -151,10 +158,39 @@ export const MQTT = (): JSX.Element => {
], ],
}, },
{ {
type: "number", type: "select",
name: "mapReportSettings.positionPrecision", name: "mapReportSettings.positionPrecision",
label: "Position Precision", label: "Approximate Location",
description: "Precision of the position", description:
"Position shared will be accurate within this distance",
properties: {
enumValue:
config.display?.units === 0
? {
"Within 23 km": 10,
"Within 12 km": 11,
"Within 5.8 km": 12,
"Within 2.9 km": 13,
"Within 1.5 km": 14,
"Within 700 m": 15,
"Within 350 m": 16,
"Within 200 m": 17,
"Within 90 m": 18,
"Within 50 m": 19,
}
: {
"Within 15 miles": 10,
"Within 7.3 miles": 11,
"Within 3.6 miles": 12,
"Within 1.8 miles": 13,
"Within 0.9 miles": 14,
"Within 0.5 miles": 15,
"Within 0.2 miles": 16,
"Within 600 feet": 17,
"Within 300 feet": 18,
"Within 150 feet": 19,
},
},
disabledBy: [ disabledBy: [
{ {
fieldName: "enabled", fieldName: "enabled",

2
src/components/PageComponents/ModuleConfig/NeighborInfo.tsx

@ -36,7 +36,7 @@ export const NeighborInfo = (): JSX.Element => {
type: "number", type: "number",
name: "updateInterval", name: "updateInterval",
label: "Update Interval", label: "Update Interval",
description: description:
"Interval in seconds of how often we should try to send our Neighbor Info to the mesh", "Interval in seconds of how often we should try to send our Neighbor Info to the mesh",
properties: { properties: {
suffix: "Seconds", suffix: "Seconds",

3
src/components/PageComponents/ModuleConfig/Paxcounter.tsx

@ -36,7 +36,8 @@ export const Paxcounter = (): JSX.Element => {
type: "number", type: "number",
name: "paxcounterUpdateInterval", name: "paxcounterUpdateInterval",
label: "Update Interval (seconds)", label: "Update Interval (seconds)",
description: "How long to wait between sending paxcounter packets", description:
"How long to wait between sending paxcounter packets",
properties: { properties: {
suffix: "Seconds", suffix: "Seconds",
}, },

2
src/components/PageLayout.tsx

@ -1,5 +1,5 @@
import { cn } from "@app/core/utils/cn.js"; import { cn } from "@app/core/utils/cn.js";
import { AlignLeftIcon, LucideIcon } from "lucide-react"; import { AlignLeftIcon, type LucideIcon } from "lucide-react";
export interface PageLayoutProps { export interface PageLayoutProps {
label: string; label: string;

22
src/components/Sidebar.tsx

@ -4,15 +4,16 @@ import { Subtle } from "@components/UI/Typography/Subtle.js";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import type { Page } from "@core/stores/deviceStore.js"; import type { Page } from "@core/stores/deviceStore.js";
import { import {
BatteryMediumIcon,
CpuIcon,
EditIcon, EditIcon,
LayersIcon, LayersIcon,
LucideIcon, type LucideIcon,
MapIcon, MapIcon,
MessageSquareIcon, MessageSquareIcon,
SettingsIcon, SettingsIcon,
UsersIcon, UsersIcon,
ZapIcon, ZapIcon,
BatteryMediumIcon
} from "lucide-react"; } from "lucide-react";
export interface SidebarProps { export interface SidebarProps {
@ -20,8 +21,9 @@ export interface SidebarProps {
} }
export const Sidebar = ({ children }: SidebarProps): JSX.Element => { export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
const { hardware, nodes } = useDevice(); const { hardware, nodes, metadata } = useDevice();
const myNode = nodes.get(hardware.myNodeNum); const myNode = nodes.get(hardware.myNodeNum);
const myMetadata = metadata.get(0);
const { activePage, setActivePage, setDialogOpen } = useDevice(); const { activePage, setActivePage, setDialogOpen } = useDevice();
interface NavLink { interface NavLink {
@ -77,12 +79,18 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
</div> </div>
<div className="px-8 pb-6"> <div className="px-8 pb-6">
<div className="flex items-center"> <div className="flex items-center">
<BatteryMediumIcon size={24} viewBox={'0 0 28 24'}/> <BatteryMediumIcon size={24} viewBox={"0 0 28 24"} />
<Subtle>{myNode?.deviceMetrics?.batteryLevel ?? "UNK"}%</Subtle> <Subtle>{myNode?.deviceMetrics?.batteryLevel ?? "UNK"}%</Subtle>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<ZapIcon size={24} viewBox={'0 0 36 24'}/> <ZapIcon size={24} viewBox={"0 0 36 24"} />
<Subtle>{myNode?.deviceMetrics?.voltage.toPrecision(3) ?? "UNK"} volts</Subtle> <Subtle>
{myNode?.deviceMetrics?.voltage.toPrecision(3) ?? "UNK"} volts
</Subtle>
</div>
<div className="flex items-center">
<CpuIcon size={24} viewBox={"0 0 36 24"} />
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle>
</div> </div>
</div> </div>

10
src/components/Toaster.tsx

@ -17,8 +17,14 @@ export function Toaster() {
{toasts.map(({ id, title, description, action, ...props }) => ( {toasts.map(({ id, title, description, action, ...props }) => (
<Toast key={id} {...props}> <Toast key={id} {...props}>
<div className="grid gap-1"> <div className="grid gap-1">
{title && <ToastTitle className="dark:text-white">{title}</ToastTitle>} {title && (
{description && <ToastDescription className="dark:text-white-400">{description}</ToastDescription>} <ToastTitle className="dark:text-white">{title}</ToastTitle>
)}
{description && (
<ToastDescription className="dark:text-white-400">
{description}
</ToastDescription>
)}
</div> </div>
{action} {action}
<ToastClose /> <ToastClose />

2
src/components/UI/Button.tsx

@ -1,4 +1,4 @@
import { VariantProps, cva } from "class-variance-authority"; import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react"; import * as React from "react";
import { cn } from "@core/utils/cn.js"; import { cn } from "@core/utils/cn.js";

2
src/components/UI/Toast.tsx

@ -1,5 +1,5 @@
import * as ToastPrimitives from "@radix-ui/react-toast"; import * as ToastPrimitives from "@radix-ui/react-toast";
import { VariantProps, cva } from "class-variance-authority"; import { type VariantProps, cva } from "class-variance-authority";
import { X } from "lucide-react"; import { X } from "lucide-react";
import * as React from "react"; import * as React from "react";

62
src/components/generic/Table/index.tsx

@ -1,4 +1,5 @@
import { ChevronUpIcon } from "lucide-react"; import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import React, { useState } from "react";
export interface TableProps { export interface TableProps {
headings: Heading[]; headings: Heading[];
@ -12,6 +13,49 @@ export interface Heading {
} }
export const Table = ({ headings, rows }: TableProps): JSX.Element => { export const Table = ({ headings, rows }: TableProps): JSX.Element => {
const [sortColumn, setSortColumn] = useState<string | null>("Last Heard");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const headingSort = (title: string) => {
if (sortColumn === title) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortColumn(title);
setSortOrder("asc");
}
};
const sortedRows = rows.slice().sort((a, b) => {
if (!sortColumn) return 0;
const columnIndex = headings.findIndex((h) => h.title === sortColumn);
const aValue = a[columnIndex].props.children;
const bValue = b[columnIndex].props.children;
// Custom comparison for 'Last Heard' column
if (sortColumn === "Last Heard") {
const aTimestamp = aValue.props.timestamp ?? 0;
const bTimestamp = bValue.props.timestamp ?? 0;
if (aTimestamp < bTimestamp) {
return sortOrder === "asc" ? -1 : 1;
}
if (aTimestamp > bTimestamp) {
return sortOrder === "asc" ? 1 : -1;
}
return 0;
}
// Default comparison for other columns
if (aValue < bValue) {
return sortOrder === "asc" ? -1 : 1;
}
if (aValue > bValue) {
return sortOrder === "asc" ? 1 : -1;
}
return 0;
});
return ( return (
<table className="min-w-full"> <table className="min-w-full">
<thead className="bg-backgroundPrimary text-sm font-semibold text-textPrimary"> <thead className="bg-backgroundPrimary text-sm font-semibold text-textPrimary">
@ -25,11 +69,19 @@ export const Table = ({ headings, rows }: TableProps): JSX.Element => {
? "cursor-pointer hover:brightness-hover active:brightness-press" ? "cursor-pointer hover:brightness-hover active:brightness-press"
: "" : ""
}`} }`}
onClick={() => heading.sortable && headingSort(heading.title)}
onKeyUp={() => heading.sortable && headingSort(heading.title)}
> >
<div className="flex gap-2"> <div className="flex gap-2">
{heading.title} {heading.title}
{heading.sortable && ( {sortColumn === heading.title && (
<ChevronUpIcon size={16} className="my-auto" /> <>
{sortOrder === "asc" ? (
<ChevronUpIcon size={16} />
) : (
<ChevronDownIcon size={16} />
)}
</>
)} )}
</div> </div>
</th> </th>
@ -37,10 +89,12 @@ export const Table = ({ headings, rows }: TableProps): JSX.Element => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row, index) => ( {sortedRows.map((row, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: TODO: Once this table is sortable, this should get fixed.
<tr key={index}> <tr key={index}>
{row.map((item, index) => ( {row.map((item, index) => (
<td <td
// biome-ignore lint/suspicious/noArrayIndexKey: OK because column order never changes.
key={index} key={index}
className="whitespace-nowrap py-2 text-sm text-textSecondary first:pl-2" className="whitespace-nowrap py-2 text-sm text-textSecondary first:pl-2"
> >

36
src/core/hooks/useToast.ts

@ -1,4 +1,4 @@
import { ReactNode, useEffect, useState } from "react"; import { type ReactNode, useSyncExternalStore } from "react";
import type { ToastActionElement, ToastProps } from "@components/UI/Toast.js"; import type { ToastActionElement, ToastProps } from "@components/UI/Toast.js";
@ -92,9 +92,9 @@ export const reducer = (state: State, action: Action): State => {
if (toastId) { if (toastId) {
addToRemoveQueue(toastId); addToRemoveQueue(toastId);
} else { } else {
state.toasts.forEach((toast) => { for (const toast of state.toasts) {
addToRemoveQueue(toast.id); addToRemoveQueue(toast.id);
}); }
} }
return { return {
@ -130,9 +130,9 @@ let memoryState: State = { toasts: [] };
function dispatch(action: Action) { function dispatch(action: Action) {
memoryState = reducer(memoryState, action); memoryState = reducer(memoryState, action);
listeners.forEach((listener) => { for (const listener of listeners) {
listener(memoryState); listener(memoryState);
}); }
} }
type Toast = Omit<ToasterToast, "id">; type Toast = Omit<ToasterToast, "id">;
@ -166,18 +166,22 @@ function toast({ ...props }: Toast) {
}; };
} }
const subscribe = (listener: () => void) => {
listeners.push(listener);
return function unsubscribe() {
const index = listeners.indexOf(listener);
if (index > -1) {
listeners.splice(index, 1);
}
};
};
const getState = () => {
return memoryState;
};
function useToast() { function useToast() {
const [state, setState] = useState<State>(memoryState); const state = useSyncExternalStore(subscribe, getState);
useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return { return {
...state, ...state,

8
src/core/stores/appStore.ts

@ -50,7 +50,10 @@ export const useAppStore = create<AppState>()((set) => ({
currentPage: "messages", currentPage: "messages",
rasterSources: [], rasterSources: [],
commandPaletteOpen: false, commandPaletteOpen: false,
darkMode: window.matchMedia("(prefers-color-scheme: dark)").matches, darkMode:
localStorage.getItem("theme-dark") !== null
? localStorage.getItem("theme-dark") === "true"
: window.matchMedia("(prefers-color-scheme: dark)").matches,
accent: "orange", accent: "orange",
connectDialogOpen: false, connectDialogOpen: false,
nodeNumToBeRemoved: 0, nodeNumToBeRemoved: 0,
@ -96,6 +99,7 @@ export const useAppStore = create<AppState>()((set) => ({
); );
}, },
setDarkMode: (enabled: boolean) => { setDarkMode: (enabled: boolean) => {
localStorage.setItem("theme-dark", enabled.toString());
set( set(
produce<AppState>((draft) => { produce<AppState>((draft) => {
draft.darkMode = enabled; draft.darkMode = enabled;
@ -104,7 +108,7 @@ export const useAppStore = create<AppState>()((set) => ({
}, },
setNodeNumToBeRemoved: (nodeNum) => setNodeNumToBeRemoved: (nodeNum) =>
set((state) => ({ set((state) => ({
nodeNumToBeRemoved: nodeNum nodeNumToBeRemoved: nodeNum,
})), })),
setAccent(color) { setAccent(color) {
set( set(

4
src/core/stores/deviceStore.ts

@ -530,8 +530,8 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
return; return;
} }
device.nodes.delete(nodeNum); device.nodes.delete(nodeNum);
}) }),
) );
}, },
setMessageState: ( setMessageState: (
type: "direct" | "broadcast", type: "direct" | "broadcast",

2
src/core/subscriptions.ts

@ -1,5 +1,5 @@
import type { Device } from "@core/stores/deviceStore.js"; import type { Device } from "@core/stores/deviceStore.js";
import { Protobuf, Types } from "@meshtastic/js"; import { Protobuf, type Types } from "@meshtastic/js";
export const subscribeAll = ( export const subscribeAll = (
device: Device, device: Device,

2
src/core/utils/cn.ts

@ -1,4 +1,4 @@
import { ClassValue, clsx } from "clsx"; import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {

5
src/pages/Config/DeviceConfig.tsx

@ -53,10 +53,7 @@ export const DeviceConfig = (): JSX.Element => {
<Tabs defaultValue="Device"> <Tabs defaultValue="Device">
<TabsList> <TabsList>
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsTrigger <TabsTrigger key={tab.label} value={tab.label}>
key={tab.label}
value={tab.label}
>
{tab.label} {tab.label}
</TabsTrigger> </TabsTrigger>
))} ))}

2
src/pages/Config/ModuleConfig.tsx

@ -5,11 +5,11 @@ import { Audio } from "@components/PageComponents/ModuleConfig/Audio.js";
import { CannedMessage } from "@components/PageComponents/ModuleConfig/CannedMessage.js"; import { CannedMessage } from "@components/PageComponents/ModuleConfig/CannedMessage.js";
import { ExternalNotification } from "@components/PageComponents/ModuleConfig/ExternalNotification.js"; import { ExternalNotification } from "@components/PageComponents/ModuleConfig/ExternalNotification.js";
import { MQTT } from "@components/PageComponents/ModuleConfig/MQTT.js"; import { MQTT } from "@components/PageComponents/ModuleConfig/MQTT.js";
import { Paxcounter } from "@components/PageComponents/ModuleConfig/Paxcounter.js";
import { RangeTest } from "@components/PageComponents/ModuleConfig/RangeTest.js"; import { RangeTest } from "@components/PageComponents/ModuleConfig/RangeTest.js";
import { Serial } from "@components/PageComponents/ModuleConfig/Serial.js"; import { Serial } from "@components/PageComponents/ModuleConfig/Serial.js";
import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.js"; import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.js";
import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.js"; import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.js";
import { Paxcounter } from "@components/PageComponents/ModuleConfig/Paxcounter.js";
import { import {
Tabs, Tabs,
TabsContent, TabsContent,

36
src/pages/Map.tsx

@ -14,20 +14,20 @@ import {
ZoomInIcon, ZoomInIcon,
ZoomOutIcon, ZoomOutIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Marker, useMap } from "react-map-gl"; import { Marker, useMap } from "react-map-gl";
import MapGl from "react-map-gl/maplibre"; import MapGl from "react-map-gl/maplibre";
export const MapPage = (): JSX.Element => { export const MapPage = (): JSX.Element => {
const { nodes, waypoints } = useDevice(); const { nodes, waypoints } = useDevice();
const { rasterSources } = useAppStore(); const { rasterSources, darkMode } = useAppStore();
const { default: map } = useMap(); const { default: map } = useMap();
const [zoom, setZoom] = useState(0); const [zoom, setZoom] = useState(0);
const allNodes = Array.from(nodes.values()); const allNodes = Array.from(nodes.values());
const getBBox = () => { const getBBox = useCallback(() => {
if (!map) { if (!map) {
return; return;
} }
@ -64,7 +64,7 @@ export const MapPage = (): JSX.Element => {
if (center) { if (center) {
map.easeTo(center); map.easeTo(center);
} }
}; }, [allNodes, map]);
useEffect(() => { useEffect(() => {
map?.on("zoom", () => { map?.on("zoom", () => {
@ -128,6 +128,11 @@ export const MapPage = (): JSX.Element => {
attributionControl={false} attributionControl={false}
renderWorldCopies={false} renderWorldCopies={false}
maxPitch={0} maxPitch={0}
style={{
filter: darkMode
? "brightness(0.6) invert(1) contrast(3) hue-rotate(200deg) saturate(0.3) brightness(0.7)"
: "",
}}
dragRotate={false} dragRotate={false}
touchZoomRotate={false} touchZoomRotate={false}
initialViewState={{ initialViewState={{
@ -160,20 +165,19 @@ export const MapPage = (): JSX.Element => {
key={node.num} key={node.num}
longitude={node.position.longitudeI / 1e7} longitude={node.position.longitudeI / 1e7}
latitude={node.position.latitudeI / 1e7} latitude={node.position.latitudeI / 1e7}
style={{ filter: darkMode ? "invert(1)" : "" }}
anchor="bottom" anchor="bottom"
onClick={() => {
map?.easeTo({
zoom: 12,
center: [
(node.position?.longitudeI ?? 0) / 1e7,
(node.position?.latitudeI ?? 0) / 1e7,
],
});
}}
> >
<div <div className="flex cursor-pointer gap-2 rounded-md border bg-backgroundPrimary p-1.5">
className="flex cursor-pointer gap-2 rounded-md border bg-backgroundPrimary p-1.5"
onClick={() => {
map?.easeTo({
zoom: 12,
center: [
(node.position?.longitudeI ?? 0) / 1e7,
(node.position?.latitudeI ?? 0) / 1e7,
],
});
}}
>
<Hashicon value={node.num.toString()} size={22} /> <Hashicon value={node.num.toString()} size={22} />
<Subtle className={cn(zoom < 12 && "hidden")}> <Subtle className={cn(zoom < 12 && "hidden")}>
{node.user?.longName} {node.user?.longName}

60
src/pages/Nodes.tsx

@ -1,15 +1,15 @@
import { useAppStore } from "@app/core/stores/appStore";
import { Sidebar } from "@components/Sidebar.js"; import { Sidebar } from "@components/Sidebar.js";
import { Button } from "@components/UI/Button.js";
import { Mono } from "@components/generic/Mono.js"; import { Mono } from "@components/generic/Mono.js";
import { Table } from "@components/generic/Table/index.js"; import { Table } from "@components/generic/Table/index.js";
import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.js"; import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.js";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react"; import { Hashicon } from "@emeraldpay/hashicon-react";
import { Protobuf } from "@meshtastic/js"; import { Protobuf } from "@meshtastic/js";
import { base16 } from "rfc4648";
import { Button } from "@components/UI/Button.js";
import { TrashIcon } from "lucide-react"; import { TrashIcon } from "lucide-react";
import { useAppStore } from "@app/core/stores/appStore"; import { Fragment } from "react";
import { base16 } from "rfc4648";
export interface DeleteNoteDialogProps { export interface DeleteNoteDialogProps {
open: boolean; open: boolean;
@ -40,8 +40,8 @@ export const NodesPage = (): JSX.Element => {
{ title: "Remove", type: "normal", sortable: false }, { title: "Remove", type: "normal", sortable: false },
]} ]}
rows={filteredNodes.map((node) => [ rows={filteredNodes.map((node) => [
<Hashicon size={24} value={node.num.toString()} />, <Hashicon key="icon" size={24} value={node.num.toString()} />,
<h1> <h1 key="header">
{node.user?.longName ?? {node.user?.longName ??
(node.user?.macaddr (node.user?.macaddr
? `Meshtastic ${base16 ? `Meshtastic ${base16
@ -50,34 +50,46 @@ export const NodesPage = (): JSX.Element => {
: `UNK: ${node.num}`)} : `UNK: ${node.num}`)}
</h1>, </h1>,
<Mono>{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}</Mono>, <Mono key="model">
<Mono> {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
</Mono>,
<Mono key="addr">
{base16 {base16
.stringify(node.user?.macaddr ?? []) .stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g) .match(/.{1,2}/g)
?.join(":") ?? "UNK"} ?.join(":") ?? "UNK"}
</Mono>, </Mono>,
node.lastHeard === 0 ? ( <Fragment key="lastHeard">
<p>Never</p> {node.lastHeard === 0 ? (
) : ( <p>Never</p>
<TimeAgo timestamp={node.lastHeard * 1000} /> ) : (
), <TimeAgo timestamp={node.lastHeard * 1000} />
<Mono> )}
</Fragment>,
<Mono key="snr">
{node.snr}db/ {node.snr}db/
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/ {Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
{(node.snr + 10) * 5}raw {(node.snr + 10) * 5}raw
</Mono>, </Mono>,
<Mono> <Mono key="hops">
{node.lastHeard != 0 ? {node.lastHeard !== 0
(node.viaMqtt === false && node.hopsAway === 0 ? node.viaMqtt === false && node.hopsAway === 0
? "Direct": node.hopsAway.toString() + " hops away") ? "Direct"
: `${node.hopsAway.toString()} hops away`
: "-"} : "-"}
{node.viaMqtt === true? ", via MQTT": ""} {node.viaMqtt === true ? ", via MQTT" : ""}
</Mono>, </Mono>,
<Button variant="destructive" onClick={() => { <Button
setNodeNumToBeRemoved(node.num); key="remove"
setDialogOpen("nodeRemoval", true) variant="destructive"
}}><TrashIcon />Remove</Button> onClick={() => {
setNodeNumToBeRemoved(node.num);
setDialogOpen("nodeRemoval", true);
}}
>
<TrashIcon />
Remove
</Button>,
])} ])}
/> />
</div> </div>

8
src/validation/config/device.ts

@ -1,6 +1,6 @@
import type { Message } from "@bufbuild/protobuf"; import type { Message } from "@bufbuild/protobuf";
import { Protobuf } from "@meshtastic/js"; import { Protobuf } from "@meshtastic/js";
import { IsBoolean, IsEnum, IsInt } from "class-validator"; import { IsBoolean, IsEnum, IsInt, IsString } from "class-validator";
export class DeviceValidation export class DeviceValidation
implements Omit<Protobuf.Config.Config_DeviceConfig, keyof Message> implements Omit<Protobuf.Config.Config_DeviceConfig, keyof Message>
@ -34,4 +34,10 @@ export class DeviceValidation
@IsBoolean() @IsBoolean()
disableTripleClick: boolean; disableTripleClick: boolean;
@IsBoolean()
ledHeartbeatDisabled: boolean;
@IsString()
tzdef: string;
} }

3
src/validation/config/display.ts

@ -34,4 +34,7 @@ export class DisplayValidation
@IsBoolean() @IsBoolean()
wakeOnTapOrMotion: boolean; wakeOnTapOrMotion: boolean;
@IsEnum(Protobuf.Config.Config_DisplayConfig_CompassOrientation)
compassOrientation: Protobuf.Config.Config_DisplayConfig_CompassOrientation;
} }

8
src/validation/config/position.ts

@ -2,10 +2,14 @@ import type { Message } from "@bufbuild/protobuf";
import { Protobuf } from "@meshtastic/js"; import { Protobuf } from "@meshtastic/js";
import { IsArray, IsBoolean, IsEnum, IsInt } from "class-validator"; import { IsArray, IsBoolean, IsEnum, IsInt } from "class-validator";
const DeprecatedPositionValidationFields = ['gpsEnabled', 'gpsAttemptTime']; const DeprecatedPositionValidationFields = ["gpsEnabled", "gpsAttemptTime"];
export class PositionValidation export class PositionValidation
implements Omit<Protobuf.Config.Config_PositionConfig, keyof Message | typeof DeprecatedPositionValidationFields[number]> implements
Omit<
Protobuf.Config.Config_PositionConfig,
keyof Message | (typeof DeprecatedPositionValidationFields)[number]
>
{ {
@IsInt() @IsInt()
positionBroadcastSecs: number; positionBroadcastSecs: number;

10
src/validation/moduleConfig/mqtt.ts

@ -1,6 +1,12 @@
import type { Message } from "@bufbuild/protobuf"; import type { Message } from "@bufbuild/protobuf";
import type { Protobuf } from "@meshtastic/js"; import type { Protobuf } from "@meshtastic/js";
import { IsBoolean, IsString, Length, IsNumber } from "class-validator"; import {
IsBoolean,
IsNumber,
IsOptional,
IsString,
Length,
} from "class-validator";
export class MqttValidation export class MqttValidation
implements implements
@ -47,8 +53,10 @@ export class MqttValidationMapReportSettings
Omit<Protobuf.ModuleConfig.ModuleConfig_MapReportSettings, keyof Message> Omit<Protobuf.ModuleConfig.ModuleConfig_MapReportSettings, keyof Message>
{ {
@IsNumber() @IsNumber()
@IsOptional()
publishIntervalSecs: number; publishIntervalSecs: number;
@IsNumber() @IsNumber()
@IsOptional()
positionPrecision: number; positionPrecision: number;
} }

6
src/validation/moduleConfig/paxcounter.ts

@ -11,4 +11,10 @@ export class PaxcounterValidation
@IsInt() @IsInt()
paxcounterUpdateInterval: number; paxcounterUpdateInterval: number;
@IsInt()
bleThreshold: number;
@IsInt()
wifiThreshold: number;
} }

6
vercel.json

@ -1,5 +1 @@
{ { "github": { "silent": true } }
"github": {
"silent": true
}
}

4
vite.config.ts

@ -1,5 +1,5 @@
import { execSync } from "child_process"; import { execSync } from "node:child_process";
import { resolve } from "path"; import { resolve } from "node:path";
import { visualizer } from "rollup-plugin-visualizer"; import { visualizer } from "rollup-plugin-visualizer";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import EnvironmentPlugin from "vite-plugin-environment"; import EnvironmentPlugin from "vite-plugin-environment";

Loading…
Cancel
Save