141 Commits

Author SHA1 Message Date
Hunter Thornsberry
2e12b27566 Merge pull request #277 from KomelT/feature/security-tab
Update key generation
2024-09-08 18:13:42 -04:00
Hunter Thornsberry
989fad7e17 biome 2024-09-07 20:24:45 -04:00
Hunter Thornsberry
f7a2e5f76b Invert adminKey disabledBy 2024-09-07 20:24:18 -04:00
Hunter Thornsberry
2b81fc47e2 disable the public key input 2024-09-07 20:19:51 -04:00
Hunter Thornsberry
b522113cd7 bad merge on Security.tsx 2024-09-07 20:16:29 -04:00
Hunter Thornsberry
fce642c24e Merge branch 'pki' into feature/security-tab 2024-09-07 19:54:57 -04:00
Tilen Komel
5e72510bef Removed submit button 2024-09-06 09:33:13 +02:00
Hunter Thornsberry
88efdc4758 Merge branch 'master' into pki 2024-09-05 19:26:32 -04:00
Ben Meadors
530d33d1e4 Merge pull request #290 from Hunter275/disabled-dynamicform
Rework disabled in DynamicForms
2024-09-05 06:19:17 -05:00
Hunter Thornsberry
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 Thornsberry
41acc4d25d Merge pull request #289 from Hunter275/gh-actions
GitHub Actions: Releases
2024-09-04 16:15:41 -04:00
Tilen Komel
d10c010b9a Dont disable PKI on Allow Legacy Admin 2024-09-04 18:50:49 +02:00
Hunter Thornsberry
80ab44c8db don't release on release 2024-09-04 00:39:12 -04:00
Hunter Thornsberry
7895df2d9f Stop release on ci 2024-09-04 00:35:22 -04:00
Hunter Thornsberry
c780437355 Merge pull request #286 from TheCyberRonin/feature/provide-compressed-build-during-pr
feat: Provide compressed build during PR GitHub Action
2024-09-04 00:12:26 -04:00
Tilen Komel
9d4aa05316 Updated pki regenerate dialog 2024-09-02 15:45:58 +02:00
Tilen Komel
354d04592b Fix build 2024-09-02 15:05:04 +02:00
Tilen Komel
9bea6870bb Add submit button to work 2024-09-02 13:11:42 +02:00
Tilen Komel
d0bd02980d Update private key generation 2024-09-02 13:10:59 +02:00
Hunter Thornsberry
7d1135b9dc Merge pull request #288 from mrfyda/master
Change 'powerScreenEnabled' config type to toggle
2024-09-01 19:00:25 -04:00
Tilen Komel
f66332b3e3 Handle undefined booleans 2024-08-31 15:58:12 +02:00
Rafael Cortês
05a6b6293e Change 'powerScreenEnabled' config type to toggle 2024-08-31 10:51:53 +01:00
Hunter Thornsberry
b9a8a2ba6c Merge pull request #287 from TheCyberRonin/feature/scope-ci-to-push-on-master
feat: Only do `CI` GHA on push to `master`
2024-08-30 12:31:09 -04:00
Ronin
c16070f02b feat: Only do CI GHA on push to master 2024-08-29 21:54:58 -04:00
Ronin
3e6653a98f chore: Fix typo in package name for uploading 2024-08-29 21:31:04 -04:00
Ronin
59126ca939 feat: Add a build step for build.tar 2024-08-29 21:27:37 -04:00
Tilen Komel
4bde402a53 Add key generation confirmation 2024-08-29 23:19:12 +02:00
Tilen Komel
4cf91272de Removed admin key generation from security 2024-08-29 23:19:12 +02:00
Tilen Komel
c711c39aa9 Add inputChange to FormInpit 2024-08-29 23:19:12 +02:00
Tilen Komel
c4342f1a2b Update passwordGenerator disabled 2024-08-29 23:19:12 +02:00
Tilen Komel
afc45588fa Fixed security key generation 2024-08-29 23:19:12 +02:00
Tilen Komel
22cd5aa88d Add key generation 2024-08-29 23:19:12 +02:00
Tilen Komel
8c4c8a760e Add @noble/curves 2024-08-29 23:19:11 +02:00
Tilen Komel
0911df6b0d Add dynamic bit to password generator 2024-08-29 23:16:02 +02:00
Tilen Komel
06c20fa950 Add dynamic value to input 2024-08-29 23:16:02 +02:00
Hunter Thornsberry
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 Thornsberry
22d900a831 Merge pull request #278 from Hunter275/dashboard-footer
flex-grow to make footer stick to bottom
2024-08-26 15:33:40 -04:00
Hunter Thornsberry
c6cc5e5e6f biome 2024-08-26 14:32:18 -04:00
Hunter Thornsberry
f0d8db1c87 flex-grow to make footer stick to bottom 2024-08-26 14:10:43 -04:00
Hunter Thornsberry
8c37be4af3 Merge pull request #274 from rcarteraz/add-footer 2024-08-24 11:50:51 -04:00
Hunter Thornsberry
076dae80b7 first working version 2024-08-23 18:58:22 -04:00
Hunter Thornsberry
7d4001ea9d Update footer and initial groundword 2024-08-23 18:40:17 -04:00
Hunter Thornsberry
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 Thornsberry
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
rcarteraz
6c1f140ad1 oops -- helps if you use the full link 2024-08-19 13:31:22 -07:00
rcarteraz
4c4be2e18f temp solution 2024-08-19 13:20:56 -07:00
Hunter Thornsberry
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 Thornsberry
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 Thornsberry
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 Thornsberry
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 Thornsberry
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 Thornsberry
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 Thornsberry
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 Thornsberry
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 Thornsberry
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 Thornsberry
29fab4b6f3 Merge branch 'master' into issue-251-mqtt-saving 2024-06-23 20:19:29 -04:00
Hunter Thornsberry
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 Thornsberry
fa127eda33 Merge branch 'master' into feature/enforce-biome 2024-06-23 20:08:56 -04:00
Hunter Thornsberry
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
server2003user
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 Thornsberry
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 Thornsberry
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 Fifield
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 Drackett
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
geftactics
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
74 changed files with 5805 additions and 3678 deletions

View File

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

View File

@@ -19,3 +19,12 @@ jobs:
- name: Build Package
run: pnpm build
- name: Compress build
run: pnpm package
- name: Archive compressed build
uses: actions/upload-artifact@v4
with:
name: build
path: dist/build.tar

55
.github/workflows/release.yml vendored Normal file
View File

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

View File

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

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

View File

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

7437
pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ import {
LayersIcon,
LayoutIcon,
LinkIcon,
LucideIcon,
type LucideIcon,
MapIcon,
MessageSquareIcon,
MoonIcon,
@@ -200,10 +200,17 @@ export const CommandPalette = (): JSX.Element => {
},
},
{
label: "Factory Reset",
label: "Factory Reset Device",
icon: FactoryIcon,
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);
return () => window.removeEventListener("keydown", handleKeydown);
}, []);
}, [setCommandPaletteOpen]);
return (
<CommandDialog

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,17 @@
import {
DynamicFormField,
FieldProps,
type FieldProps,
} from "@components/Form/DynamicFormField.js";
import { FieldWrapper } from "@components/Form/FormWrapper.js";
import { Button } from "@components/UI/Button.js";
import { H4 } from "@components/UI/Typography/H4.js";
import { Subtle } from "@components/UI/Typography/Subtle.js";
import {
Control,
DefaultValues,
FieldValues,
Path,
SubmitHandler,
type Control,
type DefaultValues,
type FieldValues,
type Path,
type SubmitHandler,
useForm,
} from "react-hook-form";
@@ -23,10 +23,12 @@ interface DisabledBy<T> {
export interface BaseFormBuilderProps<T> {
name: Path<T>;
disabled?: boolean;
disabledBy?: DisabledBy<T>[];
label: string;
description?: string;
properties?: {};
validationText?: string;
properties?: Record<string, unknown>;
}
export interface GenericFormElementProps<T extends FieldValues, Y> {
@@ -39,11 +41,12 @@ export interface DynamicFormProps<T extends FieldValues> {
onSubmit: SubmitHandler<T>;
submitType?: "onChange" | "onSubmit";
hasSubmitButton?: boolean;
// defaultValues?: DeepPartial<T>;
defaultValues?: DefaultValues<T>;
fieldGroups: {
label: string;
description: string;
valid?: boolean;
validationText?: string;
fields: FieldProps<T>[];
}[];
}
@@ -60,11 +63,16 @@ export function DynamicForm<T extends FieldValues>({
defaultValues: defaultValues,
});
const isDisabled = (disabledBy?: DisabledBy<T>[]): boolean => {
const isDisabled = (
disabledBy?: DisabledBy<T>[],
disabled?: boolean,
): boolean => {
if (disabled) return true;
if (!disabledBy) return false;
return disabledBy.some((field) => {
const value = getValues(field.fieldName);
if (value === "always") return true;
if (typeof value === "boolean") return field.invert ? value : !value;
if (typeof value === "number")
return field.invert
@@ -94,12 +102,20 @@ export function DynamicForm<T extends FieldValues>({
</div>
{fieldGroup.fields.map((field) => (
<FieldWrapper label={field.label} description={field.description}>
<FieldWrapper
key={field.label}
label={field.label}
description={field.description}
valid={
field.validationText === undefined ||
field.validationText === ""
}
validationText={field.validationText}
>
<DynamicFormField
key={field.label}
field={field}
control={control}
disabled={isDisabled(field.disabledBy)}
disabled={isDisabled(field.disabledBy, field.disabled)}
/>
</FieldWrapper>
))}

View File

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

View File

@@ -4,11 +4,14 @@ import type {
} from "@components/Form/DynamicForm.js";
import { Input } from "@components/UI/Input.js";
import type { LucideIcon } from "lucide-react";
import { Controller, FieldValues } from "react-hook-form";
import type { ChangeEventHandler } from "react";
import { Controller, type FieldValues } from "react-hook-form";
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
type: "text" | "number" | "password";
inputChange?: ChangeEventHandler;
properties?: {
value?: string;
prefix?: string;
suffix?: string;
step?: number;
@@ -33,13 +36,14 @@ export function GenericInput<T extends FieldValues>({
type={field.type}
step={field.properties?.step}
value={field.type === "number" ? Number.parseFloat(value) : value}
onChange={(e) =>
onChange={(e) => {
if (field.inputChange) field.inputChange(e);
onChange(
field.type === "number"
? Number.parseFloat(e.target.value)
: e.target.value,
)
}
);
}}
{...field.properties}
{...rest}
disabled={disabled}

View File

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

View File

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

View File

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

View File

@@ -5,12 +5,16 @@ export interface FieldWrapperProps {
description?: string;
disabled?: boolean;
children?: React.ReactNode;
valid?: boolean;
validationText?: string;
}
export const FieldWrapper = ({
label,
description,
children,
valid,
validationText,
}: FieldWrapperProps): JSX.Element => (
<div className="pt-6 sm:pt-5">
<div role="group" aria-labelledby="label-notifications">
@@ -19,6 +23,9 @@ export const FieldWrapper = ({
<div className="sm:col-span-2">
<div className="max-w-lg">
<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="flex items-center">{children}</div>
</div>

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 { useToast } from "@core/hooks/useToast.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 { useState } from "react";
export interface SettingsPanelProps {
channel: Protobuf.Channel.Channel;
@@ -13,15 +15,27 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
const { config, connection, addChannel } = useDevice();
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 channel = new Protobuf.Channel.Channel({
...data,
settings: {
...data.settings,
psk: toByteArray(data.settings.psk ?? ""),
psk: toByteArray(pass),
moduleSettings: {
positionPrecision: data.settings.positionEnabled ? data.settings.preciseLocation ? 32 : data.settings.positionPrecision : 0,
}
positionPrecision: data.settings.positionEnabled
? data.settings.preciseLocation
? 32
: data.settings.positionPrecision
: 0,
},
},
});
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 (
<DynamicForm<ChannelValidation>
onSubmit={onSubmit}
@@ -42,10 +88,17 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
...{
settings: {
...channel?.settings,
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
positionEnabled: channel?.settings?.moduleSettings?.positionPrecision != undefined && channel?.settings?.moduleSettings?.positionPrecision > 0,
preciseLocation: channel?.settings?.moduleSettings?.positionPrecision == 32,
positionPrecision: channel?.settings?.moduleSettings?.positionPrecision == undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision
psk: pass,
positionEnabled:
channel?.settings?.moduleSettings?.positionPrecision !==
undefined &&
channel?.settings?.moduleSettings?.positionPrecision > 0,
preciseLocation:
channel?.settings?.moduleSettings?.positionPrecision === 32,
positionPrecision:
channel?.settings?.moduleSettings?.positionPrecision === undefined
? 10
: channel?.settings?.moduleSettings?.positionPrecision,
},
},
}}
@@ -65,12 +118,17 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
},
},
{
type: "password",
type: "passwordGenerator",
name: "settings.psk",
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: {
// act
value: pass,
},
},
{
@@ -111,9 +169,32 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
description:
"If not sharing precise location, position shared on channel will be accurate within this distance",
properties: {
enumValue: config.display?.units == 0 ?
{ "Within 23 km":10, "Within 12 km":11, "Within 5.8 km":12, "Within 2.9 km":13, "Within 1.5 km":14, "Within 700 m":15, "Within 350 m":16, "Within 200 m":17, "Within 90 m":18, "Within 50 m":19 } :
{ "Within 15 miles":10, "Within 7.3 miles":11, "Within 3.6 miles":12, "Within 1.8 miles":13, "Within 0.9 miles":14, "Within 0.5 miles":15, "Within 0.2 miles":16, "Within 600 feet":17, "Within 300 feet":18, "Within 150 feet":19 }
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,
},
},
},
],

View File

@@ -36,19 +36,6 @@ export const Device = (): JSX.Element => {
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",
name: "buttonGpio",
@@ -86,12 +73,6 @@ export const Device = (): JSX.Element => {
label: "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",
name: "disableTripleClick",

View File

@@ -41,7 +41,7 @@ export const LoRa = (): JSX.Element => {
label: "Hop Limit",
description: "Maximum number of hops",
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 },
},
},
{

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import React, { useState } from "react";
import { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import type { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import { Button } from "@components/UI/Button.js";
import { Input } from "@components/UI/Input.js";
import { Label } from "@components/UI/Label.js";
@@ -9,6 +8,7 @@ import { useDeviceStore } from "@core/stores/deviceStore.js";
import { subscribeAll } from "@core/subscriptions.js";
import { randId } from "@core/utils/randId.js";
import { HttpConnection } from "@meshtastic/js";
import { useState } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
@@ -20,7 +20,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
}>({
defaultValues: {
ip: ["client.meshtastic.org", "localhost"].includes(
window.location.hostname
window.location.hostname,
)
? "meshtastic.local"
: window.location.hostname,
@@ -38,7 +38,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
const onSubmit = handleSubmit(async (data) => {
setConnectionInProgress(true);
const id = randId();
const device = addDevice(id);
const connection = new HttpConnection(id);
@@ -75,7 +75,9 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
<Switch
// label="Use TLS"
// description="Description"
disabled={location.protocol === "https:" || connectionInProgress}
disabled={
location.protocol === "https:" || connectionInProgress
}
checked={value}
{...rest}
/>
@@ -84,7 +86,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
/>
</div>
<Button type="submit" disabled={connectionInProgress}>
<span>{connectionInProgress ? 'Connecting...' : 'Connect' }</span>
<span>{connectionInProgress ? "Connecting..." : "Connect"}</span>
</Button>
</form>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -87,7 +87,7 @@ export const Telemetry = (): JSX.Element => {
description: "How often to send Power data over the mesh",
},
{
type: "text",
type: "toggle",
name: "powerScreenEnabled",
label: "Power Screen Enabled",
description: "Enable the Power Telemetry Screen",

View File

@@ -1,5 +1,6 @@
import { cn } from "@app/core/utils/cn.js";
import { AlignLeftIcon, LucideIcon } from "lucide-react";
import { AlignLeftIcon, type LucideIcon } from "lucide-react";
import Footer from "./UI/Footer";
export interface PageLayoutProps {
label: string;
@@ -18,40 +19,43 @@ export const PageLayout = ({
children,
}: PageLayoutProps): JSX.Element => {
return (
<div className="relative flex h-full w-full flex-col">
<div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4">
<button
type="button"
className="pl-4 transition-all hover:text-accent md:hidden"
>
<AlignLeftIcon />
</button>
<div className="flex flex-1 items-center justify-between px-4 md:px-0">
<div className="flex w-full items-center">
<span className="w-full text-lg font-medium">{label}</span>
<div className="flex justify-end space-x-4">
{actions?.map((action, index) => (
<button
key={action.icon.name}
type="button"
className="transition-all hover:text-accent"
onClick={action.onClick}
>
<action.icon />
</button>
))}
<>
<div className="relative flex h-full w-full flex-col">
<div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4">
<button
type="button"
className="pl-4 transition-all hover:text-accent md:hidden"
>
<AlignLeftIcon />
</button>
<div className="flex flex-1 items-center justify-between px-4 md:px-0">
<div className="flex w-full items-center">
<span className="w-full text-lg font-medium">{label}</span>
<div className="flex justify-end space-x-4">
{actions?.map((action, index) => (
<button
key={action.icon.name}
type="button"
className="transition-all hover:text-accent"
onClick={action.onClick}
>
<action.icon />
</button>
))}
</div>
</div>
</div>
</div>
<div
className={cn(
"flex h-full w-full flex-col overflow-y-auto",
!noPadding && "pl-3 pr-3 ",
)}
>
{children}
<Footer />
</div>
</div>
<div
className={cn(
"flex h-full w-full flex-col overflow-y-auto",
!noPadding && "p-3",
)}
>
{children}
</div>
</div>
</>
);
};

View File

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

View File

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

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 { 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",
destructive:
"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:
"bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100",
subtle:

View File

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

View File

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

View File

@@ -1,10 +1,27 @@
import * as React from "react";
import { cn } from "@core/utils/cn.js";
import { type VariantProps, cva } from "class-variance-authority";
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
extends React.InputHTMLAttributes<HTMLInputElement> {
extends React.InputHTMLAttributes<HTMLInputElement>,
VariantProps<typeof inputVariants> {
prefix?: string;
suffix?: string;
action?: {
@@ -14,7 +31,7 @@ export interface InputProps
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, prefix, suffix, action, ...props }, ref) => {
({ className, value, variant, prefix, suffix, action, ...props }, ref) => {
return (
<div className="relative w-full">
{prefix && (
@@ -24,10 +41,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
)}
<input
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",
className,
inputVariants({ variant }),
)}
value={value}
ref={ref}
{...props}
/>
@@ -51,4 +69,4 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
);
Input.displayName = "Input";
export { Input };
export { Input, inputVariants };

View File

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

View File

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

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

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

View File

@@ -50,7 +50,10 @@ export const useAppStore = create<AppState>()((set) => ({
currentPage: "messages",
rasterSources: [],
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",
connectDialogOpen: false,
nodeNumToBeRemoved: 0,
@@ -96,7 +99,7 @@ export const useAppStore = create<AppState>()((set) => ({
);
},
setDarkMode: (enabled: boolean) => {
localStorage.setItem('theme-dark', enabled.toString());
localStorage.setItem("theme-dark", enabled.toString());
set(
produce<AppState>((draft) => {
draft.darkMode = enabled;
@@ -105,7 +108,7 @@ export const useAppStore = create<AppState>()((set) => ({
},
setNodeNumToBeRemoved: (nodeNum) =>
set((state) => ({
nodeNumToBeRemoved: nodeNum
nodeNumToBeRemoved: nodeNum,
})),
setAccent(color) {
set(

View File

@@ -42,6 +42,10 @@ export interface Device {
direct: Map<number, MessageWithState[]>;
broadcast: Map<Types.ChannelNumber, MessageWithState[]>;
};
traceroutes: Map<
number,
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
>;
connection?: Types.ConnectionType;
activePage: Page;
activeNode: number;
@@ -75,6 +79,9 @@ export interface Device {
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
addConnection: (connection: Types.ConnectionType) => void;
addMessage: (message: MessageWithState) => void;
addTraceRoute: (
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
) => void;
addMetadata: (from: number, metadata: Protobuf.Mesh.DeviceMetadata) => void;
removeNode: (nodeNum: number) => void;
setMessageState: (
@@ -122,6 +129,7 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
direct: new Map(),
broadcast: new Map(),
},
traceroutes: new Map(),
connection: undefined,
activePage: "messages",
activeNode: 0,
@@ -183,6 +191,9 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
device.config.bluetooth = config.payloadVariant.value;
break;
}
case "security": {
device.config.security = config.payloadVariant.value;
}
}
}
}),
@@ -487,6 +498,7 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
}),
);
},
addMetadata: (from, metadata) => {
set(
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) => {
set(
produce<DeviceState>((draft) => {
@@ -506,8 +538,8 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
return;
}
device.nodes.delete(nodeNum);
})
)
}),
);
},
setMessageState: (
type: "direct" | "broadcast",

View File

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

View File

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

15
src/core/utils/x25519.ts Normal file
View File

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

View File

@@ -5,6 +5,7 @@ import { LoRa } from "@components/PageComponents/Config/LoRa.js";
import { Network } from "@components/PageComponents/Config/Network.js";
import { Position } from "@components/PageComponents/Config/Position.js";
import { Power } from "@components/PageComponents/Config/Power.js";
import { Security } from "@components/PageComponents/Config/Security.js";
import {
Tabs,
TabsContent,
@@ -47,16 +48,17 @@ export const DeviceConfig = (): JSX.Element => {
label: "Bluetooth",
element: Bluetooth,
},
{
label: "Security",
element: Security,
},
];
return (
<Tabs defaultValue="Device">
<TabsList>
{tabs.map((tab) => (
<TabsTrigger
key={tab.label}
value={tab.label}
>
<TabsTrigger key={tab.label} value={tab.label}>
{tab.label}
</TabsTrigger>
))}

View File

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

View File

@@ -21,79 +21,81 @@ export const Dashboard = () => {
const devices = useMemo(() => getDevices(), [getDevices]);
return (
<div className="flex flex-col gap-3 p-3">
<div className="flex items-center justify-between">
<div className="space-y-1">
<H3>Connected Devices</H3>
<Subtle>Manage, connect and disconnect devices</Subtle>
<>
<div className="flex flex-col gap-3 p-3">
<div className="flex items-center justify-between">
<div className="space-y-1">
<H3>Connected Devices</H3>
<Subtle>Manage, connect and disconnect devices</Subtle>
</div>
</div>
<Separator />
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
{devices.length ? (
<ul className="grow divide-y divide-gray-200">
{devices.map((device) => {
return (
<li key={device.id}>
<div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<p className="truncate text-sm font-medium text-accent">
{device.nodes.get(device.hardware.myNodeNum)?.user
?.longName ?? "UNK"}
</p>
<div className="inline-flex w-24 justify-center gap-2 rounded-full bg-slate-100 py-1 text-xs font-semibold text-slate-900 transition-colors hover:bg-slate-700 hover:text-slate-50">
{device.connection?.connType === "ble" && (
<>
<BluetoothIcon size={16} />
BLE
</>
)}
{device.connection?.connType === "serial" && (
<>
<UsbIcon size={16} />
Serial
</>
)}
{device.connection?.connType === "http" && (
<>
<NetworkIcon size={16} />
Network
</>
)}
</div>
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="flex gap-2 text-sm text-gray-500">
<UsersIcon
size={20}
className="text-gray-400"
aria-hidden="true"
/>
{device.nodes.size === 0 ? 0 : device.nodes.size - 1}
</div>
</div>
</div>
</li>
);
})}
</ul>
) : (
<div className="m-auto flex flex-col gap-3 text-center">
<ListPlusIcon size={48} className="mx-auto text-textSecondary" />
<H3>No Devices</H3>
<Subtle>Connect atleast one device to get started</Subtle>
<Button
className="gap-2"
onClick={() => setConnectDialogOpen(true)}
>
<PlusIcon size={16} />
New Connection
</Button>
</div>
)}
</div>
</div>
<Separator />
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
{devices.length ? (
<ul className="grow divide-y divide-gray-200">
{devices.map((device) => {
return (
<li key={device.id}>
<div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<p className="truncate text-sm font-medium text-accent">
{device.nodes.get(device.hardware.myNodeNum)?.user
?.longName ?? "UNK"}
</p>
<div className="inline-flex w-24 justify-center gap-2 rounded-full bg-slate-100 py-1 text-xs font-semibold text-slate-900 transition-colors hover:bg-slate-700 hover:text-slate-50">
{device.connection?.connType === "ble" && (
<>
<BluetoothIcon size={16} />
BLE
</>
)}
{device.connection?.connType === "serial" && (
<>
<UsbIcon size={16} />
Serial
</>
)}
{device.connection?.connType === "http" && (
<>
<NetworkIcon size={16} />
Network
</>
)}
</div>
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="flex gap-2 text-sm text-gray-500">
<UsersIcon
size={20}
className="text-gray-400"
aria-hidden="true"
/>
{device.nodes.size === 0 ? 0 : device.nodes.size - 1}
</div>
</div>
</div>
</li>
);
})}
</ul>
) : (
<div className="m-auto flex flex-col gap-3 text-center">
<ListPlusIcon size={48} className="mx-auto text-textSecondary" />
<H3>No Devices</H3>
<Subtle>Connect atleast one device to get started</Subtle>
<Button
className="gap-2"
onClick={() => setConnectDialogOpen(true)}
>
<PlusIcon size={16} />
New Connection
</Button>
</div>
)}
</div>
</div>
</>
);
};

View File

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

View File

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

View File

@@ -1,15 +1,16 @@
import Footer from "@app/components/UI/Footer";
import { useAppStore } from "@app/core/stores/appStore";
import { Sidebar } from "@components/Sidebar.js";
import { Button } from "@components/UI/Button.js";
import { Mono } from "@components/generic/Mono.js";
import { Table } from "@components/generic/Table/index.js";
import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.js";
import { useDevice } from "@core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react";
import { Protobuf } from "@meshtastic/js";
import { base16 } from "rfc4648";
import { Button } from "@components/UI/Button.js";
import { TrashIcon } from "lucide-react";
import { useAppStore } from "@app/core/stores/appStore";
import { Fragment } from "react";
import { base16 } from "rfc4648";
export interface DeleteNoteDialogProps {
open: boolean;
@@ -27,59 +28,76 @@ export const NodesPage = (): JSX.Element => {
return (
<>
<Sidebar />
<div className="w-full overflow-y-auto">
<Table
headings={[
{ title: "", type: "blank", sortable: false },
{ title: "Name", type: "normal", sortable: true },
{ title: "Model", type: "normal", sortable: true },
{ title: "MAC Address", type: "normal", sortable: true },
{ title: "Last Heard", type: "normal", sortable: true },
{ title: "SNR", type: "normal", sortable: true },
{ title: "Connection", type: "normal", sortable: true },
{ title: "Remove", type: "normal", sortable: false },
]}
rows={filteredNodes.map((node) => [
<Hashicon size={24} value={node.num.toString()} />,
<h1>
{node.user?.longName ??
(node.user?.macaddr
? `Meshtastic ${base16
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
.toLowerCase()}`
: `UNK: ${node.num}`)}
</h1>,
<div className="flex flex-col w-full">
<div className="overflow-y-auto h-full">
<Table
headings={[
{ title: "", type: "blank", sortable: false },
{ title: "Name", type: "normal", sortable: true },
{ title: "Model", type: "normal", sortable: true },
{ title: "MAC Address", type: "normal", sortable: true },
{ title: "Last Heard", type: "normal", sortable: true },
{ title: "SNR", type: "normal", sortable: true },
{ title: "Connection", type: "normal", sortable: true },
{ title: "Remove", type: "normal", sortable: false },
]}
rows={filteredNodes.map((node) => [
<Hashicon key="icon" size={24} value={node.num.toString()} />,
<h1 key="header">
{node.user?.longName ??
(node.user?.macaddr
? `Meshtastic ${base16
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
.toLowerCase()}`
: `UNK: ${node.num}`)}
</h1>,
<Mono>{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}</Mono>,
<Mono>
{base16
.stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g)
?.join(":") ?? "UNK"}
</Mono>,
node.lastHeard === 0 ? (
<p>Never</p>
) : (
<TimeAgo timestamp={node.lastHeard * 1000} />
),
<Mono>
{node.snr}db/
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
{(node.snr + 10) * 5}raw
</Mono>,
<Mono>
{node.lastHeard != 0 ?
(node.viaMqtt === false && node.hopsAway === 0
? "Direct": node.hopsAway.toString() + " hops away")
: "-"}
{node.viaMqtt === true? ", via MQTT": ""}
</Mono>,
<Button variant="destructive" onClick={() => {
setNodeNumToBeRemoved(node.num);
setDialogOpen("nodeRemoval", true)
}}><TrashIcon />Remove</Button>
])}
/>
<Mono key="model">
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
</Mono>,
<Mono key="addr">
{base16
.stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g)
?.join(":") ?? "UNK"}
</Mono>,
<Fragment key="lastHeard">
{node.lastHeard === 0 ? (
<p>Never</p>
) : (
<TimeAgo timestamp={node.lastHeard * 1000} />
)}
</Fragment>,
<Mono key="snr">
{node.snr}db/
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
{(node.snr + 10) * 5}raw
</Mono>,
<Mono key="hops">
{node.lastHeard !== 0
? node.viaMqtt === false && node.hopsAway === 0
? "Direct"
: `${node.hopsAway.toString()} ${
node.hopsAway > 1 ? "hops" : "hop"
} away`
: "-"}
{node.viaMqtt === true ? ", via MQTT" : ""}
</Mono>,
<Button
key="remove"
variant="destructive"
onClick={() => {
setNodeNumToBeRemoved(node.num);
setDialogOpen("nodeRemoval", true);
}}
>
<TrashIcon />
Remove
</Button>,
])}
/>
</div>
<Footer />
</div>
</>
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,14 @@ import type { Message } from "@bufbuild/protobuf";
import { Protobuf } from "@meshtastic/js";
import { IsArray, IsBoolean, IsEnum, IsInt } from "class-validator";
const DeprecatedPositionValidationFields = ['gpsEnabled', 'gpsAttemptTime'];
const DeprecatedPositionValidationFields = ["gpsEnabled", "gpsAttemptTime"];
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()
positionBroadcastSecs: number;

View File

@@ -3,7 +3,8 @@ import type { Protobuf } from "@meshtastic/js";
import { IsBoolean, IsInt, IsNumber, Max, Min } from "class-validator";
export class PowerValidation
implements Omit<Protobuf.Config.Config_PowerConfig, keyof Message>
implements
Omit<Protobuf.Config.Config_PowerConfig, keyof Message | "powermonEnables">
{
@IsBoolean()
isPowerSaving: boolean;

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

View File

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

View File

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

View File

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

View File

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

View File

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