100 Commits
Author SHA1 Message Date
Ben MeadorsandGitHub 530d33d1e4 Merge pull request #290 from Hunter275/disabled-dynamicform
CI / build-and-package (push) Canceled after 0s
Rework disabled in DynamicForms
2024-09-05 06:19:17 -05:00
Hunter ThornsberryandGitHub 6375187d50 Merge branch 'pki' into disabled-dynamicform 2024-09-04 21:56:24 -04:00
Hunter Thornsberry 300fb5c3e5 fix disabled check location 2024-09-04 21:54:48 -04:00
Hunter Thornsberry 0dcc2b1975 rewrite disabled dynamicforms stuff and fix factory reset in command palette 2024-09-04 21:50:23 -04:00
Hunter ThornsberryandGitHub cd0056783c Merge pull request #284 from KomelT/pki
Upgrade @meshtastic/js 2.3.7-0 > 2.3.7-1
2024-08-29 17:13:10 -04:00
Tilen Komel b1baf2d8e6 .factoryReset() split 2024-08-29 23:04:13 +02:00
Tilen Komel 2d106eb3a9 Upgrade @meshtastic/js 2.3.7-0 > 2.3.7-1 2024-08-29 22:52:36 +02:00
Hunter ThornsberryandGitHub 4215eb1a55 Merge pull request #276 from KomelT/feature/security-tab
Key verification & generation
2024-08-22 13:28:56 -04:00
Tilen Komel e3fad3015f Add hide toggle to password generator 2024-08-22 15:45:11 +02:00
Tilen Komel 02a63c213e Add key verification & generation 2024-08-22 14:08:17 +02:00
Hunter ThornsberryandGitHub 737fbb4320 Merge pull request #275 from KomelT/feature/security-tab
Feature: Security tab
2024-08-21 13:45:31 -04:00
Tilen Komel 66fb300575 Moved prop. from Device to Security & Hide PKI Eye 2024-08-21 18:52:03 +02:00
Tilen Komel cf423620c4 Error & formating fixes 2024-08-21 17:41:24 +02:00
Tilen Komel 8bb0a96744 Downgraded @buf/meshtastic_protobufs.bufbuild_es from 2.0 to 1.10 2024-08-21 13:13:42 +02:00
Tilen Komel 65247c4f35 Update Security tab 2024-08-20 18:51:02 +02:00
Tilen Komel af51659e71 Add always to disabled by in dynamics form 2024-08-20 18:50:19 +02:00
Tilen Komel f0eae444c7 Update descriptions in Security 2024-08-20 18:10:39 +02:00
Tilen Komel 6d3bf39b76 Updated @buf/meshtastic_protobufs.bufbuild_es to lastest 2024-08-20 18:06:27 +02:00
Tilen Komel 7d5950d6cc Add SecurityValidation to security channel 2024-08-20 17:23:42 +02:00
Tilen Komel d3836a7250 Add class SecurityValidation 2024-08-20 17:19:36 +02:00
Tilen Komel f64b96527e Add security to device config store 2024-08-20 17:18:55 +02:00
Tilen Komel be9169f56f Use protobuf pki branch, tmp for dev 2024-08-20 15:08:13 +02:00
Tilen Komel f6be57224e Updated inputs 2024-08-20 15:04:10 +02:00
Tilen Komel a8dcab0844 Added security input fields 2024-08-20 00:33:27 +02:00
Tilen Komel 049f3de919 Added security tab 2024-08-20 00:33:27 +02:00
Hunter ThornsberryandGitHub 2b34d78a86 Merge pull request #266 from Hunter275/issue-261-password-generator
PSK Generator
2024-08-15 19:59:02 -04:00
Hunter Thornsberry 54a7b88146 rewrite PSK validation 2024-08-10 19:50:21 -04:00
Hunter Thornsberry e7892fd6a0 biome 2024-08-10 19:07:02 -04:00
Hunter Thornsberry 1eedb6d97b validation 2024-08-10 18:50:47 -04:00
Hunter Thornsberry faf094084c remove used imports and rename clickEventCb 2024-08-06 01:04:44 -04:00
Hunter ThornsberryandGitHub 6bbe995ee5 Merge branch 'meshtastic:master' into issue-261-password-generator 2024-08-06 00:59:04 -04:00
Hunter Thornsberry 32acd23362 biome 2024-08-06 00:58:26 -04:00
Hunter Thornsberry cd0fcbbf90 initial working version 2024-08-06 00:50:17 -04:00
Hunter ThornsberryandGitHub cfc2ea0fe5 Merge pull request #268 from KomelT/fix/hops-singular
Fixed hops singular form
2024-08-06 00:45:10 -04:00
Tilen Komel 5771e1b733 Fixed hops singular form 2024-08-05 16:33:57 +02:00
Hunter Thornsberry 1c7c44a472 biome linting 2024-07-28 17:46:51 -04:00
Hunter Thornsberry 9c6aff534a a bit of cleanup 2024-07-28 16:43:41 -04:00
Hunter Thornsberry 38b7e600b1 key generation 2024-07-28 15:57:17 -04:00
Hunter Thornsberry 1ae879342a added password generator 2024-07-28 15:09:11 -04:00
Hunter ThornsberryandGitHub 2528391814 Merge pull request #260 from AddisonTustin/qr-code-query-string
fix: move qrcode URL query-string before fragment
2024-07-24 19:13:27 -04:00
Hunter ThornsberryandGitHub d6147f5b7f Merge pull request #265 from fifieldt/fix-message-limit
Allow single character messages in MessageInput
2024-07-24 13:16:07 -04:00
Tom Fifield 8c17a8be38 Allow single character messages in MessageInput
In character-based languages (eg Chinese, Japanese, Korean), there
are many common interjections that are single characters. Allow
single character messages to support these users to communicate
naturally.

fixes meshtastic/web#264
2024-07-24 12:47:39 +08:00
Addison Tustin a4e2e7eec1 fix: move qrcode URL query-string before fragment 2024-07-20 12:23:30 -07:00
Hunter ThornsberryandGitHub 4653656420 Merge pull request #211 from fifieldt/traceroute
Add Traceroute Feature
2024-07-04 17:43:41 -04:00
Hunter Thornsberry d42e8c10a0 minor changes and biome fixes 2024-07-04 17:29:40 -04:00
Hunter Thornsberry 0955bbe24b Merge remote-tracking branch 'hunter275/traceroute' into traceroute 2024-07-04 17:09:31 -04:00
Hunter ThornsberryandGitHub f3fbe75c66 Merge branch 'meshtastic:master' into traceroute 2024-07-04 17:06:28 -04:00
Hunter Thornsberry bcebfd211b Merge remote-tracking branch 'meshtastic/master' into traceroute 2024-07-04 17:05:33 -04:00
Hunter ThornsberryandGitHub 5bd385b535 Merge pull request #258 from Hunter275/issue-257-node-sorting
fix sorting + new protobufs
2024-06-28 22:29:18 -04:00
Hunter Thornsberry 9f5604971b node stuff 2024-06-28 12:41:02 -04:00
Hunter Thornsberry 93d9f721b4 protobuf changes 2024-06-28 12:36:32 -04:00
Hunter Thornsberry 569bb09f94 fix sorting 2024-06-28 12:30:06 -04:00
Hunter ThornsberryandGitHub 2566395168 Merge pull request #252 from Hunter275/issue-251-mqtt-saving
Fix MQTT settings
2024-06-26 09:24:29 -04:00
Hunter Thornsberry 8893a196c2 ignore vercel.json in biome 2024-06-25 14:31:37 -04:00
Hunter Thornsberry fdbcdd955b vercel fix v2 2024-06-25 14:18:03 -04:00
Hunter Thornsberry 723c9ee5d8 vercel fix 2024-06-25 14:16:19 -04:00
Hunter Thornsberry 22c862fd08 revert package.json 2024-06-25 12:43:14 -04:00
Hunter Thornsberry c47aeb652b Merge branch 'issue-251-mqtt-saving' of https://github.com/Hunter275/web into issue-251-mqtt-saving 2024-06-25 12:39:32 -04:00
Hunter Thornsberry 2f37390985 Linting 2024-06-25 12:39:06 -04:00
Hunter Thornsberry 205c9a21e3 Merge branch 'master' into issue-251-mqtt-saving 2024-06-25 12:38:48 -04:00
Hunter ThornsberryandGitHub 29fab4b6f3 Merge branch 'master' into issue-251-mqtt-saving 2024-06-23 20:19:29 -04:00
Hunter ThornsberryandGitHub 0dddf6ad2f Merge pull request #213 from bmv437/feature/enforce-biome
Update biome, fix and enforce recommended rules
2024-06-23 20:17:44 -04:00
Hunter Thornsberry b24c9312c4 linting fixes 2024-06-23 20:15:33 -04:00
Hunter Thornsberry 268ad4908a remove duplicate icon 2024-06-23 20:10:18 -04:00
Hunter ThornsberryandGitHub fa127eda33 Merge branch 'master' into feature/enforce-biome 2024-06-23 20:08:56 -04:00
Hunter ThornsberryandGitHub 8942468c2c Merge pull request #253 from Server2003User/master
Add device firmware version to WebUI
2024-06-23 19:10:59 -04:00
Hunter Thornsberry 3aedc7b7c2 fixes 2024-06-23 19:03:59 -04:00
server2003userandGitHub 401a594ec7 Add Firmware 2024-06-23 18:24:46 -04:00
Hunter Thornsberry a5a37cd4ab update setWorkingModuleConfig to include mapReportSettings 2024-06-22 21:14:50 -04:00
Hunter Thornsberry 283f548136 add optional to publishIntervalSecs and positionPrecision 2024-06-22 20:54:45 -04:00
Brandon Vandegrift 93b139c268 Update biome, fix and enforce recommended rules 2024-06-22 12:54:44 -04:00
Tom Fifield 9e04c1f486 Merge branch 'master' into traceroute 2024-06-21 15:36:03 +08:00
Hunter ThornsberryandGitHub 6b268b79ed Merge pull request #165 from geftactics/master
Fix sorting on peers table
2024-06-20 22:02:11 -04:00
Hunter Thornsberry 11fe2fdb35 fix sorting 2024-06-20 21:55:30 -04:00
Hunter ThornsberryandGitHub 69f67e3657 Merge pull request #244 from Hunter275/issues-242-243-dropdown-and-warning
update MQTT precision dropdown and encryption warning
2024-06-18 15:36:32 -04:00
Hunter Thornsberry 4143249c55 remove mention of channel 2024-06-18 14:19:29 -04:00
Hunter Thornsberry 4c1737bc44 remove precise location mention as there is a setting for that 2024-06-18 14:18:55 -04:00
Hunter Thornsberry 17c44054be update MQTT precision dropdown and encryption warning 2024-06-17 17:01:29 -04:00
Tom FifieldandGitHub 026256ac5c Merge branch 'meshtastic:master' into master 2024-06-17 12:14:11 +08:00
Hunter Thornsberry e9a681ab21 Add tr column and Toast when starting tr 2024-06-16 11:01:39 -04:00
Hunter Thornsberry bcac95e7ed Merge remote-tracking branch 'origin/master' into traceroute 2024-06-16 11:01:06 -04:00
Tom Fifield daff97a5e0 Fix double double arrow on unknown nodes in multi-hop traceroutes 2024-05-27 21:04:12 +08:00
Tom Fifield 452f2581e2 Fix incorrect use of operator build error. 2024-05-27 21:02:42 +08:00
Tom Fifield 569c2daa09 Fix traceroute ordering for multi-hop traceroutes 2024-05-27 20:57:09 +08:00
Tom Fifield e825a737b0 Fix build errors 2024-05-27 12:08:32 +08:00
Tom Fifield 54c983439c Fix type Number - number 2024-05-27 11:52:33 +08:00
Tom Fifield 7b6b8daeba Fix build errors
Several types were wrong.
2024-05-27 11:40:59 +08:00
Tom Fifield 0f89e04bb0 Only show traceroutes on node direct message pageso 2024-05-27 10:56:34 +08:00
Tom Fifield a728b848e1 Don't show traceroute button on Channels
It doesn't work, it's only for nodes!
2024-05-27 10:39:45 +08:00
Tom Fifield f16cce90c8 Fix case where intermediary node in traceroute is unknown. 2024-05-27 10:09:59 +08:00
Tom Fifield 056a194ede Add formatting logic for multi-hop traceroutes 2024-05-27 09:53:20 +08:00
Tom Fifield be3117651c Add basic traceroute display
This patch adds basic traceroute display functionality on the
Messages page for an individual node.
2024-05-26 21:00:30 +08:00
Tom Fifield 86263990e6 Add Toast lib to Messages page 2024-05-26 18:41:48 +08:00
Tom Fifield 390b16812a Subscribe to and store traceroutePackets 2024-05-26 18:41:13 +08:00
Tom Fifield 1458497fc3 Add button to Message page to send a traceroute to a node.
As noted in meshtastic/web#179, it would be great to be able to
send traceroutes from the web application.

This patch adds a button to the messages page that sends a
traceroute to the selected node.
2024-05-26 15:42:50 +08:00
Tom Fifield 97b9570196 Revert "Add button to Message page to send a traceroute to a node."
This reverts commit 6e145421ef.
2024-05-26 15:41:37 +08:00
Chris DrackettandTom Fifield 82c1d3e3f1 Support floats in number fields 2024-05-26 15:41:32 +08:00
Tom Fifield a7237bcb2b Revert "Support floats in number fields"
This reverts commit 5fdd07ee68.
2024-05-26 15:40:55 +08:00
Tom Fifield 6e145421ef Add button to Message page to send a traceroute to a node.
As noted in meshtastic/web#179, it would be great to be able to
send traceroutes from the web application.

This patch adds a button to the messages page that sends a
traceroute to the selected node.
2024-05-26 15:37:17 +08:00
geftacticsandGitHub 98f8965aed Fix sorting on peers table
Enables sorting by clicking column heading.
Defaults to most recent contacts at the top
2024-02-22 12:19:18 +00:00
64 changed files with 5406 additions and 3464 deletions
+6 -3
View File
@@ -1,4 +1,7 @@
{ {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true "editor.codeActionsOnSave": {
} "quickfix.biome": "explicit"
},
"editor.formatOnSave": true
}
+4 -3
View File
@@ -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
} }
} }
} }
+36 -34
View File
@@ -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,65 @@
}, },
"homepage": "https://meshtastic.org", "homepage": "https://meshtastic.org",
"dependencies": { "dependencies": {
"@bufbuild/protobuf": "^1.8.0", "@bufbuild/protobuf": "^1.10.0",
"@emeraldpay/hashicon-react": "^0.5.2", "@emeraldpay/hashicon-react": "^0.5.2",
"@meshtastic/js": "2.3.4-0", "@meshtastic/js": "2.3.7-1",
"@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.1.0",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-menubar": "^1.0.4", "@radix-ui/react-menubar": "^1.1.1",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.1.1",
"@turf/turf": "^6.5.0", "@turf/turf": "^6.5.0",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.0", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"immer": "^10.0.4", "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": "npm:empty-npm-package@^1.0.0",
"maplibre-gl": "4.1.2", "maplibre-gl": "4.1.2",
"react": "^18.2.0", "react": "^18.3.1",
"react-dom": "^18.2.0", "react-dom": "^18.3.1",
"react-hook-form": "^7.51.2", "react-hook-form": "^7.52.0",
"react-map-gl": "7.1.7", "react-map-gl": "7.1.7",
"react-qrcode-logo": "^2.9.0", "react-qrcode-logo": "^2.10.0",
"rfc4648": "^1.5.3", "rfc4648": "^1.5.3",
"tailwind-merge": "^2.2.2", "tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"timeago-react": "^3.0.6", "timeago-react": "^3.0.6",
"zustand": "4.5.2" "zustand": "4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.6.3", "@biomejs/biome": "^1.8.2",
"@buf/meshtastic_protobufs.bufbuild_es": "1.8.0-20240325205556-b11811405eea.2", "@buf/meshtastic_protobufs.bufbuild_es": "1.10.0-20240820152623-fac6975bbc78.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"
} }
} }
+4248 -3173
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
module.exports = { module.exports = {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {} autoprefixer: {},
} },
}; };
+11 -4
View File
@@ -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
+1 -1
View File
@@ -9,8 +9,8 @@ import {
LanguagesIcon, LanguagesIcon,
MoonIcon, MoonIcon,
PlusIcon, PlusIcon,
SunIcon,
SearchIcon, SearchIcon,
SunIcon,
} from "lucide-react"; } from "lucide-react";
export const DeviceSelector = (): JSX.Element => { export const DeviceSelector = (): JSX.Element => {
+1 -1
View File
@@ -1,9 +1,9 @@
import { RemoveNodeDialog } from "@app/components/Dialog/RemoveNodeDialog.js";
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.js"; import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.js";
import { ImportDialog } from "@components/Dialog/ImportDialog.js"; import { ImportDialog } from "@components/Dialog/ImportDialog.js";
import { QRDialog } from "@components/Dialog/QRDialog.js"; import { QRDialog } from "@components/Dialog/QRDialog.js";
import { RebootDialog } from "@components/Dialog/RebootDialog.js"; import { RebootDialog } from "@components/Dialog/RebootDialog.js";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.js"; import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.js";
import { RemoveNodeDialog } from "@app/components/Dialog/RemoveNodeDialog.js"
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
export const DialogManager = (): JSX.Element => { export const DialogManager = (): JSX.Element => {
+24 -9
View File
@@ -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), "=")
.replace(/-/g, "+")
.replace(/_/g, "/");
try { 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, "/");
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 && (
+23 -13
View File
@@ -9,10 +9,10 @@ import {
} from "@components/UI/Dialog.js"; } from "@components/UI/Dialog.js";
import { Input } from "@components/UI/Input.js"; import { Input } from "@components/UI/Input.js";
import { Label } from "@components/UI/Label.js"; import { Label } from "@components/UI/Label.js";
import { Protobuf, Types } from "@meshtastic/js"; import { Protobuf, type Types } from "@meshtastic/js";
import { fromByteArray } from "base64-js"; import { fromByteArray } from "base64-js";
import { ClipboardIcon } from "lucide-react"; import { ClipboardIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { QRCode } from "react-qrcode-logo"; import { QRCode } from "react-qrcode-logo";
export interface QRDialogProps { export interface QRDialogProps {
@@ -32,7 +32,7 @@ export const QRDialog = ({
const [qrCodeUrl, setQrCodeUrl] = useState<string>(""); const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
const [qrCodeAdd, setQrCodeAdd] = useState<boolean>(); const [qrCodeAdd, setQrCodeAdd] = useState<boolean>();
const allChannels = Array.from(channels.values()); const allChannels = useMemo(() => Array.from(channels.values()), [channels]);
useEffect(() => { useEffect(() => {
const channelsToEncode = allChannels const channelsToEncode = allChannels
@@ -50,8 +50,10 @@ export const QRDialog = ({
.replace(/\+/g, "-") .replace(/\+/g, "-")
.replace(/\//g, "_"); .replace(/\//g, "_");
setQrCodeUrl(`https://meshtastic.org/e/#${base64}${qrCodeAdd ? "?add=true" : ""}`); setQrCodeUrl(
}, [channels, selectedChannels, qrCodeAdd, loraConfig]); `https://meshtastic.org/e/${qrCodeAdd ? "?add=true" : ""}#${base64}`,
);
}, [allChannels, selectedChannels, qrCodeAdd, loraConfig]);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -97,18 +99,26 @@ export const QRDialog = ({
</div> </div>
<div className="flex justify-center"> <div className="flex justify-center">
<button <button
type="button" type="button"
className={ "border-black border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 " + (qrCodeAdd ? "focus:ring-green-800 bg-green-800 text-white" : "focus:ring-slate-400 bg-slate-400 hover:bg-green-600") } className={`border-black border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 ${
qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
onClick={() => setQrCodeAdd(true)} onClick={() => setQrCodeAdd(true)}
> >
Add Channels Add Channels
</button> </button>
<button <button
type="button" type="button"
className={ "border-black border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 " + (!qrCodeAdd ? "focus:ring-green-800 bg-green-800 text-white" : "focus:ring-slate-400 bg-slate-400 hover:bg-green-600") } className={`border-black border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 ${
!qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
onClick={() => setQrCodeAdd(false)} onClick={() => setQrCodeAdd(false)}
> >
Replace Channels Replace Channels
</button> </button>
</div> </div>
</div> </div>
+1 -1
View File
@@ -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() {
+3 -1
View File
@@ -44,7 +44,9 @@ export const RemoveNodeDialog = ({
</form> </form>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}>Remove</Button> <Button variant="destructive" onClick={() => onSubmit()}>
Remove
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
+1 -1
View File
@@ -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
+28 -12
View File
@@ -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
key={field.label}
label={field.label}
description={field.description}
valid={
field.validationText === undefined ||
field.validationText === ""
}
validationText={field.validationText}
>
<DynamicFormField <DynamicFormField
key={field.label}
field={field} field={field}
control={control} control={control}
disabled={isDisabled(field.disabledBy)} disabled={isDisabled(field.disabledBy, field.disabled)}
/> />
</FieldWrapper> </FieldWrapper>
))} ))}
+26 -4
View File
@@ -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>;
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import type {
} from "@components/Form/DynamicForm.js"; } from "@components/Form/DynamicForm.js";
import { Input } from "@components/UI/Input.js"; import { Input } from "@components/UI/Input.js";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Controller, FieldValues } from "react-hook-form"; import { Controller, type FieldValues } from "react-hook-form";
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> { export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
type: "text" | "number" | "password"; type: "text" | "number" | "password";
@@ -0,0 +1,44 @@
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;
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}
inputChange={field.inputChange}
selectChange={field.selectChange}
buttonClick={field.buttonClick}
value={value}
variant={field.validationText ? "invalid" : "default"}
buttonText="Generate"
{...field.properties}
{...rest}
disabled={disabled}
/>
)}
/>
);
}
+2 -2
View File
@@ -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}
+2 -2
View File
@@ -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
View File
@@ -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>
+95 -14
View File
@@ -1,9 +1,11 @@
import type{ ChannelValidation } from "@app/validation/channel.js"; import type { ChannelValidation } from "@app/validation/channel.js";
import { DynamicForm } from "@components/Form/DynamicForm.js"; import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useToast } from "@core/hooks/useToast.js"; import { useToast } from "@core/hooks/useToast.js";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
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;
@@ -13,15 +15,27 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
const { config, 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: { moduleSettings: {
positionPrecision: data.settings.positionEnabled ? data.settings.preciseLocation ? 32 : data.settings.positionPrecision : 0, positionPrecision: data.settings.positionEnabled
} ? data.settings.preciseLocation
? 32
: data.settings.positionPrecision
: 0,
},
}, },
}); });
connection?.setChannel(channel).then(() => { connection?.setChannel(channel).then(() => {
@@ -32,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}
@@ -42,10 +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, positionEnabled:
preciseLocation: channel?.settings?.moduleSettings?.positionPrecision == 32, channel?.settings?.moduleSettings?.positionPrecision !==
positionPrecision: channel?.settings?.moduleSettings?.positionPrecision == undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision undefined &&
channel?.settings?.moduleSettings?.positionPrecision > 0,
preciseLocation:
channel?.settings?.moduleSettings?.positionPrecision === 32,
positionPrecision:
channel?.settings?.moduleSettings?.positionPrecision === undefined
? 10
: channel?.settings?.moduleSettings?.positionPrecision,
}, },
}, },
}} }}
@@ -65,12 +118,17 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
}, },
}, },
{ {
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,
}, },
}, },
{ {
@@ -111,9 +169,32 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
description: description:
"If not sharing precise location, position shared on channel will be accurate within this distance", "If not sharing precise location, position shared on channel will be accurate within this distance",
properties: { properties: {
enumValue: config.display?.units == 0 ? enumValue:
{ "Within 23 km":10, "Within 12 km":11, "Within 5.8 km":12, "Within 2.9 km":13, "Within 1.5 km":14, "Within 700 m":15, "Within 350 m":16, "Within 200 m":17, "Within 90 m":18, "Within 50 m":19 } : config.display?.units === 0
{ "Within 15 miles":10, "Within 7.3 miles":11, "Within 3.6 miles":12, "Within 1.8 miles":13, "Within 0.9 miles":14, "Within 0.5 miles":15, "Within 0.2 miles":16, "Within 600 feet":17, "Within 300 feet":18, "Within 150 feet":19 } ? {
"Within 23 km": 10,
"Within 12 km": 11,
"Within 5.8 km": 12,
"Within 2.9 km": 13,
"Within 1.5 km": 14,
"Within 700 m": 15,
"Within 350 m": 16,
"Within 200 m": 17,
"Within 90 m": 18,
"Within 50 m": 19,
}
: {
"Within 15 miles": 10,
"Within 7.3 miles": 11,
"Within 3.6 miles": 12,
"Within 1.8 miles": 13,
"Within 0.9 miles": 14,
"Within 0.5 miles": 15,
"Within 0.2 miles": 16,
"Within 600 feet": 17,
"Within 300 feet": 18,
"Within 150 feet": 19,
},
}, },
}, },
], ],
@@ -36,19 +36,6 @@ export const Device = (): JSX.Element => {
formatEnumName: true, 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",
name: "buttonGpio", name: "buttonGpio",
@@ -86,12 +73,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",
@@ -41,7 +41,7 @@ export const LoRa = (): JSX.Element => {
label: "Hop Limit", label: "Hop Limit",
description: "Maximum number of hops", description: "Maximum number of hops",
properties: { properties: {
enumValue: {1:1, 2:2, 3:3, 4:4, 5:5, 6:6, 7:7} enumValue: { 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7 },
}, },
}, },
{ {
@@ -0,0 +1,240 @@
import { DynamicForm } from "@app/components/Form/DynamicForm.js";
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 cryptoRandomString from "crypto-random-string";
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 ?? 16,
);
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 ?? new Uint8Array(0)),
);
const [adminKeyVisible, setAdminKeyVisible] = useState<boolean>(false);
const [adminKeyBitCount, setAdminKeyBitCount] = useState<number>(
config.security?.adminKey.length ?? 16,
);
const [adminKeyValidationText, setAdminKeyValidationText] =
useState<string>();
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 clickEvent = (
setKey: (value: React.SetStateAction<string>) => void,
bitCount: number,
setValidationText: (
value: React.SetStateAction<string | undefined>,
) => void,
) => {
setKey(
btoa(
cryptoRandomString({
length: bitCount ?? 0,
type: "alphanumeric",
}),
),
);
setValidationText(undefined);
};
const validatePass = (
input: string,
count: number,
setValidationText: (
value: React.SetStateAction<string | undefined>,
) => void,
) => {
if (input.length % 4 !== 0 || toByteArray(input).length !== count) {
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
} else {
setValidationText(undefined);
}
};
const privateKeyInputChangeEvent = (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const psk = e.currentTarget?.value;
setPrivateKey(psk);
validatePass(psk, privateKeyBitCount, setPrivateKeyValidationText);
};
const adminKeyInputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
const psk = e.currentTarget?.value;
setAdminKey(psk);
validatePass(psk, privateKeyBitCount, setAdminKeyValidationText);
};
const privateKeySelectChangeEvent = (e: string) => {
const count = Number.parseInt(e);
setPrivateKeyBitCount(count);
validatePass(privateKey, count, setPrivateKeyValidationText);
};
const adminKeySelectChangeEvent = (e: string) => {
const count = Number.parseInt(e);
setAdminKeyBitCount(count);
validatePass(privateKey, count, setAdminKeyValidationText);
};
return (
<DynamicForm<SecurityValidation>
onSubmit={onSubmit}
defaultValues={{
...config.security,
...{
adminKey: adminKey,
privateKey: privateKey,
publicKey: publicKey,
},
}}
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",
validationText: privateKeyValidationText,
devicePSKBitCount: privateKeyBitCount,
inputChange: privateKeyInputChangeEvent,
selectChange: privateKeySelectChangeEvent,
hide: !privateKeyVisible,
buttonClick: () =>
clickEvent(
setPrivateKey,
privateKeyBitCount,
setPrivateKeyValidationText,
),
disabledBy: [
{
fieldName: "adminChannelEnabled",
invert: true,
},
],
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",
},
],
},
{
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: "passwordGenerator",
name: "adminKey",
label: "Admin Key",
description:
"The public key authorized to send admin messages to this node",
validationText: adminKeyValidationText,
devicePSKBitCount: adminKeyBitCount,
inputChange: adminKeyInputChangeEvent,
selectChange: adminKeySelectChangeEvent,
hide: !adminKeyVisible,
buttonClick: () =>
clickEvent(
setAdminKey,
adminKeyBitCount,
setAdminKeyValidationText,
),
disabledBy: [{ fieldName: "adminChannelEnabled" }],
properties: {
value: adminKey,
action: {
icon: adminKeyVisible ? EyeOff : Eye,
onClick: () => setAdminKeyVisible(!adminKeyVisible),
},
},
},
],
},
{
label: "Logging Settings",
description: "Settings for Logging",
fields: [
{
type: "toggle",
name: "bluetoothLoggingEnabled",
label: "Allow Bluetooth Logging",
description: "Enables device (serial style logs) over Bluetooth",
},
{
type: "toggle",
name: "debugLogApiEnabled",
label: "Enable Debug Log API",
description: "Output live debug logging over serial",
},
{
type: "toggle",
name: "serialEnabled",
label: "Serial Output Enabled",
description: "Serial Console over the Stream API",
},
],
},
]}
/>
);
};
@@ -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";
@@ -1,5 +1,4 @@
import React, { useState } from "react"; import type { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import { 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";
@@ -9,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 => {
@@ -20,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,
@@ -38,7 +38,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
const onSubmit = handleSubmit(async (data) => { const onSubmit = handleSubmit(async (data) => {
setConnectionInProgress(true); setConnectionInProgress(true);
const id = randId(); const id = randId();
const device = addDevice(id); const device = addDevice(id);
const connection = new HttpConnection(id); const connection = new HttpConnection(id);
@@ -75,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:" || connectionInProgress} disabled={
location.protocol === "https:" || connectionInProgress
}
checked={value} checked={value}
{...rest} {...rest}
/> />
@@ -84,7 +86,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
/> />
</div> </div>
<Button type="submit" disabled={connectionInProgress}> <Button type="submit" disabled={connectionInProgress}>
<span>{connectionInProgress ? 'Connecting...' : 'Connect' }</span> <span>{connectionInProgress ? "Connecting..." : "Connect"}</span>
</Button> </Button>
</form> </form>
); );
@@ -1,4 +1,4 @@
import { TabElementProps } from "@app/components/Dialog/NewDeviceDialog"; import type { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import { Button } from "@components/UI/Button.js"; import { Button } from "@components/UI/Button.js";
import { Mono } from "@components/generic/Mono.js"; import { Mono } from "@components/generic/Mono.js";
import { useAppStore } from "@core/stores/appStore.js"; import { useAppStore } from "@core/stores/appStore.js";
@@ -48,19 +48,22 @@ export const Serial = ({ closeDialog }: TabElementProps): JSX.Element => {
return ( return (
<div className="flex w-full flex-col gap-2 p-4"> <div className="flex w-full flex-col gap-2 p-4">
<div className="flex h-48 flex-col gap-2 overflow-y-auto"> <div className="flex h-48 flex-col gap-2 overflow-y-auto">
{serialPorts.map((port, index) => ( {serialPorts.map((port, index) => {
<Button const { usbProductId, usbVendorId } = port.getInfo();
key={index} return (
disabled={port.readable !== null} <Button
onClick={async () => { key={`${usbVendorId ?? "UNK"}-${usbProductId ?? "UNK"}-${index}`}
await onConnect(port); disabled={port.readable !== null}
}} onClick={async () => {
> await onConnect(port);
{`# ${index} - ${port.getInfo().usbVendorId ?? "UNK"} - ${ }}
port.getInfo().usbProductId ?? "UNK" >
}`} {`# ${index} - ${usbVendorId ?? "UNK"} - ${
</Button> usbProductId ?? "UNK"
))} }`}
</Button>
);
})}
{serialPorts.length === 0 && ( {serialPorts.length === 0 && (
<Mono className="m-auto select-none">No devices paired yet.</Mono> <Mono className="m-auto select-none">No devices paired yet.</Mono>
)} )}
@@ -1,43 +1,72 @@
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 flex-col"> <div className="flex flex-grow">
{messages ? ( <div className="flex flex-grow flex-col">
messages.map((message, index) => ( {messages ? (
<Message messages.map((message, index) => (
key={message.id} <Message
message={message} key={message.id}
lastMsgSameUser={ message={message}
index === 0 ? false : messages[index - 1].from === message.from lastMsgSameUser={
} index === 0
sender={nodes.get(message.from)} ? false
/> : messages[index - 1].from === message.from
)) }
) : ( sender={nodes.get(message.from)}
<div className="m-auto"> />
<InboxIcon className="m-auto" /> ))
<Subtle>No Messages</Subtle> ) : (
</div> <div className="m-auto">
)} <InboxIcon className="m-auto" />
<Subtle>No Messages</Subtle>
</div>
)}
</div>
<div
className={`flex flex-grow flex-col border-slate-400 border-l ${traceroutes === undefined ? "hidden" : ""}`}
>
{to === "broadcast" ? null : traceroutes ? (
traceroutes.map((traceroute, index) => (
<TraceRoute
key={traceroute.id}
from={nodes.get(traceroute.from)}
to={nodes.get(traceroute.to)}
route={traceroute.data.route}
/>
))
) : (
<div className="m-auto">
<InboxIcon className="m-auto" />
<Subtle>No Traceroutes</Subtle>
</div>
)}
</div>
</div> </div>
<div className="p-3"> <div className="p-3">
<MessageInput to={to} channel={channel} /> <MessageInput to={to} channel={channel} />
@@ -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)}
@@ -0,0 +1,32 @@
import { useDevice } from "@app/core/stores/deviceStore.js";
import type { Protobuf } from "@meshtastic/js";
export interface TraceRouteProps {
from?: Protobuf.Mesh.NodeInfo;
to?: Protobuf.Mesh.NodeInfo;
route: Array<number>;
}
export const TraceRoute = ({
from,
to,
route,
}: TraceRouteProps): JSX.Element => {
const { nodes } = useDevice();
return route.length === 0 ? (
<div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
{to?.user?.longName}↔{from?.user?.longName}
</span>
</div>
) : (
<div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
{to?.user?.longName}↔
{route.map((hop) => `${nodes.get(hop)?.user?.longName ?? "Unknown"}↔`)}
{from?.user?.longName}
</span>
</div>
);
};
@@ -38,9 +38,9 @@ export const DetectionSensor = (): JSX.Element => {
label: "Minimum Broadcast Seconds", label: "Minimum Broadcast Seconds",
description: description:
"The interval in seconds of how often we can send a message to the mesh when a state change is detected", "The interval in seconds of how often we can send a message to the mesh when a state change is detected",
properties: { properties: {
suffix: "Seconds", suffix: "Seconds",
}, },
disabledBy: [ disabledBy: [
{ {
fieldName: "enabled", fieldName: "enabled",
@@ -4,14 +4,20 @@ import { DynamicForm } from "@components/Form/DynamicForm.js";
import { Protobuf } from "@meshtastic/js"; import { Protobuf } from "@meshtastic/js";
export const MQTT = (): JSX.Element => { export const MQTT = (): JSX.Element => {
const { moduleConfig, setWorkingModuleConfig } = useDevice(); const { config, moduleConfig, setWorkingModuleConfig } = useDevice();
const onSubmit = (data: MqttValidation) => { const onSubmit = (data: MqttValidation) => {
setWorkingModuleConfig( setWorkingModuleConfig(
new Protobuf.ModuleConfig.ModuleConfig({ new Protobuf.ModuleConfig.ModuleConfig({
payloadVariant: { payloadVariant: {
case: "mqtt", case: "mqtt",
value: data, value: {
...data,
mapReportSettings:
new Protobuf.ModuleConfig.ModuleConfig_MapReportSettings(
data.mapReportSettings,
),
},
}, },
}), }),
); );
@@ -70,7 +76,8 @@ export const MQTT = (): JSX.Element => {
type: "toggle", type: "toggle",
name: "encryptionEnabled", name: "encryptionEnabled",
label: "Encryption Enabled", label: "Encryption Enabled",
description: "Enable or disable MQTT encryption", description:
"Enable or disable MQTT encryption. Note: All messages are sent to the MQTT broker unencrypted if this option is not enabled, even when your uplink channels have encryption keys set. This includes position data.",
disabledBy: [ disabledBy: [
{ {
fieldName: "enabled", fieldName: "enabled",
@@ -151,10 +158,39 @@ export const MQTT = (): JSX.Element => {
], ],
}, },
{ {
type: "number", type: "select",
name: "mapReportSettings.positionPrecision", name: "mapReportSettings.positionPrecision",
label: "Position Precision", label: "Approximate Location",
description: "Precision of the position", description:
"Position shared will be accurate within this distance",
properties: {
enumValue:
config.display?.units === 0
? {
"Within 23 km": 10,
"Within 12 km": 11,
"Within 5.8 km": 12,
"Within 2.9 km": 13,
"Within 1.5 km": 14,
"Within 700 m": 15,
"Within 350 m": 16,
"Within 200 m": 17,
"Within 90 m": 18,
"Within 50 m": 19,
}
: {
"Within 15 miles": 10,
"Within 7.3 miles": 11,
"Within 3.6 miles": 12,
"Within 1.8 miles": 13,
"Within 0.9 miles": 14,
"Within 0.5 miles": 15,
"Within 0.2 miles": 16,
"Within 600 feet": 17,
"Within 300 feet": 18,
"Within 150 feet": 19,
},
},
disabledBy: [ disabledBy: [
{ {
fieldName: "enabled", fieldName: "enabled",
@@ -36,7 +36,7 @@ export const NeighborInfo = (): JSX.Element => {
type: "number", type: "number",
name: "updateInterval", name: "updateInterval",
label: "Update Interval", label: "Update Interval",
description: description:
"Interval in seconds of how often we should try to send our Neighbor Info to the mesh", "Interval in seconds of how often we should try to send our Neighbor Info to the mesh",
properties: { properties: {
suffix: "Seconds", suffix: "Seconds",
@@ -36,7 +36,8 @@ export const Paxcounter = (): JSX.Element => {
type: "number", type: "number",
name: "paxcounterUpdateInterval", name: "paxcounterUpdateInterval",
label: "Update Interval (seconds)", label: "Update Interval (seconds)",
description: "How long to wait between sending paxcounter packets", description:
"How long to wait between sending paxcounter packets",
properties: { properties: {
suffix: "Seconds", suffix: "Seconds",
}, },
+1 -1
View File
@@ -1,5 +1,5 @@
import { cn } from "@app/core/utils/cn.js"; import { cn } from "@app/core/utils/cn.js";
import { AlignLeftIcon, LucideIcon } from "lucide-react"; import { AlignLeftIcon, type LucideIcon } from "lucide-react";
export interface PageLayoutProps { export interface PageLayoutProps {
label: string; label: string;
+15 -7
View File
@@ -4,15 +4,16 @@ import { Subtle } from "@components/UI/Typography/Subtle.js";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import type { Page } from "@core/stores/deviceStore.js"; import type { Page } from "@core/stores/deviceStore.js";
import { import {
BatteryMediumIcon,
CpuIcon,
EditIcon, EditIcon,
LayersIcon, LayersIcon,
LucideIcon, type LucideIcon,
MapIcon, MapIcon,
MessageSquareIcon, MessageSquareIcon,
SettingsIcon, SettingsIcon,
UsersIcon, UsersIcon,
ZapIcon, ZapIcon,
BatteryMediumIcon
} from "lucide-react"; } from "lucide-react";
export interface SidebarProps { export interface SidebarProps {
@@ -20,8 +21,9 @@ export interface SidebarProps {
} }
export const Sidebar = ({ children }: SidebarProps): JSX.Element => { export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
const { hardware, nodes } = useDevice(); const { hardware, nodes, metadata } = useDevice();
const myNode = nodes.get(hardware.myNodeNum); const myNode = nodes.get(hardware.myNodeNum);
const myMetadata = metadata.get(0);
const { activePage, setActivePage, setDialogOpen } = useDevice(); const { activePage, setActivePage, setDialogOpen } = useDevice();
interface NavLink { interface NavLink {
@@ -77,12 +79,18 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
</div> </div>
<div className="px-8 pb-6"> <div className="px-8 pb-6">
<div className="flex items-center"> <div className="flex items-center">
<BatteryMediumIcon size={24} viewBox={'0 0 28 24'}/> <BatteryMediumIcon size={24} viewBox={"0 0 28 24"} />
<Subtle>{myNode?.deviceMetrics?.batteryLevel ?? "UNK"}%</Subtle> <Subtle>{myNode?.deviceMetrics?.batteryLevel ?? "UNK"}%</Subtle>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<ZapIcon size={24} viewBox={'0 0 36 24'}/> <ZapIcon size={24} viewBox={"0 0 36 24"} />
<Subtle>{myNode?.deviceMetrics?.voltage.toPrecision(3) ?? "UNK"} volts</Subtle> <Subtle>
{myNode?.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts
</Subtle>
</div>
<div className="flex items-center">
<CpuIcon size={24} viewBox={"0 0 36 24"} />
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle>
</div> </div>
</div> </div>
+8 -2
View File
@@ -17,8 +17,14 @@ export function Toaster() {
{toasts.map(({ id, title, description, action, ...props }) => ( {toasts.map(({ id, title, description, action, ...props }) => (
<Toast key={id} {...props}> <Toast key={id} {...props}>
<div className="grid gap-1"> <div className="grid gap-1">
{title && <ToastTitle className="dark:text-white">{title}</ToastTitle>} {title && (
{description && <ToastDescription className="dark:text-white-400">{description}</ToastDescription>} <ToastTitle className="dark:text-white">{title}</ToastTitle>
)}
{description && (
<ToastDescription className="dark:text-white-400">
{description}
</ToastDescription>
)}
</div> </div>
{action} {action}
<ToastClose /> <ToastClose />
+3 -1
View File
@@ -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:
+91
View File
@@ -0,0 +1,91 @@
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;
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,
selectChange,
inputChange,
buttonClick,
action,
disabled,
...props
},
ref,
) => {
return (
<>
<Input
type={hide ? "password" : "text"}
id="pskInput"
variant={variant}
value={value}
onChange={inputChange}
action={action}
disabled={disabled}
/>
<Select
value={devicePSKBitCount?.toString()}
onValueChange={(e) => selectChange(e)}
>
<SelectTrigger className="!max-w-max">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem key="bit256" value="32">
256 bit
</SelectItem>
<SelectItem key="bit128" value="16">
128 bit
</SelectItem>
<SelectItem key="bit8" value="1">
8 bit
</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="success"
onClick={buttonClick}
{...props}
>
{buttonText}
</Button>
</>
);
},
);
Generator.displayName = "Button";
export { Generator };
+21 -4
View File
@@ -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, variant, prefix, suffix, action, ...props }, ref) => {
return ( return (
<div className="relative w-full"> <div className="relative w-full">
{prefix && ( {prefix && (
@@ -24,9 +41,9 @@ 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 }),
)} )}
ref={ref} ref={ref}
{...props} {...props}
@@ -51,4 +68,4 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
); );
Input.displayName = "Input"; Input.displayName = "Input";
export { Input }; export { Input, inputVariants };
+1 -1
View File
@@ -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";
+58 -4
View File
@@ -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"
> >
+20 -16
View File
@@ -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() {
const index = listeners.indexOf(listener);
if (index > -1) {
listeners.splice(index, 1);
}
};
};
useEffect(() => { const getState = () => {
listeners.push(setState); return memoryState;
return () => { };
const index = listeners.indexOf(setState);
if (index > -1) { function useToast() {
listeners.splice(index, 1); const state = useSyncExternalStore(subscribe, getState);
}
};
}, [state]);
return { return {
...state, ...state,
+6 -3
View File
@@ -50,7 +50,10 @@ export const useAppStore = create<AppState>()((set) => ({
currentPage: "messages", currentPage: "messages",
rasterSources: [], rasterSources: [],
commandPaletteOpen: false, commandPaletteOpen: false,
darkMode: (localStorage.getItem('theme-dark') !== null ? (localStorage.getItem('theme-dark') === 'true' ? true : false) : window.matchMedia("(prefers-color-scheme: dark)").matches), darkMode:
localStorage.getItem("theme-dark") !== null
? localStorage.getItem("theme-dark") === "true"
: window.matchMedia("(prefers-color-scheme: dark)").matches,
accent: "orange", accent: "orange",
connectDialogOpen: false, connectDialogOpen: false,
nodeNumToBeRemoved: 0, nodeNumToBeRemoved: 0,
@@ -96,7 +99,7 @@ export const useAppStore = create<AppState>()((set) => ({
); );
}, },
setDarkMode: (enabled: boolean) => { setDarkMode: (enabled: boolean) => {
localStorage.setItem('theme-dark', enabled.toString()); localStorage.setItem("theme-dark", enabled.toString());
set( set(
produce<AppState>((draft) => { produce<AppState>((draft) => {
draft.darkMode = enabled; draft.darkMode = enabled;
@@ -105,7 +108,7 @@ export const useAppStore = create<AppState>()((set) => ({
}, },
setNodeNumToBeRemoved: (nodeNum) => setNodeNumToBeRemoved: (nodeNum) =>
set((state) => ({ set((state) => ({
nodeNumToBeRemoved: nodeNum nodeNumToBeRemoved: nodeNum,
})), })),
setAccent(color) { setAccent(color) {
set( set(
+34 -2
View File
@@ -42,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;
@@ -75,6 +79,9 @@ 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; removeNode: (nodeNum: number) => void;
setMessageState: ( setMessageState: (
@@ -122,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,
@@ -183,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;
}
} }
} }
}), }),
@@ -487,6 +498,7 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
}), }),
); );
}, },
addMetadata: (from, metadata) => { addMetadata: (from, metadata) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
@@ -498,6 +510,26 @@ 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) => { removeNode: (nodeNum) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
@@ -506,8 +538,8 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
return; return;
} }
device.nodes.delete(nodeNum); device.nodes.delete(nodeNum);
}) }),
) );
}, },
setMessageState: ( setMessageState: (
type: "direct" | "broadcast", type: "direct" | "broadcast",
+7 -1
View File
@@ -1,5 +1,5 @@
import type { Device } from "@core/stores/deviceStore.js"; import type { Device } from "@core/stores/deviceStore.js";
import { Protobuf, Types } from "@meshtastic/js"; import { Protobuf, type Types } from "@meshtastic/js";
export const subscribeAll = ( export const subscribeAll = (
device: Device, device: Device,
@@ -86,6 +86,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);
}); });
+1 -1
View File
@@ -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[]) {
+6 -4
View File
@@ -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>
))} ))}
+1 -1
View File
@@ -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,
+23 -21
View File
@@ -14,7 +14,7 @@ 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";
@@ -27,7 +27,7 @@ export const MapPage = (): JSX.Element => {
const allNodes = Array.from(nodes.values()); const allNodes = Array.from(nodes.values());
const getBBox = () => { const getBBox = useCallback(() => {
if (!map) { if (!map) {
return; return;
} }
@@ -64,7 +64,7 @@ export const MapPage = (): JSX.Element => {
if (center) { if (center) {
map.easeTo(center); map.easeTo(center);
} }
}; }, [allNodes, map]);
useEffect(() => { useEffect(() => {
map?.on("zoom", () => { map?.on("zoom", () => {
@@ -128,7 +128,11 @@ export const MapPage = (): JSX.Element => {
attributionControl={false} attributionControl={false}
renderWorldCopies={false} renderWorldCopies={false}
maxPitch={0} maxPitch={0}
style = {{filter: darkMode ? 'brightness(0.6) invert(1) contrast(3) hue-rotate(200deg) saturate(0.3) brightness(0.7)' : ''}} 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={{
@@ -140,8 +144,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>
@@ -159,23 +163,21 @@ 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)' : ''}} style={{ filter: darkMode ? "invert(1)" : "" }}
anchor="bottom" anchor="bottom"
onClick={() => {
map?.easeTo({
zoom: 12,
center: [
(node.position?.longitudeI ?? 0) / 1e7,
(node.position?.latitudeI ?? 0) / 1e7,
],
});
}}
> >
<div <div className="flex cursor-pointer gap-2 rounded-md border bg-backgroundPrimary p-1.5">
className="flex cursor-pointer gap-2 rounded-md border bg-backgroundPrimary p-1.5"
onClick={() => {
map?.easeTo({
zoom: 12,
center: [
(node.position?.longitudeI ?? 0) / 1e7,
(node.position?.latitudeI ?? 0) / 1e7,
],
});
}}
>
<Hashicon value={node.num.toString()} size={22} /> <Hashicon value={node.num.toString()} size={22} />
<Subtle className={cn(zoom < 12 && "hidden")}> <Subtle className={cn(zoom < 12 && "hidden")}>
{node.user?.longName} {node.user?.longName}
+27 -2
View File
@@ -3,15 +3,17 @@ 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 { getChannelName } from "@pages/Channels.js"; import { getChannelName } from "@pages/Channels.js";
import { HashIcon } from "lucide-react"; import { HashIcon, 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 +27,7 @@ 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();
return ( return (
<> <>
@@ -72,6 +75,27 @@ export const MessagesPage = (): JSX.Element => {
? nodes.get(activeChat)?.user?.longName ?? "Unknown" ? nodes.get(activeChat)?.user?.longName ?? "Unknown"
: "Loading..." : "Loading..."
}`} }`}
actions={
chatType === "direct"
? [
{
icon: WaypointsIcon,
async onClick() {
const targetNode = nodes.get(activeChat)?.num;
if (targetNode === undefined) return;
toast({
title: "Sending Traceroute, please wait...",
});
await connection?.traceRoute(targetNode).then(() =>
toast({
title: "Traceroute sent.",
}),
);
},
},
]
: []
}
> >
{allChannels.map( {allChannels.map(
(channel) => (channel) =>
@@ -92,6 +116,7 @@ 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)}
/> />
), ),
)} )}
+38 -24
View File
@@ -1,15 +1,15 @@
import { useAppStore } from "@app/core/stores/appStore";
import { Sidebar } from "@components/Sidebar.js"; import { Sidebar } from "@components/Sidebar.js";
import { Button } from "@components/UI/Button.js";
import { Mono } from "@components/generic/Mono.js"; import { Mono } from "@components/generic/Mono.js";
import { Table } from "@components/generic/Table/index.js"; import { Table } from "@components/generic/Table/index.js";
import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.js"; import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.js";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react"; import { Hashicon } from "@emeraldpay/hashicon-react";
import { Protobuf } from "@meshtastic/js"; import { Protobuf } from "@meshtastic/js";
import { base16 } from "rfc4648";
import { Button } from "@components/UI/Button.js";
import { TrashIcon } from "lucide-react"; import { TrashIcon } from "lucide-react";
import { useAppStore } from "@app/core/stores/appStore"; import { Fragment } from "react";
import { base16 } from "rfc4648";
export interface DeleteNoteDialogProps { export interface DeleteNoteDialogProps {
open: boolean; open: boolean;
@@ -40,8 +40,8 @@ export const NodesPage = (): JSX.Element => {
{ title: "Remove", type: "normal", sortable: false }, { title: "Remove", type: "normal", sortable: false },
]} ]}
rows={filteredNodes.map((node) => [ rows={filteredNodes.map((node) => [
<Hashicon size={24} value={node.num.toString()} />, <Hashicon key="icon" size={24} value={node.num.toString()} />,
<h1> <h1 key="header">
{node.user?.longName ?? {node.user?.longName ??
(node.user?.macaddr (node.user?.macaddr
? `Meshtastic ${base16 ? `Meshtastic ${base16
@@ -50,34 +50,48 @@ export const NodesPage = (): JSX.Element => {
: `UNK: ${node.num}`)} : `UNK: ${node.num}`)}
</h1>, </h1>,
<Mono>{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}</Mono>, <Mono key="model">
<Mono> {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
</Mono>,
<Mono key="addr">
{base16 {base16
.stringify(node.user?.macaddr ?? []) .stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g) .match(/.{1,2}/g)
?.join(":") ?? "UNK"} ?.join(":") ?? "UNK"}
</Mono>, </Mono>,
node.lastHeard === 0 ? ( <Fragment key="lastHeard">
<p>Never</p> {node.lastHeard === 0 ? (
) : ( <p>Never</p>
<TimeAgo timestamp={node.lastHeard * 1000} /> ) : (
), <TimeAgo timestamp={node.lastHeard * 1000} />
<Mono> )}
</Fragment>,
<Mono key="snr">
{node.snr}db/ {node.snr}db/
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/ {Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
{(node.snr + 10) * 5}raw {(node.snr + 10) * 5}raw
</Mono>, </Mono>,
<Mono> <Mono key="hops">
{node.lastHeard != 0 ? {node.lastHeard !== 0
(node.viaMqtt === false && node.hopsAway === 0 ? node.viaMqtt === false && node.hopsAway === 0
? "Direct": node.hopsAway.toString() + " hops away") ? "Direct"
: `${node.hopsAway.toString()} ${
node.hopsAway > 1 ? "hops" : "hop"
} away`
: "-"} : "-"}
{node.viaMqtt === true? ", via MQTT": ""} {node.viaMqtt === true ? ", via MQTT" : ""}
</Mono>, </Mono>,
<Button variant="destructive" onClick={() => { <Button
setNodeNumToBeRemoved(node.num); key="remove"
setDialogOpen("nodeRemoval", true) variant="destructive"
}}><TrashIcon />Remove</Button> onClick={() => {
setNodeNumToBeRemoved(node.num);
setDialogOpen("nodeRemoval", true);
}}
>
<TrashIcon />
Remove
</Button>,
])} ])}
/> />
</div> </div>
+5 -1
View File
@@ -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;
+7 -1
View File
@@ -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
View File
@@ -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;
} }
+2 -1
View File
@@ -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;
+6 -2
View File
@@ -2,10 +2,14 @@ import type { Message } from "@bufbuild/protobuf";
import { Protobuf } from "@meshtastic/js"; import { Protobuf } from "@meshtastic/js";
import { IsArray, IsBoolean, IsEnum, IsInt } from "class-validator"; import { IsArray, IsBoolean, IsEnum, IsInt } from "class-validator";
const DeprecatedPositionValidationFields = ['gpsEnabled', 'gpsAttemptTime']; const DeprecatedPositionValidationFields = ["gpsEnabled", "gpsAttemptTime"];
export class PositionValidation export class PositionValidation
implements Omit<Protobuf.Config.Config_PositionConfig, keyof Message | typeof DeprecatedPositionValidationFields[number]> implements
Omit<
Protobuf.Config.Config_PositionConfig,
keyof Message | (typeof DeprecatedPositionValidationFields)[number]
>
{ {
@IsInt() @IsInt()
positionBroadcastSecs: number; positionBroadcastSecs: number;
+2 -1
View File
@@ -3,7 +3,8 @@ import type { Protobuf } from "@meshtastic/js";
import { IsBoolean, IsInt, IsNumber, 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;
+35
View File
@@ -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;
}
+9 -1
View File
@@ -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;
} }
@@ -11,4 +11,10 @@ export class PaxcounterValidation
@IsInt() @IsInt()
paxcounterUpdateInterval: number; paxcounterUpdateInterval: number;
@IsInt()
bleThreshold: number;
@IsInt()
wifiThreshold: number;
} }
+4 -1
View File
@@ -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;
+1 -5
View File
@@ -1,5 +1 @@
{ { "github": { "silent": true } }
"github": {
"silent": true
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { execSync } from "child_process"; import { execSync } from "node:child_process";
import { resolve } from "path"; import { resolve } from "node:path";
import { visualizer } from "rollup-plugin-visualizer"; import { visualizer } from "rollup-plugin-visualizer";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import EnvironmentPlugin from "vite-plugin-environment"; import EnvironmentPlugin from "vite-plugin-environment";