Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2f03aaf81 | ||
|
|
6c676fa8da | ||
|
|
183b3ae8cc | ||
|
|
43143bfdf6 | ||
|
|
c6a564f7e4 | ||
|
|
762aed50b7 | ||
|
|
bb91350ef5 | ||
|
|
ec9b299b37 | ||
|
|
0e6a4818ea | ||
|
|
181c984b27 | ||
|
|
7adbe01723 | ||
|
|
a5339af0dd | ||
|
|
c91e5e6b7b | ||
|
|
ccc4202aa4 | ||
|
|
118f848308 | ||
|
|
c36ff60778 | ||
|
|
474e610c3d | ||
|
|
0b6ae0ce32 | ||
|
|
fad1b984bf | ||
|
|
6cc6986904 | ||
|
|
26d5c0a08a | ||
|
|
78e1d1f81a | ||
|
|
48862141dc | ||
|
|
47f8264c31 | ||
|
|
851da0707c | ||
|
|
4275bdd0c0 | ||
|
|
828e5d0903 | ||
|
|
1cbd98ec53 | ||
|
|
df036d3904 | ||
|
|
08dbe94679 | ||
|
|
24e9764fcb | ||
|
|
eb2a2717b1 | ||
|
|
9d74fe2d6e | ||
|
|
8413f6345c | ||
|
|
37a53b747c | ||
|
|
a642080b90 | ||
|
|
9cb5cffdb1 | ||
|
|
57b9942cce | ||
|
|
1274584497 |
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
if [ "$SKIP_SIMPLE_GIT_HOOKS" = "1" ]; then
|
||||||
|
echo "[INFO] SKIP_SIMPLE_GIT_HOOKS is set to 1, skipping hook."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$SIMPLE_GIT_HOOKS_RC" ]; then
|
||||||
|
. "$SIMPLE_GIT_HOOKS_RC"
|
||||||
|
fi
|
||||||
|
|
||||||
|
deno task lint:fix && deno task format
|
||||||
@@ -1,48 +1,50 @@
|
|||||||
<!--
|
<!--
|
||||||
Thank you for your contribution to our project! Please fill out the following template to help reviewers understand your changes.
|
Thank you for your contribution to our project!
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Provide a clear and concise description of what this PR does. Explain the problem it solves or the feature it adds.
|
Provide a clear and concise description of what this PR does. Explain the problem it solves or the feature it adds.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## Related Issues
|
## Related Issues
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Link any related issues here using the GitHub syntax: "Fixes #123" or "Relates to #456".
|
Link any related issues here using the GitHub syntax: "Fixes #123" or "Relates to #456".
|
||||||
If there are no related issues, you can remove this section.
|
If there are no related issues, you can remove this section.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## Changes Made
|
## Changes Made
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
List the key changes you've made. Focus on the most important aspects that reviewers should understand.
|
List the key changes you've made. Focus on the most important aspects that reviewers should understand.
|
||||||
-->
|
-->
|
||||||
-
|
|
||||||
-
|
-
|
||||||
-
|
-
|
||||||
-
|
-
|
||||||
|
|
||||||
## Testing Done
|
## Testing Done
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Describe how you tested these changes.
|
Describe how you tested these changes (added new tests, etc).
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## Screenshots (if applicable)
|
## Screenshots (if applicable)
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
If your changes affect the UI, include screenshots or screencasts showing the before and after.
|
If your changes affect the UI, include screenshots or screencasts showing the before and after.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Check all that apply. If an item doesn't apply to your PR, you can leave it unchecked or remove it.
|
Check all that apply. If an item doesn't apply to your PR, you can leave it unchecked or remove it.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
- [ ] Code follows project style guidelines
|
- [ ] Code follows project style guidelines
|
||||||
- [ ] Documentation has been updated or added
|
- [ ] Documentation has been updated or added
|
||||||
- [ ] Tests have been added or updated
|
- [ ] Tests have been added or updated
|
||||||
- [ ] All CI checks pass
|
- [ ] All i18n translation labels have been added (read
|
||||||
- [ ] Dependent changes have been merged
|
CONTRIBUTING_I18N_DEVELOPER_GUIDE.md for more details)
|
||||||
|
|
||||||
## Additional Notes
|
|
||||||
<!--
|
|
||||||
Add any other context about the PR here.
|
|
||||||
-->
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
name: Push to Master/Main CI
|
name: Push to Main CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- master
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: Crowdin Download Translations Action
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule: # Every Sunday at midnight
|
||||||
|
- cron: '0 0 * * 0'
|
||||||
|
workflow_dispatch: # Allow manual triggering
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
synchronize-with-crowdin:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download translations with Crowdin
|
||||||
|
uses: crowdin/github-action@v2
|
||||||
|
with:
|
||||||
|
base_url: 'https://meshtastic.crowdin.com/api/v2'
|
||||||
|
config: 'crowdin.yml'
|
||||||
|
upload_sources: false
|
||||||
|
upload_translations: false
|
||||||
|
download_translations: true
|
||||||
|
localization_branch_name: i18n_crowdin_translations
|
||||||
|
commit_message: 'chore(i18n): New Crowdin Translations by GitHub Action'
|
||||||
|
create_pull_request: true
|
||||||
|
pull_request_title: 'chore(i18n): New Crowdin Translations'
|
||||||
|
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
|
||||||
|
pull_request_base_branch_name: 'main'
|
||||||
|
pull_request_labels: 'i18n'
|
||||||
|
crowdin_branch_name: 'main'
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||||
|
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
name: Crowdin Upload Sources Action
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
# Monitor all .json files within the /src/i18n/locales/en/ directory.
|
||||||
|
# This ensures the workflow triggers if any the English namespace files are modified on the main branch.
|
||||||
|
paths:
|
||||||
|
- "/src/i18n/locales/en/*.json"
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch: # Allow manual triggering
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
synchronize-with-crowdin:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Upload sources with Crowdin
|
||||||
|
uses: crowdin/github-action@v2
|
||||||
|
with:
|
||||||
|
base_url: "https://meshtastic.crowdin.com/api/v2"
|
||||||
|
config: "crowdin.yml"
|
||||||
|
upload_sources: true
|
||||||
|
upload_translations: false
|
||||||
|
download_translations: false
|
||||||
|
crowdin_branch_name: "main"
|
||||||
|
|
||||||
|
env:
|
||||||
|
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||||
|
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: Crowdin Upload Translations Action
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch: # Allow manual triggering
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
synchronize-with-crowdin:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Upload translations with Crowdin
|
||||||
|
uses: crowdin/github-action@v2
|
||||||
|
with:
|
||||||
|
base_url: "https://meshtastic.crowdin.com/api/v2"
|
||||||
|
config: "crowdin.yml"
|
||||||
|
upload_sources: false
|
||||||
|
upload_translations: true
|
||||||
|
download_translations: false
|
||||||
|
crowdin_branch_name: "main"
|
||||||
|
env:
|
||||||
|
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||||
|
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||||
@@ -31,11 +31,26 @@ jobs:
|
|||||||
- name: Cache Dependencies
|
- name: Cache Dependencies
|
||||||
run: deno cache src/index.tsx
|
run: deno cache src/index.tsx
|
||||||
|
|
||||||
- name: Run linter
|
- name: Get changed files
|
||||||
run: deno task lint
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v46
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
**/*.ts
|
||||||
|
**/*.tsx
|
||||||
|
|
||||||
- name: Check formatter
|
# Uncomment the following lines when you have figured out how to ignore files
|
||||||
run: deno task format --check
|
# - name: Type check changed files
|
||||||
|
# if: steps.changed-files.outputs.all_changed_files != ''
|
||||||
|
# run: deno check ${{ steps.changed-files.outputs.all_changed_files }}
|
||||||
|
|
||||||
|
- name: Run linter on changed files
|
||||||
|
if: steps.changed-files.outputs.all_changed_files != ''
|
||||||
|
run: deno task lint ${{ steps.changed-files.outputs.all_changed_files }}
|
||||||
|
|
||||||
|
- name: Check format on changed files
|
||||||
|
if: steps.changed-files.outputs.all_changed_files != ''
|
||||||
|
run: deno task format --check ${{ steps.changed-files.outputs.all_changed_files }}
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: deno task test
|
run: deno task test
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Update Stable Branch from Master on Latest Release
|
name: Update Stable Branch from Main on Latest Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
@@ -9,7 +9,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-stable-branch:
|
update-stable-branch:
|
||||||
name: Update Stable Branch from Master
|
name: Update Stable Branch from Main
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -24,14 +24,14 @@ jobs:
|
|||||||
git config user.name "GitHub Actions Bot"
|
git config user.name "GitHub Actions Bot"
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
- name: Fetch latest master and stable branches
|
- name: Fetch latest main and stable branches
|
||||||
run: |
|
run: |
|
||||||
git fetch origin master:master
|
git fetch origin main:main
|
||||||
git fetch origin stable:stable || echo "Stable branch not found remotely, will create."
|
git fetch origin stable:stable || echo "Stable branch not found remotely, will create."
|
||||||
|
|
||||||
- name: Get latest master commit SHA
|
- name: Get latest main commit SHA
|
||||||
id: get_master_sha
|
id: get_main_sha
|
||||||
run: echo "MASTER_SHA=$(git rev-parse master)" >> $GITHUB_ENV
|
run: echo "MAIN_SHA=$(git rev-parse main)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Check out stable branch
|
- name: Check out stable branch
|
||||||
run: |
|
run: |
|
||||||
@@ -39,12 +39,12 @@ jobs:
|
|||||||
git checkout stable
|
git checkout stable
|
||||||
git pull origin stable # Sync with remote stable if it exists
|
git pull origin stable # Sync with remote stable if it exists
|
||||||
else
|
else
|
||||||
echo "Creating local stable branch based on master HEAD."
|
echo "Creating local stable branch based on main HEAD."
|
||||||
git checkout -b stable ${{ env.MASTER_SHA }}
|
git checkout -b stable ${{ env.MAIN_SHA }}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Reset stable branch to latest master
|
- name: Reset stable branch to latest main
|
||||||
run: git reset --hard ${{ env.MASTER_SHA }}
|
run: git reset --hard ${{ env.MAIN_SHA }}
|
||||||
|
|
||||||
- name: Force push stable branch
|
- name: Force push stable branch
|
||||||
run: git push origin stable --force
|
run: git push origin stable --force
|
||||||
@@ -5,3 +5,4 @@ stats.html
|
|||||||
.vite
|
.vite
|
||||||
dev-dist
|
dev-dist
|
||||||
__screenshots__*
|
__screenshots__*
|
||||||
|
*.diff
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "src/core/connection"]
|
||||||
|
path = src/core/connection
|
||||||
|
url = https://github.com/meshtastic/js.git
|
||||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"recommendations": ["bradlc.vscode-tailwindcss", "biomejs.biome"]
|
"recommendations": ["bradlc.vscode-tailwindcss", "denoland.vscode-deno"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# i18n Developer Guide
|
||||||
|
|
||||||
|
When developing new components, all user-facing text must be added as an i18n
|
||||||
|
key and rendered using our translation functions. This ensures your UI can be
|
||||||
|
translated into multiple languages.
|
||||||
|
|
||||||
|
## Adding New i18n Keys
|
||||||
|
|
||||||
|
### Search Before Creating
|
||||||
|
|
||||||
|
Before adding a new key, please perform a quick search to see if one that fits
|
||||||
|
your needs already exists. Many common labels like "Save," "Cancel," "Name,"
|
||||||
|
"Description," "Loading...," or "Error" are likely already present, especially
|
||||||
|
in the common.json namespace. Reusing existing keys prevents duplication and
|
||||||
|
ensures consistency across the application. Using your code editor's search
|
||||||
|
function across the /src/i18n/locales/en/ directory is an effective way to do
|
||||||
|
this.
|
||||||
|
|
||||||
|
### Key Naming and Structure Rules
|
||||||
|
|
||||||
|
To maintain consistency and ease of use, please adhere to the following rules
|
||||||
|
when creating new keys in the JSON files.
|
||||||
|
|
||||||
|
- **Keys are camelCase:** `exampleKey`, `anotherExampleKey`.
|
||||||
|
- **Avoid Deep Nesting:** One or two levels of nesting are acceptable for
|
||||||
|
grouping related keys (e.g., all labels for a specific menu). However, nesting
|
||||||
|
deeper than two levels should be avoided to maintain readability and ease of
|
||||||
|
use.
|
||||||
|
- **Good (1 level):**
|
||||||
|
```json
|
||||||
|
"buttons": {
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Acceptable (2 levels):**
|
||||||
|
```json
|
||||||
|
"userMenu": {
|
||||||
|
"items": {
|
||||||
|
"profile": "Profile",
|
||||||
|
"settings": "Settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Avoid (3+ levels):**
|
||||||
|
```json
|
||||||
|
"userMenu": {
|
||||||
|
"items": {
|
||||||
|
"actions": {
|
||||||
|
"viewProfile": "View Profile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Organize for Retrieval, Not UI Layout:** Keys should be named logically for
|
||||||
|
easy retrieval, not to mirror the layout of your component.
|
||||||
|
|
||||||
|
### Namespace Rules
|
||||||
|
|
||||||
|
We use namespaces to organize keys. All source keys are added to the English
|
||||||
|
(`en`) files located at `/src/i18n/locales/en/`. Place your new keys in the
|
||||||
|
appropriate file based on these rules:
|
||||||
|
|
||||||
|
- `common.json`:
|
||||||
|
- All button labels (`save`, `cancel`, `submit`, etc.).
|
||||||
|
- Any text that is repeated and used throughout the application (e.g.,
|
||||||
|
"Loading...", "Error").
|
||||||
|
- `ui.json`:
|
||||||
|
- Labels and text specific to a distinct UI element or view that isn't a
|
||||||
|
dialog or a config page.
|
||||||
|
- `dialog.json`:
|
||||||
|
- All text specific to modal dialogs (titles, body text, prompts).
|
||||||
|
- `messages.json`:
|
||||||
|
- Text specifically related to the messaging interface.
|
||||||
|
- `deviceConfig.json` & `moduleConfig.json`:
|
||||||
|
- Labels and descriptions for the settings on the Device and Module
|
||||||
|
configuration pages.
|
||||||
|
|
||||||
|
## Using i18n Keys in Components
|
||||||
|
|
||||||
|
We use the `useTranslation` hook from `react-i18next` to access the translation
|
||||||
|
function, `t`.
|
||||||
|
|
||||||
|
### Default Namespaces
|
||||||
|
|
||||||
|
Our i18next configuration has fallback namespaces configured which includes
|
||||||
|
`common`, `ui`, and `dialog`. This means you **do not** need to explicitly
|
||||||
|
specify these namespaces when calling the hook. The system will automatically
|
||||||
|
check these files for your key.
|
||||||
|
|
||||||
|
For any keys in `common.json`, `ui.json`, or `dialog.json`, you can instantiate
|
||||||
|
the hook simply:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
// In your component
|
||||||
|
const { t } = useTranslation(["messages"]);
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
return <p>{t("someMessageLabel")}</p>;
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also specify the namespace on a per-call basis using the options object.
|
||||||
|
This is useful if a component primarily uses a default namespace but needs a
|
||||||
|
single key from another.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return <p>{t("someMessageLabel", { ns: "messages" })}</p>;
|
||||||
|
```
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Contributing Translations
|
||||||
|
|
||||||
|
Thank you for your interest in making the Meshtastic Web Client accessible to a
|
||||||
|
global audience! Your translation efforts are greatly appreciated.
|
||||||
|
|
||||||
|
## Our Translation Platform: Crowdin
|
||||||
|
|
||||||
|
We manage all our translations through a platform called
|
||||||
|
[Crowdin](https://crowdin.com/). This allows for a collaborative and streamlined
|
||||||
|
translation process. All translation work should be done on our Crowdin project,
|
||||||
|
not directly in the code repository via Pull Requests.
|
||||||
|
|
||||||
|
### How to Get Started
|
||||||
|
|
||||||
|
1. **Create a Crowdin Account:** If you don't already have one, sign up for a
|
||||||
|
free account on Crowdin.
|
||||||
|
2. **Join Our Project:** Please ask for a link to our specific Crowdin project
|
||||||
|
on the Meshtastic Discord.
|
||||||
|
3. **Request Translator Role:** Once you have an account, join the Meshtastic
|
||||||
|
Discord and notify an admin in the `#web` channel. They will grant you the
|
||||||
|
necessary permissions to start translating.
|
||||||
|
4. **Start Translating:** Once you have your role, you can begin translating the
|
||||||
|
source labels into your native language directly on the Crowdin platform.
|
||||||
|
|
||||||
|
### Language Activation
|
||||||
|
|
||||||
|
A new language will only be added to the web client and appear in the language
|
||||||
|
picker once its translation is 100% complete on Crowdin. The repository
|
||||||
|
maintainers will handle this process once the milestone is reached.
|
||||||
|
|
||||||
|
Thank you for helping us bring Meshtastic to more users around the world!
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<!--Project specific badges here-->
|
<!--Project specific badges here-->
|
||||||
|
|
||||||
[](https://github.com/meshtastic/web/actions/workflows/ci.yml)
|
[](https://github.com/meshtastic/web/actions/workflows/ci.yml)
|
||||||
[](https://cla-assistant.io/meshtastic/web)
|
[](https://cla-assistant.io/meshtastic/web)
|
||||||
[](https://opencollective.com/meshtastic/)
|
[](https://opencollective.com/meshtastic/)
|
||||||
[](https://vercel.com?utm_source=meshtastic&utm_campaign=oss)
|
[](https://vercel.com?utm_source=meshtastic&utm_campaign=oss)
|
||||||
@@ -61,7 +61,7 @@ Our release process follows these guidelines:
|
|||||||
early adoption.
|
early adoption.
|
||||||
- **Nightly Builds:** An experimental Docker image containing the latest
|
- **Nightly Builds:** An experimental Docker image containing the latest
|
||||||
cutting-edge features and fixes is automatically built nightly from the
|
cutting-edge features and fixes is automatically built nightly from the
|
||||||
`master` branch.
|
`main` branch.
|
||||||
|
|
||||||
### Nightly Builds
|
### Nightly Builds
|
||||||
|
|
||||||
@@ -170,6 +170,6 @@ requests:
|
|||||||
Meshtastic nodes.
|
Meshtastic nodes.
|
||||||
|
|
||||||
Please review our
|
Please review our
|
||||||
[Contribution Guidelines](https://github.com/meshtastic/web/blob/master/CONTRIBUTING.md)
|
[Contribution Guidelines](https://github.com/meshtastic/web/blob/main/CONTRIBUTING.md)
|
||||||
before submitting a pull request. We appreciate your help in making the project
|
before submitting a pull request. We appreciate your help in making the project
|
||||||
better!
|
better!
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
project_id_env: CROWDIN_PROJECT_ID
|
||||||
|
api_token_env: CROWDIN_PERSONAL_TOKEN
|
||||||
|
base_path: "."
|
||||||
|
base_url: "https://meshtastic.crowdin.com/api/v2"
|
||||||
|
|
||||||
|
preserve_hierarchy: true
|
||||||
|
|
||||||
|
files:
|
||||||
|
- source: "/src/i18n/locales/en/*.json"
|
||||||
|
translation: "/src/i18n/locales/%locale%/%original_file_name%"
|
||||||
@@ -4,8 +4,10 @@
|
|||||||
"@pages/": "./src/pages/",
|
"@pages/": "./src/pages/",
|
||||||
"@components/": "./src/components/",
|
"@components/": "./src/components/",
|
||||||
"@core/": "./src/core/",
|
"@core/": "./src/core/",
|
||||||
"@layouts/": "./src/layouts/"
|
"@layouts/": "./src/layouts/",
|
||||||
|
"@std/path": "jsr:@std/path@^1.1.0"
|
||||||
},
|
},
|
||||||
|
"include": ["src", "./vite-env.d.ts"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": [
|
"lib": [
|
||||||
"DOM",
|
"DOM",
|
||||||
@@ -23,24 +25,35 @@
|
|||||||
"types": [
|
"types": [
|
||||||
"vite/client",
|
"vite/client",
|
||||||
"node",
|
"node",
|
||||||
"@types/web-bluetooth",
|
"npm:@types/w3c-web-serial",
|
||||||
"@types/w3c-web-serial"
|
"npm:@types/web-bluetooth"
|
||||||
],
|
],
|
||||||
"strictPropertyInitialization": false
|
"strictPropertyInitialization": false
|
||||||
},
|
},
|
||||||
"fmt": {
|
"fmt": {
|
||||||
"exclude": [
|
"exclude": [
|
||||||
|
"src/routeTree.gen.ts",
|
||||||
"*.test.ts",
|
"*.test.ts",
|
||||||
"*.test.tsx"
|
"*.test.tsx"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"exclude": [
|
"exclude": [
|
||||||
|
"src/routeTree.gen.ts",
|
||||||
"*.test.ts",
|
"*.test.ts",
|
||||||
"*.test.tsx"
|
"*.test.tsx"
|
||||||
],
|
],
|
||||||
"report": "pretty"
|
"report": "pretty"
|
||||||
},
|
},
|
||||||
|
"exclude": [
|
||||||
|
"routeTree.gen.ts",
|
||||||
|
"node_modules/",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"coverage",
|
||||||
|
"out",
|
||||||
|
".vscode-test"
|
||||||
|
],
|
||||||
"unstable": [
|
"unstable": [
|
||||||
"sloppy-imports"
|
"sloppy-imports"
|
||||||
]
|
]
|
||||||
|
|||||||
+21
-11
@@ -11,8 +11,9 @@
|
|||||||
"lint:fix": "deno lint --fix src/",
|
"lint:fix": "deno lint --fix src/",
|
||||||
"format": "deno fmt src/",
|
"format": "deno fmt src/",
|
||||||
"dev": "deno task dev:ui",
|
"dev": "deno task dev:ui",
|
||||||
"dev:ui": "deno run -A npm:vite dev",
|
"dev:ui": "VITE_APP_VERSION=development deno run -A npm:vite dev",
|
||||||
"test": "deno run -A npm:vitest",
|
"test": "deno run -A npm:vitest",
|
||||||
|
"check": "deno check",
|
||||||
"preview": "deno run -A npm:vite preview",
|
"preview": "deno run -A npm:vite preview",
|
||||||
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ."
|
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ."
|
||||||
},
|
},
|
||||||
@@ -34,12 +35,13 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://meshtastic.org",
|
"homepage": "https://meshtastic.org",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.2",
|
"@bufbuild/protobuf": "^2.2.5",
|
||||||
|
"@hookform/resolvers": "^5.1.1",
|
||||||
|
"@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.4",
|
||||||
"@meshtastic/js": "npm:@jsr/meshtastic__js@2.6.0-0",
|
"@meshtastic/js": "npm:@jsr/meshtastic__js@2.6.0-0",
|
||||||
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http",
|
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http",
|
||||||
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth",
|
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth",
|
||||||
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial",
|
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial",
|
||||||
"@bufbuild/protobuf": "^2.2.5",
|
|
||||||
"@noble/curves": "^1.9.0",
|
"@noble/curves": "^1.9.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.8",
|
"@radix-ui/react-accordion": "^1.2.8",
|
||||||
"@radix-ui/react-checkbox": "^1.2.3",
|
"@radix-ui/react-checkbox": "^1.2.3",
|
||||||
@@ -55,14 +57,21 @@
|
|||||||
"@radix-ui/react-switch": "^1.2.2",
|
"@radix-ui/react-switch": "^1.2.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.9",
|
"@radix-ui/react-tabs": "^1.1.9",
|
||||||
"@radix-ui/react-toast": "^1.2.11",
|
"@radix-ui/react-toast": "^1.2.11",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.9",
|
||||||
"@radix-ui/react-tooltip": "^1.2.4",
|
"@radix-ui/react-tooltip": "^1.2.4",
|
||||||
|
"@tanstack/react-router": "^1.120.15",
|
||||||
|
"@tanstack/react-router-devtools": "^1.120.16",
|
||||||
|
"@tanstack/router-devtools": "^1.120.15",
|
||||||
"@turf/turf": "^7.2.0",
|
"@turf/turf": "^7.2.0",
|
||||||
|
"@types/web-bluetooth": "^0.0.21",
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"class-validator": "^0.14.2",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"crypto-random-string": "^5.0.0",
|
"crypto-random-string": "^5.0.0",
|
||||||
|
"i18next": "^25.2.0",
|
||||||
|
"i18next-browser-languagedetector": "^8.1.0",
|
||||||
|
"i18next-http-backend": "^3.0.2",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
@@ -72,26 +81,26 @@
|
|||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-error-boundary": "^6.0.0",
|
"react-error-boundary": "^6.0.0",
|
||||||
"react-hook-form": "^7.56.2",
|
"react-hook-form": "^7.56.2",
|
||||||
|
"react-i18next": "^15.5.1",
|
||||||
"react-map-gl": "8.0.4",
|
"react-map-gl": "8.0.4",
|
||||||
"react-qrcode-logo": "^3.0.0",
|
"react-qrcode-logo": "^3.0.0",
|
||||||
"rfc4648": "^1.5.4",
|
"rfc4648": "^1.5.4",
|
||||||
"vite-plugin-node-polyfills": "^0.23.0",
|
"zod": "^3.25.67",
|
||||||
"zod": "^3.24.3",
|
"zustand": "5.0.5"
|
||||||
"zustand": "5.0.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.5",
|
"@tailwindcss/postcss": "^4.1.5",
|
||||||
|
"@tanstack/router-plugin": "^1.120.15",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/chrome": "^0.0.318",
|
"@types/chrome": "^0.0.318",
|
||||||
"@types/js-cookie": "^3.0.6",
|
|
||||||
"@types/node": "^22.15.3",
|
"@types/node": "^22.15.3",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.3",
|
"@types/react-dom": "^19.1.3",
|
||||||
"@types/serviceworker": "^0.0.133",
|
"@types/serviceworker": "^0.0.133",
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/w3c-web-serial": "^1.0.8",
|
"@types/w3c-web-serial": "^1.0.8",
|
||||||
"@types/web-bluetooth": "^0.0.21",
|
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"gzipper": "^8.2.1",
|
"gzipper": "^8.2.1",
|
||||||
@@ -105,7 +114,8 @@
|
|||||||
"testing-library": "^0.0.2",
|
"testing-library": "^0.0.2",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^6.3.4",
|
"vite": "^6.3.4",
|
||||||
"vitest": "^3.1.2",
|
"vite-plugin-pwa": "^1.0.0",
|
||||||
"vite-plugin-pwa": "^1.0.0"
|
"vite-plugin-static-copy": "^3.0.0",
|
||||||
|
"vitest": "^3.1.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "Meshtastic",
|
"name": "Meshtastic",
|
||||||
"short_name": "Meshtastic",
|
"short_name": "Web Client",
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
"description": "Meshtastic web app",
|
"description": "Meshtastic Web App",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icon.svg",
|
"src": "/Logo.svg",
|
||||||
"sizes": "any",
|
"sizes": "any",
|
||||||
"type": "image/svg+xml"
|
"type": "image/svg+xml"
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-5
@@ -1,5 +1,4 @@
|
|||||||
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
||||||
import { PageRouter } from "@app/PageRouter.tsx";
|
|
||||||
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
|
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
|
||||||
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
|
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
|
||||||
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
|
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
|
||||||
@@ -8,15 +7,16 @@ import Footer from "@components/UI/Footer.tsx";
|
|||||||
import { useAppStore } from "@core/stores/appStore.ts";
|
import { useAppStore } from "@core/stores/appStore.ts";
|
||||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||||
import { Dashboard } from "@pages/Dashboard/index.tsx";
|
import { Dashboard } from "@pages/Dashboard/index.tsx";
|
||||||
import type { JSX } from "react";
|
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
|
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
|
||||||
import { MapProvider } from "react-map-gl/maplibre";
|
import { MapProvider } from "react-map-gl/maplibre";
|
||||||
import { CommandPalette } from "@components/CommandPalette/index.tsx";
|
import { CommandPalette } from "@components/CommandPalette/index.tsx";
|
||||||
import { SidebarProvider } from "@core/stores/sidebarStore.tsx";
|
import { SidebarProvider } from "@core/stores/sidebarStore.tsx";
|
||||||
import { useTheme } from "@core/hooks/useTheme.ts";
|
import { useTheme } from "@core/hooks/useTheme.ts";
|
||||||
|
import { Outlet } from "@tanstack/react-router";
|
||||||
|
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||||
|
|
||||||
export const App = (): JSX.Element => {
|
export function App() {
|
||||||
const { getDevice } = useDeviceStore();
|
const { getDevice } = useDeviceStore();
|
||||||
const { selectedDevice, setConnectDialogOpen, connectDialogOpen } =
|
const { selectedDevice, setConnectDialogOpen, connectDialogOpen } =
|
||||||
useAppStore();
|
useAppStore();
|
||||||
@@ -35,6 +35,7 @@ export const App = (): JSX.Element => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
<TanStackRouterDevtools position="bottom-right" />
|
||||||
<DeviceWrapper device={device}>
|
<DeviceWrapper device={device}>
|
||||||
<div
|
<div
|
||||||
className="flex h-screen flex-col bg-background-primary text-text-primary"
|
className="flex h-screen flex-col bg-background-primary text-text-primary"
|
||||||
@@ -49,7 +50,7 @@ export const App = (): JSX.Element => {
|
|||||||
<KeyBackupReminder />
|
<KeyBackupReminder />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<MapProvider>
|
<MapProvider>
|
||||||
<PageRouter />
|
<Outlet />
|
||||||
</MapProvider>
|
</MapProvider>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -65,4 +66,4 @@ export const App = (): JSX.Element => {
|
|||||||
</DeviceWrapper>
|
</DeviceWrapper>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import MapPage from "@app/pages/Map/index.tsx";
|
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
|
||||||
import ChannelsPage from "@pages/Channels.tsx";
|
|
||||||
import ConfigPage from "@pages/Config/index.tsx";
|
|
||||||
import MessagesPage from "@pages/Messages.tsx";
|
|
||||||
import NodesPage from "@pages/Nodes.tsx";
|
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
|
||||||
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
|
|
||||||
|
|
||||||
export const ErrorBoundaryWrapper = ({
|
|
||||||
children,
|
|
||||||
}: { children: React.ReactNode }) => (
|
|
||||||
<ErrorBoundary FallbackComponent={ErrorPage}>{children}</ErrorBoundary>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const PageRouter = () => {
|
|
||||||
const { activePage } = useDevice();
|
|
||||||
return (
|
|
||||||
<ErrorBoundary FallbackComponent={ErrorPage}>
|
|
||||||
{activePage === "messages" && <MessagesPage />}
|
|
||||||
{activePage === "map" && <MapPage />}
|
|
||||||
{activePage === "config" && <ConfigPage />}
|
|
||||||
{activePage === "channels" && <ChannelsPage />}
|
|
||||||
{activePage === "nodes" && <NodesPage />}
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -5,56 +5,48 @@ import {
|
|||||||
BatteryMediumIcon,
|
BatteryMediumIcon,
|
||||||
PlugZapIcon,
|
PlugZapIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { DeviceMetrics } from "./types.ts";
|
||||||
|
|
||||||
interface DeviceMetrics {
|
type BatteryStatusKey = keyof typeof BATTERY_STATUS;
|
||||||
batteryLevel?: number | null;
|
|
||||||
voltage?: number | null;
|
interface BatteryStatusProps {
|
||||||
|
deviceMetrics?: DeviceMetrics | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BatteryStatusProps {
|
interface BatteryStatusProps {
|
||||||
deviceMetrics?: DeviceMetrics | null;
|
deviceMetrics?: DeviceMetrics | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BatteryStateConfig {
|
interface StatusConfig {
|
||||||
condition: (level: number) => boolean;
|
|
||||||
Icon: React.ElementType;
|
Icon: React.ElementType;
|
||||||
className: string;
|
className: string;
|
||||||
text: (level: number) => string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const batteryStates: BatteryStateConfig[] = [
|
const BATTERY_STATUS = {
|
||||||
{
|
PLUGGED_IN: "PLUGGED_IN",
|
||||||
condition: (level) => level > 100,
|
FULL: "FULL",
|
||||||
Icon: PlugZapIcon,
|
MEDIUM: "MEDIUM",
|
||||||
className: "text-gray-500",
|
LOW: "LOW",
|
||||||
text: () => "Plugged in",
|
} as const;
|
||||||
},
|
|
||||||
{
|
|
||||||
condition: (level) => level > 80,
|
|
||||||
Icon: BatteryFullIcon,
|
|
||||||
className: "text-green-500",
|
|
||||||
text: (level) => `${level}% charging`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
condition: (level) => level > 20,
|
|
||||||
Icon: BatteryMediumIcon,
|
|
||||||
className: "text-yellow-500",
|
|
||||||
text: (level) => `${level}% charging`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
condition: () => true,
|
|
||||||
Icon: BatteryLowIcon,
|
|
||||||
className: "text-red-500",
|
|
||||||
text: (level) => `${level}% charging`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const getBatteryState = (level: number) => {
|
export const getBatteryStatus = (level: number): BatteryStatusKey => {
|
||||||
return batteryStates.find((state) => state.condition(level));
|
if (level > 100) {
|
||||||
|
return BATTERY_STATUS.PLUGGED_IN;
|
||||||
|
}
|
||||||
|
if (level > 80) {
|
||||||
|
return BATTERY_STATUS.FULL;
|
||||||
|
}
|
||||||
|
if (level > 20) {
|
||||||
|
return BATTERY_STATUS.MEDIUM;
|
||||||
|
}
|
||||||
|
return BATTERY_STATUS.LOW;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
|
const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
deviceMetrics?.batteryLevel === undefined ||
|
deviceMetrics?.batteryLevel === undefined ||
|
||||||
deviceMetrics?.batteryLevel === null
|
deviceMetrics?.batteryLevel === null
|
||||||
@@ -62,25 +54,47 @@ const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { batteryLevel, voltage } = deviceMetrics;
|
const { batteryLevel } = deviceMetrics;
|
||||||
const currentState = getBatteryState(batteryLevel) ??
|
|
||||||
batteryStates[batteryStates.length - 1];
|
|
||||||
|
|
||||||
const BatteryIcon = currentState.Icon;
|
const statusKey = getBatteryStatus(batteryLevel);
|
||||||
const iconClassName = currentState.className;
|
|
||||||
const statusText = currentState.text(batteryLevel);
|
|
||||||
|
|
||||||
const voltageTitle = `${voltage?.toPrecision(3) ?? "Unknown"} volts`;
|
const statusConfigMap: Record<BatteryStatusKey, StatusConfig> = {
|
||||||
|
[BATTERY_STATUS.PLUGGED_IN]: {
|
||||||
|
Icon: PlugZapIcon,
|
||||||
|
className: "text-gray-500",
|
||||||
|
text: t("batteryStatus.pluggedIn"),
|
||||||
|
},
|
||||||
|
[BATTERY_STATUS.FULL]: {
|
||||||
|
Icon: BatteryFullIcon,
|
||||||
|
className: "text-green-500",
|
||||||
|
text: t("batteryStatus.charging", { level: batteryLevel }),
|
||||||
|
},
|
||||||
|
[BATTERY_STATUS.MEDIUM]: {
|
||||||
|
Icon: BatteryMediumIcon,
|
||||||
|
className: "text-yellow-500",
|
||||||
|
text: t("batteryStatus.charging", { level: batteryLevel }),
|
||||||
|
},
|
||||||
|
[BATTERY_STATUS.LOW]: {
|
||||||
|
Icon: BatteryLowIcon,
|
||||||
|
className: "text-red-500",
|
||||||
|
text: t("batteryStatus.charging", { level: batteryLevel }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Use the key to get the current state configuration
|
||||||
|
const {
|
||||||
|
Icon: BatteryIcon,
|
||||||
|
className: iconClassName,
|
||||||
|
text: statusText,
|
||||||
|
} = statusConfigMap[statusKey];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1 mt-0.5 text-gray-500"
|
className="flex items-center gap-1 mt-0.5 "
|
||||||
title={voltageTitle}
|
aria-label={t("batteryStatus.title")}
|
||||||
>
|
>
|
||||||
<BatteryIcon size={22} className={iconClassName} />
|
<BatteryIcon size={22} className={iconClassName} />
|
||||||
<Subtle aria-label="Battery">
|
|
||||||
{statusText}
|
{statusText}
|
||||||
</Subtle>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,9 +33,12 @@ import {
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||||
import { cn } from "@core/utils/cn.ts";
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
|
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
||||||
export interface Group {
|
export interface Group {
|
||||||
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
commands: Command[];
|
commands: Command[];
|
||||||
@@ -61,68 +64,72 @@ export const CommandPalette = () => {
|
|||||||
setSelectedDevice,
|
setSelectedDevice,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const { getDevices } = useDeviceStore();
|
const { getDevices } = useDeviceStore();
|
||||||
const { setDialogOpen, setActivePage, getNode, connection } = useDevice();
|
const { setDialogOpen, getNode, connection } = useDevice();
|
||||||
const { pinnedItems, togglePinnedItem } = usePinnedItems({
|
const { pinnedItems, togglePinnedItem } = usePinnedItems({
|
||||||
storageName: "pinnedCommandMenuGroups",
|
storageName: "pinnedCommandMenuGroups",
|
||||||
});
|
});
|
||||||
|
const { t } = useTranslation("commandPalette");
|
||||||
|
const navigate = useNavigate({ from: "/" });
|
||||||
|
|
||||||
const groups: Group[] = [
|
const groups: Group[] = [
|
||||||
{
|
{
|
||||||
label: "Goto",
|
id: "gotoGroup",
|
||||||
|
label: t("goto.label"),
|
||||||
icon: LinkIcon,
|
icon: LinkIcon,
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
label: "Messages",
|
label: t("goto.command.messages"),
|
||||||
icon: MessageSquareIcon,
|
icon: MessageSquareIcon,
|
||||||
action() {
|
action() {
|
||||||
setActivePage("messages");
|
navigate({ to: "/messages" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Map",
|
label: t("goto.command.map"),
|
||||||
icon: MapIcon,
|
icon: MapIcon,
|
||||||
action() {
|
action() {
|
||||||
setActivePage("map");
|
navigate({ to: "/map" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Config",
|
label: t("goto.command.config"),
|
||||||
icon: SettingsIcon,
|
icon: SettingsIcon,
|
||||||
action() {
|
action() {
|
||||||
setActivePage("config");
|
navigate({ to: "/config" });
|
||||||
},
|
},
|
||||||
tags: ["settings"],
|
tags: ["settings"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Channels",
|
label: t("goto.command.channels"),
|
||||||
icon: LayersIcon,
|
icon: LayersIcon,
|
||||||
action() {
|
action() {
|
||||||
setActivePage("channels");
|
navigate({ to: "/channels" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Nodes",
|
label: t("goto.command.nodes"),
|
||||||
icon: UsersIcon,
|
icon: UsersIcon,
|
||||||
action() {
|
action() {
|
||||||
setActivePage("nodes");
|
navigate({ to: "/nodes" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Manage",
|
id: "manageGroup",
|
||||||
|
label: t("manage.label"),
|
||||||
icon: SmartphoneIcon,
|
icon: SmartphoneIcon,
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
label: "Switch Node",
|
label: t("manage.command.switchNode"),
|
||||||
icon: ArrowLeftRightIcon,
|
icon: ArrowLeftRightIcon,
|
||||||
subItems: getDevices().map((device) => ({
|
subItems: getDevices().map((device) => ({
|
||||||
label: getNode(device.hardware.myNodeNum)?.user?.longName ??
|
label: getNode(device.hardware.myNodeNum)?.user?.longName ??
|
||||||
device.hardware.myNodeNum.toString(),
|
t("unknown.shortName"),
|
||||||
icon: (
|
icon: (
|
||||||
<Avatar
|
<Avatar
|
||||||
text={getNode(device.hardware.myNodeNum)?.user?.shortName ??
|
text={getNode(device.hardware.myNodeNum)?.user?.shortName ??
|
||||||
device.hardware.myNodeNum.toString()}
|
t("unknown.shortName")}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
action() {
|
action() {
|
||||||
@@ -131,7 +138,7 @@ export const CommandPalette = () => {
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Connect New Node",
|
label: t("manage.command.connectNewNode"),
|
||||||
icon: PlusIcon,
|
icon: PlusIcon,
|
||||||
action() {
|
action() {
|
||||||
setConnectDialogOpen(true);
|
setConnectDialogOpen(true);
|
||||||
@@ -140,22 +147,23 @@ export const CommandPalette = () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Contextual",
|
id: "contextualGroup",
|
||||||
|
label: t("contextual.label"),
|
||||||
icon: BoxSelectIcon,
|
icon: BoxSelectIcon,
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
label: "QR Code",
|
label: t("contextual.command.qrCode"),
|
||||||
icon: QrCodeIcon,
|
icon: QrCodeIcon,
|
||||||
subItems: [
|
subItems: [
|
||||||
{
|
{
|
||||||
label: "Generator",
|
label: t("contextual.command.qrGenerator"),
|
||||||
icon: <QrCodeIcon size={16} />,
|
icon: <QrCodeIcon size={16} />,
|
||||||
action() {
|
action() {
|
||||||
setDialogOpen("QR", true);
|
setDialogOpen("QR", true);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Import",
|
label: t("contextual.command.qrImport"),
|
||||||
icon: <QrCodeIcon size={16} />,
|
icon: <QrCodeIcon size={16} />,
|
||||||
action() {
|
action() {
|
||||||
setDialogOpen("import", true);
|
setDialogOpen("import", true);
|
||||||
@@ -164,42 +172,42 @@ export const CommandPalette = () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Schedule Shutdown",
|
label: t("contextual.command.scheduleShutdown"),
|
||||||
icon: PowerIcon,
|
icon: PowerIcon,
|
||||||
action() {
|
action() {
|
||||||
setDialogOpen("shutdown", true);
|
setDialogOpen("shutdown", true);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Schedule Reboot",
|
label: t("contextual.command.scheduleReboot"),
|
||||||
icon: RefreshCwIcon,
|
icon: RefreshCwIcon,
|
||||||
action() {
|
action() {
|
||||||
setDialogOpen("reboot", true);
|
setDialogOpen("reboot", true);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Reboot To OTA Mode",
|
label: t("contextual.command.rebootToOtaMode"),
|
||||||
icon: RefreshCwIcon,
|
icon: RefreshCwIcon,
|
||||||
action() {
|
action() {
|
||||||
setDialogOpen("rebootOTA", true);
|
setDialogOpen("rebootOTA", true);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Reset Nodes",
|
label: t("contextual.command.resetNodeDb"),
|
||||||
icon: TrashIcon,
|
icon: TrashIcon,
|
||||||
action() {
|
action() {
|
||||||
connection?.resetNodes();
|
connection?.resetNodes();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Factory Reset Device",
|
label: t("contextual.command.factoryResetDevice"),
|
||||||
icon: FactoryIcon,
|
icon: FactoryIcon,
|
||||||
action() {
|
action() {
|
||||||
connection?.factoryResetDevice();
|
connection?.factoryResetDevice();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Factory Reset Config",
|
label: t("contextual.command.factoryResetConfig"),
|
||||||
icon: FactoryIcon,
|
icon: FactoryIcon,
|
||||||
action() {
|
action() {
|
||||||
connection?.factoryResetConfig();
|
connection?.factoryResetConfig();
|
||||||
@@ -208,18 +216,19 @@ export const CommandPalette = () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Debug",
|
id: "debugGroup",
|
||||||
|
label: t("debug.label"),
|
||||||
icon: BugIcon,
|
icon: BugIcon,
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
label: "Reconfigure",
|
label: t("debug.command.reconfigure"),
|
||||||
icon: RefreshCwIcon,
|
icon: RefreshCwIcon,
|
||||||
action() {
|
action() {
|
||||||
void connection?.configure();
|
void connection?.configure();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Clear All Stored Message",
|
label: t("debug.command.clearAllStoredMessages"),
|
||||||
icon: EraserIcon,
|
icon: EraserIcon,
|
||||||
action() {
|
action() {
|
||||||
setDialogOpen("deleteMessages", true);
|
setDialogOpen("deleteMessages", true);
|
||||||
@@ -230,8 +239,8 @@ export const CommandPalette = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const sortedGroups = [...groups].sort((a, b) => {
|
const sortedGroups = [...groups].sort((a, b) => {
|
||||||
const aPinned = pinnedItems.includes(a.label) ? 1 : 0;
|
const aPinned = pinnedItems.includes(a.id) ? 1 : 0;
|
||||||
const bPinned = pinnedItems.includes(b.label) ? 1 : 0;
|
const bPinned = pinnedItems.includes(b.id) ? 1 : 0;
|
||||||
return bPinned - aPinned;
|
return bPinned - aPinned;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -252,9 +261,9 @@ export const CommandPalette = () => {
|
|||||||
open={commandPaletteOpen}
|
open={commandPaletteOpen}
|
||||||
onOpenChange={setCommandPaletteOpen}
|
onOpenChange={setCommandPaletteOpen}
|
||||||
>
|
>
|
||||||
<CommandInput placeholder="Type a command or search..." />
|
<CommandInput placeholder={t("search.commandPalette")} />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
<CommandEmpty>{t("emptyState")}</CommandEmpty>
|
||||||
{sortedGroups.map((group) => (
|
{sortedGroups.map((group) => (
|
||||||
<CommandGroup
|
<CommandGroup
|
||||||
key={group.label}
|
key={group.label}
|
||||||
@@ -263,13 +272,13 @@ export const CommandPalette = () => {
|
|||||||
<span>{group.label}</span>
|
<span>{group.label}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => togglePinnedItem(group.label)}
|
onClick={() => togglePinnedItem(group.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100",
|
"transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100",
|
||||||
)}
|
)}
|
||||||
aria-description={pinnedItems.includes(group.label)
|
aria-description={pinnedItems.includes(group.label)
|
||||||
? "Unpin command group"
|
? t("unpinGroup.label")
|
||||||
: "Pin command group"}
|
: t("pinGroup.label")}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
data-label
|
data-label
|
||||||
@@ -279,7 +288,7 @@ export const CommandPalette = () => {
|
|||||||
size={16}
|
size={16}
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-opacity",
|
"transition-opacity",
|
||||||
pinnedItems.includes(group.label)
|
pinnedItems.includes(group.id)
|
||||||
? "opacity-100 text-red-500"
|
? "opacity-100 text-red-500"
|
||||||
: "opacity-40 hover:opacity-70",
|
: "opacity-40 hover:opacity-70",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
import {
|
||||||
|
CpuIcon,
|
||||||
|
Languages,
|
||||||
|
type LucideIcon,
|
||||||
|
Palette,
|
||||||
|
PenLine,
|
||||||
|
Search as SearchIcon,
|
||||||
|
ZapIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import BatteryStatus from "./BatteryStatus.tsx";
|
||||||
|
import { Subtle } from "./UI/Typography/Subtle.tsx";
|
||||||
|
import { Avatar } from "./UI/Avatar.tsx";
|
||||||
|
import type { DeviceMetrics } from "./types.ts";
|
||||||
|
import { Button } from "./UI/Button.tsx";
|
||||||
|
import React, { Fragment } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import ThemeSwitcher from "./ThemeSwitcher.tsx";
|
||||||
|
import LanguageSwitcher from "./LanguageSwitcher.tsx";
|
||||||
|
|
||||||
|
interface DeviceInfoPanelProps {
|
||||||
|
isCollapsed: boolean;
|
||||||
|
deviceMetrics: DeviceMetrics;
|
||||||
|
firmwareVersion: string;
|
||||||
|
user: {
|
||||||
|
shortName: string;
|
||||||
|
longName: string;
|
||||||
|
};
|
||||||
|
setDialogOpen: () => void;
|
||||||
|
setCommandPaletteOpen: () => void;
|
||||||
|
disableHover?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InfoDisplayItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
customComponent?: React.ReactNode;
|
||||||
|
value?: string | number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActionButtonConfig {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
onClick?: () => void;
|
||||||
|
render?: () => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeviceInfoPanel = ({
|
||||||
|
deviceMetrics,
|
||||||
|
firmwareVersion,
|
||||||
|
user,
|
||||||
|
isCollapsed,
|
||||||
|
setDialogOpen,
|
||||||
|
setCommandPaletteOpen,
|
||||||
|
disableHover = false,
|
||||||
|
}: DeviceInfoPanelProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { batteryLevel, voltage } = deviceMetrics;
|
||||||
|
|
||||||
|
const deviceInfoItems: InfoDisplayItem[] = [
|
||||||
|
{
|
||||||
|
id: "battery",
|
||||||
|
label: t("batteryStatus.title"),
|
||||||
|
customComponent: <BatteryStatus deviceMetrics={deviceMetrics} />,
|
||||||
|
value: batteryLevel !== undefined ? `${batteryLevel}%` : "N/A",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "voltage",
|
||||||
|
label: t("batteryVoltage.title"),
|
||||||
|
icon: ZapIcon,
|
||||||
|
value: voltage !== undefined
|
||||||
|
? `${voltage?.toPrecision(3)} V`
|
||||||
|
: t("unknown.notAvailable", "N/A"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "firmware",
|
||||||
|
label: t("sidebar.deviceInfo.firmware.title"),
|
||||||
|
icon: CpuIcon,
|
||||||
|
value: firmwareVersion ?? t("unknown.notAvailable", "N/A"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const actionButtons: ActionButtonConfig[] = [
|
||||||
|
{
|
||||||
|
id: "changeName",
|
||||||
|
label: t("sidebar.deviceInfo.deviceName.changeName"),
|
||||||
|
icon: PenLine,
|
||||||
|
onClick: setDialogOpen,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "commandMenu",
|
||||||
|
label: t("page.title", { ns: "commandPalette" }),
|
||||||
|
icon: SearchIcon,
|
||||||
|
onClick: setCommandPaletteOpen,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "theme",
|
||||||
|
label: t("theme.changeTheme"),
|
||||||
|
icon: Palette,
|
||||||
|
render: () => <ThemeSwitcher />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "language",
|
||||||
|
label: t("language.changeLanguage"),
|
||||||
|
icon: Languages,
|
||||||
|
render: () => <LanguageSwitcher />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 p-1 flex-shrink-0",
|
||||||
|
isCollapsed && "justify-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
text={user.shortName}
|
||||||
|
className={cn("flex-shrink-0", isCollapsed && "")}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium text-gray-800 dark:text-gray-200",
|
||||||
|
"transition-opacity duration-300 ease-in-out truncate",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{user.longName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0">
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-2 mt-1",
|
||||||
|
"transition-all duration-300 ease-in-out",
|
||||||
|
isCollapsed
|
||||||
|
? "opacity-0 max-w-0 h-0 invisible pointer-events-none"
|
||||||
|
: "opacity-100 max-w-xs h-auto visible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{deviceInfoItems.map((item) => {
|
||||||
|
const IconComponent = item.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-center gap-2.5 text-sm"
|
||||||
|
>
|
||||||
|
{IconComponent && (
|
||||||
|
<IconComponent
|
||||||
|
size={16}
|
||||||
|
className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{item.customComponent}
|
||||||
|
{item.id !== "battery" && (
|
||||||
|
<Subtle className="text-gray-600 dark:text-gray-300">
|
||||||
|
{item.label}: {item.value}
|
||||||
|
</Subtle>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0">
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-1 mt-1",
|
||||||
|
"transition-all duration-300 ease-in-out",
|
||||||
|
isCollapsed
|
||||||
|
? "opacity-0 max-w-0 h-0 invisible pointer-events-none"
|
||||||
|
: "opacity-100 max-w-xs visible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{actionButtons.map((buttonItem) => {
|
||||||
|
const Icon = buttonItem.icon;
|
||||||
|
if (buttonItem.render) {
|
||||||
|
return (
|
||||||
|
<Fragment key={buttonItem.id}>
|
||||||
|
{buttonItem.render()}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={buttonItem.id}
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={buttonItem.label}
|
||||||
|
onClick={buttonItem.onClick}
|
||||||
|
className={cn(
|
||||||
|
"group",
|
||||||
|
"flex w-full items-center justify-start text-sm p-1.5 rounded-md",
|
||||||
|
"gap-2.5",
|
||||||
|
"transition-colors duration-150",
|
||||||
|
!disableHover && "hover:bg-gray-100 dark:hover:bg-gray-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={16}
|
||||||
|
className={cn(
|
||||||
|
"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",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Subtle
|
||||||
|
className={cn(
|
||||||
|
"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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{buttonItem.label}
|
||||||
|
</Subtle>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "@components/UI/Dialog.tsx";
|
} from "@components/UI/Dialog.tsx";
|
||||||
import { AlertTriangleIcon } from "lucide-react";
|
import { AlertTriangleIcon } from "lucide-react";
|
||||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface DeleteMessagesDialogProps {
|
export interface DeleteMessagesDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -20,6 +21,7 @@ export const DeleteMessagesDialog = ({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: DeleteMessagesDialogProps) => {
|
}: DeleteMessagesDialogProps) => {
|
||||||
|
const { t } = useTranslation("dialog");
|
||||||
const { deleteAllMessages } = useMessageStore();
|
const { deleteAllMessages } = useMessageStore();
|
||||||
const handleCloseDialog = () => {
|
const handleCloseDialog = () => {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -32,19 +34,19 @@ export const DeleteMessagesDialog = ({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<AlertTriangleIcon className="h-5 w-5 text-warning" />
|
<AlertTriangleIcon className="h-5 w-5 text-warning" />
|
||||||
Clear All Messages
|
{t("deleteMessages.title")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This action will clear all message history. This cannot be undone.
|
{t("deleteMessages.description")}
|
||||||
Are you sure you want to continue?
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter className="mt-4">
|
<DialogFooter className="mt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleCloseDialog}
|
onClick={handleCloseDialog}
|
||||||
|
name="dismiss"
|
||||||
>
|
>
|
||||||
Dismiss
|
{t("button.dismiss")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -52,8 +54,9 @@ export const DeleteMessagesDialog = ({
|
|||||||
deleteAllMessages();
|
deleteAllMessages();
|
||||||
handleCloseDialog();
|
handleCloseDialog();
|
||||||
}}
|
}}
|
||||||
|
name="clearMessages"
|
||||||
>
|
>
|
||||||
Clear Messages
|
{t("button.clearMessages")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@components/UI/Dialog.tsx";
|
} from "@components/UI/Dialog.tsx";
|
||||||
import { Label } from "@components/UI/Label.tsx";
|
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { GenericInput } from "@components/Form/FormInput.tsx";
|
import { GenericInput } from "@components/Form/FormInput.tsx";
|
||||||
import { validateMaxByteLength } from "@core/utils/string.ts";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Label } from "../UI/Label.tsx";
|
||||||
|
import z from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
longName: string;
|
longName: string;
|
||||||
@@ -25,38 +27,39 @@ export interface DeviceNameDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
const MAX_LONG_NAME_BYTE_LENGTH = 40;
|
|
||||||
const MAX_SHORT_NAME_BYTE_LENGTH = 4;
|
|
||||||
|
|
||||||
export const DeviceNameDialog = ({
|
export const DeviceNameDialog = ({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: DeviceNameDialogProps) => {
|
}: DeviceNameDialogProps) => {
|
||||||
|
const { t } = useTranslation("dialog");
|
||||||
const { hardware, getNode, connection } = useDevice();
|
const { hardware, getNode, connection } = useDevice();
|
||||||
const myNode = getNode(hardware.myNodeNum);
|
const myNode = getNode(hardware.myNodeNum);
|
||||||
|
|
||||||
const defaultValues = {
|
const defaultValues = {
|
||||||
longName: myNode?.user?.longName ?? "Unknown",
|
shortName: myNode?.user?.shortName ?? "",
|
||||||
shortName: myNode?.user?.shortName ?? "??",
|
longName: myNode?.user?.longName ?? "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const { getValues, setValue, reset, control, handleSubmit } = useForm<User>({
|
const deviceNameSchema = z.object({
|
||||||
values: defaultValues,
|
longName: z
|
||||||
|
.string()
|
||||||
|
.min(1, t("deviceName.validation.longNameMin"))
|
||||||
|
.max(40, t("deviceName.validation.longNameMax")),
|
||||||
|
shortName: z
|
||||||
|
.string()
|
||||||
|
.min(2, t("deviceName.validation.shortNameMin"))
|
||||||
|
.max(4, t("deviceName.validation.shortNameMax")),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { currentLength: currentLongNameLength } = validateMaxByteLength(
|
const { getValues, reset, control, handleSubmit } = useForm<User>({
|
||||||
getValues("longName"),
|
values: defaultValues,
|
||||||
MAX_LONG_NAME_BYTE_LENGTH,
|
resolver: zodResolver(deviceNameSchema),
|
||||||
);
|
});
|
||||||
const { currentLength: currentShortNameLength } = validateMaxByteLength(
|
|
||||||
getValues("shortName"),
|
|
||||||
MAX_SHORT_NAME_BYTE_LENGTH,
|
|
||||||
);
|
|
||||||
|
|
||||||
const onSubmit = handleSubmit((data) => {
|
const onSubmit = handleSubmit((data) => {
|
||||||
connection?.setOwner(
|
connection?.setOwner(
|
||||||
create(Protobuf.Mesh.UserSchema, {
|
create(Protobuf.Mesh.UserSchema, {
|
||||||
...(myNode?.user ?? {}),
|
|
||||||
...data,
|
...data,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -64,9 +67,7 @@ export const DeviceNameDialog = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
reset({ longName: "", shortName: "" });
|
reset(defaultValues);
|
||||||
setValue("longName", "");
|
|
||||||
setValue("shortName", "");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -74,25 +75,28 @@ export const DeviceNameDialog = ({
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogClose />
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Change Device Name</DialogTitle>
|
<DialogTitle>{t("deviceName.title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
The Device will restart once the config is saved.
|
{t("deviceName.description")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="longName">Long Name</Label>
|
<Label htmlFor="longName">
|
||||||
|
{t("deviceName.longName")}
|
||||||
|
</Label>
|
||||||
<GenericInput
|
<GenericInput
|
||||||
control={control}
|
control={control}
|
||||||
field={{
|
field={{
|
||||||
name: "longName",
|
name: "longName",
|
||||||
label: "Long Name",
|
label: t("deviceName.longName"),
|
||||||
type: "text",
|
type: "text",
|
||||||
properties: {
|
properties: {
|
||||||
className: "text-slate-900 dark:text-slate-200",
|
className: "text-slate-900 dark:text-slate-200",
|
||||||
fieldLength: {
|
fieldLength: {
|
||||||
currentValueLength: currentLongNameLength ?? 0,
|
currentValueLength: getValues("longName").length,
|
||||||
max: MAX_LONG_NAME_BYTE_LENGTH,
|
max: 40,
|
||||||
|
min: 1,
|
||||||
showCharacterCount: true,
|
showCharacterCount: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -100,17 +104,20 @@ export const DeviceNameDialog = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="shortName">Short Name</Label>
|
<Label htmlFor="shortName">
|
||||||
|
{t("deviceName.shortName")}
|
||||||
|
</Label>
|
||||||
<GenericInput
|
<GenericInput
|
||||||
control={control}
|
control={control}
|
||||||
field={{
|
field={{
|
||||||
name: "shortName",
|
name: "shortName",
|
||||||
label: "Short Name",
|
label: t("deviceName.shortName"),
|
||||||
type: "text",
|
type: "text",
|
||||||
properties: {
|
properties: {
|
||||||
fieldLength: {
|
fieldLength: {
|
||||||
currentValueLength: currentShortNameLength ?? 0,
|
currentValueLength: getValues("shortName").length,
|
||||||
max: MAX_SHORT_NAME_BYTE_LENGTH,
|
max: 4,
|
||||||
|
min: 1,
|
||||||
showCharacterCount: true,
|
showCharacterCount: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -119,10 +126,20 @@ export const DeviceNameDialog = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="destructive" onClick={handleReset}>
|
<Button
|
||||||
Reset
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
name="reset"
|
||||||
|
onClick={handleReset}
|
||||||
|
>
|
||||||
|
{t("button.reset")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
name="save"
|
||||||
|
>
|
||||||
|
{t("button.save")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit">Save</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useDevice } from "@core/stores/deviceStore.ts";
|
|||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { toByteArray } from "base64-js";
|
import { toByteArray } from "base64-js";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface ImportDialogProps {
|
export interface ImportDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -28,6 +29,7 @@ export const ImportDialog = ({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: ImportDialogProps) => {
|
}: ImportDialogProps) => {
|
||||||
|
const { t } = useTranslation("dialog");
|
||||||
const [importDialogInput, setImportDialogInput] = useState<string>("");
|
const [importDialogInput, setImportDialogInput] = useState<string>("");
|
||||||
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
|
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
|
||||||
const [validUrl, setValidUrl] = useState<boolean>(false);
|
const [validUrl, setValidUrl] = useState<boolean>(false);
|
||||||
@@ -44,7 +46,7 @@ export const ImportDialog = ({
|
|||||||
channelsUrl.pathname !== "/e/") ||
|
channelsUrl.pathname !== "/e/") ||
|
||||||
!channelsUrl.hash
|
!channelsUrl.hash
|
||||||
) {
|
) {
|
||||||
throw "Invalid Meshtastic URL";
|
throw t("import.error.invalidUrl");
|
||||||
}
|
}
|
||||||
|
|
||||||
const encodedChannelConfig = channelsUrl.hash.substring(1);
|
const encodedChannelConfig = channelsUrl.hash.substring(1);
|
||||||
@@ -70,7 +72,8 @@ export const ImportDialog = ({
|
|||||||
}, [importDialogInput]);
|
}, [importDialogInput]);
|
||||||
|
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
channelSet?.settings.map((ch: unknown, index: number) => {
|
channelSet?.settings.map(
|
||||||
|
(ch: Protobuf.Channel.ChannelSettings, index: number) => {
|
||||||
connection?.setChannel(
|
connection?.setChannel(
|
||||||
create(Protobuf.Channel.ChannelSchema, {
|
create(Protobuf.Channel.ChannelSchema, {
|
||||||
index,
|
index,
|
||||||
@@ -80,7 +83,8 @@ export const ImportDialog = ({
|
|||||||
settings: ch,
|
settings: ch,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (channelSet?.loraConfig) {
|
if (channelSet?.loraConfig) {
|
||||||
connection?.setConfig(
|
connection?.setConfig(
|
||||||
@@ -99,13 +103,13 @@ export const ImportDialog = ({
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogClose />
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Import Channel Set</DialogTitle>
|
<DialogTitle>{t("import.title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
The current LoRa configuration will be overridden.
|
{t("import.description")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Label>Channel Set/QR Code URL</Label>
|
<Label>{t("import.channelSetUrl")}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={importDialogInput}
|
value={importDialogInput}
|
||||||
suffix={validUrl ? "✅" : "❌"}
|
suffix={validUrl ? "✅" : "❌"}
|
||||||
@@ -117,7 +121,7 @@ export const ImportDialog = ({
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex w-full gap-2">
|
<div className="flex w-full gap-2">
|
||||||
<div className="w-36">
|
<div className="w-36">
|
||||||
<Label>Use Preset?</Label>
|
<Label>{t("import.usePreset")}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
disabled
|
disabled
|
||||||
checked={channelSet?.loraConfig?.usePreset ?? true}
|
checked={channelSet?.loraConfig?.usePreset ?? true}
|
||||||
@@ -144,7 +148,7 @@ export const ImportDialog = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
<span className="text-md block font-medium text-text-primary">
|
<span className="text-md block font-medium text-text-primary">
|
||||||
Channels:
|
{t("import.channels")}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex w-40 flex-col gap-1">
|
<div className="flex w-40 flex-col gap-1">
|
||||||
{channelSet?.settings.map((channel) => (
|
{channelSet?.settings.map((channel) => (
|
||||||
@@ -152,7 +156,7 @@ export const ImportDialog = ({
|
|||||||
<Label>
|
<Label>
|
||||||
{channel.name.length
|
{channel.name.length
|
||||||
? channel.name
|
? channel.name
|
||||||
: `Channel: ${channel.id}`}
|
: `${t("import.channelPrefix")}${channel.id}`}
|
||||||
</Label>
|
</Label>
|
||||||
<Checkbox key={channel.id} />
|
<Checkbox key={channel.id} />
|
||||||
</div>
|
</div>
|
||||||
@@ -162,8 +166,8 @@ export const ImportDialog = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button onClick={apply} disabled={!validUrl}>
|
<Button onClick={apply} disabled={!validUrl} name="apply">
|
||||||
Apply
|
{t("button.apply")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useDevice } from "../../core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
@@ -9,9 +9,10 @@ import {
|
|||||||
} from "../UI/Dialog.tsx";
|
} from "../UI/Dialog.tsx";
|
||||||
import type { Protobuf, Types } from "@meshtastic/core";
|
import type { Protobuf, Types } from "@meshtastic/core";
|
||||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface LocationResponseDialogProps {
|
export interface LocationResponseDialogProps {
|
||||||
location: Types.PacketMetadata<Protobuf.Mesh.location> | undefined;
|
location: Types.PacketMetadata<Protobuf.Mesh.Position> | undefined;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: () => void;
|
onOpenChange: () => void;
|
||||||
}
|
}
|
||||||
@@ -21,41 +22,70 @@ export const LocationResponseDialog = ({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: LocationResponseDialogProps) => {
|
}: LocationResponseDialogProps) => {
|
||||||
|
const { t } = useTranslation("dialog");
|
||||||
const { getNode } = useDevice();
|
const { getNode } = useDevice();
|
||||||
|
|
||||||
const from = getNode(location?.from ?? 0);
|
const from = getNode(location?.from ?? 0);
|
||||||
const longName = from?.user?.longName ??
|
const longName = from?.user?.longName ??
|
||||||
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
|
(from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
|
||||||
const shortName = from?.user?.shortName ??
|
const shortName = from?.user?.shortName ??
|
||||||
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK");
|
(from
|
||||||
|
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
|
||||||
|
: t("unknown.shortName"));
|
||||||
|
|
||||||
|
const position = location?.data;
|
||||||
|
|
||||||
|
const hasCoordinates = position &&
|
||||||
|
typeof position.latitudeI === "number" &&
|
||||||
|
typeof position.longitudeI === "number" &&
|
||||||
|
typeof position.altitude === "number";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogClose />
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{`Location: ${longName} (${shortName})`}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{t("locationResponse.title", {
|
||||||
|
identifier: `${longName} (${shortName})`,
|
||||||
|
})}
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
{hasCoordinates
|
||||||
|
? (
|
||||||
<div className="ml-5 flex">
|
<div className="ml-5 flex">
|
||||||
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
|
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
|
||||||
<p>
|
<p>
|
||||||
Coordinates:{" "}
|
{t("locationResponse.coordinates")}
|
||||||
<a
|
<a
|
||||||
className="text-blue-500 dark:text-blue-400"
|
className="text-blue-500 dark:text-blue-400"
|
||||||
href={`https://www.openstreetmap.org/?mlat=${
|
href={`https://www.openstreetmap.org/?mlat=${
|
||||||
location?.data.latitudeI / 1e7
|
position.latitudeI ?? 0 / 1e7
|
||||||
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`}
|
}&mlon=${position.longitudeI ?? 0 / 1e7}&layers=N`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
{location?.data.latitudeI / 1e7},{" "}
|
{" "}
|
||||||
{location?.data.longitudeI / 1e7}
|
{position.latitudeI ?? 0 / 1e7},{" "}
|
||||||
|
{position.longitudeI ?? 0 / 1e7}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p>Altitude: {location?.data.altitude}m</p>
|
<p>
|
||||||
|
{t("locationResponse.altitude")} {position.altitude}
|
||||||
|
{(position.altitude ?? 0) < 1
|
||||||
|
? t("unit.meter.one")
|
||||||
|
: t("unit.meter.plural")}
|
||||||
|
</p>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
// Optional: Show a message if coordinates are not available
|
||||||
|
<p className="text-textPrimary">
|
||||||
|
{t("locationResponse.noCoordinates")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@components/UI/Dialog.tsx";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export interface ManagedModeDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: () => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ManagedModeDialog = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: ManagedModeDialogProps) => {
|
||||||
|
const { t } = useTranslation("dialog");
|
||||||
|
const [confirmState, setConfirmState] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogClose />
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("managedMode.title")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans
|
||||||
|
i18nKey="managedMode.description"
|
||||||
|
components={{
|
||||||
|
"bold": <p className="font-bold inline" />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="managedMode"
|
||||||
|
checked={confirmState}
|
||||||
|
onChange={() => setConfirmState(!confirmState)}
|
||||||
|
name="confirmUnderstanding"
|
||||||
|
>
|
||||||
|
<p className="dark:text-white pt-1">
|
||||||
|
{t("managedMode.confirmUnderstanding")}
|
||||||
|
</p>
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
name="regenerate"
|
||||||
|
disabled={!confirmState}
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmState(false);
|
||||||
|
onSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("button.confirm")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type BrowserFeature,
|
type BrowserFeature,
|
||||||
useBrowserFeatureDetection,
|
useBrowserFeatureDetection,
|
||||||
} from "../../core/hooks/useBrowserFeatureDetection.ts";
|
} from "@core/hooks/useBrowserFeatureDetection.ts";
|
||||||
import { BLE } from "@components/PageComponents/Connect/BLE.tsx";
|
import { BLE } from "@components/PageComponents/Connect/BLE.tsx";
|
||||||
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
|
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
|
||||||
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
|
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
|
||||||
@@ -18,16 +18,16 @@ import {
|
|||||||
TabsList,
|
TabsList,
|
||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from "@components/UI/Tabs.tsx";
|
} from "@components/UI/Tabs.tsx";
|
||||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
|
||||||
import { AlertCircle } from "lucide-react";
|
import { AlertCircle } from "lucide-react";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { Link } from "../UI/Typography/Link.tsx";
|
import { Link } from "../UI/Typography/Link.tsx";
|
||||||
import { Fragment } from "react/jsx-runtime";
|
|
||||||
|
|
||||||
export interface TabElementProps {
|
export interface TabElementProps {
|
||||||
closeDialog: () => void;
|
closeDialog: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TabManifest {
|
export interface TabManifest {
|
||||||
|
id: "HTTP" | "BLE" | "Serial";
|
||||||
label: string;
|
label: string;
|
||||||
element: React.FC<TabElementProps>;
|
element: React.FC<TabElementProps>;
|
||||||
isDisabled: boolean;
|
isDisabled: boolean;
|
||||||
@@ -40,23 +40,28 @@ export interface NewDeviceProps {
|
|||||||
|
|
||||||
interface FeatureErrorProps {
|
interface FeatureErrorProps {
|
||||||
missingFeatures: BrowserFeature[];
|
missingFeatures: BrowserFeature[];
|
||||||
|
tabId: "HTTP" | "BLE" | "Serial";
|
||||||
}
|
}
|
||||||
|
|
||||||
const links: { [key: string]: string } = {
|
const errors: Record<BrowserFeature, { href: string; i18nKey: string }> = {
|
||||||
"Web Bluetooth":
|
"Web Bluetooth": {
|
||||||
|
href:
|
||||||
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
|
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
|
||||||
"Web Serial":
|
i18nKey: "newDeviceDialog.validation.requiresWebBluetooth",
|
||||||
|
},
|
||||||
|
"Web Serial": {
|
||||||
|
href:
|
||||||
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
|
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
|
||||||
"Secure Context":
|
i18nKey: "newDeviceDialog.validation.requiresWebSerial",
|
||||||
|
},
|
||||||
|
"Secure Context": {
|
||||||
|
href:
|
||||||
"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
|
"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
|
||||||
|
i18nKey: "newDeviceDialog.validation.requiresSecureContext",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const listFormatter = new Intl.ListFormat("en", {
|
const ErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
|
||||||
style: "long",
|
|
||||||
type: "disjunction",
|
|
||||||
});
|
|
||||||
|
|
||||||
const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
|
|
||||||
if (missingFeatures.length === 0) return null;
|
if (missingFeatures.length === 0) return null;
|
||||||
|
|
||||||
const browserFeatures = missingFeatures.filter(
|
const browserFeatures = missingFeatures.filter(
|
||||||
@@ -64,46 +69,51 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
|
|||||||
);
|
);
|
||||||
const needsSecureContext = missingFeatures.includes("Secure Context");
|
const needsSecureContext = missingFeatures.includes("Secure Context");
|
||||||
|
|
||||||
const formatFeatureList = (features: string[]) => {
|
const needsFeature =
|
||||||
const parts = listFormatter.formatToParts(features);
|
(tabId === "BLE" && browserFeatures.includes("Web Bluetooth"))
|
||||||
return parts.map((part) => {
|
? "Web Bluetooth"
|
||||||
if (part.type === "element") {
|
: (tabId === "Serial" && browserFeatures.includes("Web Serial"))
|
||||||
return (
|
? "Web Serial"
|
||||||
<Link key={part.value} href={links[part.value]}>
|
: undefined;
|
||||||
{part.value}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <Fragment key={part.value}>{part.value}</Fragment>;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Subtle className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md">
|
<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">
|
<div className="flex items-center gap-2 w-full">
|
||||||
<AlertCircle size={40} className="mr-2 shrink-0 text-white" />
|
<AlertCircle size={40} className="mr-2 shrink-0 text-white" />
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p className="text-sm text-white">
|
<div className="text-sm text-white">
|
||||||
{browserFeatures.length > 0 && (
|
{needsFeature && (
|
||||||
<>
|
<Trans
|
||||||
This connection type requires{" "}
|
i18nKey={errors[needsFeature].i18nKey}
|
||||||
{formatFeatureList(browserFeatures)}. Please use a supported
|
components={[
|
||||||
browser, like Chrome or Edge.
|
<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 && (
|
{needsSecureContext && (
|
||||||
<>
|
<Trans
|
||||||
{browserFeatures.length > 0 && " Additionally, it"}
|
i18nKey={browserFeatures.length > 0
|
||||||
{browserFeatures.length === 0 && "This application"} requires a
|
? "newDeviceDialog.validation.additionallyRequiresSecureContext"
|
||||||
{" "}
|
: "newDeviceDialog.validation.requiresSecureContext"}
|
||||||
<Link href={links["Secure Context"]}>secure context</Link>.
|
components={{
|
||||||
Please connect using HTTPS or localhost.
|
"0": (
|
||||||
</>
|
<Link
|
||||||
|
href={errors["Secure Context"].href}
|
||||||
|
className="underline hover:text-slate-200"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Subtle>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,22 +121,26 @@ export const NewDeviceDialog = ({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: NewDeviceProps) => {
|
}: NewDeviceProps) => {
|
||||||
|
const { t } = useTranslation("dialog");
|
||||||
const { unsupported } = useBrowserFeatureDetection();
|
const { unsupported } = useBrowserFeatureDetection();
|
||||||
|
|
||||||
const tabs: TabManifest[] = [
|
const tabs: TabManifest[] = [
|
||||||
{
|
{
|
||||||
label: "HTTP",
|
id: "HTTP",
|
||||||
|
label: t("newDeviceDialog.tabHttp"),
|
||||||
element: HTTP,
|
element: HTTP,
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Bluetooth",
|
id: "BLE",
|
||||||
|
label: t("newDeviceDialog.tabBluetooth"),
|
||||||
element: BLE,
|
element: BLE,
|
||||||
isDisabled: unsupported.includes("Web Bluetooth") ||
|
isDisabled: unsupported.includes("Web Bluetooth") ||
|
||||||
unsupported.includes("Secure Context"),
|
unsupported.includes("Secure Context"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Serial",
|
id: "Serial",
|
||||||
|
label: t("newDeviceDialog.tabSerial"),
|
||||||
element: Serial,
|
element: Serial,
|
||||||
isDisabled: unsupported.includes("Web Serial") ||
|
isDisabled: unsupported.includes("Web Serial") ||
|
||||||
unsupported.includes("Secure Context"),
|
unsupported.includes("Secure Context"),
|
||||||
@@ -138,25 +152,32 @@ export const NewDeviceDialog = ({
|
|||||||
<DialogContent aria-describedby={undefined}>
|
<DialogContent aria-describedby={undefined}>
|
||||||
<DialogClose />
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Connect New Device</DialogTitle>
|
<DialogTitle>{t("newDeviceDialog.title")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Tabs defaultValue="HTTP">
|
<Tabs defaultValue="HTTP">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<TabsTrigger key={tab.label} value={tab.label}>
|
<TabsTrigger key={tab.id} value={tab.id}>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<TabsContent key={tab.label} value={tab.label}>
|
<TabsContent key={tab.id} value={tab.id}>
|
||||||
<fieldset disabled={tab.isDisabled}>
|
<fieldset disabled={tab.isDisabled}>
|
||||||
{(tab.label !== "HTTP" && tab.isDisabled)
|
{(tab.id !== "HTTP" &&
|
||||||
? <ErrorMessage missingFeatures={unsupported} />
|
tab.isDisabled)
|
||||||
: null}
|
? (
|
||||||
|
<ErrorMessage
|
||||||
|
missingFeatures={unsupported}
|
||||||
|
tabId={tab.id}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: (
|
||||||
<tab.element
|
<tab.element
|
||||||
closeDialog={() => onOpenChange(false)}
|
closeDialog={() => onOpenChange(false)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
|
|
||||||
import { useAppStore } from "@core/stores/appStore.ts";
|
|
||||||
import { Protobuf } from "@meshtastic/core";
|
|
||||||
|
|
||||||
vi.mock("@core/stores/deviceStore", () => {
|
|
||||||
return {
|
|
||||||
useDevice: () => ({
|
|
||||||
setDialogOpen: vi.fn(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
vi.mock("@core/stores/appStore");
|
|
||||||
|
|
||||||
const mockUseAppStore = vi.mocked(useAppStore);
|
|
||||||
|
|
||||||
describe("NodeDetailsDialog", () => {
|
|
||||||
const mockNode = {
|
|
||||||
num: 1234,
|
|
||||||
user: {
|
|
||||||
longName: "Test Node",
|
|
||||||
shortName: "TN",
|
|
||||||
hwModel: 1,
|
|
||||||
role: 1,
|
|
||||||
},
|
|
||||||
lastHeard: 1697500000,
|
|
||||||
position: {
|
|
||||||
latitudeI: 450000000,
|
|
||||||
longitudeI: -750000000,
|
|
||||||
altitude: 200,
|
|
||||||
},
|
|
||||||
deviceMetrics: {
|
|
||||||
airUtilTx: 50.123,
|
|
||||||
channelUtilization: 75.456,
|
|
||||||
batteryLevel: 88.789,
|
|
||||||
voltage: 4.2,
|
|
||||||
uptimeSeconds: 3600,
|
|
||||||
},
|
|
||||||
} as unknown as Protobuf.Mesh.NodeInfo;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetAllMocks();
|
|
||||||
|
|
||||||
mockUseAppStore.mockReturnValue({
|
|
||||||
nodeNumDetails: 1234,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders node details correctly", () => {
|
|
||||||
render(<NodeDetailsDialog open node={mockNode} onOpenChange={() => {}} />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/Node Details for Test Node \(TN\)/i))
|
|
||||||
.toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(screen.getByText("Node Number: 1234")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Node Hex: !/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Last Heard:/i)).toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument();
|
|
||||||
const link = screen.getByRole("link", { name: /^45, -75$/ });
|
|
||||||
|
|
||||||
expect(link).toBeInTheDocument();
|
|
||||||
expect(link).toHaveAttribute(
|
|
||||||
"href",
|
|
||||||
expect.stringContaining("openstreetmap.org"),
|
|
||||||
);
|
|
||||||
expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Channel utilization: 75.46%/i))
|
|
||||||
.toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Uptime:/i)).toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders null if node is undefined", () => {
|
|
||||||
const mockNode = undefined;
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<NodeDetailsDialog open node={mockNode} onOpenChange={() => {}} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container.firstChild).toBeNull();
|
|
||||||
expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders correctly when position is missing", () => {
|
|
||||||
const nodeWithoutPosition = { ...mockNode, position: undefined };
|
|
||||||
|
|
||||||
render(
|
|
||||||
<NodeDetailsDialog
|
|
||||||
open
|
|
||||||
node={nodeWithoutPosition}
|
|
||||||
onOpenChange={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByText(/Coordinates:/i)).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText(/Altitude:/i)).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders correctly when deviceMetrics are missing", () => {
|
|
||||||
const nodeWithoutMetrics = { ...mockNode, deviceMetrics: undefined };
|
|
||||||
|
|
||||||
render(
|
|
||||||
<NodeDetailsDialog
|
|
||||||
open
|
|
||||||
node={nodeWithoutMetrics}
|
|
||||||
onOpenChange={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByText(/Device Metrics:/i)).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText(/Air TX utilization:/i)).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders 'Never' for lastHeard when timestamp is 0", () => {
|
|
||||||
const nodeNeverHeard = { ...mockNode, lastHeard: 0 };
|
|
||||||
|
|
||||||
render(
|
|
||||||
<NodeDetailsDialog open node={nodeNeverHeard} onOpenChange={() => {}} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,19 +1,14 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useAppStore } from "@core/stores/appStore.ts";
|
import { useAppStore } from "@core/stores/appStore.ts";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import {
|
|
||||||
MessageType,
|
|
||||||
useMessageStore,
|
|
||||||
} from "@core/stores/messageStore/index.ts";
|
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||||
import { DeviceImage } from "@components/generic/DeviceImage.tsx";
|
import { DeviceImage } from "@components/generic/DeviceImage.tsx";
|
||||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||||
import { Uptime } from "@components/generic/Uptime.tsx";
|
import { Uptime } from "@components/generic/Uptime.tsx";
|
||||||
import { toast } from "@core/hooks/useToast.ts";
|
import { toast } from "@core/hooks/useToast.ts";
|
||||||
import { useFavoriteNode } from "../../../core/hooks/useFavoriteNode.ts";
|
import { useFavoriteNode } from "@core/hooks/useFavoriteNode.ts";
|
||||||
import { useIgnoreNode } from "../../../core/hooks/useIgnoreNode.ts";
|
import { useIgnoreNode } from "@core/hooks/useIgnoreNode.ts";
|
||||||
import { cn } from "@core/utils/cn.ts";
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -48,27 +43,33 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@components/UI/Tooltip.tsx";
|
} from "@components/UI/Tooltip.tsx";
|
||||||
import { Separator } from "@components/UI/Seperator.tsx";
|
import { Separator } from "@components/UI/Seperator.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
||||||
export interface NodeDetailsDialogProps {
|
export interface NodeDetailsDialogProps {
|
||||||
node: Protobuf.Mesh.NodeInfo | undefined;
|
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NodeDetailsDialog = ({
|
export const NodeDetailsDialog = ({
|
||||||
node,
|
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: NodeDetailsDialogProps) => {
|
}: NodeDetailsDialogProps) => {
|
||||||
const { setDialogOpen, connection, setActivePage } = useDevice();
|
const { t } = useTranslation("dialog");
|
||||||
const { setNodeNumToBeRemoved } = useAppStore();
|
const { setDialogOpen, connection, getNode } = useDevice();
|
||||||
const { setChatType, setActiveChat } = useMessageStore();
|
const navigate = useNavigate();
|
||||||
|
const { setNodeNumToBeRemoved, nodeNumDetails } = useAppStore();
|
||||||
const { updateFavorite } = useFavoriteNode();
|
const { updateFavorite } = useFavoriteNode();
|
||||||
const [isFavoriteState, setIsFavoriteState] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const { updateIgnored } = useIgnoreNode();
|
const { updateIgnored } = useIgnoreNode();
|
||||||
const [isIgnoredState, setIsIgnoredState] = useState<boolean>(false);
|
|
||||||
|
const node = getNode(nodeNumDetails);
|
||||||
|
|
||||||
|
const [isFavoriteState, setIsFavoriteState] = useState<boolean>(
|
||||||
|
node?.isFavorite ?? false,
|
||||||
|
);
|
||||||
|
const [isIgnoredState, setIsIgnoredState] = useState<boolean>(
|
||||||
|
node?.isIgnored ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
@@ -80,21 +81,19 @@ export const NodeDetailsDialog = ({
|
|||||||
|
|
||||||
function handleDirectMessage() {
|
function handleDirectMessage() {
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
navigate({ to: `/messages/direct/${node.num}` });
|
||||||
setChatType(MessageType.Direct);
|
setDialogOpen("nodeDetails", false);
|
||||||
setActiveChat(node.num);
|
|
||||||
setActivePage("messages");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRequestPosition() {
|
function handleRequestPosition() {
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Requesting position, please wait...",
|
title: t("toast.requestingPosition.title", { ns: "ui" }),
|
||||||
});
|
});
|
||||||
connection?.requestPosition(node.num).then(() =>
|
connection?.requestPosition(node.num).then(() =>
|
||||||
toast({
|
toast({
|
||||||
title: "Position request sent.",
|
title: t("toast.positionRequestSent.title", { ns: "ui" }),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -104,11 +103,11 @@ export const NodeDetailsDialog = ({
|
|||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Sending Traceroute, please wait...",
|
title: t("toast.sendingTraceroute.title", { ns: "ui" }),
|
||||||
});
|
});
|
||||||
connection?.traceRoute(node.num).then(() =>
|
connection?.traceRoute(node.num).then(() =>
|
||||||
toast({
|
toast({
|
||||||
title: "Traceroute sent.",
|
title: t("toast.tracerouteSent.title", { ns: "ui" }),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -139,25 +138,25 @@ export const NodeDetailsDialog = ({
|
|||||||
const deviceMetricsMap = [
|
const deviceMetricsMap = [
|
||||||
{
|
{
|
||||||
key: "airUtilTx",
|
key: "airUtilTx",
|
||||||
label: "Air TX utilization",
|
label: t("nodeDetails.airTxUtilization"),
|
||||||
value: node.deviceMetrics?.airUtilTx,
|
value: node.deviceMetrics?.airUtilTx,
|
||||||
format: (val: number) => `${val.toFixed(2)}%`,
|
format: (val: number) => `${val.toFixed(2)}%`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "channelUtilization",
|
key: "channelUtilization",
|
||||||
label: "Channel utilization",
|
label: t("nodeDetails.channelUtilization"),
|
||||||
value: node.deviceMetrics?.channelUtilization,
|
value: node.deviceMetrics?.channelUtilization,
|
||||||
format: (val: number) => `${val.toFixed(2)}%`,
|
format: (val: number) => `${val.toFixed(2)}%`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "batteryLevel",
|
key: "batteryLevel",
|
||||||
label: "Battery level",
|
label: t("nodeDetails.batteryLevel"),
|
||||||
value: node.deviceMetrics?.batteryLevel,
|
value: node.deviceMetrics?.batteryLevel,
|
||||||
format: (val: number) => `${val.toFixed(2)}%`,
|
format: (val: number) => `${val.toFixed(2)}%`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "voltage",
|
key: "voltage",
|
||||||
label: "Voltage",
|
label: t("nodeDetails.voltage"),
|
||||||
value: node.deviceMetrics?.voltage,
|
value: node.deviceMetrics?.voltage,
|
||||||
format: (val: number) => `${val.toFixed(2)}V`,
|
format: (val: number) => `${val.toFixed(2)}V`,
|
||||||
},
|
},
|
||||||
@@ -169,20 +168,31 @@ export const NodeDetailsDialog = ({
|
|||||||
<DialogClose />
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
Node Details for {node.user?.longName ?? "UNKNOWN"} (
|
{t("nodeDetails.title", {
|
||||||
{node.user?.shortName ?? "UNK"})
|
identifier: `${node.user?.longName ?? t("unknown.shortName")} (${
|
||||||
|
node.user?.shortName ?? t("unknown.shortName")
|
||||||
|
})`,
|
||||||
|
})}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex flex-row flex-wrap space-y-1">
|
<div className="flex flex-row flex-wrap space-y-1">
|
||||||
<Button className="mr-1" onClick={handleDirectMessage}>
|
<Button
|
||||||
|
className="mr-1"
|
||||||
|
name="message"
|
||||||
|
onClick={handleDirectMessage}
|
||||||
|
>
|
||||||
<MessageSquareIcon className="mr-2" />
|
<MessageSquareIcon className="mr-2" />
|
||||||
Message
|
{t("nodeDetails.message")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="mr-1" onClick={handleTraceroute}>
|
<Button
|
||||||
|
className="mr-1"
|
||||||
|
name="traceRoute"
|
||||||
|
onClick={handleTraceroute}
|
||||||
|
>
|
||||||
<WaypointsIcon className="mr-2" />
|
<WaypointsIcon className="mr-2" />
|
||||||
Trace Route
|
{t("nodeDetails.traceRoute")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="mr-1" onClick={handleToggleFavorite}>
|
<Button className="mr-1" onClick={handleToggleFavorite}>
|
||||||
<StarIcon
|
<StarIcon
|
||||||
@@ -209,7 +219,9 @@ export const NodeDetailsDialog = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
||||||
{isIgnoredState ? "Unignore node" : "Ignore node"}
|
{isIgnoredState
|
||||||
|
? t("nodeDetails.unignoreNode")
|
||||||
|
: t("nodeDetails.ignoreNode")}
|
||||||
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
|
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -227,7 +239,7 @@ export const NodeDetailsDialog = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
||||||
Remove node
|
{t("nodeDetails.removeNode")}
|
||||||
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
|
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -239,23 +251,30 @@ export const NodeDetailsDialog = ({
|
|||||||
<div className="flex flex-col flex-wrap space-x-1 space-y-1">
|
<div className="flex flex-col flex-wrap space-x-1 space-y-1">
|
||||||
<div className="flex flex-row space-x-2">
|
<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">
|
<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">Details:</p>
|
<p className="text-lg font-semibold">
|
||||||
<p>Node Number: {node.num}</p>
|
{t("nodeDetails.details")}
|
||||||
<p>Node Hex: !{numberToHexUnpadded(node.num)}</p>
|
</p>
|
||||||
|
<p>{t("nodeDetails.nodeNumber")}{node.num}</p>
|
||||||
<p>
|
<p>
|
||||||
Role: {Protobuf.Config.Config_DeviceConfig_Role[
|
{t("nodeDetails.nodeHexPrefix")}
|
||||||
|
{numberToHexUnpadded(node.num)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{t("nodeDetails.role")}
|
||||||
|
{Protobuf.Config.Config_DeviceConfig_Role[
|
||||||
node.user?.role ?? 0
|
node.user?.role ?? 0
|
||||||
].replace(/_/g, " ")}
|
].replace(/_/g, " ")}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Last Heard: {node.lastHeard === 0
|
{t("nodeDetails.lastHeard")}
|
||||||
? "Never"
|
{node.lastHeard === 0
|
||||||
|
? t("nodesTable.lastHeardStatus.never", { ns: "nodes" })
|
||||||
: <TimeAgo timestamp={node.lastHeard * 1000} />}
|
: <TimeAgo timestamp={node.lastHeard * 1000} />}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Hardware:{" "}
|
{t("nodeDetails.hardware")}
|
||||||
{(Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ??
|
{(Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ??
|
||||||
"Unknown")
|
t("unknown.shortName"))
|
||||||
.replace(/_/g, " ")}
|
.replace(/_/g, " ")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,7 +288,9 @@ export const NodeDetailsDialog = ({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
||||||
<p className="text-lg font-semibold">Position:</p>
|
<p className="text-lg font-semibold">
|
||||||
|
{t("nodeDetails.position")}
|
||||||
|
</p>
|
||||||
|
|
||||||
{node.position
|
{node.position
|
||||||
? (
|
? (
|
||||||
@@ -277,7 +298,7 @@ export const NodeDetailsDialog = ({
|
|||||||
{node.position.latitudeI &&
|
{node.position.latitudeI &&
|
||||||
node.position.longitudeI && (
|
node.position.longitudeI && (
|
||||||
<p>
|
<p>
|
||||||
Coordinates:{" "}
|
{t("locationResponse.coordinates")}
|
||||||
<a
|
<a
|
||||||
className="text-blue-500 dark:text-blue-400"
|
className="text-blue-500 dark:text-blue-400"
|
||||||
href={`https://www.openstreetmap.org/?mlat=${
|
href={`https://www.openstreetmap.org/?mlat=${
|
||||||
@@ -292,21 +313,29 @@ export const NodeDetailsDialog = ({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{node.position.altitude && (
|
{node.position.altitude && (
|
||||||
<p>Altitude: {node.position.altitude}m</p>
|
<p>
|
||||||
|
{t("locationResponse.altitude")}
|
||||||
|
{node.position.altitude}
|
||||||
|
{t("unit.meter.one")}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: <p>Unknown</p>}
|
: <p>{t("unknown.shortName")}</p>}
|
||||||
<Button onClick={handleRequestPosition} className="mt-2">
|
<Button
|
||||||
|
onClick={handleRequestPosition}
|
||||||
|
name="requestPosition"
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
<MapPinnedIcon className="mr-2" />
|
<MapPinnedIcon className="mr-2" />
|
||||||
Request Position
|
{t("nodeDetails.requestPosition")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{node.deviceMetrics && (
|
{node.deviceMetrics && (
|
||||||
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
||||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||||
Device Metrics:
|
{t("nodeDetails.deviceMetrics")}
|
||||||
</p>
|
</p>
|
||||||
{deviceMetricsMap.map(
|
{deviceMetricsMap.map(
|
||||||
(metric) =>
|
(metric) =>
|
||||||
@@ -318,7 +347,7 @@ export const NodeDetailsDialog = ({
|
|||||||
)}
|
)}
|
||||||
{node.deviceMetrics.uptimeSeconds && (
|
{node.deviceMetrics.uptimeSeconds && (
|
||||||
<p>
|
<p>
|
||||||
Uptime:{" "}
|
{t("nodeDetails.uptime")}
|
||||||
<Uptime seconds={node.deviceMetrics.uptimeSeconds} />
|
<Uptime seconds={node.deviceMetrics.uptimeSeconds} />
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -331,7 +360,7 @@ export const NodeDetailsDialog = ({
|
|||||||
<AccordionItem className="AccordionItem" value="item-1">
|
<AccordionItem className="AccordionItem" value="item-1">
|
||||||
<AccordionTrigger>
|
<AccordionTrigger>
|
||||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
|
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
|
||||||
All Raw Metrics:
|
{t("nodeDetails.allRawMetrics")}
|
||||||
</p>
|
</p>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="overflow-x-scroll">
|
<AccordionContent className="overflow-x-scroll">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { fromByteArray } from "base64-js";
|
import { fromByteArray } from "base64-js";
|
||||||
import { DownloadIcon, PrinterIcon } from "lucide-react";
|
import { DownloadIcon, PrinterIcon } from "lucide-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface PkiBackupDialogProps {
|
export interface PkiBackupDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -22,7 +23,8 @@ export const PkiBackupDialog = ({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: PkiBackupDialogProps) => {
|
}: PkiBackupDialogProps) => {
|
||||||
const { config, setDialogOpen } = useDevice();
|
const { t } = useTranslation("dialog");
|
||||||
|
const { config, setDialogOpen, getMyNode } = useDevice();
|
||||||
const privateKey = config.security?.privateKey;
|
const privateKey = config.security?.privateKey;
|
||||||
const publicKey = config.security?.publicKey;
|
const publicKey = config.security?.publicKey;
|
||||||
|
|
||||||
@@ -46,7 +48,12 @@ export const PkiBackupDialog = ({
|
|||||||
printWindow.document.write(`
|
printWindow.document.write(`
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>=== MESHTASTIC KEYS ===</title>
|
<title>${
|
||||||
|
t("pkiBackup.header", {
|
||||||
|
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
|
||||||
|
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
|
||||||
|
})
|
||||||
|
}</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: Arial, sans-serif; padding: 20px; }
|
body { font-family: Arial, sans-serif; padding: 20px; }
|
||||||
h1 { font-size: 18px; }
|
h1 { font-size: 18px; }
|
||||||
@@ -54,14 +61,18 @@ export const PkiBackupDialog = ({
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>=== MESHTASTIC KEYS ===</h1>
|
<h1>${
|
||||||
<br>
|
t("pkiBackup.header", {
|
||||||
<h2>Public Key:</h2>
|
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
|
||||||
|
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
|
||||||
|
})
|
||||||
|
}</h1>
|
||||||
|
<h3>${t("pkiBackup.secureBackup")}</h3>
|
||||||
|
<h3>${t("pkiBackup.publicKey")}</h3>
|
||||||
<p>${decodeKeyData(publicKey)}</p>
|
<p>${decodeKeyData(publicKey)}</p>
|
||||||
<h2>Private Key:</h2>
|
<h3>${t("pkiBackup.privateKey")}</h3>
|
||||||
<p>${decodeKeyData(privateKey)}</p>
|
<p>${decodeKeyData(privateKey)}</p>
|
||||||
<br>
|
<p>${t("pkiBackup.footer")}</p>
|
||||||
<p>=== END OF KEYS ===</p>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`);
|
||||||
@@ -69,7 +80,7 @@ export const PkiBackupDialog = ({
|
|||||||
printWindow.print();
|
printWindow.print();
|
||||||
closeDialog();
|
closeDialog();
|
||||||
}
|
}
|
||||||
}, [decodeKeyData, privateKey, publicKey, closeDialog]);
|
}, [decodeKeyData, privateKey, publicKey, closeDialog, t]);
|
||||||
|
|
||||||
const createDownloadKeyFile = React.useCallback(() => {
|
const createDownloadKeyFile = React.useCallback(() => {
|
||||||
if (!privateKey || !publicKey) return;
|
if (!privateKey || !publicKey) return;
|
||||||
@@ -78,12 +89,12 @@ export const PkiBackupDialog = ({
|
|||||||
const decodedPublicKey = decodeKeyData(publicKey);
|
const decodedPublicKey = decodeKeyData(publicKey);
|
||||||
|
|
||||||
const formattedContent = [
|
const formattedContent = [
|
||||||
"=== MESHTASTIC KEYS ===\n\n",
|
`${t("pkiBackup.header")}\n\n`,
|
||||||
"Private Key:\n",
|
`${t("pkiBackup.privateKey")}\n`,
|
||||||
decodedPrivateKey,
|
decodedPrivateKey,
|
||||||
"\n\nPublic Key:\n",
|
`\n\n${t("pkiBackup.publicKey")}\n`,
|
||||||
decodedPublicKey,
|
decodedPublicKey,
|
||||||
"\n\n=== END OF KEYS ===",
|
`\n\n${t("pkiBackup.footer")}`,
|
||||||
].join("");
|
].join("");
|
||||||
|
|
||||||
const blob = new Blob([formattedContent], { type: "text/plain" });
|
const blob = new Blob([formattedContent], { type: "text/plain" });
|
||||||
@@ -91,43 +102,47 @@ export const PkiBackupDialog = ({
|
|||||||
|
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = "meshtastic_keys.txt";
|
link.download = t("pkiBackup.fileName", {
|
||||||
|
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
|
||||||
|
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
|
||||||
|
});
|
||||||
|
|
||||||
link.style.display = "none";
|
link.style.display = "none";
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
closeDialog();
|
closeDialog();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}, [decodeKeyData, privateKey, publicKey, closeDialog]);
|
}, [decodeKeyData, privateKey, publicKey, closeDialog, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogClose />
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Backup Keys</DialogTitle>
|
<DialogTitle>{t("pkiBackup.title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Its important to backup your public and private keys and store your
|
{t("pkiBackup.secureBackup")}
|
||||||
backup securely!
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<span className="font-bold break-before-auto">
|
<span className="font-bold break-before-auto">
|
||||||
If you lose your keys, you will need to reset your device.
|
{t("pkiBackup.loseKeysWarning")}
|
||||||
</span>
|
</span>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter className="mt-6">
|
<DialogFooter className="mt-6">
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
|
name="download"
|
||||||
onClick={() => createDownloadKeyFile()}
|
onClick={() => createDownloadKeyFile()}
|
||||||
className=""
|
className=""
|
||||||
>
|
>
|
||||||
<DownloadIcon size={20} className="mr-2" />
|
<DownloadIcon size={20} className="mr-2" />
|
||||||
Download
|
{t("button.download")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="default" onClick={() => renderPrintWindow()}>
|
<Button variant="default" onClick={() => renderPrintWindow()}>
|
||||||
<PrinterIcon size={20} className="mr-2" />
|
<PrinterIcon size={20} className="mr-2" />
|
||||||
Print
|
{t("button.print")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@components/UI/Dialog.tsx";
|
} from "@components/UI/Dialog.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface PkiRegenerateDialogProps {
|
export interface PkiRegenerateDialogProps {
|
||||||
text: {
|
text: {
|
||||||
@@ -22,27 +23,38 @@ export interface PkiRegenerateDialogProps {
|
|||||||
|
|
||||||
export const PkiRegenerateDialog = ({
|
export const PkiRegenerateDialog = ({
|
||||||
text = {
|
text = {
|
||||||
title: "Regenerate Key Pair",
|
title: "",
|
||||||
description: "Are you sure you want to regenerate key pair?",
|
description: "",
|
||||||
button: "Regenerate",
|
button: "",
|
||||||
},
|
},
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: PkiRegenerateDialogProps) => {
|
}: PkiRegenerateDialogProps) => {
|
||||||
|
const { t } = useTranslation("dialog");
|
||||||
|
const dialogText = {
|
||||||
|
title: text.title || t("pkiRegenerate.title"),
|
||||||
|
description: text.description ||
|
||||||
|
t("pkiRegenerate.description"),
|
||||||
|
button: text.button || t("button.regenerate"),
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogClose />
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{text?.title}</DialogTitle>
|
<DialogTitle>{dialogText.title}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{text?.description}
|
{dialogText.description}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="destructive" onClick={() => onSubmit()}>
|
<Button
|
||||||
{text?.button}
|
variant="destructive"
|
||||||
|
name="regenerate"
|
||||||
|
onClick={() => onSubmit()}
|
||||||
|
>
|
||||||
|
{dialogText.button}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import { Input } from "@components/UI/Input.tsx";
|
|||||||
import { Label } from "@components/UI/Label.tsx";
|
import { Label } from "@components/UI/Label.tsx";
|
||||||
import { Protobuf, type Types } from "@meshtastic/core";
|
import { Protobuf, type Types } from "@meshtastic/core";
|
||||||
import { fromByteArray } from "base64-js";
|
import { fromByteArray } from "base64-js";
|
||||||
import { ClipboardIcon } from "lucide-react";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { QRCode } from "react-qrcode-logo";
|
import { QRCode } from "react-qrcode-logo";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface QRDialogProps {
|
export interface QRDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -30,6 +30,7 @@ export const QRDialog = ({
|
|||||||
loraConfig,
|
loraConfig,
|
||||||
channels,
|
channels,
|
||||||
}: QRDialogProps) => {
|
}: QRDialogProps) => {
|
||||||
|
const { t } = useTranslation("dialog");
|
||||||
const [selectedChannels, setSelectedChannels] = useState<number[]>([0]);
|
const [selectedChannels, setSelectedChannels] = useState<number[]>([0]);
|
||||||
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
|
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
|
||||||
const [qrCodeAdd, setQrCodeAdd] = useState<boolean>();
|
const [qrCodeAdd, setQrCodeAdd] = useState<boolean>();
|
||||||
@@ -65,9 +66,9 @@ export const QRDialog = ({
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogClose />
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Generate QR Code</DialogTitle>
|
<DialogTitle>{t("qr.title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
The current LoRa configuration will also be shared.
|
{t("qr.description")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
@@ -79,13 +80,18 @@ export const QRDialog = ({
|
|||||||
{channel.settings?.name.length
|
{channel.settings?.name.length
|
||||||
? channel.settings.name
|
? channel.settings.name
|
||||||
: channel.role === Protobuf.Channel.Channel_Role.PRIMARY
|
: channel.role === Protobuf.Channel.Channel_Role.PRIMARY
|
||||||
? "Primary"
|
? t("page.broadcastLabel", { ns: "channels" })
|
||||||
: `Channel: ${channel.index}`}
|
: `${
|
||||||
|
t("page.channelIndex", {
|
||||||
|
ns: "channels",
|
||||||
|
index: channel.index,
|
||||||
|
})
|
||||||
|
}${channel.index}`}
|
||||||
</Label>
|
</Label>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
key={channel.index}
|
key={channel.index}
|
||||||
checked={selectedChannels.includes(channel.index)}
|
checked={selectedChannels.includes(channel.index)}
|
||||||
onCheckedChange={() => {
|
onChange={() => {
|
||||||
if (selectedChannels.includes(channel.index)) {
|
if (selectedChannels.includes(channel.index)) {
|
||||||
setSelectedChannels(
|
setSelectedChannels(
|
||||||
selectedChannels.filter((c) =>
|
selectedChannels.filter((c) =>
|
||||||
@@ -113,9 +119,10 @@ export const QRDialog = ({
|
|||||||
? "focus:ring-green-800 bg-green-800 text-white"
|
? "focus:ring-green-800 bg-green-800 text-white"
|
||||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||||
}`}
|
}`}
|
||||||
|
name="addChannels"
|
||||||
onClick={() => setQrCodeAdd(true)}
|
onClick={() => setQrCodeAdd(true)}
|
||||||
>
|
>
|
||||||
Add Channels
|
{t("qr.addChannels")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -124,24 +131,18 @@ export const QRDialog = ({
|
|||||||
? "focus:ring-green-800 bg-green-800 text-white"
|
? "focus:ring-green-800 bg-green-800 text-white"
|
||||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||||
}`}
|
}`}
|
||||||
|
name="replaceChannels"
|
||||||
onClick={() => setQrCodeAdd(false)}
|
onClick={() => setQrCodeAdd(false)}
|
||||||
>
|
>
|
||||||
Replace Channels
|
{t("qr.replaceChannels")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Label>Sharable URL</Label>
|
<Label>{t("qr.sharableUrl")}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={qrCodeUrl}
|
value={qrCodeUrl}
|
||||||
disabled
|
disabled
|
||||||
action={{
|
|
||||||
key: "copy-value",
|
|
||||||
icon: ClipboardIcon,
|
|
||||||
onClick() {
|
|
||||||
void navigator.clipboard.writeText(qrCodeUrl);
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
} from "@components/UI/Dialog.tsx";
|
} from "@components/UI/Dialog.tsx";
|
||||||
import { Input } from "@components/UI/Input.tsx";
|
import { Input } from "@components/UI/Input.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { ClockIcon, RefreshCwIcon } from "lucide-react";
|
import { RefreshCwIcon } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export interface RebootDialogProps {
|
export interface RebootDialogProps {
|
||||||
@@ -21,6 +22,7 @@ export const RebootDialog = ({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: RebootDialogProps) => {
|
}: RebootDialogProps) => {
|
||||||
|
const { t } = useTranslation("dialog");
|
||||||
const { connection } = useDevice();
|
const { connection } = useDevice();
|
||||||
|
|
||||||
const [time, setTime] = useState<number>(5);
|
const [time, setTime] = useState<number>(5);
|
||||||
@@ -30,9 +32,11 @@ export const RebootDialog = ({
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogClose />
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Schedule Reboot</DialogTitle>
|
<DialogTitle>
|
||||||
|
{t("reboot.title")}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Reboot the connected node after x minutes.
|
{t("reboot.description")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex gap-2 p-4">
|
<div className="flex gap-2 p-4">
|
||||||
@@ -41,21 +45,16 @@ export const RebootDialog = ({
|
|||||||
className="dark:text-slate-900"
|
className="dark:text-slate-900"
|
||||||
value={time}
|
value={time}
|
||||||
onChange={(e) => setTime(Number.parseInt(e.target.value))}
|
onChange={(e) => setTime(Number.parseInt(e.target.value))}
|
||||||
action={{
|
|
||||||
icon: ClockIcon,
|
|
||||||
onClick() {
|
|
||||||
connection?.reboot(time * 60).then(() => onOpenChange(false));
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
className="w-24"
|
className="w-24"
|
||||||
|
name="now"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
connection?.reboot(2).then(() => onOpenChange(false));
|
connection?.reboot(2).then(() => onOpenChange(false));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className="mr-2" size={16} />
|
<RefreshCwIcon className="mr-2" size={16} />
|
||||||
Now
|
{t("button.now")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { RebootOTADialog } from "./RebootOTADialog.tsx";
|
import { RebootOTADialog } from "./RebootOTADialog.tsx";
|
||||||
import { ReactNode } from "react";
|
import {
|
||||||
|
ButtonHTMLAttributes,
|
||||||
|
ClassAttributes,
|
||||||
|
InputHTMLAttributes,
|
||||||
|
ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { JSX } from "react/jsx-runtime";
|
||||||
|
|
||||||
const rebootOtaMock = vi.fn();
|
const rebootOtaMock = vi.fn();
|
||||||
let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
|
let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
|
||||||
@@ -18,7 +24,12 @@ vi.mock("@components/UI/Button.tsx", async () => {
|
|||||||
const actual = await vi.importActual("@components/UI/Button.tsx");
|
const actual = await vi.importActual("@components/UI/Button.tsx");
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
Button: (props) => <button {...props} />,
|
Button: (
|
||||||
|
props:
|
||||||
|
& JSX.IntrinsicAttributes
|
||||||
|
& ClassAttributes<HTMLButtonElement>
|
||||||
|
& ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
) => <button {...props} />,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,7 +37,12 @@ vi.mock("@components/UI/Input.tsx", async () => {
|
|||||||
const actual = await vi.importActual("@components/UI/Input.tsx");
|
const actual = await vi.importActual("@components/UI/Input.tsx");
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
Input: (props) => <input {...props} />,
|
Input: (
|
||||||
|
props:
|
||||||
|
& JSX.IntrinsicAttributes
|
||||||
|
& ClassAttributes<HTMLInputElement>
|
||||||
|
& InputHTMLAttributes<HTMLInputElement>,
|
||||||
|
) => <input {...props} />,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,7 +76,8 @@ describe("RebootOTADialog", () => {
|
|||||||
it("renders dialog with default input value", () => {
|
it("renders dialog with default input value", () => {
|
||||||
render(<RebootOTADialog open onOpenChange={() => {}} />);
|
render(<RebootOTADialog open onOpenChange={() => {}} />);
|
||||||
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
|
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
|
||||||
expect(screen.getByText(/schedule reboot/i)).toBeInTheDocument();
|
expect(screen.getByRole("heading", { name: /schedule reboot/i, level: 1 }))
|
||||||
|
.toBeInTheDocument();
|
||||||
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument();
|
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,7 +89,7 @@ describe("RebootOTADialog", () => {
|
|||||||
target: { value: "3" },
|
target: { value: "3" },
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByText(/schedule reboot/i));
|
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
|
||||||
|
|
||||||
expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument();
|
expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument();
|
||||||
|
|
||||||
@@ -99,12 +116,11 @@ describe("RebootOTADialog", () => {
|
|||||||
it("does not call reboot if connection is undefined", async () => {
|
it("does not call reboot if connection is undefined", async () => {
|
||||||
const onOpenChangeMock = vi.fn();
|
const onOpenChangeMock = vi.fn();
|
||||||
|
|
||||||
// simulate no connection
|
|
||||||
mockConnection = undefined;
|
mockConnection = undefined;
|
||||||
|
|
||||||
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
|
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText(/schedule reboot/i));
|
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -112,7 +128,6 @@ describe("RebootOTADialog", () => {
|
|||||||
expect(onOpenChangeMock).not.toHaveBeenCalled();
|
expect(onOpenChangeMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
// reset connection for other tests
|
|
||||||
mockConnection = { rebootOta: rebootOtaMock };
|
mockConnection = { rebootOta: rebootOtaMock };
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@components/UI/Dialog.tsx";
|
} from "@components/UI/Dialog.tsx";
|
||||||
import { Input } from "@components/UI/Input.tsx";
|
import { Input } from "@components/UI/Input.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface RebootOTADialogProps {
|
export interface RebootOTADialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -22,6 +23,7 @@ const DEFAULT_REBOOT_DELAY = 5; // seconds
|
|||||||
export const RebootOTADialog = (
|
export const RebootOTADialog = (
|
||||||
{ open, onOpenChange }: RebootOTADialogProps,
|
{ open, onOpenChange }: RebootOTADialogProps,
|
||||||
) => {
|
) => {
|
||||||
|
const { t } = useTranslation("dialog");
|
||||||
const { connection } = useDevice();
|
const { connection } = useDevice();
|
||||||
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
|
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
|
||||||
const [isScheduled, setIsScheduled] = useState(false);
|
const [isScheduled, setIsScheduled] = useState(false);
|
||||||
@@ -50,7 +52,6 @@ export const RebootOTADialog = (
|
|||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log("Rebooting...");
|
|
||||||
resolve();
|
resolve();
|
||||||
}, delay * 1000);
|
}, delay * 1000);
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
@@ -73,10 +74,11 @@ export const RebootOTADialog = (
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogClose />
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Reboot to OTA Mode</DialogTitle>
|
<DialogTitle>
|
||||||
|
{t("rebootOta.title")}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Reboot the connected node after a delay into OTA (Over-the-Air)
|
{t("rebootOta.description")}
|
||||||
mode.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -88,17 +90,25 @@ export const RebootOTADialog = (
|
|||||||
className="dark:text-slate-900 appearance-none"
|
className="dark:text-slate-900 appearance-none"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={handleSetTime}
|
onChange={handleSetTime}
|
||||||
placeholder="Enter delay (sec)"
|
placeholder={t("rebootOta.enterDelay")}
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => handleRebootWithTimeout()} className="w-9/12">
|
<Button
|
||||||
|
onClick={() => handleRebootWithTimeout()}
|
||||||
|
data-testid="scheduleRebootBtn"
|
||||||
|
className="w-9/12"
|
||||||
|
>
|
||||||
<ClockIcon className="mr-2" size={18} />
|
<ClockIcon className="mr-2" size={18} />
|
||||||
{isScheduled ? "Reboot has been scheduled" : "Schedule Reboot"}
|
{isScheduled ? t("rebootOta.scheduled") : t("rebootOta.title")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button variant="destructive" onClick={() => handleInstantReboot()}>
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
name="rebootNow"
|
||||||
|
onClick={() => handleInstantReboot()}
|
||||||
|
>
|
||||||
<RefreshCwIcon className="mr-2" size={16} />
|
<RefreshCwIcon className="mr-2" size={16} />
|
||||||
Reboot to OTA Mode Now
|
{t("button.rebootOtaNow")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Button } from "@components/UI/Button.tsx";
|
|||||||
import { LockKeyholeOpenIcon } from "lucide-react";
|
import { LockKeyholeOpenIcon } from "lucide-react";
|
||||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||||
|
|
||||||
export interface RefreshKeysDialogProps {
|
export interface RefreshKeysDialogProps {
|
||||||
@@ -19,6 +20,7 @@ export interface RefreshKeysDialogProps {
|
|||||||
export const RefreshKeysDialog = (
|
export const RefreshKeysDialog = (
|
||||||
{ open, onOpenChange }: RefreshKeysDialogProps,
|
{ open, onOpenChange }: RefreshKeysDialogProps,
|
||||||
) => {
|
) => {
|
||||||
|
const { t } = useTranslation("dialog");
|
||||||
const { activeChat } = useMessageStore();
|
const { activeChat } = useMessageStore();
|
||||||
const { nodeErrors, getNode } = useDevice();
|
const { nodeErrors, getNode } = useDevice();
|
||||||
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
|
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
|
||||||
@@ -32,13 +34,16 @@ export const RefreshKeysDialog = (
|
|||||||
const nodeWithError = getNode(nodeErrorNum.node);
|
const nodeWithError = getNode(nodeErrorNum.node);
|
||||||
|
|
||||||
const text = {
|
const text = {
|
||||||
title: `Keys Mismatch - ${nodeWithError?.user?.longName ?? ""}`,
|
title: t("refreshKeys.title", {
|
||||||
description: `Your node is unable to send a direct message to node: ${
|
identifier: nodeWithError?.user?.longName ?? "",
|
||||||
|
}),
|
||||||
|
description: `${t("refreshKeys.description.unableToSendDmPrefix")}${
|
||||||
nodeWithError?.user?.longName ?? ""
|
nodeWithError?.user?.longName ?? ""
|
||||||
} (${
|
} (${nodeWithError?.user?.shortName ?? ""})${
|
||||||
nodeWithError?.user?.shortName ?? ""
|
t("refreshKeys.description.keyMismatchReasonSuffix")
|
||||||
}). This is due to the remote node's current public key does not match the previously stored key for this node.`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
@@ -60,22 +65,26 @@ export const RefreshKeysDialog = (
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-bold mb-0.5">Accept New Keys</p>
|
<p className="font-bold mb-0.5">
|
||||||
|
{t("refreshKeys.label.acceptNewKeys")}
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
This will remove the node from device and request new keys.
|
{t("refreshKeys.description.acceptNewKeys")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
|
name="requestNewKeys"
|
||||||
onClick={handleNodeRemove}
|
onClick={handleNodeRemove}
|
||||||
>
|
>
|
||||||
Request New Keys
|
{t("button.requestNewKeys")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
name="dismiss"
|
||||||
onClick={handleCloseDialog}
|
onClick={handleCloseDialog}
|
||||||
>
|
>
|
||||||
Dismiss
|
{t("button.dismiss")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import { act, renderHook } from "@testing-library/react";
|
|
||||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
|
||||||
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
|
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
|
||||||
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
|
||||||
|
|
||||||
vi.mock("@core/stores/messageStore", () => ({
|
|
||||||
useMessageStore: vi.fn(() => ({ activeChat: "chat-123" })),
|
|
||||||
}));
|
|
||||||
vi.mock("@core/stores/deviceStore", () => ({
|
|
||||||
useDevice: vi.fn(() => ({
|
|
||||||
removeNode: vi.fn(),
|
|
||||||
setDialogOpen: vi.fn(),
|
|
||||||
getNodeError: vi.fn(),
|
|
||||||
clearNodeError: vi.fn(),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("useRefreshKeysDialog Hook", () => {
|
|
||||||
let removeNodeMock: Mock;
|
|
||||||
let setDialogOpenMock: Mock;
|
|
||||||
let getNodeErrorMock: Mock;
|
|
||||||
let clearNodeErrorMock: Mock;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
|
|
||||||
removeNodeMock = vi.fn();
|
|
||||||
setDialogOpenMock = vi.fn();
|
|
||||||
getNodeErrorMock = vi.fn().mockReturnValue(undefined);
|
|
||||||
clearNodeErrorMock = vi.fn();
|
|
||||||
|
|
||||||
vi.mocked(useDevice).mockReturnValue({
|
|
||||||
removeNode: removeNodeMock,
|
|
||||||
setDialogOpen: setDialogOpenMock,
|
|
||||||
getNodeError: getNodeErrorMock,
|
|
||||||
clearNodeError: clearNodeErrorMock,
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mocked(useMessageStore).mockReturnValue({
|
|
||||||
activeChat: "chat-123",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handleNodeRemove should remove the node and update dialog if there is an error", () => {
|
|
||||||
getNodeErrorMock.mockReturnValue({ node: "node-abc" });
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useRefreshKeysDialog());
|
|
||||||
act(() => {
|
|
||||||
result.current.handleNodeRemove();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
|
|
||||||
expect(clearNodeErrorMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123");
|
|
||||||
expect(removeNodeMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(removeNodeMock).toHaveBeenCalledWith("node-abc");
|
|
||||||
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handleNodeRemove should do nothing if there is no error", () => {
|
|
||||||
const { result } = renderHook(() => useRefreshKeysDialog());
|
|
||||||
act(() => {
|
|
||||||
result.current.handleNodeRemove();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
|
|
||||||
expect(clearNodeErrorMock).not.toHaveBeenCalled();
|
|
||||||
expect(removeNodeMock).not.toHaveBeenCalled();
|
|
||||||
expect(setDialogOpenMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handleCloseDialog should close the dialog", () => {
|
|
||||||
const { result } = renderHook(() => useRefreshKeysDialog());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.handleCloseDialog();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@components/UI/Dialog.tsx";
|
} from "@components/UI/Dialog.tsx";
|
||||||
import { Label } from "@components/UI/Label.tsx";
|
import { Label } from "@components/UI/Label.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface RemoveNodeDialogProps {
|
export interface RemoveNodeDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -21,6 +22,7 @@ export const RemoveNodeDialog = ({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: RemoveNodeDialogProps) => {
|
}: RemoveNodeDialogProps) => {
|
||||||
|
const { t } = useTranslation("dialog");
|
||||||
const { connection, getNode, removeNode } = useDevice();
|
const { connection, getNode, removeNode } = useDevice();
|
||||||
const { nodeNumToBeRemoved } = useAppStore();
|
const { nodeNumToBeRemoved } = useAppStore();
|
||||||
|
|
||||||
@@ -35,9 +37,9 @@ export const RemoveNodeDialog = ({
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogClose />
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Remove Node?</DialogTitle>
|
<DialogTitle>{t("removeNode.title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to remove this Node?
|
{t("removeNode.description")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="gap-4">
|
<div className="gap-4">
|
||||||
@@ -46,8 +48,12 @@ export const RemoveNodeDialog = ({
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="destructive" onClick={() => onSubmit()}>
|
<Button
|
||||||
Remove
|
variant="destructive"
|
||||||
|
name="remove"
|
||||||
|
onClick={() => onSubmit()}
|
||||||
|
>
|
||||||
|
{t("button.remove")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { Input } from "@components/UI/Input.tsx";
|
import { Input } from "@components/UI/Input.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { ClockIcon, PowerIcon } from "lucide-react";
|
import { ClockIcon, PowerIcon } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export interface ShutdownDialogProps {
|
export interface ShutdownDialogProps {
|
||||||
@@ -21,6 +22,7 @@ export const ShutdownDialog = ({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: ShutdownDialogProps) => {
|
}: ShutdownDialogProps) => {
|
||||||
|
const { t } = useTranslation("dialog");
|
||||||
const { connection } = useDevice();
|
const { connection } = useDevice();
|
||||||
|
|
||||||
const [time, setTime] = useState<number>(5);
|
const [time, setTime] = useState<number>(5);
|
||||||
@@ -30,9 +32,11 @@ export const ShutdownDialog = ({
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogClose />
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Schedule Shutdown</DialogTitle>
|
<DialogTitle>
|
||||||
|
{t("shutdown.title")}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Turn off the connected node after x minutes.
|
{t("shutdown.description")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -41,7 +45,7 @@ export const ShutdownDialog = ({
|
|||||||
type="number"
|
type="number"
|
||||||
value={time}
|
value={time}
|
||||||
onChange={(e) => setTime(Number.parseInt(e.target.value))}
|
onChange={(e) => setTime(Number.parseInt(e.target.value))}
|
||||||
suffix="Minutes"
|
suffix={t("unit.minute.plural")}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
className="w-24"
|
className="w-24"
|
||||||
@@ -53,12 +57,13 @@ export const ShutdownDialog = ({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="w-24"
|
className="w-24"
|
||||||
|
name="now"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
connection?.shutdown(2).then(() => () => onOpenChange(false));
|
connection?.shutdown(2).then(() => () => onOpenChange(false));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PowerIcon className="mr-2" size={16} />
|
<PowerIcon className="mr-2" size={16} />
|
||||||
Now
|
{t("button.now")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useDevice } from "../../core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
@@ -11,6 +11,7 @@ import type { Protobuf, Types } from "@meshtastic/core";
|
|||||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||||
|
|
||||||
import { TraceRoute } from "../PageComponents/Messages/TraceRoute.tsx";
|
import { TraceRoute } from "../PageComponents/Messages/TraceRoute.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface TracerouteResponseDialogProps {
|
export interface TracerouteResponseDialogProps {
|
||||||
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined;
|
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined;
|
||||||
@@ -23,30 +24,43 @@ export const TracerouteResponseDialog = ({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: TracerouteResponseDialogProps) => {
|
}: TracerouteResponseDialogProps) => {
|
||||||
|
const { t } = useTranslation("dialog");
|
||||||
const { getNode } = useDevice();
|
const { getNode } = useDevice();
|
||||||
const route: number[] = traceroute?.data.route ?? [];
|
const route: number[] = traceroute?.data.route ?? [];
|
||||||
const routeBack: number[] = traceroute?.data.routeBack ?? [];
|
const routeBack: number[] = traceroute?.data.routeBack ?? [];
|
||||||
const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
|
const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
|
||||||
const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
|
const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
|
||||||
const from = getNode(traceroute?.from ?? 0);
|
const from = getNode(traceroute?.from ?? 0);
|
||||||
const longName = from?.user?.longName ??
|
const fromLongName = from?.user?.longName ??
|
||||||
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
|
(from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
|
||||||
const shortName = from?.user?.shortName ??
|
const fromShortName = from?.user?.shortName ??
|
||||||
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK");
|
(from
|
||||||
const to = getNode(traceroute?.to ?? 0);
|
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
|
||||||
|
: t("unknown.shortName"));
|
||||||
|
|
||||||
|
const toUser = getNode(traceroute?.to ?? 0);
|
||||||
|
|
||||||
|
if (!toUser || !from) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogClose />
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{`Traceroute: ${longName} (${shortName})`}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{t("tracerouteResponse.title", {
|
||||||
|
identifier: `${fromLongName} (${fromShortName})`,
|
||||||
|
})}
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<TraceRoute
|
<TraceRoute
|
||||||
route={route}
|
route={route}
|
||||||
routeBack={routeBack}
|
routeBack={routeBack}
|
||||||
from={from}
|
from={{ user: from.user }}
|
||||||
to={to}
|
to={{ user: toUser.user }}
|
||||||
snrTowards={snrTowards}
|
snrTowards={snrTowards}
|
||||||
snrBack={snrBack}
|
snrBack={snrBack}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
// deno-lint-ignore-file
|
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
|
||||||
import { eventBus } from "@core/utils/eventBus.ts";
|
|
||||||
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
|
||||||
|
|
||||||
describe("UnsafeRolesDialog", () => {
|
|
||||||
const mockDevice = {
|
|
||||||
setDialogOpen: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderWithDeviceContext = (ui: React.ReactNode) => {
|
|
||||||
return render(
|
|
||||||
<DeviceWrapper device={mockDevice}>
|
|
||||||
{ui}
|
|
||||||
</DeviceWrapper>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
it("renders the dialog when open is true", () => {
|
|
||||||
renderWithDeviceContext(
|
|
||||||
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
const dialog = screen.getByRole("dialog");
|
|
||||||
expect(dialog).toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(screen.getByText(/I have read the/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/understand the implications/i))
|
|
||||||
.toBeInTheDocument();
|
|
||||||
|
|
||||||
const links = screen.getAllByRole("link");
|
|
||||||
expect(links).toHaveLength(2);
|
|
||||||
expect(links[0]).toHaveTextContent("Device Role Documentation");
|
|
||||||
expect(links[1]).toHaveTextContent("Choosing The Right Device Role");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("displays the correct links", () => {
|
|
||||||
renderWithDeviceContext(
|
|
||||||
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
const docLink = screen.getByRole("link", {
|
|
||||||
name: /Device Role Documentation/i,
|
|
||||||
});
|
|
||||||
const blogLink = screen.getByRole("link", {
|
|
||||||
name: /Choosing The Right Device Role/i,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(docLink).toHaveAttribute(
|
|
||||||
"href",
|
|
||||||
"https://meshtastic.org/docs/configuration/radio/device/",
|
|
||||||
);
|
|
||||||
expect(blogLink).toHaveAttribute(
|
|
||||||
"href",
|
|
||||||
"https://meshtastic.org/blog/choosing-the-right-device-role/",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not allow confirmation until checkbox is checked", () => {
|
|
||||||
renderWithDeviceContext(
|
|
||||||
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
|
||||||
|
|
||||||
expect(confirmButton).toBeDisabled();
|
|
||||||
|
|
||||||
const checkbox = screen.getByRole("checkbox");
|
|
||||||
fireEvent.click(checkbox);
|
|
||||||
|
|
||||||
expect(confirmButton).toBeEnabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits the correct event when closing via close button", () => {
|
|
||||||
const eventSpy = vi.spyOn(eventBus, "emit");
|
|
||||||
renderWithDeviceContext(
|
|
||||||
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
const dismissButton = screen.getByRole("button", { name: /close/i });
|
|
||||||
fireEvent.click(dismissButton);
|
|
||||||
|
|
||||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
|
|
||||||
action: "dismiss",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits the correct event when dismissing", () => {
|
|
||||||
const eventSpy = vi.spyOn(eventBus, "emit");
|
|
||||||
renderWithDeviceContext(
|
|
||||||
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
const dismissButton = screen.getByRole("button", { name: /dismiss/i });
|
|
||||||
fireEvent.click(dismissButton);
|
|
||||||
|
|
||||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
|
|
||||||
action: "dismiss",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits the correct event when confirming", () => {
|
|
||||||
const eventSpy = vi.spyOn(eventBus, "emit");
|
|
||||||
renderWithDeviceContext(
|
|
||||||
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
const checkbox = screen.getByRole("checkbox");
|
|
||||||
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
|
||||||
|
|
||||||
fireEvent.click(checkbox);
|
|
||||||
fireEvent.click(confirmButton);
|
|
||||||
|
|
||||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
|
|
||||||
action: "confirm",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -13,6 +13,7 @@ import { Button } from "@components/UI/Button.tsx";
|
|||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { eventBus } from "@core/utils/eventBus.ts";
|
import { eventBus } from "@core/utils/eventBus.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface RouterRoleDialogProps {
|
export interface RouterRoleDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -22,6 +23,7 @@ export interface RouterRoleDialogProps {
|
|||||||
export const UnsafeRolesDialog = (
|
export const UnsafeRolesDialog = (
|
||||||
{ open, onOpenChange }: RouterRoleDialogProps,
|
{ open, onOpenChange }: RouterRoleDialogProps,
|
||||||
) => {
|
) => {
|
||||||
|
const { t } = useTranslation("dialog");
|
||||||
const [confirmState, setConfirmState] = useState(false);
|
const [confirmState, setConfirmState] = useState(false);
|
||||||
const { setDialogOpen } = useDevice();
|
const { setDialogOpen } = useDevice();
|
||||||
|
|
||||||
@@ -41,26 +43,29 @@ export const UnsafeRolesDialog = (
|
|||||||
<DialogContent className="max-w-8 flex flex-col">
|
<DialogContent className="max-w-8 flex flex-col">
|
||||||
<DialogClose onClick={() => handleCloseDialog("dismiss")} />
|
<DialogClose onClick={() => handleCloseDialog("dismiss")} />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Are you sure?</DialogTitle>
|
<DialogTitle>{t("unsafeRoles.title")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogDescription className="text-md">
|
<DialogDescription className="text-md">
|
||||||
I have read the{" "}
|
{t("unsafeRoles.preamble")}
|
||||||
<Link href={deviceRoleLink} className="">
|
<Link href={deviceRoleLink} className="">
|
||||||
Device Role Documentation
|
{t("unsafeRoles.deviceRoleDocumentation")}
|
||||||
</Link>{" "}
|
</Link>
|
||||||
and the blog post about{" "}
|
{t("unsafeRoles.conjunction")}
|
||||||
<Link href={choosingTheRightDeviceRoleLink}>
|
<Link href={choosingTheRightDeviceRoleLink}>
|
||||||
Choosing The Right Device Role
|
{t("unsafeRoles.choosingRightDeviceRole")}
|
||||||
</Link>{" "}
|
</Link>
|
||||||
and understand the implications of changing the role.
|
{t("unsafeRoles.postamble")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="routerRole"
|
id="routerRole"
|
||||||
checked={confirmState}
|
checked={confirmState}
|
||||||
onChange={() => setConfirmState(!confirmState)}
|
onChange={() => setConfirmState(!confirmState)}
|
||||||
|
name="confirmUnderstanding"
|
||||||
>
|
>
|
||||||
Yes, I know what I'm doing
|
<span className="dark:text-white">
|
||||||
|
{t("unsafeRoles.confirmUnderstanding")}
|
||||||
|
</span>
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="mt-6">
|
<DialogFooter className="mt-6">
|
||||||
@@ -69,7 +74,7 @@ export const UnsafeRolesDialog = (
|
|||||||
name="dismiss"
|
name="dismiss"
|
||||||
onClick={() => handleCloseDialog("dismiss")}
|
onClick={() => handleCloseDialog("dismiss")}
|
||||||
>
|
>
|
||||||
Dismiss
|
{t("button.dismiss")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -77,7 +82,7 @@ export const UnsafeRolesDialog = (
|
|||||||
disabled={!confirmState}
|
disabled={!confirmState}
|
||||||
onClick={() => handleCloseDialog("confirm")}
|
onClick={() => handleCloseDialog("confirm")}
|
||||||
>
|
>
|
||||||
Confirm
|
{t("button.confirm")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from "vitest";
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
type Mock,
|
||||||
|
vi,
|
||||||
|
} from "vitest";
|
||||||
import { renderHook } from "@testing-library/react";
|
import { renderHook } from "@testing-library/react";
|
||||||
import {
|
import {
|
||||||
UNSAFE_ROLES,
|
UNSAFE_ROLES,
|
||||||
@@ -6,6 +14,17 @@ import {
|
|||||||
} from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
} from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||||
import { eventBus } from "@core/utils/eventBus.ts";
|
import { eventBus } from "@core/utils/eventBus.ts";
|
||||||
|
|
||||||
|
const mockNavigate = vi.fn();
|
||||||
|
vi.mock("@tanstack/react-router", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<
|
||||||
|
typeof import("@tanstack/react-router")
|
||||||
|
>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigate: () => mockNavigate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("@core/utils/eventBus", () => ({
|
vi.mock("@core/utils/eventBus", () => ({
|
||||||
eventBus: {
|
eventBus: {
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
@@ -27,6 +46,7 @@ vi.mock("@core/stores/deviceStore", () => ({
|
|||||||
describe("useUnsafeRolesDialog", () => {
|
describe("useUnsafeRolesDialog", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
mockNavigate.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback } from "react";
|
|||||||
import { eventBus } from "@core/utils/eventBus.ts";
|
import { eventBus } from "@core/utils/eventBus.ts";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
|
|
||||||
export const UNSAFE_ROLES = ["ROUTER", "REPEATER"];
|
export const UNSAFE_ROLES = ["ROUTER", "ROUTER_LATE", "REPEATER"];
|
||||||
export type UnsafeRole = typeof UNSAFE_ROLES[number];
|
export type UnsafeRole = typeof UNSAFE_ROLES[number];
|
||||||
|
|
||||||
export const useUnsafeRolesDialog = () => {
|
export const useUnsafeRolesDialog = () => {
|
||||||
|
|||||||
@@ -9,11 +9,20 @@ import {
|
|||||||
type Control,
|
type Control,
|
||||||
type DefaultValues,
|
type DefaultValues,
|
||||||
type FieldValues,
|
type FieldValues,
|
||||||
|
FormProvider,
|
||||||
|
get,
|
||||||
type Path,
|
type Path,
|
||||||
type SubmitHandler,
|
type SubmitHandler,
|
||||||
useForm,
|
useForm,
|
||||||
|
type UseFormReturn,
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||||
|
import { ZodType } from "zod/v4";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { createZodResolver } from "@components/Form/createZodResolver.ts";
|
||||||
|
import { useAppStore } from "@core/stores/appStore.ts";
|
||||||
|
import { dotPaths } from "@core/utils/dotPath.ts";
|
||||||
|
|
||||||
interface DisabledBy<T> {
|
interface DisabledBy<T> {
|
||||||
fieldName: Path<T>;
|
fieldName: Path<T>;
|
||||||
@@ -36,13 +45,18 @@ export interface GenericFormElementProps<T extends FieldValues, Y> {
|
|||||||
control: Control<T>;
|
control: Control<T>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
field: Y;
|
field: Y;
|
||||||
|
isDirty?: boolean;
|
||||||
|
invalid?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DynamicFormProps<T extends FieldValues> {
|
export interface DynamicFormProps<T extends FieldValues> {
|
||||||
|
propMethods?: UseFormReturn<T, T, T>;
|
||||||
onSubmit: SubmitHandler<T>;
|
onSubmit: SubmitHandler<T>;
|
||||||
|
onFormInit?: DynamicFormFormInit<T>;
|
||||||
submitType?: "onChange" | "onSubmit";
|
submitType?: "onChange" | "onSubmit";
|
||||||
hasSubmitButton?: boolean;
|
hasSubmitButton?: boolean;
|
||||||
defaultValues?: DefaultValues<T>;
|
defaultValues?: DefaultValues<T>;
|
||||||
|
values?: T;
|
||||||
fieldGroups: {
|
fieldGroups: {
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -51,19 +65,74 @@ export interface DynamicFormProps<T extends FieldValues> {
|
|||||||
validationText?: string;
|
validationText?: string;
|
||||||
fields: FieldProps<T>[];
|
fields: FieldProps<T>[];
|
||||||
}[];
|
}[];
|
||||||
|
validationSchema?: ZodType<T>;
|
||||||
|
formId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DynamicFormFormInit<T extends FieldValues> = (
|
||||||
|
methods: UseFormReturn<T, T, T>,
|
||||||
|
) => void;
|
||||||
|
|
||||||
export function DynamicForm<T extends FieldValues>({
|
export function DynamicForm<T extends FieldValues>({
|
||||||
|
propMethods,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onFormInit,
|
||||||
submitType = "onChange",
|
submitType = "onChange",
|
||||||
hasSubmitButton,
|
hasSubmitButton,
|
||||||
defaultValues,
|
defaultValues,
|
||||||
|
values,
|
||||||
fieldGroups,
|
fieldGroups,
|
||||||
|
validationSchema,
|
||||||
|
formId,
|
||||||
}: DynamicFormProps<T>) {
|
}: DynamicFormProps<T>) {
|
||||||
const { handleSubmit, control, getValues } = useForm<T>({
|
const { t } = useTranslation();
|
||||||
mode: submitType,
|
const {
|
||||||
|
addError,
|
||||||
|
removeError,
|
||||||
|
} = useAppStore();
|
||||||
|
|
||||||
|
let methods = propMethods;
|
||||||
|
if (!methods) {
|
||||||
|
methods = useForm<
|
||||||
|
T
|
||||||
|
>({
|
||||||
|
mode: "onChange",
|
||||||
defaultValues: defaultValues,
|
defaultValues: defaultValues,
|
||||||
|
resolver: validationSchema
|
||||||
|
? createZodResolver(validationSchema)
|
||||||
|
: undefined,
|
||||||
|
shouldFocusError: false,
|
||||||
|
resetOptions: { keepDefaultValues: true },
|
||||||
|
values,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
const { handleSubmit, control, getValues, formState, getFieldState } =
|
||||||
|
methods;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!propMethods) {
|
||||||
|
onFormInit?.(methods);
|
||||||
|
}
|
||||||
|
}, [onFormInit, propMethods, methods]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const errorKeys = Object.keys(formState.errors);
|
||||||
|
if (formId) {
|
||||||
|
if (
|
||||||
|
errorKeys.length === 0
|
||||||
|
) {
|
||||||
|
dotPaths(getValues()).forEach((key) => {
|
||||||
|
removeError(key);
|
||||||
|
});
|
||||||
|
removeError(formId);
|
||||||
|
} else {
|
||||||
|
errorKeys.forEach((key) => {
|
||||||
|
addError(key, "");
|
||||||
|
});
|
||||||
|
addError(formId, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [formState.errors]);
|
||||||
|
|
||||||
const isDisabled = (
|
const isDisabled = (
|
||||||
disabledBy?: DisabledBy<T>[],
|
disabledBy?: DisabledBy<T>[],
|
||||||
@@ -86,11 +155,12 @@ export function DynamicForm<T extends FieldValues>({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<FormProvider {...methods}>
|
||||||
<form
|
<form
|
||||||
className="space-y-8"
|
className="space-y-8"
|
||||||
{...(submitType === "onSubmit" ? { onSubmit: handleSubmit(onSubmit) } : {
|
{...(submitType === "onSubmit"
|
||||||
onChange: handleSubmit(onSubmit),
|
? { onSubmit: handleSubmit(onSubmit) }
|
||||||
})}
|
: { onChange: handleSubmit(onSubmit) })}
|
||||||
>
|
>
|
||||||
{fieldGroups.map((fieldGroup) => (
|
{fieldGroups.map((fieldGroup) => (
|
||||||
<div key={fieldGroup.label} className="space-y-8 sm:space-y-5">
|
<div key={fieldGroup.label} className="space-y-8 sm:space-y-5">
|
||||||
@@ -103,20 +173,29 @@ export function DynamicForm<T extends FieldValues>({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{fieldGroup.fields.map((field) => {
|
{fieldGroup.fields.map((field) => {
|
||||||
|
const error = get(formState.errors, field.name as string);
|
||||||
return (
|
return (
|
||||||
<FieldWrapper
|
<FieldWrapper
|
||||||
key={field.label}
|
key={field.label}
|
||||||
label={field.label}
|
label={field.label}
|
||||||
fieldName={field.name}
|
fieldName={field.name}
|
||||||
description={field.description}
|
description={field.description}
|
||||||
valid={field.validationText === undefined ||
|
valid={!error}
|
||||||
field.validationText === ""}
|
validationText={error
|
||||||
validationText={field.validationText}
|
? String(
|
||||||
|
t([`formValidation.${error.type}`, error.message], {
|
||||||
|
returnObjects: false,
|
||||||
|
...error.params,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: ""}
|
||||||
>
|
>
|
||||||
<DynamicFormField
|
<DynamicFormField
|
||||||
field={field}
|
field={field}
|
||||||
control={control}
|
control={control}
|
||||||
disabled={isDisabled(field.disabledBy, field.disabled)}
|
disabled={isDisabled(field.disabledBy, field.disabled)}
|
||||||
|
isDirty={getFieldState(field.name).isDirty}
|
||||||
|
invalid={getFieldState(field.name).invalid}
|
||||||
/>
|
/>
|
||||||
</FieldWrapper>
|
</FieldWrapper>
|
||||||
);
|
);
|
||||||
@@ -124,8 +203,15 @@ export function DynamicForm<T extends FieldValues>({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{hasSubmitButton && (
|
{hasSubmitButton && (
|
||||||
<Button type="submit" variant="outline">Submit</Button>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!formState.isValid}
|
||||||
|
>
|
||||||
|
{t("button.submit")}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
</FormProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,19 +31,29 @@ export interface DynamicFormFieldProps<T extends FieldValues> {
|
|||||||
field: FieldProps<T>;
|
field: FieldProps<T>;
|
||||||
control: Control<T>;
|
control: Control<T>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
isDirty?: boolean;
|
||||||
|
invalid?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DynamicFormField<T extends FieldValues>({
|
export function DynamicFormField<T extends FieldValues>({
|
||||||
field,
|
field,
|
||||||
control,
|
control,
|
||||||
disabled,
|
disabled,
|
||||||
|
isDirty,
|
||||||
|
invalid,
|
||||||
}: DynamicFormFieldProps<T>) {
|
}: DynamicFormFieldProps<T>) {
|
||||||
switch (field.type) {
|
switch (field.type) {
|
||||||
case "text":
|
case "text":
|
||||||
case "password":
|
case "password":
|
||||||
case "number":
|
case "number":
|
||||||
return (
|
return (
|
||||||
<GenericInput field={field} control={control} disabled={disabled} />
|
<GenericInput
|
||||||
|
field={field}
|
||||||
|
control={control}
|
||||||
|
disabled={disabled}
|
||||||
|
isDirty={isDirty}
|
||||||
|
invalid={invalid}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "toggle":
|
case "toggle":
|
||||||
@@ -52,6 +62,8 @@ export function DynamicFormField<T extends FieldValues>({
|
|||||||
field={field}
|
field={field}
|
||||||
control={control}
|
control={control}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
isDirty={isDirty}
|
||||||
|
invalid={invalid}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "select":
|
case "select":
|
||||||
@@ -60,6 +72,8 @@ export function DynamicFormField<T extends FieldValues>({
|
|||||||
field={field}
|
field={field}
|
||||||
control={control}
|
control={control}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
isDirty={isDirty}
|
||||||
|
invalid={invalid}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "passwordGenerator":
|
case "passwordGenerator":
|
||||||
@@ -68,11 +82,19 @@ export function DynamicFormField<T extends FieldValues>({
|
|||||||
field={field}
|
field={field}
|
||||||
control={control}
|
control={control}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
isDirty={isDirty}
|
||||||
|
invalid={invalid}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "multiSelect":
|
case "multiSelect":
|
||||||
return (
|
return (
|
||||||
<MultiSelectInput field={field} control={control} disabled={disabled} />
|
<MultiSelectInput
|
||||||
|
field={field}
|
||||||
|
control={control}
|
||||||
|
disabled={disabled}
|
||||||
|
isDirty={isDirty}
|
||||||
|
invalid={invalid}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,14 @@ import type {
|
|||||||
} from "@components/Form/DynamicForm.tsx";
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { Input } from "@components/UI/Input.tsx";
|
import { Input } from "@components/UI/Input.tsx";
|
||||||
import type { ChangeEventHandler } from "react";
|
import type { ChangeEventHandler } from "react";
|
||||||
import { useState } from "react";
|
|
||||||
import { type FieldValues, useController } from "react-hook-form";
|
import { type FieldValues, useController } from "react-hook-form";
|
||||||
|
|
||||||
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
|
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||||
type: "text" | "number" | "password";
|
type: "text" | "number" | "password";
|
||||||
inputChange?: ChangeEventHandler;
|
inputChange?: ChangeEventHandler;
|
||||||
properties?: {
|
|
||||||
value?: string;
|
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
|
properties?: {
|
||||||
|
id?: string;
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
step?: number;
|
step?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -31,27 +30,33 @@ export function GenericInput<T extends FieldValues>({
|
|||||||
control,
|
control,
|
||||||
disabled,
|
disabled,
|
||||||
field,
|
field,
|
||||||
|
invalid,
|
||||||
}: GenericFormElementProps<T, InputFieldProps<T>>) {
|
}: GenericFormElementProps<T, InputFieldProps<T>>) {
|
||||||
const { fieldLength, ...restProperties } = field.properties || {};
|
const { fieldLength, ...restProperties } = field.properties || {};
|
||||||
const [currentLength, setCurrentLength] = useState<number>(
|
|
||||||
fieldLength?.currentValueLength || 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { field: controllerField } = useController({
|
const {
|
||||||
|
field: controllerField,
|
||||||
|
fieldState: { error, isDirty },
|
||||||
|
} = useController({
|
||||||
name: field.name,
|
name: field.name,
|
||||||
control,
|
control,
|
||||||
|
rules: {
|
||||||
|
minLength: field.properties?.fieldLength?.min,
|
||||||
|
maxLength: field.properties?.fieldLength?.max,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isInvalid = invalid || Boolean(error?.message);
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const newValue = e.target.value;
|
const newValue = e.target.value;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
field.properties?.fieldLength?.max &&
|
field.properties?.fieldLength?.max &&
|
||||||
newValue.length > field.properties?.fieldLength?.max
|
newValue.length > field.properties.fieldLength.max
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCurrentLength(newValue.length);
|
|
||||||
|
|
||||||
if (field.inputChange) field.inputChange(e);
|
if (field.inputChange) field.inputChange(e);
|
||||||
|
|
||||||
@@ -62,6 +67,10 @@ export function GenericInput<T extends FieldValues>({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const currentLength = controllerField.value
|
||||||
|
? String(controllerField.value).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Input
|
<Input
|
||||||
@@ -78,11 +87,18 @@ export function GenericInput<T extends FieldValues>({
|
|||||||
className={field.properties?.className}
|
className={field.properties?.className}
|
||||||
{...restProperties}
|
{...restProperties}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
variant={error ? "invalid" : isDirty ? "dirty" : "default"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{fieldLength?.showCharacterCount && fieldLength?.max && (
|
{fieldLength?.showCharacterCount && fieldLength.max && (
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-slate-900 dark:text-slate-200">
|
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-slate-900 dark:text-slate-200">
|
||||||
{currentLength ?? fieldLength?.currentValueLength}/{fieldLength?.max}
|
{currentLength}/{fieldLength.max}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isInvalid && (
|
||||||
|
<div className="absolute inset-y-12 bottom-0 flex items-center pr-3">
|
||||||
|
<p className="text-sm text-red-500">{error?.message ?? ""}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import type {
|
|||||||
GenericFormElementProps,
|
GenericFormElementProps,
|
||||||
} from "@components/Form/DynamicForm.tsx";
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import type { FieldValues } from "react-hook-form";
|
import type { FieldValues } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { FLAGS_CONFIG } from "@core/hooks/usePositionFlags.ts";
|
||||||
import { MultiSelect, MultiSelectItem } from "../UI/MultiSelect.tsx";
|
import { MultiSelect, MultiSelectItem } from "../UI/MultiSelect.tsx";
|
||||||
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
|
||||||
export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||||
type: "multiSelect";
|
type: "multiSelect";
|
||||||
@@ -12,53 +15,64 @@ export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
|||||||
isChecked: (name: string) => boolean;
|
isChecked: (name: string) => boolean;
|
||||||
value: string[];
|
value: string[];
|
||||||
properties: BaseFormBuilderProps<T>["properties"] & {
|
properties: BaseFormBuilderProps<T>["properties"] & {
|
||||||
enumValue: {
|
enumValue:
|
||||||
[s: string]: string | number;
|
| { [s: string]: string | number }
|
||||||
};
|
| typeof FLAGS_CONFIG;
|
||||||
formatEnumName?: boolean;
|
formatEnumName?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatEnumDisplay = (name: string): string => {
|
|
||||||
return name
|
|
||||||
.replace(/_/g, " ")
|
|
||||||
.toLowerCase()
|
|
||||||
.split(" ")
|
|
||||||
.map((s) => s.charAt(0).toUpperCase() + s.substring(1))
|
|
||||||
.join(" ");
|
|
||||||
};
|
|
||||||
|
|
||||||
export function MultiSelectInput<T extends FieldValues>({
|
export function MultiSelectInput<T extends FieldValues>({
|
||||||
field,
|
field,
|
||||||
|
isDirty,
|
||||||
|
invalid,
|
||||||
}: GenericFormElementProps<T, MultiSelectFieldProps<T>>) {
|
}: GenericFormElementProps<T, MultiSelectFieldProps<T>>) {
|
||||||
const { enumValue, formatEnumName, ...remainingProperties } =
|
const { t } = useTranslation("deviceConfig");
|
||||||
field.properties;
|
const { enumValue, className, ...remainingProperties } = field.properties;
|
||||||
|
|
||||||
const valueToKeyMap: Record<string, string> = {};
|
const isNewConfigStructure =
|
||||||
const optionsEnumValues: [string, number][] = [];
|
typeof Object.values(enumValue)[0] === "object" &&
|
||||||
|
Object.values(enumValue)[0] !== null &&
|
||||||
|
"i18nKey" in Object.values(enumValue)[0];
|
||||||
|
|
||||||
if (enumValue) {
|
const optionsToRender = Object.entries(enumValue).map(
|
||||||
Object.entries(enumValue).forEach(([key, val]) => {
|
([key, configOrValue]) => {
|
||||||
if (typeof val === "number" && key !== "UNSET") {
|
if (isNewConfigStructure) {
|
||||||
valueToKeyMap[val.toString()] = key;
|
const config =
|
||||||
optionsEnumValues.push([key, val as number]);
|
configOrValue as typeof FLAGS_CONFIG[keyof typeof FLAGS_CONFIG];
|
||||||
}
|
return {
|
||||||
});
|
key,
|
||||||
|
display: t(config.i18nKey),
|
||||||
|
value: config.value,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
return { key, display: key, value: configOrValue as number };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MultiSelect {...remainingProperties}>
|
<MultiSelect
|
||||||
{optionsEnumValues.map(([name, value]) => (
|
className={cn([
|
||||||
<MultiSelectItem
|
className,
|
||||||
key={name}
|
"rounded-md",
|
||||||
name={name}
|
isDirty ? "focus:ring-sky-500 ring-sky-500 ring-2 ring-offset-5" : "",
|
||||||
value={value.toString()}
|
invalid ? "focus:ring-red-500 ring-red-500 ring-2 ring-offset-5" : "",
|
||||||
checked={field.isChecked(name)}
|
])}
|
||||||
onCheckedChange={() => field.onValueChange(name)}
|
{...remainingProperties}
|
||||||
>
|
>
|
||||||
{formatEnumName ? formatEnumDisplay(name) : name}
|
{optionsToRender.map((option) => {
|
||||||
|
return (
|
||||||
|
<MultiSelectItem
|
||||||
|
key={option.key}
|
||||||
|
name={option.key}
|
||||||
|
value={option.value.toString()}
|
||||||
|
checked={field.isChecked(option.key)}
|
||||||
|
onCheckedChange={() => field.onValueChange(option.key)}
|
||||||
|
>
|
||||||
|
{option.display}
|
||||||
</MultiSelectItem>
|
</MultiSelectItem>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</MultiSelect>
|
</MultiSelect>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import type {
|
|||||||
} from "@components/Form/DynamicForm.tsx";
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import type { ButtonVariant } from "../UI/Button.tsx";
|
import type { ButtonVariant } from "../UI/Button.tsx";
|
||||||
import { Generator } from "@components/UI/Generator.tsx";
|
import { Generator } from "@components/UI/Generator.tsx";
|
||||||
import type { ChangeEventHandler } from "react";
|
import { Controller, type FieldValues, useFormContext } from "react-hook-form";
|
||||||
import { Controller, type FieldValues } from "react-hook-form";
|
|
||||||
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
|
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
|
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
|
||||||
type: "passwordGenerator";
|
type: "passwordGenerator";
|
||||||
@@ -14,8 +14,8 @@ export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
|
|||||||
hide?: boolean;
|
hide?: boolean;
|
||||||
bits?: { text: string; value: string; key: string }[];
|
bits?: { text: string; value: string; key: string }[];
|
||||||
devicePSKBitCount: number;
|
devicePSKBitCount: number;
|
||||||
inputChange: ChangeEventHandler<HTMLInputElement> | undefined;
|
inputChange?: React.ChangeEventHandler<HTMLInputElement>;
|
||||||
selectChange: (event: string) => void;
|
selectChange?: (event: string) => void;
|
||||||
actionButtons: {
|
actionButtons: {
|
||||||
text: string;
|
text: string;
|
||||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
@@ -30,23 +30,37 @@ export function PasswordGenerator<T extends FieldValues>({
|
|||||||
control,
|
control,
|
||||||
field,
|
field,
|
||||||
disabled,
|
disabled,
|
||||||
|
isDirty,
|
||||||
|
invalid,
|
||||||
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
|
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
|
||||||
const { isVisible } = usePasswordVisibilityToggle();
|
const { isVisible } = usePasswordVisibilityToggle();
|
||||||
|
const { trigger } = useFormContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trigger(field.name);
|
||||||
|
}, [field.devicePSKBitCount, field.name, trigger]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
name={field.name}
|
name={field.name}
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, ...rest } }) => (
|
render={(
|
||||||
|
{
|
||||||
|
field: { value, onChange, ...rest },
|
||||||
|
},
|
||||||
|
) => (
|
||||||
<Generator
|
<Generator
|
||||||
type={field.hide && !isVisible ? "password" : "text"}
|
type={field.hide && !isVisible ? "password" : "text"}
|
||||||
id={field.id}
|
id={field.id}
|
||||||
devicePSKBitCount={field.devicePSKBitCount}
|
devicePSKBitCount={field.devicePSKBitCount}
|
||||||
bits={field.bits}
|
bits={field.bits}
|
||||||
inputChange={field.inputChange}
|
inputChange={(e) => {
|
||||||
selectChange={field.selectChange}
|
if (field.inputChange) field.inputChange(e);
|
||||||
|
onChange(e);
|
||||||
|
}}
|
||||||
|
selectChange={field.selectChange ?? (() => {})}
|
||||||
value={value}
|
value={value}
|
||||||
variant={field.validationText ? "invalid" : "default"}
|
variant={invalid ? "invalid" : isDirty ? "dirty" : "default"}
|
||||||
actionButtons={field.actionButtons}
|
actionButtons={field.actionButtons}
|
||||||
showPasswordToggle={field.showPasswordToggle}
|
showPasswordToggle={field.showPasswordToggle}
|
||||||
showCopyButton={field.showCopyButton}
|
showCopyButton={field.showCopyButton}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@components/UI/Select.tsx";
|
} from "@components/UI/Select.tsx";
|
||||||
import { type FieldValues, useController } from "react-hook-form";
|
import { type FieldValues, useController } from "react-hook-form";
|
||||||
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
|
||||||
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||||
type: "select";
|
type: "select";
|
||||||
@@ -38,16 +39,23 @@ export function SelectInput<T extends FieldValues>({
|
|||||||
control,
|
control,
|
||||||
disabled,
|
disabled,
|
||||||
field,
|
field,
|
||||||
|
isDirty,
|
||||||
|
invalid,
|
||||||
}: GenericFormElementProps<T, SelectFieldProps<T>>) {
|
}: GenericFormElementProps<T, SelectFieldProps<T>>) {
|
||||||
const {
|
const {
|
||||||
field: { value, onChange, ...rest },
|
field: { value, onChange, ref, onBlur, ...rest },
|
||||||
} = useController({
|
} = useController({
|
||||||
name: field.name,
|
name: field.name,
|
||||||
control,
|
control,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { enumValue, formatEnumName, ...remainingProperties } =
|
const {
|
||||||
field.properties;
|
enumValue,
|
||||||
|
formatEnumName,
|
||||||
|
defaultValue,
|
||||||
|
className,
|
||||||
|
...remainingProperties
|
||||||
|
} = field.properties;
|
||||||
const valueToKeyMap: Record<string, string> = {};
|
const valueToKeyMap: Record<string, string> = {};
|
||||||
const optionsEnumValues: [string, number][] = [];
|
const optionsEnumValues: [string, number][] = [];
|
||||||
|
|
||||||
@@ -77,10 +85,22 @@ export function SelectInput<T extends FieldValues>({
|
|||||||
onValueChange={handleValueChange}
|
onValueChange={handleValueChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
value={value?.toString()}
|
value={value?.toString()}
|
||||||
{...remainingProperties}
|
defaultValue={defaultValue?.toString()}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
<SelectTrigger id={field.name}>
|
<SelectTrigger
|
||||||
|
id={field.name}
|
||||||
|
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"
|
||||||
|
: "",
|
||||||
|
])}
|
||||||
|
ref={ref}
|
||||||
|
onBlur={onBlur}
|
||||||
|
{...remainingProperties}
|
||||||
|
>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
@@ -3,39 +3,46 @@ import type {
|
|||||||
GenericFormElementProps,
|
GenericFormElementProps,
|
||||||
} from "@components/Form/DynamicForm.tsx";
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { Switch } from "@components/UI/Switch.tsx";
|
import { Switch } from "@components/UI/Switch.tsx";
|
||||||
import type { ChangeEvent } from "react";
|
|
||||||
import { Controller, type FieldValues } from "react-hook-form";
|
import { Controller, type FieldValues } from "react-hook-form";
|
||||||
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
|
||||||
export interface ToggleFieldProps<T> extends BaseFormBuilderProps<T> {
|
export interface ToggleFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||||
type: "toggle";
|
type: "toggle";
|
||||||
|
inputChange?: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToggleInput<T extends FieldValues>({
|
export function ToggleInput<T extends FieldValues>({
|
||||||
control,
|
control,
|
||||||
disabled,
|
disabled,
|
||||||
field,
|
field,
|
||||||
|
isDirty,
|
||||||
|
invalid,
|
||||||
}: GenericFormElementProps<T, ToggleFieldProps<T>>) {
|
}: GenericFormElementProps<T, ToggleFieldProps<T>>) {
|
||||||
const onChangeHandler = (e: (event: ChangeEvent) => void) => {
|
|
||||||
return (value: boolean) => {
|
|
||||||
e({
|
|
||||||
target: {
|
|
||||||
value: value,
|
|
||||||
},
|
|
||||||
} as unknown as ChangeEvent);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
name={field.name}
|
name={field.name}
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, onChange, ...rest } }) => (
|
render={(
|
||||||
|
{ field: { value, onChange, ...rest } },
|
||||||
|
) => (
|
||||||
<Switch
|
<Switch
|
||||||
checked={value}
|
checked={value}
|
||||||
onCheckedChange={onChangeHandler(onChange)}
|
onCheckedChange={(v) => {
|
||||||
|
onChange(v);
|
||||||
|
field.inputChange?.(v);
|
||||||
|
}}
|
||||||
id={field.name}
|
id={field.name}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
{...field.properties}
|
{...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"
|
||||||
|
: "",
|
||||||
|
])}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { ZodType } from "zod/v4";
|
||||||
|
import type {
|
||||||
|
FieldError,
|
||||||
|
FieldValues,
|
||||||
|
Resolver,
|
||||||
|
ResolverOptions,
|
||||||
|
ResolverResult,
|
||||||
|
} from "react-hook-form";
|
||||||
|
|
||||||
|
export function createZodResolver<T extends FieldValues>(
|
||||||
|
schema: ZodType<T, unknown>,
|
||||||
|
): Resolver<T, unknown> {
|
||||||
|
return (
|
||||||
|
values: T,
|
||||||
|
_context: unknown,
|
||||||
|
_options?: ResolverOptions<T>,
|
||||||
|
): ResolverResult<T> => {
|
||||||
|
const result = schema.safeParse(values);
|
||||||
|
if (result.success) {
|
||||||
|
return {
|
||||||
|
values: result.data,
|
||||||
|
errors: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: Record<
|
||||||
|
string,
|
||||||
|
FieldError & { params?: Record<string, unknown> }
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
for (const issue of result.error.issues) {
|
||||||
|
const { path, code, message, ...params } = issue;
|
||||||
|
const key = path.join(".");
|
||||||
|
|
||||||
|
const suffix = "format" in params
|
||||||
|
? params.format
|
||||||
|
: "origin" in params
|
||||||
|
? params.origin
|
||||||
|
: "expected" in params
|
||||||
|
? params.expected
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const newCode = code.replace(
|
||||||
|
/_([a-z])/g,
|
||||||
|
(_, char) => char.toUpperCase(),
|
||||||
|
) + (suffix ? `.${suffix}` : "");
|
||||||
|
|
||||||
|
const fieldError: FieldError & { params?: Record<string, unknown> } = {
|
||||||
|
type: newCode,
|
||||||
|
message: message,
|
||||||
|
...(Object.keys(params).length ? { params } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!errors[key]) {
|
||||||
|
errors[key] = fieldError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
values: {} as T,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useBackupReminder } from "@core/hooks/useKeyBackupReminder.tsx";
|
import { useBackupReminder } from "@core/hooks/useKeyBackupReminder.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export const KeyBackupReminder = () => {
|
export const KeyBackupReminder = () => {
|
||||||
const { setDialogOpen } = useDevice();
|
const { setDialogOpen } = useDevice();
|
||||||
|
const { t } = useTranslation("dialog");
|
||||||
|
|
||||||
useBackupReminder({
|
useBackupReminder({
|
||||||
message:
|
message: t("pkiBackupReminder.description"),
|
||||||
"We recommend backing up your key data regularly. Would you like to back up now?",
|
|
||||||
onAccept: () => setDialogOpen("pkiBackup", true),
|
onAccept: () => setDialogOpen("pkiBackup", true),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Check, Languages } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { LangCode, supportedLanguages } from "../i18n/config.ts";
|
||||||
|
import useLang from "@core/hooks/useLang.ts";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "./UI/DropdownMenu.tsx";
|
||||||
|
import { Subtle } from "./UI/Typography/Subtle.tsx";
|
||||||
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
import { Button } from "./UI/Button.tsx";
|
||||||
|
|
||||||
|
interface LanguageSwitcherProps {
|
||||||
|
disableHover?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LanguageSwitcher({
|
||||||
|
disableHover = false,
|
||||||
|
}: LanguageSwitcherProps) {
|
||||||
|
const { i18n } = useTranslation("ui");
|
||||||
|
const { set: setLanguage, currentLanguage } = useLang();
|
||||||
|
|
||||||
|
const handleLanguageChange = async (languageCode: LangCode) => {
|
||||||
|
await setLanguage(languageCode, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center justify-start",
|
||||||
|
"transition-colors duration-150 gap-2.5 p-1.5 rounded-md",
|
||||||
|
!disableHover && "hover:bg-gray-100 dark:hover:bg-gray-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Languages
|
||||||
|
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",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{`${i18n.t("language.changeLanguage")}:`}
|
||||||
|
</Subtle>
|
||||||
|
<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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{currentLanguage?.name}
|
||||||
|
</Subtle>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="center" className="w-64">
|
||||||
|
{supportedLanguages.map((language) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={language.code}
|
||||||
|
onClick={() => handleLanguageChange(language.code)}
|
||||||
|
className="flex items-center justify-between cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{language.flag}</span>
|
||||||
|
<span>{language.name}</span>
|
||||||
|
</div>
|
||||||
|
{i18n.language === language.code && (
|
||||||
|
<Check className="h-4 w-4 text-primary" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import MapGl, {
|
||||||
|
AttributionControl,
|
||||||
|
type MapRef,
|
||||||
|
NavigationControl,
|
||||||
|
ScaleControl,
|
||||||
|
} from "react-map-gl/maplibre";
|
||||||
|
import { useTheme } from "@core/hooks/useTheme.ts";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface MapProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
onLoad?: (map: MapRef) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Map = ({ children, onLoad }: MapProps) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const darkMode = theme === "dark";
|
||||||
|
const mapRef = useRef<MapRef | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (map && onLoad) onLoad(map);
|
||||||
|
}, [onLoad]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapGl
|
||||||
|
ref={mapRef}
|
||||||
|
mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json"
|
||||||
|
attributionControl={false}
|
||||||
|
renderWorldCopies={false}
|
||||||
|
maxPitch={0}
|
||||||
|
dragRotate={false}
|
||||||
|
touchZoomRotate={false}
|
||||||
|
initialViewState={{
|
||||||
|
zoom: 1.8,
|
||||||
|
latitude: 35,
|
||||||
|
longitude: 0,
|
||||||
|
}}
|
||||||
|
style={{ filter: darkMode ? "brightness(0.9)" : undefined }}
|
||||||
|
>
|
||||||
|
<AttributionControl
|
||||||
|
style={{
|
||||||
|
background: darkMode ? "#ffffff" : undefined,
|
||||||
|
color: darkMode ? "black" : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* { Disabled for now until we can use i18n for the geolocate control} */}
|
||||||
|
{
|
||||||
|
/* <GeolocateControl
|
||||||
|
position="top-right"
|
||||||
|
i18nIsDynamicList
|
||||||
|
positionOptions={{ enableHighAccuracy: true }}
|
||||||
|
trackUserLocation
|
||||||
|
/> */
|
||||||
|
}
|
||||||
|
<NavigationControl position="top-right" showCompass={false} />
|
||||||
|
<ScaleControl />
|
||||||
|
{children}
|
||||||
|
</MapGl>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ChannelValidation } from "@app/validation/channel.ts";
|
import { makeChannelSchema } from "@app/validation/channel.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useToast } from "@core/hooks/useToast.ts";
|
import { useToast } from "@core/hooks/useToast.ts";
|
||||||
@@ -6,8 +6,10 @@ import { useDevice } from "@core/stores/deviceStore.ts";
|
|||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { fromByteArray, toByteArray } from "base64-js";
|
import { fromByteArray, toByteArray } from "base64-js";
|
||||||
import cryptoRandomString from "crypto-random-string";
|
import cryptoRandomString from "crypto-random-string";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { PkiRegenerateDialog } from "../Dialog/PkiRegenerateDialog.tsx";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
|
||||||
|
import { infer as zodInfer } from "zod/v4";
|
||||||
|
|
||||||
export interface SettingsPanelProps {
|
export interface SettingsPanelProps {
|
||||||
channel: Protobuf.Channel.Channel;
|
channel: Protobuf.Channel.Channel;
|
||||||
@@ -15,18 +17,27 @@ export interface SettingsPanelProps {
|
|||||||
|
|
||||||
export const Channel = ({ channel }: SettingsPanelProps) => {
|
export const Channel = ({ channel }: SettingsPanelProps) => {
|
||||||
const { config, connection, addChannel } = useDevice();
|
const { config, connection, addChannel } = useDevice();
|
||||||
|
const { t } = useTranslation(["channels", "ui", "dialog"]);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [pass, setPass] = useState<string>(
|
|
||||||
fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
|
|
||||||
);
|
|
||||||
const [bitCount, setBits] = useState<number>(
|
|
||||||
channel?.settings?.psk.length ?? 16,
|
|
||||||
);
|
|
||||||
const [validationText, setValidationText] = useState<string>();
|
|
||||||
const [preSharedDialogOpen, setPreSharedDialogOpen] = useState<boolean>(
|
const [preSharedDialogOpen, setPreSharedDialogOpen] = useState<boolean>(
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
const [pass, setPass] = useState<string>(
|
||||||
|
fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
|
||||||
|
);
|
||||||
|
const [byteCount, setBytes] = useState<number>(
|
||||||
|
channel?.settings?.psk.length ?? 16,
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChannelValidationSchema = useMemo(
|
||||||
|
() => {
|
||||||
|
return makeChannelSchema(byteCount);
|
||||||
|
},
|
||||||
|
[byteCount],
|
||||||
|
);
|
||||||
|
|
||||||
|
type ChannelValidation = zodInfer<typeof ChannelValidationSchema>;
|
||||||
|
|
||||||
const onSubmit = (data: ChannelValidation) => {
|
const onSubmit = (data: ChannelValidation) => {
|
||||||
const channel = create(Protobuf.Channel.ChannelSchema, {
|
const channel = create(Protobuf.Channel.ChannelSchema, {
|
||||||
@@ -41,23 +52,29 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
connection?.setChannel(channel).then(() => {
|
connection?.setChannel(channel).then(() => {
|
||||||
|
console.debug(t("toast.savedChannel.title", {
|
||||||
|
ns: "ui",
|
||||||
|
channelName: channel.settings?.name,
|
||||||
|
}));
|
||||||
toast({
|
toast({
|
||||||
title: `Saved Channel: ${channel.settings?.name}`,
|
title: t("toast.savedChannel.title", {
|
||||||
|
ns: "ui",
|
||||||
|
channelName: channel.settings?.name,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
addChannel(channel);
|
addChannel(channel);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const preSharedKeyRegenerate = () => {
|
const preSharedKeyRegenerate = () => {
|
||||||
setPass(
|
const newPsk = btoa(
|
||||||
btoa(
|
|
||||||
cryptoRandomString({
|
cryptoRandomString({
|
||||||
length: bitCount ?? 0,
|
length: byteCount ?? 0,
|
||||||
type: "alphanumeric",
|
type: "alphanumeric",
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
setValidationText(undefined);
|
setPass(newPsk);
|
||||||
|
|
||||||
setPreSharedDialogOpen(false);
|
setPreSharedDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,24 +82,13 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
setPreSharedDialogOpen(true);
|
setPreSharedDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const validatePass = (input: string, count: number) => {
|
|
||||||
if (input.length % 4 !== 0 || toByteArray(input).length !== count) {
|
|
||||||
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
|
|
||||||
} else {
|
|
||||||
setValidationText(undefined);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const inputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const psk = e.currentTarget?.value;
|
setPass(e.currentTarget?.value);
|
||||||
setPass(psk);
|
|
||||||
validatePass(psk, bitCount);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectChangeEvent = (e: string) => {
|
const selectChangeEvent = (e: string) => {
|
||||||
const count = Number.parseInt(e);
|
const count = Number.parseInt(e);
|
||||||
setBits(count);
|
setBytes(count);
|
||||||
validatePass(pass, count);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -90,6 +96,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
<DynamicForm<ChannelValidation>
|
<DynamicForm<ChannelValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
submitType="onSubmit"
|
submitType="onSubmit"
|
||||||
|
validationSchema={ChannelValidationSchema}
|
||||||
hasSubmitButton
|
hasSubmitButton
|
||||||
defaultValues={{
|
defaultValues={{
|
||||||
...channel,
|
...channel,
|
||||||
@@ -110,36 +117,36 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
}}
|
}}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "Channel Settings",
|
label: t("settings.label"),
|
||||||
description: "Crypto, MQTT & misc settings",
|
description: t("settings.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "role",
|
name: "role",
|
||||||
label: "Role",
|
label: t("role.label"),
|
||||||
disabled: channel.index === 0,
|
disabled: channel.index === 0,
|
||||||
description:
|
description: t("role.description"),
|
||||||
"Device telemetry is sent over PRIMARY. Only one PRIMARY allowed",
|
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: channel.index === 0
|
enumValue: channel.index === 0
|
||||||
? { PRIMARY: 1 }
|
? { [t("role.options.primary")]: 1 }
|
||||||
: { DISABLED: 0, SECONDARY: 2 },
|
: {
|
||||||
|
[t("role.options.disabled")]: 0,
|
||||||
|
[t("role.options.secondary")]: 2,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "passwordGenerator",
|
type: "passwordGenerator",
|
||||||
name: "settings.psk",
|
name: "settings.psk",
|
||||||
id: "channel-psk",
|
id: "channel-psk",
|
||||||
label: "Pre-Shared Key",
|
label: t("psk.label"),
|
||||||
description:
|
description: t("psk.description"),
|
||||||
"Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
|
devicePSKBitCount: byteCount ?? 0,
|
||||||
validationText: validationText,
|
|
||||||
devicePSKBitCount: bitCount ?? 0,
|
|
||||||
inputChange: inputChangeEvent,
|
inputChange: inputChangeEvent,
|
||||||
selectChange: selectChangeEvent,
|
selectChange: selectChangeEvent,
|
||||||
actionButtons: [
|
actionButtons: [
|
||||||
{
|
{
|
||||||
text: "Generate",
|
text: t("psk.generate"),
|
||||||
variant: "success",
|
variant: "success",
|
||||||
onClick: preSharedClickEvent,
|
onClick: preSharedClickEvent,
|
||||||
},
|
},
|
||||||
@@ -154,57 +161,99 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
name: "settings.name",
|
name: "settings.name",
|
||||||
label: "Name",
|
label: t("name.label"),
|
||||||
description:
|
description: t("name.description"),
|
||||||
"A unique name for the channel <12 bytes, leave blank for default",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "settings.uplinkEnabled",
|
name: "settings.uplinkEnabled",
|
||||||
label: "Uplink Enabled",
|
label: t("uplinkEnabled.label"),
|
||||||
description: "Send messages from the local mesh to MQTT",
|
description: t("uplinkEnabled.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "settings.downlinkEnabled",
|
name: "settings.downlinkEnabled",
|
||||||
label: "Downlink Enabled",
|
label: t("downlinkEnabled.label"),
|
||||||
description: "Send messages from MQTT to the local mesh",
|
description: t("downlinkEnabled.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "settings.moduleSettings.positionPrecision",
|
name: "settings.moduleSettings.positionPrecision",
|
||||||
label: "Location",
|
label: t("positionPrecision.label"),
|
||||||
description:
|
description: t("positionPrecision.description"),
|
||||||
"The precision of the location to share with the channel. Can be disabled.",
|
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: config.display?.units === 0
|
enumValue: config.display?.units === 0
|
||||||
? {
|
? {
|
||||||
"Do not share location": 0,
|
[t("positionPrecision.options.none")]: 0,
|
||||||
"Within 23 kilometers": 10,
|
[
|
||||||
"Within 12 kilometers": 11,
|
t("positionPrecision.options.metric_km23")
|
||||||
"Within 5.8 kilometers": 12,
|
]: 10,
|
||||||
"Within 2.9 kilometers": 13,
|
[
|
||||||
"Within 1.5 kilometers": 14,
|
t("positionPrecision.options.metric_km12")
|
||||||
"Within 700 meters": 15,
|
]: 11,
|
||||||
"Within 350 meters": 16,
|
[
|
||||||
"Within 200 meters": 17,
|
t("positionPrecision.options.metric_km5_8")
|
||||||
"Within 90 meters": 18,
|
]: 12,
|
||||||
"Within 50 meters": 19,
|
[
|
||||||
"Precise Location": 32,
|
t("positionPrecision.options.metric_km2_9")
|
||||||
|
]: 13,
|
||||||
|
[
|
||||||
|
t("positionPrecision.options.metric_km1_5")
|
||||||
|
]: 14,
|
||||||
|
[
|
||||||
|
t("positionPrecision.options.metric_m700")
|
||||||
|
]: 15,
|
||||||
|
[
|
||||||
|
t("positionPrecision.options.metric_m350")
|
||||||
|
]: 16,
|
||||||
|
[
|
||||||
|
t("positionPrecision.options.metric_m200")
|
||||||
|
]: 17,
|
||||||
|
[
|
||||||
|
t("positionPrecision.options.metric_m90")
|
||||||
|
]: 18,
|
||||||
|
[
|
||||||
|
t("positionPrecision.options.metric_m50")
|
||||||
|
]: 19,
|
||||||
|
[
|
||||||
|
t("positionPrecision.options.precise")
|
||||||
|
]: 32,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
"Do not share location": 0,
|
[t("positionPrecision.options.none")]: 0,
|
||||||
"Within 15 miles": 10,
|
[
|
||||||
"Within 7.3 miles": 11,
|
t("positionPrecision.options.imperial_mi15")
|
||||||
"Within 3.6 miles": 12,
|
]: 10,
|
||||||
"Within 1.8 miles": 13,
|
[
|
||||||
"Within 0.9 miles": 14,
|
t("positionPrecision.options.imperial_mi7_3")
|
||||||
"Within 0.5 miles": 15,
|
]: 11,
|
||||||
"Within 0.2 miles": 16,
|
[
|
||||||
"Within 600 feet": 17,
|
t("positionPrecision.options.imperial_mi3_6")
|
||||||
"Within 300 feet": 18,
|
]: 12,
|
||||||
"Within 150 feet": 19,
|
[
|
||||||
"Precise Location": 32,
|
t("positionPrecision.options.imperial_mi1_8")
|
||||||
|
]: 13,
|
||||||
|
[
|
||||||
|
t("positionPrecision.options.imperial_mi0_9")
|
||||||
|
]: 14,
|
||||||
|
[
|
||||||
|
t("positionPrecision.options.imperial_mi0_5")
|
||||||
|
]: 15,
|
||||||
|
[
|
||||||
|
t("positionPrecision.options.imperial_mi0_2")
|
||||||
|
]: 16,
|
||||||
|
[
|
||||||
|
t("positionPrecision.options.imperial_ft600")
|
||||||
|
]: 17,
|
||||||
|
[
|
||||||
|
t("positionPrecision.options.imperial_ft300")
|
||||||
|
]: 18,
|
||||||
|
[
|
||||||
|
t("positionPrecision.options.imperial_ft150")
|
||||||
|
]: 19,
|
||||||
|
[
|
||||||
|
t("positionPrecision.options.precise")
|
||||||
|
]: 32,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -214,10 +263,9 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
/>
|
/>
|
||||||
<PkiRegenerateDialog
|
<PkiRegenerateDialog
|
||||||
text={{
|
text={{
|
||||||
button: "Regenerate",
|
button: t("pkiRegenerateDialog.regenerate", { ns: "dialog" }),
|
||||||
title: "Regenerate Pre-Shared Key?",
|
title: t("pkiRegenerateDialog.title", { ns: "dialog" }),
|
||||||
description:
|
description: t("pkiRegenerateDialog.description", { ns: "dialog" }),
|
||||||
"Are you sure you want to regenerate the pre-shared key?",
|
|
||||||
}}
|
}}
|
||||||
open={preSharedDialogOpen}
|
open={preSharedDialogOpen}
|
||||||
onOpenChange={() => setPreSharedDialogOpen(false)}
|
onOpenChange={() => setPreSharedDialogOpen(false)}
|
||||||
|
|||||||
@@ -1,55 +1,28 @@
|
|||||||
import { useAppStore } from "../../../core/stores/appStore.ts";
|
import {
|
||||||
import type { BluetoothValidation } from "@app/validation/config/bluetooth.ts";
|
type BluetoothValidation,
|
||||||
|
BluetoothValidationSchema,
|
||||||
|
} from "@app/validation/config/bluetooth.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useState } from "react";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Bluetooth = () => {
|
interface BluetoothConfigProps {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<BluetoothValidation>;
|
||||||
const {
|
}
|
||||||
hasErrors,
|
export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => {
|
||||||
getErrorMessage,
|
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||||
hasFieldError,
|
useDevice();
|
||||||
addError,
|
const { t } = useTranslation("deviceConfig");
|
||||||
removeError,
|
|
||||||
clearErrors,
|
|
||||||
} = useAppStore();
|
|
||||||
|
|
||||||
const [bluetoothPin, setBluetoothPin] = useState(
|
|
||||||
config?.bluetooth?.fixedPin.toString() ?? "",
|
|
||||||
);
|
|
||||||
|
|
||||||
const validateBluetoothPin = (pin: string) => {
|
|
||||||
// if empty show error they need a pin set
|
|
||||||
if (pin === "") {
|
|
||||||
return addError("fixedPin", "Bluetooth Pin is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
// clear any existing errors
|
|
||||||
clearErrors();
|
|
||||||
|
|
||||||
// if it starts with 0 show error
|
|
||||||
if (pin[0] === "0") {
|
|
||||||
return addError("fixedPin", "Bluetooth Pin cannot start with 0");
|
|
||||||
}
|
|
||||||
// if it's not 6 digits show error
|
|
||||||
if (pin.length < 6) {
|
|
||||||
return addError("fixedPin", "Pin must be 6 digits");
|
|
||||||
}
|
|
||||||
|
|
||||||
removeError("fixedPin");
|
|
||||||
};
|
|
||||||
|
|
||||||
const bluetoothPinChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const numericValue = e.target.value.replace(/\D/g, "").slice(0, 6);
|
|
||||||
setBluetoothPin(numericValue);
|
|
||||||
validateBluetoothPin(numericValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = (data: BluetoothValidation) => {
|
const onSubmit = (data: BluetoothValidation) => {
|
||||||
if (hasErrors()) {
|
if (deepCompareConfig(config.bluetooth, data, true)) {
|
||||||
|
removeWorkingConfig("bluetooth");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,31 +39,28 @@ export const Bluetooth = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<BluetoothValidation>
|
<DynamicForm<BluetoothValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
|
validationSchema={BluetoothValidationSchema}
|
||||||
|
formId="Config_BluetoothConfig"
|
||||||
defaultValues={config.bluetooth}
|
defaultValues={config.bluetooth}
|
||||||
|
values={getEffectiveConfig("bluetooth")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "Bluetooth Settings",
|
label: t("bluetooth.title"),
|
||||||
description: "Settings for the Bluetooth module ",
|
description: t("bluetooth.description"),
|
||||||
notes:
|
notes: t("bluetooth.note"),
|
||||||
"Note: Some devices (ESP32) cannot use both Bluetooth and WiFi at the same time.",
|
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "enabled",
|
name: "enabled",
|
||||||
label: "Enabled",
|
label: t("bluetooth.enabled.label"),
|
||||||
description: "Enable or disable Bluetooth",
|
description: t("bluetooth.enabled.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "mode",
|
name: "mode",
|
||||||
label: "Pairing mode",
|
label: t("bluetooth.pairingMode.label"),
|
||||||
description: "Pin selection behaviour.",
|
description: t("bluetooth.pairingMode.description"),
|
||||||
selectChange: (e) => {
|
|
||||||
if (e !== "1") {
|
|
||||||
setBluetoothPin("");
|
|
||||||
removeError("fixedPin");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -104,26 +74,8 @@ export const Bluetooth = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "fixedPin",
|
name: "fixedPin",
|
||||||
label: "Pin",
|
label: t("bluetooth.pin.label"),
|
||||||
description: "Pin to use when pairing",
|
description: t("bluetooth.pin.description"),
|
||||||
validationText: hasFieldError("fixedPin")
|
|
||||||
? getErrorMessage("fixedPin")
|
|
||||||
: "",
|
|
||||||
inputChange: bluetoothPinChangeEvent,
|
|
||||||
disabledBy: [
|
|
||||||
{
|
|
||||||
fieldName: "mode",
|
|
||||||
selector: Protobuf.Config.Config_BluetoothConfig_PairingMode
|
|
||||||
.FIXED_PIN,
|
|
||||||
invert: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fieldName: "enabled",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
properties: {
|
|
||||||
value: bluetoothPin,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
useDevice,
|
||||||
|
ValidConfigType,
|
||||||
|
ValidModuleConfigType,
|
||||||
|
} from "@core/stores/deviceStore.ts";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function ConfigSuspender({
|
||||||
|
configCase,
|
||||||
|
moduleConfigCase,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
configCase?: ValidConfigType;
|
||||||
|
moduleConfigCase?: ValidModuleConfigType;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { config, moduleConfig } = useDevice();
|
||||||
|
|
||||||
|
let cfg = undefined;
|
||||||
|
if (configCase) {
|
||||||
|
cfg = config[configCase];
|
||||||
|
} else if (moduleConfigCase) {
|
||||||
|
cfg = moduleConfig[moduleConfigCase];
|
||||||
|
} else {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [ready, setReady] = useState(() => cfg !== undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cfg !== undefined) setReady(true);
|
||||||
|
}, [cfg]);
|
||||||
|
|
||||||
|
if (!ready) throw new Promise(() => {}); // triggers suspense fallback
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import { Device } from "@components/PageComponents/Config/Device/index.tsx";
|
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
|
||||||
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
|
||||||
import { Protobuf } from "@meshtastic/core";
|
|
||||||
|
|
||||||
vi.mock("@core/stores/deviceStore.ts", () => ({
|
|
||||||
useDevice: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts", () => ({
|
|
||||||
useUnsafeRolesDialog: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the DynamicForm component since we're testing the Device component,
|
|
||||||
// not the DynamicForm implementation
|
|
||||||
vi.mock("@components/Form/DynamicForm", () => ({
|
|
||||||
DynamicForm: vi.fn(({ onSubmit }) => {
|
|
||||||
// Render a simplified version of the form for testing
|
|
||||||
return (
|
|
||||||
<div data-testid="dynamic-form">
|
|
||||||
<select
|
|
||||||
data-testid="role-select"
|
|
||||||
onChange={(e) => {
|
|
||||||
// Simulate the validation and submission process
|
|
||||||
const mockData = { role: e.target.value };
|
|
||||||
onSubmit(mockData);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map((
|
|
||||||
[key, value],
|
|
||||||
) => (
|
|
||||||
<option key={key} value={value}>
|
|
||||||
{key}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
data-testid="submit-button"
|
|
||||||
onClick={() => onSubmit({ role: "CLIENT" })}
|
|
||||||
>
|
|
||||||
Submit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("Device component", () => {
|
|
||||||
const setWorkingConfigMock = vi.fn();
|
|
||||||
const validateRoleSelectionMock = vi.fn();
|
|
||||||
const mockDeviceConfig = {
|
|
||||||
role: "CLIENT",
|
|
||||||
buttonGpio: 0,
|
|
||||||
buzzerGpio: 0,
|
|
||||||
rebroadcastMode: "ALL",
|
|
||||||
nodeInfoBroadcastSecs: 300,
|
|
||||||
doubleTapAsButtonPress: false,
|
|
||||||
disableTripleClick: false,
|
|
||||||
ledHeartbeatDisabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetAllMocks();
|
|
||||||
|
|
||||||
// Mock the useDevice hook
|
|
||||||
useDevice.mockReturnValue({
|
|
||||||
config: {
|
|
||||||
device: mockDeviceConfig,
|
|
||||||
},
|
|
||||||
setWorkingConfig: setWorkingConfigMock,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock the useUnsafeRolesDialog hook
|
|
||||||
validateRoleSelectionMock.mockResolvedValue(true);
|
|
||||||
useUnsafeRolesDialog.mockReturnValue({
|
|
||||||
validateRoleSelection: validateRoleSelectionMock,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render the Device form", () => {
|
|
||||||
render(<Device />);
|
|
||||||
expect(screen.getByTestId("dynamic-form")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use the validateRoleSelection from the unsafe roles hook", () => {
|
|
||||||
render(<Device />);
|
|
||||||
expect(useUnsafeRolesDialog).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should call setWorkingConfig when form is submitted", async () => {
|
|
||||||
render(<Device />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("submit-button"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
payloadVariant: {
|
|
||||||
case: "device",
|
|
||||||
value: expect.objectContaining({ role: "CLIENT" }),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create config with proper structure", async () => {
|
|
||||||
render(<Device />);
|
|
||||||
|
|
||||||
// Simulate form submission
|
|
||||||
fireEvent.click(screen.getByTestId("submit-button"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
payloadVariant: {
|
|
||||||
case: "device",
|
|
||||||
value: expect.any(Object),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,15 +1,33 @@
|
|||||||
import type { DeviceValidation } from "@app/validation/config/device.ts";
|
import {
|
||||||
|
type DeviceValidation,
|
||||||
|
DeviceValidationSchema,
|
||||||
|
} from "@app/validation/config/device.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Device = () => {
|
interface DeviceConfigProps {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<DeviceValidation>;
|
||||||
|
}
|
||||||
|
export const Device = ({ onFormInit }: DeviceConfigProps) => {
|
||||||
|
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||||
|
useDevice();
|
||||||
|
const { t } = useTranslation("deviceConfig");
|
||||||
const { validateRoleSelection } = useUnsafeRolesDialog();
|
const { validateRoleSelection } = useUnsafeRolesDialog();
|
||||||
|
|
||||||
const onSubmit = (data: DeviceValidation) => {
|
const onSubmit = (data: DeviceValidation) => {
|
||||||
|
if (deepCompareConfig(config.device, data, true)) {
|
||||||
|
removeWorkingConfig("device");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingConfig(
|
setWorkingConfig(
|
||||||
create(Protobuf.Config.ConfigSchema, {
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -19,20 +37,25 @@ export const Device = () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicForm<DeviceValidation>
|
<DynamicForm<DeviceValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
|
validationSchema={DeviceValidationSchema}
|
||||||
|
formId="Config_DeviceConfig"
|
||||||
defaultValues={config.device}
|
defaultValues={config.device}
|
||||||
|
values={getEffectiveConfig("device")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "Device Settings",
|
label: t("device.title"),
|
||||||
description: "Settings for the device",
|
description: t("device.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "role",
|
name: "role",
|
||||||
label: "Role",
|
label: t("device.role.label"),
|
||||||
description: "What role the device performs on the mesh",
|
description: t("device.role.description"),
|
||||||
validate: validateRoleSelection,
|
validate: validateRoleSelection,
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: Protobuf.Config.Config_DeviceConfig_Role,
|
enumValue: Protobuf.Config.Config_DeviceConfig_Role,
|
||||||
@@ -42,20 +65,20 @@ export const Device = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "buttonGpio",
|
name: "buttonGpio",
|
||||||
label: "Button Pin",
|
label: t("device.buttonPin.label"),
|
||||||
description: "Button pin override",
|
description: t("device.buttonPin.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "buzzerGpio",
|
name: "buzzerGpio",
|
||||||
label: "Buzzer Pin",
|
label: t("device.buzzerPin.label"),
|
||||||
description: "Buzzer pin override",
|
description: t("device.buzzerPin.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "rebroadcastMode",
|
name: "rebroadcastMode",
|
||||||
label: "Rebroadcast Mode",
|
label: t("device.rebroadcastMode.label"),
|
||||||
description: "How to handle rebroadcasting",
|
description: t("device.rebroadcastMode.description"),
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: Protobuf.Config.Config_DeviceConfig_RebroadcastMode,
|
enumValue: Protobuf.Config.Config_DeviceConfig_RebroadcastMode,
|
||||||
formatEnumName: true,
|
formatEnumName: true,
|
||||||
@@ -64,33 +87,34 @@ export const Device = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "nodeInfoBroadcastSecs",
|
name: "nodeInfoBroadcastSecs",
|
||||||
label: "Node Info Broadcast Interval",
|
label: t("device.nodeInfoBroadcastInterval.label"),
|
||||||
description: "How often to broadcast node info",
|
description: t("device.nodeInfoBroadcastInterval.description"),
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "Seconds",
|
suffix: t("unit.second.plural"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "doubleTapAsButtonPress",
|
name: "doubleTapAsButtonPress",
|
||||||
label: "Double Tap as Button Press",
|
label: t("device.doubleTapAsButtonPress.label"),
|
||||||
description: "Treat double tap as button press",
|
description: t("device.doubleTapAsButtonPress.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "disableTripleClick",
|
name: "disableTripleClick",
|
||||||
label: "Disable Triple Click",
|
label: t("device.disableTripleClick.label"),
|
||||||
description: "Disable triple click",
|
description: t("device.disableTripleClick.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
name: "tzdef",
|
name: "tzdef",
|
||||||
label: "POSIX Timezone",
|
label: t("device.posixTimezone.label"),
|
||||||
description: "The POSIX timezone string for the device",
|
description: t("device.posixTimezone.description"),
|
||||||
properties: {
|
properties: {
|
||||||
fieldLength: {
|
fieldLength: {
|
||||||
max: 64,
|
max: 64,
|
||||||
currentValueLength: config.device?.tzdef?.length,
|
currentValueLength: getEffectiveConfig("device")?.tzdef
|
||||||
|
?.length,
|
||||||
showCharacterCount: true,
|
showCharacterCount: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -98,8 +122,8 @@ export const Device = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "ledHeartbeatDisabled",
|
name: "ledHeartbeatDisabled",
|
||||||
label: "LED Heartbeat Disabled",
|
label: t("device.ledHeartbeatDisabled.label"),
|
||||||
description: "Disable default blinking LED",
|
description: t("device.ledHeartbeatDisabled.description"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
import type { DisplayValidation } from "@app/validation/config/display.tsx";
|
import {
|
||||||
|
type DisplayValidation,
|
||||||
|
DisplayValidationSchema,
|
||||||
|
} from "@app/validation/config/display.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Display = () => {
|
interface DisplayConfigProps {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<DisplayValidation>;
|
||||||
|
}
|
||||||
|
export const Display = ({ onFormInit }: DisplayConfigProps) => {
|
||||||
|
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||||
|
useDevice();
|
||||||
|
const { t } = useTranslation("deviceConfig");
|
||||||
|
|
||||||
const onSubmit = (data: DisplayValidation) => {
|
const onSubmit = (data: DisplayValidation) => {
|
||||||
|
if (deepCompareConfig(config.display, data, true)) {
|
||||||
|
removeWorkingConfig("display");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingConfig(
|
setWorkingConfig(
|
||||||
create(Protobuf.Config.ConfigSchema, {
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -21,26 +39,30 @@ export const Display = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<DisplayValidation>
|
<DynamicForm<DisplayValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
|
validationSchema={DisplayValidationSchema}
|
||||||
|
formId="Config_DisplayConfig"
|
||||||
defaultValues={config.display}
|
defaultValues={config.display}
|
||||||
|
values={getEffectiveConfig("display")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "Display Settings",
|
label: t("display.title"),
|
||||||
description: "Settings for the device display",
|
description: t("display.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "screenOnSecs",
|
name: "screenOnSecs",
|
||||||
label: "Screen Timeout",
|
label: t("display.screenTimeout.label"),
|
||||||
description: "Turn off the display after this long",
|
description: t("display.screenTimeout.description"),
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "Seconds",
|
suffix: t("unit.second.plural"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "gpsFormat",
|
name: "gpsFormat",
|
||||||
label: "GPS Display Units",
|
label: t("display.gpsDisplayUnits.label"),
|
||||||
description: "Coordinate display format",
|
description: t("display.gpsDisplayUnits.description"),
|
||||||
properties: {
|
properties: {
|
||||||
enumValue:
|
enumValue:
|
||||||
Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat,
|
Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat,
|
||||||
@@ -49,35 +71,35 @@ export const Display = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "autoScreenCarouselSecs",
|
name: "autoScreenCarouselSecs",
|
||||||
label: "Carousel Delay",
|
label: t("display.carouselDelay.label"),
|
||||||
description: "How fast to cycle through windows",
|
description: t("display.carouselDelay.description"),
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "Seconds",
|
suffix: t("unit.second.plural"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "compassNorthTop",
|
name: "compassNorthTop",
|
||||||
label: "Compass North Top",
|
label: t("display.compassNorthTop.label"),
|
||||||
description: "Fix north to the top of compass",
|
description: t("display.compassNorthTop.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "use12hClock",
|
name: "use12hClock",
|
||||||
label: "12-Hour Clock",
|
label: t("display.twelveHourClock.label"),
|
||||||
description: "Use 12-hour clock format",
|
description: t("display.twelveHourClock.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "flipScreen",
|
name: "flipScreen",
|
||||||
label: "Flip Screen",
|
label: t("display.flipScreen.label"),
|
||||||
description: "Flip display 180 degrees",
|
description: t("display.flipScreen.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "units",
|
name: "units",
|
||||||
label: "Display Units",
|
label: t("display.displayUnits.label"),
|
||||||
description: "Display metric or imperial units",
|
description: t("display.displayUnits.description"),
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: Protobuf.Config.Config_DisplayConfig_DisplayUnits,
|
enumValue: Protobuf.Config.Config_DisplayConfig_DisplayUnits,
|
||||||
formatEnumName: true,
|
formatEnumName: true,
|
||||||
@@ -86,8 +108,8 @@ export const Display = () => {
|
|||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "oled",
|
name: "oled",
|
||||||
label: "OLED Type",
|
label: t("display.oledType.label"),
|
||||||
description: "Type of OLED screen attached to the device",
|
description: t("display.oledType.description"),
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: Protobuf.Config.Config_DisplayConfig_OledType,
|
enumValue: Protobuf.Config.Config_DisplayConfig_OledType,
|
||||||
},
|
},
|
||||||
@@ -95,8 +117,8 @@ export const Display = () => {
|
|||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "displaymode",
|
name: "displaymode",
|
||||||
label: "Display Mode",
|
label: t("display.displayMode.label"),
|
||||||
description: "Screen layout variant",
|
description: t("display.displayMode.description"),
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: Protobuf.Config.Config_DisplayConfig_DisplayMode,
|
enumValue: Protobuf.Config.Config_DisplayConfig_DisplayMode,
|
||||||
formatEnumName: true,
|
formatEnumName: true,
|
||||||
@@ -105,14 +127,14 @@ export const Display = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "headingBold",
|
name: "headingBold",
|
||||||
label: "Bold Heading",
|
label: t("display.headingBold.label"),
|
||||||
description: "Bolden the heading text",
|
description: t("display.headingBold.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "wakeOnTapOrMotion",
|
name: "wakeOnTapOrMotion",
|
||||||
label: "Wake on Tap or Motion",
|
label: t("display.wakeOnTapOrMotion.label"),
|
||||||
description: "Wake the device on tap or motion",
|
description: t("display.wakeOnTapOrMotion.description"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
import type { LoRaValidation } from "@app/validation/config/lora.tsx";
|
import {
|
||||||
|
type LoRaValidation,
|
||||||
|
LoRaValidationSchema,
|
||||||
|
} from "@app/validation/config/lora.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const LoRa = () => {
|
interface LoRaConfigProps {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<LoRaValidation>;
|
||||||
|
}
|
||||||
|
export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
|
||||||
|
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||||
|
useDevice();
|
||||||
|
const { t } = useTranslation("deviceConfig");
|
||||||
|
|
||||||
const onSubmit = (data: LoRaValidation) => {
|
const onSubmit = (data: LoRaValidation) => {
|
||||||
|
if (deepCompareConfig(config.lora, data, true)) {
|
||||||
|
removeWorkingConfig("lora");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingConfig(
|
setWorkingConfig(
|
||||||
create(Protobuf.Config.ConfigSchema, {
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -21,17 +39,21 @@ export const LoRa = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<LoRaValidation>
|
<DynamicForm<LoRaValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
|
validationSchema={LoRaValidationSchema}
|
||||||
|
formId="Config_LoRaConfig"
|
||||||
defaultValues={config.lora}
|
defaultValues={config.lora}
|
||||||
|
values={getEffectiveConfig("lora")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "Mesh Settings",
|
label: t("lora.title"),
|
||||||
description: "Settings for the LoRa mesh",
|
description: t("lora.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "region",
|
name: "region",
|
||||||
label: "Region",
|
label: t("lora.region.label"),
|
||||||
description: "Sets the region for your node",
|
description: t("lora.region.description"),
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: Protobuf.Config.Config_LoRaConfig_RegionCode,
|
enumValue: Protobuf.Config.Config_LoRaConfig_RegionCode,
|
||||||
},
|
},
|
||||||
@@ -39,8 +61,8 @@ export const LoRa = () => {
|
|||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "hopLimit",
|
name: "hopLimit",
|
||||||
label: "Hop Limit",
|
label: t("lora.hopLimit.label"),
|
||||||
description: "Maximum number of hops",
|
description: t("lora.hopLimit.description"),
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: { 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7 },
|
enumValue: { 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7 },
|
||||||
},
|
},
|
||||||
@@ -48,39 +70,38 @@ export const LoRa = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "channelNum",
|
name: "channelNum",
|
||||||
label: "Frequency Slot",
|
label: t("lora.frequencySlot.label"),
|
||||||
description: "LoRa frequency channel number",
|
description: t("lora.frequencySlot.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "ignoreMqtt",
|
name: "ignoreMqtt",
|
||||||
label: "Ignore MQTT",
|
label: t("lora.ignoreMqtt.label"),
|
||||||
description: "Don't forward MQTT messages over the mesh",
|
description: t("lora.ignoreMqtt.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "configOkToMqtt",
|
name: "configOkToMqtt",
|
||||||
label: "OK to MQTT",
|
label: t("lora.okToMqtt.label"),
|
||||||
description:
|
description: t("lora.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: "Waveform Settings",
|
label: t("lora.waveformSettings.label"),
|
||||||
description: "Settings for the LoRa waveform",
|
description: t("lora.waveformSettings.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "usePreset",
|
name: "usePreset",
|
||||||
label: "Use Preset",
|
label: t("lora.usePreset.label"),
|
||||||
description: "Use one of the predefined modem presets",
|
description: t("lora.usePreset.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "modemPreset",
|
name: "modemPreset",
|
||||||
label: "Modem Preset",
|
label: t("lora.modemPreset.label"),
|
||||||
description: "Modem preset to use",
|
description: t("lora.modemPreset.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "usePreset",
|
fieldName: "usePreset",
|
||||||
@@ -94,8 +115,8 @@ export const LoRa = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "bandwidth",
|
name: "bandwidth",
|
||||||
label: "Bandwidth",
|
label: t("lora.bandwidth.label"),
|
||||||
description: "Channel bandwidth in MHz",
|
description: t("lora.bandwidth.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "usePreset",
|
fieldName: "usePreset",
|
||||||
@@ -103,14 +124,14 @@ export const LoRa = () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "MHz",
|
suffix: t("unit.megahertz"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "spreadFactor",
|
name: "spreadFactor",
|
||||||
label: "Spreading Factor",
|
label: t("lora.spreadingFactor.label"),
|
||||||
description: "Indicates the number of chirps per symbol",
|
description: t("lora.spreadingFactor.description"),
|
||||||
|
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
@@ -119,14 +140,14 @@ export const LoRa = () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "CPS",
|
suffix: t("unit.cps"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "codingRate",
|
name: "codingRate",
|
||||||
label: "Coding Rate",
|
label: t("lora.codingRate.label"),
|
||||||
description: "The denominator of the coding rate",
|
description: t("lora.codingRate.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "usePreset",
|
fieldName: "usePreset",
|
||||||
@@ -137,53 +158,52 @@ export const LoRa = () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Radio Settings",
|
label: t("lora.radioSettings.label"),
|
||||||
description: "Settings for the LoRa radio",
|
description: t("lora.radioSettings.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "txEnabled",
|
name: "txEnabled",
|
||||||
label: "Transmit Enabled",
|
label: t("lora.transmitEnabled.label"),
|
||||||
description: "Enable/Disable transmit (TX) from the LoRa radio",
|
description: t("lora.transmitEnabled.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "txPower",
|
name: "txPower",
|
||||||
label: "Transmit Power",
|
label: t("lora.transmitPower.label"),
|
||||||
description: "Max transmit power",
|
description: t("lora.transmitPower.description"),
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "dBm",
|
suffix: t("unit.dbm"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "overrideDutyCycle",
|
name: "overrideDutyCycle",
|
||||||
label: "Override Duty Cycle",
|
label: t("lora.overrideDutyCycle.label"),
|
||||||
description: "Override Duty Cycle",
|
description: t("lora.overrideDutyCycle.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "frequencyOffset",
|
name: "frequencyOffset",
|
||||||
label: "Frequency Offset",
|
label: t("lora.frequencyOffset.label"),
|
||||||
description:
|
description: t("lora.frequencyOffset.description"),
|
||||||
"Frequency offset to correct for crystal calibration errors",
|
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "Hz",
|
suffix: t("unit.hertz"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "sx126xRxBoostedGain",
|
name: "sx126xRxBoostedGain",
|
||||||
label: "Boosted RX Gain",
|
label: t("lora.boostedRxGain.label"),
|
||||||
description: "Boosted RX gain",
|
description: t("lora.boostedRxGain.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "overrideFrequency",
|
name: "overrideFrequency",
|
||||||
label: "Override Frequency",
|
label: t("lora.overrideFrequency.label"),
|
||||||
description: "Override frequency",
|
description: t("lora.overrideFrequency.description"),
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "MHz",
|
suffix: t("unit.megahertz"),
|
||||||
step: 0.001,
|
step: 0.001,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import { Network } from "@components/PageComponents/Config/Network/index.tsx";
|
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
|
||||||
import { Protobuf } from "@meshtastic/core";
|
|
||||||
|
|
||||||
vi.mock("@core/stores/deviceStore", () => ({
|
|
||||||
useDevice: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@components/Form/DynamicForm", async () => {
|
|
||||||
const React = await import("react");
|
|
||||||
const { useState } = React;
|
|
||||||
|
|
||||||
return {
|
|
||||||
DynamicForm: ({ onSubmit, defaultValues }) => {
|
|
||||||
const [wifiEnabled, setWifiEnabled] = useState(
|
|
||||||
defaultValues.wifiEnabled ?? false,
|
|
||||||
);
|
|
||||||
const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? "");
|
|
||||||
const [psk, setPsk] = useState(defaultValues.wifiPsk ?? "");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit({
|
|
||||||
...defaultValues,
|
|
||||||
wifiEnabled,
|
|
||||||
wifiSsid: ssid,
|
|
||||||
wifiPsk: psk,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
data-testid="dynamic-form"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
aria-label="WiFi Enabled"
|
|
||||||
checked={wifiEnabled}
|
|
||||||
onChange={(e) => setWifiEnabled(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
aria-label="SSID"
|
|
||||||
value={ssid}
|
|
||||||
onChange={(e) => setSsid(e.target.value)}
|
|
||||||
disabled={!wifiEnabled}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
aria-label="PSK"
|
|
||||||
value={psk}
|
|
||||||
onChange={(e) => setPsk(e.target.value)}
|
|
||||||
disabled={!wifiEnabled}
|
|
||||||
/>
|
|
||||||
<button type="submit" data-testid="submit-button">
|
|
||||||
Submit
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Network component", () => {
|
|
||||||
const setWorkingConfigMock = vi.fn();
|
|
||||||
const mockNetworkConfig = {
|
|
||||||
wifiEnabled: false,
|
|
||||||
wifiSsid: "",
|
|
||||||
wifiPsk: "",
|
|
||||||
ntpServer: "",
|
|
||||||
ethEnabled: false,
|
|
||||||
addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
|
|
||||||
ipv4Config: {
|
|
||||||
ip: 0,
|
|
||||||
gateway: 0,
|
|
||||||
subnet: 0,
|
|
||||||
dns: 0,
|
|
||||||
},
|
|
||||||
enabledProtocols:
|
|
||||||
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
|
|
||||||
rsyslogServer: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetAllMocks();
|
|
||||||
|
|
||||||
useDevice.mockReturnValue({
|
|
||||||
config: {
|
|
||||||
network: mockNetworkConfig,
|
|
||||||
},
|
|
||||||
setWorkingConfig: setWorkingConfigMock,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render the Network form", () => {
|
|
||||||
render(<Network />);
|
|
||||||
expect(screen.getByTestId("dynamic-form")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should disable SSID and PSK fields when wifi is off", () => {
|
|
||||||
render(<Network />);
|
|
||||||
expect(screen.getByLabelText("SSID")).toBeDisabled();
|
|
||||||
expect(screen.getByLabelText("PSK")).toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should enable SSID and PSK when wifi is toggled on", async () => {
|
|
||||||
render(<Network />);
|
|
||||||
const toggle = screen.getByLabelText("WiFi Enabled");
|
|
||||||
screen.debug();
|
|
||||||
|
|
||||||
fireEvent.click(toggle); // turns wifiEnabled = true
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByLabelText("SSID")).not.toBeDisabled();
|
|
||||||
expect(screen.getByLabelText("PSK")).not.toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should call setWorkingConfig with the right structure on submit", async () => {
|
|
||||||
render(<Network />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("submit-button"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
payloadVariant: {
|
|
||||||
case: "network",
|
|
||||||
value: expect.objectContaining({
|
|
||||||
wifiEnabled: false,
|
|
||||||
wifiSsid: "",
|
|
||||||
wifiPsk: "",
|
|
||||||
ntpServer: "",
|
|
||||||
ethEnabled: false,
|
|
||||||
rsyslogServer: "",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should submit valid data after enabling wifi and entering SSID and PSK", async () => {
|
|
||||||
render(<Network />);
|
|
||||||
fireEvent.click(screen.getByLabelText("WiFi Enabled"));
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("SSID"), {
|
|
||||||
target: { value: "MySSID" },
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("PSK"), {
|
|
||||||
target: { value: "MySecretPSK" },
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("submit-button"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
payloadVariant: {
|
|
||||||
case: "network",
|
|
||||||
value: expect.objectContaining({
|
|
||||||
wifiEnabled: true,
|
|
||||||
wifiSsid: "MySSID",
|
|
||||||
wifiPsk: "MySecretPSK",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,30 +3,31 @@ import {
|
|||||||
NetworkValidationSchema,
|
NetworkValidationSchema,
|
||||||
} from "@app/validation/config/network.ts";
|
} from "@app/validation/config/network.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import {
|
import {
|
||||||
convertIntToIpAddress,
|
convertIntToIpAddress,
|
||||||
convertIpAddressToInt,
|
convertIpAddressToInt,
|
||||||
} from "@core/utils/ip.ts";
|
} from "@core/utils/ip.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { validateSchema } from "@app/validation/validate.ts";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Network = () => {
|
interface NetworkConfigProps {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<NetworkValidation>;
|
||||||
|
}
|
||||||
|
export const Network = ({ onFormInit }: NetworkConfigProps) => {
|
||||||
|
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||||
|
useDevice();
|
||||||
|
const { t } = useTranslation("deviceConfig");
|
||||||
|
|
||||||
|
const networkConfig = getEffectiveConfig("network");
|
||||||
|
|
||||||
const onSubmit = (data: NetworkValidation) => {
|
const onSubmit = (data: NetworkValidation) => {
|
||||||
const result = validateSchema(NetworkValidationSchema, data);
|
const payload = {
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
console.error("Validation errors:", result.errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
setWorkingConfig(
|
|
||||||
create(Protobuf.Config.ConfigSchema, {
|
|
||||||
payloadVariant: {
|
|
||||||
case: "network",
|
|
||||||
value: {
|
|
||||||
...data,
|
...data,
|
||||||
ipv4Config: create(
|
ipv4Config: create(
|
||||||
Protobuf.Config.Config_NetworkConfig_IpV4ConfigSchema,
|
Protobuf.Config.Config_NetworkConfig_IpV4ConfigSchema,
|
||||||
@@ -37,15 +38,28 @@ export const Network = () => {
|
|||||||
dns: convertIpAddressToInt(data.ipv4Config?.dns ?? ""),
|
dns: convertIpAddressToInt(data.ipv4Config?.dns ?? ""),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
};
|
||||||
|
|
||||||
|
if (deepCompareConfig(config.network, payload, true)) {
|
||||||
|
removeWorkingConfig("network");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWorkingConfig(
|
||||||
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
|
payloadVariant: {
|
||||||
|
case: "network",
|
||||||
|
value: payload,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicForm<NetworkValidation>
|
<DynamicForm<NetworkValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
|
validationSchema={NetworkValidationSchema}
|
||||||
|
formId="Config_NetworkConfig"
|
||||||
defaultValues={{
|
defaultValues={{
|
||||||
...config.network,
|
...config.network,
|
||||||
ipv4Config: {
|
ipv4Config: {
|
||||||
@@ -61,24 +75,38 @@ export const Network = () => {
|
|||||||
enabledProtocols: config.network?.enabledProtocols ??
|
enabledProtocols: config.network?.enabledProtocols ??
|
||||||
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
|
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
|
||||||
}}
|
}}
|
||||||
|
values={{
|
||||||
|
...networkConfig,
|
||||||
|
ipv4Config: {
|
||||||
|
ip: convertIntToIpAddress(networkConfig?.ipv4Config?.ip ?? 0),
|
||||||
|
gateway: convertIntToIpAddress(
|
||||||
|
networkConfig?.ipv4Config?.gateway ?? 0,
|
||||||
|
),
|
||||||
|
subnet: convertIntToIpAddress(
|
||||||
|
networkConfig?.ipv4Config?.subnet ?? 0,
|
||||||
|
),
|
||||||
|
dns: convertIntToIpAddress(networkConfig?.ipv4Config?.dns ?? 0),
|
||||||
|
},
|
||||||
|
enabledProtocols: networkConfig?.enabledProtocols ??
|
||||||
|
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
|
||||||
|
} as NetworkValidation}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "WiFi Config",
|
label: t("network.title"),
|
||||||
description: "WiFi radio configuration",
|
description: t("network.description"),
|
||||||
notes:
|
notes: t("network.note"),
|
||||||
"Note: Some devices (ESP32) cannot use both Bluetooth and WiFi at the same time.",
|
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "wifiEnabled",
|
name: "wifiEnabled",
|
||||||
label: "Enabled",
|
label: t("network.wifiEnabled.label"),
|
||||||
description: "Enable or disable the WiFi radio",
|
description: t("network.wifiEnabled.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
name: "wifiSsid",
|
name: "wifiSsid",
|
||||||
label: "SSID",
|
label: t("network.ssid.label"),
|
||||||
description: "Network name",
|
description: t("network.ssid.label"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "wifiEnabled",
|
fieldName: "wifiEnabled",
|
||||||
@@ -88,8 +116,8 @@ export const Network = () => {
|
|||||||
{
|
{
|
||||||
type: "password",
|
type: "password",
|
||||||
name: "wifiPsk",
|
name: "wifiPsk",
|
||||||
label: "PSK",
|
label: t("network.psk.label"),
|
||||||
description: "Network password",
|
description: t("network.psk.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "wifiEnabled",
|
fieldName: "wifiEnabled",
|
||||||
@@ -99,26 +127,26 @@ export const Network = () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Ethernet Config",
|
label: t("network.ethernetConfigSettings.label"),
|
||||||
description: "Ethernet port configuration",
|
description: t("network.ethernetConfigSettings.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "ethEnabled",
|
name: "ethEnabled",
|
||||||
label: "Enabled",
|
label: t("network.ethernetEnabled.label"),
|
||||||
description: "Enable or disable the Ethernet port",
|
description: t("network.ethernetEnabled.description"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "IP Config",
|
label: t("network.ipConfigSettings.label"),
|
||||||
description: "IP configuration",
|
description: t("network.ipConfigSettings.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "addressMode",
|
name: "addressMode",
|
||||||
label: "Address Mode",
|
label: t("network.addressMode.label"),
|
||||||
description: "Address assignment selection",
|
description: t("network.addressMode.description"),
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: Protobuf.Config.Config_NetworkConfig_AddressMode,
|
enumValue: Protobuf.Config.Config_NetworkConfig_AddressMode,
|
||||||
},
|
},
|
||||||
@@ -126,8 +154,8 @@ export const Network = () => {
|
|||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
name: "ipv4Config.ip",
|
name: "ipv4Config.ip",
|
||||||
label: "IP",
|
label: t("network.ip.label"),
|
||||||
description: "IP Address",
|
description: t("network.ip.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "addressMode",
|
fieldName: "addressMode",
|
||||||
@@ -139,8 +167,8 @@ export const Network = () => {
|
|||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
name: "ipv4Config.gateway",
|
name: "ipv4Config.gateway",
|
||||||
label: "Gateway",
|
label: t("network.gateway.label"),
|
||||||
description: "Default Gateway",
|
description: t("network.gateway.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "addressMode",
|
fieldName: "addressMode",
|
||||||
@@ -152,8 +180,8 @@ export const Network = () => {
|
|||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
name: "ipv4Config.subnet",
|
name: "ipv4Config.subnet",
|
||||||
label: "Subnet",
|
label: t("network.subnet.label"),
|
||||||
description: "Subnet Mask",
|
description: t("network.subnet.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "addressMode",
|
fieldName: "addressMode",
|
||||||
@@ -165,8 +193,8 @@ export const Network = () => {
|
|||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
name: "ipv4Config.dns",
|
name: "ipv4Config.dns",
|
||||||
label: "DNS",
|
label: t("network.dns.label"),
|
||||||
description: "DNS Server",
|
description: t("network.dns.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "addressMode",
|
fieldName: "addressMode",
|
||||||
@@ -178,13 +206,13 @@ export const Network = () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "UDP Config",
|
label: t("network.udpConfigSettings.label"),
|
||||||
description: "UDP over Mesh configuration",
|
description: t("network.udpConfigSettings.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "enabledProtocols",
|
name: "enabledProtocols",
|
||||||
label: "Mesh via UDP",
|
label: t("network.meshViaUdp.label"),
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
|
enumValue: Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
|
||||||
formatEnumName: true,
|
formatEnumName: true,
|
||||||
@@ -193,24 +221,24 @@ export const Network = () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "NTP Config",
|
label: t("network.ntpConfigSettings.label"),
|
||||||
description: "NTP configuration",
|
description: t("network.ntpConfigSettings.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
name: "ntpServer",
|
name: "ntpServer",
|
||||||
label: "NTP Server",
|
label: t("network.ntpServer.label"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Rsyslog Config",
|
label: t("network.rsyslogConfigSettings.label"),
|
||||||
description: "Rsyslog configuration",
|
description: t("network.rsyslogConfigSettings.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
name: "rsyslogServer",
|
name: "rsyslogServer",
|
||||||
label: "Rsyslog Server",
|
label: t("network.rsyslogServer.label"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,20 +2,38 @@ import {
|
|||||||
type FlagName,
|
type FlagName,
|
||||||
usePositionFlags,
|
usePositionFlags,
|
||||||
} from "@core/hooks/usePositionFlags.ts";
|
} from "@core/hooks/usePositionFlags.ts";
|
||||||
import type { PositionValidation } from "@app/validation/config/position.ts";
|
import {
|
||||||
|
type PositionValidation,
|
||||||
|
PositionValidationSchema,
|
||||||
|
} from "@app/validation/config/position.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Position = () => {
|
interface PositionConfigProps {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<PositionValidation>;
|
||||||
|
}
|
||||||
|
export const Position = ({ onFormInit }: PositionConfigProps) => {
|
||||||
|
const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } =
|
||||||
|
useDevice();
|
||||||
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
|
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
|
||||||
config?.position?.positionFlags ?? 0,
|
getEffectiveConfig("position")?.positionFlags ?? 0,
|
||||||
);
|
);
|
||||||
|
const { t } = useTranslation("deviceConfig");
|
||||||
|
|
||||||
const onSubmit = (data: PositionValidation) => {
|
const onSubmit = (data: PositionValidation) => {
|
||||||
|
if (deepCompareConfig(config.position, data, true)) {
|
||||||
|
removeWorkingConfig("position");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return setWorkingConfig(
|
return setWorkingConfig(
|
||||||
create(Protobuf.Config.ConfigSchema, {
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -39,25 +57,27 @@ export const Position = () => {
|
|||||||
data.positionFlags = flagsValue;
|
data.positionFlags = flagsValue;
|
||||||
return onSubmit(data);
|
return onSubmit(data);
|
||||||
}}
|
}}
|
||||||
|
onFormInit={onFormInit}
|
||||||
|
validationSchema={PositionValidationSchema}
|
||||||
|
formId="Config_PositionConfig"
|
||||||
defaultValues={config.position}
|
defaultValues={config.position}
|
||||||
|
values={getEffectiveConfig("position")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "Position Settings",
|
label: t("position.title"),
|
||||||
description: "Settings for the position module",
|
description: t("position.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "positionBroadcastSmartEnabled",
|
name: "positionBroadcastSmartEnabled",
|
||||||
label: "Enable Smart Position",
|
label: t("position.smartPositionEnabled.label"),
|
||||||
description:
|
description: t("position.smartPositionEnabled.description"),
|
||||||
"Only send position when there has been a meaningful change in location",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "gpsMode",
|
name: "gpsMode",
|
||||||
label: "GPS Mode",
|
label: t("position.gpsMode.label"),
|
||||||
description:
|
description: t("position.gpsMode.description"),
|
||||||
"Configure whether device GPS is Enabled, Disabled, or Not Present",
|
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: Protobuf.Config.Config_PositionConfig_GpsMode,
|
enumValue: Protobuf.Config.Config_PositionConfig_GpsMode,
|
||||||
},
|
},
|
||||||
@@ -65,9 +85,8 @@ export const Position = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "fixedPosition",
|
name: "fixedPosition",
|
||||||
label: "Fixed Position",
|
label: t("position.fixedPosition.label"),
|
||||||
description:
|
description: t("position.fixedPosition.description"),
|
||||||
"Don't report GPS position, but a manually-specified one",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "multiSelect",
|
type: "multiSelect",
|
||||||
@@ -76,10 +95,9 @@ export const Position = () => {
|
|||||||
isChecked: (name: string) =>
|
isChecked: (name: string) =>
|
||||||
activeFlags?.includes(name as FlagName) ?? false,
|
activeFlags?.includes(name as FlagName) ?? false,
|
||||||
onValueChange: onPositonFlagChange,
|
onValueChange: onPositonFlagChange,
|
||||||
label: "Position Flags",
|
label: t("position.positionFlags.label"),
|
||||||
placeholder: "Select position flags...",
|
placeholder: t("position.flags.placeholder"),
|
||||||
description:
|
description: t("position.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.",
|
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: getAllFlags(),
|
enumValue: getAllFlags(),
|
||||||
},
|
},
|
||||||
@@ -87,51 +105,50 @@ export const Position = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "rxGpio",
|
name: "rxGpio",
|
||||||
label: "Receive Pin",
|
label: t("position.receivePin.label"),
|
||||||
description: "GPS module RX pin override",
|
description: t("position.receivePin.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "txGpio",
|
name: "txGpio",
|
||||||
label: "Transmit Pin",
|
label: t("position.transmitPin.label"),
|
||||||
description: "GPS module TX pin override",
|
description: t("position.transmitPin.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "gpsEnGpio",
|
name: "gpsEnGpio",
|
||||||
label: "Enable Pin",
|
label: t("position.enablePin.label"),
|
||||||
description: "GPS module enable pin override",
|
description: t("position.enablePin.description"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Intervals",
|
label: t("position.intervalsSettings.label"),
|
||||||
description: "How often to send position updates",
|
description: t("position.intervalsSettings.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "positionBroadcastSecs",
|
name: "positionBroadcastSecs",
|
||||||
label: "Broadcast Interval",
|
label: t("position.broadcastInterval.label"),
|
||||||
description: "How often your position is sent out over the mesh",
|
description: t("position.broadcastInterval.description"),
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "Seconds",
|
suffix: t("unit.second.plural"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "gpsUpdateInterval",
|
name: "gpsUpdateInterval",
|
||||||
label: "GPS Update Interval",
|
label: t("position.gpsUpdateInterval.label"),
|
||||||
description: "How often a GPS fix should be acquired",
|
description: t("position.gpsUpdateInterval.description"),
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "Seconds",
|
suffix: t("unit.second.plural"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "broadcastSmartMinimumDistance",
|
name: "broadcastSmartMinimumDistance",
|
||||||
label: "Smart Position Minimum Distance",
|
label: t("position.smartPositionMinDistance.label"),
|
||||||
description:
|
description: t("position.smartPositionMinDistance.description"),
|
||||||
"Minimum distance (in meters) that must be traveled before a position update is sent",
|
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "positionBroadcastSmartEnabled",
|
fieldName: "positionBroadcastSmartEnabled",
|
||||||
@@ -141,9 +158,8 @@ export const Position = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "broadcastSmartMinimumIntervalSecs",
|
name: "broadcastSmartMinimumIntervalSecs",
|
||||||
label: "Smart Position Minimum Interval",
|
label: t("position.smartPositionMinInterval.label"),
|
||||||
description:
|
description: t("position.smartPositionMinInterval.description"),
|
||||||
"Minimum interval (in seconds) that must pass before a position update is sent",
|
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "positionBroadcastSmartEnabled",
|
fieldName: "positionBroadcastSmartEnabled",
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
import type { PowerValidation } from "@app/validation/config/power.tsx";
|
import {
|
||||||
|
type PowerValidation,
|
||||||
|
PowerValidationSchema,
|
||||||
|
} from "@app/validation/config/power.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Power = () => {
|
interface PowerConfigProps {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<PowerValidation>;
|
||||||
|
}
|
||||||
|
export const Power = ({ onFormInit }: PowerConfigProps) => {
|
||||||
|
const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } =
|
||||||
|
useDevice();
|
||||||
|
const { t } = useTranslation("deviceConfig");
|
||||||
|
|
||||||
const onSubmit = (data: PowerValidation) => {
|
const onSubmit = (data: PowerValidation) => {
|
||||||
|
if (deepCompareConfig(config.power, data, true)) {
|
||||||
|
removeWorkingConfig("power");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingConfig(
|
setWorkingConfig(
|
||||||
create(Protobuf.Config.ConfigSchema, {
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -21,34 +39,36 @@ export const Power = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<PowerValidation>
|
<DynamicForm<PowerValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
|
validationSchema={PowerValidationSchema}
|
||||||
|
formId="Config_PowerConfig"
|
||||||
defaultValues={config.power}
|
defaultValues={config.power}
|
||||||
|
values={getEffectiveConfig("power")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "Power Config",
|
label: t("power.powerConfigSettings.label"),
|
||||||
description: "Settings for the power module",
|
description: t("power.powerConfigSettings.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "isPowerSaving",
|
name: "isPowerSaving",
|
||||||
label: "Enable power saving mode",
|
label: t("power.powerSavingEnabled.label"),
|
||||||
description:
|
description: t("power.powerSavingEnabled.description"),
|
||||||
"Select if powered from a low-current source (i.e. solar), to minimize power consumption as much as possible.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "onBatteryShutdownAfterSecs",
|
name: "onBatteryShutdownAfterSecs",
|
||||||
label: "Shutdown on battery delay",
|
label: t("power.shutdownOnBatteryDelay.label"),
|
||||||
description:
|
description: t("power.shutdownOnBatteryDelay.description"),
|
||||||
"Automatically shutdown node after this long when on battery, 0 for indefinite",
|
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "Seconds",
|
suffix: t("unit.second.plural"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "adcMultiplierOverride",
|
name: "adcMultiplierOverride",
|
||||||
label: "ADC Multiplier Override ratio",
|
label: t("power.adcMultiplierOverride.label"),
|
||||||
description: "Used for tweaking battery voltage reading",
|
description: t("power.adcMultiplierOverride.description"),
|
||||||
properties: {
|
properties: {
|
||||||
step: 0.0001,
|
step: 0.0001,
|
||||||
},
|
},
|
||||||
@@ -56,52 +76,49 @@ export const Power = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "waitBluetoothSecs",
|
name: "waitBluetoothSecs",
|
||||||
label: "No Connection Bluetooth Disabled",
|
label: t("power.noConnectionBluetoothDisabled.label"),
|
||||||
description:
|
description: t("power.noConnectionBluetoothDisabled.description"),
|
||||||
"If the device does not receive a Bluetooth connection, the BLE radio will be disabled after this long",
|
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "Seconds",
|
suffix: t("unit.second.plural"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "deviceBatteryInaAddress",
|
name: "deviceBatteryInaAddress",
|
||||||
label: "INA219 Address",
|
label: t("power.ina219Address.label"),
|
||||||
description: "Address of the INA219 battery monitor",
|
description: t("power.ina219Address.description"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Sleep Settings",
|
label: t("power.sleepSettings.label"),
|
||||||
description: "Sleep settings for the power module",
|
description: t("power.sleepSettings.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "sdsSecs",
|
name: "sdsSecs",
|
||||||
label: "Super Deep Sleep Duration",
|
label: t("power.superDeepSleepDuration.label"),
|
||||||
description:
|
description: t("power.superDeepSleepDuration.description"),
|
||||||
"How long the device will be in super deep sleep for",
|
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "Seconds",
|
suffix: t("unit.second.plural"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "lsSecs",
|
name: "lsSecs",
|
||||||
label: "Light Sleep Duration",
|
label: t("power.lightSleepDuration.label"),
|
||||||
description: "How long the device will be in light sleep for",
|
description: t("power.lightSleepDuration.description"),
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "Seconds",
|
suffix: t("unit.second.plural"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "minWakeSecs",
|
name: "minWakeSecs",
|
||||||
label: "Minimum Wake Time",
|
label: t("power.minimumWakeTime.label"),
|
||||||
description:
|
description: t("power.minimumWakeTime.description"),
|
||||||
"Minimum amount of time the device will stay awake for after receiving a packet",
|
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "Seconds",
|
suffix: t("unit.second.plural"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,198 +1,179 @@
|
|||||||
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
|
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { ManagedModeDialog } from "@components/Dialog/ManagedModeDialog.tsx";
|
||||||
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { useAppStore } from "@core/stores/appStore.ts";
|
import { useAppStore } from "@core/stores/appStore.ts";
|
||||||
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
|
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
|
||||||
import type { SecurityValidation } from "@app/validation/config/security.ts";
|
import {
|
||||||
|
type ParsedSecurity,
|
||||||
|
type RawSecurity,
|
||||||
|
RawSecuritySchema,
|
||||||
|
} from "@app/validation/config/security.ts";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { fromByteArray, toByteArray } from "base64-js";
|
import { fromByteArray, toByteArray } from "base64-js";
|
||||||
import { useReducer } from "react";
|
import { useTranslation } from "react-i18next";
|
||||||
import { securityReducer } from "@components/PageComponents/Config/Security/securityReducer.tsx";
|
import { type DefaultValues, useForm } from "react-hook-form";
|
||||||
|
import { createZodResolver } from "@components/Form/createZodResolver.ts";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Security = () => {
|
interface SecurityConfigProps {
|
||||||
const { config, setWorkingConfig, setDialogOpen } = useDevice();
|
onFormInit: DynamicFormFormInit<RawSecurity>;
|
||||||
|
}
|
||||||
|
export const Security = ({ onFormInit }: SecurityConfigProps) => {
|
||||||
const {
|
const {
|
||||||
hasErrors,
|
config,
|
||||||
getErrorMessage,
|
setWorkingConfig,
|
||||||
hasFieldError,
|
setDialogOpen,
|
||||||
addError,
|
getEffectiveConfig,
|
||||||
removeError,
|
removeWorkingConfig,
|
||||||
clearErrors,
|
} = useDevice();
|
||||||
} = useAppStore();
|
|
||||||
|
|
||||||
const [state, dispatch] = useReducer(securityReducer, {
|
const { removeError } = useAppStore();
|
||||||
privateKey: fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
|
const { t } = useTranslation("deviceConfig");
|
||||||
privateKeyVisible: false,
|
|
||||||
adminKeyVisible: false,
|
const securityConfig = getEffectiveConfig("security");
|
||||||
privateKeyBitCount: config.security?.privateKey?.length ?? 32,
|
const defaultValues = {
|
||||||
adminKeyBitCount: config.security?.adminKey?.at(0)?.length ?? 32,
|
...securityConfig,
|
||||||
publicKey: fromByteArray(config.security?.publicKey ?? new Uint8Array(0)),
|
...{
|
||||||
adminKey: fromByteArray(
|
privateKey: fromByteArray(
|
||||||
config.security?.adminKey?.at(0) ?? new Uint8Array(0),
|
securityConfig?.privateKey ?? new Uint8Array(0),
|
||||||
),
|
),
|
||||||
privateKeyDialogOpen: false,
|
publicKey: fromByteArray(
|
||||||
});
|
securityConfig?.publicKey ?? new Uint8Array(0),
|
||||||
|
),
|
||||||
const validateKey = (
|
adminKey: [
|
||||||
input: string,
|
fromByteArray(
|
||||||
count: number,
|
securityConfig?.adminKey?.at(0) ?? new Uint8Array(0),
|
||||||
fieldName: "privateKey" | "adminKey",
|
),
|
||||||
) => {
|
fromByteArray(
|
||||||
try {
|
securityConfig?.adminKey?.at(1) ?? new Uint8Array(0),
|
||||||
removeError(fieldName);
|
),
|
||||||
|
fromByteArray(
|
||||||
if (fieldName === "privateKey" && input === "") {
|
securityConfig?.adminKey?.at(2) ?? new Uint8Array(0),
|
||||||
addError(fieldName, "Private Key is required");
|
),
|
||||||
return;
|
],
|
||||||
}
|
},
|
||||||
|
|
||||||
if (fieldName === "adminKey" && input === "") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.length % 4 !== 0) {
|
|
||||||
addError(
|
|
||||||
fieldName,
|
|
||||||
`${
|
|
||||||
fieldName === "privateKey" ? "Private" : "Admin"
|
|
||||||
} Key is required to be a 256 bit pre-shared key (PSK)`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const decoded = toByteArray(input);
|
|
||||||
if (decoded.length !== count) {
|
|
||||||
addError(fieldName, `Please enter a valid ${count * 8} bit PSK`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
addError(
|
|
||||||
fieldName,
|
|
||||||
`Invalid ${
|
|
||||||
fieldName === "privateKey" ? "Private" : "Admin"
|
|
||||||
} Key format`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = (data: SecurityValidation) => {
|
const formMethods = useForm<RawSecurity>({
|
||||||
if (hasErrors()) {
|
mode: "onChange",
|
||||||
|
defaultValues: defaultValues as DefaultValues<RawSecurity>,
|
||||||
|
resolver: createZodResolver(RawSecuritySchema),
|
||||||
|
shouldFocusError: false,
|
||||||
|
resetOptions: { keepDefaultValues: true },
|
||||||
|
});
|
||||||
|
const { setValue, formState } = formMethods;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onFormInit?.(formMethods);
|
||||||
|
}, [onFormInit, formMethods]);
|
||||||
|
|
||||||
|
const [privateKeyDialogOpen, setPrivateKeyDialogOpen] = useState<boolean>(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const [managedModeDialogOpen, setManagedModeDialogOpen] = useState<boolean>(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSubmit = (data: RawSecurity) => {
|
||||||
|
if (!formState.isReady) return;
|
||||||
|
|
||||||
|
const payload: ParsedSecurity = {
|
||||||
|
...data,
|
||||||
|
privateKey: toByteArray(data.privateKey),
|
||||||
|
publicKey: toByteArray(data.publicKey),
|
||||||
|
adminKey: [
|
||||||
|
toByteArray(data.adminKey.at(0) ?? ""),
|
||||||
|
toByteArray(data.adminKey.at(1) ?? ""),
|
||||||
|
toByteArray(data.adminKey.at(2) ?? ""),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (deepCompareConfig(config.security, payload, true)) {
|
||||||
|
removeWorkingConfig("security");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setWorkingConfig(
|
setWorkingConfig(
|
||||||
create(Protobuf.Config.ConfigSchema, {
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
case: "security",
|
case: "security",
|
||||||
value: {
|
value: payload,
|
||||||
...data,
|
|
||||||
adminKey: [new Uint8Array(0)],
|
|
||||||
privateKey: toByteArray(state.privateKey),
|
|
||||||
publicKey: toByteArray(state.publicKey),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pkiRegenerate = () => {
|
const pkiRegenerate = () => {
|
||||||
clearErrors();
|
|
||||||
const privateKey = getX25519PrivateKey();
|
const privateKey = getX25519PrivateKey();
|
||||||
const publicKey = getX25519PublicKey(privateKey);
|
updatePublicKey(fromByteArray(privateKey));
|
||||||
|
};
|
||||||
|
|
||||||
dispatch({
|
const updatePublicKey = (privateKey: string) => {
|
||||||
type: "REGENERATE_PRIV_PUB_KEY",
|
try {
|
||||||
payload: {
|
const publicKey = fromByteArray(
|
||||||
privateKey: fromByteArray(privateKey),
|
getX25519PublicKey(toByteArray(privateKey)),
|
||||||
publicKey: fromByteArray(publicKey),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
validateKey(
|
|
||||||
fromByteArray(privateKey),
|
|
||||||
state.privateKeyBitCount,
|
|
||||||
"privateKey",
|
|
||||||
);
|
);
|
||||||
|
setValue("privateKey", privateKey);
|
||||||
|
setValue("publicKey", publicKey);
|
||||||
|
|
||||||
|
removeError("privateKey");
|
||||||
|
removeError("publicKey");
|
||||||
|
setPrivateKeyDialogOpen(false);
|
||||||
|
} catch (_e) {
|
||||||
|
setValue("privateKey", privateKey);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const privateKeyInputChangeEvent = (
|
const bits = [
|
||||||
e: React.ChangeEvent<HTMLInputElement>,
|
{
|
||||||
) => {
|
text: t("security.256bit"),
|
||||||
const privateKeyB64String = e.target.value;
|
value: "32",
|
||||||
dispatch({ type: "SET_PRIVATE_KEY", payload: privateKeyB64String });
|
key: "bit256",
|
||||||
validateKey(privateKeyB64String, state.privateKeyBitCount, "privateKey");
|
},
|
||||||
|
];
|
||||||
const publicKey = getX25519PublicKey(toByteArray(privateKeyB64String));
|
|
||||||
dispatch({ type: "SET_PUBLIC_KEY", payload: fromByteArray(publicKey) });
|
|
||||||
};
|
|
||||||
|
|
||||||
const adminKeyInputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const psk = e.currentTarget?.value;
|
|
||||||
dispatch({ type: "SET_ADMIN_KEY", payload: psk });
|
|
||||||
validateKey(psk, state.privateKeyBitCount, "adminKey");
|
|
||||||
};
|
|
||||||
|
|
||||||
const privateKeySelectChangeEvent = (e: string) => {
|
|
||||||
const count = Number.parseInt(e);
|
|
||||||
dispatch({ type: "SET_PRIVATE_KEY_BIT_COUNT", payload: count });
|
|
||||||
validateKey(state.privateKey, count, "privateKey");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DynamicForm<SecurityValidation>
|
<DynamicForm<RawSecurity>
|
||||||
|
propMethods={formMethods}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
submitType="onChange"
|
formId="Config_SecurityConfig"
|
||||||
defaultValues={{
|
|
||||||
...config.security,
|
|
||||||
...{
|
|
||||||
adminKey: state.adminKey,
|
|
||||||
privateKey: state.privateKey,
|
|
||||||
publicKey: state.publicKey,
|
|
||||||
adminChannelEnabled: config.security?.adminChannelEnabled ?? false,
|
|
||||||
isManaged: config.security?.isManaged ?? false,
|
|
||||||
debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false,
|
|
||||||
serialEnabled: config.security?.serialEnabled ?? false,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "Security Settings",
|
label: t("security.title"),
|
||||||
description: "Settings for the Security configuration",
|
description: t("security.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "passwordGenerator",
|
type: "passwordGenerator",
|
||||||
id: "pskInput",
|
id: "pskInput",
|
||||||
name: "privateKey",
|
name: "privateKey",
|
||||||
label: "Private Key",
|
label: t("security.privateKey.label"),
|
||||||
description: "Used to create a shared key with a remote device",
|
description: t("security.privateKey.description"),
|
||||||
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
|
bits,
|
||||||
validationText: hasFieldError("privateKey")
|
devicePSKBitCount: 32,
|
||||||
? getErrorMessage("privateKey")
|
hide: true,
|
||||||
: "",
|
inputChange: (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
devicePSKBitCount: state.privateKeyBitCount,
|
updatePublicKey(e.target.value);
|
||||||
inputChange: privateKeyInputChangeEvent,
|
},
|
||||||
selectChange: privateKeySelectChangeEvent,
|
|
||||||
hide: !state.privateKeyVisible,
|
|
||||||
actionButtons: [
|
actionButtons: [
|
||||||
{
|
{
|
||||||
text: "Generate",
|
text: t("button.generate"),
|
||||||
onClick: () =>
|
onClick: () => setPrivateKeyDialogOpen(true),
|
||||||
dispatch({
|
|
||||||
type: "SHOW_PRIVATE_KEY_DIALOG",
|
|
||||||
payload: true,
|
|
||||||
}),
|
|
||||||
variant: "success",
|
variant: "success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Backup Key",
|
text: t("button.backupKey"),
|
||||||
onClick: () => setDialogOpen("pkiBackup", true),
|
onClick: () => setDialogOpen("pkiBackup", true),
|
||||||
variant: "subtle",
|
variant: "subtle",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
properties: {
|
properties: {
|
||||||
value: state.privateKey,
|
|
||||||
showCopyButton: true,
|
showCopyButton: true,
|
||||||
showPasswordToggle: true,
|
showPasswordToggle: true,
|
||||||
},
|
},
|
||||||
@@ -200,94 +181,109 @@ export const Security = () => {
|
|||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
name: "publicKey",
|
name: "publicKey",
|
||||||
label: "Public Key",
|
label: t("security.publicKey.label"),
|
||||||
disabled: true,
|
disabled: true,
|
||||||
description:
|
description: t("security.publicKey.description"),
|
||||||
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
|
|
||||||
properties: {
|
properties: {
|
||||||
value: state.publicKey,
|
|
||||||
showCopyButton: true,
|
showCopyButton: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Admin Settings",
|
label: t("security.adminSettings.label"),
|
||||||
description: "Settings for Admin",
|
description: t("security.adminSettings.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
|
||||||
type: "toggle",
|
|
||||||
name: "adminChannelEnabled",
|
|
||||||
label: "Allow Legacy Admin",
|
|
||||||
description:
|
|
||||||
"Allow incoming device control over the insecure legacy admin channel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "toggle",
|
|
||||||
name: "isManaged",
|
|
||||||
label: "Managed",
|
|
||||||
description:
|
|
||||||
"If true, device configuration options are only able to be changed remotely by a Remote Admin node via admin messages. Do not enable this option unless a suitable Remote Admin node has been setup, and the public key stored in the field below.",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: "passwordGenerator",
|
type: "passwordGenerator",
|
||||||
name: "adminKey",
|
name: "adminKey.0",
|
||||||
id: "adminKeyInput",
|
id: "adminKey0Input",
|
||||||
label: "Admin Key",
|
label: t("security.primaryAdminKey.label"),
|
||||||
description:
|
description: t("security.primaryAdminKey.description"),
|
||||||
"The public key authorized to send admin messages to this node",
|
bits,
|
||||||
validationText: hasFieldError("adminKey")
|
devicePSKBitCount: 32,
|
||||||
? getErrorMessage("adminKey")
|
hide: true,
|
||||||
: "",
|
actionButtons: [],
|
||||||
inputChange: adminKeyInputChangeEvent,
|
|
||||||
selectChange: () => {},
|
|
||||||
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
|
|
||||||
devicePSKBitCount: state.privateKeyBitCount,
|
|
||||||
hide: !state.adminKeyVisible,
|
|
||||||
actionButtons: [
|
|
||||||
{
|
|
||||||
text: "Generate",
|
|
||||||
variant: "success",
|
|
||||||
onClick: () => {
|
|
||||||
const adminKey = getX25519PrivateKey();
|
|
||||||
dispatch({
|
|
||||||
type: "REGENERATE_ADMIN_KEY",
|
|
||||||
payload: { adminKey: fromByteArray(adminKey) },
|
|
||||||
});
|
|
||||||
validateKey(
|
|
||||||
fromByteArray(adminKey),
|
|
||||||
state.adminKeyBitCount,
|
|
||||||
"adminKey",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{ fieldName: "adminChannelEnabled", invert: true },
|
{ fieldName: "adminChannelEnabled", invert: true },
|
||||||
],
|
],
|
||||||
properties: {
|
properties: {
|
||||||
value: state.adminKey,
|
|
||||||
showCopyButton: true,
|
showCopyButton: true,
|
||||||
|
showPasswordToggle: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "passwordGenerator",
|
||||||
|
name: "adminKey.1",
|
||||||
|
id: "adminKey1Input",
|
||||||
|
label: t("security.secondaryAdminKey.label"),
|
||||||
|
description: t("security.secondaryAdminKey.description"),
|
||||||
|
bits,
|
||||||
|
devicePSKBitCount: 32,
|
||||||
|
hide: true,
|
||||||
|
actionButtons: [],
|
||||||
|
disabledBy: [
|
||||||
|
{ fieldName: "adminChannelEnabled", invert: true },
|
||||||
|
],
|
||||||
|
properties: {
|
||||||
|
showCopyButton: true,
|
||||||
|
showPasswordToggle: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "passwordGenerator",
|
||||||
|
name: "adminKey.2",
|
||||||
|
id: "adminKey2Input",
|
||||||
|
label: t("security.tertiaryAdminKey.label"),
|
||||||
|
description: t("security.tertiaryAdminKey.description"),
|
||||||
|
bits,
|
||||||
|
devicePSKBitCount: 32,
|
||||||
|
hide: true,
|
||||||
|
actionButtons: [],
|
||||||
|
disabledBy: [
|
||||||
|
{ fieldName: "adminChannelEnabled", invert: true },
|
||||||
|
],
|
||||||
|
properties: {
|
||||||
|
showCopyButton: true,
|
||||||
|
showPasswordToggle: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "toggle",
|
||||||
|
name: "isManaged",
|
||||||
|
label: t("security.managed.label"),
|
||||||
|
description: t("security.managed.description"),
|
||||||
|
inputChange: (checked) => {
|
||||||
|
if (checked) {
|
||||||
|
setManagedModeDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue("isManaged", false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "toggle",
|
||||||
|
name: "adminChannelEnabled",
|
||||||
|
label: t("security.adminChannelEnabled.label"),
|
||||||
|
description: t("security.adminChannelEnabled.description"),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Logging Settings",
|
label: t("security.loggingSettings.label"),
|
||||||
description: "Settings for Logging",
|
description: t("security.loggingSettings.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "debugLogApiEnabled",
|
name: "debugLogApiEnabled",
|
||||||
label: "Enable Debug Log API",
|
label: t("security.enableDebugLogApi.label"),
|
||||||
description:
|
description: t("security.enableDebugLogApi.description"),
|
||||||
"Output live debug logging over serial, view and export position-redacted device logs over Bluetooth",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "serialEnabled",
|
name: "serialEnabled",
|
||||||
label: "Serial Output Enabled",
|
label: t("security.serialOutputEnabled.label"),
|
||||||
description: "Serial Console over the Stream API",
|
description: t("security.serialOutputEnabled.description"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -295,15 +291,23 @@ export const Security = () => {
|
|||||||
/>
|
/>
|
||||||
<PkiRegenerateDialog
|
<PkiRegenerateDialog
|
||||||
text={{
|
text={{
|
||||||
button: "Regenerate",
|
button: t("button.regenerate"),
|
||||||
title: "Regenerate Key pair?",
|
title: t("pkiRegenerate.title"),
|
||||||
description: "Are you sure you want to regenerate key pair?",
|
description: t("pkiRegenerate.description"),
|
||||||
}}
|
}}
|
||||||
open={state.privateKeyDialogOpen}
|
open={privateKeyDialogOpen}
|
||||||
onOpenChange={() =>
|
onOpenChange={() => setPrivateKeyDialogOpen((prev) => !prev)}
|
||||||
dispatch({ type: "SHOW_PRIVATE_KEY_DIALOG", payload: false })}
|
|
||||||
onSubmit={pkiRegenerate}
|
onSubmit={pkiRegenerate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ManagedModeDialog
|
||||||
|
open={managedModeDialogOpen}
|
||||||
|
onOpenChange={() => setManagedModeDialogOpen((prev) => !prev)}
|
||||||
|
onSubmit={() => {
|
||||||
|
setValue("isManaged", true);
|
||||||
|
setManagedModeDialogOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import type { SecurityAction, SecurityState } from "./types.ts";
|
|
||||||
|
|
||||||
export function securityReducer(
|
|
||||||
state: SecurityState,
|
|
||||||
action: SecurityAction,
|
|
||||||
): SecurityState {
|
|
||||||
switch (action.type) {
|
|
||||||
case "SET_PRIVATE_KEY":
|
|
||||||
return { ...state, privateKey: action.payload };
|
|
||||||
case "TOGGLE_PRIVATE_KEY_VISIBILITY":
|
|
||||||
return { ...state, privateKeyVisible: !state.privateKeyVisible };
|
|
||||||
case "TOGGLE_ADMIN_KEY_VISIBILITY":
|
|
||||||
return { ...state, adminKeyVisible: !state.adminKeyVisible };
|
|
||||||
case "SET_PRIVATE_KEY_BIT_COUNT":
|
|
||||||
return { ...state, privateKeyBitCount: action.payload };
|
|
||||||
case "SET_PUBLIC_KEY":
|
|
||||||
return { ...state, publicKey: action.payload };
|
|
||||||
case "SET_ADMIN_KEY":
|
|
||||||
return { ...state, adminKey: action.payload };
|
|
||||||
case "SHOW_PRIVATE_KEY_DIALOG":
|
|
||||||
return { ...state, privateKeyDialogOpen: action.payload };
|
|
||||||
case "REGENERATE_PRIV_PUB_KEY":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
privateKey: action.payload.privateKey,
|
|
||||||
publicKey: action.payload.publicKey,
|
|
||||||
privateKeyDialogOpen: false,
|
|
||||||
};
|
|
||||||
case "REGENERATE_ADMIN_KEY":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
adminKey: action.payload.adminKey,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
export interface SecurityState {
|
|
||||||
privateKey: string;
|
|
||||||
privateKeyVisible: boolean;
|
|
||||||
adminKeyVisible: boolean;
|
|
||||||
privateKeyBitCount: number;
|
|
||||||
adminKeyBitCount: number;
|
|
||||||
publicKey: string;
|
|
||||||
adminKey: string;
|
|
||||||
privateKeyDialogOpen: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SecurityAction =
|
|
||||||
| { type: "SET_PRIVATE_KEY"; payload: string }
|
|
||||||
| { type: "TOGGLE_PRIVATE_KEY_VISIBILITY" }
|
|
||||||
| { type: "TOGGLE_ADMIN_KEY_VISIBILITY" }
|
|
||||||
| { type: "SET_PRIVATE_KEY_BIT_COUNT"; payload: number }
|
|
||||||
| { type: "SET_PUBLIC_KEY"; payload: string }
|
|
||||||
| { type: "SET_ADMIN_KEY"; payload: string }
|
|
||||||
| { type: "SHOW_PRIVATE_KEY_DIALOG"; payload: boolean }
|
|
||||||
| {
|
|
||||||
type: "REGENERATE_PRIV_PUB_KEY";
|
|
||||||
payload: { privateKey: string; publicKey: string };
|
|
||||||
}
|
|
||||||
| { type: "REGENERATE_ADMIN_KEY"; payload: { adminKey: string } };
|
|
||||||
@@ -5,9 +5,11 @@ import { useAppStore } from "@core/stores/appStore.ts";
|
|||||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||||
import { subscribeAll } from "@core/subscriptions.ts";
|
import { subscribeAll } from "@core/subscriptions.ts";
|
||||||
import { randId } from "@core/utils/randId.ts";
|
import { randId } from "@core/utils/randId.ts";
|
||||||
import { BleConnection, ServiceUuid } from "@meshtastic/js";
|
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
|
||||||
|
import { MeshDevice } from "@meshtastic/core";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export const BLE = (
|
export const BLE = (
|
||||||
{ closeDialog }: TabElementProps,
|
{ closeDialog }: TabElementProps,
|
||||||
@@ -17,6 +19,7 @@ export const BLE = (
|
|||||||
const { addDevice } = useDeviceStore();
|
const { addDevice } = useDeviceStore();
|
||||||
const messageStore = useMessageStore();
|
const messageStore = useMessageStore();
|
||||||
const { setSelectedDevice } = useAppStore();
|
const { setSelectedDevice } = useAppStore();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const updateBleDeviceList = useCallback(async (): Promise<void> => {
|
const updateBleDeviceList = useCallback(async (): Promise<void> => {
|
||||||
setBleDevices(await navigator.bluetooth.getDevices());
|
setBleDevices(await navigator.bluetooth.getDevices());
|
||||||
@@ -28,15 +31,13 @@ export const BLE = (
|
|||||||
|
|
||||||
const onConnect = async (bleDevice: BluetoothDevice) => {
|
const onConnect = async (bleDevice: BluetoothDevice) => {
|
||||||
const id = randId();
|
const id = randId();
|
||||||
|
const transport = await TransportWebBluetooth.createFromDevice(bleDevice);
|
||||||
const device = addDevice(id);
|
const device = addDevice(id);
|
||||||
|
const connection = new MeshDevice(transport, id);
|
||||||
|
connection.configure();
|
||||||
setSelectedDevice(id);
|
setSelectedDevice(id);
|
||||||
const connection = new BleConnection(id);
|
|
||||||
await connection.connect({
|
|
||||||
device: bleDevice,
|
|
||||||
});
|
|
||||||
device.addConnection(connection);
|
device.addConnection(connection);
|
||||||
subscribeAll(device, connection, messageStore);
|
subscribeAll(device, connection, messageStore);
|
||||||
|
|
||||||
closeDialog();
|
closeDialog();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,7 +60,9 @@ export const BLE = (
|
|||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
{bleDevices.length === 0 && (
|
{bleDevices.length === 0 && (
|
||||||
<Mono className="m-auto select-none">No devices paired yet.</Mono>
|
<Mono className="m-auto select-none">
|
||||||
|
{t("newDeviceDialog.bluetoothConnection.noDevicesPaired")}
|
||||||
|
</Mono>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -67,14 +70,14 @@ export const BLE = (
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await navigator.bluetooth
|
await navigator.bluetooth
|
||||||
.requestDevice({
|
.requestDevice({
|
||||||
filters: [{ services: [ServiceUuid] }],
|
filters: [{ services: [TransportWebBluetooth.ServiceUuid] }],
|
||||||
})
|
})
|
||||||
.then((device) => {
|
.then((device) => {
|
||||||
const exists = bleDevices.findIndex((d) => d.id === device.id);
|
const exists = bleDevices.findIndex((d) => d.id === device.id);
|
||||||
if (exists === -1) {
|
if (exists === -1) {
|
||||||
setBleDevices(bleDevices.concat(device));
|
setBleDevices(bleDevices.concat(device));
|
||||||
}
|
}
|
||||||
}).catch((error) => {
|
}).catch((error: Error) => {
|
||||||
console.error("Error requesting device:", error);
|
console.error("Error requesting device:", error);
|
||||||
setConnectionInProgress(false);
|
setConnectionInProgress(false);
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
@@ -82,7 +85,7 @@ export const BLE = (
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>New device</span>
|
<span>{t("newDeviceDialog.bluetoothConnection.newDeviceButton")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import { TransportHTTP } from "@meshtastic/transport-http";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useController, useForm } from "react-hook-form";
|
import { useController, useForm } from "react-hook-form";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface FormData {
|
interface FormData {
|
||||||
ip: string;
|
ip: string;
|
||||||
@@ -23,6 +24,7 @@ interface FormData {
|
|||||||
export const HTTP = (
|
export const HTTP = (
|
||||||
{ closeDialog }: TabElementProps,
|
{ closeDialog }: TabElementProps,
|
||||||
) => {
|
) => {
|
||||||
|
const { t } = useTranslation("dialog");
|
||||||
const [connectionInProgress, setConnectionInProgress] = useState(false);
|
const [connectionInProgress, setConnectionInProgress] = useState(false);
|
||||||
const isURLHTTPS = location.protocol === "https:";
|
const isURLHTTPS = location.protocol === "https:";
|
||||||
|
|
||||||
@@ -64,7 +66,9 @@ export const HTTP = (
|
|||||||
subscribeAll(device, connection, messageStore);
|
subscribeAll(device, connection, messageStore);
|
||||||
closeDialog();
|
closeDialog();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
console.error("Connection error:", error);
|
console.error("Connection error:", error);
|
||||||
|
}
|
||||||
// Capture all connection errors regardless of type
|
// Capture all connection errors regardless of type
|
||||||
setConnectionError({ host: data.ip, secure: data.tls });
|
setConnectionError({ host: data.ip, secure: data.tls });
|
||||||
setConnectionInProgress(false);
|
setConnectionInProgress(false);
|
||||||
@@ -79,22 +83,24 @@ export const HTTP = (
|
|||||||
disabled={connectionInProgress}
|
disabled={connectionInProgress}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Label>IP Address/Hostname</Label>
|
<Label>{t("newDeviceDialog.httpConnection.label")}</Label>
|
||||||
<Input
|
<Input
|
||||||
prefix={tlsValue ? "https://" : "http://"}
|
prefix={tlsValue
|
||||||
placeholder="000.000.000.000 / meshtastic.local"
|
? `${t("newDeviceDialog.https")}://`
|
||||||
|
: `${t("newDeviceDialog.http")}://`}
|
||||||
|
placeholder={t("newDeviceDialog.httpConnection.placeholder")}
|
||||||
className="text-slate-900 dark:text-slate-100"
|
className="text-slate-900 dark:text-slate-100"
|
||||||
{...register("ip")}
|
{...register("ip")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
onCheckedChange={setTLS}
|
onCheckedChange={setTLS}
|
||||||
disabled={isURLHTTPS}
|
disabled={isURLHTTPS}
|
||||||
checked={isURLHTTPS || tlsValue}
|
checked={isURLHTTPS || tlsValue}
|
||||||
{...register("tls")}
|
{...register("tls")}
|
||||||
/>
|
/>
|
||||||
<Label>Use HTTPS</Label>
|
<Label>{t("newDeviceDialog.useHttps")}</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{connectionError && (
|
{connectionError && (
|
||||||
@@ -106,30 +112,38 @@ export const HTTP = (
|
|||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-800">
|
<p className="text-sm font-medium text-amber-800 dark:text-amber-800">
|
||||||
Connection Failed
|
{t("newDeviceDialog.connectionFailedAlert.title")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs mt-1 text-amber-700 dark:text-amber-700">
|
<p className="text-xs mt-1 text-amber-700 dark:text-amber-700">
|
||||||
Could not connect to the device. {connectionError.secure &&
|
{t("newDeviceDialog.connectionFailedAlert.descriptionPrefix")}
|
||||||
"If using HTTPS, you may need to accept a self-signed certificate first. "}
|
{connectionError.secure &&
|
||||||
Please open{" "}
|
t("newDeviceDialog.connectionFailedAlert.httpsHint")}
|
||||||
|
{t("newDeviceDialog.connectionFailedAlert.openLinkPrefix")}
|
||||||
<Link
|
<Link
|
||||||
href={`${
|
href={`${
|
||||||
connectionError.secure ? "https" : "http"
|
connectionError.secure
|
||||||
|
? t("newDeviceDialog.https")
|
||||||
|
: t("newDeviceDialog.http")
|
||||||
}://${connectionError.host}`}
|
}://${connectionError.host}`}
|
||||||
className="underline font-medium text-amber-800 dark:text-amber-800"
|
className="underline font-medium text-amber-800 dark:text-amber-800"
|
||||||
>
|
>
|
||||||
{`${
|
{`${
|
||||||
connectionError.secure ? "https" : "http"
|
connectionError.secure
|
||||||
|
? t("newDeviceDialog.https")
|
||||||
|
: t("newDeviceDialog.http")
|
||||||
}://${connectionError.host}`}
|
}://${connectionError.host}`}
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
in a new tab{connectionError.secure
|
{t("newDeviceDialog.connectionFailedAlert.openLinkSuffix")}
|
||||||
? ", accept any TLS warnings if prompted, then try again"
|
{connectionError.secure
|
||||||
|
? t(
|
||||||
|
"newDeviceDialog.connectionFailedAlert.acceptTlsWarningSuffix",
|
||||||
|
)
|
||||||
: ""}.{" "}
|
: ""}.{" "}
|
||||||
<Link
|
<Link
|
||||||
href="https://meshtastic.org/docs/software/web-client/#http"
|
href="https://meshtastic.org/docs/software/web-client/#http"
|
||||||
className="underline font-medium text-amber-800 dark:text-amber-800"
|
className="underline font-medium text-amber-800 dark:text-amber-800"
|
||||||
>
|
>
|
||||||
Learn more
|
{t("newDeviceDialog.connectionFailedAlert.learnMoreLink")}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,7 +155,11 @@ export const HTTP = (
|
|||||||
type="submit"
|
type="submit"
|
||||||
variant="default"
|
variant="default"
|
||||||
>
|
>
|
||||||
<span>{connectionInProgress ? "Connecting..." : "Connect"}</span>
|
<span>
|
||||||
|
{connectionInProgress
|
||||||
|
? t("newDeviceDialog.connecting")
|
||||||
|
: t("newDeviceDialog.connect")}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { randId } from "@core/utils/randId.ts";
|
|||||||
import { MeshDevice } from "@meshtastic/core";
|
import { MeshDevice } from "@meshtastic/core";
|
||||||
import { TransportWebSerial } from "@meshtastic/transport-web-serial";
|
import { TransportWebSerial } from "@meshtastic/transport-web-serial";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { SerialPort } from "w3c-web-serial";
|
||||||
|
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||||
|
|
||||||
export const Serial = (
|
export const Serial = (
|
||||||
{ closeDialog }: TabElementProps,
|
{ closeDialog }: TabElementProps,
|
||||||
@@ -18,15 +20,16 @@ export const Serial = (
|
|||||||
const { addDevice } = useDeviceStore();
|
const { addDevice } = useDeviceStore();
|
||||||
const messageStore = useMessageStore();
|
const messageStore = useMessageStore();
|
||||||
const { setSelectedDevice } = useAppStore();
|
const { setSelectedDevice } = useAppStore();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const updateSerialPortList = useCallback(async () => {
|
const updateSerialPortList = useCallback(async () => {
|
||||||
setSerialPorts(await navigator?.serial.getPorts());
|
setSerialPorts(await navigator.serial.getPorts());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
navigator?.serial?.addEventListener("connect", () => {
|
navigator.serial.addEventListener("connect", () => {
|
||||||
updateSerialPortList();
|
updateSerialPortList();
|
||||||
});
|
});
|
||||||
navigator?.serial?.addEventListener("disconnect", () => {
|
navigator.serial.addEventListener("disconnect", () => {
|
||||||
updateSerialPortList();
|
updateSerialPortList();
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -54,24 +57,31 @@ export const Serial = (
|
|||||||
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
|
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
|
||||||
{serialPorts.map((port, index) => {
|
{serialPorts.map((port, index) => {
|
||||||
const { usbProductId, usbVendorId } = port.getInfo();
|
const { usbProductId, usbVendorId } = port.getInfo();
|
||||||
|
const vendor = usbVendorId ?? t("unknown.shortName");
|
||||||
|
const product = usbProductId ?? t("unknown.shortName");
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={`${usbVendorId ?? "UNK"}-${usbProductId ?? "UNK"}-${index}`}
|
key={`${vendor}-${product}-${index}`}
|
||||||
disabled={port.readable !== null}
|
disabled={port.readable !== null}
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setConnectionInProgress(true);
|
setConnectionInProgress(true);
|
||||||
await onConnect(port);
|
await onConnect(port);
|
||||||
|
// No need to setConnectionInProgress(false) here as closeDialog() unmounts.
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{`# ${index} - ${usbVendorId ?? "UNK"} - ${
|
{t("newDeviceDialog.serialConnection.deviceIdentifier", {
|
||||||
usbProductId ?? "UNK"
|
index: index,
|
||||||
}`}
|
vendorId: vendor,
|
||||||
|
productId: product,
|
||||||
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{serialPorts.length === 0 && (
|
{serialPorts.length === 0 && (
|
||||||
<Mono className="m-auto select-none">No devices paired yet.</Mono>
|
<Mono className="m-auto select-none">
|
||||||
|
{t("newDeviceDialog.serialConnection.noDevicesPaired")}
|
||||||
|
</Mono>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -79,15 +89,15 @@ export const Serial = (
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await navigator.serial.requestPort().then((port) => {
|
await navigator.serial.requestPort().then((port) => {
|
||||||
setSerialPorts(serialPorts.concat(port));
|
setSerialPorts(serialPorts.concat(port));
|
||||||
}).catch((error) => {
|
// No need to setConnectionInProgress(false) here if requestPort is quick
|
||||||
|
}).catch((error: Error) => {
|
||||||
console.error("Error requesting port:", error);
|
console.error("Error requesting port:", error);
|
||||||
setConnectionInProgress(false);
|
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
setConnectionInProgress(false);
|
setConnectionInProgress(false);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>New device</span>
|
<span>{t("newDeviceDialog.serialConnection.newDeviceButton")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,30 +22,30 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@radix-ui/react-tooltip";
|
} from "@radix-ui/react-tooltip";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
|
||||||
import {
|
|
||||||
MessageType,
|
|
||||||
useMessageStore,
|
|
||||||
} from "../../../core/stores/messageStore/index.ts";
|
|
||||||
import BatteryStatus from "@components/BatteryStatus.tsx";
|
import BatteryStatus from "@components/BatteryStatus.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
||||||
export interface NodeDetailProps {
|
export interface NodeDetailProps {
|
||||||
node: ProtobufType.Mesh.NodeInfo;
|
node: ProtobufType.Mesh.NodeInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NodeDetail = ({ node }: NodeDetailProps) => {
|
export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||||
const { setChatType, setActiveChat } = useMessageStore();
|
const navigate = useNavigate();
|
||||||
const { setActivePage } = useDevice();
|
const { t } = useTranslation("nodes");
|
||||||
const name = node.user?.longName ?? `UNK`;
|
const name = node.user?.longName ?? t("unknown.shortName");
|
||||||
const shortName = node.user?.shortName ?? "UNK";
|
const shortName = node.user?.shortName ?? t("unknown.shortName");
|
||||||
const hwModel = node.user?.hwModel ?? 0;
|
const hwModel = node.user?.hwModel ?? 0;
|
||||||
const hardwareType =
|
const rawHardwareType = Protobuf.Mesh.HardwareModel[hwModel] as
|
||||||
Protobuf.Mesh.HardwareModel[hwModel]?.replaceAll("_", " ") ?? `${hwModel}`;
|
| keyof typeof Protobuf.Mesh.HardwareModel
|
||||||
|
| undefined;
|
||||||
|
const hardwareType = rawHardwareType
|
||||||
|
? rawHardwareType === "UNSET"
|
||||||
|
? t("unset")
|
||||||
|
: rawHardwareType.replaceAll("_", " ")
|
||||||
|
: `${hwModel}`;
|
||||||
function handleDirectMessage() {
|
function handleDirectMessage() {
|
||||||
setChatType(MessageType.Direct);
|
navigate({ to: `/messages/direct/${node.num}` });
|
||||||
setActiveChat(node.num);
|
|
||||||
setActivePage("messages");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -66,7 +66,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
|||||||
className="text-green-600 mb-1.5"
|
className="text-green-600 mb-1.5"
|
||||||
size={12}
|
size={12}
|
||||||
strokeWidth={3}
|
strokeWidth={3}
|
||||||
aria-label="Public Key Enabled"
|
aria-label={t("node_detail_public_key_enabled_aria_label")}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
@@ -74,7 +74,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
|||||||
className="text-yellow-500 mb-1.5"
|
className="text-yellow-500 mb-1.5"
|
||||||
size={12}
|
size={12}
|
||||||
strokeWidth={3}
|
strokeWidth={3}
|
||||||
aria-label="No Public Key"
|
aria-label={t("node_detail_no_public_key_aria_label")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -94,7 +94,9 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
|||||||
align="center"
|
align="center"
|
||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
>
|
>
|
||||||
Direct Message {shortName}
|
{t("nodeDetail.directMessage.label", {
|
||||||
|
shortName,
|
||||||
|
})}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPortal>
|
</TooltipPortal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -103,15 +105,16 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
|||||||
<Star
|
<Star
|
||||||
fill={node.isFavorite ? "black" : "none"}
|
fill={node.isFavorite ? "black" : "none"}
|
||||||
size={15}
|
size={15}
|
||||||
aria-label={node.isFavorite ? "Favorite" : "Not a Favorite"}
|
aria-label={node.isFavorite
|
||||||
|
? t("nodeDetail.favorite.label")
|
||||||
|
: t("nodeDetail.notFavorite.label")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Heading as="h5">{name}</Heading>
|
<Heading as="h5">{name}</Heading>
|
||||||
|
{hardwareType !== t("unset") && <Subtle>{hardwareType}</Subtle>}
|
||||||
{hardwareType !== "UNSET" && <Subtle>{hardwareType}</Subtle>}
|
|
||||||
|
|
||||||
{!!node.deviceMetrics?.batteryLevel && (
|
{!!node.deviceMetrics?.batteryLevel && (
|
||||||
<BatteryStatus deviceMetrics={node.deviceMetrics} />
|
<BatteryStatus deviceMetrics={node.deviceMetrics} />
|
||||||
@@ -131,13 +134,14 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
|||||||
<div>
|
<div>
|
||||||
{node.lastHeard > 0 && (
|
{node.lastHeard > 0 && (
|
||||||
<div>
|
<div>
|
||||||
Heard <TimeAgo timestamp={node.lastHeard * 1000} />
|
{t("nodeDetail.status.heard")}{" "}
|
||||||
|
<TimeAgo timestamp={node.lastHeard * 1000} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{node.viaMqtt && (
|
{node.viaMqtt && (
|
||||||
<div style={{ color: "#660066" }} className="font-medium">
|
<div style={{ color: "#660066" }} className="font-medium">
|
||||||
MQTT
|
{t("nodeDetail.status.mqtt")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -149,21 +153,25 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
|||||||
<div className="flex mt-2 text-sm">
|
<div className="flex mt-2 text-sm">
|
||||||
<div className="flex items-center grow">
|
<div className="flex items-center grow">
|
||||||
<div className="border-2 border-slate-900 rounded-sm px-0.5 mr-1">
|
<div className="border-2 border-slate-900 rounded-sm px-0.5 mr-1">
|
||||||
{Number.isNaN(node.hopsAway) ? "?" : node.hopsAway}
|
{Number.isNaN(node.hopsAway)
|
||||||
|
? t("unit.hopsAway.unknown")
|
||||||
|
: node.hopsAway}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{node.hopsAway === 1 ? t("unit.hops.one") : t("unit.hop.plural")}
|
||||||
</div>
|
</div>
|
||||||
<div>{node.hopsAway === 1 ? "Hop" : "Hops"}</div>
|
|
||||||
</div>
|
</div>
|
||||||
{node.position?.altitude && (
|
{node.position?.altitude && (
|
||||||
<div className="flex items-center grow">
|
<div className="flex items-center grow">
|
||||||
<MountainSnow
|
<MountainSnow
|
||||||
size={15}
|
size={15}
|
||||||
className="ml-2 mr-1"
|
className="ml-2 mr-1"
|
||||||
aria-label="Elevation"
|
aria-label={t("node_detail_elevation_aria_label")}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
{formatQuantity(node.position?.altitude, {
|
{formatQuantity(node.position?.altitude, {
|
||||||
one: "meter",
|
one: t("unit.meter.one"),
|
||||||
other: "meters",
|
other: t("unit.meter.plural"),
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,7 +181,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
|||||||
<div className="flex mt-2">
|
<div className="flex mt-2">
|
||||||
{!!node.deviceMetrics?.channelUtilization && (
|
{!!node.deviceMetrics?.channelUtilization && (
|
||||||
<div className="grow">
|
<div className="grow">
|
||||||
<div>Channel Util</div>
|
<div>{t("nodeDetail.channelUtilization")}</div>
|
||||||
<Mono>
|
<Mono>
|
||||||
{node.deviceMetrics?.channelUtilization.toPrecision(3)}%
|
{node.deviceMetrics?.channelUtilization.toPrecision(3)}%
|
||||||
</Mono>
|
</Mono>
|
||||||
@@ -181,7 +189,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
|||||||
)}
|
)}
|
||||||
{!!node.deviceMetrics?.airUtilTx && (
|
{!!node.deviceMetrics?.airUtilTx && (
|
||||||
<div className="grow">
|
<div className="grow">
|
||||||
<div>Airtime Util</div>
|
<div>{t("nodeDetail.airTxUtilization")}</div>
|
||||||
<Mono className="text-gray-500">
|
<Mono className="text-gray-500">
|
||||||
{node.deviceMetrics?.airUtilTx.toPrecision(3)}%
|
{node.deviceMetrics?.airUtilTx.toPrecision(3)}%
|
||||||
</Mono>
|
</Mono>
|
||||||
@@ -191,13 +199,15 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
|||||||
|
|
||||||
{node.snr !== 0 && (
|
{node.snr !== 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div>SNR</div>
|
<div>{t("unit.snr")}</div>
|
||||||
<Mono className="flex items-center text-xs text-gray-500">
|
<Mono className="flex items-center text-xs text-gray-500">
|
||||||
{node.snr}db
|
{node.snr}
|
||||||
|
{t("unit.dbm")}
|
||||||
<Dot />
|
<Dot />
|
||||||
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%
|
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%
|
||||||
<Dot />
|
<Dot />
|
||||||
{(node.snr + 10) * 5}raw
|
{(node.snr + 10) * 5}
|
||||||
|
{t("unit.raw")}
|
||||||
</Mono>
|
</Mono>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
|
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
|
||||||
import { InboxIcon } from "lucide-react";
|
import { InboxIcon } from "lucide-react";
|
||||||
import { Message } from "@core/stores/messageStore/types.ts";
|
import { Message } from "@core/stores/messageStore/types.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface ChannelChatProps {
|
export interface ChannelChatProps {
|
||||||
messages?: Message[];
|
messages?: Message[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const EmptyState = () => (
|
const EmptyState = () => {
|
||||||
|
const { t } = useTranslation("messages");
|
||||||
|
return (
|
||||||
<div className="flex flex-1 flex-col place-content-center place-items-center p-8 text-slate-500 dark:text-slate-400">
|
<div className="flex flex-1 flex-col place-content-center place-items-center p-8 text-slate-500 dark:text-slate-400">
|
||||||
<InboxIcon className="mb-2 h-8 w-8" />
|
<InboxIcon className="mb-2 h-8 w-8" />
|
||||||
<span className="text-sm">No Messages</span>
|
<span className="text-sm">{t("emptyState.text")}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
|
export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
|
||||||
if (!messages?.length) {
|
if (!messages?.length) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from "@components/UI/Tooltip.tsx";
|
} from "@components/UI/Tooltip.tsx";
|
||||||
import { cn } from "@core/utils/cn.ts";
|
import { cn } from "@core/utils/cn.ts";
|
||||||
import { Reply, SmilePlus } from "lucide-react";
|
import { Reply, SmilePlus } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface MessageActionsMenuProps {
|
interface MessageActionsMenuProps {
|
||||||
onAddReaction?: () => void;
|
onAddReaction?: () => void;
|
||||||
@@ -17,6 +18,7 @@ export const MessageActionsMenu = ({
|
|||||||
onAddReaction,
|
onAddReaction,
|
||||||
onReply,
|
onReply,
|
||||||
}: MessageActionsMenuProps) => {
|
}: MessageActionsMenuProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const hoverIconBarClass = cn(
|
const hoverIconBarClass = cn(
|
||||||
"absolute top-2 right-2",
|
"absolute top-2 right-2",
|
||||||
"flex items-center gap-x-1",
|
"flex items-center gap-x-1",
|
||||||
@@ -48,7 +50,7 @@ export const MessageActionsMenu = ({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Add Reaction"
|
aria-label={t("messages_actionsMenu_addReactionLabel")}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (onAddReaction) {
|
if (onAddReaction) {
|
||||||
@@ -61,7 +63,7 @@ export const MessageActionsMenu = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs">
|
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs">
|
||||||
Add Reaction
|
{t("messages_actionsMenu_addReactionLabel")}
|
||||||
<TooltipArrow className="fill-gray-800" />
|
<TooltipArrow className="fill-gray-800" />
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -70,7 +72,7 @@ export const MessageActionsMenu = ({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Reply"
|
aria-label={t("messages_actionsMenu_replyLabel")}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (onReply) {
|
if (onReply) {
|
||||||
@@ -83,7 +85,7 @@ export const MessageActionsMenu = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs">
|
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs">
|
||||||
Reply
|
{t("messages_actionsMenu_replyLabel")}
|
||||||
<TooltipArrow className="fill-gray-800" />
|
<TooltipArrow className="fill-gray-800" />
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ describe("MessageInput", () => {
|
|||||||
|
|
||||||
it("should render the input field, byte counter, and send button", () => {
|
it("should render the input field, byte counter, and send button", () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
expect(screen.getByPlaceholderText("Enter Message")).toBeInTheDocument();
|
expect(screen.getByTestId("message-input-field")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("byte-counter")).toBeInTheDocument();
|
expect(screen.getByTestId("byte-counter")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("send-icon")).toBeInTheDocument();
|
expect(screen.getByTestId("send-icon")).toBeInTheDocument();
|
||||||
@@ -100,10 +100,6 @@ describe("MessageInput", () => {
|
|||||||
|
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
const inputElement = screen.getByPlaceholderText(
|
|
||||||
"Enter Message",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
expect(inputElement.value).toBe(initialDraft);
|
|
||||||
expect(mockGetDraft).toHaveBeenCalledWith(defaultProps.to);
|
expect(mockGetDraft).toHaveBeenCalledWith(defaultProps.to);
|
||||||
const expectedBytes = new Blob([initialDraft]).size;
|
const expectedBytes = new Blob([initialDraft]).size;
|
||||||
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
|
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
|
||||||
@@ -113,7 +109,7 @@ describe("MessageInput", () => {
|
|||||||
|
|
||||||
it("should update input value, byte counter, and call setDraft on change within limits", () => {
|
it("should update input value, byte counter, and call setDraft on change within limits", () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
const inputElement = screen.getByPlaceholderText("Enter Message");
|
const inputElement = screen.getByTestId("message-input-field");
|
||||||
const testMessage = "Hello there!";
|
const testMessage = "Hello there!";
|
||||||
const expectedBytes = new Blob([testMessage]).size;
|
const expectedBytes = new Blob([testMessage]).size;
|
||||||
|
|
||||||
@@ -130,7 +126,7 @@ describe("MessageInput", () => {
|
|||||||
it("should NOT update input value or call setDraft if maxBytes is exceeded", () => {
|
it("should NOT update input value or call setDraft if maxBytes is exceeded", () => {
|
||||||
const smallMaxBytes = 5;
|
const smallMaxBytes = 5;
|
||||||
renderComponent({ maxBytes: smallMaxBytes });
|
renderComponent({ maxBytes: smallMaxBytes });
|
||||||
const inputElement = screen.getByPlaceholderText("Enter Message");
|
const inputElement = screen.getByTestId("message-input-field");
|
||||||
const initialValue = "12345";
|
const initialValue = "12345";
|
||||||
const excessiveValue = "123456";
|
const excessiveValue = "123456";
|
||||||
|
|
||||||
@@ -150,7 +146,7 @@ describe("MessageInput", () => {
|
|||||||
|
|
||||||
it("should call onSend, clear input, reset byte counter, and call clearDraft on valid submit", async () => {
|
it("should call onSend, clear input, reset byte counter, and call clearDraft on valid submit", async () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
const inputElement = screen.getByPlaceholderText("Enter Message");
|
const inputElement = screen.getByTestId("message-input-field");
|
||||||
const formElement = screen.getByRole("form");
|
const formElement = screen.getByRole("form");
|
||||||
const testMessage = "Send this message";
|
const testMessage = "Send this message";
|
||||||
|
|
||||||
@@ -171,7 +167,7 @@ describe("MessageInput", () => {
|
|||||||
|
|
||||||
it("should trim whitespace before calling onSend", async () => {
|
it("should trim whitespace before calling onSend", async () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
const inputElement = screen.getByPlaceholderText("Enter Message");
|
const inputElement = screen.getByTestId("message-input-field");
|
||||||
const formElement = screen.getByRole("form");
|
const formElement = screen.getByRole("form");
|
||||||
const testMessageWithWhitespace = " Trim me! ";
|
const testMessageWithWhitespace = " Trim me! ";
|
||||||
const expectedTrimmedMessage = "Trim me!";
|
const expectedTrimmedMessage = "Trim me!";
|
||||||
@@ -190,7 +186,7 @@ describe("MessageInput", () => {
|
|||||||
|
|
||||||
it("should not call onSend or clearDraft if input is empty on submit", async () => {
|
it("should not call onSend or clearDraft if input is empty on submit", async () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
const inputElement = screen.getByPlaceholderText("Enter Message");
|
const inputElement = screen.getByTestId("message-input-field");
|
||||||
const formElement = screen.getByRole("form");
|
const formElement = screen.getByRole("form");
|
||||||
|
|
||||||
expect((inputElement as HTMLInputElement).value).toBe("");
|
expect((inputElement as HTMLInputElement).value).toBe("");
|
||||||
@@ -236,10 +232,14 @@ describe("MessageInput", () => {
|
|||||||
|
|
||||||
expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest);
|
expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest);
|
||||||
expect(
|
expect(
|
||||||
(screen.getByPlaceholderText("Enter Message") as HTMLInputElement).value,
|
(screen.getByTestId(
|
||||||
|
"message-input-field",
|
||||||
|
) as HTMLInputElement).value,
|
||||||
).toBe("Broadcast draft");
|
).toBe("Broadcast draft");
|
||||||
|
|
||||||
const inputElement = screen.getByPlaceholderText("Enter Message");
|
const inputElement = screen.getByTestId(
|
||||||
|
"message-input-field",
|
||||||
|
) as HTMLInputElement;
|
||||||
const formElement = screen.getByRole("form");
|
const formElement = screen.getByRole("form");
|
||||||
const newMessage = "New broadcast msg";
|
const newMessage = "New broadcast msg";
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Types } from "@meshtastic/core";
|
|||||||
import { SendIcon } from "lucide-react";
|
import { SendIcon } from "lucide-react";
|
||||||
import { startTransition, useState } from "react";
|
import { startTransition, useState } from "react";
|
||||||
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface MessageInputProps {
|
export interface MessageInputProps {
|
||||||
onSend: (message: string) => void;
|
onSend: (message: string) => void;
|
||||||
@@ -17,6 +18,7 @@ export const MessageInput = ({
|
|||||||
maxBytes,
|
maxBytes,
|
||||||
}: MessageInputProps) => {
|
}: MessageInputProps) => {
|
||||||
const { setDraft, getDraft, clearDraft } = useMessageStore();
|
const { setDraft, getDraft, clearDraft } = useMessageStore();
|
||||||
|
const { t } = useTranslation("messages");
|
||||||
|
|
||||||
const calculateBytes = (text: string) => new Blob([text]).size;
|
const calculateBytes = (text: string) => new Blob([text]).size;
|
||||||
|
|
||||||
@@ -59,7 +61,8 @@ export const MessageInput = ({
|
|||||||
autoFocus
|
autoFocus
|
||||||
minLength={1}
|
minLength={1}
|
||||||
name="messageInput"
|
name="messageInput"
|
||||||
placeholder="Enter Message"
|
placeholder={t("sendMessage.placeholder")}
|
||||||
|
autoComplete="off"
|
||||||
value={localDraft}
|
value={localDraft}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import {
|
|||||||
MessageState,
|
MessageState,
|
||||||
useMessageStore,
|
useMessageStore,
|
||||||
} from "@core/stores/messageStore/index.ts";
|
} from "@core/stores/messageStore/index.ts";
|
||||||
import { Protobuf, Types } from "@meshtastic/js";
|
import { Protobuf, Types } from "@meshtastic/core";
|
||||||
import { Message } from "@core/stores/messageStore/types.ts";
|
import { Message } from "@core/stores/messageStore/types.ts";
|
||||||
// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // Uncomment if needed later
|
import { useTranslation } from "react-i18next";
|
||||||
|
// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // TODO: Uncomment when actions menu is implemented
|
||||||
|
|
||||||
interface MessageStatusInfo {
|
interface MessageStatusInfo {
|
||||||
displayText: string;
|
displayText: string;
|
||||||
@@ -26,37 +27,6 @@ interface MessageStatusInfo {
|
|||||||
iconClassName?: string;
|
iconClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MESSAGE_STATUS_MAP: Record<MessageState, MessageStatusInfo> = {
|
|
||||||
[MessageState.Ack]: {
|
|
||||||
displayText: "Message delivered",
|
|
||||||
icon: CheckCircle2,
|
|
||||||
ariaLabel: "Message delivered",
|
|
||||||
iconClassName: "text-green-500",
|
|
||||||
},
|
|
||||||
[MessageState.Waiting]: {
|
|
||||||
displayText: "Waiting for delivery",
|
|
||||||
icon: CircleEllipsis,
|
|
||||||
ariaLabel: "Sending message",
|
|
||||||
iconClassName: "text-slate-400",
|
|
||||||
},
|
|
||||||
[MessageState.Failed]: {
|
|
||||||
displayText: "Delivery failed",
|
|
||||||
icon: AlertCircle,
|
|
||||||
ariaLabel: "Message delivery failed",
|
|
||||||
iconClassName: "text-red-500 dark:text-red-400",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const UNKNOWN_STATUS: MessageStatusInfo = {
|
|
||||||
displayText: "Unknown state",
|
|
||||||
icon: AlertCircle,
|
|
||||||
ariaLabel: "Message status unknown",
|
|
||||||
iconClassName: "text-red-500 dark:text-red-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMessageStatusInfo = (state: MessageState): MessageStatusInfo =>
|
|
||||||
MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS;
|
|
||||||
|
|
||||||
const StatusTooltip = (
|
const StatusTooltip = (
|
||||||
{ statusInfo, children }: {
|
{ statusInfo, children }: {
|
||||||
statusInfo: MessageStatusInfo;
|
statusInfo: MessageStatusInfo;
|
||||||
@@ -81,16 +51,55 @@ interface MessageItemProps {
|
|||||||
export const MessageItem = ({ message }: MessageItemProps) => {
|
export const MessageItem = ({ message }: MessageItemProps) => {
|
||||||
const { getNode } = useDevice();
|
const { getNode } = useDevice();
|
||||||
const { getMyNodeNum } = useMessageStore();
|
const { getMyNodeNum } = useMessageStore();
|
||||||
|
const { t, i18n } = useTranslation("messages");
|
||||||
|
|
||||||
|
const MESSAGE_STATUS_MAP = useMemo(
|
||||||
|
(): Record<MessageState, MessageStatusInfo> => ({
|
||||||
|
[MessageState.Ack]: {
|
||||||
|
displayText: t("deliveryStatus.delivered.displayText"),
|
||||||
|
icon: CheckCircle2,
|
||||||
|
ariaLabel: t("deliveryStatus.delivered.label"),
|
||||||
|
iconClassName: "text-green-500",
|
||||||
|
},
|
||||||
|
[MessageState.Waiting]: {
|
||||||
|
displayText: t("deliveryStatus.waiting.displayText"),
|
||||||
|
icon: CircleEllipsis,
|
||||||
|
ariaLabel: t("deliveryStatus.waiting.label"),
|
||||||
|
iconClassName: "text-slate-400",
|
||||||
|
},
|
||||||
|
[MessageState.Failed]: {
|
||||||
|
displayText: t("deliveryStatus.failed.displayText"),
|
||||||
|
icon: AlertCircle,
|
||||||
|
ariaLabel: t("deliveryStatus.failed.label"),
|
||||||
|
iconClassName: "text-red-500 dark:text-red-400",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const UNKNOWN_STATUS = useMemo((): MessageStatusInfo => ({
|
||||||
|
displayText: t("deliveryStatus.unknown.displayText"),
|
||||||
|
icon: AlertCircle,
|
||||||
|
ariaLabel: t("deliveryStatus.unknown.label"),
|
||||||
|
iconClassName: "text-red-500 dark:text-red-400",
|
||||||
|
}), [t]);
|
||||||
|
|
||||||
|
const getMessageStatusInfo = useMemo(
|
||||||
|
() => (state: MessageState): MessageStatusInfo =>
|
||||||
|
MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS,
|
||||||
|
[MESSAGE_STATUS_MAP, UNKNOWN_STATUS],
|
||||||
|
);
|
||||||
|
|
||||||
const messageUser: Protobuf.Mesh.NodeInfo | null | undefined = useMemo(() => {
|
const messageUser: Protobuf.Mesh.NodeInfo | null | undefined = useMemo(() => {
|
||||||
return message.from != null ? getNode(message.from) : null;
|
return message.from != null ? getNode(message.from) : null;
|
||||||
}, [getNode, message.from]);
|
}, [getNode, message.from]);
|
||||||
|
|
||||||
const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]);
|
const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]);
|
||||||
|
|
||||||
const { displayName, shortName, isFavorite } = useMemo(() => {
|
const { displayName, shortName, isFavorite } = useMemo(() => {
|
||||||
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, "0");
|
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, "0");
|
||||||
const last4 = userIdHex.slice(-4);
|
const last4 = userIdHex.slice(-4);
|
||||||
const fallbackName = `Meshtastic ${last4}`;
|
const fallbackName = t("fallbackName", { last4 });
|
||||||
const longName = messageUser?.user?.longName;
|
const longName = messageUser?.user?.longName;
|
||||||
const derivedShortName = messageUser?.user?.shortName || fallbackName;
|
const derivedShortName = messageUser?.user?.shortName || fallbackName;
|
||||||
const derivedDisplayName = longName || derivedShortName;
|
const derivedDisplayName = longName || derivedShortName;
|
||||||
@@ -101,7 +110,7 @@ export const MessageItem = ({ message }: MessageItemProps) => {
|
|||||||
shortName: derivedShortName,
|
shortName: derivedShortName,
|
||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
};
|
};
|
||||||
}, [messageUser, message.from]);
|
}, [messageUser, message.from, t, myNodeNum]);
|
||||||
|
|
||||||
const messageStatusInfo = getMessageStatusInfo(message.state);
|
const messageStatusInfo = getMessageStatusInfo(message.state);
|
||||||
const StatusIconComponent = messageStatusInfo.icon;
|
const StatusIconComponent = messageStatusInfo.icon;
|
||||||
@@ -110,7 +119,7 @@ export const MessageItem = ({ message }: MessageItemProps) => {
|
|||||||
() => message.date ? new Date(message.date) : null,
|
() => message.date ? new Date(message.date) : null,
|
||||||
[message.date],
|
[message.date],
|
||||||
);
|
);
|
||||||
const locale = "en-US"; // TODO: Make dynamic via props or context
|
const locale = i18n.language;
|
||||||
|
|
||||||
const formattedTime = useMemo(
|
const formattedTime = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -2,29 +2,71 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
|
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import type { Protobuf } from "@meshtastic/core";
|
import { mockDeviceStore } from "@core/stores/deviceStore.mock.ts";
|
||||||
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
|
||||||
vi.mock("@core/stores/deviceStore");
|
vi.mock("@core/stores/deviceStore");
|
||||||
|
|
||||||
describe("TraceRoute", () => {
|
describe("TraceRoute", () => {
|
||||||
|
const fromUser = {
|
||||||
|
user: {
|
||||||
|
$typeName: "meshtastic.User",
|
||||||
|
longName: "Source Node",
|
||||||
|
publicKey: new Uint8Array([1, 2, 3]),
|
||||||
|
shortName: "Source",
|
||||||
|
hwModel: 1,
|
||||||
|
macaddr: new Uint8Array([0x01, 0x02, 0x03, 0x04]),
|
||||||
|
id: "source-node",
|
||||||
|
isLicensed: false,
|
||||||
|
role: Protobuf.Config.Config_DeviceConfig_Role["CLIENT"],
|
||||||
|
} as Protobuf.Mesh.NodeInfo["user"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const toUser = {
|
||||||
|
user: {
|
||||||
|
$typeName: "meshtastic.User",
|
||||||
|
longName: "Destination Node",
|
||||||
|
publicKey: new Uint8Array([4, 5, 6]),
|
||||||
|
shortName: "Destination",
|
||||||
|
hwModel: 2,
|
||||||
|
macaddr: new Uint8Array([0x05, 0x06, 0x07, 0x08]),
|
||||||
|
id: "destination-node",
|
||||||
|
isLicensed: false,
|
||||||
|
role: Protobuf.Config.Config_DeviceConfig_Role["CLIENT"],
|
||||||
|
} as Protobuf.Mesh.NodeInfo["user"],
|
||||||
|
};
|
||||||
|
|
||||||
const mockNodes = new Map<number, Protobuf.Mesh.NodeInfo>([
|
const mockNodes = new Map<number, Protobuf.Mesh.NodeInfo>([
|
||||||
[
|
[
|
||||||
1,
|
1,
|
||||||
{ num: 1, user: { longName: "Node A" } } as Protobuf.Mesh.NodeInfo,
|
{
|
||||||
|
num: 1,
|
||||||
|
user: { longName: "Node A", $typeName: "meshtastic.User" },
|
||||||
|
$typeName: "meshtastic.NodeInfo",
|
||||||
|
} as Protobuf.Mesh.NodeInfo,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
2,
|
2,
|
||||||
{ num: 2, user: { longName: "Node B" } } as Protobuf.Mesh.NodeInfo,
|
{
|
||||||
|
num: 2,
|
||||||
|
user: { longName: "Node B", $typeName: "meshtastic.User" },
|
||||||
|
$typeName: "meshtastic.NodeInfo",
|
||||||
|
} as Protobuf.Mesh.NodeInfo,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
3,
|
3,
|
||||||
{ num: 3, user: { longName: "Node C" } } as Protobuf.Mesh.NodeInfo,
|
{
|
||||||
|
num: 3,
|
||||||
|
user: { longName: "Node C", $typeName: "meshtastic.User" },
|
||||||
|
$typeName: "meshtastic.NodeInfo",
|
||||||
|
} as Protobuf.Mesh.NodeInfo,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
vi.mocked(useDevice).mockReturnValue({
|
vi.mocked(useDevice).mockReturnValue({
|
||||||
|
...mockDeviceStore,
|
||||||
getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
|
getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
|
||||||
return mockNodes.get(nodeNum);
|
return mockNodes.get(nodeNum);
|
||||||
},
|
},
|
||||||
@@ -34,30 +76,29 @@ describe("TraceRoute", () => {
|
|||||||
it("renders the route to destination with SNR values", () => {
|
it("renders the route to destination with SNR values", () => {
|
||||||
render(
|
render(
|
||||||
<TraceRoute
|
<TraceRoute
|
||||||
from={{ user: { longName: "Source Node" } }}
|
from={fromUser}
|
||||||
to={{ user: { longName: "Destination Node" } }}
|
to={toUser}
|
||||||
route={[1, 2]}
|
route={[1, 2]}
|
||||||
snrTowards={[10, 20, 30]}
|
snrTowards={[10, 20, 30]}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getAllByText("Source Node")).toHaveLength(1);
|
expect(screen.getByText("Source Node")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Destination Node")).toBeInTheDocument();
|
expect(screen.getByText("Destination Node")).toBeInTheDocument();
|
||||||
|
|
||||||
expect(screen.getByText("Node A")).toBeInTheDocument();
|
expect(screen.getByText("Node A")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Node B")).toBeInTheDocument();
|
expect(screen.getByText("Node B")).toBeInTheDocument();
|
||||||
|
|
||||||
expect(screen.getAllByText(/↓/)).toHaveLength(3);
|
expect(screen.getAllByText(/↓/)).toHaveLength(3);
|
||||||
expect(screen.getByText("↓ 10dB")).toBeInTheDocument();
|
expect(screen.getByText("↓ 10dBm")).toBeInTheDocument();
|
||||||
expect(screen.getByText("↓ 20dB")).toBeInTheDocument();
|
expect(screen.getByText("↓ 20dBm")).toBeInTheDocument();
|
||||||
expect(screen.getByText("↓ 30dB")).toBeInTheDocument();
|
expect(screen.getByText("↓ 30dBm")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the route back when provided", () => {
|
it("renders the route back when provided", () => {
|
||||||
render(
|
render(
|
||||||
<TraceRoute
|
<TraceRoute
|
||||||
from={{ user: { longName: "Source Node" } }}
|
from={fromUser}
|
||||||
to={{ user: { longName: "Destination Node" } }}
|
to={toUser}
|
||||||
route={[1]}
|
route={[1]}
|
||||||
snrTowards={[15, 25]}
|
snrTowards={[15, 25]}
|
||||||
routeBack={[3]}
|
routeBack={[3]}
|
||||||
@@ -65,47 +106,33 @@ describe("TraceRoute", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check for the translated title
|
||||||
expect(screen.getByText("Route back:")).toBeInTheDocument();
|
expect(screen.getByText("Route back:")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// With route back, both names appear twice
|
||||||
expect(screen.getAllByText("Source Node")).toHaveLength(2);
|
expect(screen.getAllByText("Source Node")).toHaveLength(2);
|
||||||
|
|
||||||
expect(screen.getAllByText("Destination Node")).toHaveLength(2);
|
expect(screen.getAllByText("Destination Node")).toHaveLength(2);
|
||||||
|
|
||||||
expect(screen.getByText("Node C")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Node A")).toBeInTheDocument();
|
expect(screen.getByText("Node A")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Node C")).toBeInTheDocument();
|
||||||
|
|
||||||
expect(screen.getByText("↓ 35dB")).toBeInTheDocument();
|
expect(screen.getByText("↓ 15dBm")).toBeInTheDocument();
|
||||||
expect(screen.getByText("↓ 45dB")).toBeInTheDocument();
|
expect(screen.getByText("↓ 25dBm")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("↓ 35dBm")).toBeInTheDocument();
|
||||||
expect(screen.getByText("↓ 15dB")).toBeInTheDocument();
|
expect(screen.getByText("↓ 45dBm")).toBeInTheDocument();
|
||||||
expect(screen.getByText("↓ 25dB")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders '??' for missing SNR values", () => {
|
it("renders '??' for missing SNR values", () => {
|
||||||
render(
|
render(
|
||||||
<TraceRoute
|
<TraceRoute
|
||||||
from={{ user: { longName: "Source" } }}
|
from={fromUser}
|
||||||
to={{ user: { longName: "Dest" } }}
|
to={toUser}
|
||||||
route={[1]}
|
route={[1]}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Node A")).toBeInTheDocument();
|
expect(screen.getByText("Node A")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("↓ ??dB")).toHaveLength(2);
|
// Check for translated '??' placeholder
|
||||||
});
|
expect(screen.getAllByText(/↓ \?\?dBm/)).toHaveLength(2);
|
||||||
|
|
||||||
it("renders hop hex if node is not found", () => {
|
|
||||||
render(
|
|
||||||
<TraceRoute
|
|
||||||
from={{ user: { longName: "Source" } } as unknown}
|
|
||||||
to={{ user: { longName: "Dest" } } as unknown}
|
|
||||||
route={[99]}
|
|
||||||
snrTowards={[5, 15]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/^!63$/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("↓ 5dB")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("↓ 15dB")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import type { Protobuf } from "@meshtastic/core";
|
import type { Protobuf } from "@meshtastic/core";
|
||||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
type NodeUser = Pick<Protobuf.Mesh.NodeInfo, "user">;
|
||||||
|
|
||||||
export interface TraceRouteProps {
|
export interface TraceRouteProps {
|
||||||
from?: Protobuf.Mesh.NodeInfo;
|
from: NodeUser;
|
||||||
to?: Protobuf.Mesh.NodeInfo;
|
to: NodeUser;
|
||||||
route: Array<number>;
|
route: Array<number>;
|
||||||
routeBack?: Array<number>;
|
routeBack?: Array<number>;
|
||||||
snrTowards?: Array<number>;
|
snrTowards?: Array<number>;
|
||||||
@@ -13,16 +16,17 @@ export interface TraceRouteProps {
|
|||||||
|
|
||||||
interface RoutePathProps {
|
interface RoutePathProps {
|
||||||
title: string;
|
title: string;
|
||||||
startNode?: Protobuf.Mesh.NodeInfo;
|
from: NodeUser;
|
||||||
endNode?: Protobuf.Mesh.NodeInfo;
|
to: NodeUser;
|
||||||
path: number[];
|
path: number[];
|
||||||
snr?: number[];
|
snr?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const RoutePath = (
|
const RoutePath = (
|
||||||
{ title, startNode, endNode, path, snr }: RoutePathProps,
|
{ title, from, to, path, snr }: RoutePathProps,
|
||||||
) => {
|
) => {
|
||||||
const { getNode } = useDevice();
|
const { getNode } = useDevice();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -30,17 +34,24 @@ const RoutePath = (
|
|||||||
className="ml-4 border-l-2 pl-2 border-l-slate-900 text-slate-900 dark:text-slate-100 dark:border-l-slate-100"
|
className="ml-4 border-l-2 pl-2 border-l-slate-900 text-slate-900 dark:text-slate-100 dark:border-l-slate-100"
|
||||||
>
|
>
|
||||||
<p className="font-semibold">{title}</p>
|
<p className="font-semibold">{title}</p>
|
||||||
<p>{startNode?.user?.longName}</p>
|
<p>{from?.user?.longName}</p>
|
||||||
<p>↓ {snr?.[0] ?? "??"}dB</p>
|
<p>
|
||||||
|
↓ {snr?.[0] ?? t("unknown.num")}
|
||||||
|
{t("unit.dbm")}
|
||||||
|
</p>
|
||||||
{path.map((hop, i) => (
|
{path.map((hop, i) => (
|
||||||
<span key={getNode(hop)?.num ?? hop}>
|
<span key={getNode(hop)?.num ?? hop}>
|
||||||
<p>
|
<p>
|
||||||
{getNode(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}
|
{getNode(hop)?.user?.longName ??
|
||||||
|
`${t("traceRoute.nodeUnknownPrefix")}${numberToHexUnpadded(hop)}`}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
↓ {snr?.[i + 1] ?? t("unknown.num")}
|
||||||
|
{t("unit.dbm")}
|
||||||
</p>
|
</p>
|
||||||
<p>↓ {snr?.[i + 1] ?? "??"}dB</p>
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<p>{endNode?.user?.longName}</p>
|
<p>{to?.user?.longName}</p>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -53,20 +64,21 @@ export const TraceRoute = ({
|
|||||||
snrTowards,
|
snrTowards,
|
||||||
snrBack,
|
snrBack,
|
||||||
}: TraceRouteProps) => {
|
}: TraceRouteProps) => {
|
||||||
|
const { t } = useTranslation("dialog");
|
||||||
return (
|
return (
|
||||||
<div className="ml-5 flex">
|
<div className="ml-5 flex">
|
||||||
<RoutePath
|
<RoutePath
|
||||||
title="Route to destination:"
|
title={t("traceRoute.routeToDestination")}
|
||||||
startNode={to}
|
to={to}
|
||||||
endNode={from}
|
from={from}
|
||||||
path={route}
|
path={route}
|
||||||
snr={snrTowards}
|
snr={snrTowards}
|
||||||
/>
|
/>
|
||||||
{routeBack && (
|
{routeBack && routeBack.length > 0 && (
|
||||||
<RoutePath
|
<RoutePath
|
||||||
title="Route back:"
|
title={t("traceRoute.routeBack")}
|
||||||
startNode={from}
|
to={from}
|
||||||
endNode={to}
|
from={to}
|
||||||
path={routeBack}
|
path={routeBack}
|
||||||
snr={snrBack}
|
snr={snrBack}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,13 +1,38 @@
|
|||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import type { AmbientLightingValidation } from "@app/validation/moduleConfig/ambientLighting.tsx";
|
import {
|
||||||
|
type AmbientLightingValidation,
|
||||||
|
AmbientLightingValidationSchema,
|
||||||
|
} from "@app/validation/moduleConfig/ambientLighting.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const AmbientLighting = () => {
|
interface AmbientLightingModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<AmbientLightingValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AmbientLighting = (
|
||||||
|
{ onFormInit }: AmbientLightingModuleConfigProps,
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: AmbientLightingValidation) => {
|
const onSubmit = (data: AmbientLightingValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.ambientLighting, data, true)) {
|
||||||
|
removeWorkingModuleConfig("ambientLighting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -21,41 +46,45 @@ export const AmbientLighting = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<AmbientLightingValidation>
|
<DynamicForm<AmbientLightingValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
|
validationSchema={AmbientLightingValidationSchema}
|
||||||
|
formId="ModuleConfig_AmbientLightingConfig"
|
||||||
defaultValues={moduleConfig.ambientLighting}
|
defaultValues={moduleConfig.ambientLighting}
|
||||||
|
values={getEffectiveModuleConfig("ambientLighting")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "Ambient Lighting Settings",
|
label: t("ambientLighting.title"),
|
||||||
description: "Settings for the Ambient Lighting module",
|
description: t("ambientLighting.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "ledState",
|
name: "ledState",
|
||||||
label: "LED State",
|
label: t("ambientLighting.ledState.label"),
|
||||||
description: "Sets LED to on or off",
|
description: t("ambientLighting.ledState.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "current",
|
name: "current",
|
||||||
label: "Current",
|
label: t("ambientLighting.current.label"),
|
||||||
description: "Sets the current for the LED output. Default is 10",
|
description: t("ambientLighting.current.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "red",
|
name: "red",
|
||||||
label: "Red",
|
label: t("ambientLighting.red.label"),
|
||||||
description: "Sets the red LED level. Values are 0-255",
|
description: t("ambientLighting.red.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "green",
|
name: "green",
|
||||||
label: "Green",
|
label: t("ambientLighting.green.label"),
|
||||||
description: "Sets the green LED level. Values are 0-255",
|
description: t("ambientLighting.green.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "blue",
|
name: "blue",
|
||||||
label: "Blue",
|
label: t("ambientLighting.blue.label"),
|
||||||
description: "Sets the blue LED level. Values are 0-255",
|
description: t("ambientLighting.blue.description"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,36 @@
|
|||||||
import type { AudioValidation } from "@app/validation/moduleConfig/audio.tsx";
|
import {
|
||||||
|
type AudioValidation,
|
||||||
|
AudioValidationSchema,
|
||||||
|
} from "@app/validation/moduleConfig/audio.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Audio = () => {
|
interface AudioModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<AudioValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Audio = ({ onFormInit }: AudioModuleConfigProps) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: AudioValidation) => {
|
const onSubmit = (data: AudioValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.audio, data, true)) {
|
||||||
|
removeWorkingModuleConfig("audio");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -21,29 +44,33 @@ export const Audio = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<AudioValidation>
|
<DynamicForm<AudioValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
|
validationSchema={AudioValidationSchema}
|
||||||
|
formId="ModuleConfig_AudioConfig"
|
||||||
defaultValues={moduleConfig.audio}
|
defaultValues={moduleConfig.audio}
|
||||||
|
values={getEffectiveModuleConfig("audio")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "Audio Settings",
|
label: t("audio.title"),
|
||||||
description: "Settings for the Audio module",
|
description: t("audio.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "codec2Enabled",
|
name: "codec2Enabled",
|
||||||
label: "Codec 2 Enabled",
|
label: t("audio.codec2Enabled.label"),
|
||||||
description: "Enable Codec 2 audio encoding",
|
description: t("audio.codec2Enabled.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "pttPin",
|
name: "pttPin",
|
||||||
label: "PTT Pin",
|
label: t("audio.pttPin.label"),
|
||||||
description: "GPIO pin to use for PTT",
|
description: t("audio.pttPin.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "bitrate",
|
name: "bitrate",
|
||||||
label: "Bitrate",
|
label: t("audio.bitrate.label"),
|
||||||
description: "Bitrate to use for audio encoding",
|
description: t("audio.bitrate.description"),
|
||||||
properties: {
|
properties: {
|
||||||
enumValue:
|
enumValue:
|
||||||
Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud,
|
Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud,
|
||||||
@@ -52,26 +79,26 @@ export const Audio = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "i2sWs",
|
name: "i2sWs",
|
||||||
label: "i2S WS",
|
label: t("audio.i2sWs.label"),
|
||||||
description: "GPIO pin to use for i2S WS",
|
description: t("audio.i2sWs.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "i2sSd",
|
name: "i2sSd",
|
||||||
label: "i2S SD",
|
label: t("audio.i2sSd.label"),
|
||||||
description: "GPIO pin to use for i2S SD",
|
description: t("audio.i2sSd.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "i2sDin",
|
name: "i2sDin",
|
||||||
label: "i2S DIN",
|
label: t("audio.i2sDin.label"),
|
||||||
description: "GPIO pin to use for i2S DIN",
|
description: t("audio.i2sDin.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "i2sSck",
|
name: "i2sSck",
|
||||||
label: "i2S SCK",
|
label: t("audio.i2sSck.label"),
|
||||||
description: "GPIO pin to use for i2S SCK",
|
description: t("audio.i2sSck.description"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,38 @@
|
|||||||
import type { CannedMessageValidation } from "@app/validation/moduleConfig/cannedMessage.tsx";
|
import {
|
||||||
|
type CannedMessageValidation,
|
||||||
|
CannedMessageValidationSchema,
|
||||||
|
} from "@app/validation/moduleConfig/cannedMessage.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const CannedMessage = () => {
|
interface CannedMessageModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<CannedMessageValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CannedMessage = (
|
||||||
|
{ onFormInit }: CannedMessageModuleConfigProps,
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: CannedMessageValidation) => {
|
const onSubmit = (data: CannedMessageValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.cannedMessage, data, true)) {
|
||||||
|
removeWorkingModuleConfig("cannedMessage");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -21,47 +46,51 @@ export const CannedMessage = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<CannedMessageValidation>
|
<DynamicForm<CannedMessageValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
|
validationSchema={CannedMessageValidationSchema}
|
||||||
|
formId="ModuleConfig_CannedMessageConfig"
|
||||||
defaultValues={moduleConfig.cannedMessage}
|
defaultValues={moduleConfig.cannedMessage}
|
||||||
|
values={getEffectiveModuleConfig("cannedMessage")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "Canned Message Settings",
|
label: t("cannedMessage.title"),
|
||||||
description: "Settings for the Canned Message module",
|
description: t("cannedMessage.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "enabled",
|
name: "enabled",
|
||||||
label: "Module Enabled",
|
label: t("cannedMessage.moduleEnabled.label"),
|
||||||
description: "Enable Canned Message",
|
description: t("cannedMessage.moduleEnabled.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "rotary1Enabled",
|
name: "rotary1Enabled",
|
||||||
label: "Rotary Encoder #1 Enabled",
|
label: t("cannedMessage.rotary1Enabled.label"),
|
||||||
description: "Enable the rotary encoder",
|
description: t("cannedMessage.rotary1Enabled.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "inputbrokerPinA",
|
name: "inputbrokerPinA",
|
||||||
label: "Encoder Pin A",
|
label: t("cannedMessage.inputbrokerPinA.label"),
|
||||||
description: "GPIO Pin Value (1-39) For encoder port A",
|
description: t("cannedMessage.inputbrokerPinA.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "inputbrokerPinB",
|
name: "inputbrokerPinB",
|
||||||
label: "Encoder Pin B",
|
label: t("cannedMessage.inputbrokerPinB.label"),
|
||||||
description: "GPIO Pin Value (1-39) For encoder port B",
|
description: t("cannedMessage.inputbrokerPinB.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "inputbrokerPinPress",
|
name: "inputbrokerPinPress",
|
||||||
label: "Encoder Pin Press",
|
label: t("cannedMessage.inputbrokerPinPress.label"),
|
||||||
description: "GPIO Pin Value (1-39) For encoder Press",
|
description: t("cannedMessage.inputbrokerPinPress.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "inputbrokerEventCw",
|
name: "inputbrokerEventCw",
|
||||||
label: "Clockwise event",
|
label: t("cannedMessage.inputbrokerEventCw.label"),
|
||||||
description: "Select input event.",
|
description: t("cannedMessage.inputbrokerEventCw.description"),
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: Protobuf.ModuleConfig
|
enumValue: Protobuf.ModuleConfig
|
||||||
.ModuleConfig_CannedMessageConfig_InputEventChar,
|
.ModuleConfig_CannedMessageConfig_InputEventChar,
|
||||||
@@ -70,8 +99,8 @@ export const CannedMessage = () => {
|
|||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "inputbrokerEventCcw",
|
name: "inputbrokerEventCcw",
|
||||||
label: "Counter Clockwise event",
|
label: t("cannedMessage.inputbrokerEventCcw.label"),
|
||||||
description: "Select input event.",
|
description: t("cannedMessage.inputbrokerEventCcw.description"),
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: Protobuf.ModuleConfig
|
enumValue: Protobuf.ModuleConfig
|
||||||
.ModuleConfig_CannedMessageConfig_InputEventChar,
|
.ModuleConfig_CannedMessageConfig_InputEventChar,
|
||||||
@@ -80,8 +109,8 @@ export const CannedMessage = () => {
|
|||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "inputbrokerEventPress",
|
name: "inputbrokerEventPress",
|
||||||
label: "Press event",
|
label: t("cannedMessage.inputbrokerEventPress.label"),
|
||||||
description: "Select input event",
|
description: t("cannedMessage.inputbrokerEventPress.description"),
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: Protobuf.ModuleConfig
|
enumValue: Protobuf.ModuleConfig
|
||||||
.ModuleConfig_CannedMessageConfig_InputEventChar,
|
.ModuleConfig_CannedMessageConfig_InputEventChar,
|
||||||
@@ -90,21 +119,20 @@ export const CannedMessage = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "updown1Enabled",
|
name: "updown1Enabled",
|
||||||
label: "Up Down enabled",
|
label: t("cannedMessage.updown1Enabled.label"),
|
||||||
description: "Enable the up / down encoder",
|
description: t("cannedMessage.updown1Enabled.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
name: "allowInputSource",
|
name: "allowInputSource",
|
||||||
label: "Allow Input Source",
|
label: t("cannedMessage.allowInputSource.label"),
|
||||||
description:
|
description: t("cannedMessage.allowInputSource.description"),
|
||||||
"Select from: '_any', 'rotEnc1', 'upDownEnc1', 'cardkb'",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "sendBell",
|
name: "sendBell",
|
||||||
label: "Send Bell",
|
label: t("cannedMessage.sendBell.label"),
|
||||||
description: "Sends a bell character with each message",
|
description: t("cannedMessage.sendBell.description"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,38 @@
|
|||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import type { DetectionSensorValidation } from "@app/validation/moduleConfig/detectionSensor.tsx";
|
import {
|
||||||
|
type DetectionSensorValidation,
|
||||||
|
DetectionSensorValidationSchema,
|
||||||
|
} from "@app/validation/moduleConfig/detectionSensor.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const DetectionSensor = () => {
|
interface DetectionSensorModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<DetectionSensorValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DetectionSensor = (
|
||||||
|
{ onFormInit }: DetectionSensorModuleConfigProps,
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: DetectionSensorValidation) => {
|
const onSubmit = (data: DetectionSensorValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.detectionSensor, data, true)) {
|
||||||
|
removeWorkingModuleConfig("detectionSensor");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -21,26 +46,31 @@ export const DetectionSensor = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<DetectionSensorValidation>
|
<DynamicForm<DetectionSensorValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
|
validationSchema={DetectionSensorValidationSchema}
|
||||||
|
formId="ModuleConfig_DetectionSensorConfig"
|
||||||
defaultValues={moduleConfig.detectionSensor}
|
defaultValues={moduleConfig.detectionSensor}
|
||||||
|
values={getEffectiveModuleConfig("detectionSensor")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "Detection Sensor Settings",
|
label: t("detectionSensor.title"),
|
||||||
description: "Settings for the Detection Sensor module",
|
description: t("detectionSensor.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "enabled",
|
name: "enabled",
|
||||||
label: "Enabled",
|
label: t("detectionSensor.enabled.label"),
|
||||||
description: "Enable or disable Detection Sensor Module",
|
description: t("detectionSensor.enabled.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "minimumBroadcastSecs",
|
name: "minimumBroadcastSecs",
|
||||||
label: "Minimum Broadcast Seconds",
|
label: t("detectionSensor.minimumBroadcastSecs.label"),
|
||||||
description:
|
description: t(
|
||||||
"The interval in seconds of how often we can send a message to the mesh when a state change is detected",
|
"detectionSensor.minimumBroadcastSecs.description",
|
||||||
|
),
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "Seconds",
|
suffix: t("unit.second.plural"),
|
||||||
},
|
},
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
@@ -51,9 +81,8 @@ export const DetectionSensor = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "stateBroadcastSecs",
|
name: "stateBroadcastSecs",
|
||||||
label: "State Broadcast Seconds",
|
label: t("detectionSensor.stateBroadcastSecs.label"),
|
||||||
description:
|
description: t("detectionSensor.stateBroadcastSecs.description"),
|
||||||
"The interval in seconds of how often we should send a message to the mesh with the current state regardless of changes",
|
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -63,8 +92,8 @@ export const DetectionSensor = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "sendBell",
|
name: "sendBell",
|
||||||
label: "Send Bell",
|
label: t("detectionSensor.sendBell.label"),
|
||||||
description: "Send ASCII bell with alert message",
|
description: t("detectionSensor.sendBell.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -74,9 +103,8 @@ export const DetectionSensor = () => {
|
|||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
name: "name",
|
name: "name",
|
||||||
label: "Friendly Name",
|
label: t("detectionSensor.name.label"),
|
||||||
description:
|
description: t("detectionSensor.name.description"),
|
||||||
"Used to format the message sent to mesh, max 20 Characters",
|
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -86,8 +114,8 @@ export const DetectionSensor = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "monitorPin",
|
name: "monitorPin",
|
||||||
label: "Monitor Pin",
|
label: t("detectionSensor.monitorPin.label"),
|
||||||
description: "The GPIO pin to monitor for state changes",
|
description: t("detectionSensor.monitorPin.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -95,22 +123,27 @@ export const DetectionSensor = () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "select",
|
||||||
name: "detectionTriggeredHigh",
|
name: "detectionTriggerType",
|
||||||
label: "Detection Triggered High",
|
label: t("detectionSensor.detectionTriggerType.label"),
|
||||||
description:
|
description: t(
|
||||||
"Whether or not the GPIO pin state detection is triggered on HIGH (1), otherwise LOW (0)",
|
"detectionSensor.detectionTriggerType.description",
|
||||||
|
),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
properties: {
|
||||||
|
enumValue: Protobuf.ModuleConfig
|
||||||
|
.ModuleConfig_DetectionSensorConfig_TriggerType,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "usePullup",
|
name: "usePullup",
|
||||||
label: "Use Pullup",
|
label: t("detectionSensor.usePullup.label"),
|
||||||
description: "Whether or not use INPUT_PULLUP mode for GPIO pin",
|
description: t("detectionSensor.usePullup.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
|
|||||||
@@ -1,13 +1,38 @@
|
|||||||
import type { ExternalNotificationValidation } from "@app/validation/moduleConfig/externalNotification.tsx";
|
import {
|
||||||
|
type ExternalNotificationValidation,
|
||||||
|
ExternalNotificationValidationSchema,
|
||||||
|
} from "@app/validation/moduleConfig/externalNotification.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const ExternalNotification = () => {
|
interface ExternalNotificationModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<ExternalNotificationValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExternalNotification = (
|
||||||
|
{ onFormInit }: ExternalNotificationModuleConfigProps,
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: ExternalNotificationValidation) => {
|
const onSubmit = (data: ExternalNotificationValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.externalNotification, data, true)) {
|
||||||
|
removeWorkingModuleConfig("externalNotification");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -21,23 +46,27 @@ export const ExternalNotification = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<ExternalNotificationValidation>
|
<DynamicForm<ExternalNotificationValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
|
validationSchema={ExternalNotificationValidationSchema}
|
||||||
|
formId="ModuleConfig_ExternalNotificationConfig"
|
||||||
defaultValues={moduleConfig.externalNotification}
|
defaultValues={moduleConfig.externalNotification}
|
||||||
|
values={getEffectiveModuleConfig("externalNotification")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "External Notification Settings",
|
label: t("externalNotification.title"),
|
||||||
description: "Configure the external notification module",
|
description: t("externalNotification.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "enabled",
|
name: "enabled",
|
||||||
label: "Module Enabled",
|
label: t("externalNotification.enabled.label"),
|
||||||
description: "Enable External Notification",
|
description: t("externalNotification.enabled.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "outputMs",
|
name: "outputMs",
|
||||||
label: "Output MS",
|
label: t("externalNotification.outputMs.label"),
|
||||||
description: "Output MS",
|
description: t("externalNotification.outputMs.description"),
|
||||||
|
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
@@ -45,14 +74,14 @@ export const ExternalNotification = () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "ms",
|
suffix: t("unit.millisecond.suffix"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "output",
|
name: "output",
|
||||||
label: "Output",
|
label: t("externalNotification.output.label"),
|
||||||
description: "Output",
|
description: t("externalNotification.output.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -62,8 +91,8 @@ export const ExternalNotification = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "outputVibra",
|
name: "outputVibra",
|
||||||
label: "Output Vibrate",
|
label: t("externalNotification.outputVibra.label"),
|
||||||
description: "Output Vibrate",
|
description: t("externalNotification.outputVibra.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -73,8 +102,8 @@ export const ExternalNotification = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "outputBuzzer",
|
name: "outputBuzzer",
|
||||||
label: "Output Buzzer",
|
label: t("externalNotification.outputBuzzer.label"),
|
||||||
description: "Output Buzzer",
|
description: t("externalNotification.outputBuzzer.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -84,8 +113,8 @@ export const ExternalNotification = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "active",
|
name: "active",
|
||||||
label: "Active",
|
label: t("externalNotification.active.label"),
|
||||||
description: "Active",
|
description: t("externalNotification.active.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -95,8 +124,8 @@ export const ExternalNotification = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "alertMessage",
|
name: "alertMessage",
|
||||||
label: "Alert Message",
|
label: t("externalNotification.alertMessage.label"),
|
||||||
description: "Alert Message",
|
description: t("externalNotification.alertMessage.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -106,8 +135,10 @@ export const ExternalNotification = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "alertMessageVibra",
|
name: "alertMessageVibra",
|
||||||
label: "Alert Message Vibrate",
|
label: t("externalNotification.alertMessageVibra.label"),
|
||||||
description: "Alert Message Vibrate",
|
description: t(
|
||||||
|
"externalNotification.alertMessageVibra.description",
|
||||||
|
),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -117,8 +148,10 @@ export const ExternalNotification = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "alertMessageBuzzer",
|
name: "alertMessageBuzzer",
|
||||||
label: "Alert Message Buzzer",
|
label: t("externalNotification.alertMessageBuzzer.label"),
|
||||||
description: "Alert Message Buzzer",
|
description: t(
|
||||||
|
"externalNotification.alertMessageBuzzer.description",
|
||||||
|
),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -128,9 +161,8 @@ export const ExternalNotification = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "alertBell",
|
name: "alertBell",
|
||||||
label: "Alert Bell",
|
label: t("externalNotification.alertBell.label"),
|
||||||
description:
|
description: t("externalNotification.alertBell.description"),
|
||||||
"Should an alert be triggered when receiving an incoming bell?",
|
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -140,8 +172,8 @@ export const ExternalNotification = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "alertBellVibra",
|
name: "alertBellVibra",
|
||||||
label: "Alert Bell Vibrate",
|
label: t("externalNotification.alertBellVibra.label"),
|
||||||
description: "Alert Bell Vibrate",
|
description: t("externalNotification.alertBellVibra.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -151,8 +183,10 @@ export const ExternalNotification = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "alertBellBuzzer",
|
name: "alertBellBuzzer",
|
||||||
label: "Alert Bell Buzzer",
|
label: t("externalNotification.alertBellBuzzer.label"),
|
||||||
description: "Alert Bell Buzzer",
|
description: t(
|
||||||
|
"externalNotification.alertBellBuzzer.description",
|
||||||
|
),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -162,8 +196,8 @@ export const ExternalNotification = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "usePwm",
|
name: "usePwm",
|
||||||
label: "Use PWM",
|
label: t("externalNotification.usePwm.label"),
|
||||||
description: "Use PWM",
|
description: t("externalNotification.usePwm.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -173,8 +207,8 @@ export const ExternalNotification = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "nagTimeout",
|
name: "nagTimeout",
|
||||||
label: "Nag Timeout",
|
label: t("externalNotification.nagTimeout.label"),
|
||||||
description: "Nag Timeout",
|
description: t("externalNotification.nagTimeout.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -184,8 +218,8 @@ export const ExternalNotification = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "useI2sAsBuzzer",
|
name: "useI2sAsBuzzer",
|
||||||
label: "Use I²S Pin as Buzzer",
|
label: t("externalNotification.useI2sAsBuzzer.label"),
|
||||||
description: "Designate I²S Pin as Buzzer Output",
|
description: t("externalNotification.useI2sAsBuzzer.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
|
|||||||
@@ -1,50 +1,91 @@
|
|||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import type { MqttValidation } from "@app/validation/moduleConfig/mqtt.tsx";
|
import {
|
||||||
|
type MqttValidation,
|
||||||
|
MqttValidationSchema,
|
||||||
|
} from "@app/validation/moduleConfig/mqtt.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const MQTT = () => {
|
interface MqttModuleConfigProps {
|
||||||
const { config, moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<MqttValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
|
||||||
|
const {
|
||||||
|
config,
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: MqttValidation) => {
|
const onSubmit = (data: MqttValidation) => {
|
||||||
setWorkingModuleConfig(
|
const payload = {
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
|
||||||
payloadVariant: {
|
|
||||||
case: "mqtt",
|
|
||||||
value: {
|
|
||||||
...data,
|
...data,
|
||||||
mapReportSettings: create(
|
mapReportSettings: create(
|
||||||
Protobuf.ModuleConfig.ModuleConfig_MapReportSettingsSchema,
|
Protobuf.ModuleConfig.ModuleConfig_MapReportSettingsSchema,
|
||||||
data.mapReportSettings,
|
data.mapReportSettings,
|
||||||
),
|
),
|
||||||
},
|
};
|
||||||
|
|
||||||
|
if (deepCompareConfig(moduleConfig.mqtt, payload, true)) {
|
||||||
|
removeWorkingModuleConfig("mqtt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWorkingModuleConfig(
|
||||||
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
|
payloadVariant: {
|
||||||
|
case: "mqtt",
|
||||||
|
value: payload,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const populateDefaultValues = (
|
||||||
|
cfg: Protobuf.ModuleConfig.ModuleConfig_MQTTConfig | undefined,
|
||||||
|
) => {
|
||||||
|
return cfg
|
||||||
|
? {
|
||||||
|
...cfg,
|
||||||
|
mapReportSettings: cfg.mapReportSettings ??
|
||||||
|
{ publishIntervalSecs: 0, positionPrecision: 10 },
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicForm<MqttValidation>
|
<DynamicForm<MqttValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
defaultValues={moduleConfig.mqtt}
|
onFormInit={onFormInit}
|
||||||
|
validationSchema={MqttValidationSchema}
|
||||||
|
formId="ModuleConfig_MqttConfig"
|
||||||
|
defaultValues={populateDefaultValues(moduleConfig.mqtt)}
|
||||||
|
values={populateDefaultValues(getEffectiveModuleConfig("mqtt"))}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "MQTT Settings",
|
label: t("mqtt.title"),
|
||||||
description: "Settings for the MQTT module",
|
description: t("mqtt.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "enabled",
|
name: "enabled",
|
||||||
label: "Enabled",
|
label: t("mqtt.enabled.label"),
|
||||||
description: "Enable or disable MQTT",
|
description: t("mqtt.enabled.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
name: "address",
|
name: "address",
|
||||||
label: "MQTT Server Address",
|
label: t("mqtt.address.label"),
|
||||||
description:
|
description: t("mqtt.address.description"),
|
||||||
"MQTT server address to use for default/custom servers",
|
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -54,8 +95,8 @@ export const MQTT = () => {
|
|||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
name: "username",
|
name: "username",
|
||||||
label: "MQTT Username",
|
label: t("mqtt.username.label"),
|
||||||
description: "MQTT username to use for default/custom servers",
|
description: t("mqtt.username.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -65,8 +106,8 @@ export const MQTT = () => {
|
|||||||
{
|
{
|
||||||
type: "password",
|
type: "password",
|
||||||
name: "password",
|
name: "password",
|
||||||
label: "MQTT Password",
|
label: t("mqtt.password.label"),
|
||||||
description: "MQTT password to use for default/custom servers",
|
description: t("mqtt.password.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -76,9 +117,8 @@ export const MQTT = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "encryptionEnabled",
|
name: "encryptionEnabled",
|
||||||
label: "Encryption Enabled",
|
label: t("mqtt.encryptionEnabled.label"),
|
||||||
description:
|
description: t("mqtt.encryptionEnabled.description"),
|
||||||
"Enable or disable MQTT encryption. Note: All messages are sent to the MQTT broker unencrypted if this option is not enabled, even when your uplink channels have encryption keys set. This includes position data.",
|
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -88,8 +128,8 @@ export const MQTT = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "jsonEnabled",
|
name: "jsonEnabled",
|
||||||
label: "JSON Enabled",
|
label: t("mqtt.jsonEnabled.label"),
|
||||||
description: "Whether to send/consume JSON packets on MQTT",
|
description: t("mqtt.jsonEnabled.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -99,8 +139,8 @@ export const MQTT = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "tlsEnabled",
|
name: "tlsEnabled",
|
||||||
label: "TLS Enabled",
|
label: t("mqtt.tlsEnabled.label"),
|
||||||
description: "Enable or disable TLS",
|
description: t("mqtt.tlsEnabled.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -110,8 +150,8 @@ export const MQTT = () => {
|
|||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
name: "root",
|
name: "root",
|
||||||
label: "Root topic",
|
label: t("mqtt.root.label"),
|
||||||
description: "MQTT root topic to use for default/custom servers",
|
description: t("mqtt.root.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -121,9 +161,8 @@ export const MQTT = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "proxyToClientEnabled",
|
name: "proxyToClientEnabled",
|
||||||
label: "Proxy to Client Enabled",
|
label: t("mqtt.proxyToClientEnabled.label"),
|
||||||
description:
|
description: t("mqtt.proxyToClientEnabled.description"),
|
||||||
"Use the client's internet connection for MQTT (feature only active in mobile apps)",
|
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -133,8 +172,8 @@ export const MQTT = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "mapReportingEnabled",
|
name: "mapReportingEnabled",
|
||||||
label: "Map Reporting Enabled",
|
label: t("mqtt.mapReportingEnabled.label"),
|
||||||
description: "Enable or disable map reporting",
|
description: t("mqtt.mapReportingEnabled.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -144,10 +183,12 @@ export const MQTT = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "mapReportSettings.publishIntervalSecs",
|
name: "mapReportSettings.publishIntervalSecs",
|
||||||
label: "Map Report Publish Interval (s)",
|
label: t("mqtt.mapReportSettings.publishIntervalSecs.label"),
|
||||||
description: "Interval in seconds to publish map reports",
|
description: t(
|
||||||
|
"mqtt.mapReportSettings.publishIntervalSecs.description",
|
||||||
|
),
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "Seconds",
|
suffix: t("unit.second.plural"),
|
||||||
},
|
},
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
@@ -161,34 +202,77 @@ export const MQTT = () => {
|
|||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "mapReportSettings.positionPrecision",
|
name: "mapReportSettings.positionPrecision",
|
||||||
label: "Approximate Location",
|
label: t(
|
||||||
description:
|
"mqtt.mapReportSettings.positionPrecision.label",
|
||||||
"Position shared will be accurate within this distance",
|
),
|
||||||
|
description: t(
|
||||||
|
"mqtt.mapReportSettings.positionPrecision.description",
|
||||||
|
),
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: config.display?.units === 0
|
enumValue: config.display?.units === 0
|
||||||
? {
|
? {
|
||||||
"Within 23 km": 10,
|
[
|
||||||
"Within 12 km": 11,
|
t("mqtt.mapReportSettings.positionPrecision.options.metric_km23")
|
||||||
"Within 5.8 km": 12,
|
]: 10,
|
||||||
"Within 2.9 km": 13,
|
[
|
||||||
"Within 1.5 km": 14,
|
t("mqtt.mapReportSettings.positionPrecision.options.metric_km12")
|
||||||
"Within 700 m": 15,
|
]: 11,
|
||||||
"Within 350 m": 16,
|
[
|
||||||
"Within 200 m": 17,
|
t("mqtt.mapReportSettings.positionPrecision.options.metric_km5_8")
|
||||||
"Within 90 m": 18,
|
]: 12,
|
||||||
"Within 50 m": 19,
|
[
|
||||||
|
t("mqtt.mapReportSettings.positionPrecision.options.metric_km2_9")
|
||||||
|
]: 13,
|
||||||
|
[
|
||||||
|
t("mqtt.mapReportSettings.positionPrecision.options.metric_km1_5")
|
||||||
|
]: 14,
|
||||||
|
[
|
||||||
|
t("mqtt.mapReportSettings.positionPrecision.options.metric_m700")
|
||||||
|
]: 15,
|
||||||
|
[
|
||||||
|
t("mqtt.mapReportSettings.positionPrecision.options.metric_m350")
|
||||||
|
]: 16,
|
||||||
|
[
|
||||||
|
t("mqtt.mapReportSettings.positionPrecision.options.metric_m200")
|
||||||
|
]: 17,
|
||||||
|
[
|
||||||
|
t("mqtt.mapReportSettings.positionPrecision.options.metric_m90")
|
||||||
|
]: 18,
|
||||||
|
[
|
||||||
|
t("mqtt.mapReportSettings.positionPrecision.options.metric_m50")
|
||||||
|
]: 19,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
"Within 15 miles": 10,
|
[
|
||||||
"Within 7.3 miles": 11,
|
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi15")
|
||||||
"Within 3.6 miles": 12,
|
]: 10,
|
||||||
"Within 1.8 miles": 13,
|
[
|
||||||
"Within 0.9 miles": 14,
|
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi7_3")
|
||||||
"Within 0.5 miles": 15,
|
]: 11,
|
||||||
"Within 0.2 miles": 16,
|
[
|
||||||
"Within 600 feet": 17,
|
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi3_6")
|
||||||
"Within 300 feet": 18,
|
]: 12,
|
||||||
"Within 150 feet": 19,
|
[
|
||||||
|
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi1_8")
|
||||||
|
]: 13,
|
||||||
|
[
|
||||||
|
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_9")
|
||||||
|
]: 14,
|
||||||
|
[
|
||||||
|
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_5")
|
||||||
|
]: 15,
|
||||||
|
[
|
||||||
|
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_2")
|
||||||
|
]: 16,
|
||||||
|
[
|
||||||
|
t("mqtt.mapReportSettings.positionPrecision.options.imperial_ft600")
|
||||||
|
]: 17,
|
||||||
|
[
|
||||||
|
t("mqtt.mapReportSettings.positionPrecision.options.imperial_ft300")
|
||||||
|
]: 18,
|
||||||
|
[
|
||||||
|
t("mqtt.mapReportSettings.positionPrecision.options.imperial_ft150")
|
||||||
|
]: 19,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
|
|||||||
@@ -1,13 +1,36 @@
|
|||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import type { NeighborInfoValidation } from "@app/validation/moduleConfig/neighborInfo.tsx";
|
import {
|
||||||
|
type NeighborInfoValidation,
|
||||||
|
NeighborInfoValidationSchema,
|
||||||
|
} from "@app/validation/moduleConfig/neighborInfo.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const NeighborInfo = () => {
|
interface NeighborInfoModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<NeighborInfoValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NeighborInfo = ({ onFormInit }: NeighborInfoModuleConfigProps) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: NeighborInfoValidation) => {
|
const onSubmit = (data: NeighborInfoValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.neighborInfo, data, true)) {
|
||||||
|
removeWorkingModuleConfig("neighborInfo");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -21,26 +44,29 @@ export const NeighborInfo = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<NeighborInfoValidation>
|
<DynamicForm<NeighborInfoValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
|
validationSchema={NeighborInfoValidationSchema}
|
||||||
|
formId="ModuleConfig_NeighborInfoConfig"
|
||||||
defaultValues={moduleConfig.neighborInfo}
|
defaultValues={moduleConfig.neighborInfo}
|
||||||
|
values={getEffectiveModuleConfig("neighborInfo")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "Neighbor Info Settings",
|
label: t("neighborInfo.title"),
|
||||||
description: "Settings for the Neighbor Info module",
|
description: t("neighborInfo.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "enabled",
|
name: "enabled",
|
||||||
label: "Enabled",
|
label: t("neighborInfo.enabled.label"),
|
||||||
description: "Enable or disable Neighbor Info Module",
|
description: t("neighborInfo.enabled.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "updateInterval",
|
name: "updateInterval",
|
||||||
label: "Update Interval",
|
label: t("neighborInfo.updateInterval.label"),
|
||||||
description:
|
description: t("neighborInfo.updateInterval.description"),
|
||||||
"Interval in seconds of how often we should try to send our Neighbor Info to the mesh",
|
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "Seconds",
|
suffix: t("unit.second.plural"),
|
||||||
},
|
},
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,13 +1,36 @@
|
|||||||
import type { PaxcounterValidation } from "@app/validation/moduleConfig/paxcounter.tsx";
|
import {
|
||||||
|
type PaxcounterValidation,
|
||||||
|
PaxcounterValidationSchema,
|
||||||
|
} from "@app/validation/moduleConfig/paxcounter.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Paxcounter = () => {
|
interface PaxcounterModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<PaxcounterValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Paxcounter = ({ onFormInit }: PaxcounterModuleConfigProps) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: PaxcounterValidation) => {
|
const onSubmit = (data: PaxcounterValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.paxcounter, data, true)) {
|
||||||
|
removeWorkingModuleConfig("paxcounter");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -21,26 +44,29 @@ export const Paxcounter = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<PaxcounterValidation>
|
<DynamicForm<PaxcounterValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
|
validationSchema={PaxcounterValidationSchema}
|
||||||
|
formId="ModuleConfig_PaxcounterConfig"
|
||||||
defaultValues={moduleConfig.paxcounter}
|
defaultValues={moduleConfig.paxcounter}
|
||||||
|
values={getEffectiveModuleConfig("paxcounter")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "Paxcounter Settings",
|
label: t("paxcounter.title"),
|
||||||
description: "Settings for the Paxcounter module",
|
description: t("paxcounter.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "enabled",
|
name: "enabled",
|
||||||
label: "Module Enabled",
|
label: t("paxcounter.enabled.label"),
|
||||||
description: "Enable Paxcounter",
|
description: t("paxcounter.enabled.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "paxcounterUpdateInterval",
|
name: "paxcounterUpdateInterval",
|
||||||
label: "Update Interval (seconds)",
|
label: t("paxcounter.paxcounterUpdateInterval.label"),
|
||||||
description:
|
description: t("paxcounter.paxcounterUpdateInterval.description"),
|
||||||
"How long to wait between sending paxcounter packets",
|
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "Seconds",
|
suffix: t("unit.second.plural"),
|
||||||
},
|
},
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
@@ -48,6 +74,28 @@ export const Paxcounter = () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "number",
|
||||||
|
name: "wifiThreshold",
|
||||||
|
label: t("paxcounter.wifiThreshold.label"),
|
||||||
|
description: t("paxcounter.wifiThreshold.description"),
|
||||||
|
disabledBy: [
|
||||||
|
{
|
||||||
|
fieldName: "enabled",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "number",
|
||||||
|
name: "bleThreshold",
|
||||||
|
label: t("paxcounter.bleThreshold.label"),
|
||||||
|
description: t("paxcounter.bleThreshold.description"),
|
||||||
|
disabledBy: [
|
||||||
|
{
|
||||||
|
fieldName: "enabled",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -1,13 +1,36 @@
|
|||||||
import type { RangeTestValidation } from "@app/validation/moduleConfig/rangeTest.tsx";
|
import {
|
||||||
|
type RangeTestValidation,
|
||||||
|
RangeTestValidationSchema,
|
||||||
|
} from "@app/validation/moduleConfig/rangeTest.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const RangeTest = () => {
|
interface RangeTestModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<RangeTestValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: RangeTestValidation) => {
|
const onSubmit = (data: RangeTestValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.rangeTest, data, true)) {
|
||||||
|
removeWorkingModuleConfig("rangeTest");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -21,25 +44,29 @@ export const RangeTest = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<RangeTestValidation>
|
<DynamicForm<RangeTestValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
|
validationSchema={RangeTestValidationSchema}
|
||||||
|
formId="ModuleConfig_RangeTestConfig"
|
||||||
defaultValues={moduleConfig.rangeTest}
|
defaultValues={moduleConfig.rangeTest}
|
||||||
|
values={getEffectiveModuleConfig("rangeTest")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "Range Test Settings",
|
label: t("rangeTest.title"),
|
||||||
description: "Settings for the Range Test module",
|
description: t("rangeTest.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "enabled",
|
name: "enabled",
|
||||||
label: "Module Enabled",
|
label: t("rangeTest.enabled.label"),
|
||||||
description: "Enable Range Test",
|
description: t("rangeTest.enabled.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "sender",
|
name: "sender",
|
||||||
label: "Message Interval",
|
label: t("rangeTest.sender.label"),
|
||||||
description: "How long to wait between sending test packets",
|
description: t("rangeTest.sender.description"),
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "Seconds",
|
suffix: t("unit.second.plural"),
|
||||||
},
|
},
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
@@ -50,8 +77,8 @@ export const RangeTest = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "save",
|
name: "save",
|
||||||
label: "Save CSV to storage",
|
label: t("rangeTest.save.label"),
|
||||||
description: "ESP32 Only",
|
description: t("rangeTest.save.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
|
|||||||
@@ -1,13 +1,36 @@
|
|||||||
import type { SerialValidation } from "@app/validation/moduleConfig/serial.tsx";
|
import {
|
||||||
|
type SerialValidation,
|
||||||
|
SerialValidationSchema,
|
||||||
|
} from "@app/validation/moduleConfig/serial.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Serial = () => {
|
interface SerialModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<SerialValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Serial = ({ onFormInit }: SerialModuleConfigProps) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: SerialValidation) => {
|
const onSubmit = (data: SerialValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.serial, data, true)) {
|
||||||
|
removeWorkingModuleConfig("serial");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -21,24 +44,27 @@ export const Serial = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<SerialValidation>
|
<DynamicForm<SerialValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
|
validationSchema={SerialValidationSchema}
|
||||||
|
formId="ModuleConfig_SerialConfig"
|
||||||
defaultValues={moduleConfig.serial}
|
defaultValues={moduleConfig.serial}
|
||||||
|
values={getEffectiveModuleConfig("serial")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "Serial Settings",
|
label: t("serial.title"),
|
||||||
description: "Settings for the Serial module",
|
description: t("serial.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "enabled",
|
name: "enabled",
|
||||||
label: "Module Enabled",
|
label: t("serial.enabled.label"),
|
||||||
description: "Enable Serial output",
|
description: t("serial.enabled.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "echo",
|
name: "echo",
|
||||||
label: "Echo",
|
label: t("serial.echo.label"),
|
||||||
description:
|
description: t("serial.echo.description"),
|
||||||
"Any packets you send will be echoed back to your device",
|
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -48,8 +74,8 @@ export const Serial = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "rxd",
|
name: "rxd",
|
||||||
label: "Receive Pin",
|
label: t("serial.rxd.label"),
|
||||||
description: "Set the GPIO pin to the RXD pin you have set up.",
|
description: t("serial.rxd.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -59,8 +85,8 @@ export const Serial = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "txd",
|
name: "txd",
|
||||||
label: "Transmit Pin",
|
label: t("serial.txd.label"),
|
||||||
description: "Set the GPIO pin to the TXD pin you have set up.",
|
description: t("serial.txd.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -70,8 +96,8 @@ export const Serial = () => {
|
|||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "baud",
|
name: "baud",
|
||||||
label: "Baud Rate",
|
label: t("serial.baud.label"),
|
||||||
description: "The serial baud rate",
|
description: t("serial.baud.description"),
|
||||||
|
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
@@ -86,24 +112,22 @@ export const Serial = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "timeout",
|
name: "timeout",
|
||||||
label: "Timeout",
|
label: t("serial.timeout.label"),
|
||||||
|
description: t("serial.timeout.description"),
|
||||||
description:
|
|
||||||
"Seconds to wait before we consider your packet as 'done'",
|
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "Seconds",
|
suffix: t("unit.second.plural"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
name: "mode",
|
name: "mode",
|
||||||
label: "Mode",
|
label: t("serial.mode.label"),
|
||||||
description: "Select Mode",
|
description: t("serial.mode.description"),
|
||||||
|
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
@@ -119,9 +143,8 @@ export const Serial = () => {
|
|||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "overrideConsoleSerialPort",
|
name: "overrideConsoleSerialPort",
|
||||||
label: "Override Console Serial Port",
|
label: t("serial.overrideConsoleSerialPort.label"),
|
||||||
description:
|
description: t("serial.overrideConsoleSerialPort.description"),
|
||||||
"If you have a serial port connected to the console, this will override it.",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,36 @@
|
|||||||
import type { StoreForwardValidation } from "@app/validation/moduleConfig/storeForward.ts";
|
import {
|
||||||
|
type StoreForwardValidation,
|
||||||
|
StoreForwardValidationSchema,
|
||||||
|
} from "@app/validation/moduleConfig/storeForward.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const StoreForward = () => {
|
interface StoreForwardModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<StoreForwardValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StoreForward = ({ onFormInit }: StoreForwardModuleConfigProps) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: StoreForwardValidation) => {
|
const onSubmit = (data: StoreForwardValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.storeForward, data, true)) {
|
||||||
|
removeWorkingModuleConfig("storeForward");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -21,23 +44,27 @@ export const StoreForward = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<StoreForwardValidation>
|
<DynamicForm<StoreForwardValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
|
validationSchema={StoreForwardValidationSchema}
|
||||||
|
formId="ModuleConfig_StoreForwardConfig"
|
||||||
defaultValues={moduleConfig.storeForward}
|
defaultValues={moduleConfig.storeForward}
|
||||||
|
values={getEffectiveModuleConfig("storeForward")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "Store & Forward Settings",
|
label: t("storeForward.title"),
|
||||||
description: "Settings for the Store & Forward module",
|
description: t("storeForward.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "enabled",
|
name: "enabled",
|
||||||
label: "Module Enabled",
|
label: t("storeForward.enabled.label"),
|
||||||
description: "Enable Store & Forward",
|
description: t("storeForward.enabled.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "heartbeat",
|
name: "heartbeat",
|
||||||
label: "Heartbeat Enabled",
|
label: t("storeForward.heartbeat.label"),
|
||||||
description: "Enable Store & Forward heartbeat",
|
description: t("storeForward.heartbeat.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -47,23 +74,22 @@ export const StoreForward = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "records",
|
name: "records",
|
||||||
label: "Number of records",
|
label: t("storeForward.records.label"),
|
||||||
description: "Number of records to store",
|
description: t("storeForward.records.description"),
|
||||||
|
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "Records",
|
suffix: t("unit.record.plural"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "historyReturnMax",
|
name: "historyReturnMax",
|
||||||
label: "History return max",
|
label: t("storeForward.historyReturnMax.label"),
|
||||||
description: "Max number of records to return",
|
description: t("storeForward.historyReturnMax.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -73,8 +99,8 @@ export const StoreForward = () => {
|
|||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "historyReturnWindow",
|
name: "historyReturnWindow",
|
||||||
label: "History return window",
|
label: t("storeForward.historyReturnWindow.label"),
|
||||||
description: "Max number of records to return",
|
description: t("storeForward.historyReturnWindow.description"),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
|
|||||||
@@ -1,13 +1,36 @@
|
|||||||
import type { TelemetryValidation } from "@app/validation/moduleConfig/telemetry.ts";
|
import {
|
||||||
|
type TelemetryValidation,
|
||||||
|
TelemetryValidationSchema,
|
||||||
|
} from "@app/validation/moduleConfig/telemetry.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Telemetry = () => {
|
interface TelemetryModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<TelemetryValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: TelemetryValidation) => {
|
const onSubmit = (data: TelemetryValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.telemetry, data, true)) {
|
||||||
|
removeWorkingModuleConfig("telemetry");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -21,77 +44,85 @@ export const Telemetry = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<TelemetryValidation>
|
<DynamicForm<TelemetryValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
|
validationSchema={TelemetryValidationSchema}
|
||||||
|
formId="ModuleConfig_TelemetryConfig"
|
||||||
defaultValues={moduleConfig.telemetry}
|
defaultValues={moduleConfig.telemetry}
|
||||||
|
values={getEffectiveModuleConfig("telemetry")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: "Telemetry Settings",
|
label: t("telemetry.title"),
|
||||||
description: "Settings for the Telemetry module",
|
description: t("telemetry.description"),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "deviceUpdateInterval",
|
name: "deviceUpdateInterval",
|
||||||
label: "Device Metrics",
|
label: t("telemetry.deviceUpdateInterval.label"),
|
||||||
description: "Device metrics update interval (seconds)",
|
description: t("telemetry.deviceUpdateInterval.description"),
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "Seconds",
|
suffix: t("unit.second.plural"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "environmentUpdateInterval",
|
name: "environmentUpdateInterval",
|
||||||
label: "Environment metrics update interval (seconds)",
|
label: t("telemetry.environmentUpdateInterval.label"),
|
||||||
description: "",
|
description: t("telemetry.environmentUpdateInterval.description"),
|
||||||
properties: {
|
properties: {
|
||||||
suffix: "Seconds",
|
suffix: t("unit.second.plural"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "environmentMeasurementEnabled",
|
name: "environmentMeasurementEnabled",
|
||||||
label: "Module Enabled",
|
label: t("telemetry.environmentMeasurementEnabled.label"),
|
||||||
description: "Enable the Environment Telemetry",
|
description: t(
|
||||||
|
"telemetry.environmentMeasurementEnabled.description",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "environmentScreenEnabled",
|
name: "environmentScreenEnabled",
|
||||||
label: "Displayed on Screen",
|
label: t("telemetry.environmentScreenEnabled.label"),
|
||||||
description: "Show the Telemetry Module on the OLED",
|
description: t("telemetry.environmentScreenEnabled.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "environmentDisplayFahrenheit",
|
name: "environmentDisplayFahrenheit",
|
||||||
label: "Display Fahrenheit",
|
label: t("telemetry.environmentDisplayFahrenheit.label"),
|
||||||
description: "Display temp in Fahrenheit",
|
description: t(
|
||||||
|
"telemetry.environmentDisplayFahrenheit.description",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "airQualityEnabled",
|
name: "airQualityEnabled",
|
||||||
label: "Air Quality Enabled",
|
label: t("telemetry.airQualityEnabled.label"),
|
||||||
description: "Enable the Air Quality Telemetry",
|
description: t("telemetry.airQualityEnabled.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "airQualityInterval",
|
name: "airQualityInterval",
|
||||||
label: "Air Quality Update Interval",
|
label: t("telemetry.airQualityInterval.label"),
|
||||||
description: "How often to send Air Quality data over the mesh",
|
description: t("telemetry.airQualityInterval.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "powerMeasurementEnabled",
|
name: "powerMeasurementEnabled",
|
||||||
label: "Power Measurement Enabled",
|
label: t("telemetry.powerMeasurementEnabled.label"),
|
||||||
description: "Enable the Power Measurement Telemetry",
|
description: t("telemetry.powerMeasurementEnabled.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "number",
|
type: "number",
|
||||||
name: "powerUpdateInterval",
|
name: "powerUpdateInterval",
|
||||||
label: "Power Update Interval",
|
label: t("telemetry.powerUpdateInterval.label"),
|
||||||
description: "How often to send Power data over the mesh",
|
description: t("telemetry.powerUpdateInterval.description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "powerScreenEnabled",
|
name: "powerScreenEnabled",
|
||||||
label: "Power Screen Enabled",
|
label: t("telemetry.powerScreenEnabled.label"),
|
||||||
description: "Enable the Power Telemetry Screen",
|
description: t("telemetry.powerScreenEnabled.description"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import { ErrorPage } from "@components/UI/ErrorPage.tsx";
|
|||||||
|
|
||||||
export interface ActionItem {
|
export interface ActionItem {
|
||||||
key: string;
|
key: string;
|
||||||
icon: LucideIcon;
|
icon?: LucideIcon;
|
||||||
iconClasses?: string;
|
iconClasses?: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageLayoutProps {
|
export interface PageLayoutProps {
|
||||||
@@ -69,27 +71,38 @@ export const PageLayout = ({
|
|||||||
<span className="text-lg font-medium text-foreground truncate px-2">
|
<span className="text-lg font-medium text-foreground truncate px-2">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center space-x-3 md:space-x-4 shrink-0">
|
<div className="flex items-center space-x-1 md:space-x-2 shrink-0 pr-6">
|
||||||
{actions?.map((action) => (
|
{actions?.map((action) => {
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={action.key}
|
key={action.key}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={action.disabled || action.isLoading}
|
disabled={action.disabled || action.isLoading}
|
||||||
className="text-foreground transition-colors hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
className={cn(
|
||||||
|
"flex items-center space-x-2 py-2 px-3 rounded-md",
|
||||||
|
"text-foreground transition-colors duration-200",
|
||||||
|
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||||
|
action.className,
|
||||||
|
)}
|
||||||
onClick={action.onClick}
|
onClick={action.onClick}
|
||||||
aria-label={action.ariaLabel || `Action ${action.key}`}
|
aria-label={action.ariaLabel || `Action ${action.key}`}
|
||||||
aria-disabled={action.disabled}
|
aria-disabled={action.disabled}
|
||||||
aria-busy={action.isLoading}
|
aria-busy={action.isLoading}
|
||||||
>
|
>
|
||||||
<div className="mr-6">
|
{action.icon &&
|
||||||
{action.isLoading ? <Spinner size="md" /> : (
|
(action.isLoading ? <Spinner size="md" /> : (
|
||||||
<action.icon
|
<action.icon
|
||||||
className={cn("h-5 w-5", action.iconClasses)}
|
className={cn("h-5 w-5", action.iconClasses)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
|
{action.label && (
|
||||||
|
<span className="text-sm px-1 pt-0.5">
|
||||||
|
{action.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -111,7 +124,7 @@ export const PageLayout = ({
|
|||||||
{rightBar && (
|
{rightBar && (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-48 lg:w-[270px] shrink-0 border-l border-slate-300 dark:border-slate-700 px-2 overflow-hidden",
|
"w-56 lg:w-[270px] text-balance shrink-0 border-l border-slate-300 dark:border-slate-700 px-2 overflow-hidden",
|
||||||
rightBarClassName,
|
rightBarClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
+74
-109
@@ -1,30 +1,26 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useState, useTransition } from "react";
|
||||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
||||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import type { Page } from "@core/stores/deviceStore.ts";
|
import type { Page } from "@core/stores/deviceStore.ts";
|
||||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CircleChevronLeft,
|
CircleChevronLeft,
|
||||||
CpuIcon,
|
|
||||||
LayersIcon,
|
LayersIcon,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
MapIcon,
|
MapIcon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
PenLine,
|
|
||||||
SearchIcon,
|
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
ZapIcon,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@core/utils/cn.ts";
|
import { cn } from "@core/utils/cn.ts";
|
||||||
import { useSidebar } from "@core/stores/sidebarStore.tsx";
|
import { useSidebar } from "@core/stores/sidebarStore.tsx";
|
||||||
import ThemeSwitcher from "@components/ThemeSwitcher.tsx";
|
|
||||||
import { useAppStore } from "@core/stores/appStore.ts";
|
import { useAppStore } from "@core/stores/appStore.ts";
|
||||||
import BatteryStatus from "@components/BatteryStatus.tsx";
|
|
||||||
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
|
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { DeviceInfoPanel } from "./DeviceInfoPanel.tsx";
|
||||||
|
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||||
|
|
||||||
export interface SidebarProps {
|
export interface SidebarProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -39,7 +35,10 @@ interface NavLink {
|
|||||||
|
|
||||||
const CollapseToggleButton = () => {
|
const CollapseToggleButton = () => {
|
||||||
const { isCollapsed, toggleSidebar } = useSidebar();
|
const { isCollapsed, toggleSidebar } = useSidebar();
|
||||||
const buttonLabel = isCollapsed ? "Open sidebar" : "Close sidebar";
|
const { t } = useTranslation("ui");
|
||||||
|
const buttonLabel = isCollapsed
|
||||||
|
? t("sidebar.collapseToggle.button.open")
|
||||||
|
: t("sidebar.collapseToggle.button.close");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -71,30 +70,59 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
|||||||
getNode,
|
getNode,
|
||||||
getNodesLength,
|
getNodesLength,
|
||||||
metadata,
|
metadata,
|
||||||
activePage,
|
|
||||||
unreadCounts,
|
unreadCounts,
|
||||||
setActivePage,
|
|
||||||
setDialogOpen,
|
setDialogOpen,
|
||||||
} = useDevice();
|
} = useDevice();
|
||||||
const { setCommandPaletteOpen } = useAppStore();
|
const { setCommandPaletteOpen } = useAppStore();
|
||||||
const myNode = getNode(hardware.myNodeNum);
|
const myNode = getNode(hardware.myNodeNum);
|
||||||
const { isCollapsed } = useSidebar();
|
const { isCollapsed } = useSidebar();
|
||||||
|
const { t } = useTranslation("ui");
|
||||||
|
const navigate = useNavigate({ from: "/" });
|
||||||
|
|
||||||
|
const pathname = useLocation({
|
||||||
|
select: (location) => location.pathname.replace(/^\//, ""),
|
||||||
|
});
|
||||||
|
|
||||||
const myMetadata = metadata.get(0);
|
const myMetadata = metadata.get(0);
|
||||||
|
|
||||||
const numUnread = [...unreadCounts.values()].reduce((sum, v) => sum + v, 0);
|
const numUnread = [...unreadCounts.values()].reduce((sum, v) => sum + v, 0);
|
||||||
|
|
||||||
|
const [displayedNodeCount, setDisplayedNodeCount] = useState(() =>
|
||||||
|
Math.max(getNodesLength() - 1, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [_, startNodeCountTransition] = useTransition();
|
||||||
|
|
||||||
|
const currentNodeCountValue = Math.max(getNodesLength() - 1, 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentNodeCountValue !== displayedNodeCount) {
|
||||||
|
startNodeCountTransition(() => {
|
||||||
|
setDisplayedNodeCount(currentNodeCountValue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [currentNodeCountValue, displayedNodeCount, startNodeCountTransition]);
|
||||||
|
|
||||||
const pages: NavLink[] = [
|
const pages: NavLink[] = [
|
||||||
{
|
{
|
||||||
name: "Messages",
|
name: t("navigation.messages"),
|
||||||
icon: MessageSquareIcon,
|
icon: MessageSquareIcon,
|
||||||
page: "messages",
|
page: "messages",
|
||||||
count: numUnread ? numUnread : undefined,
|
count: numUnread ? numUnread : undefined,
|
||||||
},
|
},
|
||||||
{ name: "Map", icon: MapIcon, page: "map" },
|
{ name: t("navigation.map"), icon: MapIcon, page: "map" },
|
||||||
{ name: "Config", icon: SettingsIcon, page: "config" },
|
|
||||||
{ name: "Channels", icon: LayersIcon, page: "channels" },
|
|
||||||
{
|
{
|
||||||
name: `Nodes (${Math.max(getNodesLength() - 1, 0)})`,
|
name: t("navigation.config"),
|
||||||
|
icon: SettingsIcon,
|
||||||
|
page: "config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t("navigation.channels"),
|
||||||
|
icon: LayersIcon,
|
||||||
|
page: "channels",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `${t("navigation.nodes")} (${displayedNodeCount})`,
|
||||||
icon: UsersIcon,
|
icon: UsersIcon,
|
||||||
page: "nodes",
|
page: "nodes",
|
||||||
},
|
},
|
||||||
@@ -105,7 +133,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"relative border-slate-300 dark:border-slate-700",
|
"relative border-slate-300 dark:border-slate-700",
|
||||||
"transition-all duration-300 ease-in-out flex-shrink-0",
|
"transition-all duration-300 ease-in-out flex-shrink-0",
|
||||||
isCollapsed ? "w-24" : "w-46 lg:w-64",
|
isCollapsed ? "w-24" : "w-52 lg:w-64",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CollapseToggleButton />
|
<CollapseToggleButton />
|
||||||
@@ -118,8 +146,8 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="Logo.svg"
|
src="/Logo.svg"
|
||||||
alt="Meshtastic Logo"
|
alt={t("app.logo")}
|
||||||
className="size-10 flex-shrink-0 rounded-xl"
|
className="size-10 flex-shrink-0 rounded-xl"
|
||||||
/>
|
/>
|
||||||
<h2
|
<h2
|
||||||
@@ -131,12 +159,16 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
|||||||
: "opacity-100 max-w-xs visible ml-2",
|
: "opacity-100 max-w-xs visible ml-2",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Meshtastic
|
{t("app.title")}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SidebarSection label="Navigation" className="mt-4 px-0">
|
<SidebarSection
|
||||||
{pages.map((link) => (
|
label={t("navigation.title")}
|
||||||
|
className="mt-4 px-0"
|
||||||
|
>
|
||||||
|
{pages.map((link) => {
|
||||||
|
return (
|
||||||
<SidebarButton
|
<SidebarButton
|
||||||
key={link.name}
|
key={link.name}
|
||||||
count={link.count}
|
count={link.count}
|
||||||
@@ -144,13 +176,14 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
|||||||
Icon={link.icon}
|
Icon={link.icon}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (myNode !== undefined) {
|
if (myNode !== undefined) {
|
||||||
setActivePage(link.page);
|
navigate({ to: `/${link.page}` });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
active={link.page === activePage}
|
active={link.page === pathname}
|
||||||
disabled={myNode === undefined}
|
disabled={myNode === undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -162,7 +195,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-4 border-t-[0.5px] bg-background-primary border-slate-300 dark:border-slate-700 flex-shrink-0">
|
<div className=" pt-4 border-t-[0.5px] bg-background-primary border-slate-300 dark:border-slate-700 h-full flex-1">
|
||||||
{myNode === undefined
|
{myNode === undefined
|
||||||
? (
|
? (
|
||||||
<div className="flex flex-col items-center justify-center py-6">
|
<div className="flex flex-col items-center justify-center py-6">
|
||||||
@@ -173,94 +206,26 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
|||||||
isCollapsed ? "opacity-0 invisible" : "opacity-100 visible",
|
isCollapsed ? "opacity-0 invisible" : "opacity-100 visible",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Loading...
|
{t("loading")}
|
||||||
</Subtle>
|
</Subtle>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<>
|
<DeviceInfoPanel
|
||||||
<div
|
isCollapsed={isCollapsed}
|
||||||
className={cn(
|
setCommandPaletteOpen={() => setCommandPaletteOpen(true)}
|
||||||
"flex place-items-center gap-2",
|
setDialogOpen={() => setDialogOpen("deviceName", true)}
|
||||||
isCollapsed && "justify-center",
|
user={{
|
||||||
)}
|
longName: myNode?.user?.longName ?? t("unknown.longName"),
|
||||||
>
|
shortName: myNode?.user?.shortName ?? t("unknown.shortName"),
|
||||||
<Avatar
|
}}
|
||||||
text={myNode.user?.shortName ?? myNode.num.toString()}
|
firmwareVersion={myMetadata?.firmwareVersion ??
|
||||||
className={cn("flex-shrink-0 ml-2", isCollapsed && "ml-0")}
|
t("unknown.notAvailable")}
|
||||||
size="sm"
|
deviceMetrics={{
|
||||||
|
batteryLevel: myNode.deviceMetrics?.batteryLevel,
|
||||||
|
voltage: myNode.deviceMetrics?.voltage,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"max-w-[20ch] text-wrap text-sm font-medium",
|
|
||||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
|
||||||
isCollapsed
|
|
||||||
? "opacity-0 max-w-0 invisible"
|
|
||||||
: "opacity-100 max-w-full visible",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{myNode.user?.longName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col gap-0.5 ml-2 mt-2",
|
|
||||||
"transition-all duration-300 ease-in-out",
|
|
||||||
isCollapsed
|
|
||||||
? "opacity-0 max-w-0 h-0 invisible"
|
|
||||||
: "opacity-100 max-w-xs h-auto visible",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="inline-flex gap-2">
|
|
||||||
<BatteryStatus deviceMetrics={myNode.deviceMetrics} />
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex gap-2">
|
|
||||||
<ZapIcon
|
|
||||||
size={18}
|
|
||||||
className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0"
|
|
||||||
/>
|
|
||||||
<Subtle>
|
|
||||||
{myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"}
|
|
||||||
{" "}
|
|
||||||
volts
|
|
||||||
</Subtle>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex gap-2">
|
|
||||||
<CpuIcon
|
|
||||||
size={18}
|
|
||||||
className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0"
|
|
||||||
/>
|
|
||||||
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center flex-shrink-0 ml-2",
|
|
||||||
"transition-all duration-300 ease-in-out",
|
|
||||||
isCollapsed
|
|
||||||
? "opacity-0 max-w-0 invisible pointer-events-none"
|
|
||||||
: "opacity-100 max-w-xs visible",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Edit device name"
|
|
||||||
className="p-1 rounded transition-colors hover:text-accent"
|
|
||||||
onClick={() => setDialogOpen("deviceName", true)}
|
|
||||||
>
|
|
||||||
<PenLine size={22} />
|
|
||||||
</button>
|
|
||||||
<ThemeSwitcher />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="transition-all hover:text-accent"
|
|
||||||
onClick={() => setCommandPaletteOpen(true)}
|
|
||||||
>
|
|
||||||
<SearchIcon />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
import { useTheme } from "../core/hooks/useTheme.ts";
|
import { useTheme } from "@core/hooks/useTheme.ts";
|
||||||
import { cn } from "../core/utils/cn.ts";
|
import { cn } from "@core/utils/cn.ts";
|
||||||
import { Monitor, Moon, Sun } from "lucide-react";
|
import { Monitor, Moon, Sun } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Subtle } from "./UI/Typography/Subtle.tsx";
|
||||||
|
import { Button } from "./UI/Button.tsx";
|
||||||
|
|
||||||
type ThemePreference = "light" | "dark" | "system";
|
type ThemePreference = "light" | "dark" | "system";
|
||||||
|
|
||||||
export default function ThemeSwitcher({
|
interface ThemeSwitcherProps {
|
||||||
className = "",
|
|
||||||
}: {
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
disableHover?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThemeSwitcher({
|
||||||
|
className: passedClassName = "",
|
||||||
|
disableHover = false,
|
||||||
|
}: ThemeSwitcherProps) {
|
||||||
const { preference, setPreference } = useTheme();
|
const { preference, setPreference } = useTheme();
|
||||||
|
const { t } = useTranslation("ui");
|
||||||
|
|
||||||
|
const iconBaseClass =
|
||||||
|
"size-4 flex-shrink-0 text-gray-500 dark:text-gray-400 transition-colors duration-150";
|
||||||
|
const iconHoverClass = !disableHover
|
||||||
|
? "group-hover:text-gray-700 dark:group-hover:text-gray-200"
|
||||||
|
: "";
|
||||||
|
const combinedIconClass = cn(iconBaseClass, iconHoverClass);
|
||||||
|
|
||||||
const themeIcons = {
|
const themeIcons = {
|
||||||
light: <Sun className="size-6" />,
|
light: <Sun className={combinedIconClass} />,
|
||||||
dark: <Moon className="size-6" />,
|
dark: <Moon className={combinedIconClass} />,
|
||||||
system: <Monitor className="size-6" />,
|
system: <Monitor className={combinedIconClass} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
@@ -24,26 +39,55 @@ export default function ThemeSwitcher({
|
|||||||
setPreference(nextPreference);
|
setPreference(nextPreference);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [firstCharOfPreference = "", ...restOfPreference] = preference;
|
const preferenceDisplayMap: Record<ThemePreference, string> = {
|
||||||
|
light: t("theme.light"),
|
||||||
|
dark: t("theme.dark"),
|
||||||
|
system: t("theme.system"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentDisplayPreference = preferenceDisplayMap[preference];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
className={cn(
|
|
||||||
"transition-all duration-300 scale-100 cursor-pointer m-3 p-2 focus:*:data-label:opacity-100",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
aria-description="Change current theme"
|
id="theme-switcher"
|
||||||
|
aria-label={t("theme.changeTheme")}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex justify-start",
|
||||||
|
"gap-2.5 p-1.5 rounded-md transition-colors duration-150",
|
||||||
|
"cursor-pointer",
|
||||||
|
!disableHover && "hover:bg-gray-100 dark:hover:bg-gray-700",
|
||||||
|
"focus:*:data-label:opacity-100",
|
||||||
|
passedClassName,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
data-label
|
data-label="theme-preference-tooltip"
|
||||||
className="transition-all block absolute w-full mb-auto mt-auto ml-0 mr-0 text-xs left-0 -top-3 opacity-0 rounded-lg"
|
className={cn(
|
||||||
|
"transition-opacity duration-150",
|
||||||
|
"block absolute w-max max-w-xs",
|
||||||
|
"p-1 text-xs text-white dark:text-black bg-black dark:bg-white",
|
||||||
|
"rounded-md shadow-lg",
|
||||||
|
"left-1/2 -translate-x-1/2 -top-8",
|
||||||
|
"opacity-0",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{firstCharOfPreference.toLocaleUpperCase() +
|
{currentDisplayPreference}
|
||||||
(restOfPreference ?? []).join("")}
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{themeIcons[preference]}
|
{themeIcons[preference]}
|
||||||
</button>
|
<Subtle
|
||||||
|
className={cn(
|
||||||
|
"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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("theme.changeTheme")}
|
||||||
|
</Subtle>
|
||||||
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const AccordionTrigger = forwardRef<
|
|||||||
<AccordionPrimitive.Trigger
|
<AccordionPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex justify-between items-center w-full p-4 border-b border-slat-200 dark:border-slat-800 group",
|
"flex justify-between items-center w-full p-4 border-b border-slate-200 dark:border-slate-800 group",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -36,7 +36,7 @@ export const AccordionContent = forwardRef<
|
|||||||
<AccordionPrimitive.Content
|
<AccordionPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-4 border-b border-slat-200 dark:border-slat-800",
|
"p-4 border-b border-slate-200 dark:border-slate-800",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@components/UI/Tooltip.tsx";
|
} from "@components/UI/Tooltip.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type RGBColor = {
|
type RGBColor = {
|
||||||
r: number;
|
r: number;
|
||||||
@@ -73,6 +74,8 @@ export const Avatar = ({
|
|||||||
showFavorite = false,
|
showFavorite = false,
|
||||||
className,
|
className,
|
||||||
}: AvatarProps) => {
|
}: AvatarProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
sm: "size-10 text-xs font-light",
|
sm: "size-10 text-xs font-light",
|
||||||
lg: "size-16 text-lg",
|
lg: "size-16 text-lg",
|
||||||
@@ -82,7 +85,7 @@ export const Avatar = ({
|
|||||||
const bgColor = getColorFromText(safeText);
|
const bgColor = getColorFromText(safeText);
|
||||||
const isLight = ColorUtils.isLight(bgColor);
|
const isLight = ColorUtils.isLight(bgColor);
|
||||||
const textColor = isLight ? "#000000" : "#FFFFFF";
|
const textColor = isLight ? "#000000" : "#FFFFFF";
|
||||||
const initials = safeText?.slice(0, 4) ?? "UNK";
|
const initials = safeText?.slice(0, 4) ?? t("unknown.shortName");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -111,7 +114,7 @@ export const Avatar = ({
|
|||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
||||||
Favorite
|
{t("nodeDetail.favorite.label", { ns: "nodes" })}
|
||||||
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
|
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -129,14 +132,14 @@ export const Avatar = ({
|
|||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
||||||
Node error
|
{t("nodeDetail.error.label", { ns: "nodes" })}
|
||||||
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
|
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
<p className="p-1">
|
<p className="p-1 text-nowrap">
|
||||||
{initials}
|
{initials}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user