Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee5243a205 | ||
|
|
37c0fedeec | ||
|
|
d10b71c6f5 | ||
|
|
7de9b1c943 | ||
|
|
3f5086eb82 | ||
|
|
6d3acd85ca | ||
|
|
944de7d560 | ||
|
|
a1088b733a | ||
|
|
9a474420f2 | ||
|
|
65aefbfb51 | ||
|
|
cc117b93aa | ||
|
|
e3b84abaae | ||
|
|
38119f2e2b | ||
|
|
5a98701f51 | ||
|
|
c50036ceea | ||
|
|
7b0d334626 | ||
|
|
bddf99aef2 | ||
|
|
c6bb3cb65f | ||
|
|
87a4226c21 | ||
|
|
6ba9488f19 | ||
|
|
a574db005c | ||
|
|
ea5c033c04 | ||
|
|
b968bf9908 | ||
|
|
185933e779 | ||
|
|
2e352810c3 | ||
|
|
fce9f034f5 | ||
|
|
afd8db01d2 | ||
|
|
6894b5fe9c | ||
|
|
e89814818c | ||
|
|
904c7107f3 | ||
|
|
4f0b07c403 | ||
|
|
542172f119 | ||
|
|
b1344af667 | ||
|
|
695d6cd9a8 | ||
|
|
5f4f1094ff | ||
|
|
2b752eb807 | ||
|
|
17ebb0f5bd | ||
|
|
9ef76f6d6b | ||
|
|
e1ef436de5 | ||
|
|
f32ef36223 | ||
|
|
7cd8b8eb9a | ||
|
|
4b3ccf92f9 | ||
|
|
9d6f89105a | ||
|
|
851f6324d8 | ||
|
|
88f61b7588 | ||
|
|
696bb0b839 | ||
|
|
82e6103fc1 | ||
|
|
b30974355a | ||
|
|
523784f9c7 | ||
|
|
f15f7f5be0 | ||
|
|
02e02a72df | ||
|
|
5b10d3919d | ||
|
|
1066d1ff6a | ||
|
|
8e611a6a03 | ||
|
|
12a998242a | ||
|
|
0c2bdd33c7 | ||
|
|
daa71c5516 | ||
|
|
c3e2df6672 | ||
|
|
86cb621f10 | ||
|
|
192944f48c | ||
|
|
479d9d333c | ||
|
|
b5a96dcc2e | ||
|
|
1699097683 | ||
|
|
1577f59272 | ||
|
|
1954715077 | ||
|
|
c81a481e00 | ||
|
|
50d4923ad3 | ||
|
|
0c272cdc1e | ||
|
|
9996c59535 | ||
|
|
2e121c4c3b | ||
|
|
726075b0ab | ||
|
|
46c2b5f262 | ||
|
|
5d97a27bc3 | ||
|
|
bcee361006 | ||
|
|
9ee62f0866 | ||
|
|
8ffa89a503 | ||
|
|
35fe0a261b | ||
|
|
8670760390 | ||
|
|
54cffcf702 | ||
|
|
51b5e5c583 | ||
|
|
01325180d3 | ||
|
|
40cc2f6296 | ||
|
|
862c948472 | ||
|
|
6046c0e47e | ||
|
|
6a0cfafd94 | ||
|
|
d2e465cfa0 | ||
|
|
f7f382ce29 | ||
|
|
77cfbf257d | ||
|
|
01785fc024 | ||
|
|
bfecd2a3f3 | ||
|
|
d49c80b829 | ||
|
|
d707bb1674 | ||
|
|
20f81b2554 | ||
|
|
03aa01e6e7 | ||
|
|
214bb413bc | ||
|
|
3a1a29326d | ||
|
|
ebe2b32554 | ||
|
|
aec3ad9ed3 | ||
|
|
d71be596ab | ||
|
|
1eeabeaa91 | ||
|
|
948474a18e | ||
|
|
abe54e3b31 | ||
|
|
56cfc31e0f | ||
|
|
2f9effb04c | ||
|
|
d6fcc211dc | ||
|
|
84948adfa9 | ||
|
|
8ffdcce1fa | ||
|
|
3297518ff4 | ||
|
|
2233ab3383 | ||
|
|
d7dbc7e7d3 | ||
|
|
1c746f0a83 | ||
|
|
b14cbb2ea4 | ||
|
|
ea77054d1b | ||
|
|
1818383efd | ||
|
|
1248111621 | ||
|
|
c4fd4e33ff | ||
|
|
8189d6cf76 | ||
|
|
a7d5ea7e94 | ||
|
|
b900352920 | ||
|
|
b0bde79fc9 | ||
|
|
38c7569d0f | ||
|
|
2578415b83 | ||
|
|
625ee71e48 | ||
|
|
173ad48424 | ||
|
|
c6bdd3c8b2 | ||
|
|
671aefd11c | ||
|
|
ce61f38254 | ||
|
|
4b4eb3265d | ||
|
|
4452eae48f | ||
|
|
a7f7e8f93b | ||
|
|
69f9ad8bc3 | ||
|
|
501388eace | ||
|
|
ee174e7e5f | ||
|
|
ec0c91a334 | ||
|
|
78066d545f | ||
|
|
dba3b9b10a | ||
|
|
103082fb58 | ||
|
|
858de11b3b | ||
|
|
3ec58d8827 | ||
|
|
14ab1409e0 | ||
|
|
57158e4d63 | ||
|
|
f07e04e512 | ||
|
|
fd0fcb12ab | ||
|
|
bb09381ed7 | ||
|
|
e884514637 | ||
|
|
da51652ac1 | ||
|
|
c4f48a5ce6 | ||
|
|
16e6ac6ef9 | ||
|
|
0ef3b1cbe6 | ||
|
|
822e8e06c9 | ||
|
|
1ee012b012 | ||
|
|
80e876ab9c | ||
|
|
c4f274c8ce | ||
|
|
f8ae859614 | ||
|
|
02b011351c | ||
|
|
d9750a2f65 | ||
|
|
cc912172d9 | ||
|
|
c94a53dde9 | ||
|
|
848c840008 | ||
|
|
d2475f99e8 | ||
|
|
c57b3dc1c1 | ||
|
|
454adaa5d6 | ||
|
|
86652bd45f | ||
|
|
6bbc46a3af | ||
|
|
2042299fa8 | ||
|
|
264eb48756 | ||
|
|
ccd9d552f1 | ||
|
|
16cb8b396c | ||
|
|
13171616be | ||
|
|
98b8cfafe7 | ||
|
|
af0b50c070 | ||
|
|
91267d99e2 | ||
|
|
361323c490 | ||
|
|
78476b4d47 | ||
|
|
2fd53e3fd1 | ||
|
|
5416282788 | ||
|
|
a472d8762b | ||
|
|
5d128140cb | ||
|
|
4acdd127f5 | ||
|
|
9d1a408123 | ||
|
|
139ca7125f | ||
|
|
38755cd861 | ||
|
|
25a0632f77 | ||
|
|
ad692eef74 | ||
|
|
a7aa25aa30 | ||
|
|
77cc20635b | ||
|
|
90b2cd1fc5 | ||
|
|
a5e690e277 | ||
|
|
5ab71fcd4c | ||
|
|
bd27ed93e9 | ||
|
|
16ec124fc3 | ||
|
|
c2839d04e3 | ||
|
|
251c073a42 | ||
|
|
d2cb51d489 | ||
|
|
c5690d1edb | ||
|
|
e1ea1c4f02 | ||
|
|
12c27092be | ||
|
|
c3b9443701 | ||
|
|
0d89b09b1f | ||
|
|
77206a19ac | ||
|
|
89bfb14c77 | ||
|
|
4754504191 | ||
|
|
69acc29a95 | ||
|
|
2dc5dcde5c | ||
|
|
7809758537 | ||
|
|
f32b857705 | ||
|
|
21040e8876 | ||
|
|
193f45d568 | ||
|
|
e6d25f2fd1 | ||
|
|
a8fcd81c2b | ||
|
|
039df71307 | ||
|
|
3df59aa3a1 | ||
|
|
3212026a89 | ||
|
|
3fcba32b38 | ||
|
|
1095e1c5c4 | ||
|
|
6f4ad809ca | ||
|
|
effacf7b91 | ||
|
|
5af1d042cf | ||
|
|
f848bea33f | ||
|
|
94282b634e | ||
|
|
7b8298e292 | ||
|
|
0e04d37ad1 | ||
|
|
a5e86f8823 | ||
|
|
5946833e4d | ||
|
|
598ff8d08e | ||
|
|
c710fea5fd | ||
|
|
770acaa5e0 | ||
|
|
293a963e0e | ||
|
|
72ed413cf1 | ||
|
|
a290b3cd3d | ||
|
|
dbe8953942 | ||
|
|
3dec67f623 | ||
|
|
42de8e7403 | ||
|
|
8f0db66131 | ||
|
|
bcfa1bd8cc | ||
|
|
cd3bfc3c51 | ||
|
|
c4aa168af2 | ||
|
|
afc28b6e16 | ||
|
|
1590f17689 | ||
|
|
e6cbd88a88 | ||
|
|
cffa7f28ed | ||
|
|
bb326798f9 | ||
|
|
6719211cdb | ||
|
|
00251a5003 | ||
|
|
dc9749c042 | ||
|
|
ccc02ad438 | ||
|
|
d901ec40ae | ||
|
|
1fce10a1df | ||
|
|
341f3be6d6 |
@@ -10,4 +10,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,21 @@ updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 3 # 1 day after pnpm's cutoff, prevent a race
|
||||
open-pull-requests-limit: 10 # Increase PR limit
|
||||
groups:
|
||||
tanstack:
|
||||
patterns:
|
||||
- "@tanstack/*"
|
||||
radix-ui:
|
||||
patterns:
|
||||
- "@radix-ui/*"
|
||||
react-ecosystem:
|
||||
patterns:
|
||||
- react
|
||||
|
||||
@@ -14,13 +14,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
@@ -11,13 +11,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Download translations with Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
base_url: "https://meshtastic.crowdin.com/api/v2"
|
||||
config: "./packages/web/crowdin.yml"
|
||||
config: "./apps/web/crowdin.yml"
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
# Monitor all .json files within the /i18n/locales/en/ directory.
|
||||
# This ensures the workflow triggers if any the English namespace files are modified on the main branch.
|
||||
paths:
|
||||
- "/packages/web/public/i18n/locales/en/*.json"
|
||||
- "/apps/web/public/i18n/locales/en/*.json"
|
||||
branches: [main]
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
@@ -15,13 +15,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Upload sources with Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
base_url: "https://meshtastic.crowdin.com/api/v2"
|
||||
config: "./packages/web/crowdin.yml"
|
||||
config: "./apps/web/crowdin.yml"
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: false
|
||||
|
||||
@@ -9,13 +9,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Upload translations with Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
base_url: "https://meshtastic.crowdin.com/api/v2"
|
||||
config: "./packages/web/crowdin.yml"
|
||||
config: "./apps/web/crowdin.yml"
|
||||
upload_sources: false
|
||||
upload_translations: true
|
||||
download_translations: false
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Real-device end-to-end suite: drives the web app in Chromium against two
|
||||
# simulated `meshtasticd` firmware nodes (UDP-multicast mesh, no MQTT) and
|
||||
# exercises text messaging in both directions. See e2e/README.md.
|
||||
name: E2E (real device)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- "e2e/**"
|
||||
- "apps/web/**"
|
||||
- "packages/**"
|
||||
- "playwright.config.ts"
|
||||
- ".github/workflows/e2e.yml"
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CI: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install workspace deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Chromium
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install Python mesh peer
|
||||
run: |
|
||||
python -m venv e2e/peer/.venv
|
||||
e2e/peer/.venv/bin/pip install -r e2e/peer/requirements.txt
|
||||
|
||||
- name: Run E2E suite
|
||||
run: pnpm test:e2e
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report
|
||||
path: e2e/.report
|
||||
retention-days: 7
|
||||
|
||||
- name: Dump device logs on failure
|
||||
if: failure()
|
||||
run: docker compose -f e2e/device/docker-compose.yml logs --no-color || true
|
||||
|
||||
- name: Tear down mesh
|
||||
if: always()
|
||||
run: docker compose -f e2e/device/docker-compose.yml down -v || true
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
- uses: actions/stale@v11
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-issue-stale: 60
|
||||
|
||||
@@ -18,13 +18,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
|
||||
- name: Build Container Image (multi-arch)
|
||||
id: build-container
|
||||
uses: redhat-actions/buildah-build@v2
|
||||
uses: redhat-actions/buildah-build@v3
|
||||
with:
|
||||
containerfiles: |
|
||||
./apps/web/infra/Containerfile
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
|
||||
- name: Push To GHCR
|
||||
id: push-to-registry
|
||||
uses: redhat-actions/push-to-registry@v2
|
||||
uses: redhat-actions/push-to-registry@v3
|
||||
with:
|
||||
image: ${{ steps.build-container.outputs.image }}
|
||||
# Push the same tags used at build time:
|
||||
|
||||
@@ -21,13 +21,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
@@ -52,6 +52,7 @@ jobs:
|
||||
|
||||
- name: Check formatter
|
||||
run: pnpm run check
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm run test
|
||||
|
||||
@@ -22,13 +22,13 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ github.event.inputs.packages }}" = "all" ] || [ -z "${{ github.event.inputs.packages }}" ]; then
|
||||
mapfile -t TARGETS < <(ls -d packages/* | grep -v 'packages/web')
|
||||
mapfile -t TARGETS < <(ls -d packages/*)
|
||||
else
|
||||
IFS=',' read -ra TARGETS <<< "${{ github.event.inputs.packages }}"
|
||||
TARGETS=("${TARGETS[@]/#/packages/}")
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref != '' && inputs.ref || github.ref }}
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true # IMPORTANT: we need tags to resolve the release commit
|
||||
|
||||
@@ -18,3 +18,6 @@ packages/protobufs/packages/ts/dist
|
||||
*.pem
|
||||
*.crt
|
||||
*.key
|
||||
|
||||
# Claude Code session locks
|
||||
.claude/
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxfmt/configuration_schema.json",
|
||||
"useTabs": false,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 80,
|
||||
"ignorePatterns": ["*.md"]
|
||||
}
|
||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"recommendations": ["bradlc.vscode-tailwindcss", "biomejs.biome"]
|
||||
"recommendations": ["bradlc.vscode-tailwindcss", "oxc.oxc-vscode"]
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -2,5 +2,5 @@
|
||||
"editor.formatOnSave": true,
|
||||
"search.exclude": {
|
||||
"**/i18n/locales/*-*/**": true
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# Meshtastic Web Client - Claude Code Guide
|
||||
|
||||
@AGENTS.md
|
||||
|
||||
## Claude-Specific Instructions
|
||||
|
||||
- **Think First:** Always outline your step-by-step reasoning inside `<thinking>` tags before writing code or shell commands. Claude models perform significantly better on complex TypeScript tasks when they "think out loud" first.
|
||||
- **Skills:** The `.skills/` directory contains task-specific instruction modules. Load them as needed — only the skill relevant to your current task.
|
||||
- **Plan Mode:** Use plan mode for architectural changes spanning multiple modules. Write plans to `.agent_plans/` (git-ignored). The Copilot-CLI-specific `/plan`, `/delegate`, `/research`, and `/share` guidance in `AGENTS.md` does not apply to Claude Code — skip the `<copilot_cli_workflow>` section.
|
||||
@@ -9,32 +9,151 @@
|
||||
## Overview
|
||||
|
||||
This monorepo consolidates the official [Meshtastic](https://meshtastic.org) web
|
||||
interface and its supporting JavaScript libraries. It aims to provide a unified
|
||||
development experience for interacting with Meshtastic devices.
|
||||
interface, the domain-driven JavaScript SDK that drives it, and a set of
|
||||
runtime-specific transport packages. Everything you need to read state from or
|
||||
send commands to a Meshtastic device lives here.
|
||||
|
||||
> [!NOTE]
|
||||
> You can find the main Meshtastic documentation at https://meshtastic.org/docs/introduction/.
|
||||
|
||||
### Projects within this Monorepo (`packages/`)
|
||||
## Packages
|
||||
|
||||
All projects are located within the `packages/` directory:
|
||||
All projects live under `packages/`.
|
||||
|
||||
- **`packages/web` (Meshtastic Web Client):** The official web interface,
|
||||
designed to be hosted or served directly from a Meshtastic node.
|
||||
- **[Hosted version](https://client.meshtastic.org)**
|
||||
- **`packages/core`:** Core functionality for Meshtastic JS.
|
||||
- **`packages/transport-node`:** TCP Transport for the NodeJS runtime.
|
||||
- **`packages/transport-node-serial`:** NodeJS Serial Transport for the NodeJS runtime.
|
||||
- **`packages/transport-deno`:** TCP Transport for the Deno runtime.
|
||||
- **`packages/transport-http`:** HTTP Transport.
|
||||
- **`packages/transport-web-bluetooth`:** Web Bluetooth Transport.
|
||||
- **`packages/transport-web-serial`:** Web Serial Transport.
|
||||
- **`packages/protobufs`:** Git submodule containing Meshtastic’s shared protobuf definitions, used to generate and publish the JSR protobuf package.
|
||||
| Package | Purpose |
|
||||
| --- | --- |
|
||||
| `packages/sdk` | Framework-agnostic TypeScript SDK. Domain-driven feature slices (device, chat, nodes, channels, config, telemetry, position, traceroute, files) built around a `MeshClient` orchestrator with `@preact/signals-core` reactive state. |
|
||||
| `packages/sdk-react` | React hooks + `MeshProvider` on top of `@meshtastic/sdk`. Wraps signals in `useSyncExternalStore` for concurrent-safe renders. |
|
||||
| `apps/web` | Reference React web client. Hosted at [client.meshtastic.org](https://client.meshtastic.org). |
|
||||
| `packages/ui` | Shared Radix + Tailwind component library. |
|
||||
| `packages/protobufs` | Generated TypeScript stubs from [`meshtastic/protobufs`](https://github.com/meshtastic/protobufs), produced via `buf generate`. Source of truth for every wire-level type. |
|
||||
| `packages/transport-http` | HTTP transport for devices exposing a network interface. |
|
||||
| `packages/transport-web-bluetooth` | Web Bluetooth transport for BLE-capable devices (browsers). |
|
||||
| `packages/transport-web-serial` | Web Serial transport for USB-serial devices (browsers). |
|
||||
| `packages/transport-node` | TCP transport for Node.js. |
|
||||
| `packages/transport-node-serial` | Serial transport for Node.js. |
|
||||
| `packages/transport-deno` | TCP transport for Deno. |
|
||||
| `packages/transport-mock` | In-memory transport for tests. |
|
||||
|
||||
All `Meshtastic JS` packages (core and transports) are published both to
|
||||
[JSR](https://jsr.io/@meshtastic). [NPM](https://www.npmjs.com/org/meshtastic)
|
||||
All publishable packages ship to both [JSR](https://jsr.io/@meshtastic) and [NPM](https://www.npmjs.com/org/meshtastic).
|
||||
|
||||
---
|
||||
## Architecture
|
||||
|
||||
`@meshtastic/sdk` organises its source by feature slice. Each slice follows the
|
||||
same DDD layout:
|
||||
|
||||
```
|
||||
features/<slice>/
|
||||
domain/ # entities & value objects — pure TypeScript types
|
||||
application/ # use-cases (SendTextUseCase, FavoriteNodeUseCase, …)
|
||||
infrastructure/ # protobuf ↔ domain mappers, admin-message adapters
|
||||
state/ # signals-backed reactive stores
|
||||
<Slice>Client.ts # public facade exposing readable signals + command methods
|
||||
index.ts
|
||||
```
|
||||
|
||||
The shared kernel under `packages/sdk/src/core/` owns:
|
||||
|
||||
- **`client/`** — `MeshClient`, the thin orchestrator that owns the transport, queue, event bus, and one instance of every slice client.
|
||||
- **`transport/`** — the `Transport` interface every `transport-*` package implements.
|
||||
- **`event-bus/`** — typed pub/sub channels populated by the packet codec.
|
||||
- **`packet-codec/`** — frame parser (0x94 0xC3), `FromRadio` decoder, portnum router.
|
||||
- **`queue/`**, **`xmodem/`** — packet ack/timeout pipeline and file-transfer protocol.
|
||||
- **`signals/`** — signal and keyed-collection helpers consumed by every slice.
|
||||
- **`logging/`** — `tslog` factory used consistently by every class.
|
||||
- **`identifiers/`**, **`errors/`** — small primitives shared across slices.
|
||||
|
||||
The protobuf boundary is strict: wire messages enter through the packet codec,
|
||||
get mapped into domain entities inside `features/*/infrastructure/*Mapper.ts`,
|
||||
and signals only ever expose the domain shape.
|
||||
|
||||
```
|
||||
Transport ─▶ Packet codec ─▶ EventBus ─▶ Slice infrastructure
|
||||
│
|
||||
▼
|
||||
Signals (state) ─▶ sdk-react hooks ─▶ UI
|
||||
▲
|
||||
│
|
||||
Slice application (use-cases) ─▶ MeshClient.sendPacket ─▶ Queue ─▶ Transport
|
||||
```
|
||||
|
||||
Expected domain errors are returned as `Result<T, E>` via [`better-result`](https://www.npmjs.com/package/better-result); exceptions are reserved for programmer errors and truly exceptional conditions.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
You need [pnpm](https://pnpm.io/) installed. If you plan to regenerate
|
||||
protobufs, also install the [Buf CLI](https://buf.build/docs/cli/installation/).
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/meshtastic/web.git
|
||||
cd web
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Run the web client
|
||||
|
||||
```bash
|
||||
pnpm --filter @meshtastic/web dev
|
||||
```
|
||||
|
||||
### Build everything
|
||||
|
||||
```bash
|
||||
pnpm -r build
|
||||
```
|
||||
|
||||
### Run tests
|
||||
|
||||
```bash
|
||||
pnpm -r test
|
||||
```
|
||||
|
||||
### Lint + format
|
||||
|
||||
```bash
|
||||
pnpm check
|
||||
pnpm check:fix
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
### Adding a new feature slice to `@meshtastic/sdk`
|
||||
|
||||
1. Create `packages/sdk/src/features/<slice>/` with `domain/`, `application/`, `infrastructure/`, `state/` subdirectories plus an `index.ts` barrel.
|
||||
2. Implement a signals-backed store in `state/`.
|
||||
3. Subscribe to the relevant `EventBus` channel(s) inside a `<Slice>Client.ts` class and write mapped domain entities to the store.
|
||||
4. Wire the new client into `MeshClient` and re-export types from `packages/sdk/mod.ts`.
|
||||
5. Add vitest coverage: domain invariants, use-cases against `createFakeTransport()`, and round-trip mapper fixtures.
|
||||
6. If the slice has React callers, add a matching hook under `packages/sdk-react/src/hooks/`.
|
||||
|
||||
### Adding a new transport
|
||||
|
||||
Implement the `Transport` interface exported from `@meshtastic/sdk/transport`:
|
||||
|
||||
```ts
|
||||
interface Transport {
|
||||
toDevice: WritableStream<Uint8Array>;
|
||||
fromDevice: ReadableStream<DeviceOutput>;
|
||||
disconnect(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
The SDK does the framing and decoding — transports only supply raw bytes.
|
||||
|
||||
### Testing
|
||||
|
||||
Vitest is wired at the repo root and picks up `packages/*` projects. The SDK
|
||||
ships `@meshtastic/sdk/testing` with `createFakeTransport()` for wiring tests
|
||||
without real hardware.
|
||||
|
||||
## Publishing
|
||||
|
||||
Each publishable package has `build:npm` / `publish:npm` / `prepare:jsr` /
|
||||
`publish:jsr` scripts. See each package's `package.json` for details.
|
||||
|
||||
## Repository activity
|
||||
|
||||
@@ -42,56 +161,11 @@ All `Meshtastic JS` packages (core and transports) are published both to
|
||||
| :------------- | :-------------------------------------------------------------------------------------------------------------------- |
|
||||
| Meshtastic Web |  |
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
This monorepo leverages the following technologies:
|
||||
|
||||
- **Runtime:** pnpm / Deno
|
||||
- **Web Client:** React.js
|
||||
- **Styling:** Tailwind CSS
|
||||
- **Bundling:** Vite
|
||||
- **Language:** TypeScript
|
||||
- **Testing:** Vitest, React Testing Library
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
You'll need to have [pnpm](https://pnpm.io/) installed to work with this monorepo.
|
||||
Follow the installation instructions on their home page.
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone https://github.com/meshtastic/meshtastic-web.git
|
||||
cd meshtastic-web
|
||||
```
|
||||
2. **Install dependencies for all packages:**
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
This command installs all necessary dependencies for all packages within the
|
||||
monorepo.
|
||||
3. **Install the Buf CLI**
|
||||
Required for building `packages/protobufs`
|
||||
https://buf.build/docs/cli/installation/
|
||||
|
||||
### Running Projects
|
||||
|
||||
#### Meshtastic Web Client
|
||||
|
||||
Please refer to the [Meshtastic Web README](packages/web/README.md) for setup and usage.
|
||||
|
||||
### Feedback
|
||||
## Feedback
|
||||
|
||||
If you encounter any issues, please report them in our
|
||||
[issues tracker](https://github.com/meshtastic/web/issues). Your feedback helps
|
||||
improve the stability of future releases
|
||||
improve the stability of future releases.
|
||||
|
||||
## Star history
|
||||
|
||||
@@ -108,3 +182,7 @@ improve the stability of future releases
|
||||
<a href="https://github.com/meshtastic/web/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=meshtastic/web" width="100%"/>
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0-only. See [LICENSE](LICENSE).
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
# Testing strategy
|
||||
|
||||
How this monorepo proves correctness, and where coverage currently sits.
|
||||
|
||||
## Levels
|
||||
|
||||
Tests live in five tiers. Each PR should add coverage at the **lowest level that catches the regression**, and only climb tiers when that's not possible.
|
||||
|
||||
| Tier | Scope | Tooling | Where |
|
||||
| --- | --- | --- | --- |
|
||||
| 1. **Unit** | Pure functions, value objects, mappers, single classes with mocked deps | `vitest run` (Node env) | `*.test.ts` colocated with source |
|
||||
| 2. **Slice integration** | Use-case + store + mapper exercised against in-memory deps; assert outbound bytes and signal state | `vitest` + `createFakeTransport()` | `*.test.ts` in slice dirs |
|
||||
| 3. **Client integration** | `MeshClient` end-to-end with a fake transport feeding canned `FromRadio` packets | `vitest` + `@meshtastic/sdk/testing` | `packages/sdk/tests/integration/` |
|
||||
| 4. **Storage integration** | Real Drizzle queries against sql.js in-memory; or against `@vitest/browser` for OPFS | `vitest` (Node + browser) | `packages/sdk-storage-sqlocal/{src,tests}` |
|
||||
| 5. **Hook / DOM** | React hooks render under `<MeshProvider>` / `<MeshRegistryProvider>`, react to signals | `vitest` + `@testing-library/react` + `jsdom` | `packages/sdk-react/tests/` |
|
||||
| 6. **E2E / simulator** | Whole stack: SDK → transport → simulator/firmware. Catches protocol drift | `@vitest/browser` for OPFS; `meshtasticd` simulator over TCP for protocol | future `tests/e2e/` |
|
||||
|
||||
## Per-package coverage gates
|
||||
|
||||
| Package | Required floor | Notes |
|
||||
| --- | --- | --- |
|
||||
| `packages/sdk/core` | every primitive (signals, EventBus, Queue, packet-codec, identifiers) has a unit test; lifecycle covered by `MeshClient.test.ts` | adopt `c8` thresholds once stable |
|
||||
| `packages/sdk/features/*` | each slice ships: 1 domain invariant test, 1 use-case test against fake transport, 1 mapper round-trip (where mappers exist) | Integration covered by `tests/integration/fake-transport.test.ts` |
|
||||
| `packages/sdk-react` | each public hook has a `renderHook` test that asserts initial render + re-render on signal change | uses jsdom; provider wrapper required |
|
||||
| `packages/sdk-storage-sqlocal` | every repository method tested against sql.js in-memory; **at least one OPFS-real test per repo** runs in browser mode | sql.js validates SQL correctness; OPFS validates VFS / Worker plumbing |
|
||||
| `packages/transport-*` | minimum: framing round-trip, disconnect cleans up streams, error path emits status | low-level; ship as is |
|
||||
| `packages/web` | component tests at `34` baseline; new SDK-driven UI components must add a hook-mock test | currently all green |
|
||||
|
||||
## Current state (audit)
|
||||
|
||||
| Package | Test files | Tests pass | Gaps |
|
||||
| --- | --- | --- | --- |
|
||||
| `packages/sdk` | 7 | 25 ✅ | nodes/channels/config/telemetry/position/traceroute/files slices have **no tests**; no `MeshClient` lifecycle test (only fake-transport integration); no schema migration test |
|
||||
| `packages/sdk-react` | 1 | 2 ✅ | only `useMeshDevice` + a stubbed `useChat` test; missing `useNodes`, `useChannels`, `useConfig`, `useConnection`, `useTraceroute`, `useTelemetry`, `usePosition`, `useFileTransfer`, `useFavoriteNode`, `useIgnoreNode`, `useMeshRegistry`, `useClientById`, `useActiveClient`. No registry-aware re-render coverage. |
|
||||
| `packages/sdk-storage-sqlocal` | 2 | 8 ✅ | sql.js only — **no real OPFS test**, no Worker boot test, no cross-tab BroadcastChannel test (mocked away), no migration v1→v2 test |
|
||||
| `packages/web` | 34 | 294 ✅ | no `useConnections` test; new `meshRegistry` + `sdkStorage` modules untested; chat persistence end-to-end not exercised |
|
||||
| `packages/transport-*` | 1 each (5 of 7) | varies | `transport-deno` + `transport-mock` have no tests; transports likely lack disconnect/error coverage |
|
||||
| `packages/core` | 0 | n/a | legacy, slated for deletion in Phase C; tolerable |
|
||||
| `packages/ui` | 0 | n/a | pure presentational; visual regression only |
|
||||
| `packages/protobufs` | 0 | n/a | generated code; upstream's responsibility |
|
||||
|
||||
## Concrete additions queued (priority order)
|
||||
|
||||
1. **`packages/sdk` slice tests** — one Use-case + one Mapper test per slice (`nodes`, `channels`, `config`, `telemetry`, `position`, `traceroute`). Pattern: build a stub `MeshClient`, dispatch a synthetic event, assert signal value or outbound bytes.
|
||||
2. **`packages/sdk-react` hook tests** — for every hook listed above, mount under `<MeshProvider>`, drive a signal change, assert `result.current`. One file, ~15 cases.
|
||||
3. **`packages/sdk` `ChatClient` persistence test** — wire `InMemoryMessageRepository`, append messages, re-construct `ChatClient`, assert hydration. Validates the lazy-load contract.
|
||||
4. **`packages/sdk-storage-sqlocal` migration test** — bootstrap empty DB, run `MIGRATIONS[]`, assert `_schema.version`. Then add a fake `version: 2` migration and prove it's applied idempotently.
|
||||
5. **`packages/sdk-storage-sqlocal` browser mode** — add a second `vitest.browser.config.ts` using `@vitest/browser` (Playwright provider) so we exercise real OPFS + Worker. CI runs both modes.
|
||||
6. **`packages/sdk-storage-sqlocal` BroadcastChannel test** — instantiate two `MultiTabCoordinator` instances in same process; one broadcasts, the other observes. (Node has no `BroadcastChannel` global; use `worker_threads`'s `BroadcastChannel` polyfill or jsdom env.)
|
||||
7. **`packages/web` `meshRegistry` + `sdkStorage` lazy-init test** — assert `getStorageDb()` returns the same promise on repeated calls and only opens the DB once.
|
||||
8. **`packages/sdk-react` registry test** — mount `<MeshRegistryProvider>` with two clients, switch active, confirm a hook re-renders against the new client.
|
||||
|
||||
## E2E / simulator (Tier 6) — scope only
|
||||
|
||||
Out of immediate scope; documenting for a follow-up PR.
|
||||
|
||||
- Run `meshtasticd` (firmware simulator) in CI Docker via `services:` block.
|
||||
- Spin up `MeshClient` with `TransportHTTP` pointed at the simulator's HTTP endpoint.
|
||||
- Drive scripted scenarios: configure → send text → expect ack; channel update; node info exchange; traceroute.
|
||||
- Use `@vitest/browser` so we also exercise the real OPFS persistence path during E2E.
|
||||
- Run on `main` only (cost). Smoke subset on PR.
|
||||
|
||||
Until that lands, `createFakeTransport()` covers the protocol layer at unit/integration speed.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Test file colocated with source: `Foo.ts` → `Foo.test.ts`.
|
||||
- Integration tests under `tests/integration/`.
|
||||
- Browser-mode tests use the suffix `.browser.test.ts` so they can be filtered.
|
||||
- Protobuf fixtures live in `__fixtures__/*.fixtures.ts` next to mappers; binary data committed as base64, not raw bytes.
|
||||
- Each test imports concrete classes from the source path (`./Foo.ts`), not the package barrel — fast type-check, zero re-export drift.
|
||||
- No mocked SDK from inside SDK tests. Use `createFakeTransport()` and real `MeshClient` instances.
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
pnpm -r test # all packages, Node env
|
||||
pnpm --filter @meshtastic/sdk test
|
||||
pnpm --filter @meshtastic/sdk-storage-sqlocal test
|
||||
pnpm --filter meshtastic-web test
|
||||
# future:
|
||||
pnpm --filter @meshtastic/sdk-storage-sqlocal test:browser
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
@jsr:registry=https://npm.jsr.io
|
||||
@@ -8,6 +8,7 @@
|
||||
"dist",
|
||||
"protobufs",
|
||||
"**/locales/*-*/*.json",
|
||||
"**/packages/ui/"
|
||||
"**/packages/ui/",
|
||||
"*.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
"rules": {
|
||||
"no-debugger": "error",
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"import/no-unused-modules": "error"
|
||||
"@typescript-eslint/no-unused-vars": "error"
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"node_modules",
|
||||
|
||||
+2
-6
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="system">
|
||||
<head>
|
||||
<!-- CookieYes banner -->
|
||||
@@ -11,11 +11,7 @@
|
||||
|
||||
<link rel="manifest" href="site.webmanifest" />
|
||||
<link rel="mask-icon" href="logo_black.svg" color="#67ea94" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="src/index.css"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link rel="stylesheet" href="src/index.css" crossorigin="anonymous" />
|
||||
<meta name="theme-color" content="#67ea94" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="Meshtastic Web Client" />
|
||||
|
||||
+71
-69
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "meshtastic-web",
|
||||
"version": "2.6.6-0",
|
||||
"type": "module",
|
||||
"description": "Meshtastic web client",
|
||||
"homepage": "https://meshtastic.org",
|
||||
"bugs": {
|
||||
"url": "https://github.com/meshtastic/web/issues"
|
||||
},
|
||||
"license": "GPL-3.0-only",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/meshtastic/web.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/meshtastic/web/issues"
|
||||
},
|
||||
"homepage": "https://meshtastic.org",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"setup:certs": "mkcert localhost 127.0.0.1 ::1",
|
||||
@@ -26,98 +26,100 @@
|
||||
"dev": "vite",
|
||||
"dev:https": "USE_HTTPS=true vite",
|
||||
"test": "vitest",
|
||||
"typecheck": "pnpm run tsc --noEmit",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"preview": "vite preview",
|
||||
"docker:build": "docker build -t meshtastic-web:latest -f ./infra/Containerfile .",
|
||||
"package": "gzipper c -i html,js,css,png,ico,svg,json,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@meshtastic/core": "workspace:*",
|
||||
"@hookform/resolvers": "^5.5.7",
|
||||
"@meshtastic/sdk": "workspace:*",
|
||||
"@meshtastic/sdk-react": "workspace:*",
|
||||
"@meshtastic/sdk-storage-sqlocal": "workspace:*",
|
||||
"@meshtastic/transport-http": "workspace:*",
|
||||
"@meshtastic/transport-web-bluetooth": "workspace:*",
|
||||
"@meshtastic/transport-web-serial": "workspace:*",
|
||||
"@noble/curves": "^1.9.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"better-result": "^1.0.0",
|
||||
"@radix-ui/react-accordion": "^1.2.13",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-menubar": "^1.1.17",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.3",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@tanstack/react-router": "^1.170.15",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@preact/signals-core": "^1.14.4",
|
||||
"@radix-ui/react-accordion": "^1.2.20",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||
"@radix-ui/react-checkbox": "^1.3.11",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||
"@radix-ui/react-label": "^2.1.15",
|
||||
"@radix-ui/react-menubar": "^1.1.24",
|
||||
"@radix-ui/react-popover": "^1.1.23",
|
||||
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||
"@radix-ui/react-select": "^2.3.7",
|
||||
"@radix-ui/react-separator": "^1.1.15",
|
||||
"@radix-ui/react-slider": "^1.4.7",
|
||||
"@radix-ui/react-switch": "^1.3.7",
|
||||
"@radix-ui/react-tabs": "^1.1.21",
|
||||
"@radix-ui/react-toast": "^1.2.23",
|
||||
"@radix-ui/react-toggle-group": "^1.1.19",
|
||||
"@radix-ui/react-tooltip": "^1.2.16",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.11",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/react-router": "^1.170.18",
|
||||
"@tanstack/react-router-devtools": "^1.167.0",
|
||||
"@tanstack/router-cli": "^1.167.17",
|
||||
"@tanstack/router-cli": "^1.167.21",
|
||||
"@tanstack/router-devtools": "^1.167.0",
|
||||
"@turf/turf": "^7.2.0",
|
||||
"@types/node": "^24.7.0",
|
||||
"@turf/turf": "^7.3.5",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"base64-js": "^1.5.1",
|
||||
"better-result": "^2.10.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"crypto-random-string": "^5.0.0",
|
||||
"i18next": "^26.3.1",
|
||||
"crypto-random-string": "^6.0.0",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^4.0.0",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"immer": "^11.1.8",
|
||||
"js-cookie": "^3.0.7",
|
||||
"lucide-react": "^1.17.0",
|
||||
"maplibre-gl": "5.8.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-error-boundary": "^6.0.0",
|
||||
"react-hook-form": "^7.78.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-map-gl": "8.1.0",
|
||||
"react-qrcode-logo": "^4.0.0",
|
||||
"i18next-http-backend": "^4.0.1",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"immer": "^11.1.15",
|
||||
"js-cookie": "^3.0.8",
|
||||
"lucide-react": "^1.28.0",
|
||||
"maplibre-gl": "6.0.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-error-boundary": "^6.1.2",
|
||||
"react-hook-form": "^7.82.0",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-map-gl": "8.1.2",
|
||||
"react-qrcode-logo": "^4.1.0",
|
||||
"rfc4648": "^1.5.4",
|
||||
"vite": "^7.3.5",
|
||||
"vite": "^8.1.5",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"zod": "^4.1.12",
|
||||
"zustand": "5.0.8"
|
||||
"zustand": "5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/router-plugin": "^1.168.18",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@tanstack/router-plugin": "^1.168.22",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/chrome": "^0.1.22",
|
||||
"@types/chrome": "^0.2.2",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/react": "^19.2.1",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/serviceworker": "^0.0.158",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/serviceworker": "^0.0.200",
|
||||
"@types/w3c-web-serial": "^1.0.8",
|
||||
"@vitejs/plugin-basic-ssl": "^2.1.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"gzipper": "^8.2.1",
|
||||
"happy-dom": "^20.8.9",
|
||||
"oxfmt": "^0.16.0",
|
||||
"oxlint": "^1.41.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"gzipper": "^8.3.0",
|
||||
"happy-dom": "^20.11.1",
|
||||
"oxfmt": "^0.61.0",
|
||||
"oxlint": "^1.75.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tar": "^7.5.16",
|
||||
"tar": "^7.5.22",
|
||||
"testing-library": "^0.0.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.8"
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"tabTelemetry": "Telemetry"
|
||||
},
|
||||
"ambientLighting": {
|
||||
"title": "Ambient Lighting Settings",
|
||||
"title": "Ambient Lighting",
|
||||
"description": "Settings for the Ambient Lighting module",
|
||||
"ledState": {
|
||||
"label": "LED State",
|
||||
@@ -35,217 +35,253 @@
|
||||
"blue": {
|
||||
"label": "Blue",
|
||||
"description": "Sets the blue LED level. Values are 0-255"
|
||||
},
|
||||
"ambientLightingConfig": {
|
||||
"label": "Ambient Lighting Config",
|
||||
"description": "Ambient Lighting configuration"
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"title": "Audio Settings",
|
||||
"title": "Audio",
|
||||
"description": "Settings for the Audio module",
|
||||
"codec2Enabled": {
|
||||
"label": "Codec 2 Enabled",
|
||||
"label": "CODEC 2 enabled",
|
||||
"description": "Enable Codec 2 audio encoding"
|
||||
},
|
||||
"pttPin": {
|
||||
"label": "PTT Pin",
|
||||
"label": "PTT pin",
|
||||
"description": "GPIO pin to use for PTT"
|
||||
},
|
||||
"bitrate": {
|
||||
"label": "Bitrate",
|
||||
"label": "CODEC2 sample rate",
|
||||
"description": "Bitrate to use for audio encoding"
|
||||
},
|
||||
"i2sWs": {
|
||||
"label": "i2S WS",
|
||||
"label": "I2S word select",
|
||||
"description": "GPIO pin to use for i2S WS"
|
||||
},
|
||||
"i2sSd": {
|
||||
"label": "i2S SD",
|
||||
"label": "I2S data in",
|
||||
"description": "GPIO pin to use for i2S SD"
|
||||
},
|
||||
"i2sDin": {
|
||||
"label": "i2S DIN",
|
||||
"label": "I2S data out",
|
||||
"description": "GPIO pin to use for i2S DIN"
|
||||
},
|
||||
"i2sSck": {
|
||||
"label": "i2S SCK",
|
||||
"label": "I2S clock",
|
||||
"description": "GPIO pin to use for i2S SCK"
|
||||
},
|
||||
"audioConfig": {
|
||||
"label": "Audio Config",
|
||||
"description": "Audio configuration"
|
||||
}
|
||||
},
|
||||
"cannedMessage": {
|
||||
"title": "Canned Message Settings",
|
||||
"title": "Canned Message",
|
||||
"description": "Settings for the Canned Message module",
|
||||
"moduleEnabled": {
|
||||
"label": "Module Enabled",
|
||||
"description": "Enable Canned Message"
|
||||
},
|
||||
"rotary1Enabled": {
|
||||
"label": "Rotary Encoder #1 Enabled",
|
||||
"label": "Rotary encoder #1 enabled",
|
||||
"description": "Enable the rotary encoder"
|
||||
},
|
||||
"inputbrokerPinA": {
|
||||
"label": "Encoder Pin A",
|
||||
"label": "GPIO pin for rotary encoder A port",
|
||||
"description": "GPIO Pin Value (1-39) For encoder port A"
|
||||
},
|
||||
"inputbrokerPinB": {
|
||||
"label": "Encoder Pin B",
|
||||
"label": "GPIO pin for rotary encoder B port",
|
||||
"description": "GPIO Pin Value (1-39) For encoder port B"
|
||||
},
|
||||
"inputbrokerPinPress": {
|
||||
"label": "Encoder Pin Press",
|
||||
"label": "GPIO pin for rotary encoder Press port",
|
||||
"description": "GPIO Pin Value (1-39) For encoder Press"
|
||||
},
|
||||
"inputbrokerEventCw": {
|
||||
"label": "Clockwise event",
|
||||
"label": "Generate input event on CW",
|
||||
"description": "Select input event."
|
||||
},
|
||||
"inputbrokerEventCcw": {
|
||||
"label": "Counter Clockwise event",
|
||||
"label": "Generate input event on CCW",
|
||||
"description": "Select input event."
|
||||
},
|
||||
"inputbrokerEventPress": {
|
||||
"label": "Press event",
|
||||
"label": "Generate input event on Press",
|
||||
"description": "Select input event"
|
||||
},
|
||||
"updown1Enabled": {
|
||||
"label": "Up Down enabled",
|
||||
"label": "Up/Down/Select input enabled",
|
||||
"description": "Enable the up / down encoder"
|
||||
},
|
||||
"allowInputSource": {
|
||||
"label": "Allow Input Source",
|
||||
"label": "Allow input source",
|
||||
"description": "Select from: '_any', 'rotEnc1', 'upDownEnc1', 'cardkb'"
|
||||
},
|
||||
"sendBell": {
|
||||
"label": "Send Bell",
|
||||
"label": "Send bell",
|
||||
"description": "Sends a bell character with each message"
|
||||
},
|
||||
"cannedMessageConfig": {
|
||||
"label": "Canned Message Config",
|
||||
"description": "Canned Message configuration"
|
||||
},
|
||||
"enabled": {
|
||||
"label": "Canned message enabled",
|
||||
"description": "Enable Canned Message"
|
||||
}
|
||||
},
|
||||
"detectionSensor": {
|
||||
"title": "Detection Sensor Settings",
|
||||
"title": "Detection Sensor",
|
||||
"description": "Settings for the Detection Sensor module",
|
||||
"enabled": {
|
||||
"label": "Enabled",
|
||||
"label": "Detection Sensor enabled",
|
||||
"description": "Enable or disable Detection Sensor Module"
|
||||
},
|
||||
"minimumBroadcastSecs": {
|
||||
"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"
|
||||
"label": "Minimum broadcast (seconds)",
|
||||
"description": "Minimum interval between detection broadcasts"
|
||||
},
|
||||
"stateBroadcastSecs": {
|
||||
"label": "State Broadcast Seconds",
|
||||
"description": "The interval in seconds of how often we should send a message to the mesh with the current state regardless of changes"
|
||||
"label": "State broadcast (seconds)",
|
||||
"description": "How often to broadcast detection state"
|
||||
},
|
||||
"sendBell": {
|
||||
"label": "Send Bell",
|
||||
"description": "Send ASCII bell with alert message"
|
||||
"label": "Send bell with alert message",
|
||||
"description": "Send a bell character with the alert"
|
||||
},
|
||||
"name": {
|
||||
"label": "Friendly Name",
|
||||
"description": "Used to format the message sent to mesh, max 20 Characters"
|
||||
"label": "Friendly name",
|
||||
"description": "Friendly name for the detected event"
|
||||
},
|
||||
"monitorPin": {
|
||||
"label": "Monitor Pin",
|
||||
"description": "The GPIO pin to monitor for state changes"
|
||||
"label": "GPIO pin to monitor",
|
||||
"description": "GPIO pin to monitor for detection"
|
||||
},
|
||||
"detectionTriggerType": {
|
||||
"label": "Detection Triggered Type",
|
||||
"description": "The type of trigger event to be used"
|
||||
"label": "Detection trigger type",
|
||||
"description": "Trigger type for detection"
|
||||
},
|
||||
"usePullup": {
|
||||
"label": "Use Pullup",
|
||||
"description": "Whether or not use INPUT_PULLUP mode for GPIO pin"
|
||||
"label": "Use INPUT_PULLUP mode",
|
||||
"description": "Enable internal pull-up on the monitor pin"
|
||||
},
|
||||
"detectionSensorConfig": {
|
||||
"label": "Detection Sensor Config",
|
||||
"description": "Detection Sensor configuration"
|
||||
}
|
||||
},
|
||||
"externalNotification": {
|
||||
"title": "External Notification Settings",
|
||||
"title": "External Notification",
|
||||
"description": "Configure the external notification module",
|
||||
"enabled": {
|
||||
"label": "Module Enabled",
|
||||
"label": "External notification enabled",
|
||||
"description": "Enable External Notification"
|
||||
},
|
||||
"outputMs": {
|
||||
"label": "Output MS",
|
||||
"label": "Output duration (milliseconds)",
|
||||
"description": "Output MS"
|
||||
},
|
||||
"output": {
|
||||
"label": "Output",
|
||||
"label": "Output LED (GPIO)",
|
||||
"description": "Output"
|
||||
},
|
||||
"outputVibra": {
|
||||
"label": "Output Vibrate",
|
||||
"label": "Output vibra (GPIO)",
|
||||
"description": "Output Vibrate"
|
||||
},
|
||||
"outputBuzzer": {
|
||||
"label": "Output Buzzer",
|
||||
"label": "Output buzzer (GPIO)",
|
||||
"description": "Output Buzzer"
|
||||
},
|
||||
"active": {
|
||||
"label": "Active",
|
||||
"label": "Output LED active high",
|
||||
"description": "Active"
|
||||
},
|
||||
"alertMessage": {
|
||||
"label": "Alert Message",
|
||||
"label": "Alert message LED",
|
||||
"description": "Alert Message"
|
||||
},
|
||||
"alertMessageVibra": {
|
||||
"label": "Alert Message Vibrate",
|
||||
"label": "Alert message vibra",
|
||||
"description": "Alert Message Vibrate"
|
||||
},
|
||||
"alertMessageBuzzer": {
|
||||
"label": "Alert Message Buzzer",
|
||||
"label": "Alert message buzzer",
|
||||
"description": "Alert Message Buzzer"
|
||||
},
|
||||
"alertBell": {
|
||||
"label": "Alert Bell",
|
||||
"label": "Alert bell LED",
|
||||
"description": "Should an alert be triggered when receiving an incoming bell?"
|
||||
},
|
||||
"alertBellVibra": {
|
||||
"label": "Alert Bell Vibrate",
|
||||
"label": "Alert bell vibra",
|
||||
"description": "Alert Bell Vibrate"
|
||||
},
|
||||
"alertBellBuzzer": {
|
||||
"label": "Alert Bell Buzzer",
|
||||
"label": "Alert bell buzzer",
|
||||
"description": "Alert Bell Buzzer"
|
||||
},
|
||||
"usePwm": {
|
||||
"label": "Use PWM",
|
||||
"label": "Use PWM buzzer",
|
||||
"description": "Use PWM"
|
||||
},
|
||||
"nagTimeout": {
|
||||
"label": "Nag Timeout",
|
||||
"label": "Nag timeout (seconds)",
|
||||
"description": "Nag Timeout"
|
||||
},
|
||||
"useI2sAsBuzzer": {
|
||||
"label": "Use I²S Pin as Buzzer",
|
||||
"description": "Designate I²S Pin as Buzzer Output"
|
||||
"label": "Use I2S as buzzer",
|
||||
"description": "Use I2S output as buzzer driver"
|
||||
},
|
||||
"externalNotificationConfig": {
|
||||
"label": "External Notification Config",
|
||||
"description": "External notification configuration"
|
||||
},
|
||||
"notificationsOnMessage": {
|
||||
"label": "Notifications on message receipt",
|
||||
"description": "Alerts on incoming text messages"
|
||||
},
|
||||
"notificationsOnAlert": {
|
||||
"label": "Notifications on alert/bell receipt",
|
||||
"description": "Alerts on incoming bell/alert packets"
|
||||
},
|
||||
"advanced": {
|
||||
"label": "Advanced",
|
||||
"description": "Pin assignments and timing"
|
||||
}
|
||||
},
|
||||
"mqtt": {
|
||||
"title": "MQTT Settings",
|
||||
"description": "Settings for the MQTT module",
|
||||
"title": "MQTT",
|
||||
"description": "MQTT module configuration",
|
||||
"enabled": {
|
||||
"label": "Enabled",
|
||||
"label": "MQTT enabled",
|
||||
"description": "Enable or disable MQTT"
|
||||
},
|
||||
"address": {
|
||||
"label": "MQTT Server Address",
|
||||
"label": "Address",
|
||||
"description": "MQTT server address to use for default/custom servers"
|
||||
},
|
||||
"username": {
|
||||
"label": "MQTT Username",
|
||||
"label": "Username",
|
||||
"description": "MQTT username to use for default/custom servers"
|
||||
},
|
||||
"password": {
|
||||
"label": "MQTT Password",
|
||||
"label": "Password",
|
||||
"description": "MQTT password to use for default/custom servers"
|
||||
},
|
||||
"encryptionEnabled": {
|
||||
"label": "Encryption Enabled",
|
||||
"label": "Encryption enabled",
|
||||
"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."
|
||||
},
|
||||
"jsonEnabled": {
|
||||
"label": "JSON Enabled",
|
||||
"label": "JSON output enabled",
|
||||
"description": "Whether to send/consume JSON packets on MQTT"
|
||||
},
|
||||
"tlsEnabled": {
|
||||
"label": "TLS Enabled",
|
||||
"label": "TLS enabled",
|
||||
"description": "Enable or disable TLS"
|
||||
},
|
||||
"root": {
|
||||
@@ -253,20 +289,20 @@
|
||||
"description": "MQTT root topic to use for default/custom servers"
|
||||
},
|
||||
"proxyToClientEnabled": {
|
||||
"label": "MQTT Client Proxy Enabled",
|
||||
"label": "Proxy to client enabled",
|
||||
"description": "Utilizes the network connection to proxy MQTT messages to the client."
|
||||
},
|
||||
"mapReportingEnabled": {
|
||||
"label": "Map Reporting Enabled",
|
||||
"description": "Your node will periodically send an unencrypted map report packet to the configured MQTT server, this includes id, short and long name, approximate location, hardware model, role, firmware version, LoRa region, modem preset and primary channel name."
|
||||
"label": "Map reporting",
|
||||
"description": "Your node will periodically send an unencrypted map report packet to the configured MQTT server, this includes id, long and short name, approximate location, hardware model, role, firmware version, LoRa region, modem preset and primary channel name."
|
||||
},
|
||||
"mapReportSettings": {
|
||||
"publishIntervalSecs": {
|
||||
"label": "Map Report Publish Interval (s)",
|
||||
"label": "Map reporting interval (seconds)",
|
||||
"description": "Interval in seconds to publish map reports"
|
||||
},
|
||||
"positionPrecision": {
|
||||
"label": "Approximate Location",
|
||||
"label": "Approximate location",
|
||||
"description": "Position shared will be accurate within this distance",
|
||||
"options": {
|
||||
"metric_km23": "Within 23 km",
|
||||
@@ -290,78 +326,108 @@
|
||||
"imperial_ft300": "Within 300 feet",
|
||||
"imperial_ft150": "Within 150 feet"
|
||||
}
|
||||
},
|
||||
"shouldReportLocation": {
|
||||
"label": "I agree.",
|
||||
"description": "I have read and understand the above. I voluntarily consent to the unencrypted transmission of my node data via MQTT.",
|
||||
"consentHeader": "Consent to Share Unencrypted Node Data via MQTT",
|
||||
"consentText": "By enabling this feature, you acknowledge and expressly consent to the transmission of your device's real-time geographic location over the MQTT protocol without encryption. This location data may be used for purposes such as live map reporting, device tracking, and related telemetry functions."
|
||||
}
|
||||
},
|
||||
"mqttConfigCard": {
|
||||
"label": "MQTT Config",
|
||||
"description": "MQTT broker connection"
|
||||
},
|
||||
"mapReportingCard": {
|
||||
"label": "Map reporting",
|
||||
"description": "Periodic unencrypted map report"
|
||||
}
|
||||
},
|
||||
"neighborInfo": {
|
||||
"title": "Neighbor Info Settings",
|
||||
"title": "Neighbor Info",
|
||||
"description": "Settings for the Neighbor Info module",
|
||||
"enabled": {
|
||||
"label": "Enabled",
|
||||
"label": "Neighbor Info enabled",
|
||||
"description": "Enable or disable Neighbor Info Module"
|
||||
},
|
||||
"updateInterval": {
|
||||
"label": "Update Interval",
|
||||
"label": "Update interval (seconds)",
|
||||
"description": "Interval in seconds of how often we should try to send our Neighbor Info to the mesh"
|
||||
},
|
||||
"neighborInfoConfig": {
|
||||
"label": "Neighbor Info Config",
|
||||
"description": "Neighbor Info configuration"
|
||||
},
|
||||
"transmitOverLora": {
|
||||
"label": "Transmit over LoRa",
|
||||
"description": "Whether NeighborInfo should be transmitted over LoRa. Not available on a channel with default key and name."
|
||||
}
|
||||
},
|
||||
"paxcounter": {
|
||||
"title": "Paxcounter Settings",
|
||||
"title": "Paxcounter",
|
||||
"description": "Settings for the Paxcounter module",
|
||||
"enabled": {
|
||||
"label": "Module Enabled",
|
||||
"label": "Paxcounter enabled",
|
||||
"description": "Enable Paxcounter"
|
||||
},
|
||||
"paxcounterUpdateInterval": {
|
||||
"label": "Update Interval (seconds)",
|
||||
"label": "Update interval (seconds)",
|
||||
"description": "How long to wait between sending paxcounter packets"
|
||||
},
|
||||
"wifiThreshold": {
|
||||
"label": "WiFi RSSI Threshold",
|
||||
"label": "WiFi RSSI threshold (defaults to -80)",
|
||||
"description": "At what WiFi RSSI level should the counter increase. Defaults to -80."
|
||||
},
|
||||
"bleThreshold": {
|
||||
"label": "BLE RSSI Threshold",
|
||||
"label": "BLE RSSI threshold (defaults to -80)",
|
||||
"description": "At what BLE RSSI level should the counter increase. Defaults to -80."
|
||||
},
|
||||
"paxcounterConfig": {
|
||||
"label": "Paxcounter Config",
|
||||
"description": "Paxcounter configuration"
|
||||
}
|
||||
},
|
||||
"rangeTest": {
|
||||
"title": "Range Test Settings",
|
||||
"title": "Range Test",
|
||||
"description": "Settings for the Range Test module",
|
||||
"enabled": {
|
||||
"label": "Module Enabled",
|
||||
"label": "Range test enabled",
|
||||
"description": "Enable Range Test"
|
||||
},
|
||||
"sender": {
|
||||
"label": "Message Interval",
|
||||
"label": "Sender message interval (seconds)",
|
||||
"description": "How long to wait between sending test packets"
|
||||
},
|
||||
"save": {
|
||||
"label": "Save CSV to storage",
|
||||
"label": "Save .CSV in storage (ESP32 only)",
|
||||
"description": "ESP32 Only"
|
||||
},
|
||||
"rangeTestConfig": {
|
||||
"label": "Range Test Config",
|
||||
"description": "Range Test configuration"
|
||||
}
|
||||
},
|
||||
"serial": {
|
||||
"title": "Serial Settings",
|
||||
"title": "Serial",
|
||||
"description": "Settings for the Serial module",
|
||||
"enabled": {
|
||||
"label": "Module Enabled",
|
||||
"label": "Serial enabled",
|
||||
"description": "Enable Serial output"
|
||||
},
|
||||
"echo": {
|
||||
"label": "Echo",
|
||||
"label": "Echo enabled",
|
||||
"description": "Any packets you send will be echoed back to your device"
|
||||
},
|
||||
"rxd": {
|
||||
"label": "Receive Pin",
|
||||
"label": "RX",
|
||||
"description": "Set the GPIO pin to the RXD pin you have set up."
|
||||
},
|
||||
"txd": {
|
||||
"label": "Transmit Pin",
|
||||
"label": "TX",
|
||||
"description": "Set the GPIO pin to the TXD pin you have set up."
|
||||
},
|
||||
"baud": {
|
||||
"label": "Baud Rate",
|
||||
"label": "Serial baud rate",
|
||||
"description": "The serial baud rate"
|
||||
},
|
||||
"timeout": {
|
||||
@@ -369,23 +435,27 @@
|
||||
"description": "Seconds to wait before we consider your packet as 'done'"
|
||||
},
|
||||
"mode": {
|
||||
"label": "Mode",
|
||||
"label": "Serial mode",
|
||||
"description": "Select Mode"
|
||||
},
|
||||
"overrideConsoleSerialPort": {
|
||||
"label": "Override Console Serial Port",
|
||||
"label": "Override console serial port",
|
||||
"description": "If you have a serial port connected to the console, this will override it."
|
||||
},
|
||||
"serialConfig": {
|
||||
"label": "Serial Config",
|
||||
"description": "Serial module configuration"
|
||||
}
|
||||
},
|
||||
"storeForward": {
|
||||
"title": "Store & Forward Settings",
|
||||
"title": "Store & Forward",
|
||||
"description": "Settings for the Store & Forward module",
|
||||
"enabled": {
|
||||
"label": "Module Enabled",
|
||||
"label": "Store & Forward enabled",
|
||||
"description": "Enable Store & Forward"
|
||||
},
|
||||
"heartbeat": {
|
||||
"label": "Heartbeat Enabled",
|
||||
"label": "Heartbeat",
|
||||
"description": "Enable Store & Forward heartbeat"
|
||||
},
|
||||
"records": {
|
||||
@@ -399,50 +469,66 @@
|
||||
"historyReturnWindow": {
|
||||
"label": "History return window",
|
||||
"description": "Return records from this time window (minutes)"
|
||||
},
|
||||
"storeForwardConfig": {
|
||||
"label": "Store & Forward Config",
|
||||
"description": "Store & Forward configuration"
|
||||
},
|
||||
"isServer": {
|
||||
"label": "Server",
|
||||
"description": "Run as a Store & Forward server"
|
||||
}
|
||||
},
|
||||
"telemetry": {
|
||||
"title": "Telemetry Settings",
|
||||
"title": "Telemetry",
|
||||
"description": "Settings for the Telemetry module",
|
||||
"deviceUpdateInterval": {
|
||||
"label": "Device Metrics",
|
||||
"label": "Device metrics update interval",
|
||||
"description": "Device metrics update interval (seconds)"
|
||||
},
|
||||
"environmentUpdateInterval": {
|
||||
"label": "Environment metrics update interval (seconds)",
|
||||
"label": "Environment metrics update interval",
|
||||
"description": ""
|
||||
},
|
||||
"environmentMeasurementEnabled": {
|
||||
"label": "Module Enabled",
|
||||
"label": "Environment metrics module enabled",
|
||||
"description": "Enable the Environment Telemetry"
|
||||
},
|
||||
"environmentScreenEnabled": {
|
||||
"label": "Displayed on Screen",
|
||||
"label": "Environment metrics on-screen enabled",
|
||||
"description": "Show the Telemetry Module on the OLED"
|
||||
},
|
||||
"environmentDisplayFahrenheit": {
|
||||
"label": "Display Fahrenheit",
|
||||
"label": "Environment metrics use Fahrenheit",
|
||||
"description": "Display temp in Fahrenheit"
|
||||
},
|
||||
"airQualityEnabled": {
|
||||
"label": "Air Quality Enabled",
|
||||
"label": "Air quality metrics module enabled",
|
||||
"description": "Enable the Air Quality Telemetry"
|
||||
},
|
||||
"airQualityInterval": {
|
||||
"label": "Air Quality Update Interval",
|
||||
"label": "Air quality metrics update interval",
|
||||
"description": "How often to send Air Quality data over the mesh"
|
||||
},
|
||||
"powerMeasurementEnabled": {
|
||||
"label": "Power Measurement Enabled",
|
||||
"label": "Power metrics module enabled",
|
||||
"description": "Enable the Power Measurement Telemetry"
|
||||
},
|
||||
"powerUpdateInterval": {
|
||||
"label": "Power Update Interval",
|
||||
"label": "Power metrics update interval",
|
||||
"description": "How often to send Power data over the mesh"
|
||||
},
|
||||
"powerScreenEnabled": {
|
||||
"label": "Power Screen Enabled",
|
||||
"description": "Enable the Power Telemetry Screen"
|
||||
"label": "Power metrics on-screen enabled",
|
||||
"description": "Show power metrics on the device screen"
|
||||
},
|
||||
"telemetryConfig": {
|
||||
"label": "Telemetry Config",
|
||||
"description": "Telemetry configuration"
|
||||
},
|
||||
"deviceTelemetryEnabled": {
|
||||
"label": "Send Device Telemetry",
|
||||
"description": "Enable/Disable the device telemetry module to send metrics to the mesh."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"apply": "Apply",
|
||||
"addConnection": "Add Connection",
|
||||
"saveConnection": "Save connection",
|
||||
"saveChanges": "Save changes",
|
||||
"backupKey": "Backup Key",
|
||||
"cancel": "Cancel",
|
||||
"connect": "Connect",
|
||||
|
||||
@@ -16,15 +16,15 @@
|
||||
"label": "Configuration"
|
||||
},
|
||||
"device": {
|
||||
"title": "Device Settings",
|
||||
"title": "Device",
|
||||
"description": "Settings for the device",
|
||||
"buttonPin": {
|
||||
"description": "Button pin override",
|
||||
"label": "Button Pin"
|
||||
"label": "Button GPIO"
|
||||
},
|
||||
"buzzerPin": {
|
||||
"description": "Buzzer pin override",
|
||||
"label": "Buzzer Pin"
|
||||
"label": "Buzzer GPIO"
|
||||
},
|
||||
"disableTripleClick": {
|
||||
"description": "Disable triple click",
|
||||
@@ -43,8 +43,8 @@
|
||||
"label": "Node Info Broadcast Interval"
|
||||
},
|
||||
"posixTimezone": {
|
||||
"description": "The POSIX timezone string for the device",
|
||||
"label": "POSIX Timezone"
|
||||
"description": "Time zone for dates on the device screen and log.",
|
||||
"label": "Time zone"
|
||||
},
|
||||
"rebroadcastMode": {
|
||||
"description": "How to handle rebroadcasting",
|
||||
@@ -52,52 +52,75 @@
|
||||
},
|
||||
"role": {
|
||||
"description": "What role the device performs on the mesh",
|
||||
"label": "Role"
|
||||
"label": "Device Role"
|
||||
},
|
||||
"options": {
|
||||
"label": "Options",
|
||||
"description": "Device role, rebroadcast mode, and node-info broadcast cadence"
|
||||
},
|
||||
"hardware": {
|
||||
"label": "Hardware",
|
||||
"description": "Hardware-related toggles"
|
||||
},
|
||||
"timeZone": {
|
||||
"label": "Time Zone",
|
||||
"description": "POSIX time zone for dates on the device screen and log"
|
||||
},
|
||||
"gpio": {
|
||||
"label": "GPIO",
|
||||
"description": "GPIO pin overrides"
|
||||
},
|
||||
"useBrowserTimeZone": {
|
||||
"label": "Use browser time zone"
|
||||
}
|
||||
},
|
||||
"bluetooth": {
|
||||
"title": "Bluetooth Settings",
|
||||
"title": "Bluetooth",
|
||||
"description": "Settings for the Bluetooth module",
|
||||
"note": "Note: Some devices (ESP32) cannot use both Bluetooth and WiFi at the same time.",
|
||||
"enabled": {
|
||||
"description": "Enable or disable Bluetooth",
|
||||
"label": "Enabled"
|
||||
"label": "Bluetooth enabled"
|
||||
},
|
||||
"pairingMode": {
|
||||
"description": "Pin selection behaviour.",
|
||||
"label": "Pairing mode"
|
||||
},
|
||||
"pin": {
|
||||
"description": "Pin to use when pairing",
|
||||
"label": "Pin"
|
||||
"description": "PIN to use when pairing in fixed-PIN mode",
|
||||
"label": "Fixed PIN"
|
||||
},
|
||||
"bluetoothConfig": {
|
||||
"label": "Bluetooth Config",
|
||||
"description": "Bluetooth configuration"
|
||||
}
|
||||
},
|
||||
"display": {
|
||||
"description": "Settings for the device display",
|
||||
"title": "Display Settings",
|
||||
"title": "Display",
|
||||
"headingBold": {
|
||||
"description": "Bolden the heading text",
|
||||
"label": "Bold Heading"
|
||||
},
|
||||
"carouselDelay": {
|
||||
"description": "How fast to cycle through windows",
|
||||
"label": "Carousel Delay"
|
||||
"label": "Carousel interval"
|
||||
},
|
||||
"compassNorthTop": {
|
||||
"description": "Fix north to the top of compass",
|
||||
"label": "Compass North Top"
|
||||
"label": "Always point north"
|
||||
},
|
||||
"displayMode": {
|
||||
"description": "Screen layout variant",
|
||||
"label": "Display Mode"
|
||||
"label": "Display mode"
|
||||
},
|
||||
"displayUnits": {
|
||||
"description": "Display metric or imperial units",
|
||||
"label": "Display Units"
|
||||
"label": "Display units"
|
||||
},
|
||||
"flipScreen": {
|
||||
"description": "Flip display 180 degrees",
|
||||
"label": "Flip Screen"
|
||||
"label": "Flip screen"
|
||||
},
|
||||
"gpsDisplayUnits": {
|
||||
"description": "Coordinate display format",
|
||||
@@ -105,35 +128,47 @@
|
||||
},
|
||||
"oledType": {
|
||||
"description": "Type of OLED screen attached to the device",
|
||||
"label": "OLED Type"
|
||||
"label": "OLED type"
|
||||
},
|
||||
"screenTimeout": {
|
||||
"description": "Turn off the display after this long",
|
||||
"label": "Screen Timeout"
|
||||
"label": "Screen on for"
|
||||
},
|
||||
"twelveHourClock": {
|
||||
"description": "Use 12-hour clock format",
|
||||
"label": "12-Hour Clock"
|
||||
"description": "Display time in 12h format",
|
||||
"label": "Use 12h clock format"
|
||||
},
|
||||
"wakeOnTapOrMotion": {
|
||||
"description": "Wake the device on tap or motion",
|
||||
"label": "Wake on Tap or Motion"
|
||||
"label": "Wake on tap or motion"
|
||||
},
|
||||
"enableMessageBubbles": {
|
||||
"label": "Message Bubbles",
|
||||
"description": "Display incoming messages as bubbles on the device screen"
|
||||
},
|
||||
"deviceDisplay": {
|
||||
"label": "Device Display",
|
||||
"description": "Display orientation and clock"
|
||||
},
|
||||
"advanced": {
|
||||
"label": "Advanced",
|
||||
"description": "Advanced display settings"
|
||||
},
|
||||
"compassOrientation": {
|
||||
"label": "Compass orientation",
|
||||
"description": "Compass orientation relative to the screen"
|
||||
}
|
||||
},
|
||||
"lora": {
|
||||
"title": "Mesh Settings",
|
||||
"description": "Settings for the LoRa mesh",
|
||||
"title": "LoRa",
|
||||
"description": "LoRa radio configuration",
|
||||
"bandwidth": {
|
||||
"description": "Channel bandwidth in kHz",
|
||||
"label": "Bandwidth"
|
||||
},
|
||||
"boostedRxGain": {
|
||||
"description": "Boosted RX gain",
|
||||
"label": "Boosted RX Gain"
|
||||
"label": "RX Boosted Gain"
|
||||
},
|
||||
"codingRate": {
|
||||
"description": "The denominator of the coding rate",
|
||||
@@ -144,24 +179,24 @@
|
||||
"label": "Frequency Offset"
|
||||
},
|
||||
"frequencySlot": {
|
||||
"description": "LoRa frequency channel number",
|
||||
"description": "Your node's operating frequency is calculated based on the region, modem preset, and this field. When 0, the slot is automatically calculated based on the primary channel name and will change from the default public slot.",
|
||||
"label": "Frequency Slot"
|
||||
},
|
||||
"hopLimit": {
|
||||
"description": "Maximum number of hops",
|
||||
"label": "Hop Limit"
|
||||
"description": "Sets the maximum number of hops, default is 3. Increasing hops also increases congestion and should be used carefully. 0 hop broadcast messages will not get ACKs.",
|
||||
"label": "Number of Hops"
|
||||
},
|
||||
"ignoreMqtt": {
|
||||
"description": "Don't forward MQTT messages over the mesh",
|
||||
"label": "Ignore MQTT"
|
||||
},
|
||||
"modemPreset": {
|
||||
"description": "Modem preset to use",
|
||||
"label": "Modem Preset"
|
||||
"description": "Available modem presets, default is Long Fast.",
|
||||
"label": "Presets"
|
||||
},
|
||||
"okToMqtt": {
|
||||
"description": "When set to true, this configuration indicates that the user approves the packet to be uploaded to MQTT. If set to false, remote nodes are requested not to forward packets to MQTT",
|
||||
"label": "OK to MQTT"
|
||||
"label": "Ok to MQTT"
|
||||
},
|
||||
"overrideDutyCycle": {
|
||||
"description": "Override Duty Cycle",
|
||||
@@ -169,10 +204,10 @@
|
||||
},
|
||||
"overrideFrequency": {
|
||||
"description": "Override frequency",
|
||||
"label": "Override Frequency"
|
||||
"label": "Frequency Override"
|
||||
},
|
||||
"region": {
|
||||
"description": "Sets the region for your node",
|
||||
"description": "The region where you will be using your radios.",
|
||||
"label": "Region"
|
||||
},
|
||||
"spreadingFactor": {
|
||||
@@ -210,15 +245,23 @@
|
||||
"serialHalOnly": {
|
||||
"label": "Serial HAL Only",
|
||||
"description": "Communicate with the radio exclusively over the serial hardware abstraction layer"
|
||||
},
|
||||
"optionsCard": {
|
||||
"label": "Options",
|
||||
"description": "LoRa core options"
|
||||
},
|
||||
"advancedCard": {
|
||||
"label": "Advanced",
|
||||
"description": "Advanced LoRa parameters"
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
"title": "WiFi Config",
|
||||
"description": "WiFi radio configuration",
|
||||
"title": "Network",
|
||||
"description": "Network configuration",
|
||||
"note": "Note: Some devices (ESP32) cannot use both Bluetooth and WiFi at the same time.",
|
||||
"addressMode": {
|
||||
"description": "Address assignment selection",
|
||||
"label": "Address Mode"
|
||||
"label": "IPv4 mode"
|
||||
},
|
||||
"dns": {
|
||||
"description": "DNS Server",
|
||||
@@ -226,7 +269,7 @@
|
||||
},
|
||||
"ethernetEnabled": {
|
||||
"description": "Enable or disable the Ethernet port",
|
||||
"label": "Enabled"
|
||||
"label": "Ethernet enabled"
|
||||
},
|
||||
"gateway": {
|
||||
"description": "Default Gateway",
|
||||
@@ -238,7 +281,7 @@
|
||||
},
|
||||
"psk": {
|
||||
"description": "Network password",
|
||||
"label": "PSK"
|
||||
"label": "Password"
|
||||
},
|
||||
"ssid": {
|
||||
"description": "Network name",
|
||||
@@ -250,16 +293,17 @@
|
||||
},
|
||||
"wifiEnabled": {
|
||||
"description": "Enable or disable the WiFi radio",
|
||||
"label": "Enabled"
|
||||
"label": "WiFi enabled"
|
||||
},
|
||||
"meshViaUdp": {
|
||||
"label": "Mesh via UDP"
|
||||
"label": "Enabled",
|
||||
"description": "Forward mesh packets over UDP"
|
||||
},
|
||||
"ntpServer": {
|
||||
"label": "NTP Server"
|
||||
"label": "NTP server"
|
||||
},
|
||||
"rsyslogServer": {
|
||||
"label": "Rsyslog Server"
|
||||
"label": "rsyslog server"
|
||||
},
|
||||
"ethernetConfigSettings": {
|
||||
"description": "Ethernet port configuration",
|
||||
@@ -280,18 +324,30 @@
|
||||
"udpConfigSettings": {
|
||||
"description": "UDP over Mesh configuration",
|
||||
"label": "UDP Config"
|
||||
},
|
||||
"wifiOptions": {
|
||||
"label": "WiFi Options",
|
||||
"description": "WiFi configuration"
|
||||
},
|
||||
"ethernetOptionsCard": {
|
||||
"label": "Ethernet Options",
|
||||
"description": "Ethernet configuration"
|
||||
},
|
||||
"advancedCard": {
|
||||
"label": "Advanced",
|
||||
"description": "NTP, rsyslog, IPv4 mode and address overrides"
|
||||
}
|
||||
},
|
||||
"position": {
|
||||
"title": "Position Settings",
|
||||
"description": "Settings for the position module",
|
||||
"title": "Position",
|
||||
"description": "Position settings",
|
||||
"broadcastInterval": {
|
||||
"description": "How often your position is sent out over the mesh",
|
||||
"description": "The maximum interval that can elapse without a node broadcasting a position.",
|
||||
"label": "Broadcast Interval"
|
||||
},
|
||||
"enablePin": {
|
||||
"description": "GPS module enable pin override",
|
||||
"label": "Enable Pin"
|
||||
"description": "GPS enable GPIO pin override",
|
||||
"label": "GPS EN GPIO"
|
||||
},
|
||||
"fixedPosition": {
|
||||
"description": "Don't report GPS position, but a manually-specified one",
|
||||
@@ -311,35 +367,35 @@
|
||||
},
|
||||
"gpsMode": {
|
||||
"description": "Configure whether device GPS is Enabled, Disabled, or Not Present",
|
||||
"label": "GPS Mode"
|
||||
"label": "GPS Mode (Physical Hardware)"
|
||||
},
|
||||
"gpsUpdateInterval": {
|
||||
"description": "How often a GPS fix should be acquired",
|
||||
"label": "GPS Update Interval"
|
||||
"description": "How often should we try to get a GPS position (<10sec keeps GPS on).",
|
||||
"label": "GPS Polling Interval"
|
||||
},
|
||||
"positionFlags": {
|
||||
"description": "Optional fields to include when assembling position messages. The more fields are selected, the larger the message will be leading to longer airtime usage and a higher risk of packet loss.",
|
||||
"description": "Optional fields to include when assembling position messages. The more fields are included, the larger the message will be — leading to longer airtime and a higher risk of packet loss.",
|
||||
"label": "Position Flags"
|
||||
},
|
||||
"receivePin": {
|
||||
"description": "GPS module RX pin override",
|
||||
"label": "Receive Pin"
|
||||
"description": "GPS receive GPIO pin override",
|
||||
"label": "GPS Receive GPIO"
|
||||
},
|
||||
"smartPositionEnabled": {
|
||||
"description": "Only send position when there has been a meaningful change in location",
|
||||
"label": "Enable Smart Position"
|
||||
"label": "Smart Position"
|
||||
},
|
||||
"smartPositionMinDistance": {
|
||||
"description": "Minimum distance (in meters) that must be traveled before a position update is sent",
|
||||
"label": "Smart Position Minimum Distance"
|
||||
"description": "The minimum distance change in meters to be considered for a smart position broadcast.",
|
||||
"label": "Smart Distance"
|
||||
},
|
||||
"smartPositionMinInterval": {
|
||||
"description": "Minimum interval (in seconds) that must pass before a position update is sent",
|
||||
"label": "Smart Position Minimum Interval"
|
||||
"description": "The fastest that position updates will be sent if the minimum distance has been satisfied.",
|
||||
"label": "Smart Interval"
|
||||
},
|
||||
"transmitPin": {
|
||||
"description": "GPS module TX pin override",
|
||||
"label": "Transmit Pin"
|
||||
"description": "GPS transmit GPIO pin override",
|
||||
"label": "GPS Transmit GPIO"
|
||||
},
|
||||
"intervalsSettings": {
|
||||
"description": "How often to send position updates",
|
||||
@@ -358,16 +414,33 @@
|
||||
"unset": "Unset",
|
||||
"vehicleHeading": "Vehicle heading",
|
||||
"vehicleSpeed": "Vehicle speed"
|
||||
},
|
||||
"positionPacket": {
|
||||
"label": "Position Packet",
|
||||
"description": "Position broadcast configuration"
|
||||
},
|
||||
"deviceGps": {
|
||||
"label": "Device GPS",
|
||||
"description": "Device GPS configuration"
|
||||
},
|
||||
"advancedDeviceGps": {
|
||||
"label": "Advanced Device GPS",
|
||||
"description": "GPIO pin overrides for the GPS hardware"
|
||||
},
|
||||
"useBrowserLocation": {
|
||||
"label": "Use browser location",
|
||||
"busy": "Reading location…",
|
||||
"failed": "Could not read browser location"
|
||||
}
|
||||
},
|
||||
"power": {
|
||||
"adcMultiplierOverride": {
|
||||
"description": "Used for tweaking battery voltage reading",
|
||||
"label": "ADC Multiplier Override ratio"
|
||||
"label": "ADC multiplier override ratio"
|
||||
},
|
||||
"ina219Address": {
|
||||
"description": "Address of the INA219 battery monitor",
|
||||
"label": "INA219 Address"
|
||||
"label": "Battery INA_2XX I2C address"
|
||||
},
|
||||
"lightSleepDuration": {
|
||||
"description": "How long the device will be in light sleep for",
|
||||
@@ -375,11 +448,11 @@
|
||||
},
|
||||
"minimumWakeTime": {
|
||||
"description": "Minimum amount of time the device will stay awake for after receiving a packet",
|
||||
"label": "Minimum Wake Time"
|
||||
"label": "Minimum wake time"
|
||||
},
|
||||
"noConnectionBluetoothDisabled": {
|
||||
"description": "If the device does not receive a Bluetooth connection, the BLE radio will be disabled after this long",
|
||||
"label": "No Connection Bluetooth Disabled"
|
||||
"label": "Wait for Bluetooth duration"
|
||||
},
|
||||
"powerSavingEnabled": {
|
||||
"description": "Select if powered from a low-current source (i.e. solar), to minimize power consumption as much as possible.",
|
||||
@@ -387,11 +460,11 @@
|
||||
},
|
||||
"shutdownOnBatteryDelay": {
|
||||
"description": "Automatically shutdown node after this long when on battery, 0 for indefinite",
|
||||
"label": "Shutdown on battery delay"
|
||||
"label": "Shutdown on power loss"
|
||||
},
|
||||
"superDeepSleepDuration": {
|
||||
"description": "How long the device will be in super deep sleep for",
|
||||
"label": "Super Deep Sleep Duration"
|
||||
"label": "Super deep sleep duration"
|
||||
},
|
||||
"powerConfigSettings": {
|
||||
"description": "Settings for the power module",
|
||||
@@ -400,23 +473,27 @@
|
||||
"sleepSettings": {
|
||||
"description": "Sleep settings for the power module",
|
||||
"label": "Sleep Settings"
|
||||
},
|
||||
"powerConfig": {
|
||||
"label": "Power Config",
|
||||
"description": "Power and sleep configuration"
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"description": "Settings for the Security configuration",
|
||||
"title": "Security Settings",
|
||||
"title": "Security",
|
||||
"button_backupKey": "Backup Key",
|
||||
"adminChannelEnabled": {
|
||||
"description": "Allow incoming device control over the insecure legacy admin channel",
|
||||
"label": "Allow Legacy Admin"
|
||||
"label": "Legacy Admin channel"
|
||||
},
|
||||
"enableDebugLogApi": {
|
||||
"description": "Output live debug logging over serial, view and export position-redacted device logs over Bluetooth",
|
||||
"label": "Enable Debug Log API"
|
||||
"label": "Debug log API enabled"
|
||||
},
|
||||
"managed": {
|
||||
"description": "If enabled, device configuration options are only able to be changed remotely by a Remote Admin node via admin messages. Do not enable this option unless at least one suitable Remote Admin node has been setup, and the public key is stored in one of the fields above.",
|
||||
"label": "Managed"
|
||||
"label": "Managed Mode"
|
||||
},
|
||||
"privateKey": {
|
||||
"description": "Used to create a shared key with a remote device",
|
||||
@@ -436,7 +513,7 @@
|
||||
},
|
||||
"serialOutputEnabled": {
|
||||
"description": "Serial Console over the Stream API",
|
||||
"label": "Serial Output Enabled"
|
||||
"label": "Serial console"
|
||||
},
|
||||
"tertiaryAdminKey": {
|
||||
"description": "The tertiary public key authorized to send admin messages to this node",
|
||||
@@ -449,10 +526,26 @@
|
||||
"loggingSettings": {
|
||||
"description": "Settings for Logging",
|
||||
"label": "Logging Settings"
|
||||
},
|
||||
"directMessageKey": {
|
||||
"label": "Direct Message Key",
|
||||
"description": "Public + private direct-message keys"
|
||||
},
|
||||
"adminKeysCard": {
|
||||
"label": "Admin Keys",
|
||||
"description": "Admin authentication keys"
|
||||
},
|
||||
"logsCard": {
|
||||
"label": "Logs",
|
||||
"description": "Logging configuration"
|
||||
},
|
||||
"administration": {
|
||||
"label": "Administration",
|
||||
"description": "Managed-mode and legacy admin channel"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"title": "User Settings",
|
||||
"title": "User",
|
||||
"description": "Configure your device name and identity settings",
|
||||
"longName": {
|
||||
"label": "Long Name",
|
||||
@@ -472,11 +565,23 @@
|
||||
},
|
||||
"isUnmessageable": {
|
||||
"label": "Unmessageable",
|
||||
"description": "Used to identify unmonitored or infrastructure nodes so that messaging is not available to nodes that will never respond."
|
||||
"description": "Unmonitored or Infrastructure"
|
||||
},
|
||||
"isLicensed": {
|
||||
"label": "Licensed amateur radio (HAM)",
|
||||
"description": "Enable if you are a licensed amateur radio operator, enabling this option disables encryption and is not compatible with the default Meshtastic network."
|
||||
"label": "Licensed amateur radio (Ham)",
|
||||
"description": "Enabling this option disables encryption and is not compatible with the default Meshtastic network."
|
||||
},
|
||||
"userConfig": {
|
||||
"label": "User Config",
|
||||
"description": "Identity and licensing"
|
||||
},
|
||||
"nodeId": {
|
||||
"label": "Node ID",
|
||||
"description": "Read-only — assigned by the device"
|
||||
},
|
||||
"hardwareModel": {
|
||||
"label": "Hardware model",
|
||||
"description": "Read-only — reported by the device"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,26 @@
|
||||
},
|
||||
"lastConnectedAt": "Last connected: {{date}}",
|
||||
"neverConnected": "Never connected",
|
||||
"default": "Default",
|
||||
"button": {
|
||||
"addConnection": "Add Connection",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"retry": "Retry",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"setDefault": "Set as default",
|
||||
"unsetDefault": "Unset default",
|
||||
"rename": "Rename",
|
||||
"edit": "Edit"
|
||||
},
|
||||
"renameConnection": "Rename connection",
|
||||
"renameDescription": "Enter a new name for this connection.",
|
||||
"nameLabel": "Name",
|
||||
"editConnection": {
|
||||
"title": "Edit Connection",
|
||||
"description": "Modify the settings for this connection."
|
||||
},
|
||||
"toasts": {
|
||||
"connected": "Connected",
|
||||
"nowConnected": "{{name}} is now connected",
|
||||
@@ -29,6 +49,25 @@
|
||||
"pickConnectionAgain": "Could not connect. You may need to reselect the device/port.",
|
||||
"added": "Connection added",
|
||||
"savedByName": "{{name}} saved.",
|
||||
"savedCantConnect": "The connection was saved but could not connect."
|
||||
"savedCantConnect": "The connection was saved but could not connect.",
|
||||
"renamed": "Renamed",
|
||||
"renamedByName": "Connection renamed to {{name}}.",
|
||||
"updated": "Updated",
|
||||
"updatedByName": "{{name}} has been updated."
|
||||
},
|
||||
"overlay": {
|
||||
"srTitle": "Connecting to device",
|
||||
"phase": {
|
||||
"connecting": "Opening transport…",
|
||||
"configuring": "Streaming configuration…"
|
||||
},
|
||||
"chip": {
|
||||
"identity": "Identity",
|
||||
"metadata": "Metadata",
|
||||
"channels": "Channels",
|
||||
"nodes": "Nodes"
|
||||
},
|
||||
"stuckHint": "Taking longer than usual. The device may be in CLI / bootloader mode, or the firmware isn't responding to config requests.",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,11 @@
|
||||
"remindNever": "Never remind me",
|
||||
"backupNow": "Back up now"
|
||||
},
|
||||
"regionSetup": {
|
||||
"title": "Set your region",
|
||||
"description": "This device has no LoRa region configured and won't transmit until you pick one.",
|
||||
"cta": "Set region"
|
||||
},
|
||||
"pkiRegenerate": {
|
||||
"description": "Are you sure you want to regenerate key pair?",
|
||||
"title": "Regenerate Key Pair"
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
"blue": {
|
||||
"label": "Blue",
|
||||
"description": "Sets the blue LED level. Values are 0-255"
|
||||
},
|
||||
"ambientLightingConfig": {
|
||||
"label": "Ambient Lighting Config",
|
||||
"description": "Ambient Lighting configuration"
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
@@ -71,6 +75,10 @@
|
||||
"i2sSck": {
|
||||
"label": "i2S SCK",
|
||||
"description": "GPIO pin to use for i2S SCK"
|
||||
},
|
||||
"audioConfig": {
|
||||
"label": "Audio Config",
|
||||
"description": "Audio configuration"
|
||||
}
|
||||
},
|
||||
"cannedMessage": {
|
||||
@@ -119,6 +127,14 @@
|
||||
"sendBell": {
|
||||
"label": "Send Bell",
|
||||
"description": "Sends a bell character with each message"
|
||||
},
|
||||
"cannedMessageConfig": {
|
||||
"label": "Canned Message Config",
|
||||
"description": "Canned Message configuration"
|
||||
},
|
||||
"enabled": {
|
||||
"label": "Canned message enabled",
|
||||
"description": "Enable Canned Message"
|
||||
}
|
||||
},
|
||||
"detectionSensor": {
|
||||
@@ -155,6 +171,10 @@
|
||||
"usePullup": {
|
||||
"label": "Use Pullup",
|
||||
"description": "Whether or not use INPUT_PULLUP mode for GPIO pin"
|
||||
},
|
||||
"detectionSensorConfig": {
|
||||
"label": "Detection Sensor Config",
|
||||
"description": "Detection Sensor configuration"
|
||||
}
|
||||
},
|
||||
"externalNotification": {
|
||||
@@ -219,6 +239,22 @@
|
||||
"useI2sAsBuzzer": {
|
||||
"label": "Use I²S Pin as Buzzer",
|
||||
"description": "Designate I²S Pin as Buzzer Output"
|
||||
},
|
||||
"externalNotificationConfig": {
|
||||
"label": "External Notification Config",
|
||||
"description": "External notification configuration"
|
||||
},
|
||||
"notificationsOnMessage": {
|
||||
"label": "Notifications on message receipt",
|
||||
"description": "Alerts on incoming text messages"
|
||||
},
|
||||
"notificationsOnAlert": {
|
||||
"label": "Notifications on alert/bell receipt",
|
||||
"description": "Alerts on incoming bell/alert packets"
|
||||
},
|
||||
"advanced": {
|
||||
"label": "Advanced",
|
||||
"description": "Pin assignments and timing"
|
||||
}
|
||||
},
|
||||
"mqtt": {
|
||||
@@ -294,7 +330,21 @@
|
||||
"imperial_ft300": "Within 300 feet",
|
||||
"imperial_ft150": "Within 150 feet"
|
||||
}
|
||||
},
|
||||
"shouldReportLocation": {
|
||||
"label": "I agree.",
|
||||
"description": "I have read and understand the above. I voluntarily consent to the unencrypted transmission of my node data via MQTT.",
|
||||
"consentHeader": "Consent to Share Unencrypted Node Data via MQTT",
|
||||
"consentText": "By enabling this feature, you acknowledge and expressly consent to the transmission of your device's real-time geographic location over the MQTT protocol without encryption. This location data may be used for purposes such as live map reporting, device tracking, and related telemetry functions."
|
||||
}
|
||||
},
|
||||
"mqttConfigCard": {
|
||||
"label": "MQTT Config",
|
||||
"description": "MQTT broker connection"
|
||||
},
|
||||
"mapReportingCard": {
|
||||
"label": "Map reporting",
|
||||
"description": "Periodic unencrypted map report"
|
||||
}
|
||||
},
|
||||
"neighborInfo": {
|
||||
@@ -307,6 +357,14 @@
|
||||
"updateInterval": {
|
||||
"label": "Update Interval",
|
||||
"description": "Interval in seconds of how often we should try to send our Neighbor Info to the mesh"
|
||||
},
|
||||
"neighborInfoConfig": {
|
||||
"label": "Neighbor Info Config",
|
||||
"description": "Neighbor Info configuration"
|
||||
},
|
||||
"transmitOverLora": {
|
||||
"label": "Transmit over LoRa",
|
||||
"description": "Whether NeighborInfo should be transmitted over LoRa. Not available on a channel with default key and name."
|
||||
}
|
||||
},
|
||||
"paxcounter": {
|
||||
@@ -327,6 +385,10 @@
|
||||
"bleThreshold": {
|
||||
"label": "BLE RSSI Threshold",
|
||||
"description": "At what BLE RSSI level should the counter increase. Defaults to -80."
|
||||
},
|
||||
"paxcounterConfig": {
|
||||
"label": "Paxcounter Config",
|
||||
"description": "Paxcounter configuration"
|
||||
}
|
||||
},
|
||||
"rangeTest": {
|
||||
@@ -343,6 +405,10 @@
|
||||
"save": {
|
||||
"label": "Save CSV to storage",
|
||||
"description": "ESP32 Only"
|
||||
},
|
||||
"rangeTestConfig": {
|
||||
"label": "Range Test Config",
|
||||
"description": "Range Test configuration"
|
||||
}
|
||||
},
|
||||
"serial": {
|
||||
@@ -379,6 +445,10 @@
|
||||
"overrideConsoleSerialPort": {
|
||||
"label": "Override Console Serial Port",
|
||||
"description": "If you have a serial port connected to the console, this will override it."
|
||||
},
|
||||
"serialConfig": {
|
||||
"label": "Serial Config",
|
||||
"description": "Serial module configuration"
|
||||
}
|
||||
},
|
||||
"storeForward": {
|
||||
@@ -403,6 +473,14 @@
|
||||
"historyReturnWindow": {
|
||||
"label": "History return window",
|
||||
"description": "Return records from this time window (minutes)"
|
||||
},
|
||||
"storeForwardConfig": {
|
||||
"label": "Store & Forward Config",
|
||||
"description": "Store & Forward configuration"
|
||||
},
|
||||
"isServer": {
|
||||
"label": "Server",
|
||||
"description": "Run as a Store & Forward server"
|
||||
}
|
||||
},
|
||||
"telemetry": {
|
||||
@@ -451,6 +529,14 @@
|
||||
"airQualityScreenEnabled": {
|
||||
"label": "Air Quality Screen Enabled",
|
||||
"description": "Show the Air Quality telemetry on the device screen"
|
||||
},
|
||||
"telemetryConfig": {
|
||||
"label": "Telemetry Config",
|
||||
"description": "Telemetry configuration"
|
||||
},
|
||||
"deviceTelemetryEnabled": {
|
||||
"label": "Send Device Telemetry",
|
||||
"description": "Enable/Disable the device telemetry module to send metrics to the mesh."
|
||||
}
|
||||
},
|
||||
"remoteHardware": {
|
||||
|
||||
@@ -54,28 +54,62 @@
|
||||
"megahertz": "МГц",
|
||||
"kilohertz": "кГц",
|
||||
"raw": "сырое",
|
||||
"meter": { "one": "метр", "plural": "метров", "suffix": "м" },
|
||||
"kilometer": { "one": "километр", "plural": "километров", "suffix": "км" },
|
||||
"minute": { "one": "минута", "plural": "минут" },
|
||||
"hour": { "one": "час", "plural": "часов" },
|
||||
"meter": {
|
||||
"one": "метр",
|
||||
"plural": "метров",
|
||||
"suffix": "м"
|
||||
},
|
||||
"kilometer": {
|
||||
"one": "километр",
|
||||
"plural": "километров",
|
||||
"suffix": "км"
|
||||
},
|
||||
"minute": {
|
||||
"one": "минута",
|
||||
"plural": "минут"
|
||||
},
|
||||
"hour": {
|
||||
"one": "час",
|
||||
"plural": "часов"
|
||||
},
|
||||
"millisecond": {
|
||||
"one": "миллисекунда",
|
||||
"plural": "миллисекунд",
|
||||
"suffix": "мс"
|
||||
},
|
||||
"second": { "one": "секунда", "plural": "секунд" },
|
||||
"second": {
|
||||
"one": "секунда",
|
||||
"plural": "секунд"
|
||||
},
|
||||
"day": {
|
||||
"one": "день",
|
||||
"plural": "дней",
|
||||
"today": "Сегодня",
|
||||
"yesterday": "Вчера"
|
||||
},
|
||||
"month": { "one": "месяц", "plural": "месяцев" },
|
||||
"year": { "one": "год", "plural": "лет" },
|
||||
"month": {
|
||||
"one": "месяц",
|
||||
"plural": "месяцев"
|
||||
},
|
||||
"year": {
|
||||
"one": "год",
|
||||
"plural": "лет"
|
||||
},
|
||||
"snr": "SNR",
|
||||
"volt": { "one": "вольт", "plural": "вольт", "suffix": "В" },
|
||||
"record": { "one": "запись", "plural": "записей" },
|
||||
"degree": { "one": "градус", "plural": "градусов", "suffix": "°" }
|
||||
"volt": {
|
||||
"one": "вольт",
|
||||
"plural": "вольт",
|
||||
"suffix": "В"
|
||||
},
|
||||
"record": {
|
||||
"one": "запись",
|
||||
"plural": "записей"
|
||||
},
|
||||
"degree": {
|
||||
"one": "градус",
|
||||
"plural": "градусов",
|
||||
"suffix": "°"
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"0bit": "Пусто",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"pickConnectionAgain": "Не удалось подключиться. Возможно, нужно заново выбрать устройство/порт.",
|
||||
"added": "Подключение добавлено",
|
||||
"savedByName": "{{name}} сохранено.",
|
||||
"savedCantConnect": "Подключение сохранено, но подключиться не удалось."
|
||||
"savedCantConnect": "Подключение сохранено, но подключиться не удалось.",
|
||||
"checkConnetion": "Check your device or settings and try again"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,14 +42,28 @@
|
||||
"commandPalette": "Поиск команд..."
|
||||
},
|
||||
"toast": {
|
||||
"positionRequestSent": { "title": "Запрос позиции отправлен." },
|
||||
"requestingPosition": { "title": "Запрашиваем позицию, пожалуйста, подождите..." },
|
||||
"sendingTraceroute": { "title": "Отправляем трассировку, пожалуйста, подождите..." },
|
||||
"tracerouteSent": { "title": "Трассировка отправлена." },
|
||||
"savedChannel": { "title": "Канал сохранён: {{channelName}}" },
|
||||
"positionRequestSent": {
|
||||
"title": "Запрос позиции отправлен."
|
||||
},
|
||||
"requestingPosition": {
|
||||
"title": "Запрашиваем позицию, пожалуйста, подождите..."
|
||||
},
|
||||
"sendingTraceroute": {
|
||||
"title": "Отправляем трассировку, пожалуйста, подождите..."
|
||||
},
|
||||
"tracerouteSent": {
|
||||
"title": "Трассировка отправлена."
|
||||
},
|
||||
"savedChannel": {
|
||||
"title": "Канал сохранён: {{channelName}}"
|
||||
},
|
||||
"messages": {
|
||||
"pkiEncryption": { "title": "Чат использует шифрование PKI." },
|
||||
"pskEncryption": { "title": "Чат использует шифрование PSK." }
|
||||
"pkiEncryption": {
|
||||
"title": "Чат использует шифрование PKI."
|
||||
},
|
||||
"pskEncryption": {
|
||||
"title": "Чат использует шифрование PSK."
|
||||
}
|
||||
},
|
||||
"configSaveError": {
|
||||
"title": "Ошибка сохранения настроек",
|
||||
@@ -212,5 +226,9 @@
|
||||
"footer": {
|
||||
"text": "Работает на <0>▲ Vercel</0> | Meshtastic® — зарегистрированный товарный знак Meshtastic LLC. | <1>Юридическая информация</1>",
|
||||
"commitSha": "SHA коммита: {{sha}}"
|
||||
},
|
||||
"language": {
|
||||
"label": "Язык",
|
||||
"changeLanguage": "Change Language"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
||||
import { CommandPalette } from "@components/CommandPalette/index.tsx";
|
||||
import { ConnectingOverlay } from "@components/ConnectingOverlay.tsx";
|
||||
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
|
||||
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
|
||||
import { RegionSetupReminder } from "@components/RegionSetupReminder.tsx";
|
||||
import { Toaster } from "@components/Toaster.tsx";
|
||||
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
|
||||
import Footer from "@components/UI/Footer.tsx";
|
||||
@@ -22,17 +24,14 @@ export function App() {
|
||||
const device = getDevice(selectedDeviceId);
|
||||
|
||||
return (
|
||||
// <ThemeProvider defaultTheme="system" storageKey="theme">
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}>
|
||||
{/* <NewDeviceDialog
|
||||
open={connectDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setConnectDialogOpen(open);
|
||||
}}
|
||||
/> */}
|
||||
<Toaster />
|
||||
<TanStackRouterDevtools position="bottom-right" />
|
||||
<DeviceWrapper deviceId={selectedDeviceId}>
|
||||
{/* Overlay sits outside the device-conditional branch so it shows
|
||||
during a first-time connect from the Connections screen as
|
||||
well as reconnects from inside the app. */}
|
||||
<ConnectingOverlay />
|
||||
<div
|
||||
className="flex h-screen flex-col bg-background-primary text-text-primary"
|
||||
style={{ scrollbarWidth: "thin" }}
|
||||
@@ -43,6 +42,7 @@ export function App() {
|
||||
<div className="h-full flex w-full">
|
||||
<DialogManager />
|
||||
<KeyBackupReminder />
|
||||
<RegionSetupReminder />
|
||||
<CommandPalette />
|
||||
<MapProvider>
|
||||
<Outlet />
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface DeviceWrapperProps {
|
||||
|
||||
export const DeviceWrapper = ({ children, deviceId }: DeviceWrapperProps) => {
|
||||
return (
|
||||
<CurrentDeviceContext.Provider value={{ deviceId }}>{children}</CurrentDeviceContext.Provider>
|
||||
<CurrentDeviceContext.Provider value={{ deviceId }}>
|
||||
{children}
|
||||
</CurrentDeviceContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock("@components/UI/Checkbox.tsx", () => ({
|
||||
Checkbox: ({ id, checked, onChange }: { id: string; checked: boolean; onChange: () => void }) => (
|
||||
<input data-testid="checkbox" type="checkbox" id={id} checked={checked} onChange={onChange} />
|
||||
Checkbox: ({
|
||||
id,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
onChange: () => void;
|
||||
}) => (
|
||||
<input
|
||||
data-testid="checkbox"
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { BatteryFullIcon, BatteryLowIcon, BatteryMediumIcon, PlugZapIcon } from "lucide-react";
|
||||
import {
|
||||
BatteryFullIcon,
|
||||
BatteryLowIcon,
|
||||
BatteryMediumIcon,
|
||||
PlugZapIcon,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DeviceMetrics } from "./types.ts";
|
||||
@@ -38,7 +43,10 @@ export const getBatteryStatus = (level: number): BatteryStatusKey => {
|
||||
const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (deviceMetrics?.batteryLevel === undefined || deviceMetrics?.batteryLevel === null) {
|
||||
if (
|
||||
deviceMetrics?.batteryLevel === undefined ||
|
||||
deviceMetrics?.batteryLevel === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
CommandList,
|
||||
} from "@components/UI/Command.tsx";
|
||||
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
|
||||
import { useAppStore, useDevice, useDeviceStore, useNodeDB } from "@core/stores";
|
||||
import { useNodesAsProto } from "@core/hooks/useNodesAsProto.ts";
|
||||
import { useAppStore, useDevice, useDeviceStore } from "@core/stores";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useCommandState } from "cmdk";
|
||||
@@ -57,11 +58,16 @@ export interface SubItem {
|
||||
}
|
||||
|
||||
export const CommandPalette = () => {
|
||||
const { commandPaletteOpen, setCommandPaletteOpen, setConnectDialogOpen, setSelectedDevice } =
|
||||
useAppStore();
|
||||
const {
|
||||
commandPaletteOpen,
|
||||
setCommandPaletteOpen,
|
||||
setConnectDialogOpen,
|
||||
setSelectedDevice,
|
||||
} = useAppStore();
|
||||
const { getDevices } = useDeviceStore();
|
||||
const { setDialogOpen, connection } = useDevice();
|
||||
const { getNode } = useNodeDB();
|
||||
const allNodes = useNodesAsProto();
|
||||
const getNode = (n: number) => allNodes.find((node) => node.num === n);
|
||||
const { pinnedItems, togglePinnedItem } = usePinnedItems({
|
||||
storageName: "pinnedCommandMenuGroups",
|
||||
});
|
||||
@@ -114,7 +120,9 @@ export const CommandPalette = () => {
|
||||
label: t("manage.command.switchNode"),
|
||||
icon: ArrowLeftRightIcon,
|
||||
subItems: getDevices().map((device) => ({
|
||||
label: getNode(device.hardware.myNodeNum)?.user?.longName ?? t("unknown.shortName"),
|
||||
label:
|
||||
getNode(device.hardware.myNodeNum)?.user?.longName ??
|
||||
t("unknown.shortName"),
|
||||
icon: <Avatar nodeNum={device.hardware.myNodeNum} />,
|
||||
action() {
|
||||
setSelectedDevice(device.id);
|
||||
@@ -257,7 +265,10 @@ export const CommandPalette = () => {
|
||||
}, [setCommandPaletteOpen]);
|
||||
|
||||
return (
|
||||
<CommandDialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
|
||||
<CommandDialog
|
||||
open={commandPaletteOpen}
|
||||
onOpenChange={setCommandPaletteOpen}
|
||||
>
|
||||
<CommandInput placeholder={t("search.commandPalette")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("emptyState")}</CommandEmpty>
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Dialog, DialogContent, DialogTitle } from "@components/UI/Dialog.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { useDeviceStore } from "@core/stores";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { useConnections } from "@app/pages/Connections/useConnections";
|
||||
import { useConnectionProgress } from "@meshtastic/sdk-react";
|
||||
import { Bluetooth, Cable, CheckCircle2, Globe, Loader2 } from "lucide-react";
|
||||
import type { ComponentType, ReactElement } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const STUCK_THRESHOLD_MS = 15_000;
|
||||
|
||||
const TRANSPORT_ICON: Record<
|
||||
"http" | "bluetooth" | "serial",
|
||||
ComponentType<{ className?: string }>
|
||||
> = {
|
||||
http: Globe,
|
||||
bluetooth: Bluetooth,
|
||||
serial: Cable,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hero-card connect overlay. Visible while any saved connection's status
|
||||
* is "connecting" or "configuring" — driven from the saved-connection
|
||||
* status field rather than `device.connectionPhase` so the overlay shows
|
||||
* during transport-open + storage-open (the device entry doesn't exist
|
||||
* yet during those phases).
|
||||
*
|
||||
* Center: device name + transport icon with an animated radial pulse.
|
||||
* Surrounding: 4 stat chips (Identity / Channels / Nodes / Metadata)
|
||||
* that flip from gray to green as the SDK reports each piece arriving.
|
||||
* Auto-dismisses on "configured" (with a brief success state) or
|
||||
* "disconnected" / "error".
|
||||
*/
|
||||
export const ConnectingOverlay = (): ReactElement | null => {
|
||||
const savedConnections = useDeviceStore((s) => s.savedConnections);
|
||||
const progress = useConnectionProgress();
|
||||
const { disconnect } = useConnections();
|
||||
const { t } = useTranslation("connections");
|
||||
|
||||
const active = savedConnections.find(
|
||||
(c) => c.status === "connecting" || c.status === "configuring",
|
||||
);
|
||||
|
||||
// Stuck-detection: once an attempt has been visible for STUCK_THRESHOLD_MS,
|
||||
// surface a Cancel button so the user can bail out of the overlay even when
|
||||
// `onConfigComplete` never arrives (firmware in CLI / bootloader, framing
|
||||
// out of sync, etc.). Resets when a fresh attempt starts.
|
||||
const [showCancel, setShowCancel] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setShowCancel(false);
|
||||
return;
|
||||
}
|
||||
setShowCancel(false);
|
||||
const t = setTimeout(() => setShowCancel(true), STUCK_THRESHOLD_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [active?.id]);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
const Icon = TRANSPORT_ICON[active.type];
|
||||
const counters =
|
||||
progress.phase === "configuring" || progress.phase === "configured"
|
||||
? progress.received
|
||||
: {
|
||||
config: 0,
|
||||
modules: 0,
|
||||
channels: 0,
|
||||
nodes: 0,
|
||||
myInfo: false,
|
||||
metadata: false,
|
||||
};
|
||||
|
||||
const isConnecting = active.status === "connecting";
|
||||
const phaseLabel = isConnecting
|
||||
? t("overlay.phase.connecting", { defaultValue: "Opening transport…" })
|
||||
: t("overlay.phase.configuring", {
|
||||
defaultValue: "Streaming configuration…",
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open>
|
||||
<DialogContent
|
||||
className="max-w-sm overflow-hidden bg-background-primary p-0 text-text-primary shadow-2xl"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
{t("overlay.srTitle", { defaultValue: "Connecting to device" })}
|
||||
</DialogTitle>
|
||||
|
||||
<div className="flex flex-col items-center gap-6 px-8 pt-10 pb-8">
|
||||
{/* Hero icon with radial pings — uses the theme link color so light
|
||||
and dark modes both look at-home. */}
|
||||
<div className="relative flex h-28 w-28 items-center justify-center">
|
||||
<span className="absolute inset-0 animate-ping rounded-full bg-link/20 [animation-duration:1.8s]" />
|
||||
<span className="absolute inset-3 animate-ping rounded-full bg-link/30 [animation-duration:2.4s] [animation-delay:0.4s]" />
|
||||
<span className="absolute inset-6 rounded-full bg-link shadow-lg shadow-link/40" />
|
||||
<Icon className="relative h-9 w-9 text-background-primary" />
|
||||
</div>
|
||||
|
||||
{/* Name + phase */}
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold tracking-tight text-text-primary">
|
||||
{active.name}
|
||||
</div>
|
||||
<div className="mt-1 inline-flex items-center gap-1.5 text-xs text-text-secondary">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{phaseLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat chips — 2x2 grid */}
|
||||
<div className="grid w-full grid-cols-2 gap-2">
|
||||
<Chip
|
||||
label={t("overlay.chip.identity", { defaultValue: "Identity" })}
|
||||
done={counters.myInfo}
|
||||
/>
|
||||
<Chip
|
||||
label={t("overlay.chip.metadata", { defaultValue: "Metadata" })}
|
||||
done={counters.metadata}
|
||||
/>
|
||||
<Chip
|
||||
label={t("overlay.chip.channels", { defaultValue: "Channels" })}
|
||||
count={counters.channels}
|
||||
done={counters.channels > 0 && progress.phase === "configured"}
|
||||
/>
|
||||
<Chip
|
||||
label={t("overlay.chip.nodes", { defaultValue: "Nodes" })}
|
||||
count={counters.nodes}
|
||||
done={counters.nodes > 0 && progress.phase === "configured"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showCancel && (
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<p className="text-center text-xs text-text-secondary">
|
||||
{t("overlay.stuckHint", {
|
||||
defaultValue:
|
||||
"Taking longer than usual. The device may be in CLI / bootloader mode, or the firmware isn't responding to config requests.",
|
||||
})}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void disconnect(active.id)}
|
||||
>
|
||||
{t("overlay.cancel", { defaultValue: "Cancel" })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
interface ChipProps {
|
||||
label: string;
|
||||
count?: number;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
function Chip({ label, count, done }: ChipProps): ReactElement {
|
||||
const filled = count !== undefined ? count > 0 : done;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm transition-colors",
|
||||
filled
|
||||
? "border-green-500/40 bg-green-500/10 text-green-700 dark:text-green-300"
|
||||
: "border-text-secondary/30 bg-text-secondary/5 text-text-secondary",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{label}</span>
|
||||
{count !== undefined ? (
|
||||
<span className="font-mono text-xs tabular-nums">{count}</span>
|
||||
) : done ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<span className="h-4 w-4 rounded-full border border-dashed border-text-secondary/50" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ConnectionStatus } from "@app/core/stores/deviceStore/types.ts";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import type { Protobuf } from "@meshtastic/sdk";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
ChevronRight,
|
||||
@@ -107,7 +107,9 @@ export const DeviceInfoPanel = ({
|
||||
label: t("batteryVoltage.title"),
|
||||
icon: ZapIcon,
|
||||
value:
|
||||
voltage !== undefined ? `${voltage?.toPrecision(3)} V` : t("unknown.notAvailable", "N/A"),
|
||||
voltage !== undefined
|
||||
? `${voltage?.toPrecision(3)} V`
|
||||
: t("unknown.notAvailable", "N/A"),
|
||||
},
|
||||
{
|
||||
id: "firmware",
|
||||
@@ -205,7 +207,9 @@ export const DeviceInfoPanel = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isCollapsed && <div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0" />}
|
||||
{!isCollapsed && (
|
||||
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
@@ -237,7 +241,9 @@ export const DeviceInfoPanel = ({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!isCollapsed && <div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0" />}
|
||||
{!isCollapsed && (
|
||||
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
@@ -251,7 +257,9 @@ export const DeviceInfoPanel = ({
|
||||
{actionButtons.map((buttonItem) => {
|
||||
const Icon = buttonItem.icon;
|
||||
if (buttonItem.render) {
|
||||
return <Fragment key={buttonItem.id}>{buttonItem.render()}</Fragment>;
|
||||
return (
|
||||
<Fragment key={buttonItem.id}>{buttonItem.render()}</Fragment>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
@@ -273,7 +281,8 @@ export const DeviceInfoPanel = ({
|
||||
"flex-shrink-0 w-4",
|
||||
"text-gray-500 dark:text-gray-400",
|
||||
"transition-colors duration-150",
|
||||
!disableHover && "group-hover:text-gray-700 dark:group-hover:text-gray-200",
|
||||
!disableHover &&
|
||||
"group-hover:text-gray-700 dark:group-hover:text-gray-200",
|
||||
)}
|
||||
/>
|
||||
<Subtle
|
||||
@@ -281,7 +290,8 @@ export const DeviceInfoPanel = ({
|
||||
"text-sm",
|
||||
"text-gray-600 dark:text-gray-300",
|
||||
"transition-colors duration-150",
|
||||
!disableHover && "group-hover:text-gray-800 dark:group-hover:text-gray-100",
|
||||
!disableHover &&
|
||||
"group-hover:text-gray-800 dark:group-hover:text-gray-100",
|
||||
)}
|
||||
>
|
||||
{buttonItem.label}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { SupportBadge } from "@app/components/Badge/SupportedBadge.tsx";
|
||||
import { Switch } from "@app/components/UI/Switch.tsx";
|
||||
import type { ConnectionType, NewConnection } from "@app/core/stores/deviceStore/types.ts";
|
||||
import type {
|
||||
ConnectionType,
|
||||
NewConnection,
|
||||
} from "@app/core/stores/deviceStore/types.ts";
|
||||
import { testHttpReachable } from "@app/pages/Connections/utils";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { Label } from "@components/UI/Label.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/UI/Tabs.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@components/UI/Tabs.tsx";
|
||||
import { Link } from "@components/UI/Typography/Link.tsx";
|
||||
import {
|
||||
type BrowserFeature,
|
||||
@@ -37,7 +45,9 @@ type DialogState = {
|
||||
protocol: "http" | "https";
|
||||
url: string;
|
||||
testStatus: TestingStatus;
|
||||
btSelected: { id: string; name?: string; device?: BluetoothDevice } | undefined;
|
||||
btSelected:
|
||||
| { id: string; name?: string; device?: BluetoothDevice }
|
||||
| undefined;
|
||||
serialSelected: { vendorId?: number; productId?: number } | undefined;
|
||||
};
|
||||
|
||||
@@ -50,7 +60,9 @@ type DialogAction =
|
||||
| { type: "SET_TEST_STATUS"; payload: TestingStatus }
|
||||
| {
|
||||
type: "SET_BT_SELECTED";
|
||||
payload: { id: string; name?: string; device?: BluetoothDevice } | undefined;
|
||||
payload:
|
||||
| { id: string; name?: string; device?: BluetoothDevice }
|
||||
| undefined;
|
||||
}
|
||||
| {
|
||||
type: "SET_SERIAL_SELECTED";
|
||||
@@ -70,7 +82,8 @@ type Pane = {
|
||||
build: () => NewConnection | null;
|
||||
};
|
||||
|
||||
const featureErrors: Record<BrowserFeature, { href: string; i18nKey: string }> = {
|
||||
const featureErrors: Record<BrowserFeature, { href: string; i18nKey: string }> =
|
||||
{
|
||||
"Web Bluetooth": {
|
||||
href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
|
||||
i18nKey: "addConnection.validation.requiresWebBluetooth",
|
||||
@@ -90,7 +103,9 @@ const FeatureErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const browserFeatures = missingFeatures.filter((feature) => feature !== "Secure Context");
|
||||
const browserFeatures = missingFeatures.filter(
|
||||
(feature) => feature !== "Secure Context",
|
||||
);
|
||||
const needsSecureContext = missingFeatures.includes("Secure Context");
|
||||
|
||||
const needsFeature =
|
||||
@@ -100,6 +115,10 @@ const FeatureErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
|
||||
? "Web Serial"
|
||||
: undefined;
|
||||
|
||||
if (!needsFeature && !needsSecureContext) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md text-sm mt-4">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
@@ -152,17 +171,22 @@ const initialState: DialogState = {
|
||||
btSelected: undefined,
|
||||
serialSelected: undefined,
|
||||
};
|
||||
export const createInitialDialogState = (overrides?: Partial<DialogState>): DialogState => {
|
||||
export const createInitialDialogState = (
|
||||
overrides?: Partial<DialogState>,
|
||||
): DialogState => {
|
||||
return { ...initialState, ...(overrides ?? {}) };
|
||||
};
|
||||
|
||||
export const dialogStateInitializer = (overrides?: Partial<DialogState>): DialogState =>
|
||||
createInitialDialogState(overrides);
|
||||
export const dialogStateInitializer = (
|
||||
overrides?: Partial<DialogState>,
|
||||
): DialogState => createInitialDialogState(overrides);
|
||||
|
||||
function dialogReducer(state: DialogState, action: DialogAction): DialogState {
|
||||
switch (action.type) {
|
||||
case "RESET":
|
||||
return createInitialDialogState(action.payload?.isHTTPS ? { protocol: "https" } : {});
|
||||
return createInitialDialogState(
|
||||
action.payload?.isHTTPS ? { protocol: "https" } : {},
|
||||
);
|
||||
case "SET_TAB":
|
||||
return { ...state, tab: action.payload };
|
||||
case "SET_NAME":
|
||||
@@ -203,13 +227,22 @@ function PickerRow({
|
||||
<div className="grid gap-2">
|
||||
<Label>{label}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="subtle" className="gap-2" onClick={onPick} disabled={disabled}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
className="gap-2"
|
||||
onClick={onPick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<MousePointerClick className="h-4 w-4" />
|
||||
{buttonText}
|
||||
</Button>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400 truncate">{display}</div>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400 truncate">
|
||||
{display}
|
||||
</div>
|
||||
{helper ? <p className="text-xs text-slate-500 dark:text-slate-400">{helper}</p> : null}
|
||||
</div>
|
||||
{helper ? (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{helper}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -238,8 +271,10 @@ export default function AddConnectionDialog({
|
||||
const { unsupported } = useBrowserFeatureDetection();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const bluetoothSupported = typeof navigator !== "undefined" && "bluetooth" in navigator;
|
||||
const serialSupported = typeof navigator !== "undefined" && "serial" in navigator;
|
||||
const bluetoothSupported =
|
||||
typeof navigator !== "undefined" && "bluetooth" in navigator;
|
||||
const serialSupported =
|
||||
typeof navigator !== "undefined" && "serial" in navigator;
|
||||
const isURLHTTPS = isHTTPS;
|
||||
|
||||
const reset = useCallback(() => {
|
||||
@@ -268,7 +303,9 @@ export default function AddConnectionDialog({
|
||||
if (!bluetoothSupported) {
|
||||
toast({
|
||||
title: t("addConnection.bluetoothConnection.notSupported.title"),
|
||||
description: t("addConnection.bluetoothConnection.notSupported.description"),
|
||||
description: t(
|
||||
"addConnection.bluetoothConnection.notSupported.description",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -303,7 +340,9 @@ export default function AddConnectionDialog({
|
||||
if (!serialSupported) {
|
||||
toast({
|
||||
title: t("addConnection.serialConnection.notSupported.title"),
|
||||
description: t("addConnection.serialConnection.notSupported.description"),
|
||||
description: t(
|
||||
"addConnection.serialConnection.notSupported.description",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -335,7 +374,9 @@ export default function AddConnectionDialog({
|
||||
}
|
||||
toast({
|
||||
title: t("addConnection.serialConnection.portSelected.title"),
|
||||
description: t("addConnection.serialConnection.portSelected.description"),
|
||||
description: t(
|
||||
"addConnection.serialConnection.portSelected.description",
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
makeToastErrorHandler("Serial")(err);
|
||||
@@ -360,7 +401,9 @@ export default function AddConnectionDialog({
|
||||
dispatch({ type: "SET_TEST_STATUS", payload: "failure" });
|
||||
toast({
|
||||
title: t("addConnection.httpConnection.connectionTest.failure.title"),
|
||||
description: t("addConnection.httpConnection.connectionTest.failure.description"),
|
||||
description: t(
|
||||
"addConnection.httpConnection.connectionTest.failure.description",
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [state.protocol, state.url, toast, t]);
|
||||
@@ -371,7 +414,9 @@ export default function AddConnectionDialog({
|
||||
placeholder: t("addConnection.httpConnection.namePlaceholder"),
|
||||
children: () => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Label htmlFor="url">{t("addConnection.httpConnection.heading")}</Label>
|
||||
<Label htmlFor="url">
|
||||
{t("addConnection.httpConnection.heading")}
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id={"url"}
|
||||
@@ -407,19 +452,23 @@ export default function AddConnectionDialog({
|
||||
className="gap-2"
|
||||
onClick={handleTestHttp}
|
||||
disabled={
|
||||
urlOrIpv4Schema.safeParse(`${state.protocol}://${state.url}`).success === false ||
|
||||
state.testStatus === "testing"
|
||||
urlOrIpv4Schema.safeParse(`${state.protocol}://${state.url}`)
|
||||
.success === false || state.testStatus === "testing"
|
||||
}
|
||||
>
|
||||
{state.testStatus === "testing" ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("addConnection.httpConnection.connectionTest.button.loading")}
|
||||
{t(
|
||||
"addConnection.httpConnection.connectionTest.button.loading",
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MousePointerClick className="h-4 w-4" />
|
||||
{t("addConnection.httpConnection.connectionTest.button.label")}
|
||||
{t(
|
||||
"addConnection.httpConnection.connectionTest.button.label",
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -432,7 +481,9 @@ export default function AddConnectionDialog({
|
||||
{state.testStatus === "failure" && (
|
||||
<div className="flex items-center gap-1 text-sm text-red-600 dark:text-red-400">
|
||||
<XCircle className="h-4 w-4" />
|
||||
{t("addConnection.httpConnection.connectionTest.notReachable")}
|
||||
{t(
|
||||
"addConnection.httpConnection.connectionTest.notReachable",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -442,8 +493,8 @@ export default function AddConnectionDialog({
|
||||
</div>
|
||||
),
|
||||
validate: () =>
|
||||
urlOrIpv4Schema.safeParse(`${state.protocol}://${state.url}`).success === true &&
|
||||
state.testStatus === "success",
|
||||
urlOrIpv4Schema.safeParse(`${state.protocol}://${state.url}`)
|
||||
.success === true && state.testStatus === "success",
|
||||
build: () => ({
|
||||
type: "http",
|
||||
name: state.name.trim(),
|
||||
@@ -456,8 +507,12 @@ export default function AddConnectionDialog({
|
||||
<>
|
||||
<SupportBadge
|
||||
supported={bluetoothSupported}
|
||||
labelSupported={t("addConnection.bluetoothConnection.supported.title")}
|
||||
labelUnsupported={t("addConnection.bluetoothConnection.notSupported.title")}
|
||||
labelSupported={t(
|
||||
"addConnection.bluetoothConnection.supported.title",
|
||||
)}
|
||||
labelUnsupported={t(
|
||||
"addConnection.bluetoothConnection.notSupported.title",
|
||||
)}
|
||||
/>
|
||||
<PickerRow
|
||||
label={t("addConnection.bluetoothConnection.device")}
|
||||
@@ -471,7 +526,10 @@ export default function AddConnectionDialog({
|
||||
}
|
||||
helper={t("addConnection.bluetoothConnection.helperText")}
|
||||
/>
|
||||
<FeatureErrorMessage missingFeatures={unsupported} tabId="bluetooth" />
|
||||
<FeatureErrorMessage
|
||||
missingFeatures={unsupported}
|
||||
tabId="bluetooth"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
validate: () => state.name.trim().length > 0 && !!state.btSelected,
|
||||
@@ -489,8 +547,12 @@ export default function AddConnectionDialog({
|
||||
<>
|
||||
<SupportBadge
|
||||
supported={serialSupported}
|
||||
labelSupported={t("addConnection.serialConnection.supported.title")}
|
||||
labelUnsupported={t("addConnection.serialConnection.notSupported.title")}
|
||||
labelSupported={t(
|
||||
"addConnection.serialConnection.supported.title",
|
||||
)}
|
||||
labelUnsupported={t(
|
||||
"addConnection.serialConnection.notSupported.title",
|
||||
)}
|
||||
/>
|
||||
<PickerRow
|
||||
label={t("addConnection.serialConnection.port")}
|
||||
@@ -500,8 +562,10 @@ export default function AddConnectionDialog({
|
||||
display={
|
||||
state.serialSelected
|
||||
? t("addConnection.serialConnection.deviceName", {
|
||||
vendorId: state.serialSelected.vendorId?.toString(16) ?? "?",
|
||||
productId: state.serialSelected.productId?.toString(16) ?? "?",
|
||||
vendorId:
|
||||
state.serialSelected.vendorId?.toString(16) ?? "?",
|
||||
productId:
|
||||
state.serialSelected.productId?.toString(16) ?? "?",
|
||||
})
|
||||
: t("addConnection.serialConnection.notSelected")
|
||||
}
|
||||
@@ -511,7 +575,8 @@ export default function AddConnectionDialog({
|
||||
</>
|
||||
),
|
||||
validate: () =>
|
||||
state.name.trim().length > 0 && (!!state.serialSelected || !serialSupported),
|
||||
state.name.trim().length > 0 &&
|
||||
(!!state.serialSelected || !serialSupported),
|
||||
build: () => ({
|
||||
type: "serial",
|
||||
name: state.name.trim(),
|
||||
@@ -537,7 +602,8 @@ export default function AddConnectionDialog({
|
||||
const canCreate = useMemo(() => currentPane.validate(), [currentPane]);
|
||||
|
||||
const submit =
|
||||
(fn: (p: NewConnection, device?: BluetoothDevice) => Promise<void>) => async () => {
|
||||
(fn: (p: NewConnection, device?: BluetoothDevice) => Promise<void>) =>
|
||||
async () => {
|
||||
if (!canCreate) {
|
||||
return;
|
||||
}
|
||||
@@ -546,7 +612,8 @@ export default function AddConnectionDialog({
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
const btDevice = state.tab === "bluetooth" ? state.btSelected?.device : undefined;
|
||||
const btDevice =
|
||||
state.tab === "bluetooth" ? state.btSelected?.device : undefined;
|
||||
await fn(payload, btDevice);
|
||||
};
|
||||
|
||||
@@ -562,7 +629,9 @@ export default function AddConnectionDialog({
|
||||
>
|
||||
<Tabs
|
||||
value={state.tab}
|
||||
onValueChange={(v) => dispatch({ type: "SET_TAB", payload: v as TabKey })}
|
||||
onValueChange={(v) =>
|
||||
dispatch({ type: "SET_TAB", payload: v as TabKey })
|
||||
}
|
||||
>
|
||||
<TabsList className="grid grid-cols-3">
|
||||
{TAB_META.map(({ key, label, Icon }) => (
|
||||
@@ -582,14 +651,20 @@ export default function AddConnectionDialog({
|
||||
<Input
|
||||
id={`name-${state.tab}`}
|
||||
value={state.name}
|
||||
onChange={(evt) => dispatch({ type: "SET_NAME", payload: evt.target.value })}
|
||||
onChange={(evt) =>
|
||||
dispatch({ type: "SET_NAME", payload: evt.target.value })
|
||||
}
|
||||
placeholder={currentPane.placeholder}
|
||||
/>
|
||||
</div>
|
||||
{PANES[key].children()}
|
||||
<div className="flex justify-end">
|
||||
<div className="inline-flex rounded-md shadow-sm overflow-hidden border">
|
||||
<Button onClick={submit(onSave)} disabled={!canCreate} className="rounded-none">
|
||||
<Button
|
||||
onClick={submit(onSave)}
|
||||
disabled={!canCreate}
|
||||
className="rounded-none"
|
||||
>
|
||||
{t("button.saveConnection")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -28,13 +28,18 @@ export const urlOrIpv4Schema = z
|
||||
}
|
||||
|
||||
// IPv4 pattern
|
||||
const ipv4Regex = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
||||
const ipv4Regex =
|
||||
/^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
||||
// Domain pattern (e.g. example.com, meshtastic.local)
|
||||
const domainRegex = /^(?!-)(?:[a-zA-Z0-9-]{1,63}\.)+[a-zA-Z]{2,}$/;
|
||||
// Local domain (e.g. meshtastic.local)
|
||||
const localDomainRegex = /^(?!-)[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*\.local$/;
|
||||
|
||||
return ipv4Regex.test(host) || domainRegex.test(host) || localDomainRegex.test(host);
|
||||
return (
|
||||
ipv4Regex.test(host) ||
|
||||
domainRegex.test(host) ||
|
||||
localDomainRegex.test(host)
|
||||
);
|
||||
}, "Must be a valid IPv4 address or domain name with optional port (10-65535)")
|
||||
.transform((val) => {
|
||||
return /^https?:\/\//i.test(val) ? val : `http://${val}`;
|
||||
|
||||
@@ -47,7 +47,9 @@ describe("ClearAllStoresDialog", () => {
|
||||
|
||||
it("calls clearAllStores and navigates to '/' when confirm is clicked", () => {
|
||||
render(<ClearAllStoresDialog open onOpenChange={mockOnOpenChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear all local storage" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Clear all local storage" }),
|
||||
);
|
||||
|
||||
expect(mockClearAllStores).toHaveBeenCalledTimes(1);
|
||||
expect(assignedHref).toBe("/"); // forced reload target
|
||||
|
||||
@@ -7,7 +7,10 @@ export interface ClearAllStoresDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const ClearAllStoresDialog = ({ open, onOpenChange }: ClearAllStoresDialogProps) => {
|
||||
export const ClearAllStoresDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ClearAllStoresDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
|
||||
const handleClearAllStores = () => {
|
||||
|
||||
+31
-9
@@ -21,9 +21,13 @@ export interface ClientNotificationDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const ClientNotificationDialog = ({ open, onOpenChange }: ClientNotificationDialogProps) => {
|
||||
export const ClientNotificationDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ClientNotificationDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection, getClientNotification, removeClientNotification } = useDevice();
|
||||
const { connection, getClientNotification, removeClientNotification } =
|
||||
useDevice();
|
||||
const [securityNumber, setSecurityNumber] = useState("");
|
||||
|
||||
const notification = getClientNotification(0);
|
||||
@@ -42,7 +46,9 @@ export const ClientNotificationDialog = ({ open, onOpenChange }: ClientNotificat
|
||||
nonce: bigint,
|
||||
secNum?: number,
|
||||
) => {
|
||||
connection?.sendKeyVerification(messageType, 0, nonce, secNum).catch((error) => {
|
||||
connection
|
||||
?.sendKeyVerification(messageType, 0, nonce, secNum)
|
||||
.catch((error) => {
|
||||
console.error("Failed to send key verification message:", error);
|
||||
});
|
||||
dismiss();
|
||||
@@ -62,7 +68,9 @@ export const ClientNotificationDialog = ({ open, onOpenChange }: ClientNotificat
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("keyVerification.inform.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("keyVerification.inform.description", { name: value.remoteLongname })}
|
||||
{t("keyVerification.inform.description", {
|
||||
name: value.remoteLongname,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="py-4 text-center font-mono text-4xl tracking-widest">
|
||||
@@ -84,7 +92,9 @@ export const ClientNotificationDialog = ({ open, onOpenChange }: ClientNotificat
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("keyVerification.request.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("keyVerification.request.description", { name: value.remoteLongname })}
|
||||
{t("keyVerification.request.description", {
|
||||
name: value.remoteLongname,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
@@ -102,7 +112,11 @@ export const ClientNotificationDialog = ({ open, onOpenChange }: ClientNotificat
|
||||
variant="default"
|
||||
disabled={securityNumber === ""}
|
||||
onClick={() =>
|
||||
respond(MessageType.PROVIDE_SECURITY_NUMBER, value.nonce, Number(securityNumber))
|
||||
respond(
|
||||
MessageType.PROVIDE_SECURITY_NUMBER,
|
||||
value.nonce,
|
||||
Number(securityNumber),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("keyVerification.request.submit")}
|
||||
@@ -119,7 +133,9 @@ export const ClientNotificationDialog = ({ open, onOpenChange }: ClientNotificat
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("keyVerification.final.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("keyVerification.final.description", { name: value.remoteLongname })}
|
||||
{t("keyVerification.final.description", {
|
||||
name: value.remoteLongname,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="py-4 text-center font-mono text-4xl tracking-widest">
|
||||
@@ -132,7 +148,10 @@ export const ClientNotificationDialog = ({ open, onOpenChange }: ClientNotificat
|
||||
>
|
||||
{t("keyVerification.final.reject")}
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => respond(MessageType.DO_VERIFY, value.nonce)}>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => respond(MessageType.DO_VERIFY, value.nonce)}
|
||||
>
|
||||
{t("keyVerification.final.verify")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -145,7 +164,10 @@ export const ClientNotificationDialog = ({ open, onOpenChange }: ClientNotificat
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("clientNotification.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t([`clientNotification.${notification?.message}`, notification?.message ?? ""])}
|
||||
{t([
|
||||
`clientNotification.${notification?.message}`,
|
||||
notification?.message ?? "",
|
||||
])}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
);
|
||||
|
||||
@@ -1,33 +1,25 @@
|
||||
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
|
||||
import { type MessageStore, useMessages } from "@core/stores";
|
||||
import { useActiveClient } from "@meshtastic/sdk-react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@core/stores", () => ({
|
||||
CurrentDeviceContext: {
|
||||
_currentValue: { deviceId: 1234 },
|
||||
},
|
||||
useMessages: vi.fn(() => ({
|
||||
deleteAllMessages: vi.fn(),
|
||||
const mockClearAll = vi.fn();
|
||||
|
||||
vi.mock("@meshtastic/sdk-react", () => ({
|
||||
useActiveClient: vi.fn(() => ({
|
||||
chat: { clearAll: mockClearAll },
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("DeleteMessagesDialog", () => {
|
||||
const mockOnOpenChange = vi.fn();
|
||||
const mockClearAllMessages = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnOpenChange.mockClear();
|
||||
mockClearAllMessages.mockClear();
|
||||
|
||||
const mockedUseMessages = vi.mocked(useMessages);
|
||||
mockedUseMessages.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
deleteAllMessages: mockClearAllMessages,
|
||||
}) as unknown as MessageStore,
|
||||
);
|
||||
mockedUseMessages.mockClear();
|
||||
mockClearAll.mockClear();
|
||||
vi.mocked(useActiveClient).mockReturnValue({
|
||||
chat: { clearAll: mockClearAll },
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("calls onOpenChange with false when the close button (X) is clicked", () => {
|
||||
@@ -46,28 +38,42 @@ describe("DeleteMessagesDialog", () => {
|
||||
it("renders the dialog when open is true", () => {
|
||||
render(<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />);
|
||||
expect(screen.getByText("Clear All Messages")).toBeInTheDocument();
|
||||
expect(screen.getByText(/This action will clear all message history./)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/This action will clear all message history./),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Clear Messages" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Clear Messages" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the dialog when open is false", () => {
|
||||
render(<DeleteMessagesDialog open={false} onOpenChange={mockOnOpenChange} />);
|
||||
render(
|
||||
<DeleteMessagesDialog open={false} onOpenChange={mockOnOpenChange} />,
|
||||
);
|
||||
expect(screen.queryByText("Clear All Messages")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onOpenChange with false when the dismiss button is clicked", () => {
|
||||
render(<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("calls deleteAllMessages and onOpenChange with false when the clear messages button is clicked", () => {
|
||||
it("calls chat.clearAll and onOpenChange with false when Clear Messages is clicked", () => {
|
||||
render(<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear Messages" }));
|
||||
expect(mockClearAllMessages).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
|
||||
expect(mockClearAll).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("no-ops gracefully when there is no active client", () => {
|
||||
vi.mocked(useActiveClient).mockReturnValue(undefined);
|
||||
render(<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear Messages" }));
|
||||
expect(mockClearAll).not.toHaveBeenCalled();
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMessages } from "@app/core/stores/index.ts";
|
||||
import { useActiveClient } from "@meshtastic/sdk-react";
|
||||
import { AlertTriangleIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DialogWrapper } from "../DialogWrapper.tsx";
|
||||
@@ -8,12 +8,15 @@ export interface DeleteMessagesDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const DeleteMessagesDialog = ({ open, onOpenChange }: DeleteMessagesDialogProps) => {
|
||||
export const DeleteMessagesDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DeleteMessagesDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const messageStore = useMessages();
|
||||
const meshClient = useActiveClient();
|
||||
|
||||
const handleConfirm = () => {
|
||||
messageStore.deleteAllMessages();
|
||||
void meshClient?.chat.clearAll();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRo
|
||||
import { useDevice } from "@core/stores";
|
||||
|
||||
export const DialogManager = () => {
|
||||
const { channels, config, dialog, setDialogOpen } = useDevice();
|
||||
const { config, dialog, setDialogOpen } = useDevice();
|
||||
return (
|
||||
<>
|
||||
<QRDialog
|
||||
@@ -24,7 +24,6 @@ export const DialogManager = () => {
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("QR", open);
|
||||
}}
|
||||
channels={channels}
|
||||
loraConfig={config.lora}
|
||||
/>
|
||||
<ImportDialog
|
||||
|
||||
+3
-1
@@ -33,7 +33,9 @@ describe("FactoryResetConfigDialog", () => {
|
||||
it("calls factoryResetConfig and then closes the dialog on confirm", async () => {
|
||||
render(<FactoryResetConfigDialog open onOpenChange={mockOnOpenChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Factory Reset Config" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Factory Reset Config" }),
|
||||
);
|
||||
|
||||
expect(mockFactoryReset).toHaveBeenCalledTimes(1);
|
||||
|
||||
|
||||
+4
-1
@@ -8,7 +8,10 @@ export interface FactoryResetConfigDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const FactoryResetConfigDialog = ({ open, onOpenChange }: FactoryResetConfigDialogProps) => {
|
||||
export const FactoryResetConfigDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: FactoryResetConfigDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection } = useDevice();
|
||||
|
||||
|
||||
+4
-23
@@ -4,21 +4,12 @@ import { FactoryResetDeviceDialog } from "./FactoryResetDeviceDialog.tsx";
|
||||
|
||||
const mockFactoryResetDevice = vi.fn();
|
||||
const mockRemoveDevice = vi.fn();
|
||||
const mockRemoveMessageStore = vi.fn();
|
||||
const mockRemoveNodeDB = vi.fn();
|
||||
const mockToast = vi.fn();
|
||||
|
||||
vi.mock("@core/stores", () => {
|
||||
// Make each store a callable fn (like a Zustand hook), and attach .getState()
|
||||
const useDeviceStore = Object.assign(vi.fn(), {
|
||||
getState: () => ({ removeDevice: mockRemoveDevice }),
|
||||
});
|
||||
const useMessageStore = Object.assign(vi.fn(), {
|
||||
getState: () => ({ removeMessageStore: mockRemoveMessageStore }),
|
||||
});
|
||||
const useNodeDBStore = Object.assign(vi.fn(), {
|
||||
getState: () => ({ removeNodeDB: mockRemoveNodeDB }),
|
||||
});
|
||||
|
||||
return {
|
||||
CurrentDeviceContext: {
|
||||
@@ -29,8 +20,6 @@ vi.mock("@core/stores", () => {
|
||||
connection: { factoryResetDevice: mockFactoryResetDevice },
|
||||
}),
|
||||
useDeviceStore,
|
||||
useMessageStore,
|
||||
useNodeDBStore,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -45,13 +34,10 @@ describe("FactoryResetDeviceDialog", () => {
|
||||
mockOnOpenChange.mockClear();
|
||||
mockFactoryResetDevice.mockReset();
|
||||
mockRemoveDevice.mockClear();
|
||||
mockRemoveMessageStore.mockClear();
|
||||
mockRemoveNodeDB.mockClear();
|
||||
mockToast.mockClear();
|
||||
});
|
||||
|
||||
it("calls factoryResetDevice, closes dialog, and after reset resolves clears messages and node DB", async () => {
|
||||
// Control the promise returned by factoryResetDevice
|
||||
it("calls factoryResetDevice, closes dialog, and after reset resolves removes the device", async () => {
|
||||
let resolveReset: (() => void) | undefined;
|
||||
mockFactoryResetDevice.mockImplementation(
|
||||
() =>
|
||||
@@ -61,23 +47,20 @@ describe("FactoryResetDeviceDialog", () => {
|
||||
);
|
||||
|
||||
render(<FactoryResetDeviceDialog open onOpenChange={mockOnOpenChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Factory Reset Device" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Factory Reset Device" }),
|
||||
);
|
||||
|
||||
// Called immediately
|
||||
expect(mockFactoryResetDevice).toHaveBeenCalledTimes(1);
|
||||
|
||||
// DialogWrapper awaits onConfirm (which returns undefined), so close happens on next microtask
|
||||
await waitFor(() => {
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
// Resolve the reset
|
||||
resolveReset?.();
|
||||
|
||||
expect(mockRemoveDevice).toHaveBeenCalledTimes(1);
|
||||
expect(mockRemoveMessageStore).toHaveBeenCalledTimes(1);
|
||||
expect(mockRemoveNodeDB).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onOpenChange(false) and does not call factoryResetDevice when cancel is clicked", async () => {
|
||||
@@ -91,7 +74,5 @@ describe("FactoryResetDeviceDialog", () => {
|
||||
|
||||
expect(mockFactoryResetDevice).not.toHaveBeenCalled();
|
||||
expect(mockRemoveDevice).not.toHaveBeenCalled();
|
||||
expect(mockRemoveMessageStore).not.toHaveBeenCalled();
|
||||
expect(mockRemoveNodeDB).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+5
-4
@@ -1,5 +1,5 @@
|
||||
import { toast } from "@core/hooks/useToast.ts";
|
||||
import { useDevice, useDeviceStore, useMessageStore, useNodeDBStore } from "@core/stores";
|
||||
import { useDevice, useDeviceStore } from "@core/stores";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DialogWrapper } from "../DialogWrapper.tsx";
|
||||
|
||||
@@ -8,7 +8,10 @@ export interface FactoryResetDeviceDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const FactoryResetDeviceDialog = ({ open, onOpenChange }: FactoryResetDeviceDialogProps) => {
|
||||
export const FactoryResetDeviceDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: FactoryResetDeviceDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection, id } = useDevice();
|
||||
|
||||
@@ -23,8 +26,6 @@ export const FactoryResetDeviceDialog = ({ open, onOpenChange }: FactoryResetDev
|
||||
// The device will be wiped and disconnected without resolving the promise
|
||||
// so we proceed to clear all data associated with the device immediately
|
||||
useDeviceStore.getState().removeDevice(id);
|
||||
useMessageStore.getState().removeMessageStore(id);
|
||||
useNodeDBStore.getState().removeNodeDB(id);
|
||||
|
||||
// Reload the app to ensure all ephemeral state is cleared
|
||||
window.location.href = "/";
|
||||
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
} from "@components/UI/Select.tsx";
|
||||
import { Switch } from "@components/UI/Switch.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { Protobuf } from "@meshtastic/sdk";
|
||||
import { useConfigEditor } from "@meshtastic/sdk-react";
|
||||
import { toByteArray } from "base64-js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
@@ -33,7 +33,8 @@ export interface ImportDialogProps {
|
||||
}
|
||||
|
||||
export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
||||
const { setChange, channels, config } = useDevice();
|
||||
const { config } = useDevice();
|
||||
const editor = useConfigEditor();
|
||||
const { t } = useTranslation("dialog");
|
||||
const [importDialogInput, setImportDialogInput] = useState<string>("");
|
||||
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
|
||||
@@ -47,7 +48,8 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
||||
try {
|
||||
const channelsUrl = new URL(importDialogInput);
|
||||
if (
|
||||
(channelsUrl.hostname !== "meshtastic.org" && channelsUrl.pathname !== "/e/") ||
|
||||
(channelsUrl.hostname !== "meshtastic.org" &&
|
||||
channelsUrl.pathname !== "/e/") ||
|
||||
!channelsUrl.hash
|
||||
) {
|
||||
throw t("import.error.invalidUrl");
|
||||
@@ -55,7 +57,11 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
||||
|
||||
const encodedChannelConfig = channelsUrl.hash.substring(1);
|
||||
const paddedString = encodedChannelConfig
|
||||
.padEnd(encodedChannelConfig.length + ((4 - (encodedChannelConfig.length % 4)) % 4), "=")
|
||||
.padEnd(
|
||||
encodedChannelConfig.length +
|
||||
((4 - (encodedChannelConfig.length % 4)) % 4),
|
||||
"=",
|
||||
)
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/");
|
||||
|
||||
@@ -77,7 +83,9 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
||||
}, [importDialogInput, t]);
|
||||
|
||||
const apply = () => {
|
||||
channelSet?.settings.forEach((ch: Protobuf.Channel.ChannelSettings, index: number) => {
|
||||
if (!editor) return;
|
||||
channelSet?.settings.forEach(
|
||||
(ch: Protobuf.Channel.ChannelSettings, index: number) => {
|
||||
if (importIndex[index] === -1) {
|
||||
return;
|
||||
}
|
||||
@@ -91,24 +99,16 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
||||
settings: ch,
|
||||
});
|
||||
|
||||
if (!deepCompareConfig(channels.get(importIndex[index] ?? 0), payload, true)) {
|
||||
setChange(
|
||||
{ type: "channel", index: importIndex[index] ?? 0 },
|
||||
payload,
|
||||
channels.get(importIndex[index] ?? 0),
|
||||
editor.setChannel(payload);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (channelSet?.loraConfig && updateConfig) {
|
||||
const payload = {
|
||||
...config.lora,
|
||||
...channelSet.loraConfig,
|
||||
};
|
||||
|
||||
if (!deepCompareConfig(config.lora, payload, true)) {
|
||||
setChange({ type: "config", variant: "lora" }, payload, config.lora);
|
||||
}
|
||||
} as Protobuf.Config.Config_LoRaConfig;
|
||||
editor.setRadioSection("lora", payload);
|
||||
}
|
||||
// Reset state after import
|
||||
setImportDialogInput("");
|
||||
@@ -133,14 +133,23 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("import.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans i18nKey={"import.description"} components={{ italic: <i />, br: <br /> }} />
|
||||
<Trans
|
||||
i18nKey={"import.description"}
|
||||
components={{ italic: <i />, br: <br /> }}
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>{t("import.channelSetUrl")}</Label>
|
||||
<Input
|
||||
value={importDialogInput}
|
||||
variant={importDialogInput === "" ? "default" : validUrl ? "dirty" : "invalid"}
|
||||
variant={
|
||||
importDialogInput === ""
|
||||
? "default"
|
||||
: validUrl
|
||||
? "dirty"
|
||||
: "invalid"
|
||||
}
|
||||
onChange={(e) => {
|
||||
setImportDialogInput(e.target.value);
|
||||
}}
|
||||
@@ -169,7 +178,10 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
||||
<span className="flex-1">{t("import.channelSlot")}</span>
|
||||
</div>
|
||||
{channelSet?.settings.map((channel, index) => (
|
||||
<div className="flex items-center" key={`channel_${channel.id}_${index}`}>
|
||||
<div
|
||||
className="flex items-center"
|
||||
key={`channel_${channel.id}_${index}`}
|
||||
>
|
||||
<Label className="flex-1">
|
||||
{channel.name.length
|
||||
? channel.name
|
||||
@@ -189,10 +201,14 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
||||
disabled={importIndex.includes(i) && index !== i}
|
||||
value={i.toString()}
|
||||
>
|
||||
{i === 0 ? t("import.primary") : `${t("import.channelPrefix")}${i}`}
|
||||
{i === 0
|
||||
? t("import.primary")
|
||||
: `${t("import.channelPrefix")}${i}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="-1">{t("import.doNotImport")}</SelectItem>
|
||||
<SelectItem value="-1">
|
||||
{t("import.doNotImport")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useNodeDB } from "@core/stores";
|
||||
import type { Protobuf, Types } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { useNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
|
||||
import type { Protobuf, Types } from "@meshtastic/sdk";
|
||||
import { numberToHexUnpadded } from "@noble/curves/utils.js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -23,14 +23,15 @@ export const LocationResponseDialog = ({
|
||||
onOpenChange,
|
||||
}: LocationResponseDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { getNode } = useNodeDB();
|
||||
|
||||
const from = getNode(location?.from ?? 0);
|
||||
const from = useNodeAsProto(location?.from ?? 0);
|
||||
const longName =
|
||||
from?.user?.longName ?? (from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
|
||||
from?.user?.longName ??
|
||||
(from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
|
||||
const shortName =
|
||||
from?.user?.shortName ??
|
||||
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : t("unknown.shortName"));
|
||||
(from
|
||||
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
|
||||
: t("unknown.shortName"));
|
||||
|
||||
const position = location?.data;
|
||||
|
||||
@@ -67,18 +68,23 @@ export const LocationResponseDialog = ({
|
||||
rel="noreferrer"
|
||||
>
|
||||
{" "}
|
||||
{position.latitudeI ?? 0 / 1e7}, {position.longitudeI ?? 0 / 1e7}
|
||||
{position.latitudeI ?? 0 / 1e7},{" "}
|
||||
{position.longitudeI ?? 0 / 1e7}
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
{t("locationResponse.altitude")} {position.altitude}
|
||||
{(position.altitude ?? 0) < 1 ? t("unit.meter.one") : t("unit.meter.plural")}
|
||||
{(position.altitude ?? 0) < 1
|
||||
? t("unit.meter.one")
|
||||
: t("unit.meter.plural")}
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
// Optional: Show a message if coordinates are not available
|
||||
<p className="text-textPrimary">{t("locationResponse.noCoordinates")}</p>
|
||||
<p className="text-textPrimary">
|
||||
{t("locationResponse.noCoordinates")}
|
||||
</p>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogContent>
|
||||
|
||||
@@ -18,7 +18,11 @@ export interface ManagedModeDialogProps {
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export const ManagedModeDialog = ({ open, onOpenChange, onSubmit }: ManagedModeDialogProps) => {
|
||||
export const ManagedModeDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: ManagedModeDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const [confirmState, setConfirmState] = useState(false);
|
||||
|
||||
@@ -43,7 +47,9 @@ export const ManagedModeDialog = ({ open, onOpenChange, onSubmit }: ManagedModeD
|
||||
onChange={() => setConfirmState(!confirmState)}
|
||||
name="confirmUnderstanding"
|
||||
>
|
||||
<p className="dark:text-white pt-1">{t("managedMode.confirmUnderstanding")}</p>
|
||||
<p className="dark:text-white pt-1">
|
||||
{t("managedMode.confirmUnderstanding")}
|
||||
</p>
|
||||
</Checkbox>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { BLE } from "@components/PageComponents/Connect/BLE.tsx";
|
||||
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
|
||||
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/UI/Tabs.tsx";
|
||||
import {
|
||||
type BrowserFeature,
|
||||
useBrowserFeatureDetection,
|
||||
} from "@core/hooks/useBrowserFeatureDetection.ts";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link } from "../UI/Typography/Link.tsx";
|
||||
|
||||
export interface TabElementProps {
|
||||
closeDialog: () => void;
|
||||
}
|
||||
|
||||
export interface TabManifest {
|
||||
id: "HTTP" | "BLE" | "Serial";
|
||||
label: string;
|
||||
element: React.FC<TabElementProps>;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
export interface NewDeviceProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface FeatureErrorProps {
|
||||
missingFeatures: BrowserFeature[];
|
||||
tabId: "HTTP" | "BLE" | "Serial";
|
||||
}
|
||||
|
||||
const errors: Record<BrowserFeature, { href: string; i18nKey: string }> = {
|
||||
"Web Bluetooth": {
|
||||
href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
|
||||
i18nKey: "newDeviceDialog.validation.requiresWebBluetooth",
|
||||
},
|
||||
"Web Serial": {
|
||||
href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
|
||||
i18nKey: "newDeviceDialog.validation.requiresWebSerial",
|
||||
},
|
||||
"Secure Context": {
|
||||
href: "https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
|
||||
i18nKey: "newDeviceDialog.validation.requiresSecureContext",
|
||||
},
|
||||
};
|
||||
|
||||
const ErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
|
||||
if (missingFeatures.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const browserFeatures = missingFeatures.filter((feature) => feature !== "Secure Context");
|
||||
const needsSecureContext = missingFeatures.includes("Secure Context");
|
||||
|
||||
const needsFeature =
|
||||
tabId === "BLE" && browserFeatures.includes("Web Bluetooth")
|
||||
? "Web Bluetooth"
|
||||
: tabId === "Serial" && browserFeatures.includes("Web Serial")
|
||||
? "Web Serial"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md text-sm text-slate-500 dark:text-slate-400">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<AlertCircle size={40} className="mr-2 shrink-0 text-white" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-sm text-white">
|
||||
{needsFeature && (
|
||||
<Trans
|
||||
i18nKey={errors[needsFeature].i18nKey}
|
||||
components={[
|
||||
<Link
|
||||
key="0"
|
||||
href={errors[needsFeature].href}
|
||||
className="underline hover:text-slate-200 text-white dark:text-white dark:hover:text-slate-300"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{needsFeature && needsSecureContext && " "}
|
||||
{needsSecureContext && (
|
||||
<Trans
|
||||
i18nKey={
|
||||
browserFeatures.length > 0
|
||||
? "newDeviceDialog.validation.additionallyRequiresSecureContext"
|
||||
: "newDeviceDialog.validation.requiresSecureContext"
|
||||
}
|
||||
components={{
|
||||
"0": (
|
||||
<Link
|
||||
href={errors["Secure Context"].href}
|
||||
className="underline hover:text-slate-200"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const NewDeviceDialog = ({ open, onOpenChange }: NewDeviceProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { unsupported } = useBrowserFeatureDetection();
|
||||
|
||||
const tabs: TabManifest[] = [
|
||||
{
|
||||
id: "HTTP",
|
||||
label: t("newDeviceDialog.tabHttp"),
|
||||
element: HTTP,
|
||||
isDisabled: false,
|
||||
},
|
||||
{
|
||||
id: "BLE",
|
||||
label: t("newDeviceDialog.tabBluetooth"),
|
||||
element: BLE,
|
||||
isDisabled: unsupported.includes("Web Bluetooth") || unsupported.includes("Secure Context"),
|
||||
},
|
||||
{
|
||||
id: "Serial",
|
||||
label: t("newDeviceDialog.tabSerial"),
|
||||
element: Serial,
|
||||
isDisabled: unsupported.includes("Web Serial") || unsupported.includes("Secure Context"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("newDeviceDialog.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="HTTP">
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.id} value={tab.id}>
|
||||
<fieldset disabled={tab.isDisabled}>
|
||||
{tab.id !== "HTTP" && tab.isDisabled ? (
|
||||
<ErrorMessage missingFeatures={unsupported} tabId={tab.id} />
|
||||
) : (
|
||||
<tab.element closeDialog={() => onOpenChange(false)} />
|
||||
)}
|
||||
</fieldset>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -27,10 +27,11 @@ import {
|
||||
import { useFavoriteNode } from "@core/hooks/useFavoriteNode.ts";
|
||||
import { useIgnoreNode } from "@core/hooks/useIgnoreNode.ts";
|
||||
import { toast } from "@core/hooks/useToast.ts";
|
||||
import { useAppStore, useDevice, useNodeDB } from "@core/stores";
|
||||
import { useNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
|
||||
import { useAppStore, useDevice } from "@core/stores";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { Protobuf } from "@meshtastic/sdk";
|
||||
import { numberToHexUnpadded } from "@noble/curves/utils.js";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { fromByteArray } from "base64-js";
|
||||
import {
|
||||
@@ -51,19 +52,25 @@ export interface NodeDetailsDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const NodeDetailsDialog = ({ open, onOpenChange }: NodeDetailsDialogProps) => {
|
||||
export const NodeDetailsDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: NodeDetailsDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { setDialogOpen, connection } = useDevice();
|
||||
const { getNode } = useNodeDB();
|
||||
const navigate = useNavigate();
|
||||
const { setNodeNumToBeRemoved, nodeNumDetails } = useAppStore();
|
||||
const { updateFavorite } = useFavoriteNode();
|
||||
const { updateIgnored } = useIgnoreNode();
|
||||
|
||||
const node = getNode(nodeNumDetails);
|
||||
const node = useNodeAsProto(nodeNumDetails);
|
||||
|
||||
const [isFavoriteState, setIsFavoriteState] = useState<boolean>(node?.isFavorite ?? false);
|
||||
const [isIgnoredState, setIsIgnoredState] = useState<boolean>(node?.isIgnored ?? false);
|
||||
const [isFavoriteState, setIsFavoriteState] = useState<boolean>(
|
||||
node?.isFavorite ?? false,
|
||||
);
|
||||
const [isIgnoredState, setIsIgnoredState] = useState<boolean>(
|
||||
node?.isIgnored ?? false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!node) {
|
||||
@@ -178,7 +185,8 @@ export const NodeDetailsDialog = ({ open, onOpenChange }: NodeDetailsDialogProps
|
||||
key: "batteryLevel",
|
||||
label: t("nodeDetails.batteryLevel"),
|
||||
value: node.deviceMetrics?.batteryLevel,
|
||||
format: (val: number) => (val === 101 ? t("batteryStatus.pluggedIn") : `${val.toFixed(2)}%`),
|
||||
format: (val: number) =>
|
||||
val === 101 ? t("batteryStatus.pluggedIn") : `${val.toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
key: "voltage",
|
||||
@@ -211,21 +219,35 @@ export const NodeDetailsDialog = ({ open, onOpenChange }: NodeDetailsDialogProps
|
||||
<DialogFooter>
|
||||
<div className="w-full ">
|
||||
<div className="flex flex-row flex-wrap space-y-1">
|
||||
<Button className="mr-1" name="message" onClick={handleDirectMessage}>
|
||||
<Button
|
||||
className="mr-1"
|
||||
name="message"
|
||||
onClick={handleDirectMessage}
|
||||
>
|
||||
<MessageSquareIcon className="mr-2" />
|
||||
{t("nodeDetails.message")}
|
||||
</Button>
|
||||
<Button className="mr-1" name="traceRoute" onClick={handleTraceroute}>
|
||||
<Button
|
||||
className="mr-1"
|
||||
name="traceRoute"
|
||||
onClick={handleTraceroute}
|
||||
>
|
||||
<WaypointsIcon className="mr-2" />
|
||||
{t("nodeDetails.traceRoute")}
|
||||
</Button>
|
||||
<Button className="mr-1" onClick={handleToggleFavorite}>
|
||||
<StarIcon
|
||||
className={cn(isFavoriteState ? " fill-yellow-400 stroke-yellow-400" : "")}
|
||||
className={cn(
|
||||
isFavoriteState ? " fill-yellow-400 stroke-yellow-400" : "",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
{node.user?.publicKey && node.user.publicKey.length > 0 && (
|
||||
<Button className="mr-1" name="verifyKey" onClick={handleInitiateKeyVerification}>
|
||||
<Button
|
||||
className="mr-1"
|
||||
name="verifyKey"
|
||||
onClick={handleInitiateKeyVerification}
|
||||
>
|
||||
<ShieldCheckIcon className="mr-2" />
|
||||
{t("nodeDetails.verifyKey")}
|
||||
</Button>
|
||||
@@ -248,7 +270,9 @@ export const NodeDetailsDialog = ({ open, onOpenChange }: NodeDetailsDialogProps
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
||||
{isIgnoredState ? t("nodeDetails.unignoreNode") : t("nodeDetails.ignoreNode")}
|
||||
{isIgnoredState
|
||||
? t("nodeDetails.unignoreNode")
|
||||
: t("nodeDetails.ignoreNode")}
|
||||
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -278,7 +302,9 @@ export const NodeDetailsDialog = ({ open, onOpenChange }: NodeDetailsDialogProps
|
||||
<div className="flex flex-col flex-wrap space-x-1 space-y-1">
|
||||
<div className="flex flex-row space-x-2">
|
||||
<div className="w-full bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg">
|
||||
<p className="text-lg font-semibold">{t("nodeDetails.details")}</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{t("nodeDetails.details")}
|
||||
</p>
|
||||
<table className="table-fixed w-full">
|
||||
<tbody>
|
||||
<tr>
|
||||
@@ -292,10 +318,9 @@ export const NodeDetailsDialog = ({ open, onOpenChange }: NodeDetailsDialogProps
|
||||
<tr>
|
||||
<td>{t("nodeDetails.role")}</td>
|
||||
<td>
|
||||
{Protobuf.Config.Config_DeviceConfig_Role[node.user?.role ?? 0]?.replace(
|
||||
/_/g,
|
||||
" ",
|
||||
)}
|
||||
{Protobuf.Config.Config_DeviceConfig_Role[
|
||||
node.user?.role ?? 0
|
||||
]?.replace(/_/g, " ")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -314,35 +339,44 @@ export const NodeDetailsDialog = ({ open, onOpenChange }: NodeDetailsDialogProps
|
||||
<td>{t("nodeDetails.hardware")}</td>
|
||||
<td>
|
||||
{(
|
||||
Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ??
|
||||
t("unknown.shortName")
|
||||
Protobuf.Mesh.HardwareModel[
|
||||
node.user?.hwModel ?? 0
|
||||
] ?? t("unknown.shortName")
|
||||
).replace(/_/g, " ")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("nodeDetails.messageable")}</td>
|
||||
<td>{node.user?.isUnmessagable ? t("no") : t("yes")}</td>
|
||||
<td>
|
||||
{node.user?.isUnmessagable ? t("no") : t("yes")}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DeviceImage
|
||||
className="w-40 p-2 rounded-lg border-4 border-slate-200 dark:border-slate-800"
|
||||
deviceType={Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ?? "UNKNOWN"}
|
||||
deviceType={
|
||||
Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ??
|
||||
"UNKNOWN"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={sectionClassName}>
|
||||
<p className="text-lg font-semibold">{t("nodeDetails.security")}</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{t("nodeDetails.security")}
|
||||
</p>
|
||||
<table className="table-auto w-full">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="pr-2">{t("nodeDetails.publicKey")}</td>
|
||||
<td>
|
||||
<pre className="text-xs pt-0.5">
|
||||
{node.user?.publicKey && node.user?.publicKey.length > 0
|
||||
{node.user?.publicKey &&
|
||||
node.user?.publicKey.length > 0
|
||||
? fromByteArray(node.user.publicKey)
|
||||
: t("unknown.longName")}
|
||||
</pre>
|
||||
@@ -361,7 +395,9 @@ export const NodeDetailsDialog = ({ open, onOpenChange }: NodeDetailsDialogProps
|
||||
</div>
|
||||
|
||||
<div className={sectionClassName}>
|
||||
<p className="text-lg font-semibold">{t("nodeDetails.position")}</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{t("nodeDetails.position")}
|
||||
</p>
|
||||
|
||||
{node.position ? (
|
||||
<table className="table-auto w-full">
|
||||
@@ -378,7 +414,8 @@ export const NodeDetailsDialog = ({ open, onOpenChange }: NodeDetailsDialogProps
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{node.position.latitudeI / 1e7}, {node.position.longitudeI / 1e7}
|
||||
{node.position.latitudeI / 1e7},{" "}
|
||||
{node.position.longitudeI / 1e7}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -397,7 +434,11 @@ export const NodeDetailsDialog = ({ open, onOpenChange }: NodeDetailsDialogProps
|
||||
) : (
|
||||
<p>{t("unknown.longName")}</p>
|
||||
)}
|
||||
<Button onClick={handleRequestPosition} name="requestPosition" className="mt-2">
|
||||
<Button
|
||||
onClick={handleRequestPosition}
|
||||
name="requestPosition"
|
||||
className="mt-2"
|
||||
>
|
||||
<MapPinnedIcon className="mr-2" />
|
||||
{t("nodeDetails.requestPosition")}
|
||||
</Button>
|
||||
@@ -422,7 +463,9 @@ export const NodeDetailsDialog = ({ open, onOpenChange }: NodeDetailsDialogProps
|
||||
<tr>
|
||||
<td>{t("nodeDetails.uptime")}</td>
|
||||
<td>
|
||||
<Uptime seconds={node.deviceMetrics.uptimeSeconds} />
|
||||
<Uptime
|
||||
seconds={node.deviceMetrics.uptimeSeconds}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -441,7 +484,9 @@ export const NodeDetailsDialog = ({ open, onOpenChange }: NodeDetailsDialogProps
|
||||
</p>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="overflow-x-scroll">
|
||||
<pre className="text-xs w-full">{JSON.stringify(node, null, 2)}</pre>
|
||||
<pre className="text-xs w-full">
|
||||
{JSON.stringify(node, null, 2)}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { useDevice, useNodeDB } from "@core/stores";
|
||||
import { useMyNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { fromByteArray } from "base64-js";
|
||||
import { DownloadIcon, PrinterIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
@@ -19,19 +20,25 @@ export interface PkiBackupDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const PkiBackupDialog = ({ open, onOpenChange }: PkiBackupDialogProps) => {
|
||||
export const PkiBackupDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: PkiBackupDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { config, setDialogOpen } = useDevice();
|
||||
const { getMyNode } = useNodeDB();
|
||||
const myNode = useMyNodeAsProto();
|
||||
const privateKey = config.security?.privateKey;
|
||||
const publicKey = config.security?.publicKey;
|
||||
|
||||
const decodeKeyData = React.useCallback((key: Uint8Array<ArrayBufferLike>) => {
|
||||
const decodeKeyData = React.useCallback(
|
||||
(key: Uint8Array<ArrayBufferLike>) => {
|
||||
if (!key) {
|
||||
return "";
|
||||
}
|
||||
return fromByteArray(key ?? new Uint8Array(0));
|
||||
}, []);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const closeDialog = React.useCallback(() => {
|
||||
setDialogOpen("pkiBackup", false);
|
||||
@@ -49,8 +56,8 @@ export const PkiBackupDialog = ({ open, onOpenChange }: PkiBackupDialogProps) =>
|
||||
<head>
|
||||
<title>${t("pkiBackup.header", {
|
||||
interpolation: { escapeValue: false },
|
||||
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
|
||||
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
|
||||
shortName: myNode?.user?.shortName ?? t("unknown.shortName"),
|
||||
longName: myNode?.user?.longName ?? t("unknown.longName"),
|
||||
})}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 20px; }
|
||||
@@ -61,8 +68,8 @@ export const PkiBackupDialog = ({ open, onOpenChange }: PkiBackupDialogProps) =>
|
||||
<body>
|
||||
<h1>${t("pkiBackup.header", {
|
||||
interpolation: { escapeValue: false },
|
||||
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
|
||||
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
|
||||
shortName: myNode?.user?.shortName ?? t("unknown.shortName"),
|
||||
longName: myNode?.user?.longName ?? t("unknown.longName"),
|
||||
})}</h1>
|
||||
<h3>${t("pkiBackup.secureBackup")}</h3>
|
||||
<h3>${t("pkiBackup.publicKey")}</h3>
|
||||
@@ -77,7 +84,7 @@ export const PkiBackupDialog = ({ open, onOpenChange }: PkiBackupDialogProps) =>
|
||||
printWindow.print();
|
||||
closeDialog();
|
||||
}
|
||||
}, [decodeKeyData, privateKey, publicKey, closeDialog, t, getMyNode]);
|
||||
}, [decodeKeyData, privateKey, publicKey, closeDialog, t, myNode]);
|
||||
|
||||
const createDownloadKeyFile = React.useCallback(() => {
|
||||
if (!privateKey || !publicKey) {
|
||||
@@ -90,8 +97,8 @@ export const PkiBackupDialog = ({ open, onOpenChange }: PkiBackupDialogProps) =>
|
||||
const formattedContent = [
|
||||
`${t("pkiBackup.header", {
|
||||
interpolation: { escapeValue: false },
|
||||
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
|
||||
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
|
||||
shortName: myNode?.user?.shortName ?? t("unknown.shortName"),
|
||||
longName: myNode?.user?.longName ?? t("unknown.longName"),
|
||||
})}\n\n`,
|
||||
`${t("pkiBackup.privateKey")}\n`,
|
||||
decodedPrivateKey,
|
||||
@@ -107,8 +114,8 @@ export const PkiBackupDialog = ({ open, onOpenChange }: PkiBackupDialogProps) =>
|
||||
link.href = url;
|
||||
link.download = t("pkiBackup.fileName", {
|
||||
interpolation: { escapeValue: false },
|
||||
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
|
||||
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
|
||||
shortName: myNode?.user?.shortName ?? t("unknown.shortName"),
|
||||
longName: myNode?.user?.longName ?? t("unknown.longName"),
|
||||
});
|
||||
|
||||
link.style.display = "none";
|
||||
@@ -117,7 +124,7 @@ export const PkiBackupDialog = ({ open, onOpenChange }: PkiBackupDialogProps) =>
|
||||
document.body.removeChild(link);
|
||||
closeDialog();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [decodeKeyData, privateKey, publicKey, closeDialog, t, getMyNode]);
|
||||
}, [decodeKeyData, privateKey, publicKey, closeDialog, t, myNode]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -127,7 +134,9 @@ export const PkiBackupDialog = ({ open, onOpenChange }: PkiBackupDialogProps) =>
|
||||
<DialogTitle>{t("pkiBackup.title")}</DialogTitle>
|
||||
<DialogDescription>{t("pkiBackup.secureBackup")}</DialogDescription>
|
||||
<DialogDescription>
|
||||
<span className="font-bold break-before-auto">{t("pkiBackup.loseKeysWarning")}</span>
|
||||
<span className="font-bold break-before-auto">
|
||||
{t("pkiBackup.loseKeysWarning")}
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-6">
|
||||
|
||||
@@ -46,7 +46,11 @@ export const PkiRegenerateDialog = ({
|
||||
<DialogDescription>{dialogText.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="destructive" name="regenerate" onClick={() => onSubmit()}>
|
||||
<Button
|
||||
variant="destructive"
|
||||
name="regenerate"
|
||||
onClick={() => onSubmit()}
|
||||
>
|
||||
{dialogText.button}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { Label } from "@components/UI/Label.tsx";
|
||||
import { Protobuf, type Types } from "@meshtastic/core";
|
||||
import { Protobuf } from "@meshtastic/sdk";
|
||||
import { useChannels } from "@meshtastic/sdk-react";
|
||||
import { fromByteArray } from "base64-js";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { QRCode } from "react-qrcode-logo";
|
||||
import { Checkbox } from "../UI/Checkbox/index.tsx";
|
||||
@@ -21,16 +22,15 @@ export interface QRDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
loraConfig?: Protobuf.Config.Config_LoRaConfig;
|
||||
channels: Map<Types.ChannelNumber, Protobuf.Channel.Channel>;
|
||||
}
|
||||
|
||||
export const QRDialog = ({ open, onOpenChange, loraConfig, channels }: QRDialogProps) => {
|
||||
export const QRDialog = ({ open, onOpenChange, loraConfig }: QRDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const [selectedChannels, setSelectedChannels] = useState<number[]>([0]);
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
|
||||
const [qrCodeAdd, setQrCodeAdd] = useState<boolean>();
|
||||
|
||||
const allChannels = useMemo(() => Array.from(channels.values()), [channels]);
|
||||
const allChannels = useChannels();
|
||||
|
||||
useEffect(() => {
|
||||
const channelsToEncode = allChannels
|
||||
@@ -44,12 +44,16 @@ export const QRDialog = ({ open, onOpenChange, loraConfig, channels }: QRDialogP
|
||||
settings: channelsToEncode,
|
||||
}),
|
||||
);
|
||||
const base64 = fromByteArray(toBinary(Protobuf.AppOnly.ChannelSetSchema, encoded))
|
||||
const base64 = fromByteArray(
|
||||
toBinary(Protobuf.AppOnly.ChannelSetSchema, encoded),
|
||||
)
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
|
||||
setQrCodeUrl(`https://meshtastic.org/e/${qrCodeAdd ? "?add=true" : ""}#${base64}`);
|
||||
setQrCodeUrl(
|
||||
`https://meshtastic.org/e/${qrCodeAdd ? "?add=true" : ""}#${base64}`,
|
||||
);
|
||||
}, [allChannels, selectedChannels, qrCodeAdd, loraConfig]);
|
||||
|
||||
return (
|
||||
@@ -80,9 +84,14 @@ export const QRDialog = ({ open, onOpenChange, loraConfig, channels }: QRDialogP
|
||||
checked={selectedChannels.includes(channel.index)}
|
||||
onChange={() => {
|
||||
if (selectedChannels.includes(channel.index)) {
|
||||
setSelectedChannels(selectedChannels.filter((c) => c !== channel.index));
|
||||
setSelectedChannels(
|
||||
selectedChannels.filter((c) => c !== channel.index),
|
||||
);
|
||||
} else {
|
||||
setSelectedChannels([...selectedChannels, channel.index]);
|
||||
setSelectedChannels([
|
||||
...selectedChannels,
|
||||
channel.index,
|
||||
]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ButtonHTMLAttributes, ClassAttributes, InputHTMLAttributes, ReactNode } from "react";
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
ClassAttributes,
|
||||
InputHTMLAttributes,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import type { JSX } from "react/jsx-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RebootDialog } from "./RebootDialog.tsx";
|
||||
@@ -49,10 +54,16 @@ vi.mock("@components/UI/Input.tsx", async () => {
|
||||
vi.mock("@components/UI/Dialog.tsx", () => {
|
||||
return {
|
||||
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => (
|
||||
<p>{children}</p>
|
||||
),
|
||||
DialogClose: () => null,
|
||||
};
|
||||
});
|
||||
@@ -70,8 +81,12 @@ describe("RebootDialog", () => {
|
||||
it("renders dialog with default input value", () => {
|
||||
render(<RebootDialog open onOpenChange={() => {}} />);
|
||||
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
|
||||
expect(screen.getByRole("heading", { name: /reboot device/i, level: 1 })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /reboot now/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /reboot device/i, level: 1 }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /reboot now/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls correct reboot function based on OTA checkbox state", () => {
|
||||
@@ -184,6 +199,8 @@ describe("RebootDialog", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||
});
|
||||
expect(rebootMock).toHaveBeenCalledWith(-1);
|
||||
expect(screen.queryByText(/reboot has been scheduled/i)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/reboot has been scheduled/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,7 +96,11 @@ export const RebootDialog = ({ open, onOpenChange }: RebootDialogProps) => {
|
||||
<Separator />
|
||||
{!isScheduled ? (
|
||||
<>
|
||||
<Checkbox checked={isOTA} onChange={(checked) => setIsOTA(checked)} className="px-2">
|
||||
<Checkbox
|
||||
checked={isOTA}
|
||||
onChange={(checked) => setIsOTA(checked)}
|
||||
className="px-2"
|
||||
>
|
||||
{t("reboot.ota")}
|
||||
</Checkbox>
|
||||
<div className="flex gap-2 px-2 items-center relative">
|
||||
@@ -133,7 +137,9 @@ export const RebootDialog = ({ open, onOpenChange }: RebootDialogProps) => {
|
||||
) : (
|
||||
<div className="px-2">
|
||||
<div className="pb-6 pt-2 text-center">
|
||||
<Label className=" text-gray-700 dark:text-gray-300 ">{t("reboot.scheduled")}</Label>
|
||||
<Label className=" text-gray-700 dark:text-gray-300 ">
|
||||
{t("reboot.scheduled")}
|
||||
</Label>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { CurrentDeviceContext, useDeviceStore, useMessageStore } from "@core/stores";
|
||||
import { CurrentDeviceContext, useDeviceStore } from "@core/stores";
|
||||
import { MeshRegistry } from "@meshtastic/sdk";
|
||||
import { MeshRegistryProvider } from "@meshtastic/sdk-react";
|
||||
import { render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||
|
||||
vi.mock("@core/stores", async () => {
|
||||
const actual = (await vi.importActual("@core/stores")) as typeof import("@core/stores");
|
||||
return {
|
||||
...actual,
|
||||
useMessageStore: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("./useRefreshKeysDialog");
|
||||
|
||||
const mockUseMessageStore = vi.mocked(useMessageStore);
|
||||
const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
|
||||
|
||||
const getInitialState = () =>
|
||||
@@ -31,22 +25,25 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("does not render dialog if no error exists for active chat", () => {
|
||||
test("does not render dialog when there are no node errors", () => {
|
||||
const deviceId = 1;
|
||||
const activeChatNum = 54321;
|
||||
|
||||
useDeviceStore.getState().addDevice(deviceId);
|
||||
|
||||
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
|
||||
mockUseRefreshKeysDialog.mockReturnValue({
|
||||
handleCloseDialog: vi.fn(),
|
||||
handleNodeRemove: vi.fn(),
|
||||
});
|
||||
|
||||
// Empty MeshRegistry so useNodeErrors returns the empty fallback.
|
||||
const registry = new MeshRegistry();
|
||||
|
||||
const { container } = render(
|
||||
<MeshRegistryProvider registry={registry}>
|
||||
<CurrentDeviceContext.Provider value={{ deviceId }}>
|
||||
<RefreshKeysDialog open onOpenChange={vi.fn()} />
|
||||
</CurrentDeviceContext.Provider>,
|
||||
</CurrentDeviceContext.Provider>
|
||||
</MeshRegistryProvider>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { useMessages, useNodeDB } from "@core/stores";
|
||||
import { useNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
|
||||
import { useNodeErrors } from "@meshtastic/sdk-react";
|
||||
import { LockKeyholeOpenIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||
@@ -16,21 +17,20 @@ export interface RefreshKeysDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => {
|
||||
export const RefreshKeysDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: RefreshKeysDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { activeChat } = useMessages();
|
||||
const { nodeErrors, getNode } = useNodeDB();
|
||||
const nodeError = useNodeErrors()[0];
|
||||
const nodeWithError = useNodeAsProto(nodeError?.node ?? 0);
|
||||
|
||||
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
|
||||
|
||||
const nodeErrorNum = nodeErrors.get(activeChat);
|
||||
|
||||
if (!nodeErrorNum) {
|
||||
if (!nodeError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodeWithError = getNode(nodeErrorNum.node);
|
||||
|
||||
const text = {
|
||||
title: t("refreshKeys.title", {
|
||||
interpolation: { escapeValue: false },
|
||||
@@ -45,7 +45,10 @@ export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-8 flex flex-col gap-2" aria-describedby={undefined}>
|
||||
<DialogContent
|
||||
className="max-w-8 flex flex-col gap-2"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<DialogClose onClick={handleCloseDialog} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{text.title}</DialogTitle>
|
||||
@@ -54,17 +57,30 @@ export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps
|
||||
<ul className="mt-2">
|
||||
<li className="flex place-items-center gap-2 items-start">
|
||||
<div className="p-2 bg-slate-500 rounded-lg mt-1">
|
||||
<LockKeyholeOpenIcon size={30} className="text-white justify-center" />
|
||||
<LockKeyholeOpenIcon
|
||||
size={30}
|
||||
className="text-white justify-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<p className="font-bold mb-0.5">{t("refreshKeys.label.acceptNewKeys")}</p>
|
||||
<p className="font-bold mb-0.5">
|
||||
{t("refreshKeys.label.acceptNewKeys")}
|
||||
</p>
|
||||
<p>{t("refreshKeys.description.acceptNewKeys")}</p>
|
||||
</div>
|
||||
<Button variant="default" name="requestNewKeys" onClick={handleNodeRemove}>
|
||||
<Button
|
||||
variant="default"
|
||||
name="requestNewKeys"
|
||||
onClick={handleNodeRemove}
|
||||
>
|
||||
{t("button.requestNewKeys")}
|
||||
</Button>
|
||||
<Button variant="outline" name="dismiss" onClick={handleCloseDialog}>
|
||||
<Button
|
||||
variant="outline"
|
||||
name="dismiss"
|
||||
onClick={handleCloseDialog}
|
||||
>
|
||||
{t("button.dismiss")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import { useDevice, useMessages, useNodeDB } from "@core/stores";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { useActiveClient, useNodeErrors } from "@meshtastic/sdk-react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export function useRefreshKeysDialog() {
|
||||
const { setDialogOpen } = useDevice();
|
||||
const { removeNode, clearNodeError, getNodeError } = useNodeDB();
|
||||
const { activeChat } = useMessages();
|
||||
const meshClient = useActiveClient();
|
||||
const error = useNodeErrors()[0];
|
||||
|
||||
const handleCloseDialog = useCallback(() => {
|
||||
setDialogOpen("refreshKeys", false);
|
||||
}, [setDialogOpen]);
|
||||
|
||||
const handleNodeRemove = useCallback(() => {
|
||||
const nodeWithError = getNodeError(activeChat);
|
||||
if (!nodeWithError) {
|
||||
return;
|
||||
}
|
||||
clearNodeError(activeChat);
|
||||
if (!meshClient || !error) return;
|
||||
meshClient.nodes.clearError(error.node);
|
||||
handleCloseDialog();
|
||||
return removeNode(nodeWithError?.node);
|
||||
}, [activeChat, clearNodeError, getNodeError, removeNode, handleCloseDialog]);
|
||||
void meshClient.nodes.remove(error.node);
|
||||
}, [meshClient, error, handleCloseDialog]);
|
||||
|
||||
return {
|
||||
handleCloseDialog,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Label } from "@components/UI/Label.tsx";
|
||||
import { useAppStore, useDevice, useNodeDB } from "@core/stores";
|
||||
import { useNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
|
||||
import { useAppStore } from "@core/stores";
|
||||
import { useActiveClient } from "@meshtastic/sdk-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DialogWrapper } from "./DialogWrapper.tsx";
|
||||
|
||||
@@ -8,15 +10,17 @@ export interface RemoveNodeDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const RemoveNodeDialog = ({ open, onOpenChange }: RemoveNodeDialogProps) => {
|
||||
export const RemoveNodeDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: RemoveNodeDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection } = useDevice();
|
||||
const { getNode, removeNode } = useNodeDB();
|
||||
const meshClient = useActiveClient();
|
||||
const { nodeNumToBeRemoved } = useAppStore();
|
||||
const node = useNodeAsProto(nodeNumToBeRemoved);
|
||||
|
||||
const handleConfirm = () => {
|
||||
connection?.removeNodeByNum(nodeNumToBeRemoved);
|
||||
removeNode(nodeNumToBeRemoved);
|
||||
void meshClient?.nodes.remove(nodeNumToBeRemoved);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -31,7 +35,7 @@ export const RemoveNodeDialog = ({ open, onOpenChange }: RemoveNodeDialogProps)
|
||||
onConfirm={handleConfirm}
|
||||
>
|
||||
<div className="gap-4">
|
||||
<Label>{getNode(nodeNumToBeRemoved)?.user?.longName}</Label>
|
||||
<Label>{node?.user?.longName}</Label>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
|
||||
@@ -1,93 +1,70 @@
|
||||
// ResetNodeDbDialog.test.tsx
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ResetNodeDbDialog } from "./ResetNodeDbDialog.tsx";
|
||||
|
||||
const mockResetNodes = vi.fn();
|
||||
const mockDeleteAllMessages = vi.fn();
|
||||
const mockRemoveAllNodeErrors = vi.fn();
|
||||
const mockRemoveAllNodes = vi.fn();
|
||||
const mockClearAll = vi.fn();
|
||||
|
||||
vi.mock("@core/stores", () => ({
|
||||
CurrentDeviceContext: {
|
||||
_currentValue: { deviceId: 1234 },
|
||||
},
|
||||
useDevice: () => ({
|
||||
connection: {
|
||||
resetNodes: mockResetNodes,
|
||||
},
|
||||
}),
|
||||
useMessages: () => ({
|
||||
deleteAllMessages: mockDeleteAllMessages,
|
||||
}),
|
||||
useNodeDB: () => ({
|
||||
removeAllNodeErrors: mockRemoveAllNodeErrors,
|
||||
removeAllNodes: mockRemoveAllNodes,
|
||||
}),
|
||||
const { mockUseActiveClient } = vi.hoisted(() => ({
|
||||
mockUseActiveClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@meshtastic/sdk-react", () => ({
|
||||
useActiveClient: mockUseActiveClient,
|
||||
}));
|
||||
|
||||
describe("ResetNodeDbDialog", () => {
|
||||
const mockOnOpenChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnOpenChange.mockClear();
|
||||
mockResetNodes.mockClear();
|
||||
mockDeleteAllMessages.mockClear();
|
||||
mockRemoveAllNodeErrors.mockClear();
|
||||
mockRemoveAllNodes.mockClear();
|
||||
vi.clearAllMocks();
|
||||
mockUseActiveClient.mockReturnValue({
|
||||
nodes: { reset: mockResetNodes },
|
||||
chat: { clearAll: mockClearAll },
|
||||
});
|
||||
});
|
||||
|
||||
it("calls resetNodes, closes dialog, and after resolve clears messages and node DB (with true flag)", async () => {
|
||||
// Control the promise returned by resetNodes
|
||||
let resolveReset: (() => void) | undefined;
|
||||
it("calls reset({ keepMyNode: true }) then clears chat after resolve", async () => {
|
||||
let resolveReset:
|
||||
| ((value: { status: "ok"; value: number }) => void)
|
||||
| undefined;
|
||||
mockResetNodes.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
new Promise((resolve) => {
|
||||
resolveReset = resolve;
|
||||
}),
|
||||
);
|
||||
mockClearAll.mockResolvedValue(undefined);
|
||||
|
||||
render(<ResetNodeDbDialog open onOpenChange={mockOnOpenChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reset Node Database" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Reset Node Database" }),
|
||||
);
|
||||
|
||||
// Called immediately
|
||||
expect(mockResetNodes).toHaveBeenCalledTimes(1);
|
||||
expect(mockResetNodes).toHaveBeenCalledWith({ keepMyNode: true });
|
||||
|
||||
// DialogWrapper awaits onConfirm (which returns undefined), so close happens on next microtask
|
||||
await waitFor(() => {
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
// Nothing else should have happened yet (the promise hasn't resolved)
|
||||
expect(mockDeleteAllMessages).not.toHaveBeenCalled();
|
||||
expect(mockRemoveAllNodeErrors).not.toHaveBeenCalled();
|
||||
expect(mockRemoveAllNodes).not.toHaveBeenCalled();
|
||||
expect(mockClearAll).not.toHaveBeenCalled();
|
||||
|
||||
// Resolve the reset
|
||||
resolveReset?.();
|
||||
resolveReset?.({ status: "ok", value: 1 });
|
||||
|
||||
// Now the .then() chain should fire
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteAllMessages).toHaveBeenCalledTimes(1);
|
||||
expect(mockRemoveAllNodeErrors).toHaveBeenCalledTimes(1);
|
||||
expect(mockRemoveAllNodes).toHaveBeenCalledTimes(1);
|
||||
expect(mockRemoveAllNodes).toHaveBeenCalledWith(true);
|
||||
expect(mockClearAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onOpenChange(false) and does not call resetNodes when cancel is clicked", async () => {
|
||||
it("does not call reset when cancel is clicked", async () => {
|
||||
render(<ResetNodeDbDialog open onOpenChange={mockOnOpenChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
expect(mockResetNodes).not.toHaveBeenCalled();
|
||||
expect(mockDeleteAllMessages).not.toHaveBeenCalled();
|
||||
expect(mockRemoveAllNodeErrors).not.toHaveBeenCalled();
|
||||
expect(mockRemoveAllNodes).not.toHaveBeenCalled();
|
||||
expect(mockClearAll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { toast } from "@core/hooks/useToast.ts";
|
||||
import { useDevice, useMessages, useNodeDB } from "@core/stores";
|
||||
import { useActiveClient } from "@meshtastic/sdk-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DialogWrapper } from "../DialogWrapper.tsx";
|
||||
|
||||
@@ -8,24 +8,23 @@ export interface ResetNodeDbDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const ResetNodeDbDialog = ({ open, onOpenChange }: ResetNodeDbDialogProps) => {
|
||||
export const ResetNodeDbDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ResetNodeDbDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection } = useDevice();
|
||||
const { removeAllNodeErrors, removeAllNodes } = useNodeDB();
|
||||
const { deleteAllMessages } = useMessages();
|
||||
const meshClient = useActiveClient();
|
||||
|
||||
const handleResetNodeDb = () => {
|
||||
connection
|
||||
?.resetNodes()
|
||||
.then(() => {
|
||||
deleteAllMessages();
|
||||
removeAllNodeErrors();
|
||||
removeAllNodes(true);
|
||||
if (!meshClient) return;
|
||||
meshClient.nodes
|
||||
.reset({ keepMyNode: true })
|
||||
.then((result) => {
|
||||
if (result.status === "error") throw result.error;
|
||||
return meshClient.chat.clearAll();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: t("resetNodeDb.failedTitle"),
|
||||
});
|
||||
toast({ title: t("resetNodeDb.failedTitle") });
|
||||
console.error("Failed to reset Node DB:", error);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useNodeDB } from "@core/stores";
|
||||
import type { Protobuf, Types } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { useNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
|
||||
import type { Protobuf, Types } from "@meshtastic/sdk";
|
||||
import { numberToHexUnpadded } from "@noble/curves/utils.js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { TraceRoute } from "../PageComponents/Messages/TraceRoute.tsx";
|
||||
@@ -25,19 +25,21 @@ export const TracerouteResponseDialog = ({
|
||||
onOpenChange,
|
||||
}: TracerouteResponseDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { getNode } = useNodeDB();
|
||||
const route: number[] = traceroute?.data.route ?? [];
|
||||
const routeBack: number[] = traceroute?.data.routeBack ?? [];
|
||||
const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
|
||||
const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
|
||||
const from = getNode(traceroute?.to ?? 0); // The origin of the traceroute = the "to" node of the mesh packet
|
||||
const from = useNodeAsProto(traceroute?.to ?? 0); // The origin of the traceroute = the "to" node of the mesh packet
|
||||
const fromLongName =
|
||||
from?.user?.longName ?? (from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
|
||||
from?.user?.longName ??
|
||||
(from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
|
||||
const fromShortName =
|
||||
from?.user?.shortName ??
|
||||
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : t("unknown.shortName"));
|
||||
(from
|
||||
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
|
||||
: t("unknown.shortName"));
|
||||
|
||||
const toUser = getNode(traceroute?.from ?? 0); // The destination of the traceroute = the "from" node of the mesh packet
|
||||
const toUser = useNodeAsProto(traceroute?.from ?? 0); // The destination of the traceroute = the "from" node of the mesh packet
|
||||
|
||||
if (!toUser || !from) {
|
||||
return null;
|
||||
|
||||
@@ -20,12 +20,16 @@ export interface RouterRoleDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const UnsafeRolesDialog = ({ open, onOpenChange }: RouterRoleDialogProps) => {
|
||||
export const UnsafeRolesDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: RouterRoleDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const [confirmState, setConfirmState] = useState(false);
|
||||
const { setDialogOpen } = useDevice();
|
||||
|
||||
const deviceRoleLink = "https://meshtastic.org/docs/configuration/radio/device/";
|
||||
const deviceRoleLink =
|
||||
"https://meshtastic.org/docs/configuration/radio/device/";
|
||||
const choosingTheRightDeviceRoleLink =
|
||||
"https://meshtastic.org/blog/choosing-the-right-device-role/";
|
||||
|
||||
@@ -59,11 +63,17 @@ export const UnsafeRolesDialog = ({ open, onOpenChange }: RouterRoleDialogProps)
|
||||
onChange={() => setConfirmState(!confirmState)}
|
||||
name="confirmUnderstanding"
|
||||
>
|
||||
<span className="dark:text-white">{t("unsafeRoles.confirmUnderstanding")}</span>
|
||||
<span className="dark:text-white">
|
||||
{t("unsafeRoles.confirmUnderstanding")}
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button variant="default" name="dismiss" onClick={() => handleCloseDialog("dismiss")}>
|
||||
<Button
|
||||
variant="default"
|
||||
name="dismiss"
|
||||
onClick={() => handleCloseDialog("dismiss")}
|
||||
>
|
||||
{t("button.dismiss")}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -4,11 +4,20 @@ import {
|
||||
} from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||
import { eventBus } from "@core/utils/eventBus.ts";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "vitest";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
type Mock,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock("@tanstack/react-router", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
|
||||
const actual =
|
||||
await importOriginal<typeof import("@tanstack/react-router")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
@@ -56,7 +65,10 @@ describe("useUnsafeRolesDialog", () => {
|
||||
|
||||
result.current.handleCloseDialog();
|
||||
|
||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith("unsafeRoles", false);
|
||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
|
||||
"unsafeRoles",
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +77,8 @@ describe("useUnsafeRolesDialog", () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
const safeRole = "SAFE_ROLE";
|
||||
|
||||
const validationResult = await result.current.validateRoleSelection(safeRole);
|
||||
const validationResult =
|
||||
await result.current.validateRoleSelection(safeRole);
|
||||
|
||||
expect(validationResult).toBe(true);
|
||||
expect(mockDevice.setDialogOpen).not.toHaveBeenCalled();
|
||||
@@ -74,40 +87,61 @@ describe("useUnsafeRolesDialog", () => {
|
||||
it("should open dialog for unsafe roles and resolve with true when confirmed", async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
|
||||
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[0]!);
|
||||
const validationPromise = result.current.validateRoleSelection(
|
||||
UNSAFE_ROLES[0]!,
|
||||
);
|
||||
|
||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith("unsafeRoles", true);
|
||||
expect(eventBus.on).toHaveBeenCalledWith("dialog:unsafeRoles", expect.any(Function));
|
||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
|
||||
"unsafeRoles",
|
||||
true,
|
||||
);
|
||||
expect(eventBus.on).toHaveBeenCalledWith(
|
||||
"dialog:unsafeRoles",
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
const onHandler = (eventBus.on as Mock).mock.calls[0]![1];
|
||||
onHandler({ action: "confirm" });
|
||||
const validationResult = await validationPromise;
|
||||
|
||||
expect(validationResult).toBe(true);
|
||||
expect(eventBus.off).toHaveBeenCalledWith("dialog:unsafeRoles", onHandler);
|
||||
expect(eventBus.off).toHaveBeenCalledWith(
|
||||
"dialog:unsafeRoles",
|
||||
onHandler,
|
||||
);
|
||||
});
|
||||
|
||||
it("should resolve with false when user dismisses the dialog", async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[0]!);
|
||||
const validationPromise = result.current.validateRoleSelection(
|
||||
UNSAFE_ROLES[0]!,
|
||||
);
|
||||
const onHandler = (eventBus.on as Mock).mock.calls[0]![1];
|
||||
onHandler({ action: "dismiss" });
|
||||
|
||||
const validationResult = await validationPromise;
|
||||
expect(validationResult).toBe(false);
|
||||
expect(eventBus.off).toHaveBeenCalledWith("dialog:unsafeRoles", onHandler);
|
||||
expect(eventBus.off).toHaveBeenCalledWith(
|
||||
"dialog:unsafeRoles",
|
||||
onHandler,
|
||||
);
|
||||
});
|
||||
|
||||
it("should clean up event listener after response", async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
|
||||
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[1]!);
|
||||
const validationPromise = result.current.validateRoleSelection(
|
||||
UNSAFE_ROLES[1]!,
|
||||
);
|
||||
const onHandler = (eventBus.on as Mock).mock.calls[0]![1];
|
||||
|
||||
onHandler({ action: "confirm" });
|
||||
await validationPromise;
|
||||
|
||||
expect(eventBus.off).toHaveBeenCalledWith("dialog:unsafeRoles", onHandler);
|
||||
expect(eventBus.off).toHaveBeenCalledWith(
|
||||
"dialog:unsafeRoles",
|
||||
onHandler,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,9 +152,13 @@ describe("useUnsafeRolesDialog", () => {
|
||||
mockDevice.setDialogOpen.mockClear();
|
||||
(eventBus.on as Mock).mockClear();
|
||||
|
||||
const validationPromise = result.current.validateRoleSelection(unsafeRole);
|
||||
const validationPromise =
|
||||
result.current.validateRoleSelection(unsafeRole);
|
||||
|
||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith("unsafeRoles", true);
|
||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
|
||||
"unsafeRoles",
|
||||
true,
|
||||
);
|
||||
|
||||
const onHandler = (eventBus.on as Mock).mock.calls[0]![1];
|
||||
onHandler({ action: "confirm" });
|
||||
|
||||
@@ -21,7 +21,11 @@ export const useUnsafeRolesDialog = () => {
|
||||
setDialogOpen("unsafeRoles", true);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const handleResponse = ({ action }: { action: "confirm" | "dismiss" }) => {
|
||||
const handleResponse = ({
|
||||
action,
|
||||
}: {
|
||||
action: "confirm" | "dismiss";
|
||||
}) => {
|
||||
eventBus.off("dialog:unsafeRoles", handleResponse);
|
||||
resolve(action === "confirm");
|
||||
};
|
||||
|
||||
@@ -62,7 +62,9 @@ export interface UseDialogReturn {
|
||||
}
|
||||
|
||||
export const useDialog = (initialState?: DialogState): UseDialogReturn => {
|
||||
const [state, setState] = useState<DialogState>(initialState ?? { isOpen: false });
|
||||
const [state, setState] = useState<DialogState>(
|
||||
initialState ?? { isOpen: false },
|
||||
);
|
||||
|
||||
const openDialog = useCallback((config: DialogConfig) => {
|
||||
setState({ isOpen: true, config });
|
||||
@@ -95,7 +97,10 @@ export const useDialog = (initialState?: DialogState): UseDialogReturn => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getDefaultTexts = (type: DialogType, t: ReturnType<typeof useTranslation>["t"]) => {
|
||||
export const getDefaultTexts = (
|
||||
type: DialogType,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
) => {
|
||||
switch (type) {
|
||||
case "confirm":
|
||||
return {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { createZodResolver } from "@components/Form/createZodResolver.ts";
|
||||
import { DynamicFormField, type FieldProps } from "@components/Form/DynamicFormField.tsx";
|
||||
import {
|
||||
DynamicFormField,
|
||||
type FieldProps,
|
||||
} from "@components/Form/DynamicFormField.tsx";
|
||||
import { FieldWrapper } from "@components/Form/FormWrapper.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||
import { useEffect } from "react";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import {
|
||||
type Control,
|
||||
type DefaultValues,
|
||||
@@ -59,11 +62,16 @@ export interface DynamicFormProps<T extends FieldValues> {
|
||||
valid?: boolean;
|
||||
validationText?: string;
|
||||
fields: FieldProps<T>[];
|
||||
/** Optional JSX rendered after the field list (e.g. action buttons that
|
||||
* reach into the form via useFormContext). */
|
||||
footer?: ReactNode;
|
||||
}[];
|
||||
validationSchema?: ZodType<T>;
|
||||
}
|
||||
|
||||
export type DynamicFormFormInit<T extends FieldValues> = (methods: UseFormReturn<T, T, T>) => void;
|
||||
export type DynamicFormFormInit<T extends FieldValues> = (
|
||||
methods: UseFormReturn<T, T, T>,
|
||||
) => void;
|
||||
|
||||
export function DynamicForm<T extends FieldValues>({
|
||||
propMethods,
|
||||
@@ -81,7 +89,9 @@ export function DynamicForm<T extends FieldValues>({
|
||||
const internalMethods = useForm<T>({
|
||||
mode: "onChange",
|
||||
defaultValues: defaultValues,
|
||||
resolver: validationSchema ? createZodResolver(validationSchema) : undefined,
|
||||
resolver: validationSchema
|
||||
? createZodResolver(validationSchema)
|
||||
: undefined,
|
||||
shouldFocusError: false,
|
||||
resetOptions: { keepDefaultValues: true },
|
||||
values,
|
||||
@@ -89,7 +99,8 @@ export function DynamicForm<T extends FieldValues>({
|
||||
|
||||
const methods = propMethods ?? internalMethods;
|
||||
|
||||
const { handleSubmit, control, getValues, formState, getFieldState } = methods;
|
||||
const { handleSubmit, control, getValues, formState, getFieldState } =
|
||||
methods;
|
||||
|
||||
useEffect(() => {
|
||||
if (!propMethods) {
|
||||
@@ -97,7 +108,10 @@ export function DynamicForm<T extends FieldValues>({
|
||||
}
|
||||
}, [onFormInit, propMethods, internalMethods]);
|
||||
|
||||
const isDisabled = (disabledBy?: DisabledBy<T>[], disabled?: boolean): boolean => {
|
||||
const isDisabled = (
|
||||
disabledBy?: DisabledBy<T>[],
|
||||
disabled?: boolean,
|
||||
): boolean => {
|
||||
if (disabled) {
|
||||
return true;
|
||||
}
|
||||
@@ -114,7 +128,9 @@ export function DynamicForm<T extends FieldValues>({
|
||||
return field.invert ? value : !value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return field.invert ? field.selector !== value : field.selector === value;
|
||||
return field.invert
|
||||
? field.selector !== value
|
||||
: field.selector === value;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
@@ -168,6 +184,7 @@ export function DynamicForm<T extends FieldValues>({
|
||||
</FieldWrapper>
|
||||
);
|
||||
})}
|
||||
{fieldGroup.footer}
|
||||
</div>
|
||||
))}
|
||||
{hasSubmitButton && (
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { GenericInput, type InputFieldProps } from "@components/Form/FormInput.tsx";
|
||||
import {
|
||||
GenericInput,
|
||||
type InputFieldProps,
|
||||
} from "@components/Form/FormInput.tsx";
|
||||
import {
|
||||
PasswordGenerator,
|
||||
type PasswordGeneratorProps,
|
||||
} from "@components/Form/FormPasswordGenerator.tsx";
|
||||
import { type SelectFieldProps, SelectInput } from "@components/Form/FormSelect.tsx";
|
||||
import { type ToggleFieldProps, ToggleInput } from "@components/Form/FormToggle.tsx";
|
||||
import {
|
||||
type SelectFieldProps,
|
||||
SelectInput,
|
||||
} from "@components/Form/FormSelect.tsx";
|
||||
import {
|
||||
type ToggleFieldProps,
|
||||
ToggleInput,
|
||||
} from "@components/Form/FormToggle.tsx";
|
||||
import type { Control, FieldValues } from "react-hook-form";
|
||||
import { type MultiSelectFieldProps, MultiSelectInput } from "./FormMultiSelect.tsx";
|
||||
import {
|
||||
type MultiSelectFieldProps,
|
||||
MultiSelectInput,
|
||||
} from "./FormMultiSelect.tsx";
|
||||
|
||||
export type FieldProps<T> =
|
||||
| InputFieldProps<T>
|
||||
@@ -34,7 +46,14 @@ export function DynamicFormField<T extends FieldValues>({
|
||||
case "text":
|
||||
case "password":
|
||||
case "number":
|
||||
return <GenericInput field={field} control={control} disabled={disabled} isDirty={isDirty} />;
|
||||
return (
|
||||
<GenericInput
|
||||
field={field}
|
||||
control={control}
|
||||
disabled={disabled}
|
||||
isDirty={isDirty}
|
||||
/>
|
||||
);
|
||||
|
||||
case "toggle":
|
||||
return (
|
||||
|
||||
@@ -48,7 +48,10 @@ export function GenericInput<T extends FieldValues>({
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
if (field.properties?.fieldLength?.max && newValue.length > field.properties.fieldLength.max) {
|
||||
if (
|
||||
field.properties?.fieldLength?.max &&
|
||||
newValue.length > field.properties.fieldLength.max
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,22 +60,32 @@ export function GenericInput<T extends FieldValues>({
|
||||
}
|
||||
|
||||
controllerField.onChange(
|
||||
field.type === "number" ? Number.parseFloat(newValue).toString() : newValue,
|
||||
field.type === "number"
|
||||
? Number.parseFloat(newValue).toString()
|
||||
: newValue,
|
||||
);
|
||||
};
|
||||
|
||||
const currentLength = controllerField.value ? String(controllerField.value).length : 0;
|
||||
const currentLength = controllerField.value
|
||||
? String(controllerField.value).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
type={field.type}
|
||||
step={field.properties?.step}
|
||||
value={field.type === "number" ? String(controllerField.value) : controllerField.value}
|
||||
value={
|
||||
field.type === "number"
|
||||
? String(controllerField.value)
|
||||
: controllerField.value
|
||||
}
|
||||
id={field.name}
|
||||
onChange={handleInputChange}
|
||||
showCopyButton={field.properties?.showCopyButton}
|
||||
showPasswordToggle={field.properties?.showPasswordToggle || field.type === "password"}
|
||||
showPasswordToggle={
|
||||
field.properties?.showPasswordToggle || field.type === "password"
|
||||
}
|
||||
className={field.properties?.className}
|
||||
{...restProperties}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -33,9 +33,11 @@ export function MultiSelectInput<T extends FieldValues>({
|
||||
Object.values(enumValue)[0] !== null &&
|
||||
"i18nKey" in Object.values(enumValue)[0];
|
||||
|
||||
const optionsToRender = Object.entries(enumValue).map(([key, configOrValue]) => {
|
||||
const optionsToRender = Object.entries(enumValue).map(
|
||||
([key, configOrValue]) => {
|
||||
if (isNewConfigStructure) {
|
||||
const config = configOrValue as (typeof FLAGS_CONFIG)[keyof typeof FLAGS_CONFIG];
|
||||
const config =
|
||||
configOrValue as (typeof FLAGS_CONFIG)[keyof typeof FLAGS_CONFIG];
|
||||
return {
|
||||
key,
|
||||
display: t(config.i18nKey),
|
||||
@@ -43,7 +45,8 @@ export function MultiSelectInput<T extends FieldValues>({
|
||||
};
|
||||
}
|
||||
return { key, display: key, value: configOrValue as number };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
|
||||
@@ -49,8 +49,13 @@ export function SelectInput<T extends FieldValues>({
|
||||
control,
|
||||
});
|
||||
|
||||
const { enumValue, formatEnumName, defaultValue, className, ...remainingProperties } =
|
||||
field.properties;
|
||||
const {
|
||||
enumValue,
|
||||
formatEnumName,
|
||||
defaultValue,
|
||||
className,
|
||||
...remainingProperties
|
||||
} = field.properties;
|
||||
const valueToKeyMap: Record<string, string> = {};
|
||||
const optionsEnumValues: [string, number][] = [];
|
||||
|
||||
@@ -95,7 +100,9 @@ export function SelectInput<T extends FieldValues>({
|
||||
className={cn([
|
||||
className,
|
||||
isDirty ? "focus:ring-sky-500 ring-sky-500 ring-2 ring-offset-2" : "",
|
||||
invalid ? "focus:ring-red-500 ring-red-500 ring-2 outline-offset-2" : "",
|
||||
invalid
|
||||
? "focus:ring-red-500 ring-red-500 ring-2 outline-offset-2"
|
||||
: "",
|
||||
])}
|
||||
ref={ref}
|
||||
onBlur={onBlur}
|
||||
|
||||
@@ -34,8 +34,12 @@ export function ToggleInput<T extends FieldValues>({
|
||||
{...field.properties}
|
||||
className={cn([
|
||||
field.properties?.className,
|
||||
isDirty ? "focus:ring-sky-500 ring-sky-500 ring-2 ring-offset-2" : "",
|
||||
invalid ? "focus:ring-red-500 ring-red-500 ring-2 ring-offset-2" : "",
|
||||
isDirty
|
||||
? "focus:ring-sky-500 ring-sky-500 ring-2 ring-offset-2"
|
||||
: "",
|
||||
invalid
|
||||
? "focus:ring-red-500 ring-red-500 ring-2 ring-offset-2"
|
||||
: "",
|
||||
])}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
@@ -24,7 +24,9 @@ export const FieldWrapper = ({
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[0.6fr_2fr_.1fr] sm:items-baseline gap-4">
|
||||
<Label htmlFor={fieldName}>{label}</Label>
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{description}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{description}
|
||||
</p>
|
||||
<p hidden={valid ?? true} className="text-sm text-red-500">
|
||||
{validationText}
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
FieldError,
|
||||
FieldErrors,
|
||||
FieldValues,
|
||||
Resolver,
|
||||
ResolverOptions,
|
||||
@@ -10,7 +11,11 @@ import type { ZodType } from "zod/v4";
|
||||
export function createZodResolver<T extends FieldValues>(
|
||||
schema: ZodType<T, unknown>,
|
||||
): Resolver<T, unknown> {
|
||||
return (values: T, _context: unknown, _options?: ResolverOptions<T>): ResolverResult<T> => {
|
||||
return (
|
||||
values: T,
|
||||
_context: unknown,
|
||||
_options?: ResolverOptions<T>,
|
||||
): ResolverResult<T> => {
|
||||
const result = schema.safeParse(values);
|
||||
if (result.success) {
|
||||
return {
|
||||
@@ -19,7 +24,10 @@ export function createZodResolver<T extends FieldValues>(
|
||||
};
|
||||
}
|
||||
|
||||
const errors: Record<string, FieldError & { params?: Record<string, unknown> }> = {};
|
||||
const errors: Record<
|
||||
string,
|
||||
FieldError & { params?: Record<string, unknown> }
|
||||
> = {};
|
||||
|
||||
for (const issue of result.error.issues) {
|
||||
const { path, code, message, ...params } = issue;
|
||||
@@ -35,7 +43,8 @@ export function createZodResolver<T extends FieldValues>(
|
||||
: "";
|
||||
|
||||
const newCode =
|
||||
code.replace(/_([a-z])/g, (_, char) => char.toUpperCase()) + (suffix ? `.${suffix}` : "");
|
||||
code.replace(/_([a-z])/g, (_, char) => char.toUpperCase()) +
|
||||
(suffix ? `.${suffix}` : "");
|
||||
|
||||
const fieldError: FieldError & { params?: Record<string, unknown> } = {
|
||||
type: newCode,
|
||||
@@ -49,8 +58,8 @@ export function createZodResolver<T extends FieldValues>(
|
||||
}
|
||||
|
||||
return {
|
||||
values: {} as T,
|
||||
errors,
|
||||
values: {} as Record<string, never>,
|
||||
errors: errors as FieldErrors<T>,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ interface LanguageSwitcherProps {
|
||||
disableHover?: boolean;
|
||||
}
|
||||
|
||||
export default function LanguageSwitcher({ disableHover = false }: LanguageSwitcherProps) {
|
||||
export default function LanguageSwitcher({
|
||||
disableHover = false,
|
||||
}: LanguageSwitcherProps) {
|
||||
const { i18n } = useTranslation("ui");
|
||||
const { set: setLanguage, current, getSupportedLangs } = useLang();
|
||||
|
||||
@@ -44,13 +46,15 @@ export default function LanguageSwitcher({ disableHover = false }: LanguageSwitc
|
||||
size={16}
|
||||
className={cn(
|
||||
"text-gray-500 dark:text-gray-400 w-4 flex-shrink-0 transition-colors duration-150",
|
||||
!disableHover && "group-hover:text-gray-700 dark:group-hover:text-gray-200",
|
||||
!disableHover &&
|
||||
"group-hover:text-gray-700 dark:group-hover:text-gray-200",
|
||||
)}
|
||||
/>
|
||||
<Subtle
|
||||
className={cn(
|
||||
"text-sm text-gray-600 dark:text-gray-100 transition-colors duration-150",
|
||||
!disableHover && "group-hover:text-gray-800 dark:group-hover:text-gray-100",
|
||||
!disableHover &&
|
||||
"group-hover:text-gray-800 dark:group-hover:text-gray-100",
|
||||
)}
|
||||
>
|
||||
{`${t("languagePicker.label")}:`}
|
||||
@@ -58,7 +62,8 @@ export default function LanguageSwitcher({ disableHover = false }: LanguageSwitc
|
||||
<Subtle
|
||||
className={cn(
|
||||
"text-sm font-medium text-gray-700 dark:text-gray-200 transition-colors duration-150",
|
||||
!disableHover && "group-hover:text-gray-900 dark:group-hover:text-white",
|
||||
!disableHover &&
|
||||
"group-hover:text-gray-900 dark:group-hover:text-white",
|
||||
)}
|
||||
>
|
||||
{current?.name}
|
||||
@@ -76,7 +81,9 @@ export default function LanguageSwitcher({ disableHover = false }: LanguageSwitc
|
||||
<span>{language.flag}</span>
|
||||
<span>{language.name}</span>
|
||||
</div>
|
||||
{i18n.language === language.code && <Check className="h-4 w-4 text-primary" />}
|
||||
{i18n.language === language.code && (
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -45,7 +45,9 @@ export const BaseMap = ({
|
||||
|
||||
const locale = useMemo(() => {
|
||||
return {
|
||||
"GeolocateControl.FindMyLocation": t("maplibre.GeolocateControl.FindMyLocation"),
|
||||
"GeolocateControl.FindMyLocation": t(
|
||||
"maplibre.GeolocateControl.FindMyLocation",
|
||||
),
|
||||
"NavigationControl.ZoomIn": t("maplibre.NavigationControl.ZoomIn"),
|
||||
"NavigationControl.ZoomOut": t("maplibre.NavigationControl.ZoomOut"),
|
||||
"ScaleControl.Meters": t("unit.meter.suffix"),
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { type ChannelValidation, makeChannelSchema } from "@app/validation/channel.ts";
|
||||
import {
|
||||
type ChannelValidation,
|
||||
makeChannelSchema,
|
||||
} from "@app/validation/channel.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
|
||||
import { createZodResolver } from "@components/Form/createZodResolver.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { Protobuf } from "@meshtastic/sdk";
|
||||
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
|
||||
import { fromByteArray, toByteArray } from "base64-js";
|
||||
import cryptoRandomString from "crypto-random-string";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -17,8 +23,23 @@ export interface SettingsPanelProps {
|
||||
channel: Protobuf.Channel.Channel;
|
||||
}
|
||||
|
||||
const EMPTY_CHANNELS_SIGNAL = {
|
||||
value: new Map<number, Protobuf.Channel.Channel>() as ReadonlyMap<
|
||||
number,
|
||||
Protobuf.Channel.Channel
|
||||
>,
|
||||
peek: () =>
|
||||
new Map<number, Protobuf.Channel.Channel>() as ReadonlyMap<
|
||||
number,
|
||||
Protobuf.Channel.Channel
|
||||
>,
|
||||
subscribe: () => () => {},
|
||||
} as const;
|
||||
|
||||
export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
|
||||
const { config, setChange, getChange, removeChange } = useDevice();
|
||||
const { config } = useDevice();
|
||||
const editor = useConfigEditor();
|
||||
const editorChannels = useSignal(editor?.channels ?? EMPTY_CHANNELS_SIGNAL);
|
||||
const { t } = useTranslation(["channels", "ui", "dialog"]);
|
||||
|
||||
const defaultConfig = channel;
|
||||
@@ -31,7 +52,8 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
|
||||
moduleSettings: {
|
||||
...defaultConfig?.settings?.moduleSettings,
|
||||
positionPrecision:
|
||||
defaultConfig?.settings?.moduleSettings?.positionPrecision === undefined
|
||||
defaultConfig?.settings?.moduleSettings?.positionPrecision ===
|
||||
undefined
|
||||
? 10
|
||||
: defaultConfig?.settings?.moduleSettings?.positionPrecision,
|
||||
},
|
||||
@@ -39,10 +61,7 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
|
||||
},
|
||||
};
|
||||
|
||||
const workingChannel = getChange({
|
||||
type: "channel",
|
||||
index: channel.index,
|
||||
}) as Protobuf.Channel.Channel | undefined;
|
||||
const workingChannel = editorChannels.get(channel.index);
|
||||
const effectiveConfig = workingChannel ?? channel;
|
||||
const formValues = {
|
||||
...effectiveConfig,
|
||||
@@ -53,7 +72,8 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
|
||||
moduleSettings: {
|
||||
...effectiveConfig?.settings?.moduleSettings,
|
||||
positionPrecision:
|
||||
effectiveConfig?.settings?.moduleSettings?.positionPrecision === undefined
|
||||
effectiveConfig?.settings?.moduleSettings?.positionPrecision ===
|
||||
undefined
|
||||
? 10
|
||||
: effectiveConfig?.settings?.moduleSettings?.positionPrecision,
|
||||
},
|
||||
@@ -61,8 +81,11 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
|
||||
},
|
||||
};
|
||||
|
||||
const [preSharedDialogOpen, setPreSharedDialogOpen] = useState<boolean>(false);
|
||||
const [byteCount, setBytes] = useState<number>(effectiveConfig?.settings?.psk.length ?? 16);
|
||||
const [preSharedDialogOpen, setPreSharedDialogOpen] =
|
||||
useState<boolean>(false);
|
||||
const [byteCount, setBytes] = useState<number>(
|
||||
effectiveConfig?.settings?.psk.length ?? 16,
|
||||
);
|
||||
const ChannelValidationSchema = useMemo(() => {
|
||||
return makeChannelSchema(byteCount);
|
||||
}, [byteCount]);
|
||||
@@ -95,7 +118,7 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
|
||||
}, [effectiveByteCount, trigger]);
|
||||
|
||||
const onSubmit = (data: ChannelValidation) => {
|
||||
if (!formState.isReady) {
|
||||
if (!formState.isReady || !editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -111,12 +134,7 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
if (deepCompareConfig(channel, payload, true)) {
|
||||
removeChange({ type: "channel", index: channel.index });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "channel", index: channel.index }, payload, channel);
|
||||
editor.setChannel(payload);
|
||||
};
|
||||
|
||||
const preSharedKeyRegenerate = async () => {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { Channel } from "@app/components/PageComponents/Channels/Channel";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/UI/Tabs.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@components/UI/Tabs.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { Protobuf } from "@meshtastic/sdk";
|
||||
import { useChannels, useConfigEditor, useSignal } from "@meshtastic/sdk-react";
|
||||
import i18next from "i18next";
|
||||
import { QrCodeIcon, UploadIcon } from "lucide-react";
|
||||
import { Suspense, useMemo } from "react";
|
||||
@@ -14,9 +21,12 @@ interface ConfigProps {
|
||||
onFormInit: <T extends object>(methods: UseFormReturn<T>) => void;
|
||||
}
|
||||
|
||||
export const getChannelName = (channel: Protobuf.Channel.Channel) => {
|
||||
return channel.settings?.name.length
|
||||
? channel.settings?.name
|
||||
export const getChannelName = (channel: {
|
||||
index: number;
|
||||
settings?: { name?: string };
|
||||
}) => {
|
||||
return channel.settings?.name?.length
|
||||
? channel.settings.name
|
||||
: channel.index === 0
|
||||
? i18next.t("page.broadcastLabel")
|
||||
: i18next.t("page.channelIndex", {
|
||||
@@ -25,14 +35,41 @@ export const getChannelName = (channel: Protobuf.Channel.Channel) => {
|
||||
});
|
||||
};
|
||||
|
||||
const EMPTY_DIRTY_CHANNELS_SIGNAL = {
|
||||
value: [] as readonly number[],
|
||||
peek: () => [] as readonly number[],
|
||||
subscribe: () => () => {},
|
||||
} as const;
|
||||
|
||||
export const Channels = ({ onFormInit }: ConfigProps) => {
|
||||
const { channels, hasChannelChange, setDialogOpen } = useDevice();
|
||||
const { setDialogOpen } = useDevice();
|
||||
const editor = useConfigEditor();
|
||||
const channels = useChannels();
|
||||
const dirtyChannels = useSignal(
|
||||
editor?.dirtyChannels ?? EMPTY_DIRTY_CHANNELS_SIGNAL,
|
||||
);
|
||||
const { t } = useTranslation("channels");
|
||||
|
||||
const allChannels = Array.from(channels.values());
|
||||
const allChannels = useMemo(
|
||||
() =>
|
||||
channels.map((c) =>
|
||||
create(Protobuf.Channel.ChannelSchema, {
|
||||
index: c.index,
|
||||
role: c.role,
|
||||
settings: c.settings,
|
||||
}),
|
||||
),
|
||||
[channels],
|
||||
);
|
||||
const flags = useMemo(
|
||||
() => new Map(allChannels.map((channel) => [channel.index, hasChannelChange(channel.index)])),
|
||||
[allChannels, hasChannelChange],
|
||||
() =>
|
||||
new Map(
|
||||
allChannels.map((channel) => [
|
||||
channel.index,
|
||||
dirtyChannels.includes(channel.index),
|
||||
]),
|
||||
),
|
||||
[allChannels, dirtyChannels],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -53,7 +90,10 @@ export const Channels = ({ onFormInit }: ConfigProps) => {
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
<Button className="ml-auto mr-1 h-8" onClick={() => setDialogOpen("import", true)}>
|
||||
<Button
|
||||
className="ml-auto mr-1 h-8"
|
||||
onClick={() => setDialogOpen("import", true)}
|
||||
>
|
||||
<UploadIcon className="mr-2" size={14} />
|
||||
{t("page.import")}
|
||||
</Button>
|
||||
@@ -63,9 +103,16 @@ export const Channels = ({ onFormInit }: ConfigProps) => {
|
||||
</Button>
|
||||
</TabsList>
|
||||
{allChannels.map((channel) => (
|
||||
<TabsContent key={`channel_${channel.index}`} value={`channel_${channel.index}`}>
|
||||
<TabsContent
|
||||
key={`channel_${channel.index}`}
|
||||
value={`channel_${channel.index}`}
|
||||
>
|
||||
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
|
||||
<Channel key={channel.index} onFormInit={onFormInit} channel={channel} />
|
||||
<Channel
|
||||
key={channel.index}
|
||||
onFormInit={onFormInit}
|
||||
channel={channel}
|
||||
/>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Button } from "@app/components/UI/Button";
|
||||
import type { Connection } from "@app/core/stores/deviceStore/types";
|
||||
|
||||
export function ConnectionStatusBadge({ status }: { status: Connection["status"] }) {
|
||||
export function ConnectionStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: Connection["status"];
|
||||
}) {
|
||||
let color = "";
|
||||
let displayStatus = status;
|
||||
|
||||
@@ -26,8 +30,13 @@ export function ConnectionStatusBadge({ status }: { status: Connection["status"]
|
||||
}
|
||||
return (
|
||||
<Button variant="subtle" className="inline-flex items-center gap-2">
|
||||
<span className={`h-2.5 w-2.5 rounded-full ${color}`} aria-hidden="true" />
|
||||
<span className="text-xs capitalize text-slate-500 dark:text-slate-400">{displayStatus}</span>
|
||||
<span
|
||||
className={`h-2.5 w-2.5 rounded-full ${color}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-xs capitalize text-slate-500 dark:text-slate-400">
|
||||
{displayStatus}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { hasPos, toLngLat } from "@core/utils/geo";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import type { Protobuf } from "@meshtastic/sdk";
|
||||
import type { Feature, FeatureCollection } from "geojson";
|
||||
import type { HeatmapLayerSpecification } from "maplibre-gl";
|
||||
import { useMemo } from "react";
|
||||
@@ -11,9 +11,16 @@ export interface HeatmapLayerProps {
|
||||
id: string;
|
||||
filteredNodes: Protobuf.Mesh.NodeInfo[];
|
||||
mode: HeatmapMode;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export const HeatmapLayer = ({ id, filteredNodes, mode }: HeatmapLayerProps) => {
|
||||
export const HeatmapLayer = ({
|
||||
id,
|
||||
filteredNodes,
|
||||
mode,
|
||||
isVisible,
|
||||
}: HeatmapLayerProps) => {
|
||||
if (!isVisible) return null;
|
||||
const data: FeatureCollection = useMemo(() => {
|
||||
const features: Feature[] = filteredNodes
|
||||
.filter((node) => hasPos(node.position))
|
||||
@@ -41,7 +48,9 @@ export const HeatmapLayer = ({ id, filteredNodes, mode }: HeatmapLayerProps) =>
|
||||
const paintProps: HeatmapLayerSpecification["paint"] = useMemo(
|
||||
() => ({
|
||||
"heatmap-weight":
|
||||
mode === "density" ? 1 : ["interpolate", ["linear"], ["get", "snr"], -20, 0, 10, 1],
|
||||
mode === "density"
|
||||
? 1
|
||||
: ["interpolate", ["linear"], ["get", "snr"], -20, 0, 10, 1],
|
||||
"heatmap-intensity": ["interpolate", ["linear"], ["zoom"], 0, 1, 15, 3],
|
||||
// Color ramp for heatmap. Domain is 0 (low) to 1 (high).
|
||||
// Begin color ramp at 0-stop with a 0-transparancy color
|
||||
@@ -63,7 +72,17 @@ export const HeatmapLayer = ({ id, filteredNodes, mode }: HeatmapLayerProps) =>
|
||||
1,
|
||||
"rgb(178,24,43)",
|
||||
],
|
||||
"heatmap-radius": ["interpolate", ["linear"], ["zoom"], 0, 2, 9, 20, 15, 30],
|
||||
"heatmap-radius": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
0,
|
||||
2,
|
||||
9,
|
||||
20,
|
||||
15,
|
||||
30,
|
||||
],
|
||||
// Opacity 0.7 to be visible but not blocking
|
||||
"heatmap-opacity": 0.7,
|
||||
}),
|
||||
|
||||
@@ -13,9 +13,9 @@ import { NodeDetail } from "@components/PageComponents/Map/Popups/NodeDetail.tsx
|
||||
import type { PopupState } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx";
|
||||
import { PopupWrapper } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx";
|
||||
import { useMapFitting } from "@core/hooks/useMapFitting";
|
||||
import { useNodeDB } from "@core/stores";
|
||||
import { hasPos, toLngLat } from "@core/utils/geo.ts";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import type { Protobuf } from "@meshtastic/sdk";
|
||||
import { useNodeErrors } from "@meshtastic/sdk-react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MapRef } from "react-map-gl/maplibre";
|
||||
@@ -43,14 +43,20 @@ export const NodesLayer = ({
|
||||
}: NodeMarkerProps): React.ReactNode[] => {
|
||||
const { t } = useTranslation("map");
|
||||
|
||||
const { hasNodeError } = useNodeDB();
|
||||
const errors = useNodeErrors();
|
||||
const errorSet = useMemo(() => new Set(errors.map((e) => e.node)), [errors]);
|
||||
const hasNodeError = useCallback(
|
||||
(num: number) => errorSet.has(num),
|
||||
[errorSet],
|
||||
);
|
||||
const { focusLngLat } = useMapFitting(mapRef);
|
||||
|
||||
const selectedNode = useMemo(
|
||||
() =>
|
||||
popupState?.type !== "node"
|
||||
? undefined
|
||||
: (filteredNodes.find((node) => node.num === popupState.num) ?? undefined),
|
||||
: (filteredNodes.find((node) => node.num === popupState.num) ??
|
||||
undefined),
|
||||
[popupState, filteredNodes],
|
||||
);
|
||||
|
||||
@@ -77,7 +83,9 @@ export const NodesLayer = ({
|
||||
const isExpanded = expandedCluster === key;
|
||||
|
||||
// Precompute pixel offsets for expanded state
|
||||
const expandedOffsets = isExpanded ? fanOutOffsetsPx(nodes.length, key) : undefined;
|
||||
const expandedOffsets = isExpanded
|
||||
? fanOutOffsetsPx(nodes.length, key)
|
||||
: undefined;
|
||||
|
||||
// Always render all node markers in the cluster
|
||||
for (const [i, node] of nodes.entries()) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getColorFromNodeNum, isLightColor } from "@app/core/utils/color";
|
||||
import { precisionBitsToMeters, toLngLat } from "@core/utils/geo.ts";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import type { Protobuf } from "@meshtastic/sdk";
|
||||
import { circle } from "@turf/turf";
|
||||
import type { Feature, FeatureCollection, Polygon } from "geojson";
|
||||
import { Layer, Source } from "react-map-gl/maplibre";
|
||||
@@ -36,7 +36,10 @@ export function generatePrecisionCircles(
|
||||
>();
|
||||
|
||||
for (const node of filteredNodes) {
|
||||
if (node.position?.precisionBits === undefined || node.position.precisionBits === 0) {
|
||||
if (
|
||||
node.position?.precisionBits === undefined ||
|
||||
node.position.precisionBits === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const [lng, lat] = toLngLat(node.position);
|
||||
@@ -60,7 +63,9 @@ export function generatePrecisionCircles(
|
||||
}
|
||||
}
|
||||
|
||||
const items = Array.from(unique.values()).sort((a, b) => a.radiusM - b.radiusM);
|
||||
const items = Array.from(unique.values()).sort(
|
||||
(a, b) => a.radiusM - b.radiusM,
|
||||
);
|
||||
|
||||
const features: Feature<Polygon, CircleProps>[] = items.map(
|
||||
({ lng, lat, radiusM, r, g, b, a }) => {
|
||||
@@ -91,7 +96,13 @@ export const SourcePrecisionCircles = ({
|
||||
type="fill"
|
||||
layout={{ visibility: isVisible ? "visible" : "none" }}
|
||||
paint={{
|
||||
"fill-color": ["rgba", ["get", "r"], ["get", "g"], ["get", "b"], ["get", "a"]],
|
||||
"fill-color": [
|
||||
"rgba",
|
||||
["get", "r"],
|
||||
["get", "g"],
|
||||
["get", "b"],
|
||||
["get", "a"],
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
mercatorToLngLat,
|
||||
toLngLat,
|
||||
} from "@core/utils/geo";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import type { Protobuf } from "@meshtastic/sdk";
|
||||
import type { Feature, FeatureCollection } from "geojson";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layer, Source } from "react-map-gl/maplibre";
|
||||
@@ -31,7 +31,7 @@ export interface SNRTooltipProps {
|
||||
pos: { x: number; y: number };
|
||||
snr: number;
|
||||
from: string;
|
||||
to: string;
|
||||
to: string | undefined;
|
||||
}
|
||||
|
||||
type NeighborPlus = Protobuf.Mesh.Neighbor & {
|
||||
@@ -64,7 +64,11 @@ type Pair = {
|
||||
ba?: number; // SNR b->a
|
||||
};
|
||||
|
||||
function arcSegment(aLngLat: LngLat, bLngLat: LngLat, curved: boolean): LngLat[] | undefined {
|
||||
function arcSegment(
|
||||
aLngLat: LngLat,
|
||||
bLngLat: LngLat,
|
||||
curved: boolean,
|
||||
): LngLat[] | undefined {
|
||||
if (!curved && distanceMeters(aLngLat, bLngLat) < MIN_LEN) {
|
||||
// Straight line
|
||||
return [aLngLat, bLngLat];
|
||||
@@ -176,7 +180,9 @@ function pushIfFeature(
|
||||
}
|
||||
}
|
||||
|
||||
function generateNeighborLines(neighborInfos: NeighborInfos[]): FeatureCollection {
|
||||
function generateNeighborLines(
|
||||
neighborInfos: NeighborInfos[],
|
||||
): FeatureCollection {
|
||||
// Collect positions for all referenced nodes, discard pairs with missing positions
|
||||
const idToLngLat = new Map<number, LngLat>();
|
||||
const ensure = (node?: Protobuf.Mesh.NodeInfo | NeighborPlus) => {
|
||||
@@ -240,7 +246,12 @@ function generateNeighborLines(neighborInfos: NeighborInfos[]): FeatureCollectio
|
||||
return { type: "FeatureCollection", features };
|
||||
}
|
||||
|
||||
export const SNRTooltip = ({ pos, snr, from, to }: Partial<SNRTooltipProps> = {}) => {
|
||||
export const SNRTooltip = ({
|
||||
pos,
|
||||
snr,
|
||||
from,
|
||||
to,
|
||||
}: Partial<SNRTooltipProps> = {}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!pos) {
|
||||
@@ -290,7 +301,9 @@ export const SNRLayer = ({
|
||||
neighborInfo: {
|
||||
...neighborInfo,
|
||||
neighbors: neighborInfo.neighbors.map((n) => {
|
||||
const node = filteredNodes.find((node) => node.num === n.nodeId);
|
||||
const node = filteredNodes.find(
|
||||
(node) => node.num === n.nodeId,
|
||||
);
|
||||
return { ...n, num: node?.num, position: node?.position };
|
||||
}),
|
||||
},
|
||||
@@ -312,7 +325,10 @@ export const SNRLayer = ({
|
||||
}))
|
||||
: [];
|
||||
|
||||
const featureCollection = generateNeighborLines([...remotePairs, ...directPairs]);
|
||||
const featureCollection = generateNeighborLines([
|
||||
...remotePairs,
|
||||
...directPairs,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Source type="geojson" data={featureCollection}>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PopupWrapper } from "@components/PageComponents/Map/Popups/PopupWrapper
|
||||
import { WaypointDetail } from "@components/PageComponents/Map/Popups/WaypointDetail.tsx";
|
||||
import { useMapFitting } from "@core/hooks/useMapFitting";
|
||||
import { useDevice, type WaypointWithMetadata } from "@core/stores";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import type { Protobuf } from "@meshtastic/sdk";
|
||||
import { useCallback } from "react";
|
||||
import type { MapRef } from "react-map-gl/maplibre";
|
||||
|
||||
@@ -82,7 +82,11 @@ export const WaypointLayer = ({
|
||||
offset={[0, 25]}
|
||||
onClose={() => setPopupState(undefined)}
|
||||
>
|
||||
<WaypointDetail waypoint={popupState.waypoint} myNode={myNode} onEdit={() => {}} />
|
||||
<WaypointDetail
|
||||
waypoint={popupState.waypoint}
|
||||
myNode={myNode}
|
||||
onEdit={() => {}}
|
||||
/>
|
||||
</PopupWrapper>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,13 @@ type StackBadgeProps = {
|
||||
onClick: (e: { originalEvent: MouseEvent }) => void;
|
||||
};
|
||||
|
||||
export const StackBadge = ({ lng, lat, count, isVisible = true, onClick }: StackBadgeProps) => {
|
||||
export const StackBadge = ({
|
||||
lng,
|
||||
lat,
|
||||
count,
|
||||
isVisible = true,
|
||||
onClick,
|
||||
}: StackBadgeProps) => {
|
||||
return (
|
||||
<Marker
|
||||
longitude={lng}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Separator } from "@components/UI/Separator.tsx";
|
||||
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||
import { formatQuantity } from "@core/utils/string.ts";
|
||||
import type { Protobuf as ProtobufType } from "@meshtastic/core";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import type { Protobuf as ProtobufType } from "@meshtastic/sdk";
|
||||
import { Protobuf } from "@meshtastic/sdk";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -16,7 +16,14 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@radix-ui/react-tooltip";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { Dot, LockIcon, LockOpenIcon, MessageSquareIcon, MountainSnow, Star } from "lucide-react";
|
||||
import {
|
||||
Dot,
|
||||
LockIcon,
|
||||
LockOpenIcon,
|
||||
MessageSquareIcon,
|
||||
MountainSnow,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface NodeDetailProps {
|
||||
@@ -38,9 +45,16 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
: rawHardwareType.replaceAll("_", " ")
|
||||
: `${hwModel}`;
|
||||
// SNR is in dB; derive a 0–100% quality heuristic (-10 dB → 0%, +10 dB → 100%).
|
||||
const snrQuality = Math.min(Math.max(Math.round((node.snr + 10) * 5), 0), 100);
|
||||
const snrQuality = Math.min(
|
||||
Math.max(Math.round((node.snr + 10) * 5), 0),
|
||||
100,
|
||||
);
|
||||
const snrTone =
|
||||
snrQuality >= 67 ? "text-green-600" : snrQuality >= 34 ? "text-yellow-600" : "text-red-600";
|
||||
snrQuality >= 67
|
||||
? "text-green-600"
|
||||
: snrQuality >= 34
|
||||
? "text-yellow-600"
|
||||
: "text-red-600";
|
||||
function handleDirectMessage() {
|
||||
navigate({ to: `/messages/direct/${node.num}` });
|
||||
}
|
||||
@@ -101,7 +115,9 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
fill={node.isFavorite ? "black" : "none"}
|
||||
size={15}
|
||||
aria-label={
|
||||
node.isFavorite ? t("nodeDetail.favorite.label") : t("nodeDetail.notFavorite.label")
|
||||
node.isFavorite
|
||||
? t("nodeDetail.favorite.label")
|
||||
: t("nodeDetail.notFavorite.label")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -122,12 +138,15 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
|
||||
<div
|
||||
className="flex gap-1"
|
||||
title={new Date(node.lastHeard * 1000).toLocaleString(navigator.language)}
|
||||
title={new Date(node.lastHeard * 1000).toLocaleString(
|
||||
navigator.language,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{node.lastHeard > 0 && (
|
||||
<div>
|
||||
{t("nodeDetail.status.heard")} <TimeAgo timestamp={node.lastHeard * 1000} />
|
||||
{t("nodeDetail.status.heard")}{" "}
|
||||
<TimeAgo timestamp={node.lastHeard * 1000} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -145,9 +164,13 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
<div className="flex mt-2 text-sm">
|
||||
<div className="flex items-center grow">
|
||||
<div className="border-2 border-slate-900 rounded-sm px-0.5 mr-1">
|
||||
{Number.isNaN(node.hopsAway) ? t("unit.hopsAway.unknown") : node.hopsAway}
|
||||
{Number.isNaN(node.hopsAway)
|
||||
? t("unit.hopsAway.unknown")
|
||||
: node.hopsAway}
|
||||
</div>
|
||||
<div>
|
||||
{node.hopsAway === 1 ? t("unit.hop.one") : t("unit.hop.plural")}
|
||||
</div>
|
||||
<div>{node.hopsAway === 1 ? t("unit.hop.one") : t("unit.hop.plural")}</div>
|
||||
</div>
|
||||
{node.position?.altitude && (
|
||||
<div className="flex items-center grow">
|
||||
@@ -170,13 +193,17 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
{!!node.deviceMetrics?.channelUtilization && (
|
||||
<div className="grow">
|
||||
<div>{t("channelUtilization.short")}</div>
|
||||
<Mono>{node.deviceMetrics?.channelUtilization.toPrecision(3)}%</Mono>
|
||||
<Mono>
|
||||
{node.deviceMetrics?.channelUtilization.toPrecision(3)}%
|
||||
</Mono>
|
||||
</div>
|
||||
)}
|
||||
{!!node.deviceMetrics?.airUtilTx && (
|
||||
<div className="grow">
|
||||
<div>{t("airtimeUtilization.short")}</div>
|
||||
<Mono className="text-gray-500">{node.deviceMetrics?.airUtilTx.toPrecision(3)}%</Mono>
|
||||
<Mono className="text-gray-500">
|
||||
{node.deviceMetrics?.airUtilTx.toPrecision(3)}%
|
||||
</Mono>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { TimeAgo } from "@components/generic/TimeAgo";
|
||||
import { Separator } from "@components/UI/Separator.tsx";
|
||||
import { useNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
|
||||
import type { WaypointWithMetadata } from "@core/stores";
|
||||
import { useNodeDB } from "@core/stores";
|
||||
import { bearingDegrees, distanceMeters, hasPos, toLngLat } from "@core/utils/geo";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import {
|
||||
bearingDegrees,
|
||||
distanceMeters,
|
||||
hasPos,
|
||||
toLngLat,
|
||||
} from "@core/utils/geo";
|
||||
import type { Protobuf } from "@meshtastic/sdk";
|
||||
import {
|
||||
ClockFadingIcon,
|
||||
ClockPlusIcon,
|
||||
@@ -24,7 +29,7 @@ interface WaypointDetailProps {
|
||||
|
||||
export const WaypointDetail = ({ waypoint, myNode }: WaypointDetailProps) => {
|
||||
const { t } = useTranslation("map");
|
||||
const { getNode } = useNodeDB();
|
||||
const lockedToNode = useNodeAsProto(waypoint.lockedTo ?? 0);
|
||||
|
||||
const waypointLngLat = toLngLat({
|
||||
latitudeI: waypoint.latitudeI,
|
||||
@@ -83,10 +88,14 @@ export const WaypointDetail = ({ waypoint, myNode }: WaypointDetailProps) => {
|
||||
<div className="flex flex-wrap items-start gap-x-3">
|
||||
<dt className="inline-flex items-center gap-2 text-slate-500 min-w-0">
|
||||
<ClockPlusIcon size={14} aria-hidden />
|
||||
<span className="truncate">{t("waypointDetail.createdDate")}</span>
|
||||
<span className="truncate">
|
||||
{t("waypointDetail.createdDate")}
|
||||
</span>
|
||||
</dt>
|
||||
<dd className="ms-auto text-right">
|
||||
<time dateTime={new Date(waypoint.metadata.created).toISOString()}>
|
||||
<time
|
||||
dateTime={new Date(waypoint.metadata.created).toISOString()}
|
||||
>
|
||||
<TimeAgo timestamp={waypoint.metadata.created} />
|
||||
</time>
|
||||
</dd>
|
||||
@@ -100,7 +109,9 @@ export const WaypointDetail = ({ waypoint, myNode }: WaypointDetailProps) => {
|
||||
<span className="truncate">{t("waypointDetail.updated")}</span>
|
||||
</dt>
|
||||
<dd className="ms-auto text-right">
|
||||
<time dateTime={new Date(waypoint.metadata.updated).toISOString()}>
|
||||
<time
|
||||
dateTime={new Date(waypoint.metadata.updated).toISOString()}
|
||||
>
|
||||
<TimeAgo timestamp={waypoint.metadata.updated} />
|
||||
</time>
|
||||
</dd>
|
||||
@@ -132,7 +143,9 @@ export const WaypointDetail = ({ waypoint, myNode }: WaypointDetailProps) => {
|
||||
<dd className="ms-auto text-right">
|
||||
<data value={Math.round(distance)}>
|
||||
{Math.round(distance)}{" "}
|
||||
{distance === 1 ? t("unit.meter.one") : t("unit.meter.plural")}
|
||||
{distance === 1
|
||||
? t("unit.meter.one")
|
||||
: t("unit.meter.plural")}
|
||||
</data>
|
||||
</dd>
|
||||
</div>
|
||||
@@ -166,7 +179,7 @@ export const WaypointDetail = ({ waypoint, myNode }: WaypointDetailProps) => {
|
||||
<span className="truncate">{t("waypointDetail.lockedTo")}</span>
|
||||
</dt>
|
||||
<dd className="ms-auto text-right">
|
||||
{getNode(waypoint.lockedTo)?.user?.longName ?? t("unknown.longName")}
|
||||
{lockedToNode?.user?.longName ?? t("unknown.longName")}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { HeatmapMode } from "@components/PageComponents/Map/Layers/HeatmapLayer.tsx";
|
||||
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/UI/Popover.tsx";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@components/UI/Popover.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { LayersIcon } from "lucide-react";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
@@ -40,7 +44,12 @@ interface CheckboxProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CheckboxItem = ({ label, checked, onChange, className }: CheckboxProps) => {
|
||||
const CheckboxItem = ({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
className,
|
||||
}: CheckboxProps) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
@@ -140,7 +149,9 @@ export function MapLayerTool({
|
||||
<CheckboxItem
|
||||
label={label}
|
||||
checked={visibilityState[key as keyof VisibilityState]}
|
||||
onChange={() => handleCheckboxChange(key as keyof VisibilityState)}
|
||||
onChange={() =>
|
||||
handleCheckboxChange(key as keyof VisibilityState)
|
||||
}
|
||||
/>
|
||||
{key === "heatmap" && visibilityState.heatmap && (
|
||||
<div className="pl-6 pt-2 flex flex-col gap-1">
|
||||
@@ -151,7 +162,9 @@ export function MapLayerTool({
|
||||
checked={heatmapMode === "density"}
|
||||
onChange={() => setHeatmapMode("density")}
|
||||
/>
|
||||
<span className="text-sm dark:text-slate-300">{t("layerTool.density")}</span>
|
||||
<span className="text-sm dark:text-slate-300">
|
||||
{t("layerTool.density")}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import type { Protobuf } from "@meshtastic/sdk";
|
||||
|
||||
export type ClusterKey = string;
|
||||
export type PxOffset = [number, number];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
|
||||
import { Separator } from "@components/UI/Separator";
|
||||
import { Skeleton } from "@components/UI/Skeleton.tsx";
|
||||
import type { Message } from "@core/stores/messageStore/types.ts";
|
||||
import type { Message } from "@core/stores/messageStore";
|
||||
import type { TFunction } from "i18next";
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import { Fragment, Suspense, useMemo } from "react";
|
||||
@@ -68,14 +68,15 @@ const DateDelimiter = ({ label }: { label: string }) => (
|
||||
<li aria-label={label}>
|
||||
<div className="my-2 flex h-3 items-center justify-center">
|
||||
<Separator className="bg-slate-100 dark:bg-slate-800" />
|
||||
<div className="mx-5 whitespace-nowrap text-center text-xs text-slate-400">{label}</div>
|
||||
<div className="mx-5 whitespace-nowrap text-center text-xs text-slate-400">
|
||||
{label}
|
||||
</div>
|
||||
<Separator className="bg-slate-100 dark:bg-slate-800" />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
const MessageSkeleton = () => {
|
||||
console.log("[ChannelChat] Showing MessageSkeleton (Suspense fallback)");
|
||||
return (
|
||||
<li className="group w-full py-2 relative list-none rounded-md">
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-2">
|
||||
@@ -106,7 +107,9 @@ export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
|
||||
const { i18n, t } = useTranslation();
|
||||
|
||||
const locale = useMemo(
|
||||
() => i18n.language || (typeof navigator !== "undefined" ? navigator.language : "en-US"),
|
||||
() =>
|
||||
i18n.language ||
|
||||
(typeof navigator !== "undefined" ? navigator.language : "en-US"),
|
||||
[i18n.language],
|
||||
);
|
||||
|
||||
|
||||
@@ -14,7 +14,10 @@ interface MessageActionsMenuProps {
|
||||
onReply?: () => void;
|
||||
}
|
||||
|
||||
export const MessageActionsMenu = ({ onAddReaction, onReply }: MessageActionsMenuProps) => {
|
||||
export const MessageActionsMenu = ({
|
||||
onAddReaction,
|
||||
onReply,
|
||||
}: MessageActionsMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
const hoverIconBarClass = cn(
|
||||
"absolute top-2 right-2",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user