Browse Source

Merge branch 'master' into master

pull/185/head
Hunter Thornsberry 2 years ago
committed by GitHub
parent
commit
a70ae526ea
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 94
      .github/ISSUE_TEMPLATE/bug.yml
  2. 17
      .github/ISSUE_TEMPLATE/feature.yml
  3. 46
      .github/workflows/ci.yml
  4. 11
      .github/workflows/pr.yml
  5. 61
      .github/workflows/release.yml
  6. 3
      .vscode/settings.json
  7. 7
      biome.json
  8. 74
      package.json
  9. 7937
      pnpm-lock.yaml
  10. 4
      postcss.config.cjs
  11. 5
      src/App.tsx
  12. 15
      src/components/CommandPalette.tsx
  13. 3
      src/components/DeviceSelector.tsx
  14. 7
      src/components/Dialog/DialogManager.tsx
  15. 31
      src/components/Dialog/ImportDialog.tsx
  16. 39
      src/components/Dialog/PkiRegenerateDialog.tsx
  17. 37
      src/components/Dialog/QRDialog.tsx
  18. 2
      src/components/Dialog/RebootDialog.tsx
  19. 54
      src/components/Dialog/RemoveNodeDialog.tsx
  20. 2
      src/components/Dialog/ShutdownDialog.tsx
  21. 40
      src/components/Form/DynamicForm.tsx
  22. 30
      src/components/Form/DynamicFormField.tsx
  23. 16
      src/components/Form/FormInput.tsx
  24. 46
      src/components/Form/FormPasswordGenerator.tsx
  25. 4
      src/components/Form/FormSelect.tsx
  26. 4
      src/components/Form/FormToggle.tsx
  27. 7
      src/components/Form/FormWrapper.tsx
  28. 129
      src/components/PageComponents/Channel.tsx
  29. 36
      src/components/PageComponents/Config/Device.tsx
  30. 5
      src/components/PageComponents/Config/Display.tsx
  31. 12
      src/components/PageComponents/Config/LoRa.tsx
  32. 27
      src/components/PageComponents/Config/Network.tsx
  33. 41
      src/components/PageComponents/Config/Position.tsx
  34. 2
      src/components/PageComponents/Config/Power.tsx
  35. 237
      src/components/PageComponents/Config/Security.tsx
  36. 2
      src/components/PageComponents/Connect/BLE.tsx
  37. 22
      src/components/PageComponents/Connect/HTTP.tsx
  38. 15
      src/components/PageComponents/Connect/Serial.tsx
  39. 37
      src/components/PageComponents/Messages/ChannelChat.tsx
  40. 2
      src/components/PageComponents/Messages/MessageInput.tsx
  41. 36
      src/components/PageComponents/Messages/TraceRoute.tsx
  42. 3
      src/components/PageComponents/ModuleConfig/DetectionSensor.tsx
  43. 51
      src/components/PageComponents/ModuleConfig/MQTT.tsx
  44. 3
      src/components/PageComponents/ModuleConfig/NeighborInfo.tsx
  45. 6
      src/components/PageComponents/ModuleConfig/Paxcounter.tsx
  46. 3
      src/components/PageComponents/ModuleConfig/RangeTest.tsx
  47. 6
      src/components/PageComponents/ModuleConfig/Telemetry.tsx
  48. 11
      src/components/PageLayout.tsx
  49. 26
      src/components/Sidebar.tsx
  50. 10
      src/components/Toaster.tsx
  51. 4
      src/components/UI/Button.tsx
  52. 2
      src/components/UI/Command.tsx
  53. 37
      src/components/UI/Footer.tsx
  54. 111
      src/components/UI/Generator.tsx
  55. 28
      src/components/UI/Input.tsx
  56. 2
      src/components/UI/Tabs.tsx
  57. 2
      src/components/UI/Toast.tsx
  58. 62
      src/components/generic/Table/index.tsx
  59. 30
      src/core/hooks/useToast.ts
  60. 13
      src/core/stores/appStore.ts
  61. 49
      src/core/stores/deviceStore.ts
  62. 6
      src/core/subscriptions.ts
  63. 2
      src/core/utils/cn.ts
  64. 14
      src/core/utils/ip.ts
  65. 15
      src/core/utils/x25519.ts
  66. 10
      src/pages/Config/DeviceConfig.tsx
  67. 2
      src/pages/Config/ModuleConfig.tsx
  68. 2
      src/pages/Dashboard/index.tsx
  69. 30
      src/pages/Map.tsx
  70. 55
      src/pages/Messages.tsx
  71. 70
      src/pages/Nodes.tsx
  72. 9
      src/validation/channel.ts
  73. 6
      src/validation/config/bluetooth.ts
  74. 8
      src/validation/config/device.ts
  75. 3
      src/validation/config/display.ts
  76. 6
      src/validation/config/lora.ts
  77. 13
      src/validation/config/network.ts
  78. 17
      src/validation/config/position.ts
  79. 7
      src/validation/config/power.ts
  80. 35
      src/validation/config/security.ts
  81. 10
      src/validation/moduleConfig/mqtt.ts
  82. 6
      src/validation/moduleConfig/paxcounter.ts
  83. 5
      src/validation/moduleConfig/storeForward.ts
  84. 6
      vercel.json
  85. 7
      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

46
.github/workflows/ci.yml

@ -1,6 +1,9 @@
name: CI name: CI
on: push on:
push:
branches:
- master
permissions: permissions:
contents: write contents: write
@ -12,7 +15,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- uses: pnpm/action-setup@v2 - uses: pnpm/action-setup@v4
with: with:
version: latest version: latest
@ -21,42 +24,3 @@ jobs:
- name: Build Package - name: Build Package
run: pnpm build run: pnpm build
- name: Package Output
run: pnpm package
- name: Upload Artifact
uses: "marvinpinto/action-automatic-releases@latest"
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: "latest"
prerelease: false
files: |
./dist/build.tar
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Buildah Build
id: build-container
uses: redhat-actions/buildah-build@v2
with:
containerfiles: |
./Containerfile
image: ${{github.event.repository.full_name}}
tags: latest ${{ github.sha }}
oci: true
platforms: linux/amd64, linux/arm64
- name: Push To Registry
id: push-to-registry
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-container.outputs.image }}
tags: ${{ steps.build-container.outputs.tags }}
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Print image url
run: echo "Image pushed to ${{ steps.push-to-registry.outputs.registry-paths }}"

11
.github/workflows/pr.yml

@ -10,7 +10,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v4
with: with:
version: latest version: latest
@ -19,3 +19,12 @@ jobs:
- name: Build Package - name: Build Package
run: pnpm build run: pnpm build
- name: Compress build
run: pnpm package
- name: Archive compressed build
uses: actions/upload-artifact@v4
with:
name: build
path: dist/build.tar

61
.github/workflows/release.yml

@ -0,0 +1,61 @@
name: 'Release'
on:
release:
types: [released]
permissions:
contents: write
packages: write
jobs:
build-and-package:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- name: Install Dependencies
run: pnpm install
- name: Build Package
run: pnpm build
- name: Package Output
run: pnpm package
- name: Archive compressed build
uses: actions/upload-artifact@v4
with:
name: build
path: dist/build.tar
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Buildah Build
id: build-container
uses: redhat-actions/buildah-build@v2
with:
containerfiles: |
./Containerfile
image: ${{github.event.repository.full_name}}
tags: latest ${{ github.sha }}
oci: true
platforms: linux/amd64, linux/arm64
- name: Push To Registry
id: push-to-registry
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-container.outputs.image }}
tags: ${{ steps.build-container.outputs.tags }}
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Print image url
run: echo "Image pushed to ${{ steps.push-to-registry.outputs.registry-paths }}"

3
.vscode/settings.json

@ -1,4 +1,7 @@
{ {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"quickfix.biome": "explicit"
},
"editor.formatOnSave": true "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
} }
} }
} }

74
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,67 @@
}, },
"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.3-0", "@meshtastic/js": "2.3.7-5",
"@radix-ui/react-accordion": "^1.1.2", "@noble/curves": "^1.5.0",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-checkbox": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-menubar": "^1.0.4", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-menubar": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-toast": "^1.2.1",
"@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", "crypto-random-string": "^5.0.0",
"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": "^3.6.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",
"vite-plugin-node-polyfills": "^0.22.0",
"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-20240906232734-3da561588c55.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"
} }
} }

7937
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: {},
} },
}; };

5
src/App.tsx

@ -5,6 +5,7 @@ import { DeviceSelector } from "@components/DeviceSelector.js";
import { DialogManager } from "@components/Dialog/DialogManager.js"; import { DialogManager } from "@components/Dialog/DialogManager.js";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.js"; import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.js";
import { Toaster } from "@components/Toaster.js"; import { Toaster } from "@components/Toaster.js";
import Footer from "@components/UI/Footer.js";
import { ThemeController } from "@components/generic/ThemeController.js"; import { ThemeController } from "@components/generic/ThemeController.js";
import { useAppStore } from "@core/stores/appStore.js"; import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js"; import { useDeviceStore } from "@core/stores/deviceStore.js";
@ -40,7 +41,11 @@ export const App = (): JSX.Element => {
<PageRouter /> <PageRouter />
</div> </div>
) : ( ) : (
<>
<Dashboard /> <Dashboard />
<div className="flex flex-grow" />
<Footer />
</>
)} )}
</div> </div>
</div> </div>

15
src/components/CommandPalette.tsx

@ -19,7 +19,7 @@ import {
LayersIcon, LayersIcon,
LayoutIcon, LayoutIcon,
LinkIcon, LinkIcon,
LucideIcon, type LucideIcon,
MapIcon, MapIcon,
MessageSquareIcon, MessageSquareIcon,
MoonIcon, MoonIcon,
@ -200,10 +200,17 @@ export const CommandPalette = (): JSX.Element => {
}, },
}, },
{ {
label: "Factory Reset", label: "Factory Reset Device",
icon: FactoryIcon, icon: FactoryIcon,
action() { action() {
connection?.factoryReset(); connection?.factoryResetDevice();
},
},
{
label: "Factory Reset Config",
icon: FactoryIcon,
action() {
connection?.factoryResetConfig();
}, },
}, },
], ],
@ -350,7 +357,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

3
src/components/DeviceSelector.tsx

@ -9,6 +9,7 @@ import {
LanguagesIcon, LanguagesIcon,
MoonIcon, MoonIcon,
PlusIcon, PlusIcon,
SearchIcon,
SunIcon, SunIcon,
TerminalIcon, TerminalIcon,
BellIcon, BellIcon,
@ -84,7 +85,7 @@ export const DeviceSelector = (): JSX.Element => {
className="transition-all hover:text-accent" className="transition-all hover:text-accent"
onClick={() => setCommandPaletteOpen(true)} onClick={() => setCommandPaletteOpen(true)}
> >
<TerminalIcon /> <SearchIcon />
</button> </button>
<button type="button" className="transition-all hover:text-accent"> <button type="button" className="transition-all hover:text-accent">
<LanguagesIcon /> <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 { 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";
@ -42,6 +43,12 @@ export const DialogManager = (): JSX.Element => {
setDialogOpen("deviceName", open); setDialogOpen("deviceName", open);
}} }}
/> />
<RemoveNodeDialog
open={dialog.nodeRemoval}
onOpenChange={(open) => {
setDialogOpen("nodeRemoval", open);
}}
/>
</> </>
); );
}; };

31
src/components/Dialog/ImportDialog.tsx

@ -26,19 +26,34 @@ export const ImportDialog = ({
open, open,
onOpenChange, onOpenChange,
}: ImportDialogProps): JSX.Element => { }: ImportDialogProps): JSX.Element => {
const [qrCodeUrl, setQrCodeUrl] = useState<string>(""); const [importDialogInput, setImportDialogInput] = useState<string>("");
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>(); const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
const [validUrl, setValidUrl] = useState<boolean>(false); const [validUrl, setValidUrl] = useState<boolean>(false);
const { connection } = useDevice(); const { connection } = useDevice();
useEffect(() => { useEffect(() => {
const base64String = qrCodeUrl.split("e/#")[1]; // the channel information is contained in the URL's fragment, which will be present after a
const paddedString = base64String // non-URL encoded `#`.
?.padEnd(base64String.length + ((4 - (base64String.length % 4)) % 4), "=") try {
const channelsUrl = new URL(importDialogInput);
if (
(channelsUrl.hostname !== "meshtastic.org" &&
channelsUrl.pathname !== "/e/") ||
!channelsUrl.hash
) {
throw "Invalid Meshtastic URL";
}
const encodedChannelConfig = channelsUrl.hash.substring(1);
const paddedString = encodedChannelConfig
.padEnd(
encodedChannelConfig.length +
((4 - (encodedChannelConfig.length % 4)) % 4),
"=",
)
.replace(/-/g, "+") .replace(/-/g, "+")
.replace(/_/g, "/"); .replace(/_/g, "/");
try {
setChannelSet( setChannelSet(
Protobuf.AppOnly.ChannelSet.fromBinary(toByteArray(paddedString)), Protobuf.AppOnly.ChannelSet.fromBinary(toByteArray(paddedString)),
); );
@ -47,7 +62,7 @@ export const ImportDialog = ({
setValidUrl(false); setValidUrl(false);
setChannelSet(undefined); setChannelSet(undefined);
} }
}, [qrCodeUrl]); }, [importDialogInput]);
const apply = () => { const apply = () => {
channelSet?.settings.map((ch, index) => { channelSet?.settings.map((ch, index) => {
@ -87,10 +102,10 @@ export const ImportDialog = ({
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Label>Channel Set/QR Code URL</Label> <Label>Channel Set/QR Code URL</Label>
<Input <Input
value={qrCodeUrl} value={importDialogInput}
suffix={validUrl ? "✅" : "❌"} suffix={validUrl ? "✅" : "❌"}
onChange={(e) => { onChange={(e) => {
setQrCodeUrl(e.target.value); setImportDialogInput(e.target.value);
}} }}
/> />
{validUrl && ( {validUrl && (

39
src/components/Dialog/PkiRegenerateDialog.tsx

@ -0,0 +1,39 @@
import { Button } from "@components/UI/Button.js";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.js";
export interface PkiRegenerateDialogProps {
open: boolean;
onOpenChange: () => void;
onSubmit: () => void;
}
export const PkiRegenerateDialog = ({
open,
onOpenChange,
onSubmit,
}: PkiRegenerateDialogProps): JSX.Element => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Regenerate Key pair?</DialogTitle>
<DialogDescription>
Are you sure you want to regenerate key pair?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}>
Regenerate
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

37
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 {
@ -30,8 +30,9 @@ export const QRDialog = ({
}: QRDialogProps): JSX.Element => { }: QRDialogProps): JSX.Element => {
const [selectedChannels, setSelectedChannels] = useState<number[]>([0]); const [selectedChannels, setSelectedChannels] = useState<number[]>([0]);
const [qrCodeUrl, setQrCodeUrl] = useState<string>(""); 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(() => { useEffect(() => {
const channelsToEncode = allChannels const channelsToEncode = allChannels
@ -49,8 +50,10 @@ export const QRDialog = ({
.replace(/\+/g, "-") .replace(/\+/g, "-")
.replace(/\//g, "_"); .replace(/\//g, "_");
setQrCodeUrl(`https://meshtastic.org/e/#${base64}`); setQrCodeUrl(
}, [channels, selectedChannels, loraConfig]); `https://meshtastic.org/e/${qrCodeAdd ? "?add=true" : ""}#${base64}`,
);
}, [allChannels, selectedChannels, qrCodeAdd, loraConfig]);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@ -94,6 +97,30 @@ export const QRDialog = ({
</div> </div>
<QRCode value={qrCodeUrl} size={200} qrStyle="dots" /> <QRCode value={qrCodeUrl} size={200} qrStyle="dots" />
</div> </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> </div>
<DialogFooter> <DialogFooter>
<Label>Sharable URL</Label> <Label>Sharable URL</Label>

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() {

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 <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

40
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";
@ -23,10 +23,12 @@ interface DisabledBy<T> {
export interface BaseFormBuilderProps<T> { export interface BaseFormBuilderProps<T> {
name: Path<T>; name: Path<T>;
disabled?: boolean;
disabledBy?: DisabledBy<T>[]; disabledBy?: DisabledBy<T>[];
label: string; label: string;
description?: string; description?: string;
properties?: {}; validationText?: string;
properties?: Record<string, unknown>;
} }
export interface GenericFormElementProps<T extends FieldValues, Y> { export interface GenericFormElementProps<T extends FieldValues, Y> {
@ -39,11 +41,12 @@ export interface DynamicFormProps<T extends FieldValues> {
onSubmit: SubmitHandler<T>; onSubmit: SubmitHandler<T>;
submitType?: "onChange" | "onSubmit"; submitType?: "onChange" | "onSubmit";
hasSubmitButton?: boolean; hasSubmitButton?: boolean;
// defaultValues?: DeepPartial<T>;
defaultValues?: DefaultValues<T>; defaultValues?: DefaultValues<T>;
fieldGroups: { fieldGroups: {
label: string; label: string;
description: string; description: string;
valid?: boolean;
validationText?: string;
fields: FieldProps<T>[]; fields: FieldProps<T>[];
}[]; }[];
} }
@ -60,11 +63,16 @@ export function DynamicForm<T extends FieldValues>({
defaultValues: defaultValues, defaultValues: defaultValues,
}); });
const isDisabled = (disabledBy?: DisabledBy<T>[]): boolean => { const isDisabled = (
disabledBy?: DisabledBy<T>[],
disabled?: boolean,
): boolean => {
if (disabled) return true;
if (!disabledBy) return false; if (!disabledBy) return false;
return disabledBy.some((field) => { return disabledBy.some((field) => {
const value = getValues(field.fieldName); const value = getValues(field.fieldName);
if (value === "always") return true;
if (typeof value === "boolean") return field.invert ? value : !value; if (typeof value === "boolean") return field.invert ? value : !value;
if (typeof value === "number") if (typeof value === "number")
return field.invert return field.invert
@ -94,12 +102,20 @@ export function DynamicForm<T extends FieldValues>({
</div> </div>
{fieldGroup.fields.map((field) => ( {fieldGroup.fields.map((field) => (
<FieldWrapper label={field.label} description={field.description}> <FieldWrapper
<DynamicFormField
key={field.label} key={field.label}
label={field.label}
description={field.description}
valid={
field.validationText === undefined ||
field.validationText === ""
}
validationText={field.validationText}
>
<DynamicFormField
field={field} field={field}
control={control} control={control}
disabled={isDisabled(field.disabledBy)} disabled={isDisabled(field.disabledBy, field.disabled)}
/> />
</FieldWrapper> </FieldWrapper>
))} ))}

30
src/components/Form/DynamicFormField.tsx

@ -1,12 +1,26 @@
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 {
PasswordGenerator,
type PasswordGeneratorProps,
} from "@components/Form/FormPasswordGenerator.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> =
| InputFieldProps<T> | InputFieldProps<T>
| SelectFieldProps<T> | SelectFieldProps<T>
| ToggleFieldProps<T>; | ToggleFieldProps<T>
| PasswordGeneratorProps<T>;
export interface DynamicFormFieldProps<T extends FieldValues> { export interface DynamicFormFieldProps<T extends FieldValues> {
field: FieldProps<T>; field: FieldProps<T>;
@ -35,6 +49,14 @@ export function DynamicFormField<T extends FieldValues>({
return ( return (
<SelectInput field={field} control={control} disabled={disabled} /> <SelectInput field={field} control={control} disabled={disabled} />
); );
case "passwordGenerator":
return (
<PasswordGenerator
field={field}
control={control}
disabled={disabled}
/>
);
case "multiSelect": case "multiSelect":
return <div>tmp</div>; return <div>tmp</div>;
} }

16
src/components/Form/FormInput.tsx

@ -4,11 +4,14 @@ 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 type { ChangeEventHandler } from "react";
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";
inputChange?: ChangeEventHandler;
properties?: { properties?: {
value?: string;
prefix?: string; prefix?: string;
suffix?: string; suffix?: string;
step?: number; step?: number;
@ -32,14 +35,15 @@ export function GenericInput<T extends FieldValues>({
<Input <Input
type={field.type} type={field.type}
step={field.properties?.step} step={field.properties?.step}
value={field.type === "number" ? Number.parseInt(value) : value} value={field.type === "number" ? Number.parseFloat(value) : value}
onChange={(e) => onChange={(e) => {
if (field.inputChange) field.inputChange(e);
onChange( onChange(
field.type === "number" field.type === "number"
? Number.parseInt(e.target.value) ? Number.parseFloat(e.target.value)
: e.target.value, : e.target.value,
) );
} }}
{...field.properties} {...field.properties}
{...rest} {...rest}
disabled={disabled} disabled={disabled}

46
src/components/Form/FormPasswordGenerator.tsx

@ -0,0 +1,46 @@
import type {
BaseFormBuilderProps,
GenericFormElementProps,
} from "@components/Form/DynamicForm.js";
import { Generator } from "@components/UI/Generator.js";
import type { ChangeEventHandler, MouseEventHandler } from "react";
import { Controller, type FieldValues } from "react-hook-form";
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
type: "passwordGenerator";
hide?: boolean;
bits?: { text: string; value: string; key: string }[];
devicePSKBitCount: number;
inputChange: ChangeEventHandler;
selectChange: (event: string) => void;
buttonClick: MouseEventHandler;
}
export function PasswordGenerator<T extends FieldValues>({
control,
field,
disabled,
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
return (
<Controller
name={field.name}
control={control}
render={({ field: { value, ...rest } }) => (
<Generator
hide={field.hide}
devicePSKBitCount={field.devicePSKBitCount}
bits={field.bits}
inputChange={field.inputChange}
selectChange={field.selectChange}
buttonClick={field.buttonClick}
value={value}
variant={field.validationText ? "invalid" : "default"}
buttonText="Generate"
{...field.properties}
{...rest}
disabled={disabled}
/>
)}
/>
);
}

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";

7
src/components/Form/FormWrapper.tsx

@ -5,12 +5,16 @@ export interface FieldWrapperProps {
description?: string; description?: string;
disabled?: boolean; disabled?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
valid?: boolean;
validationText?: string;
} }
export const FieldWrapper = ({ export const FieldWrapper = ({
label, label,
description, description,
children, children,
valid,
validationText,
}: FieldWrapperProps): JSX.Element => ( }: FieldWrapperProps): JSX.Element => (
<div className="pt-6 sm:pt-5"> <div className="pt-6 sm:pt-5">
<div role="group" aria-labelledby="label-notifications"> <div role="group" aria-labelledby="label-notifications">
@ -19,6 +23,9 @@ export const FieldWrapper = ({
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<div className="max-w-lg"> <div className="max-w-lg">
<p className="text-sm text-gray-500">{description}</p> <p className="text-sm text-gray-500">{description}</p>
<p hidden={valid ?? true} className="text-sm text-red-500">
{validationText}
</p>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<div className="flex items-center">{children}</div> <div className="flex items-center">{children}</div>
</div> </div>

129
src/components/PageComponents/Channel.tsx

@ -4,21 +4,38 @@ import { useToast } from "@core/hooks/useToast.js";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { Protobuf } from "@meshtastic/js"; import { Protobuf } from "@meshtastic/js";
import { fromByteArray, toByteArray } from "base64-js"; import { fromByteArray, toByteArray } from "base64-js";
import cryptoRandomString from "crypto-random-string";
import { useState } from "react";
export interface SettingsPanelProps { export interface SettingsPanelProps {
channel: Protobuf.Channel.Channel; channel: Protobuf.Channel.Channel;
} }
export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => { export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
const { connection, addChannel } = useDevice(); const { config, connection, addChannel } = useDevice();
const { toast } = useToast(); const { toast } = useToast();
const [pass, setPass] = useState<string>(
fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
);
const [bitCount, setBits] = useState<number>(
channel?.settings?.psk.length ?? 16,
);
const [validationText, setValidationText] = useState<string>();
const onSubmit = (data: ChannelValidation) => { const onSubmit = (data: ChannelValidation) => {
const channel = new Protobuf.Channel.Channel({ const channel = new Protobuf.Channel.Channel({
...data, ...data,
settings: { settings: {
...data.settings, ...data.settings,
psk: toByteArray(data.settings.psk ?? ""), psk: toByteArray(pass),
moduleSettings: {
positionPrecision: data.settings.positionEnabled
? data.settings.preciseLocation
? 32
: data.settings.positionPrecision
: 0,
},
}, },
}); });
connection?.setChannel(channel).then(() => { connection?.setChannel(channel).then(() => {
@ -29,6 +46,38 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
}); });
}; };
const clickEvent = () => {
setPass(
btoa(
cryptoRandomString({
length: bitCount ?? 0,
type: "alphanumeric",
}),
),
);
setValidationText(undefined);
};
const validatePass = (input: string, count: number) => {
if (input.length % 4 !== 0 || toByteArray(input).length !== count) {
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
} else {
setValidationText(undefined);
}
};
const inputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
const psk = e.currentTarget?.value;
setPass(psk);
validatePass(psk, bitCount);
};
const selectChangeEvent = (e: string) => {
const count = Number.parseInt(e);
setBits(count);
validatePass(pass, count);
};
return ( return (
<DynamicForm<ChannelValidation> <DynamicForm<ChannelValidation>
onSubmit={onSubmit} onSubmit={onSubmit}
@ -39,7 +88,17 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
...{ ...{
settings: { settings: {
...channel?.settings, ...channel?.settings,
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)), psk: pass,
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,
}, },
}, },
}} }}
@ -52,19 +111,28 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
type: "select", type: "select",
name: "role", name: "role",
label: "Role", label: "Role",
disabled: channel.index === 0,
description: description:
"Device telemetry is sent over PRIMARY. Only one PRIMARY allowed", "Device telemetry is sent over PRIMARY. Only one PRIMARY allowed",
properties: { properties: {
enumValue: Protobuf.Channel.Channel_Role, enumValue:
channel.index === 0
? { PRIMARY: 1 }
: { DISABLED: 0, SECONDARY: 2 },
}, },
}, },
{ {
type: "password", type: "passwordGenerator",
name: "settings.psk", name: "settings.psk",
label: "pre-Shared Key", label: "pre-Shared Key",
description: "16, or 32 bytes", description: "256, 128, or 8 bit PSKs allowed",
validationText: validationText,
devicePSKBitCount: bitCount ?? 0,
inputChange: inputChangeEvent,
selectChange: selectChangeEvent,
buttonClick: clickEvent,
properties: { properties: {
// act value: pass,
}, },
}, },
{ {
@ -86,6 +154,53 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
label: "Downlink Enabled", label: "Downlink Enabled",
description: "Send messages from MQTT to the local mesh", 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,
},
},
},
], ],
}, },
]} ]}

36
src/components/PageComponents/Config/Device.tsx

@ -32,22 +32,24 @@ export const Device = (): JSX.Element => {
label: "Role", label: "Role",
description: "What role the device performs on the mesh", description: "What role the device performs on the mesh",
properties: { properties: {
enumValue: Protobuf.Config.Config_DeviceConfig_Role, enumValue: {
formatEnumName: true, Client: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
}, "Client Mute":
Protobuf.Config.Config_DeviceConfig_Role.CLIENT_MUTE,
Router: Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
Repeater: Protobuf.Config.Config_DeviceConfig_Role.REPEATER,
Tracker: Protobuf.Config.Config_DeviceConfig_Role.TRACKER,
Sensor: Protobuf.Config.Config_DeviceConfig_Role.SENSOR,
TAK: Protobuf.Config.Config_DeviceConfig_Role.TAK,
"Client Hidden":
Protobuf.Config.Config_DeviceConfig_Role.CLIENT_HIDDEN,
"Lost and Found":
Protobuf.Config.Config_DeviceConfig_Role.LOST_AND_FOUND,
"TAK Tracker":
Protobuf.Config.Config_DeviceConfig_Role.SENSOR,
}, },
{ formatEnumName: true,
type: "toggle",
name: "serialEnabled",
label: "Serial Output Enabled",
description: "Enable the device's serial console",
}, },
{
type: "toggle",
name: "debugLogEnabled",
label: "Enabled Debug Log",
description:
"Output debugging information to the device's serial port (auto disables when serial client is connected)",
}, },
{ {
type: "number", type: "number",
@ -86,12 +88,6 @@ export const Device = (): JSX.Element => {
label: "Double Tap as Button Press", label: "Double Tap as Button Press",
description: "Treat double tap as button press", description: "Treat double tap as button press",
}, },
{
type: "toggle",
name: "isManaged",
label: "Managed",
description: "Is this device managed by a mesh administator",
},
{ {
type: "toggle", type: "toggle",
name: "disableTripleClick", name: "disableTripleClick",

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

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

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

@ -36,10 +36,13 @@ export const LoRa = (): JSX.Element => {
}, },
}, },
{ {
type: "number", type: "select",
name: "hopLimit", name: "hopLimit",
label: "Hop Limit", label: "Hop Limit",
description: "Maximum number of hops", description: "Maximum number of hops",
properties: {
enumValue: { 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7 },
},
}, },
{ {
type: "number", type: "number",
@ -53,6 +56,13 @@ export const LoRa = (): JSX.Element => {
label: "Ignore MQTT", label: "Ignore MQTT",
description: "Don't forward MQTT messages over the mesh", description: "Don't forward MQTT messages over the mesh",
}, },
{
type: "toggle",
name: "configOkToMqtt",
label: "OK to MQTT",
description:
"When set to true, this configuration indicates that the user approves the packet to be uploaded to MQTT. If set to false, remote nodes are requested not to forward packets to MQTT",
},
], ],
}, },
{ {

27
src/components/PageComponents/Config/Network.tsx

@ -1,6 +1,10 @@
import type { NetworkValidation } from "@app/validation/config/network.js"; import type { NetworkValidation } from "@app/validation/config/network.js";
import { DynamicForm } from "@components/Form/DynamicForm.js"; import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import {
convertIntToIpAddress,
convertIpAddressToInt,
} from "@core/utils/ip.js";
import { Protobuf } from "@meshtastic/js"; import { Protobuf } from "@meshtastic/js";
export const Network = (): JSX.Element => { export const Network = (): JSX.Element => {
@ -13,9 +17,12 @@ export const Network = (): JSX.Element => {
case: "network", case: "network",
value: { value: {
...data, ...data,
ipv4Config: new Protobuf.Config.Config_NetworkConfig_IpV4Config( ipv4Config: new Protobuf.Config.Config_NetworkConfig_IpV4Config({
data.ipv4Config, ip: convertIpAddressToInt(data.ipv4Config.ip) ?? 0,
), gateway: convertIpAddressToInt(data.ipv4Config.gateway) ?? 0,
subnet: convertIpAddressToInt(data.ipv4Config.subnet) ?? 0,
dns: convertIpAddressToInt(data.ipv4Config.dns) ?? 0,
}),
}, },
}, },
}), }),
@ -25,7 +32,19 @@ export const Network = (): JSX.Element => {
return ( return (
<DynamicForm<NetworkValidation> <DynamicForm<NetworkValidation>
onSubmit={onSubmit} onSubmit={onSubmit}
defaultValues={config.network} defaultValues={{
...config.network,
ipv4Config: {
ip: convertIntToIpAddress(config.network?.ipv4Config?.ip ?? 0),
gateway: convertIntToIpAddress(
config.network?.ipv4Config?.gateway ?? 0,
),
subnet: convertIntToIpAddress(
config.network?.ipv4Config?.subnet ?? 0,
),
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
},
}}
fieldGroups={[ fieldGroups={[
{ {
label: "WiFi Config", label: "WiFi Config",

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

@ -33,6 +33,16 @@ export const Position = (): JSX.Element => {
description: description:
"Only send position when there has been a meaningful change in location", "Only send position when there has been a meaningful change in location",
}, },
{
type: "select",
name: "gpsMode",
label: "GPS Mode",
description:
"Configure whether device GPS is Enabled, Disabled, or Not Present",
properties: {
enumValue: Protobuf.Config.Config_PositionConfig_GpsMode,
},
},
{ {
type: "toggle", type: "toggle",
name: "fixedPosition", name: "fixedPosition",
@ -40,12 +50,6 @@ export const Position = (): JSX.Element => {
description: description:
"Don't report GPS position, but a manually-specified one", "Don't report GPS position, but a manually-specified one",
}, },
{
type: "toggle",
name: "gpsEnabled",
label: "GPS Enabled",
description: "Enable the internal GPS module",
},
{ {
type: "multiSelect", type: "multiSelect",
name: "positionFlags", name: "positionFlags",
@ -73,21 +77,6 @@ export const Position = (): JSX.Element => {
label: "Enable Pin", label: "Enable Pin",
description: "GPS module enable pin override", description: "GPS module enable pin override",
}, },
{
type: "select",
name: "gpsMode",
label: "GPS Mode",
description: "GPS module mode",
properties: {
enumValue: Protobuf.Config.Config_PositionConfig_GpsMode,
},
},
{
type: "number",
name: "channelPrecision",
label: "Channel Precision",
description: "GPS channel precision",
},
], ],
}, },
{ {
@ -99,18 +88,18 @@ export const Position = (): JSX.Element => {
name: "positionBroadcastSecs", name: "positionBroadcastSecs",
label: "Broadcast Interval", label: "Broadcast Interval",
description: "How often your position is sent out over the mesh", description: "How often your position is sent out over the mesh",
properties: {
suffix: "Seconds",
},
}, },
{ {
type: "number", type: "number",
name: "gpsUpdateInterval", name: "gpsUpdateInterval",
label: "GPS Update Interval", label: "GPS Update Interval",
description: "How often a GPS fix should be acquired", description: "How often a GPS fix should be acquired",
properties: {
suffix: "Seconds",
}, },
{
type: "number",
name: "gpsAttemptTime",
label: "Fix Attempt Duration",
description: "How long the device will try to get a fix for",
}, },
{ {
type: "number", type: "number",

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

@ -49,7 +49,7 @@ export const Power = (): JSX.Element => {
label: "ADC Multiplier Override ratio", label: "ADC Multiplier Override ratio",
description: "Used for tweaking battery voltage reading", description: "Used for tweaking battery voltage reading",
properties: { properties: {
step: 0.01, step: 0.0001,
}, },
}, },
{ {

237
src/components/PageComponents/Config/Security.tsx

@ -0,0 +1,237 @@
import { PkiRegenerateDialog } from "@app/components/Dialog/PkiRegenerateDialog";
import { DynamicForm } from "@app/components/Form/DynamicForm.js";
import {
getX25519PrivateKey,
getX25519PublicKey,
} from "@app/core/utils/x25519";
import type { SecurityValidation } from "@app/validation/config/security.js";
import { useDevice } from "@core/stores/deviceStore.js";
import { Protobuf } from "@meshtastic/js";
import { fromByteArray, toByteArray } from "base64-js";
import { Eye, EyeOff } from "lucide-react";
import { useState } from "react";
export const Security = (): JSX.Element => {
const { config, nodes, hardware, setWorkingConfig } = useDevice();
const [privateKey, setPrivateKey] = useState<string>(
fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
);
const [privateKeyVisible, setPrivateKeyVisible] = useState<boolean>(false);
const [privateKeyBitCount, setPrivateKeyBitCount] = useState<number>(
config.security?.privateKey.length ?? 32,
);
const [privateKeyValidationText, setPrivateKeyValidationText] =
useState<string>();
const [publicKey, setPublicKey] = useState<string>(
fromByteArray(config.security?.publicKey ?? new Uint8Array(0)),
);
const [adminKey, setAdminKey] = useState<string>(
fromByteArray(config.security?.adminKey[0] ?? new Uint8Array(0)),
);
const [adminKeyValidationText, setAdminKeyValidationText] =
useState<string>();
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const onSubmit = (data: SecurityValidation) => {
if (privateKeyValidationText || adminKeyValidationText) return;
setWorkingConfig(
new Protobuf.Config.Config({
payloadVariant: {
case: "security",
value: {
...data,
adminKey: [toByteArray(adminKey)],
privateKey: toByteArray(privateKey),
publicKey: toByteArray(publicKey),
},
},
}),
);
};
const validateKey = (
input: string,
count: number,
setValidationText: (
value: React.SetStateAction<string | undefined>,
) => void,
) => {
try {
if (input.length % 4 !== 0 || toByteArray(input).length !== count) {
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
} else {
setValidationText(undefined);
}
} catch (e) {
console.error(e);
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
}
};
const privateKeyClickEvent = () => {
setDialogOpen(true);
};
const pkiRegenerate = () => {
const privateKey = getX25519PrivateKey();
const publicKey = getX25519PublicKey(privateKey);
setPrivateKey(fromByteArray(privateKey));
setPublicKey(fromByteArray(publicKey));
validateKey(
fromByteArray(privateKey),
privateKeyBitCount,
setPrivateKeyValidationText,
);
setDialogOpen(false);
};
const privateKeyInputChangeEvent = (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const privateKeyB64String = e.target.value;
setPrivateKey(privateKeyB64String);
validateKey(
privateKeyB64String,
privateKeyBitCount,
setPrivateKeyValidationText,
);
const publicKey = getX25519PublicKey(toByteArray(privateKeyB64String));
setPublicKey(fromByteArray(publicKey));
};
const adminKeyInputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
const psk = e.currentTarget?.value;
setAdminKey(psk);
validateKey(psk, privateKeyBitCount, setAdminKeyValidationText);
};
const privateKeySelectChangeEvent = (e: string) => {
const count = Number.parseInt(e);
setPrivateKeyBitCount(count);
validateKey(privateKey, count, setPrivateKeyValidationText);
};
return (
<>
<DynamicForm<SecurityValidation>
onSubmit={onSubmit}
submitType="onChange"
defaultValues={{
...config.security,
...{
adminKey: adminKey,
privateKey: privateKey,
publicKey: publicKey,
adminChannelEnabled: config.security?.adminChannelEnabled ?? false,
isManaged: config.security?.isManaged ?? false,
debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false,
serialEnabled: config.security?.serialEnabled ?? false,
},
}}
fieldGroups={[
{
label: "Security Settings",
description: "Settings for the Security configuration",
fields: [
{
type: "passwordGenerator",
name: "privateKey",
label: "Private Key",
description: "Used to create a shared key with a remote device",
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
validationText: privateKeyValidationText,
devicePSKBitCount: privateKeyBitCount,
inputChange: privateKeyInputChangeEvent,
selectChange: privateKeySelectChangeEvent,
hide: !privateKeyVisible,
buttonClick: privateKeyClickEvent,
properties: {
value: privateKey,
action: {
icon: privateKeyVisible ? EyeOff : Eye,
onClick: () => setPrivateKeyVisible(!privateKeyVisible),
},
},
},
{
type: "text",
name: "publicKey",
label: "Public Key",
disabled: true,
description:
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
properties: {
value: publicKey,
},
},
],
},
{
label: "Admin Settings",
description: "Settings for Admin",
fields: [
{
type: "toggle",
name: "adminChannelEnabled",
label: "Allow Legacy Admin",
description:
"Allow incoming device control over the insecure legacy admin channel",
},
{
type: "toggle",
name: "isManaged",
label: "Managed",
description:
'If true, device is considered to be "managed" by a mesh administrator via admin messages',
},
{
type: "text",
name: "adminKey",
label: "Admin Key",
description:
"The public key authorized to send admin messages to this node",
validationText: adminKeyValidationText,
inputChange: adminKeyInputChangeEvent,
disabledBy: [
{ fieldName: "adminChannelEnabled", invert: true },
],
properties: {
value: adminKey,
},
},
],
},
{
label: "Logging Settings",
description: "Settings for Logging",
fields: [
{
type: "toggle",
name: "debugLogApiEnabled",
label: "Enable Debug Log API",
description:
"Output live debug logging over serial, view and export position-redacted device logs over Bluetooth",
},
{
type: "toggle",
name: "serialEnabled",
label: "Serial Output Enabled",
description: "Serial Console over the Stream API",
},
],
},
]}
/>
<PkiRegenerateDialog
open={dialogOpen}
onOpenChange={() => setDialogOpen(false)}
onSubmit={() => pkiRegenerate()}
/>
</>
);
};

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>
); );

15
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) => {
const { usbProductId, usbVendorId } = port.getInfo();
return (
<Button <Button
key={index} key={`${usbVendorId ?? "UNK"}-${usbProductId ?? "UNK"}-${index}`}
disabled={port.readable !== null} disabled={port.readable !== null}
onClick={async () => { onClick={async () => {
await onConnect(port); await onConnect(port);
}} }}
> >
{`# ${index} - ${port.getInfo().usbVendorId ?? "UNK"} - ${ {`# ${index} - ${usbVendorId ?? "UNK"} - ${
port.getInfo().usbProductId ?? "UNK" usbProductId ?? "UNK"
}`} }`}
</Button> </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>
)} )}

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

@ -1,25 +1,32 @@
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 { MessageInput } from "@components/PageComponents/Messages/MessageInput.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"; import { InboxIcon } from "lucide-react";
export interface ChannelChatProps { export interface ChannelChatProps {
messages?: MessageWithState[]; messages?: MessageWithState[];
channel: Types.ChannelNumber; channel: Types.ChannelNumber;
to: Types.Destination; to: Types.Destination;
traceroutes?: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[];
} }
export const ChannelChat = ({ export const ChannelChat = ({
messages, messages,
channel, channel,
to, to,
traceroutes,
}: ChannelChatProps): JSX.Element => { }: ChannelChatProps): JSX.Element => {
const { nodes } = useDevice(); const { nodes } = useDevice();
return ( return (
<div className="flex flex-grow flex-col"> <div className="flex flex-grow flex-col">
<div className="flex flex-grow">
<div className="flex flex-grow flex-col"> <div className="flex flex-grow flex-col">
{messages ? ( {messages ? (
messages.map((message, index) => ( messages.map((message, index) => (
@ -27,7 +34,9 @@ export const ChannelChat = ({
key={message.id} key={message.id}
message={message} message={message}
lastMsgSameUser={ lastMsgSameUser={
index === 0 ? false : messages[index - 1].from === message.from index === 0
? false
: messages[index - 1].from === message.from
} }
sender={nodes.get(message.from)} sender={nodes.get(message.from)}
/> />
@ -39,7 +48,27 @@ export const ChannelChat = ({
</div> </div>
)} )}
</div> </div>
<div className="p-3"> <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="pl-3 pr-3 pt-3 pb-1">
<MessageInput to={to} channel={channel} /> <MessageInput to={to} channel={channel} />
</div> </div>
</div> </div>

2
src/components/PageComponents/Messages/MessageInput.tsx

@ -62,7 +62,7 @@ export const MessageInput = ({
<span className="w-full"> <span className="w-full">
<Input <Input
autoFocus={true} autoFocus={true}
minLength={2} minLength={1}
placeholder="Enter Message" placeholder="Enter Message"
value={messageDraft} value={messageDraft}
onChange={(e) => setMessageDraft(e.target.value)} onChange={(e) => setMessageDraft(e.target.value)}

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

@ -0,0 +1,36 @@
import { useDevice } from "@app/core/stores/deviceStore.js";
import type { Protobuf } from "@meshtastic/js";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
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) => {
const node = nodes.get(hop);
return `${node?.user?.longName ?? (node?.num ? numberToHexUnpadded(node.num) : "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", 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: {
suffix: "Seconds",
},
disabledBy: [ disabledBy: [
{ {
fieldName: "enabled", 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"; 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",
@ -138,6 +145,9 @@ export const MQTT = (): JSX.Element => {
name: "mapReportSettings.publishIntervalSecs", name: "mapReportSettings.publishIntervalSecs",
label: "Map Report Publish Interval (s)", label: "Map Report Publish Interval (s)",
description: "Interval in seconds to publish map reports", description: "Interval in seconds to publish map reports",
properties: {
suffix: "Seconds",
},
disabledBy: [ disabledBy: [
{ {
fieldName: "enabled", fieldName: "enabled",
@ -148,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/NeighborInfo.tsx

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

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

@ -36,7 +36,11 @@ 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: {
suffix: "Seconds",
},
disabledBy: [ disabledBy: [
{ {
fieldName: "enabled", fieldName: "enabled",

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

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

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

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

11
src/components/PageLayout.tsx

@ -1,5 +1,6 @@
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";
import Footer from "./UI/Footer";
export interface PageLayoutProps { export interface PageLayoutProps {
label: string; label: string;
@ -7,6 +8,7 @@ export interface PageLayoutProps {
children: React.ReactNode; children: React.ReactNode;
actions?: { actions?: {
icon: LucideIcon; icon: LucideIcon;
iconClasses?: string;
onClick: () => void; onClick: () => void;
}[]; }[];
} }
@ -18,6 +20,7 @@ export const PageLayout = ({
children, children,
}: PageLayoutProps): JSX.Element => { }: PageLayoutProps): JSX.Element => {
return ( return (
<>
<div className="relative flex h-full w-full flex-col"> <div className="relative flex h-full w-full flex-col">
<div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4"> <div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4">
<button <button
@ -37,7 +40,7 @@ export const PageLayout = ({
className="transition-all hover:text-accent" className="transition-all hover:text-accent"
onClick={action.onClick} onClick={action.onClick}
> >
<action.icon /> <action.icon className={action.iconClasses} />
</button> </button>
))} ))}
</div> </div>
@ -47,11 +50,13 @@ export const PageLayout = ({
<div <div
className={cn( className={cn(
"flex h-full w-full flex-col overflow-y-auto", "flex h-full w-full flex-col overflow-y-auto",
!noPadding && "p-3", !noPadding && "pl-3 pr-3 ",
)} )}
> >
{children} {children}
<Footer />
</div> </div>
</div> </div>
</>
); );
}; };

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 { 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,
} from "lucide-react"; } from "lucide-react";
export interface SidebarProps { export interface SidebarProps {
@ -18,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 {
@ -58,7 +62,7 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
return ( 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="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> <div>
<span className="text-lg font-medium"> <span className="text-lg font-medium">
{myNode?.user?.shortName ?? "UNK"} {myNode?.user?.shortName ?? "UNK"}
@ -73,6 +77,22 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
<EditIcon size={16} /> <EditIcon size={16} />
</button> </button>
</div> </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"> <SidebarSection label="Navigation">
{pages.map((link) => ( {pages.map((link) => (

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>{title}</ToastTitle>} {title && (
{description && <ToastDescription>{description}</ToastDescription>} <ToastTitle className="dark:text-white">{title}</ToastTitle>
)}
{description && (
<ToastDescription className="dark:text-white-400">
{description}
</ToastDescription>
)}
</div> </div>
{action} {action}
<ToastClose /> <ToastClose />

4
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";
@ -12,6 +12,8 @@ const buttonVariants = cva(
"bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900", "bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900",
destructive: destructive:
"bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600", "bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600",
success:
"bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600",
outline: outline:
"bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100", "bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100",
subtle: subtle:

2
src/components/UI/Command.tsx

@ -116,7 +116,7 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item <CommandPrimitive.Item
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...props}

37
src/components/UI/Footer.tsx

@ -0,0 +1,37 @@
import React from "react";
export interface FooterProps extends React.HTMLAttributes<HTMLElement> {}
const Footer = React.forwardRef<HTMLElement, FooterProps>(
({ className, ...props }, ref) => {
return (
<footer
className={`flex flex- justify-center p-2 ${className}`}
style={{
backgroundColor: "var(--backgroundPrimary)",
color: "var(--textPrimary)",
}}
>
<p>
<a
href="https://vercel.com/?utm_source=meshtastic&utm_campaign=oss"
className="hover:underline"
style={{ color: "var(--link)" }}
>
Powered by Vercel
</a>{" "}
| Meshtastic® is a registered trademark of Meshtastic LLC. |{" "}
<a
href="https://meshtastic.org/docs/legal"
className="hover:underline"
style={{ color: "var(--link)" }}
>
Legal Information
</a>
</p>
</footer>
);
},
);
export default Footer;

111
src/components/UI/Generator.tsx

@ -0,0 +1,111 @@
import * as React from "react";
import { Button } from "@components/UI/Button.js";
import { Input } from "@components/UI/Input.js";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/UI/Select.js";
import type { LucideIcon } from "lucide-react";
export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
hide?: boolean;
devicePSKBitCount?: number;
value: string;
variant: "default" | "invalid";
buttonText?: string;
bits?: { text: string; value: string; key: string }[];
selectChange: (event: string) => void;
inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
buttonClick: React.MouseEventHandler<HTMLButtonElement>;
action?: {
icon: LucideIcon;
onClick: () => void;
};
disabled?: boolean;
}
const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
(
{
hide = true,
devicePSKBitCount,
variant,
value,
buttonText,
bits = [
{ text: "256 bit", value: "32", key: "bit256" },
{ text: "128 bit", value: "16", key: "bit128" },
{ text: "8 bit", value: "1", key: "bit8" },
],
selectChange,
inputChange,
buttonClick,
action,
disabled,
...props
},
ref,
) => {
const inputRef = React.useRef<HTMLInputElement>(null);
// Invokes onChange event on the input element when the value changes from the parent component
React.useEffect(() => {
if (!inputRef.current) return;
const setValue = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
if (!setValue) return;
inputRef.current.value = "";
setValue.call(inputRef.current, value);
inputRef.current.dispatchEvent(new Event("input", { bubbles: true }));
}, [value]);
return (
<>
<Input
type={hide ? "password" : "text"}
id="pskInput"
variant={variant}
value={value}
onChange={inputChange}
action={action}
disabled={disabled}
ref={inputRef}
/>
<Select
value={devicePSKBitCount?.toString()}
onValueChange={(e) => selectChange(e)}
disabled={disabled}
>
<SelectTrigger className="!max-w-max">
<SelectValue />
</SelectTrigger>
<SelectContent>
{bits.map(({ text, value, key }) => (
<SelectItem key={key} value={value}>
{text}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="success"
onClick={buttonClick}
disabled={disabled}
{...props}
>
{buttonText}
</Button>
</>
);
},
);
Generator.displayName = "Button";
export { Generator };

28
src/components/UI/Input.tsx

@ -1,10 +1,27 @@
import * as React from "react"; import * as React from "react";
import { cn } from "@core/utils/cn.js"; import { cn } from "@core/utils/cn.js";
import { type VariantProps, cva } from "class-variance-authority";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
const inputVariants = cva(
"flex h-10 w-full rounded-md border bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:text-slate-50 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900",
{
variants: {
variant: {
default: "border-slate-300 dark:border-slate-700",
invalid: "border-red-500 dark:border-red-500",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface InputProps export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> { extends React.InputHTMLAttributes<HTMLInputElement>,
VariantProps<typeof inputVariants> {
prefix?: string; prefix?: string;
suffix?: string; suffix?: string;
action?: { action?: {
@ -14,7 +31,7 @@ export interface InputProps
} }
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, prefix, suffix, action, ...props }, ref) => { ({ className, value, variant, prefix, suffix, action, ...props }, ref) => {
return ( return (
<div className="relative w-full"> <div className="relative w-full">
{prefix && ( {prefix && (
@ -24,15 +41,16 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
)} )}
<input <input
className={cn( className={cn(
"flex h-10 w-full rounded-md border border-slate-300 bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-50 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900",
action && "pr-8", action && "pr-8",
className, className,
inputVariants({ variant }),
)} )}
value={value}
ref={ref} ref={ref}
{...props} {...props}
/> />
{suffix && ( {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> <span className="text-gray-500 sm:text-sm">{suffix}</span>
</div> </div>
)} )}
@ -51,4 +69,4 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
); );
Input.displayName = "Input"; Input.displayName = "Input";
export { Input }; export { Input, inputVariants };

2
src/components/UI/Tabs.tsx

@ -12,7 +12,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex flex-wrap items-center rounded-md bg-slate-100 p-1 dark:bg-slate-800", "inline-flex flex-wrap items-center rounded-md bg-slate-100 p-1 mt-2 dark:bg-slate-800",
className, className,
)} )}
{...props} {...props}

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"
> >

30
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) {
}; };
} }
function useToast() { const subscribe = (listener: () => void) => {
const [state, setState] = useState<State>(memoryState); listeners.push(listener);
return function unsubscribe() {
useEffect(() => { const index = listeners.indexOf(listener);
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) { if (index > -1) {
listeners.splice(index, 1); listeners.splice(index, 1);
} }
}; };
}, [state]); };
const getState = () => {
return memoryState;
};
function useToast() {
const state = useSyncExternalStore(subscribe, getState);
return { return {
...state, ...state,

13
src/core/stores/appStore.ts

@ -27,6 +27,7 @@ interface AppState {
commandPaletteOpen: boolean; commandPaletteOpen: boolean;
darkMode: boolean; darkMode: boolean;
notifications: boolean; notifications: boolean;
nodeNumToBeRemoved: number;
accent: AccentColor; accent: AccentColor;
connectDialogOpen: boolean; connectDialogOpen: boolean;
@ -40,6 +41,7 @@ interface AppState {
setCommandPaletteOpen: (open: boolean) => void; setCommandPaletteOpen: (open: boolean) => void;
setDarkMode: (enabled: boolean) => void; setDarkMode: (enabled: boolean) => void;
setNotifications: (enabled: boolean) => void; setNotifications: (enabled: boolean) => void;
setNodeNumToBeRemoved: (nodeNum: number) => void;
setAccent: (color: AccentColor) => void; setAccent: (color: AccentColor) => void;
setConnectDialogOpen: (open: boolean) => void; setConnectDialogOpen: (open: boolean) => void;
} }
@ -50,10 +52,14 @@ export const useAppStore = create<AppState>()((set) => ({
currentPage: "messages", currentPage: "messages",
rasterSources: [], rasterSources: [],
commandPaletteOpen: false, commandPaletteOpen: false,
darkMode: window.matchMedia("(prefers-color-scheme: dark)").matches,
notifications: true, notifications: true,
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,
setRasterSources: (sources: RasterSource[]) => { setRasterSources: (sources: RasterSource[]) => {
set( set(
@ -96,6 +102,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;
@ -109,6 +116,10 @@ export const useAppStore = create<AppState>()((set) => ({
}), }),
); );
}, },
setNodeNumToBeRemoved: (nodeNum) =>
set((state) => ({
nodeNumToBeRemoved: nodeNum,
})),
setAccent(color) { setAccent(color) {
set( set(
produce<AppState>((draft) => { produce<AppState>((draft) => {

49
src/core/stores/deviceStore.ts

@ -24,7 +24,8 @@ export type DialogVariant =
| "QR" | "QR"
| "shutdown" | "shutdown"
| "reboot" | "reboot"
| "deviceName"; | "deviceName"
| "nodeRemoval";
export interface Device { export interface Device {
id: number; id: number;
@ -41,6 +42,10 @@ export interface Device {
direct: Map<number, MessageWithState[]>; direct: Map<number, MessageWithState[]>;
broadcast: Map<Types.ChannelNumber, MessageWithState[]>; broadcast: Map<Types.ChannelNumber, MessageWithState[]>;
}; };
traceroutes: Map<
number,
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
>;
connection?: Types.ConnectionType; connection?: Types.ConnectionType;
activePage: Page; activePage: Page;
activeNode: number; activeNode: number;
@ -54,6 +59,7 @@ export interface Device {
shutdown: boolean; shutdown: boolean;
reboot: boolean; reboot: boolean;
deviceName: boolean; deviceName: boolean;
nodeRemoval: boolean;
}; };
setStatus: (status: Types.DeviceStatusEnum) => void; setStatus: (status: Types.DeviceStatusEnum) => void;
@ -73,7 +79,11 @@ export interface Device {
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void; addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
addConnection: (connection: Types.ConnectionType) => void; addConnection: (connection: Types.ConnectionType) => void;
addMessage: (message: MessageWithState) => void; addMessage: (message: MessageWithState) => void;
addTraceRoute: (
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
) => void;
addMetadata: (from: number, metadata: Protobuf.Mesh.DeviceMetadata) => void; addMetadata: (from: number, metadata: Protobuf.Mesh.DeviceMetadata) => void;
removeNode: (nodeNum: number) => void;
setMessageState: ( setMessageState: (
type: "direct" | "broadcast", type: "direct" | "broadcast",
channelIndex: Types.ChannelNumber, channelIndex: Types.ChannelNumber,
@ -119,6 +129,7 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
direct: new Map(), direct: new Map(),
broadcast: new Map(), broadcast: new Map(),
}, },
traceroutes: new Map(),
connection: undefined, connection: undefined,
activePage: "messages", activePage: "messages",
activeNode: 0, activeNode: 0,
@ -130,6 +141,7 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
shutdown: false, shutdown: false,
reboot: false, reboot: false,
deviceName: false, deviceName: false,
nodeRemoval: false,
}, },
pendingSettingsChanges: false, pendingSettingsChanges: false,
messageDraft: "", messageDraft: "",
@ -179,6 +191,9 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
device.config.bluetooth = config.payloadVariant.value; device.config.bluetooth = config.payloadVariant.value;
break; break;
} }
case "security": {
device.config.security = config.payloadVariant.value;
}
} }
} }
}), }),
@ -483,6 +498,7 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
}), }),
); );
}, },
addMetadata: (from, metadata) => { addMetadata: (from, metadata) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
@ -494,6 +510,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: ( setMessageState: (
type: "direct" | "broadcast", type: "direct" | "broadcast",
channelIndex: Types.ChannelNumber, channelIndex: Types.ChannelNumber,

6
src/core/subscriptions.ts

@ -90,6 +90,12 @@ export const subscribeAll = (
} }
}); });
connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => {
device.addTraceRoute({
...traceRoutePacket,
});
});
connection.events.onPendingSettingsChange.subscribe((state) => { connection.events.onPendingSettingsChange.subscribe((state) => {
device.setPendingSettingsChanges(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"; import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {

14
src/core/utils/ip.ts

@ -0,0 +1,14 @@
export function convertIntToIpAddress(int: number): string {
return `${int & 0xff}.${(int >> 8) & 0xff}.${(int >> 16) & 0xff}.${(int >> 24) & 0xff}`;
}
export function convertIpAddressToInt(ip: string): number | null {
return (
ip
.split(".")
.reverse()
.reduce((ipnum, octet) => {
return (ipnum << 8) + Number.parseInt(octet);
}, 0) >>> 0
);
}

15
src/core/utils/x25519.ts

@ -0,0 +1,15 @@
import { x25519 } from "@noble/curves/ed25519";
export function getX25519PrivateKey(): Uint8Array {
const key = x25519.utils.randomPrivateKey();
key[0] &= 248;
key[31] &= 127;
key[31] |= 64;
return key;
}
export function getX25519PublicKey(privateKey: Uint8Array): Uint8Array {
return x25519.getPublicKey(privateKey);
}

10
src/pages/Config/DeviceConfig.tsx

@ -5,6 +5,7 @@ import { LoRa } from "@components/PageComponents/Config/LoRa.js";
import { Network } from "@components/PageComponents/Config/Network.js"; import { Network } from "@components/PageComponents/Config/Network.js";
import { Position } from "@components/PageComponents/Config/Position.js"; import { Position } from "@components/PageComponents/Config/Position.js";
import { Power } from "@components/PageComponents/Config/Power.js"; import { Power } from "@components/PageComponents/Config/Power.js";
import { Security } from "@components/PageComponents/Config/Security.js";
import { import {
Tabs, Tabs,
TabsContent, TabsContent,
@ -47,16 +48,17 @@ export const DeviceConfig = (): JSX.Element => {
label: "Bluetooth", label: "Bluetooth",
element: Bluetooth, element: Bluetooth,
}, },
{
label: "Security",
element: Security,
},
]; ];
return ( return (
<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,

2
src/pages/Dashboard/index.tsx

@ -21,6 +21,7 @@ export const Dashboard = () => {
const devices = useMemo(() => getDevices(), [getDevices]); const devices = useMemo(() => getDevices(), [getDevices]);
return ( return (
<>
<div className="flex flex-col gap-3 p-3"> <div className="flex flex-col gap-3 p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-1"> <div className="space-y-1">
@ -95,5 +96,6 @@ export const Dashboard = () => {
)} )}
</div> </div>
</div> </div>
</>
); );
}; };

30
src/pages/Map.tsx

@ -7,6 +7,7 @@ import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.js";
import { useAppStore } from "@core/stores/appStore.js"; import { useAppStore } from "@core/stores/appStore.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 { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { bbox, lineString } from "@turf/turf"; import { bbox, lineString } from "@turf/turf";
import { import {
BoxSelectIcon, BoxSelectIcon,
@ -14,20 +15,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 +65,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 +129,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={{
@ -139,8 +145,8 @@ export const MapPage = (): JSX.Element => {
{waypoints.map((wp) => ( {waypoints.map((wp) => (
<Marker <Marker
key={wp.id} key={wp.id}
longitude={wp.longitudeI / 1e7} longitude={(wp.longitudeI ?? 0) / 1e7}
latitude={wp.latitudeI / 1e7} latitude={(wp.latitudeI ?? 0) / 1e7}
anchor="bottom" anchor="bottom"
> >
<div> <div>
@ -158,12 +164,10 @@ export const MapPage = (): JSX.Element => {
return ( return (
<Marker <Marker
key={node.num} key={node.num}
longitude={node.position.longitudeI / 1e7} longitude={(node.position.longitudeI ?? 0) / 1e7}
latitude={node.position.latitudeI / 1e7} latitude={(node.position.latitudeI ?? 0) / 1e7}
style={{ filter: darkMode ? "invert(1)" : "" }}
anchor="bottom" anchor="bottom"
>
<div
className="flex cursor-pointer gap-2 rounded-md border bg-backgroundPrimary p-1.5"
onClick={() => { onClick={() => {
map?.easeTo({ map?.easeTo({
zoom: 12, zoom: 12,
@ -174,9 +178,11 @@ export const MapPage = (): JSX.Element => {
}); });
}} }}
> >
<div className="flex cursor-pointer gap-2 rounded-md border bg-backgroundPrimary p-1.5">
<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 ||
`!${numberToHexUnpadded(node.num)}`}
</Subtle> </Subtle>
</div> </div>
</Marker> </Marker>

55
src/pages/Messages.tsx

@ -3,15 +3,18 @@ import { PageLayout } from "@components/PageLayout.js";
import { Sidebar } from "@components/Sidebar.js"; import { Sidebar } from "@components/Sidebar.js";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.js"; import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.js";
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.js"; import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.js";
import { useToast } from "@core/hooks/useToast.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, Types } from "@meshtastic/js"; import { Protobuf, Types } from "@meshtastic/js";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { getChannelName } from "@pages/Channels.js"; import { getChannelName } from "@pages/Channels.js";
import { HashIcon } from "lucide-react"; import { HashIcon, LockIcon, LockOpenIcon, WaypointsIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
export const MessagesPage = (): JSX.Element => { export const MessagesPage = (): JSX.Element => {
const { channels, nodes, hardware, messages } = useDevice(); const { channels, nodes, hardware, messages, traceroutes, connection } =
useDevice();
const [chatType, setChatType] = const [chatType, setChatType] =
useState<Types.PacketDestination>("broadcast"); useState<Types.PacketDestination>("broadcast");
const [activeChat, setActiveChat] = useState<number>( const [activeChat, setActiveChat] = useState<number>(
@ -25,6 +28,9 @@ export const MessagesPage = (): JSX.Element => {
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED, (ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
); );
const currentChannel = channels.get(activeChat); const currentChannel = channels.get(activeChat);
const { toast } = useToast();
const node = nodes.get(activeChat);
const nodeHex = node?.num ? numberToHexUnpadded(node.num) : "Unknown";
return ( return (
<> <>
@ -53,7 +59,7 @@ export const MessagesPage = (): JSX.Element => {
{filteredNodes.map((node) => ( {filteredNodes.map((node) => (
<SidebarButton <SidebarButton
key={node.num} key={node.num}
label={node.user?.longName ?? "Unknown"} label={node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`}
active={activeChat === node.num} active={activeChat === node.num}
onClick={() => { onClick={() => {
setChatType("direct"); setChatType("direct");
@ -64,14 +70,53 @@ export const MessagesPage = (): JSX.Element => {
))} ))}
</SidebarSection> </SidebarSection>
</Sidebar> </Sidebar>
<div className="flex flex-col flex-grow">
<PageLayout <PageLayout
label={`Messages: ${ label={`Messages: ${
chatType === "broadcast" && currentChannel chatType === "broadcast" && currentChannel
? getChannelName(currentChannel) ? getChannelName(currentChannel)
: chatType === "direct" && nodes.get(activeChat) : chatType === "direct" && nodes.get(activeChat)
? nodes.get(activeChat)?.user?.longName ?? "Unknown" ? nodes.get(activeChat)?.user?.longName ?? nodeHex
: "Loading..." : "Loading..."
}`} }`}
actions={
chatType === "direct"
? [
{
icon: nodes.get(activeChat)?.user?.publicKey.length
? LockIcon
: LockOpenIcon,
iconClasses: nodes.get(activeChat)?.user?.publicKey.length
? "text-green-600"
: "text-yellow-300",
async onClick() {
const targetNode = nodes.get(activeChat)?.num;
if (targetNode === undefined) return;
toast({
title: nodes.get(activeChat)?.user?.publicKey.length
? "Chat is using PKI encryption."
: "Chat is using PSK encryption.",
});
},
},
{
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( {allChannels.map(
(channel) => (channel) =>
@ -92,10 +137,12 @@ export const MessagesPage = (): JSX.Element => {
to={activeChat} to={activeChat}
messages={messages.direct.get(node.num)} messages={messages.direct.get(node.num)}
channel={Types.ChannelNumber.Primary} channel={Types.ChannelNumber.Primary}
traceroutes={traceroutes.get(node.num)}
/> />
), ),
)} )}
</PageLayout> </PageLayout>
</div>
</> </>
); );
}; };

70
src/pages/Nodes.tsx

@ -1,14 +1,26 @@
import Footer from "@app/components/UI/Footer";
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 { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { LockIcon, LockOpenIcon, TrashIcon } from "lucide-react";
import { Fragment } from "react";
import { base16 } from "rfc4648"; import { base16 } from "rfc4648";
export interface DeleteNoteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const NodesPage = (): JSX.Element => { export const NodesPage = (): JSX.Element => {
const { nodes, hardware } = useDevice(); const { nodes, hardware, setDialogOpen } = useDevice();
const { setNodeNumToBeRemoved } = useAppStore();
const filteredNodes = Array.from(nodes.values()).filter( const filteredNodes = Array.from(nodes.values()).filter(
(n) => n.num !== hardware.myNodeNum, (n) => n.num !== hardware.myNodeNum,
@ -17,7 +29,8 @@ export const NodesPage = (): JSX.Element => {
return ( return (
<> <>
<Sidebar /> <Sidebar />
<div className="w-full overflow-y-auto"> <div className="flex flex-col w-full">
<div className="overflow-y-auto h-full">
<Table <Table
headings={[ headings={[
{ title: "", type: "blank", sortable: false }, { title: "", type: "blank", sortable: false },
@ -26,38 +39,75 @@ export const NodesPage = (): JSX.Element => {
{ title: "MAC Address", type: "normal", sortable: true }, { title: "MAC Address", type: "normal", sortable: true },
{ title: "Last Heard", type: "normal", sortable: true }, { title: "Last Heard", type: "normal", sortable: true },
{ title: "SNR", type: "normal", sortable: true }, { title: "SNR", type: "normal", sortable: true },
{ title: "Encryption", type: "normal", sortable: false },
{ title: "Connection", type: "normal", sortable: true },
{ 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
.stringify(node.user?.macaddr.subarray(4, 6) ?? []) .stringify(node.user?.macaddr.subarray(4, 6) ?? [])
.toLowerCase()}` .toLowerCase()}`
: `UNK: ${node.num}`)} : `!${numberToHexUnpadded(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">
{node.lastHeard === 0 ? (
<p>Never</p> <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 key="pki">
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
<LockIcon className="text-green-600" />
) : (
<LockOpenIcon className="text-yellow-300" />
)}
</Mono>,
<Mono key="hops">
{node.lastHeard !== 0
? node.viaMqtt === false && node.hopsAway === 0
? "Direct"
: `${node.hopsAway.toString()} ${
node.hopsAway > 1 ? "hops" : "hop"
} away`
: "-"}
{node.viaMqtt === true ? ", via MQTT" : ""}
</Mono>,
<Button
key="remove"
variant="destructive"
onClick={() => {
setNodeNumToBeRemoved(node.num);
setDialogOpen("nodeRemoval", true);
}}
>
<TrashIcon />
Remove
</Button>,
])} ])}
/> />
</div> </div>
<Footer />
</div>
</> </>
); );
}; };

9
src/validation/channel.ts

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

6
src/validation/config/bluetooth.ts

@ -3,7 +3,11 @@ import { Protobuf } from "@meshtastic/js";
import { IsBoolean, IsEnum, IsInt } from "class-validator"; import { IsBoolean, IsEnum, IsInt } from "class-validator";
export class BluetoothValidation export class BluetoothValidation
implements Omit<Protobuf.Config.Config_BluetoothConfig, keyof Message> implements
Omit<
Protobuf.Config.Config_BluetoothConfig,
keyof Message | "deviceLoggingEnabled"
>
{ {
@IsBoolean() @IsBoolean()
enabled: boolean; enabled: boolean;

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;
} }

6
src/validation/config/lora.ts

@ -3,7 +3,8 @@ import { Protobuf } from "@meshtastic/js";
import { IsArray, IsBoolean, IsEnum, IsInt, Max, Min } from "class-validator"; import { IsArray, IsBoolean, IsEnum, IsInt, Max, Min } from "class-validator";
export class LoRaValidation export class LoRaValidation
implements Omit<Protobuf.Config.Config_LoRaConfig, keyof Message> implements
Omit<Protobuf.Config.Config_LoRaConfig, keyof Message | "paFanDisabled">
{ {
@IsBoolean() @IsBoolean()
usePreset: boolean; usePreset: boolean;
@ -59,4 +60,7 @@ export class LoRaValidation
@IsBoolean() @IsBoolean()
ignoreMqtt: boolean; ignoreMqtt: boolean;
@IsBoolean()
configOkToMqtt: boolean;
} }

13
src/validation/config/network.ts

@ -41,21 +41,24 @@ export class NetworkValidation
export class NetworkValidationIpV4Config export class NetworkValidationIpV4Config
implements implements
Omit<Protobuf.Config.Config_NetworkConfig_IpV4Config, keyof Message> Omit<
Protobuf.Config.Config_NetworkConfig_IpV4Config,
keyof Message | "ip" | "gateway" | "subnet" | "dns"
>
{ {
@IsIP() @IsIP()
@IsOptional() @IsOptional()
ip: number; ip: string;
@IsIP() @IsIP()
@IsOptional() @IsOptional()
gateway: number; gateway: string;
@IsIP() @IsIP()
@IsOptional() @IsOptional()
subnet: number; subnet: string;
@IsIP() @IsIP()
@IsOptional() @IsOptional()
dns: number; dns: string;
} }

17
src/validation/config/position.ts

@ -2,8 +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"];
export class PositionValidation export class PositionValidation
implements Omit<Protobuf.Config.Config_PositionConfig, keyof Message> implements
Omit<
Protobuf.Config.Config_PositionConfig,
keyof Message | (typeof DeprecatedPositionValidationFields)[number]
>
{ {
@IsInt() @IsInt()
positionBroadcastSecs: number; positionBroadcastSecs: number;
@ -14,15 +20,9 @@ export class PositionValidation
@IsBoolean() @IsBoolean()
fixedPosition: boolean; fixedPosition: boolean;
@IsBoolean()
gpsEnabled: boolean;
@IsInt() @IsInt()
gpsUpdateInterval: number; gpsUpdateInterval: number;
@IsInt()
gpsAttemptTime: number;
@IsInt() @IsInt()
positionFlags: number; positionFlags: number;
@ -43,7 +43,4 @@ export class PositionValidation
@IsEnum(Protobuf.Config.Config_PositionConfig_GpsMode) @IsEnum(Protobuf.Config.Config_PositionConfig_GpsMode)
gpsMode: Protobuf.Config.Config_PositionConfig_GpsMode; gpsMode: Protobuf.Config.Config_PositionConfig_GpsMode;
@IsArray()
channelPrecision: number[];
} }

7
src/validation/config/power.ts

@ -1,9 +1,10 @@
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, IsInt, Max, Min } from "class-validator"; import { IsBoolean, IsInt, IsNumber, Max, Min } from "class-validator";
export class PowerValidation export class PowerValidation
implements Omit<Protobuf.Config.Config_PowerConfig, keyof Message> implements
Omit<Protobuf.Config.Config_PowerConfig, keyof Message | "powermonEnables">
{ {
@IsBoolean() @IsBoolean()
isPowerSaving: boolean; isPowerSaving: boolean;
@ -11,7 +12,7 @@ export class PowerValidation
@IsInt() @IsInt()
onBatteryShutdownAfterSecs: number; onBatteryShutdownAfterSecs: number;
@IsInt() @IsNumber()
@Min(2) @Min(2)
@Max(4) @Max(4)
adcMultiplierOverride: number; adcMultiplierOverride: number;

35
src/validation/config/security.ts

@ -0,0 +1,35 @@
import type { Message } from "@bufbuild/protobuf";
import type { Protobuf } from "@meshtastic/js";
import { IsBoolean, IsString } from "class-validator";
export class SecurityValidation
implements
Omit<
Protobuf.Config.Config_SecurityConfig,
keyof Message | "adminKey" | "privateKey" | "publicKey"
>
{
@IsBoolean()
adminChannelEnabled: boolean;
@IsString()
adminKey: string;
@IsBoolean()
bluetoothLoggingEnabled: boolean;
@IsBoolean()
debugLogApiEnabled: boolean;
@IsBoolean()
isManaged: boolean;
@IsString()
privateKey: string;
@IsString()
publicKey: string;
@IsBoolean()
serialEnabled: boolean;
}

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;
} }

5
src/validation/moduleConfig/storeForward.ts

@ -4,7 +4,10 @@ import { IsBoolean, IsInt } from "class-validator";
export class StoreForwardValidation export class StoreForwardValidation
implements implements
Omit<Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig, keyof Message> Omit<
Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig,
keyof Message | "isServer"
>
{ {
@IsBoolean() @IsBoolean()
enabled: boolean; enabled: boolean;

6
vercel.json

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

7
vite.config.ts

@ -1,11 +1,10 @@
import { execSync } from "child_process"; import { execSync } from "node:child_process";
import { resolve } from "path"; import { resolve } from "node:path";
import react from "@vitejs/plugin-react";
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";
import react from "@vitejs/plugin-react";
let hash = ""; let hash = "";
try { try {

Loading…
Cancel
Save