Browse Source

Merge branch 'master' of github.com:meshtastic/web into test/browser-setup

pull/205/head
Nick Oliver 2 years ago
parent
commit
3c9088beb5
  1. 94
      .github/ISSUE_TEMPLATE/bug.yml
  2. 17
      .github/ISSUE_TEMPLATE/feature.yml
  3. 2
      .github/workflows/ci.yml
  4. 2
      .github/workflows/pr.yml
  5. 7
      .vscode/settings.json
  6. 7
      biome.json
  7. 74
      package.json
  8. 7611
      pnpm-lock.yaml
  9. 4
      postcss.config.cjs
  10. 4
      src/components/CommandPalette.tsx
  11. 4
      src/components/DeviceSelector.tsx
  12. 7
      src/components/Dialog/DialogManager.tsx
  13. 37
      src/components/Dialog/QRDialog.tsx
  14. 2
      src/components/Dialog/RebootDialog.tsx
  15. 54
      src/components/Dialog/RemoveNodeDialog.tsx
  16. 2
      src/components/Dialog/ShutdownDialog.tsx
  17. 21
      src/components/Form/DynamicForm.tsx
  18. 15
      src/components/Form/DynamicFormField.tsx
  19. 2
      src/components/Form/FormInput.tsx
  20. 4
      src/components/Form/FormSelect.tsx
  21. 4
      src/components/Form/FormToggle.tsx
  22. 66
      src/components/PageComponents/Channel.tsx
  23. 5
      src/components/PageComponents/Config/Display.tsx
  24. 5
      src/components/PageComponents/Config/LoRa.tsx
  25. 6
      src/components/PageComponents/Config/Position.tsx
  26. 2
      src/components/PageComponents/Connect/BLE.tsx
  27. 22
      src/components/PageComponents/Connect/HTTP.tsx
  28. 31
      src/components/PageComponents/Connect/Serial.tsx
  29. 69
      src/components/PageComponents/Messages/ChannelChat.tsx
  30. 32
      src/components/PageComponents/Messages/TraceRoute.tsx
  31. 3
      src/components/PageComponents/ModuleConfig/DetectionSensor.tsx
  32. 51
      src/components/PageComponents/ModuleConfig/MQTT.tsx
  33. 3
      src/components/PageComponents/ModuleConfig/NeighborInfo.tsx
  34. 6
      src/components/PageComponents/ModuleConfig/Paxcounter.tsx
  35. 3
      src/components/PageComponents/ModuleConfig/RangeTest.tsx
  36. 4
      src/components/PageComponents/ModuleConfig/Telemetry.tsx
  37. 2
      src/components/PageLayout.tsx
  38. 26
      src/components/Sidebar.tsx
  39. 10
      src/components/Toaster.tsx
  40. 2
      src/components/UI/Button.tsx
  41. 2
      src/components/UI/Command.tsx
  42. 2
      src/components/UI/Input.tsx
  43. 2
      src/components/UI/Toast.tsx
  44. 62
      src/components/generic/Table/index.tsx
  45. 36
      src/core/hooks/useToast.ts
  46. 13
      src/core/stores/appStore.ts
  47. 46
      src/core/stores/deviceStore.ts
  48. 8
      src/core/subscriptions.ts
  49. 2
      src/core/utils/cn.ts
  50. 5
      src/pages/Config/DeviceConfig.tsx
  51. 2
      src/pages/Config/ModuleConfig.tsx
  52. 36
      src/pages/Map.tsx
  53. 29
      src/pages/Messages.tsx
  54. 57
      src/pages/Nodes.tsx
  55. 9
      src/validation/channel.ts
  56. 8
      src/validation/config/device.ts
  57. 3
      src/validation/config/display.ts
  58. 8
      src/validation/config/position.ts
  59. 4
      src/validation/config/power.ts
  60. 10
      src/validation/moduleConfig/mqtt.ts
  61. 6
      src/validation/moduleConfig/paxcounter.ts
  62. 6
      vercel.json
  63. 4
      vite.config.ts

94
.github/ISSUE_TEMPLATE/bug.yml

@ -0,0 +1,94 @@
name: Bug Report
description: File a bug report
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: dropdown
id: hardware
attributes:
label: Hardware
description: What hardware are you encountering this issue on?
multiple: true
options:
- Not Applicable
- T-Beam
- T-Beam S3
- T-Beam 0.7
- T-Lora v1
- T-Lora v1.3
- T-Lora v2 1.6
- T-Deck
- T-Echo
- T-Watch
- Rak4631
- Rak11200
- Rak11310
- Heltec v1
- Heltec v2
- Heltec v2.1
- Heltec V3
- Heltec Wireless Paper
- Heltec Wireless Tracker
- Raspberry Pi Pico (W)
- Relay v1
- Relay v2
- DIY
- Other
validations:
required: true
- type: dropdown
id: category
attributes:
label: Connection Type
description: How are you connecting to your device?
multiple: true
options:
- HTTP
- Bluetooth
- Serial
validations:
required: true
- type: dropdown
id: local
attributes:
label: Local or Hosted
description: Are you using `meshtastic.local` or `client.meshtastic.org`?
multiple: true
options:
- http://meshtastic.local
- https://client.meshtastic.org
validations:
required: true
- type: input
id: version
attributes:
label: Firmware Version
description: This can be found on the device's screen or via one of the apps.
placeholder: x.x.x.yyyyyyy
validations:
required: true
- type: textarea
id: body
attributes:
label: Description
description: Please provide details on what steps you performed for this to happen.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant console output
description: If you have any log output to help in diagnosing your bug, please provide it here.
render: Shell
validations:
required: false

17
.github/ISSUE_TEMPLATE/feature.yml

@ -0,0 +1,17 @@
name: Feature Request
description: Request a new feature
title: "[Feature Request]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for your request this will not gurantee that we will implement it, but it will be reviewed.
- type: textarea
id: body
attributes:
label: Description
description: Please provide details about your enhancement.
validations:
required: true

2
.github/workflows/ci.yml

@ -12,7 +12,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- uses: pnpm/action-setup@v4
with:
version: latest

2
.github/workflows/pr.yml

@ -10,7 +10,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v4
with:
version: latest

7
.vscode/settings.json

@ -1,4 +1,7 @@
{
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
"editor.defaultFormatter": "biomejs.biome",
"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": {
"enabled": true
},
"files": {
"ignoreUnknown": true
"ignoreUnknown": true,
"ignore": ["vercel.json"]
},
"vcs": {
"enabled": true,
@ -20,7 +21,7 @@
"linter": {
"enabled": true,
"rules": {
"all": true
"recommended": true
}
}
}

74
package.json

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

7611
pnpm-lock.yaml

File diff suppressed because it is too large

4
postcss.config.cjs

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

4
src/components/CommandPalette.tsx

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

4
src/components/DeviceSelector.tsx

@ -9,8 +9,8 @@ import {
LanguagesIcon,
MoonIcon,
PlusIcon,
SearchIcon,
SunIcon,
TerminalIcon,
} from "lucide-react";
export const DeviceSelector = (): JSX.Element => {
@ -73,7 +73,7 @@ export const DeviceSelector = (): JSX.Element => {
className="transition-all hover:text-accent"
onClick={() => setCommandPaletteOpen(true)}
>
<TerminalIcon />
<SearchIcon />
</button>
<button type="button" className="transition-all hover:text-accent">
<LanguagesIcon />

7
src/components/Dialog/DialogManager.tsx

@ -1,3 +1,4 @@
import { RemoveNodeDialog } from "@app/components/Dialog/RemoveNodeDialog.js";
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.js";
import { ImportDialog } from "@components/Dialog/ImportDialog.js";
import { QRDialog } from "@components/Dialog/QRDialog.js";
@ -42,6 +43,12 @@ export const DialogManager = (): JSX.Element => {
setDialogOpen("deviceName", open);
}}
/>
<RemoveNodeDialog
open={dialog.nodeRemoval}
onOpenChange={(open) => {
setDialogOpen("nodeRemoval", open);
}}
/>
</>
);
};

37
src/components/Dialog/QRDialog.tsx

@ -9,10 +9,10 @@ import {
} from "@components/UI/Dialog.js";
import { Input } from "@components/UI/Input.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 { ClipboardIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { QRCode } from "react-qrcode-logo";
export interface QRDialogProps {
@ -30,8 +30,9 @@ export const QRDialog = ({
}: QRDialogProps): JSX.Element => {
const [selectedChannels, setSelectedChannels] = useState<number[]>([0]);
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
const [qrCodeAdd, setQrCodeAdd] = useState<boolean>();
const allChannels = Array.from(channels.values());
const allChannels = useMemo(() => Array.from(channels.values()), [channels]);
useEffect(() => {
const channelsToEncode = allChannels
@ -49,8 +50,10 @@ export const QRDialog = ({
.replace(/\+/g, "-")
.replace(/\//g, "_");
setQrCodeUrl(`https://meshtastic.org/e/#${base64}`);
}, [channels, selectedChannels, loraConfig]);
setQrCodeUrl(
`https://meshtastic.org/e/#${base64}${qrCodeAdd ? "?add=true" : ""}`,
);
}, [allChannels, selectedChannels, qrCodeAdd, loraConfig]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -94,6 +97,30 @@ export const QRDialog = ({
</div>
<QRCode value={qrCodeUrl} size={200} qrStyle="dots" />
</div>
<div className="flex justify-center">
<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"
}`}
onClick={() => setQrCodeAdd(true)}
>
Add Channels
</button>
<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"
}`}
onClick={() => setQrCodeAdd(false)}
>
Replace Channels
</button>
</div>
</div>
<DialogFooter>
<Label>Sharable URL</Label>

2
src/components/Dialog/RebootDialog.tsx

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

54
src/components/Dialog/RemoveNodeDialog.tsx

@ -0,0 +1,54 @@
import { useAppStore } from "@app/core/stores/appStore";
import { useDevice } from "@app/core/stores/deviceStore.js";
import { Button } from "@components/UI/Button.js";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.js";
import { Label } from "@components/UI/Label.js";
export interface RemoveNodeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const RemoveNodeDialog = ({
open,
onOpenChange,
}: RemoveNodeDialogProps): JSX.Element => {
const { connection, nodes, removeNode } = useDevice();
const { nodeNumToBeRemoved } = useAppStore();
const onSubmit = () => {
connection?.removeNodeByNum(nodeNumToBeRemoved);
removeNode(nodeNumToBeRemoved);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove Node?</DialogTitle>
<DialogDescription>
Are you sure you want to remove this Node?
</DialogDescription>
</DialogHeader>
<div className="gap-4">
<form onSubmit={onSubmit}>
<Label>{nodes.get(nodeNumToBeRemoved)?.user?.longName}</Label>
</form>
</div>
<DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}>
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

2
src/components/Dialog/ShutdownDialog.tsx

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

21
src/components/Form/DynamicForm.tsx

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

15
src/components/Form/DynamicFormField.tsx

@ -1,6 +1,15 @@
import { GenericInput, InputFieldProps } from "@components/Form/FormInput.js";
import { SelectFieldProps, SelectInput } from "@components/Form/FormSelect.js";
import { ToggleFieldProps, ToggleInput } from "@components/Form/FormToggle.js";
import {
GenericInput,
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";
export type FieldProps<T> =

2
src/components/Form/FormInput.tsx

@ -4,7 +4,7 @@ import type {
} from "@components/Form/DynamicForm.js";
import { Input } from "@components/UI/Input.js";
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> {
type: "text" | "number" | "password";

4
src/components/Form/FormSelect.tsx

@ -9,7 +9,7 @@ import {
SelectTrigger,
SelectValue,
} 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> {
type: "select" | "multiSelect";
@ -40,7 +40,7 @@ export function SelectInput<T extends FieldValues>({
: [];
return (
<Select
onValueChange={(e) => onChange(parseInt(e))}
onValueChange={(e) => onChange(Number.parseInt(e))}
disabled={disabled}
value={value?.toString()}
{...remainingProperties}

4
src/components/Form/FormToggle.tsx

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

66
src/components/PageComponents/Channel.tsx

@ -10,7 +10,7 @@ export interface SettingsPanelProps {
}
export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
const { connection, addChannel } = useDevice();
const { config, connection, addChannel } = useDevice();
const { toast } = useToast();
const onSubmit = (data: ChannelValidation) => {
@ -19,6 +19,13 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
settings: {
...data.settings,
psk: toByteArray(data.settings.psk ?? ""),
moduleSettings: {
positionPrecision: data.settings.positionEnabled
? data.settings.preciseLocation
? 32
: data.settings.positionPrecision
: 0,
},
},
});
connection?.setChannel(channel).then(() => {
@ -40,6 +47,16 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
settings: {
...channel?.settings,
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
positionEnabled:
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,
},
},
}}
@ -86,6 +103,53 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
label: "Downlink Enabled",
description: "Send messages from MQTT to the local mesh",
},
{
type: "toggle",
name: "settings.positionEnabled",
label: "Allow Position Requests",
description: "Send position to channel",
},
{
type: "toggle",
name: "settings.preciseLocation",
label: "Precise Location",
description: "Send precise location to channel",
},
{
type: "select",
name: "settings.positionPrecision",
label: "Approximate Location",
description:
"If not sharing precise location, position shared on channel 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,
},
},
},
],
},
]}

5
src/components/PageComponents/Config/Display.tsx

@ -32,7 +32,7 @@ export const Display = (): JSX.Element => {
label: "Screen Timeout",
description: "Turn off the display after this long",
properties: {
suffix: "seconds",
suffix: "Seconds",
},
},
{
@ -50,6 +50,9 @@ export const Display = (): JSX.Element => {
name: "autoScreenCarouselSecs",
label: "Carousel Delay",
description: "How fast to cycle through windows",
properties: {
suffix: "Seconds",
},
},
{
type: "toggle",

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

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

6
src/components/PageComponents/Config/Position.tsx

@ -94,12 +94,18 @@ export const Position = (): JSX.Element => {
name: "positionBroadcastSecs",
label: "Broadcast Interval",
description: "How often your position is sent out over the mesh",
properties: {
suffix: "Seconds",
},
},
{
type: "number",
name: "gpsUpdateInterval",
label: "GPS Update Interval",
description: "How often a GPS fix should be acquired",
properties: {
suffix: "Seconds",
},
},
{
type: "number",

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 { Mono } from "@components/generic/Mono.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 { Input } from "@components/UI/Input.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 { randId } from "@core/utils/randId.js";
import { HttpConnection } from "@meshtastic/js";
import { useState } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
@ -19,7 +20,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
}>({
defaultValues: {
ip: ["client.meshtastic.org", "localhost"].includes(
window.location.hostname
window.location.hostname,
)
? "meshtastic.local"
: window.location.hostname,
@ -33,10 +34,13 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
defaultValue: location.protocol === "https:",
});
const [connectionInProgress, setConnectionInProgress] = useState(false);
const onSubmit = handleSubmit(async (data) => {
setConnectionInProgress(true);
const id = randId();
const device = addDevice(id);
setSelectedDevice(id);
const connection = new HttpConnection(id);
// TODO: Promise never resolves
await connection.connect({
@ -44,9 +48,10 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
fetchInterval: 2000,
tls: data.tls,
});
setSelectedDevice(id);
device.addConnection(connection);
subscribeAll(device, connection);
closeDialog();
});
@ -58,6 +63,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
// label="IP Address/Hostname"
prefix={tlsEnabled ? "https://" : "http://"}
placeholder="000.000.000.000 / meshtastic.local"
disabled={connectionInProgress}
{...register("ip")}
/>
<Controller
@ -69,7 +75,9 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
<Switch
// label="Use TLS"
// description="Description"
disabled={location.protocol === "https:"}
disabled={
location.protocol === "https:" || connectionInProgress
}
checked={value}
{...rest}
/>
@ -77,8 +85,8 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
)}
/>
</div>
<Button type="submit">
<span>Connect</span>
<Button type="submit" disabled={connectionInProgress}>
<span>{connectionInProgress ? "Connecting..." : "Connect"}</span>
</Button>
</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 { Mono } from "@components/generic/Mono.js";
import { useAppStore } from "@core/stores/appStore.js";
@ -48,19 +48,22 @@ export const Serial = ({ closeDialog }: TabElementProps): JSX.Element => {
return (
<div className="flex w-full flex-col gap-2 p-4">
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
{serialPorts.map((port, index) => (
<Button
key={index}
disabled={port.readable !== null}
onClick={async () => {
await onConnect(port);
}}
>
{`# ${index} - ${port.getInfo().usbVendorId ?? "UNK"} - ${
port.getInfo().usbProductId ?? "UNK"
}`}
</Button>
))}
{serialPorts.map((port, index) => {
const { usbProductId, usbVendorId } = port.getInfo();
return (
<Button
key={`${usbVendorId ?? "UNK"}-${usbProductId ?? "UNK"}-${index}`}
disabled={port.readable !== null}
onClick={async () => {
await onConnect(port);
}}
>
{`# ${index} - ${usbVendorId ?? "UNK"} - ${
usbProductId ?? "UNK"
}`}
</Button>
);
})}
{serialPorts.length === 0 && (
<Mono className="m-auto select-none">No devices paired yet.</Mono>
)}

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

@ -1,43 +1,72 @@
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 { MessageInput } from "@components/PageComponents/Messages/MessageInput.js";
import type { Types } from "@meshtastic/js";
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.js";
import type { Protobuf, Types } from "@meshtastic/js";
import { InboxIcon } from "lucide-react";
export interface ChannelChatProps {
messages?: MessageWithState[];
channel: Types.ChannelNumber;
to: Types.Destination;
traceroutes?: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[];
}
export const ChannelChat = ({
messages,
channel,
to,
traceroutes,
}: ChannelChatProps): JSX.Element => {
const { nodes } = useDevice();
return (
<div className="flex flex-grow flex-col">
<div className="flex flex-grow flex-col">
{messages ? (
messages.map((message, index) => (
<Message
key={message.id}
message={message}
lastMsgSameUser={
index === 0 ? false : messages[index - 1].from === message.from
}
sender={nodes.get(message.from)}
/>
))
) : (
<div className="m-auto">
<InboxIcon className="m-auto" />
<Subtle>No Messages</Subtle>
</div>
)}
<div className="flex flex-grow">
<div className="flex flex-grow flex-col">
{messages ? (
messages.map((message, index) => (
<Message
key={message.id}
message={message}
lastMsgSameUser={
index === 0
? false
: messages[index - 1].from === message.from
}
sender={nodes.get(message.from)}
/>
))
) : (
<div className="m-auto">
<InboxIcon className="m-auto" />
<Subtle>No Messages</Subtle>
</div>
)}
</div>
<div
className={`flex flex-grow flex-col border-slate-400 border-l ${traceroutes === undefined ? "hidden" : ""}`}
>
{to === "broadcast" ? null : traceroutes ? (
traceroutes.map((traceroute, index) => (
<TraceRoute
key={traceroute.id}
from={nodes.get(traceroute.from)}
to={nodes.get(traceroute.to)}
route={traceroute.data.route}
/>
))
) : (
<div className="m-auto">
<InboxIcon className="m-auto" />
<Subtle>No Traceroutes</Subtle>
</div>
)}
</div>
</div>
<div className="p-3">
<MessageInput to={to} channel={channel} />

32
src/components/PageComponents/Messages/TraceRoute.tsx

@ -0,0 +1,32 @@
import { useDevice } from "@app/core/stores/deviceStore.js";
import type { Protobuf } from "@meshtastic/js";
export interface TraceRouteProps {
from?: Protobuf.Mesh.NodeInfo;
to?: Protobuf.Mesh.NodeInfo;
route: Array<number>;
}
export const TraceRoute = ({
from,
to,
route,
}: TraceRouteProps): JSX.Element => {
const { nodes } = useDevice();
return route.length === 0 ? (
<div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
{to?.user?.longName}{from?.user?.longName}
</span>
</div>
) : (
<div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
{to?.user?.longName}
{route.map((hop) => `${nodes.get(hop)?.user?.longName ?? "Unknown"}`)}
{from?.user?.longName}
</span>
</div>
);
};

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

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

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

@ -4,14 +4,20 @@ import { DynamicForm } from "@components/Form/DynamicForm.js";
import { Protobuf } from "@meshtastic/js";
export const MQTT = (): JSX.Element => {
const { moduleConfig, setWorkingModuleConfig } = useDevice();
const { config, moduleConfig, setWorkingModuleConfig } = useDevice();
const onSubmit = (data: MqttValidation) => {
setWorkingModuleConfig(
new Protobuf.ModuleConfig.ModuleConfig({
payloadVariant: {
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",
name: "encryptionEnabled",
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: [
{
fieldName: "enabled",
@ -138,6 +145,9 @@ export const MQTT = (): JSX.Element => {
name: "mapReportSettings.publishIntervalSecs",
label: "Map Report Publish Interval (s)",
description: "Interval in seconds to publish map reports",
properties: {
suffix: "Seconds",
},
disabledBy: [
{
fieldName: "enabled",
@ -148,10 +158,39 @@ export const MQTT = (): JSX.Element => {
],
},
{
type: "number",
type: "select",
name: "mapReportSettings.positionPrecision",
label: "Position Precision",
description: "Precision of the position",
label: "Approximate Location",
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: [
{
fieldName: "enabled",

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

@ -38,6 +38,9 @@ export const NeighborInfo = (): JSX.Element => {
label: "Update Interval",
description:
"Interval in seconds of how often we should try to send our Neighbor Info to the mesh",
properties: {
suffix: "Seconds",
},
disabledBy: [
{
fieldName: "enabled",

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

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

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

@ -37,6 +37,9 @@ export const RangeTest = (): JSX.Element => {
name: "sender",
label: "Message Interval",
description: "How long to wait between sending test packets",
properties: {
suffix: "Seconds",
},
disabledBy: [
{
fieldName: "enabled",

4
src/components/PageComponents/ModuleConfig/Telemetry.tsx

@ -32,7 +32,7 @@ export const Telemetry = (): JSX.Element => {
label: "Query Interval",
description: "Interval to get telemetry data",
properties: {
suffix: "seconds",
suffix: "Seconds",
},
},
{
@ -41,7 +41,7 @@ export const Telemetry = (): JSX.Element => {
label: "Update Interval",
description: "How often to send Metrics over the mesh",
properties: {
suffix: "seconds",
suffix: "Seconds",
},
},
{

2
src/components/PageLayout.tsx

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

26
src/components/Sidebar.tsx

@ -4,13 +4,16 @@ import { Subtle } from "@components/UI/Typography/Subtle.js";
import { useDevice } from "@core/stores/deviceStore.js";
import type { Page } from "@core/stores/deviceStore.js";
import {
BatteryMediumIcon,
CpuIcon,
EditIcon,
LayersIcon,
LucideIcon,
type LucideIcon,
MapIcon,
MessageSquareIcon,
SettingsIcon,
UsersIcon,
ZapIcon,
} from "lucide-react";
export interface SidebarProps {
@ -18,8 +21,9 @@ export interface SidebarProps {
}
export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
const { hardware, nodes } = useDevice();
const { hardware, nodes, metadata } = useDevice();
const myNode = nodes.get(hardware.myNodeNum);
const myMetadata = metadata.get(0);
const { activePage, setActivePage, setDialogOpen } = useDevice();
interface NavLink {
@ -58,7 +62,7 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
return (
<div className="min-w-[280px] max-w-min flex-col overflow-y-auto border-r-[0.5px] border-slate-300 bg-transparent dark:border-slate-700">
<div className="flex justify-between px-8 py-6">
<div className="flex justify-between px-8 pt-6">
<div>
<span className="text-lg font-medium">
{myNode?.user?.shortName ?? "UNK"}
@ -73,6 +77,22 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
<EditIcon size={16} />
</button>
</div>
<div className="px-8 pb-6">
<div className="flex items-center">
<BatteryMediumIcon size={24} viewBox={"0 0 28 24"} />
<Subtle>{myNode?.deviceMetrics?.batteryLevel ?? "UNK"}%</Subtle>
</div>
<div className="flex items-center">
<ZapIcon size={24} viewBox={"0 0 36 24"} />
<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>
<SidebarSection label="Navigation">
{pages.map((link) => (

10
src/components/Toaster.tsx

@ -17,8 +17,14 @@ export function Toaster() {
{toasts.map(({ id, title, description, action, ...props }) => (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
{title && (
<ToastTitle className="dark:text-white">{title}</ToastTitle>
)}
{description && (
<ToastDescription className="dark:text-white-400">
{description}
</ToastDescription>
)}
</div>
{action}
<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 { cn } from "@core/utils/cn.js";

2
src/components/UI/Command.tsx

@ -116,7 +116,7 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-md py-1.5 px-2 text-sm font-medium outline-none aria-selected:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:aria-selected:bg-slate-700",
"relative flex cursor-default select-none items-center rounded-md py-1.5 px-2 text-sm font-medium outline-none aria-selected:bg-slate-100 data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50 dark:aria-selected:bg-slate-700",
className,
)}
{...props}

2
src/components/UI/Input.tsx

@ -32,7 +32,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
{...props}
/>
{suffix && (
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 font-mono text-textSecondary">
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-9 font-mono text-textSecondary">
<span className="text-gray-500 sm:text-sm">{suffix}</span>
</div>
)}

2
src/components/UI/Toast.tsx

@ -1,5 +1,5 @@
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 * 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 {
headings: Heading[];
@ -12,6 +13,49 @@ export interface Heading {
}
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 (
<table className="min-w-full">
<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"
: ""
}`}
onClick={() => heading.sortable && headingSort(heading.title)}
onKeyUp={() => heading.sortable && headingSort(heading.title)}
>
<div className="flex gap-2">
{heading.title}
{heading.sortable && (
<ChevronUpIcon size={16} className="my-auto" />
{sortColumn === heading.title && (
<>
{sortOrder === "asc" ? (
<ChevronUpIcon size={16} />
) : (
<ChevronDownIcon size={16} />
)}
</>
)}
</div>
</th>
@ -37,10 +89,12 @@ export const Table = ({ headings, rows }: TableProps): JSX.Element => {
</tr>
</thead>
<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}>
{row.map((item, index) => (
<td
// biome-ignore lint/suspicious/noArrayIndexKey: OK because column order never changes.
key={index}
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";
@ -92,9 +92,9 @@ export const reducer = (state: State, action: Action): State => {
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
for (const toast of state.toasts) {
addToRemoveQueue(toast.id);
});
}
}
return {
@ -130,9 +130,9 @@ let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
for (const listener of listeners) {
listener(memoryState);
});
}
}
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() {
const [state, setState] = useState<State>(memoryState);
useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
const state = useSyncExternalStore(subscribe, getState);
return {
...state,

13
src/core/stores/appStore.ts

@ -26,6 +26,7 @@ interface AppState {
rasterSources: RasterSource[];
commandPaletteOpen: boolean;
darkMode: boolean;
nodeNumToBeRemoved: number;
accent: AccentColor;
connectDialogOpen: boolean;
@ -38,6 +39,7 @@ interface AppState {
removeDevice: (deviceId: number) => void;
setCommandPaletteOpen: (open: boolean) => void;
setDarkMode: (enabled: boolean) => void;
setNodeNumToBeRemoved: (nodeNum: number) => void;
setAccent: (color: AccentColor) => void;
setConnectDialogOpen: (open: boolean) => void;
}
@ -48,9 +50,13 @@ export const useAppStore = create<AppState>()((set) => ({
currentPage: "messages",
rasterSources: [],
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",
connectDialogOpen: false,
nodeNumToBeRemoved: 0,
setRasterSources: (sources: RasterSource[]) => {
set(
@ -93,12 +99,17 @@ export const useAppStore = create<AppState>()((set) => ({
);
},
setDarkMode: (enabled: boolean) => {
localStorage.setItem("theme-dark", enabled.toString());
set(
produce<AppState>((draft) => {
draft.darkMode = enabled;
}),
);
},
setNodeNumToBeRemoved: (nodeNum) =>
set((state) => ({
nodeNumToBeRemoved: nodeNum,
})),
setAccent(color) {
set(
produce<AppState>((draft) => {

46
src/core/stores/deviceStore.ts

@ -24,7 +24,8 @@ export type DialogVariant =
| "QR"
| "shutdown"
| "reboot"
| "deviceName";
| "deviceName"
| "nodeRemoval";
export interface Device {
id: number;
@ -41,6 +42,10 @@ export interface Device {
direct: Map<number, MessageWithState[]>;
broadcast: Map<Types.ChannelNumber, MessageWithState[]>;
};
traceroutes: Map<
number,
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
>;
connection?: Types.ConnectionType;
activePage: Page;
activeNode: number;
@ -54,6 +59,7 @@ export interface Device {
shutdown: boolean;
reboot: boolean;
deviceName: boolean;
nodeRemoval: boolean;
};
setStatus: (status: Types.DeviceStatusEnum) => void;
@ -73,7 +79,11 @@ export interface Device {
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
addConnection: (connection: Types.ConnectionType) => void;
addMessage: (message: MessageWithState) => void;
addTraceRoute: (
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
) => void;
addMetadata: (from: number, metadata: Protobuf.Mesh.DeviceMetadata) => void;
removeNode: (nodeNum: number) => void;
setMessageState: (
type: "direct" | "broadcast",
channelIndex: Types.ChannelNumber,
@ -119,6 +129,7 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
direct: new Map(),
broadcast: new Map(),
},
traceroutes: new Map(),
connection: undefined,
activePage: "messages",
activeNode: 0,
@ -130,6 +141,7 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
shutdown: false,
reboot: false,
deviceName: false,
nodeRemoval: false,
},
pendingSettingsChanges: false,
messageDraft: "",
@ -483,6 +495,7 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
}),
);
},
addMetadata: (from, metadata) => {
set(
produce<DeviceState>((draft) => {
@ -494,6 +507,37 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
}),
);
},
addTraceRoute: (traceroute) => {
set(
produce<DeviceState>((draft) => {
console.log("addTraceRoute called");
console.log(traceroute);
const device = draft.devices.get(id);
if (!device) {
return;
}
const nodetraceroutes = device.traceroutes.get(traceroute.from);
if (nodetraceroutes) {
nodetraceroutes.push(traceroute);
device.traceroutes.set(traceroute.from, nodetraceroutes);
} else {
device.traceroutes.set(traceroute.from, [traceroute]);
}
}),
);
},
removeNode: (nodeNum) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
}
device.nodes.delete(nodeNum);
}),
);
},
setMessageState: (
type: "direct" | "broadcast",
channelIndex: Types.ChannelNumber,

8
src/core/subscriptions.ts

@ -1,5 +1,5 @@
import type { Device } from "@core/stores/deviceStore.js";
import { Protobuf, Types } from "@meshtastic/js";
import { Protobuf, type Types } from "@meshtastic/js";
export const subscribeAll = (
device: Device,
@ -86,6 +86,12 @@ export const subscribeAll = (
});
});
connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => {
device.addTraceRoute({
...traceRoutePacket,
});
});
connection.events.onPendingSettingsChange.subscribe((state) => {
device.setPendingSettingsChanges(state);
});

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";
export function cn(...inputs: ClassValue[]) {

5
src/pages/Config/DeviceConfig.tsx

@ -53,10 +53,7 @@ export const DeviceConfig = (): JSX.Element => {
<Tabs defaultValue="Device">
<TabsList>
{tabs.map((tab) => (
<TabsTrigger
key={tab.label}
value={tab.label}
>
<TabsTrigger key={tab.label} value={tab.label}>
{tab.label}
</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 { ExternalNotification } from "@components/PageComponents/ModuleConfig/ExternalNotification.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 { Serial } from "@components/PageComponents/ModuleConfig/Serial.js";
import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.js";
import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.js";
import { Paxcounter } from "@components/PageComponents/ModuleConfig/Paxcounter.js";
import {
Tabs,
TabsContent,

36
src/pages/Map.tsx

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

29
src/pages/Messages.tsx

@ -3,15 +3,17 @@ import { PageLayout } from "@components/PageLayout.js";
import { Sidebar } from "@components/Sidebar.js";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.js";
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.js";
import { useToast } from "@core/hooks/useToast.js";
import { useDevice } from "@core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react";
import { Protobuf, Types } from "@meshtastic/js";
import { getChannelName } from "@pages/Channels.js";
import { HashIcon } from "lucide-react";
import { HashIcon, WaypointsIcon } from "lucide-react";
import { useState } from "react";
export const MessagesPage = (): JSX.Element => {
const { channels, nodes, hardware, messages } = useDevice();
const { channels, nodes, hardware, messages, traceroutes, connection } =
useDevice();
const [chatType, setChatType] =
useState<Types.PacketDestination>("broadcast");
const [activeChat, setActiveChat] = useState<number>(
@ -25,6 +27,7 @@ export const MessagesPage = (): JSX.Element => {
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
);
const currentChannel = channels.get(activeChat);
const { toast } = useToast();
return (
<>
@ -72,6 +75,27 @@ export const MessagesPage = (): JSX.Element => {
? nodes.get(activeChat)?.user?.longName ?? "Unknown"
: "Loading..."
}`}
actions={
chatType === "direct"
? [
{
icon: WaypointsIcon,
async onClick() {
const targetNode = nodes.get(activeChat)?.num;
if (targetNode === undefined) return;
toast({
title: "Sending Traceroute, please wait...",
});
await connection?.traceRoute(targetNode).then(() =>
toast({
title: "Traceroute sent.",
}),
);
},
},
]
: []
}
>
{allChannels.map(
(channel) =>
@ -92,6 +116,7 @@ export const MessagesPage = (): JSX.Element => {
to={activeChat}
messages={messages.direct.get(node.num)}
channel={Types.ChannelNumber.Primary}
traceroutes={traceroutes.get(node.num)}
/>
),
)}

57
src/pages/Nodes.tsx

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

9
src/validation/channel.ts

@ -41,4 +41,13 @@ export class Channel_SettingsValidation
@IsBoolean()
downlinkEnabled: boolean;
@IsBoolean()
positionEnabled: boolean;
@IsBoolean()
preciseLocation: boolean;
@IsInt()
positionPrecision: number;
}

8
src/validation/config/device.ts

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

3
src/validation/config/display.ts

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

8
src/validation/config/position.ts

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

4
src/validation/config/power.ts

@ -1,6 +1,6 @@
import type { Message } from "@bufbuild/protobuf";
import type { Protobuf } from "@meshtastic/js";
import { IsBoolean, IsInt, Max, Min } from "class-validator";
import { IsBoolean, IsInt, IsNumber, Max, Min } from "class-validator";
export class PowerValidation
implements Omit<Protobuf.Config.Config_PowerConfig, keyof Message>
@ -11,7 +11,7 @@ export class PowerValidation
@IsInt()
onBatteryShutdownAfterSecs: number;
@IsInt()
@IsNumber()
@Min(2)
@Max(4)
adcMultiplierOverride: number;

10
src/validation/moduleConfig/mqtt.ts

@ -1,6 +1,12 @@
import type { Message } from "@bufbuild/protobuf";
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
implements
@ -47,8 +53,10 @@ export class MqttValidationMapReportSettings
Omit<Protobuf.ModuleConfig.ModuleConfig_MapReportSettings, keyof Message>
{
@IsNumber()
@IsOptional()
publishIntervalSecs: number;
@IsNumber()
@IsOptional()
positionPrecision: number;
}

6
src/validation/moduleConfig/paxcounter.ts

@ -11,4 +11,10 @@ export class PaxcounterValidation
@IsInt()
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 { resolve } from "path";
import { execSync } from "node:child_process";
import { resolve } from "node:path";
import { visualizer } from "rollup-plugin-visualizer";
import { defineConfig } from "vite";
import EnvironmentPlugin from "vite-plugin-environment";

Loading…
Cancel
Save