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. 7
      .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. 32
      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. 3
      src/components/PageComponents/ModuleConfig/Paxcounter.tsx
  27. 2
      src/components/PageLayout.tsx
  28. 22
      src/components/Sidebar.tsx
  29. 10
      src/components/Toaster.tsx
  30. 2
      src/components/UI/Button.tsx
  31. 2
      src/components/UI/Toast.tsx
  32. 62
      src/components/generic/Table/index.tsx
  33. 36
      src/core/hooks/useToast.ts
  34. 8
      src/core/stores/appStore.ts
  35. 4
      src/core/stores/deviceStore.ts
  36. 2
      src/core/subscriptions.ts
  37. 2
      src/core/utils/cn.ts
  38. 5
      src/pages/Config/DeviceConfig.tsx
  39. 2
      src/pages/Config/ModuleConfig.tsx
  40. 36
      src/pages/Map.tsx
  41. 60
      src/pages/Nodes.tsx
  42. 8
      src/validation/config/device.ts
  43. 3
      src/validation/config/display.ts
  44. 8
      src/validation/config/position.ts
  45. 10
      src/validation/moduleConfig/mqtt.ts
  46. 6
      src/validation/moduleConfig/paxcounter.ts
  47. 6
      vercel.json
  48. 4
      vite.config.ts

7
.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 => {

32
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}>
@ -98,17 +100,25 @@ export const QRDialog = ({
<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",

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