Browse Source

Merge branch 'meshtastic:main' into zod-v4-validation

pull/635/head
Jeremy Gallant 1 year ago
committed by GitHub
parent
commit
3685f52b22
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 27
      .github/pull_request_template.md
  2. 3
      .github/workflows/ci.yml
  3. 35
      .github/workflows/crowdin-download.yml
  4. 32
      .github/workflows/crowdin-upload-sources.yml
  5. 25
      .github/workflows/crowdin-upload-translations.yml
  6. 22
      .github/workflows/update-stable-from-master.yml
  7. 10
      crowdin.yml
  8. 3
      deno.json
  9. 508
      deno.lock
  10. 11
      package.json
  11. 94
      src/components/BatteryStatus.tsx
  12. 71
      src/components/CommandPalette/index.tsx
  13. 240
      src/components/DeviceInfoPanel.tsx
  14. 13
      src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx
  15. 35
      src/components/Dialog/DeviceNameDialog.tsx
  16. 20
      src/components/Dialog/ImportDialog.tsx
  17. 26
      src/components/Dialog/LocationResponseDialog.tsx
  18. 69
      src/components/Dialog/NewDeviceDialog.tsx
  19. 67
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx
  20. 117
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx
  21. 57
      src/components/Dialog/PKIBackupDialog.tsx
  22. 26
      src/components/Dialog/PkiRegenerateDialog.tsx
  23. 23
      src/components/Dialog/QRDialog.tsx
  24. 11
      src/components/Dialog/RebootDialog.tsx
  25. 9
      src/components/Dialog/RebootOTADialog.test.tsx
  26. 28
      src/components/Dialog/RebootOTADialog.tsx
  27. 27
      src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx
  28. 14
      src/components/Dialog/RemoveNodeDialog.tsx
  29. 13
      src/components/Dialog/ShutdownDialog.tsx
  30. 14
      src/components/Dialog/TracerouteResponseDialog.tsx
  31. 25
      src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx
  32. 2
      src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts
  33. 71
      src/components/Form/FormMultiSelect.tsx
  34. 5
      src/components/KeyBackupReminder.tsx
  35. 91
      src/components/LanguageSwitcher.tsx
  36. 151
      src/components/PageComponents/Channel.tsx
  37. 30
      src/components/PageComponents/Config/Bluetooth.tsx
  38. 44
      src/components/PageComponents/Config/Device/index.tsx
  39. 58
      src/components/PageComponents/Config/Display.tsx
  40. 92
      src/components/PageComponents/Config/LoRa.tsx
  41. 71
      src/components/PageComponents/Config/Network/index.tsx
  42. 66
      src/components/PageComponents/Config/Position.tsx
  43. 59
      src/components/PageComponents/Config/Power.tsx
  44. 151
      src/components/PageComponents/Config/Security/Security.tsx
  45. 8
      src/components/PageComponents/Connect/BLE.tsx
  46. 48
      src/components/PageComponents/Connect/HTTP.tsx
  47. 23
      src/components/PageComponents/Connect/Serial.tsx
  48. 65
      src/components/PageComponents/Map/NodeDetail.tsx
  49. 16
      src/components/PageComponents/Messages/ChannelChat.tsx
  50. 10
      src/components/PageComponents/Messages/MessageActionsMenu.tsx
  51. 1
      src/components/PageComponents/Messages/MessageInput.tsx
  52. 77
      src/components/PageComponents/Messages/MessageItem.tsx
  53. 21
      src/components/PageComponents/Messages/TraceRoute.test.tsx
  54. 22
      src/components/PageComponents/Messages/TraceRoute.tsx
  55. 28
      src/components/PageComponents/ModuleConfig/AmbientLighting.tsx
  56. 36
      src/components/PageComponents/ModuleConfig/Audio.tsx
  57. 53
      src/components/PageComponents/ModuleConfig/CannedMessage.tsx
  58. 50
      src/components/PageComponents/ModuleConfig/DetectionSensor.tsx
  59. 77
      src/components/PageComponents/ModuleConfig/ExternalNotification.tsx
  60. 148
      src/components/PageComponents/ModuleConfig/MQTT.tsx
  61. 19
      src/components/PageComponents/ModuleConfig/NeighborInfo.tsx
  62. 27
      src/components/PageComponents/ModuleConfig/Paxcounter.tsx
  63. 22
      src/components/PageComponents/ModuleConfig/RangeTest.tsx
  64. 46
      src/components/PageComponents/ModuleConfig/Serial.tsx
  65. 29
      src/components/PageComponents/ModuleConfig/StoreForward.tsx
  66. 54
      src/components/PageComponents/ModuleConfig/Telemetry.tsx
  67. 160
      src/components/Sidebar.tsx
  68. 86
      src/components/ThemeSwitcher.tsx
  69. 7
      src/components/UI/Avatar.tsx
  70. 31
      src/components/UI/Footer.tsx
  71. 22
      src/components/UI/Input.tsx
  72. 317
      src/components/generic/Filter/FilterControl.tsx
  73. 4
      src/components/types.ts
  74. 92
      src/core/hooks/useLang.ts
  75. 99
      src/core/hooks/usePositionFlags.ts
  76. 52
      src/i18n/config.ts
  77. 69
      src/i18n/locales/en/channels.json
  78. 50
      src/i18n/locales/en/commandPalette.json
  79. 73
      src/i18n/locales/en/common.json
  80. 12
      src/i18n/locales/en/dashboard.json
  81. 442
      src/i18n/locales/en/deviceConfig.json
  82. 160
      src/i18n/locales/en/dialog.json
  83. 37
      src/i18n/locales/en/messages.json
  84. 448
      src/i18n/locales/en/moduleConfig.json
  85. 51
      src/i18n/locales/en/nodes.json
  86. 162
      src/i18n/locales/en/ui.json
  87. 7
      src/index.tsx
  88. 29
      src/pages/Channels.tsx
  89. 26
      src/pages/Config/DeviceConfig.tsx
  90. 28
      src/pages/Config/ModuleConfig.tsx
  91. 37
      src/pages/Config/index.tsx
  92. 42
      src/pages/Dashboard/index.tsx
  93. 35
      src/pages/Messages.tsx
  94. 101
      src/pages/Nodes.tsx
  95. 65
      src/tests/setupTests.ts
  96. 47
      vite.config.ts
  97. 22
      vitest.config.ts

27
.github/pull_request_template.md

@ -1,48 +1,49 @@
<!-- <!--
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 bee added
- [ ] Dependent changes have been merged
## Additional Notes
<!--
Add any other context about the PR here.
-->

3
.github/workflows/ci.yml

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

35
.github/workflows/crowdin-download.yml

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

32
.github/workflows/crowdin-upload-sources.yml

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

25
.github/workflows/crowdin-upload-translations.yml

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

22
.github/workflows/update-stable-from-master.yml

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

10
crowdin.yml

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

3
deno.json

@ -4,7 +4,8 @@
"@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"
}, },
"compilerOptions": { "compilerOptions": {
"lib": [ "lib": [

508
deno.lock

File diff suppressed because it is too large

11
package.json

@ -34,12 +34,12 @@
}, },
"homepage": "https://meshtastic.org", "homepage": "https://meshtastic.org",
"dependencies": { "dependencies": {
"@bufbuild/protobuf": "^2.2.5",
"@meshtastic/core": "npm:@jsr/[email protected]", "@meshtastic/core": "npm:@jsr/[email protected]",
"@meshtastic/js": "npm:@jsr/[email protected]", "@meshtastic/js": "npm:@jsr/[email protected]",
"@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",
@ -64,6 +64,9 @@
"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",
@ -73,9 +76,11 @@
"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-i18n-ally": "^6.0.1",
"vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-node-polyfills": "^0.23.0",
"zod": "^3.25.0", "zod": "^3.25.0",
"zustand": "5.0.4" "zustand": "5.0.4"
@ -106,7 +111,7 @@
"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" "vitest": "^3.1.2"
} }
} }

94
src/components/BatteryStatus.tsx

@ -5,16 +5,8 @@ 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 {
batteryLevel?: number | null;
voltage?: number | null;
}
interface BatteryStatusProps {
deviceMetrics?: DeviceMetrics | null;
}
interface BatteryStateConfig { interface BatteryStateConfig {
condition: (level: number) => boolean; condition: (level: number) => boolean;
@ -23,34 +15,45 @@ interface BatteryStateConfig {
text: (level: number) => string; text: (level: number) => string;
} }
const batteryStates: BatteryStateConfig[] = [ interface BatteryStatusProps {
{ deviceMetrics?: DeviceMetrics | null;
condition: (level) => level > 100, }
Icon: PlugZapIcon,
className: "text-gray-500", const getBatteryStates = (
text: () => "Plugged in", t: (key: string, options?: object) => string,
}, ): BatteryStateConfig[] => {
{ return [
condition: (level) => level > 80, {
Icon: BatteryFullIcon, condition: (level) => level > 100,
className: "text-green-500", Icon: PlugZapIcon,
text: (level) => `${level}% charging`, className: "text-gray-500",
}, text: () => t("batteryStatus.pluggedIn"),
{ },
condition: (level) => level > 20, {
Icon: BatteryMediumIcon, condition: (level) => level > 80,
className: "text-yellow-500", Icon: BatteryFullIcon,
text: (level) => `${level}% charging`, className: "text-green-500",
}, text: (level) => t("batteryStatus.charging", { level }),
{ },
condition: () => true, {
Icon: BatteryLowIcon, condition: (level) => level > 20,
className: "text-red-500", Icon: BatteryMediumIcon,
text: (level) => `${level}% charging`, className: "text-yellow-500",
}, text: (level) => t("batteryStatus.charging", { level }),
]; },
{
condition: () => true,
Icon: BatteryLowIcon,
className: "text-red-500",
text: (level) => t("batteryStatus.charging", { level }),
},
];
};
const getBatteryState = (level: number) => { const getBatteryState = (
level: number,
batteryStates: BatteryStateConfig[],
) => {
return batteryStates.find((state) => state.condition(level)); return batteryStates.find((state) => state.condition(level));
}; };
@ -62,25 +65,24 @@ const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
return null; return null;
} }
const { batteryLevel, voltage } = deviceMetrics; const { t } = useTranslation();
const currentState = getBatteryState(batteryLevel) ?? const batteryStates = getBatteryStates(t);
const { batteryLevel } = deviceMetrics;
const currentState = getBatteryState(batteryLevel, batteryStates) ??
batteryStates[batteryStates.length - 1]; batteryStates[batteryStates.length - 1];
const BatteryIcon = currentState.Icon; const BatteryIcon = currentState.Icon;
const iconClassName = currentState.className; const iconClassName = currentState.className;
const statusText = currentState.text(batteryLevel); const statusText = currentState.text(batteryLevel);
const voltageTitle = `${voltage?.toPrecision(3) ?? "Unknown"} volts`;
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>
); );
}; };

71
src/components/CommandPalette/index.tsx

@ -33,9 +33,11 @@ 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";
export interface Group { export interface Group {
id: string;
label: string; label: string;
icon: LucideIcon; icon: LucideIcon;
commands: Command[]; commands: Command[];
@ -65,28 +67,30 @@ export const CommandPalette = () => {
const { pinnedItems, togglePinnedItem } = usePinnedItems({ const { pinnedItems, togglePinnedItem } = usePinnedItems({
storageName: "pinnedCommandMenuGroups", storageName: "pinnedCommandMenuGroups",
}); });
const { t } = useTranslation("commandPalette");
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"); setActivePage("messages");
}, },
}, },
{ {
label: "Map", label: t("goto.command.map"),
icon: MapIcon, icon: MapIcon,
action() { action() {
setActivePage("map"); setActivePage("map");
}, },
}, },
{ {
label: "Config", label: t("goto.command.config"),
icon: SettingsIcon, icon: SettingsIcon,
action() { action() {
setActivePage("config"); setActivePage("config");
@ -94,14 +98,14 @@ export const CommandPalette = () => {
tags: ["settings"], tags: ["settings"],
}, },
{ {
label: "Channels", label: t("goto.command.channels"),
icon: LayersIcon, icon: LayersIcon,
action() { action() {
setActivePage("channels"); setActivePage("channels");
}, },
}, },
{ {
label: "Nodes", label: t("goto.command.nodes"),
icon: UsersIcon, icon: UsersIcon,
action() { action() {
setActivePage("nodes"); setActivePage("nodes");
@ -110,19 +114,20 @@ export const CommandPalette = () => {
], ],
}, },
{ {
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 +136,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 +145,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 +170,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 +214,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 +237,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 +259,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 +270,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 +286,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",
)} )}

240
src/components/DeviceInfoPanel.tsx

@ -0,0 +1,240 @@
import { cn } from "@core/utils/cn.ts";
import {
CpuIcon,
Languages,
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 { 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="p-1">
<div className="flex flex-col">
<div
className={cn(
"flex items-center gap-3 p-1",
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",
)}
>
{user.longName}
</p>
)}
</div>
{!isCollapsed && (
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700"></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"></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>
);
})}
{/* <Code>{import.meta.env.COMMIT_HASH}</Code> */}
</div>
</div>
</div>
);
};

13
src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx

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

35
src/components/Dialog/DeviceNameDialog.tsx

@ -10,11 +10,12 @@ 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 { useTranslation } from "react-i18next";
import { validateMaxByteLength } from "@core/utils/string.ts"; import { validateMaxByteLength } from "@core/utils/string.ts";
import { Label } from "../UI/Label.tsx";
export interface User { export interface User {
longName: string; longName: string;
@ -32,12 +33,13 @@ 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", longName: myNode?.user?.longName ?? t("unknown.longName"),
shortName: myNode?.user?.shortName ?? "??", shortName: myNode?.user?.shortName ?? t("unknown.shortName"),
}; };
const { getValues, setValue, reset, control, handleSubmit } = useForm<User>({ const { getValues, setValue, reset, control, handleSubmit } = useForm<User>({
@ -74,19 +76,21 @@ 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",
@ -100,12 +104,14 @@ 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: {
@ -119,10 +125,15 @@ 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>
<Button type="submit">Save</Button> <Button type="submit" name="save">{t("button.save")}</Button>
</DialogFooter> </DialogFooter>
</form> </form>
</DialogContent> </DialogContent>

20
src/components/Dialog/ImportDialog.tsx

@ -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);
@ -99,13 +101,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 +119,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 +146,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 +154,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 +164,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>

26
src/components/Dialog/LocationResponseDialog.tsx

@ -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,6 +9,7 @@ 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.location> | undefined;
@ -21,26 +22,33 @@ 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"));
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>
<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=${
@ -53,7 +61,13 @@ export const LocationResponseDialog = ({
{location?.data.longitudeI / 1e7} {location?.data.longitudeI / 1e7}
</a> </a>
</p> </p>
<p>Altitude: {location?.data.altitude}m</p> <p>
{t("locationResponse.altitude")}
{location?.data.altitude}
{location?.data.altitde < 1
? t("unit.meter.one")
: t("unit.meter.plural")}
</p>
</span> </span>
</div> </div>
</DialogDescription> </DialogDescription>

69
src/components/Dialog/NewDeviceDialog.tsx

@ -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";
@ -20,8 +20,9 @@ import {
} from "@components/UI/Tabs.tsx"; } from "@components/UI/Tabs.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx"; import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { AlertCircle } from "lucide-react"; import { AlertCircle } from "lucide-react";
import { useMemo } from "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;
@ -51,12 +52,18 @@ const links: { [key: string]: string } = {
"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts", "https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
}; };
const listFormatter = new Intl.ListFormat("en", {
style: "long",
type: "disjunction",
});
const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => { const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
const { i18n } = useTranslation("dialog");
const listFormatter = useMemo(
() =>
new Intl.ListFormat(i18n.language, {
style: "long",
type: "disjunction",
}),
[i18n.language],
);
if (missingFeatures.length === 0) return null; if (missingFeatures.length === 0) return null;
const browserFeatures = missingFeatures.filter( const browserFeatures = missingFeatures.filter(
@ -74,10 +81,12 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
</Link> </Link>
); );
} }
return <Fragment key={part.value}>{part.value}</Fragment>; return <span key={part.value}>{part.value}</span>;
}); });
}; };
const featureNodes = formatFeatureList(browserFeatures);
return ( return (
<Subtle className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md"> <Subtle className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md">
<div className="flex items-center gap-2 w-full"> <div className="flex items-center gap-2 w-full">
@ -85,20 +94,28 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<p className="text-sm text-white"> <p className="text-sm text-white">
{browserFeatures.length > 0 && ( {browserFeatures.length > 0 && (
<> <Trans
This connection type requires{" "} i18nKey="newDeviceDialog.validation.requiresFeatures"
{formatFeatureList(browserFeatures)}. Please use a supported components={{
browser, like Chrome or Edge. "0": <>{featureNodes}</>,
</> }}
/>
)} )}
{browserFeatures.length > 0 && 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={links["Secure Context"]}
className="underline hover:text-slate-200"
/>
),
}}
/>
)} )}
</p> </p>
</div> </div>
@ -111,22 +128,23 @@ 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", label: t("newDeviceDialog.tabHttp"),
element: HTTP, element: HTTP,
isDisabled: false, isDisabled: false,
}, },
{ {
label: "Bluetooth", 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", 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,7 +156,7 @@ 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>
@ -151,7 +169,8 @@ export const NewDeviceDialog = ({
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}> <TabsContent key={tab.label} value={tab.label}>
<fieldset disabled={tab.isDisabled}> <fieldset disabled={tab.isDisabled}>
{(tab.label !== "HTTP" && tab.isDisabled) {(tab.label !== "HTTP" &&
tab.isDisabled)
? <ErrorMessage missingFeatures={unsupported} /> ? <ErrorMessage missingFeatures={unsupported} />
: null} : null}
<tab.element <tab.element

67
src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx

@ -1,18 +1,14 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx"; import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useAppStore } from "@core/stores/appStore.ts"; import { useAppStore } from "@core/stores/appStore.ts";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/deviceStore", () => { vi.mock("@core/stores/deviceStore");
return {
useDevice: () => ({
setDialogOpen: vi.fn(),
}),
};
});
vi.mock("@core/stores/appStore"); vi.mock("@core/stores/appStore");
const mockUseDevice = vi.mocked(useDevice);
const mockUseAppStore = vi.mocked(useAppStore); const mockUseAppStore = vi.mocked(useAppStore);
describe("NodeDetailsDialog", () => { describe("NodeDetailsDialog", () => {
@ -42,13 +38,22 @@ describe("NodeDetailsDialog", () => {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
mockUseDevice.mockReturnValue({
getNode: (nodeNum: number) => {
if (nodeNum === 1234) {
return mockNode;
}
return undefined;
},
});
mockUseAppStore.mockReturnValue({ mockUseAppStore.mockReturnValue({
nodeNumDetails: 1234, nodeNumDetails: 1234,
}); });
}); });
it("renders node details correctly", () => { it("renders node details correctly", () => {
render(<NodeDetailsDialog open node={mockNode} onOpenChange={() => {}} />); render(<NodeDetailsDialog open onOpenChange={() => {}} />);
expect(screen.getByText(/Node Details for Test Node \(TN\)/i)) expect(screen.getByText(/Node Details for Test Node \(TN\)/i))
.toBeInTheDocument(); .toBeInTheDocument();
@ -78,10 +83,26 @@ describe("NodeDetailsDialog", () => {
}); });
it("renders null if node is undefined", () => { it("renders null if node is undefined", () => {
const mockNode = undefined; const requestedNodeNum = 5678;
mockUseAppStore.mockReturnValue({
nodeNumDetails: requestedNodeNum,
});
mockUseDevice.mockReturnValue({
getNode: (nodeNum: number) => {
if (nodeNum === requestedNodeNum) {
return undefined;
}
if (nodeNum === 1234) {
return mockNode;
}
return undefined;
},
});
const { container } = render( const { container } = render(
<NodeDetailsDialog open node={mockNode} onOpenChange={() => {}} />, <NodeDetailsDialog open onOpenChange={() => {}} />,
); );
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();
@ -90,14 +111,10 @@ describe("NodeDetailsDialog", () => {
it("renders correctly when position is missing", () => { it("renders correctly when position is missing", () => {
const nodeWithoutPosition = { ...mockNode, position: undefined }; const nodeWithoutPosition = { ...mockNode, position: undefined };
mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutPosition });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
render( render(<NodeDetailsDialog open onOpenChange={() => {}} />);
<NodeDetailsDialog
open
node={nodeWithoutPosition}
onOpenChange={() => {}}
/>,
);
expect(screen.queryByText(/Coordinates:/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Coordinates:/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Altitude:/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Altitude:/i)).not.toBeInTheDocument();
@ -106,14 +123,10 @@ describe("NodeDetailsDialog", () => {
it("renders correctly when deviceMetrics are missing", () => { it("renders correctly when deviceMetrics are missing", () => {
const nodeWithoutMetrics = { ...mockNode, deviceMetrics: undefined }; const nodeWithoutMetrics = { ...mockNode, deviceMetrics: undefined };
mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutMetrics });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
render( render(<NodeDetailsDialog open onOpenChange={() => {}} />);
<NodeDetailsDialog
open
node={nodeWithoutMetrics}
onOpenChange={() => {}}
/>,
);
expect(screen.queryByText(/Device Metrics:/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Device Metrics:/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Air TX utilization:/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Air TX utilization:/i)).not.toBeInTheDocument();
@ -122,10 +135,10 @@ describe("NodeDetailsDialog", () => {
it("renders 'Never' for lastHeard when timestamp is 0", () => { it("renders 'Never' for lastHeard when timestamp is 0", () => {
const nodeNeverHeard = { ...mockNode, lastHeard: 0 }; const nodeNeverHeard = { ...mockNode, lastHeard: 0 };
mockUseDevice.mockReturnValue({ getNode: () => nodeNeverHeard });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
render( render(<NodeDetailsDialog open onOpenChange={() => {}} />);
<NodeDetailsDialog open node={nodeNeverHeard} onOpenChange={() => {}} />,
);
expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument(); expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument();
}); });

117
src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx

@ -48,27 +48,32 @@ 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";
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, setActivePage, getNode } = useDevice();
const { setNodeNumToBeRemoved, nodeNumDetails } = useAppStore();
const { setChatType, setActiveChat } = useMessageStore(); const { setChatType, setActiveChat } = useMessageStore();
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;
@ -90,11 +95,11 @@ export const NodeDetailsDialog = ({
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 +109,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 +144,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 +174,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 +225,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 +245,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 +257,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>
{t("nodeDetails.nodeHexPrefix")}
{numberToHexUnpadded(node.num)}
</p>
<p> <p>
Role: {Protobuf.Config.Config_DeviceConfig_Role[ {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 +294,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 +304,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 +319,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 +353,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 +366,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">

57
src/components/Dialog/PKIBackupDialog.tsx

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

26
src/components/Dialog/PkiRegenerateDialog.tsx

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

23
src/components/Dialog/QRDialog.tsx

@ -16,6 +16,7 @@ import { fromByteArray } from "base64-js";
import { ClipboardIcon } from "lucide-react"; 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 +31,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 +67,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,8 +81,13 @@ 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}
@ -113,9 +120,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,14 +132,15 @@ 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

11
src/components/Dialog/RebootDialog.tsx

@ -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, RefreshCwIcon } from "lucide-react"; import { ClockIcon, 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">
@ -50,12 +54,13 @@ export const RebootDialog = ({
/> />
<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>

9
src/components/Dialog/RebootOTADialog.test.tsx

@ -60,7 +60,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 +73,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 +100,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 +112,6 @@ describe("RebootOTADialog", () => {
expect(onOpenChangeMock).not.toHaveBeenCalled(); expect(onOpenChangeMock).not.toHaveBeenCalled();
}); });
// reset connection for other tests
mockConnection = { rebootOta: rebootOtaMock }; mockConnection = { rebootOta: rebootOtaMock };
}); });
}); });

28
src/components/Dialog/RebootOTADialog.tsx

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

27
src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx

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

14
src/components/Dialog/RemoveNodeDialog.tsx

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

13
src/components/Dialog/ShutdownDialog.tsx

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

14
src/components/Dialog/TracerouteResponseDialog.tsx

@ -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,6 +24,7 @@ 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 ?? [];
@ -30,16 +32,22 @@ export const TracerouteResponseDialog = ({
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 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 to = getNode(traceroute?.to ?? 0); const to = getNode(traceroute?.to ?? 0);
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: `${longName} (${shortName})`,
})}
</DialogTitle>
</DialogHeader> </DialogHeader>
<DialogDescription> <DialogDescription>
<TraceRoute <TraceRoute

25
src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx

@ -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,27 @@ 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 {t("unsafeRoles.confirmUnderstanding")}
</Checkbox> </Checkbox>
</div> </div>
<DialogFooter className="mt-6"> <DialogFooter className="mt-6">
@ -69,7 +72,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 +80,7 @@ export const UnsafeRolesDialog = (
disabled={!confirmState} disabled={!confirmState}
onClick={() => handleCloseDialog("confirm")} onClick={() => handleCloseDialog("confirm")}
> >
Confirm {t("button.confirm")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

2
src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts

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

71
src/components/Form/FormMultiSelect.tsx

@ -3,6 +3,8 @@ 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";
export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> { export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
@ -12,53 +14,54 @@ 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,
}: GenericFormElementProps<T, MultiSelectFieldProps<T>>) { }: GenericFormElementProps<T, MultiSelectFieldProps<T>>) {
const { enumValue, formatEnumName, ...remainingProperties } = const { t } = useTranslation("deviceConfig");
field.properties; const { enumValue, ...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 {...remainingProperties}>
{optionsEnumValues.map(([name, value]) => ( {optionsToRender.map((option) => {
<MultiSelectItem return (
key={name} <MultiSelectItem
name={name} key={option.key}
value={value.toString()} name={option.key}
checked={field.isChecked(name)} value={option.value.toString()}
onCheckedChange={() => field.onValueChange(name)} checked={field.isChecked(option.key)}
> onCheckedChange={() => field.onValueChange(option.key)}
{formatEnumName ? formatEnumDisplay(name) : name} >
</MultiSelectItem> {option.display}
))} </MultiSelectItem>
);
})}
</MultiSelect> </MultiSelect>
); );
} }

5
src/components/KeyBackupReminder.tsx

@ -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("pkiBackup.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,
}); });

91
src/components/LanguageSwitcher.tsx

@ -0,0 +1,91 @@
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 } = useLang();
const currentLanguage =
supportedLanguages.find((lang) => lang.code === i18n.language) ||
supportedLanguages[0];
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.code.toUpperCase()}
</Subtle>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-64">
{supportedLanguages.map((language) => (
<DropdownMenuItem
key={language.code}
onClick={() => handleLanguageChange(language.code as LangCode)}
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>
);
}

151
src/components/PageComponents/Channel.tsx

@ -7,6 +7,7 @@ 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 { useState } from "react";
import { useTranslation } from "react-i18next";
import { PkiRegenerateDialog } from "../Dialog/PkiRegenerateDialog.tsx"; import { PkiRegenerateDialog } from "../Dialog/PkiRegenerateDialog.tsx";
export interface SettingsPanelProps { export interface SettingsPanelProps {
@ -15,6 +16,7 @@ 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>( const [pass, setPass] = useState<string>(
@ -42,7 +44,10 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
}); });
connection?.setChannel(channel).then(() => { connection?.setChannel(channel).then(() => {
toast({ toast({
title: `Saved Channel: ${channel.settings?.name}`, title: t("toast.savedChannel", {
ns: "ui",
channelName: channel.settings?.name,
}),
}); });
addChannel(channel); addChannel(channel);
}); });
@ -67,7 +72,9 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
const validatePass = (input: string, count: number) => { const validatePass = (input: string, count: number) => {
if (input.length % 4 !== 0 || toByteArray(input).length !== count) { if (input.length % 4 !== 0 || toByteArray(input).length !== count) {
setValidationText(`Please enter a valid ${count * 8} bit PSK.`); setValidationText(
t("validation.pskInvalid", { bits: count * 8 }),
);
} else { } else {
setValidationText(undefined); setValidationText(undefined);
} }
@ -110,36 +117,37 @@ 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)",
validationText: validationText, validationText: validationText,
devicePSKBitCount: bitCount ?? 0, 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 +162,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 +264,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)}

30
src/components/PageComponents/Config/Bluetooth.tsx

@ -5,6 +5,7 @@ import { DynamicForm } 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 { useState } from "react";
import { useTranslation } from "react-i18next";
export const Bluetooth = () => { export const Bluetooth = () => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
@ -16,6 +17,7 @@ export const Bluetooth = () => {
removeError, removeError,
clearErrors, clearErrors,
} = useAppStore(); } = useAppStore();
const { t } = useTranslation("deviceConfig");
const [bluetoothPin, setBluetoothPin] = useState( const [bluetoothPin, setBluetoothPin] = useState(
config?.bluetooth?.fixedPin.toString() ?? "", config?.bluetooth?.fixedPin.toString() ?? "",
@ -24,7 +26,7 @@ export const Bluetooth = () => {
const validateBluetoothPin = (pin: string) => { const validateBluetoothPin = (pin: string) => {
// if empty show error they need a pin set // if empty show error they need a pin set
if (pin === "") { if (pin === "") {
return addError("fixedPin", "Bluetooth Pin is required"); return addError("fixedPin", t("bluetooth.validation.pinRequired"));
} }
// clear any existing errors // clear any existing errors
@ -32,11 +34,14 @@ export const Bluetooth = () => {
// if it starts with 0 show error // if it starts with 0 show error
if (pin[0] === "0") { if (pin[0] === "0") {
return addError("fixedPin", "Bluetooth Pin cannot start with 0"); return addError(
"fixedPin",
t("bluetooth.validation.pinCannotStartWithZero"),
);
} }
// if it's not 6 digits show error // if it's not 6 digits show error
if (pin.length < 6) { if (pin.length < 6) {
return addError("fixedPin", "Pin must be 6 digits"); return addError("fixedPin", t("bluetooth.validation.pinMustBeSixDigits"));
} }
removeError("fixedPin"); removeError("fixedPin");
@ -69,22 +74,21 @@ export const Bluetooth = () => {
defaultValues={config.bluetooth} defaultValues={config.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) => { selectChange: (e) => {
if (e !== "1") { if (e !== "1") {
setBluetoothPin(""); setBluetoothPin("");
@ -104,8 +108,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") validationText: hasFieldError("fixedPin")
? getErrorMessage("fixedPin") ? getErrorMessage("fixedPin")
: "", : "",

44
src/components/PageComponents/Config/Device/index.tsx

@ -4,9 +4,11 @@ import { DynamicForm } 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";
export const Device = () => { export const Device = () => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
const { t } = useTranslation("deviceConfig");
const { validateRoleSelection } = useUnsafeRolesDialog(); const { validateRoleSelection } = useUnsafeRolesDialog();
const onSubmit = (data: DeviceValidation) => { const onSubmit = (data: DeviceValidation) => {
@ -25,14 +27,14 @@ export const Device = () => {
defaultValues={config.device} defaultValues={config.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 +44,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,29 +66,29 @@ 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,
@ -98,8 +100,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"),
}, },
], ],
}, },

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

@ -1,11 +1,13 @@
import type { DisplayValidation } from "@app/validation/config/display.tsx"; import type { DisplayValidation } 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 } 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";
export const Display = () => { export const Display = () => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
const { t } = useTranslation("deviceConfig");
const onSubmit = (data: DisplayValidation) => { const onSubmit = (data: DisplayValidation) => {
setWorkingConfig( setWorkingConfig(
@ -24,23 +26,23 @@ export const Display = () => {
defaultValues={config.display} defaultValues={config.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 +51,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,17 +88,17 @@ 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_Displayjonfig_OledType,
}, },
}, },
{ {
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 +107,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"),
}, },
], ],
}, },

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

@ -1,11 +1,13 @@
import type { LoRaValidation } from "@app/validation/config/lora.tsx"; import type { LoRaValidation } 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 } 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";
export const LoRa = () => { export const LoRa = () => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
const { t } = useTranslation("deviceConfig");
const onSubmit = (data: LoRaValidation) => { const onSubmit = (data: LoRaValidation) => {
setWorkingConfig( setWorkingConfig(
@ -24,14 +26,14 @@ export const LoRa = () => {
defaultValues={config.lora} defaultValues={config.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 +41,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 +50,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 +95,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 +104,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 +120,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 +138,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,
}, },
}, },

71
src/components/PageComponents/Config/Network/index.tsx

@ -11,9 +11,11 @@ import {
} 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 { validateSchema } from "@app/validation/validate.ts";
import { useTranslation } from "react-i18next";
export const Network = () => { export const Network = () => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
const { t } = useTranslation("deviceConfig");
const onSubmit = (data: NetworkValidation) => { const onSubmit = (data: NetworkValidation) => {
const result = validateSchema(NetworkValidationSchema, data); const result = validateSchema(NetworkValidationSchema, data);
@ -63,22 +65,21 @@ export const Network = () => {
}} }}
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 +89,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 +100,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 +127,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 +140,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 +153,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 +166,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 +179,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 +194,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"),
}, },
], ],
}, },

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

@ -8,12 +8,14 @@ import { DynamicForm } 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";
export const Position = () => { export const Position = () => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags( const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
config?.position?.positionFlags ?? 0, config?.position?.positionFlags ?? 0,
); );
const { t } = useTranslation("deviceConfig");
const onSubmit = (data: PositionValidation) => { const onSubmit = (data: PositionValidation) => {
return setWorkingConfig( return setWorkingConfig(
@ -42,22 +44,20 @@ export const Position = () => {
defaultValues={config.position} defaultValues={config.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 +65,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 +75,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 +85,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 +138,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",

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

@ -1,11 +1,13 @@
import type { PowerValidation } from "@app/validation/config/power.tsx"; import type { PowerValidation } 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 } 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";
export const Power = () => { export const Power = () => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
const { t } = useTranslation("deviceConfig");
const onSubmit = (data: PowerValidation) => { const onSubmit = (data: PowerValidation) => {
setWorkingConfig( setWorkingConfig(
@ -24,31 +26,29 @@ export const Power = () => {
defaultValues={config.power} defaultValues={config.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 +56,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"),
}, },
}, },
], ],

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

@ -10,6 +10,7 @@ import { fromByteArray, toByteArray } from "base64-js";
import { useReducer } from "react"; import { useReducer } from "react";
import { securityReducer } from "@components/PageComponents/Config/Security/securityReducer.tsx"; import { securityReducer } from "@components/PageComponents/Config/Security/securityReducer.tsx";
import type { SecurityConfigInit } from "./types.ts"; import type { SecurityConfigInit } from "./types.ts";
import { useTranslation } from "react-i18next";
export const Security = () => { export const Security = () => {
const { config, setWorkingConfig, setDialogOpen } = useDevice(); const { config, setWorkingConfig, setDialogOpen } = useDevice();
@ -21,6 +22,7 @@ export const Security = () => {
removeError, removeError,
clearErrors, clearErrors,
} = useAppStore(); } = useAppStore();
const { t } = useTranslation("deviceConfig");
const [state, dispatch] = useReducer(securityReducer, { const [state, dispatch] = useReducer(securityReducer, {
privateKey: fromByteArray(config.security?.privateKey ?? new Uint8Array(0)), privateKey: fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
@ -50,20 +52,18 @@ export const Security = () => {
try { try {
removeError(fieldNameKey); removeError(fieldNameKey);
if (fieldName === "privateKey" && input === "") { if (fieldName === "privateKey" && input === "") {
addError(fieldNameKey, "Private Key is required"); addError(fieldNameKey, t("security.validation.privateKeyRequired"));
return; return;
} }
if (fieldName === "adminKey" && input === "") { if (fieldName === "adminKey" && input === "") {
if ( if (
state.isManaged && state.adminKey state.isManaged &&
.map((v, i) => i === fieldIndex ? input : v) state.adminKey
.map((v, i) => (i === fieldIndex ? input : v))
.every((s) => s === "") .every((s) => s === "")
) { ) {
addError( addError("adminKey0", t("security."));
"adminKey0",
"At least one admin key is requred if the node is managed.",
);
} }
return; return;
@ -72,32 +72,35 @@ export const Security = () => {
if (input.length % 4 !== 0) { if (input.length % 4 !== 0) {
addError( addError(
fieldNameKey, fieldNameKey,
`${ fieldName === "privateKey"
fieldName === "privateKey" ? "Private" : "Admin" ? t("security.validation.privateKeyMustBe256BitPsk")
} Key is required to be a 256 bit pre-shared key (PSK)`, : t("security.validation.adminKeyMustBe256BitPsk"),
); );
return; return;
} }
const decoded = toByteArray(input); const decoded = toByteArray(input);
if (decoded.length !== count) { if (decoded.length !== count) {
addError(fieldNameKey, `Please enter a valid ${count * 8} bit PSK`); addError(
fieldNameKey,
t("security.validation.enterValid256BitPsk", {
bits: count * 8,
}),
);
return; return;
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
addError( addError(
fieldNameKey, fieldNameKey,
`Invalid ${ fieldName === "privateKey"
fieldName === "privateKey" ? "Private" : "Admin" ? t("security.validation.invalidPrivateKeyFormat")
} Key format`, : t("security.validation.invalidAdminKeyFormat"),
); );
} }
}; };
function setSecurityPayload( function setSecurityPayload(overrides: SecurityConfigInit) {
overrides: SecurityConfigInit,
) {
const base: SecurityConfigInit = { const base: SecurityConfigInit = {
isManaged: state.isManaged, isManaged: state.isManaged,
adminChannelEnabled: state.adminChannelEnabled, adminChannelEnabled: state.adminChannelEnabled,
@ -204,13 +207,12 @@ export const Security = () => {
) => { ) => {
dispatch({ type: "SET_TOGGLE", field, payload: next }); dispatch({ type: "SET_TOGGLE", field, payload: next });
if ( if (field === "isManaged" && state.adminKey.every((s) => s === "")) {
field === "isManaged" && state.adminKey.every((s) => s === "")
) {
if (next) { if (next) {
// If enabling 'managed' and no admin keys are set
addError( addError(
"adminKey0", "adminKey0",
"At least one admin key is requred if the node is managed.", t("security.validation.adminKeyRequiredWhenManaged"),
); );
} else { } else {
removeError("adminKey0"); removeError("adminKey0");
@ -252,16 +254,22 @@ export const Security = () => {
}} }}
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: [
{
text: t("security.256bit"),
value: "32",
key: "bit256",
},
],
validationText: hasFieldError("privateKey") validationText: hasFieldError("privateKey")
? getErrorMessage("privateKey") ? getErrorMessage("privateKey")
: "", : "",
@ -271,7 +279,7 @@ export const Security = () => {
hide: !state.privateKeyVisible, hide: !state.privateKeyVisible,
actionButtons: [ actionButtons: [
{ {
text: "Generate", text: t("button.generate"),
onClick: () => onClick: () =>
dispatch({ dispatch({
type: "SHOW_PRIVATE_KEY_DIALOG", type: "SHOW_PRIVATE_KEY_DIALOG",
@ -280,7 +288,7 @@ export const Security = () => {
variant: "success", variant: "success",
}, },
{ {
text: "Backup Key", text: t("button.backupKey"),
onClick: () => setDialogOpen("pkiBackup", true), onClick: () => setDialogOpen("pkiBackup", true),
variant: "subtle", variant: "subtle",
}, },
@ -294,10 +302,9 @@ 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, value: state.publicKey,
showCopyButton: true, showCopyButton: true,
@ -306,22 +313,27 @@ export const Security = () => {
], ],
}, },
{ {
label: "Admin Settings", label: t("security.adminSettings.label"),
description: "Settings for Admin", description: t("security.adminSettings.description"),
fields: [ fields: [
{ {
type: "passwordGenerator", type: "passwordGenerator",
name: "adminKey.0", name: "adminKey.0",
id: "adminKey0Input", id: "adminKey0Input",
label: "Primary Admin Key", label: t("security.primaryAdminKey.label"),
description: description: t("security.primaryAdminKey.description"),
"The primary public key authorized to send admin messages to this node",
validationText: hasFieldError("adminKey0") validationText: hasFieldError("adminKey0")
? getErrorMessage("adminKey0") ? getErrorMessage("adminKey0")
: "", : "",
inputChange: (e) => adminKeyInputChangeEvent(e, 0), inputChange: (e) => adminKeyInputChangeEvent(e, 0),
selectChange: () => {}, selectChange: () => {},
bits: [{ text: "256 bit", value: "32", key: "bit256" }], bits: [
{
text: t("security.256bit"),
value: "32",
key: "bit256",
},
],
devicePSKBitCount: state.privateKeyBitCount, devicePSKBitCount: state.privateKeyBitCount,
hide: !state.adminKeyVisible[0], hide: !state.adminKeyVisible[0],
actionButtons: [], actionButtons: [],
@ -338,15 +350,20 @@ export const Security = () => {
type: "passwordGenerator", type: "passwordGenerator",
name: "adminKey.1", name: "adminKey.1",
id: "adminKey1Input", id: "adminKey1Input",
label: "Secondary Admin Key", label: t("security.secondaryAdminKey.label"),
description: description: t("security.secondaryAdminKey.description"),
"The secondary public key authorized to send admin messages to this node",
validationText: hasFieldError("adminKey1") validationText: hasFieldError("adminKey1")
? getErrorMessage("adminKey1") ? getErrorMessage("adminKey1")
: "", : "",
inputChange: (e) => adminKeyInputChangeEvent(e, 1), inputChange: (e) => adminKeyInputChangeEvent(e, 1),
selectChange: () => {}, selectChange: () => {},
bits: [{ text: "256 bit", value: "32", key: "bit256" }], bits: [
{
text: t("security.256bit"),
value: "32",
key: "bit256",
},
],
devicePSKBitCount: state.privateKeyBitCount, devicePSKBitCount: state.privateKeyBitCount,
hide: !state.adminKeyVisible[1], hide: !state.adminKeyVisible[1],
actionButtons: [], actionButtons: [],
@ -363,15 +380,20 @@ export const Security = () => {
type: "passwordGenerator", type: "passwordGenerator",
name: "adminKey.2", name: "adminKey.2",
id: "adminKey2Input", id: "adminKey2Input",
label: "Tertiary Admin Key", label: t("security.tertiaryAdminKey.label"),
description: description: t("security.tertiaryAdminKey.description"),
"The tertiary public key authorized to send admin messages to this node",
validationText: hasFieldError("adminKey2") validationText: hasFieldError("adminKey2")
? getErrorMessage("adminKey2") ? getErrorMessage("adminKey2")
: "", : "",
inputChange: (e) => adminKeyInputChangeEvent(e, 2), inputChange: (e) => adminKeyInputChangeEvent(e, 2),
selectChange: () => {}, selectChange: () => {},
bits: [{ text: "256 bit", value: "32", key: "bit256" }], bits: [
{
text: t("security.256bit"),
value: "32",
key: "bit256",
},
],
devicePSKBitCount: state.privateKeyBitCount, devicePSKBitCount: state.privateKeyBitCount,
hide: !state.adminKeyVisible[2], hide: !state.adminKeyVisible[2],
actionButtons: [], actionButtons: [],
@ -387,26 +409,22 @@ export const Security = () => {
{ {
type: "toggle", type: "toggle",
name: "isManaged", name: "isManaged",
label: "Managed", label: t("security.managed.label"),
description: description: t("security.managed.description"),
"If enabled, device configuration options are only able to be changed remotely by a Remote Admin node via admin messages. Do not enable this option unless at least one suitable Remote Admin node has been setup, and the public key is stored in one of the fields above.",
inputChange: (e: boolean) => onToggleChange("isManaged", e), inputChange: (e: boolean) => onToggleChange("isManaged", e),
properties: { properties: {
checked: state.isManaged, checked: state.isManaged,
}, },
disabled: ( disabled: (hasFieldError("adminKey0") ||
(hasFieldError("adminKey0") || hasFieldError("adminKey1") ||
hasFieldError("adminKey1") || hasFieldError("adminKey2")) &&
hasFieldError("adminKey2")) && !state.adminKey.every((s) => s === ""),
!state.adminKey.every((s) => s === "")
),
}, },
{ {
type: "toggle", type: "toggle",
name: "adminChannelEnabled", name: "adminChannelEnabled",
label: "Allow Legacy Admin", label: t("security.adminChannelEnabled.label"),
description: description: t("security.adminChannelEnabled.description"),
"Allow incoming device control over the insecure legacy admin channel",
inputChange: (e: boolean) => inputChange: (e: boolean) =>
onToggleChange("adminChannelEnabled", e), onToggleChange("adminChannelEnabled", e),
properties: { properties: {
@ -416,15 +434,14 @@ export const Security = () => {
], ],
}, },
{ {
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",
inputChange: (e: boolean) => inputChange: (e: boolean) =>
onToggleChange("debugLogApiEnabled", e), onToggleChange("debugLogApiEnabled", e),
properties: { properties: {
@ -434,8 +451,8 @@ export const Security = () => {
{ {
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"),
inputChange: (e: boolean) => onToggleChange("serialEnabled", e), inputChange: (e: boolean) => onToggleChange("serialEnabled", e),
properties: { properties: {
checked: state.serialEnabled, checked: state.serialEnabled,
@ -447,9 +464,9 @@ 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={state.privateKeyDialogOpen}
onOpenChange={() => onOpenChange={() =>

8
src/components/PageComponents/Connect/BLE.tsx

@ -8,6 +8,7 @@ import { randId } from "@core/utils/randId.ts";
import { BleConnection, ServiceUuid } from "@meshtastic/js"; import { BleConnection, ServiceUuid } from "@meshtastic/js";
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 +18,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());
@ -59,7 +61,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
@ -82,7 +86,7 @@ export const BLE = (
}); });
}} }}
> >
<span>New device</span> <span>{t("newDeviceDialog.bluetoothConnection.newDeviceButton")}</span>
</Button> </Button>
</fieldset> </fieldset>
); );

48
src/components/PageComponents/Connect/HTTP.tsx

@ -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:";
@ -79,22 +81,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 +110,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 +153,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>
); );

23
src/components/PageComponents/Connect/Serial.tsx

@ -8,6 +8,7 @@ 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 { useTranslation } from "react-i18next";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export const Serial = ( export const Serial = (
@ -18,6 +19,7 @@ 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());
@ -54,24 +56,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 +88,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));
// No need to setConnectionInProgress(false) here if requestPort is quick
}).catch((error) => { }).catch((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>
); );

65
src/components/PageComponents/Map/NodeDetail.tsx

@ -26,8 +26,9 @@ import { useDevice } from "@core/stores/deviceStore.ts";
import { import {
MessageType, MessageType,
useMessageStore, useMessageStore,
} from "../../../core/stores/messageStore/index.ts"; } from "@core/stores/messageStore/index.ts";
import BatteryStatus from "@components/BatteryStatus.tsx"; import BatteryStatus from "@components/BatteryStatus.tsx";
import { useTranslation } from "react-i18next";
export interface NodeDetailProps { export interface NodeDetailProps {
node: ProtobufType.Mesh.NodeInfo; node: ProtobufType.Mesh.NodeInfo;
@ -35,13 +36,19 @@ export interface NodeDetailProps {
export const NodeDetail = ({ node }: NodeDetailProps) => { export const NodeDetail = ({ node }: NodeDetailProps) => {
const { setChatType, setActiveChat } = useMessageStore(); const { setChatType, setActiveChat } = useMessageStore();
const { t } = useTranslation("nodes");
const { setActivePage } = useDevice(); const { setActivePage } = useDevice();
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); setChatType(MessageType.Direct);
setActiveChat(node.num); setActiveChat(node.num);
@ -66,7 +73,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 +81,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 +101,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 +112,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 +141,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 +160,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 +188,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 +196,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 +206,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>
)} )}

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

@ -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 = () => {
<div className="flex flex-1 flex-col place-content-center place-items-center p-8 text-slate-500 dark:text-slate-400"> const { t } = useTranslation("messages");
<InboxIcon className="mb-2 h-8 w-8" /> return (
<span className="text-sm">No Messages</span> <div className="flex flex-1 flex-col place-content-center place-items-center p-8 text-slate-500 dark:text-slate-400">
</div> <InboxIcon className="mb-2 h-8 w-8" />
); <span className="text-sm">{t("emptyState.text")}</span>
</div>
);
};
export const ChannelChat = ({ messages = [] }: ChannelChatProps) => { export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
if (!messages?.length) { if (!messages?.length) {

10
src/components/PageComponents/Messages/MessageActionsMenu.tsx

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

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

@ -60,6 +60,7 @@ export const MessageInput = ({
minLength={1} minLength={1}
name="messageInput" name="messageInput"
placeholder="Enter Message" placeholder="Enter Message"
autoComplete="off"
value={localDraft} value={localDraft}
onChange={handleInputChange} onChange={handleInputChange}
/> />

77
src/components/PageComponents/Messages/MessageItem.tsx

@ -17,6 +17,7 @@ import {
} from "@core/stores/messageStore/index.ts"; } from "@core/stores/messageStore/index.ts";
import { Protobuf, Types } from "@meshtastic/js"; import { Protobuf, Types } from "@meshtastic/js";
import { Message } from "@core/stores/messageStore/types.ts"; import { Message } from "@core/stores/messageStore/types.ts";
import { useTranslation } from "react-i18next";
// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // Uncomment if needed later // import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // Uncomment if needed later
interface MessageStatusInfo { interface MessageStatusInfo {
@ -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();
const MESSAGE_STATUS_MAP = useMemo(
(): Record<MessageState, MessageStatusInfo> => ({
[MessageState.Ack]: {
displayText: t("message_item_status_delivered_displayText"),
icon: CheckCircle2,
ariaLabel: t("message_item_status_delivered_ariaLabel"),
iconClassName: "text-green-500",
},
[MessageState.Waiting]: {
displayText: t("message_item_status_waiting_displayText"),
icon: CircleEllipsis,
ariaLabel: t("message_item_status_waiting_ariaLabel"),
iconClassName: "text-slate-400",
},
[MessageState.Failed]: {
displayText: t("message_item_status_failed_displayText"),
icon: AlertCircle,
ariaLabel: t("message_item_status_failed_ariaLabel"),
iconClassName: "text-red-500 dark:text-red-400",
},
}),
[t],
);
const UNKNOWN_STATUS = useMemo((): MessageStatusInfo => ({
displayText: t("message_item_status_unknown_displayText"),
icon: AlertCircle,
ariaLabel: t("message_item_status_unknown_ariaLabel"),
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("message_item_fallbackName_withLastFour", { 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(
() => () =>

21
src/components/PageComponents/Messages/TraceRoute.test.tsx

@ -48,9 +48,9 @@ describe("TraceRoute", () => {
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", () => {
@ -74,11 +74,11 @@ describe("TraceRoute", () => {
expect(screen.getByText("Node C")).toBeInTheDocument(); expect(screen.getByText("Node C")).toBeInTheDocument();
expect(screen.getByText("Node A")).toBeInTheDocument(); expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getByText("↓ 35dB")).toBeInTheDocument(); expect(screen.getByText("↓ 35dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 45dB")).toBeInTheDocument(); expect(screen.getByText("↓ 45dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 15dB")).toBeInTheDocument(); expect(screen.getByText("↓ 15dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 25dB")).toBeInTheDocument(); expect(screen.getByText("↓ 25dBm")).toBeInTheDocument();
}); });
it("renders '??' for missing SNR values", () => { it("renders '??' for missing SNR values", () => {
@ -91,7 +91,7 @@ describe("TraceRoute", () => {
); );
expect(screen.getByText("Node A")).toBeInTheDocument(); expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getAllByText("↓ ??dB")).toHaveLength(2); expect(screen.getAllByText("↓ ??dBm")).toHaveLength(2);
}); });
it("renders hop hex if node is not found", () => { it("renders hop hex if node is not found", () => {
@ -104,8 +104,7 @@ describe("TraceRoute", () => {
/>, />,
); );
expect(screen.getByText(/^!63$/)).toBeInTheDocument(); expect(screen.getByText("↓ 5dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 5dB")).toBeInTheDocument(); expect(screen.getByText("↓ 15dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 15dB")).toBeInTheDocument();
}); });
}); });

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

@ -1,6 +1,7 @@
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";
export interface TraceRouteProps { export interface TraceRouteProps {
from?: Protobuf.Mesh.NodeInfo; from?: Protobuf.Mesh.NodeInfo;
@ -23,6 +24,7 @@ const RoutePath = (
{ title, startNode, endNode, path, snr }: RoutePathProps, { title, startNode, endNode, path, snr }: RoutePathProps,
) => { ) => {
const { getNode } = useDevice(); const { getNode } = useDevice();
const { t } = useTranslation();
return ( return (
<span <span
@ -31,13 +33,20 @@ const RoutePath = (
> >
<p className="font-semibold">{title}</p> <p className="font-semibold">{title}</p>
<p>{startNode?.user?.longName}</p> <p>{startNode?.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>{endNode?.user?.longName}</p>
@ -53,18 +62,19 @@ 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} startNode={to}
endNode={from} endNode={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} startNode={from}
endNode={to} endNode={to}
path={routeBack} path={routeBack}

28
src/components/PageComponents/ModuleConfig/AmbientLighting.tsx

@ -1,11 +1,13 @@
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 } 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 } from "@components/Form/DynamicForm.tsx";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
export const AmbientLighting = () => { export const AmbientLighting = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice(); const { moduleConfig, setWorkingModuleConfig } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: AmbientLightingValidation) => { const onSubmit = (data: AmbientLightingValidation) => {
setWorkingModuleConfig( setWorkingModuleConfig(
@ -24,38 +26,38 @@ export const AmbientLighting = () => {
defaultValues={moduleConfig.ambientLighting} defaultValues={moduleConfig.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"),
}, },
], ],
}, },

36
src/components/PageComponents/ModuleConfig/Audio.tsx

@ -1,11 +1,13 @@
import type { AudioValidation } from "@app/validation/moduleConfig/audio.tsx"; import type { AudioValidation } 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 } 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";
export const Audio = () => { export const Audio = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice(); const { moduleConfig, setWorkingModuleConfig } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: AudioValidation) => { const onSubmit = (data: AudioValidation) => {
setWorkingModuleConfig( setWorkingModuleConfig(
@ -24,26 +26,26 @@ export const Audio = () => {
defaultValues={moduleConfig.audio} defaultValues={moduleConfig.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 +54,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"),
}, },
], ],
}, },

53
src/components/PageComponents/ModuleConfig/CannedMessage.tsx

@ -1,11 +1,13 @@
import type { CannedMessageValidation } from "@app/validation/moduleConfig/cannedMessage.tsx"; import type { CannedMessageValidation } 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 } 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";
export const CannedMessage = () => { export const CannedMessage = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice(); const { moduleConfig, setWorkingModuleConfig } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: CannedMessageValidation) => { const onSubmit = (data: CannedMessageValidation) => {
setWorkingModuleConfig( setWorkingModuleConfig(
@ -24,44 +26,44 @@ export const CannedMessage = () => {
defaultValues={moduleConfig.cannedMessage} defaultValues={moduleConfig.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 +72,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 +82,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 +92,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"),
}, },
], ],
}, },

50
src/components/PageComponents/ModuleConfig/DetectionSensor.tsx

@ -1,11 +1,13 @@
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 } 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 } from "@components/Form/DynamicForm.tsx";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
export const DetectionSensor = () => { export const DetectionSensor = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice(); const { moduleConfig, setWorkingModuleConfig } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: DetectionSensorValidation) => { const onSubmit = (data: DetectionSensorValidation) => {
setWorkingModuleConfig( setWorkingModuleConfig(
@ -24,23 +26,24 @@ export const DetectionSensor = () => {
defaultValues={moduleConfig.detectionSensor} defaultValues={moduleConfig.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 +54,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 +65,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 +76,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 +87,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",
@ -97,9 +98,10 @@ export const DetectionSensor = () => {
{ {
type: "toggle", type: "toggle",
name: "detectionTriggeredHigh", name: "detectionTriggeredHigh",
label: "Detection Triggered High", label: t("detectionSensor.detectionTriggeredHigh.label"),
description: description: t(
"Whether or not the GPIO pin state detection is triggered on HIGH (1), otherwise LOW (0)", "detectionSensor.detectionTriggeredHigh.description",
),
disabledBy: [ disabledBy: [
{ {
fieldName: "enabled", fieldName: "enabled",
@ -109,8 +111,8 @@ export const DetectionSensor = () => {
{ {
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",

77
src/components/PageComponents/ModuleConfig/ExternalNotification.tsx

@ -1,11 +1,13 @@
import type { ExternalNotificationValidation } from "@app/validation/moduleConfig/externalNotification.tsx"; import type { ExternalNotificationValidation } 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 } 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";
export const ExternalNotification = () => { export const ExternalNotification = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice(); const { moduleConfig, setWorkingModuleConfig } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: ExternalNotificationValidation) => { const onSubmit = (data: ExternalNotificationValidation) => {
setWorkingModuleConfig( setWorkingModuleConfig(
@ -24,20 +26,20 @@ export const ExternalNotification = () => {
defaultValues={moduleConfig.externalNotification} defaultValues={moduleConfig.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 +47,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 +64,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 +75,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 +86,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 +97,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 +108,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 +121,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 +134,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 +145,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 +156,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 +169,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 +180,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 +191,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",

148
src/components/PageComponents/ModuleConfig/MQTT.tsx

@ -1,11 +1,13 @@
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 } 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 } from "@components/Form/DynamicForm.tsx";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
export const MQTT = () => { export const MQTT = () => {
const { config, moduleConfig, setWorkingModuleConfig } = useDevice(); const { config, moduleConfig, setWorkingModuleConfig } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: MqttValidation) => { const onSubmit = (data: MqttValidation) => {
setWorkingModuleConfig( setWorkingModuleConfig(
@ -30,21 +32,20 @@ export const MQTT = () => {
defaultValues={moduleConfig.mqtt} defaultValues={moduleConfig.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 +55,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 +66,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 +77,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 +88,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 +99,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 +110,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 +121,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 +132,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 +143,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 +162,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: [

19
src/components/PageComponents/ModuleConfig/NeighborInfo.tsx

@ -1,11 +1,13 @@
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 } 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 } from "@components/Form/DynamicForm.tsx";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
export const NeighborInfo = () => { export const NeighborInfo = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice(); const { moduleConfig, setWorkingModuleConfig } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: NeighborInfoValidation) => { const onSubmit = (data: NeighborInfoValidation) => {
setWorkingModuleConfig( setWorkingModuleConfig(
@ -24,23 +26,22 @@ export const NeighborInfo = () => {
defaultValues={moduleConfig.neighborInfo} defaultValues={moduleConfig.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: [
{ {

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

@ -3,9 +3,11 @@ import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { DynamicForm } 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";
export const Paxcounter = () => { export const Paxcounter = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice(); const { moduleConfig, setWorkingModuleConfig } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: PaxcounterValidation) => { const onSubmit = (data: PaxcounterValidation) => {
setWorkingModuleConfig( setWorkingModuleConfig(
@ -24,23 +26,22 @@ export const Paxcounter = () => {
defaultValues={moduleConfig.paxcounter} defaultValues={moduleConfig.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: [
{ {
@ -51,9 +52,8 @@ export const Paxcounter = () => {
{ {
type: "number", type: "number",
name: "wifiThreshold", name: "wifiThreshold",
label: "WiFi RSSI Threshold", label: t("paxcounter.wifiThreshold.label"),
description: description: t("paxcounter.wifiThreshold.description"),
"At what WiFi RSSI level should the counter increase. Defaults to -80.",
disabledBy: [ disabledBy: [
{ {
fieldName: "enabled", fieldName: "enabled",
@ -63,9 +63,8 @@ export const Paxcounter = () => {
{ {
type: "number", type: "number",
name: "bleThreshold", name: "bleThreshold",
label: "BLE RSSI Threshold", label: t("paxcounter.bleThreshold.label"),
description: description: t("paxcounter.bleThreshold.description"),
"At what BLE RSSI level should the counter increase. Defaults to -80.",
disabledBy: [ disabledBy: [
{ {
fieldName: "enabled", fieldName: "enabled",

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

@ -1,11 +1,13 @@
import type { RangeTestValidation } from "@app/validation/moduleConfig/rangeTest.tsx"; import type { RangeTestValidation } 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 } 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";
export const RangeTest = () => { export const RangeTest = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice(); const { moduleConfig, setWorkingModuleConfig } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: RangeTestValidation) => { const onSubmit = (data: RangeTestValidation) => {
setWorkingModuleConfig( setWorkingModuleConfig(
@ -24,22 +26,22 @@ export const RangeTest = () => {
defaultValues={moduleConfig.rangeTest} defaultValues={moduleConfig.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 +52,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",

46
src/components/PageComponents/ModuleConfig/Serial.tsx

@ -1,11 +1,13 @@
import type { SerialValidation } from "@app/validation/moduleConfig/serial.tsx"; import type { SerialValidation } 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 } 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";
export const Serial = () => { export const Serial = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice(); const { moduleConfig, setWorkingModuleConfig } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: SerialValidation) => { const onSubmit = (data: SerialValidation) => {
setWorkingModuleConfig( setWorkingModuleConfig(
@ -24,21 +26,20 @@ export const Serial = () => {
defaultValues={moduleConfig.serial} defaultValues={moduleConfig.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 +49,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 +60,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 +71,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 +87,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 +118,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.",
}, },
], ],
}, },

29
src/components/PageComponents/ModuleConfig/StoreForward.tsx

@ -3,9 +3,11 @@ import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { DynamicForm } 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";
export const StoreForward = () => { export const StoreForward = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice(); const { moduleConfig, setWorkingModuleConfig } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: StoreForwardValidation) => { const onSubmit = (data: StoreForwardValidation) => {
setWorkingModuleConfig( setWorkingModuleConfig(
@ -24,20 +26,20 @@ export const StoreForward = () => {
defaultValues={moduleConfig.storeForward} defaultValues={moduleConfig.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 +49,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 +74,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",

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

@ -3,9 +3,11 @@ import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { DynamicForm } 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";
export const Telemetry = () => { export const Telemetry = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice(); const { moduleConfig, setWorkingModuleConfig } = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: TelemetryValidation) => { const onSubmit = (data: TelemetryValidation) => {
setWorkingModuleConfig( setWorkingModuleConfig(
@ -24,74 +26,78 @@ export const Telemetry = () => {
defaultValues={moduleConfig.telemetry} defaultValues={moduleConfig.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"),
}, },
], ],
}, },

160
src/components/Sidebar.tsx

@ -1,30 +1,25 @@
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";
export interface SidebarProps { export interface SidebarProps {
children?: React.ReactNode; children?: React.ReactNode;
@ -39,7 +34,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
@ -79,22 +77,47 @@ export const Sidebar = ({ children }: SidebarProps) => {
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 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",
}, },
@ -119,7 +142,7 @@ 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,11 +154,14 @@ 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
label={t("navigation.title")}
className="mt-4 px-0"
>
{pages.map((link) => ( {pages.map((link) => (
<SidebarButton <SidebarButton
key={link.name} key={link.name}
@ -173,94 +199,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.firmwareVersion")}
size="sm" deviceMetrics={{
/> batteryLevel: myNode.deviceMetrics?.batteryLevel,
<p voltage: myNode.deviceMetrics?.voltage,
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>

86
src/components/ThemeSwitcher.tsx

@ -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"
onClick={toggleTheme}
id="theme-switcher"
aria-label={t("theme.changeTheme")}
className={cn( className={cn(
"transition-all duration-300 scale-100 cursor-pointer m-3 p-2 focus:*:data-label:opacity-100", "group relative flex justify-start",
className, "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,
)} )}
onClick={toggleTheme}
aria-description="Change current theme"
> >
<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>
); );
} }

7
src/components/UI/Avatar.tsx

@ -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
@ -136,7 +139,7 @@ export const Avatar = ({
</TooltipProvider> </TooltipProvider>
) )
: null} : null}
<p className="p-1"> <p className="p-1 text-nowrap">
{initials} {initials}
</p> </p>
</div> </div>

31
src/components/UI/Footer.tsx

@ -1,4 +1,5 @@
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { Trans } from "react-i18next";
type FooterProps = { type FooterProps = {
className?: string; className?: string;
@ -14,19 +15,23 @@ const Footer = ({ className, ...props }: FooterProps) => {
{...props} {...props}
> >
<p> <p>
<a <Trans
href="https://vercel.com/?utm_source=meshtastic&utm_campaign=oss" i18nKey="footer.text"
className="hover:underline text-link" components={[
> <a
Powered by Vercel key="vercel"
</a>{" "} rel="noopener noreferrer"
| Meshtastic® is a registered trademark of Meshtastic LLC. |{" "} href="https://vercel.com/?utm_source=meshtastic&utm_campaign=oss"
<a className="hover:underline text-link"
href="https://meshtastic.org/docs/legal" />,
className="hover:underline text-link" <a
> key="legal"
Legal Information rel="noopener noreferrer"
</a> href="https://meshtastic.org/docs/legal"
className="hover:underline text-link"
/>,
]}
/>
</p> </p>
</footer> </footer>
); );

22
src/components/UI/Input.tsx

@ -4,6 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { Check, Copy, Eye, EyeOff, type LucideIcon, X } from "lucide-react"; import { Check, Copy, Eye, EyeOff, type LucideIcon, X } from "lucide-react";
import { useCopyToClipboard } from "@core/hooks/useCopyToClipboard.ts"; import { useCopyToClipboard } from "@core/hooks/useCopyToClipboard.ts";
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts"; import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
import { useTranslation } from "react-i18next";
const inputVariants = cva( const inputVariants = cva(
"flex h-10 w-full rounded-md border border-slate-300 bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-slate-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:bg-transparet dark:text-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-600", "flex h-10 w-full rounded-md border border-slate-300 bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-slate-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:bg-transparet dark:text-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-600",
@ -62,6 +63,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
) => { ) => {
const { isVisible, toggleVisibility } = usePasswordVisibilityToggle(); const { isVisible, toggleVisibility } = usePasswordVisibilityToggle();
const { copy, isCopied } = useCopyToClipboard({ timeout: 1500 }); const { copy, isCopied } = useCopyToClipboard({ timeout: 1500 });
const { t } = useTranslation("ui");
const potentialActions: InputActionType[] = [ const potentialActions: InputActionType[] = [
{ {
@ -80,8 +82,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
ref.current.focus(); ref.current.focus();
} }
}, },
ariaLabel: "Clear input", ariaLabel: t("clearInput.label"),
tooltip: "Clear input", tooltip: t("clearInput.label"),
condition: !!showClearButton && !!value, condition: !!showClearButton && !!value,
}, },
{ {
@ -91,8 +93,12 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
e.stopPropagation(); e.stopPropagation();
toggleVisibility(); toggleVisibility();
}, },
ariaLabel: isVisible ? "Hide password" : "Show password", ariaLabel: isVisible
tooltip: isVisible ? "Hide password" : "Show password", ? t("notifications.hidePassword.label")
: t("notifications.showPassword.label"),
tooltip: isVisible
? t("notifications.hidePassword.label")
: t("notifications.showPassword.label"),
condition: !!showPasswordToggle && type === "password", condition: !!showPasswordToggle && type === "password",
}, },
{ {
@ -104,8 +110,12 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
copy(String(value)); copy(String(value));
} }
}, },
ariaLabel: isCopied ? "Copied!" : "Copy to clipboard", ariaLabel: isCopied
tooltip: isCopied ? "Copied!" : "Copy to clipboard", ? t("notifications.copied.label")
: t("notifications.copyToClipboard.label"),
tooltip: isCopied
? t("notifications.copied.label")
: t("notifications.copyToClipboard.label"),
condition: !!showCopyButton, condition: !!showCopyButton,
}, },
]; ];

317
src/components/generic/Filter/FilterControl.tsx

@ -1,15 +1,19 @@
import { import {
type ComponentProps, type ComponentProps,
ReactNode, ReactNode,
useCallback,
useEffect, useEffect,
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { FunnelIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { debounce } from "@core/utils/debounce.ts"; import { debounce } from "@core/utils/debounce.ts";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import type { FilterState } from "@components/generic/Filter/useFilterNode.ts";
import { import {
Popover, Popover,
@ -18,8 +22,8 @@ import {
} from "@components/UI/Popover.tsx"; } from "@components/UI/Popover.tsx";
import { Input } from "@components/UI/Input.tsx"; import { Input } from "@components/UI/Input.tsx";
import { Accordion } from "@components/UI/Accordion.tsx"; import { Accordion } from "@components/UI/Accordion.tsx";
import { FunnelIcon } from "lucide-react";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { import {
FilterAccordionItem, FilterAccordionItem,
FilterMulti, FilterMulti,
@ -27,6 +31,11 @@ import {
FilterToggle, FilterToggle,
} from "@components/generic/Filter/FilterComponents.tsx"; } from "@components/generic/Filter/FilterComponents.tsx";
import type { FilterState } from "@components/generic/Filter/useFilterNode.ts";
const DEBOUNCE_DELAY_MS = 250;
const BATTERY_STATUS_PLUGGED_IN_VALUE = 101;
type PopoverContentProps = ComponentProps<typeof PopoverContent>; type PopoverContentProps = ComponentProps<typeof PopoverContent>;
interface FilterControlProps { interface FilterControlProps {
@ -40,10 +49,93 @@ interface FilterControlProps {
popoverTriggerClassName?: string; popoverTriggerClassName?: string;
showTextSearch?: boolean; showTextSearch?: boolean;
}; };
children?: ReactNode; children?: ReactNode;
} }
interface HopsLabelProps {
hopsAway: number[];
t: TFunction<"ui", undefined>;
}
function HopsLabelContent({ hopsAway, t }: HopsLabelProps) {
const startHops = hopsAway[0];
const endHops = hopsAway[1];
return (
<>
{t("hops.text", {
value: startHops === 0 ? t("hops.direct") : startHops,
})}
{startHops !== endHops ? `${endHops}` : ""}
</>
);
}
interface LastHeardLabelProps {
lastHeardRange: number[];
defaultMaxLastHeard: number;
formatTS: (ts: number) => ReactNode;
t: TFunction<"ui", undefined>;
}
function LastHeardLabelContent(
{ lastHeardRange, defaultMaxLastHeard, formatTS, t }: LastHeardLabelProps,
) {
const [start, end] = lastHeardRange;
return (
<>
{t("lastHeard.labelText", { value: "" })}
<br />
{start === 0
? (
t("lastHeard.nowLabel")
)
: (
<>
{start === defaultMaxLastHeard && ">"}
{formatTS(start)}
</>
)}
{start !== end && (
<>
{" — "}
{end === defaultMaxLastHeard && ">"}
{formatTS(end)}
</>
)}
</>
);
}
interface BatteryLevelLabelProps {
batteryLevelRange: (number | undefined)[];
t: TFunction<"ui", undefined>;
}
function BatteryLevelLabelContent(
{ batteryLevelRange, t }: BatteryLevelLabelProps,
) {
const [start, end] = batteryLevelRange;
const formatBatteryValue = (value: number | undefined) => {
if (value === undefined) return "";
return value === BATTERY_STATUS_PLUGGED_IN_VALUE
? t("batteryStatus.pluggedIn")
: `${value}%`;
};
return (
<>
{t("batteryLevel.labelText", {
value: formatBatteryValue(start),
})}
{start !== end && typeof end !== "undefined" && (
<>
{" – "}
{formatBatteryValue(end)}
</>
)}
</>
);
}
export function FilterControl({ export function FilterControl({
filterState, filterState,
defaultFilterValues, defaultFilterValues,
@ -52,69 +144,78 @@ export function FilterControl({
parameters, parameters,
children, children,
}: FilterControlProps) { }: FilterControlProps) {
// Copy of the state that we only use for rendering sliders and their labels directly, rest is debounced const { t } = useTranslation("ui");
const [localFilterState, setLocalFilterState] = useState(filterState); const [localFilterState, setLocalFilterState] = useState(filterState);
const skipNextSync = useRef(false); const skipNextFilterStateSync = useRef(false);
useEffect(() => { useEffect(() => {
if (skipNextSync.current) { if (skipNextFilterStateSync.current) {
skipNextSync.current = false; skipNextFilterStateSync.current = false;
return; return;
} }
setLocalFilterState(filterState); setLocalFilterState(filterState);
}, [filterState]); }, [filterState]);
const handleTextChange = const handleTextChange = useCallback(
<K extends keyof FilterState>(key: K) => <K extends keyof FilterState>(key: K) =>
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
setFilterState((prev) => ({ setFilterState((prev) => ({
...prev, ...prev,
[key]: e.target.value, [key]: e.target.value,
})); }));
}; },
const handleRangeChange = [setFilterState],
);
const debouncedSetFilterState = useCallback(
debounce(<K extends keyof FilterState>(key: K, value: number[]) => {
skipNextFilterStateSync.current = true;
setFilterState((prev) => ({
...prev,
[key]: value,
}));
}, DEBOUNCE_DELAY_MS),
[setFilterState],
);
const handleRangeChange = useCallback(
<K extends keyof FilterState>(key: K) => (value: number[]) => { <K extends keyof FilterState>(key: K) => (value: number[]) => {
// immediate slider update
setLocalFilterState((prev) => ({ setLocalFilterState((prev) => ({
...prev, ...prev,
[key]: value, [key]: value,
})); }));
debouncedSetFilterState(key, value);
},
[debouncedSetFilterState],
);
// debounced write to filterState (table/map render) const handleBoolChange = useCallback(
debounce( <K extends keyof FilterState>(key: K, value: string) => {
() => { const typedValue = value === ""
skipNextSync.current = true; ? undefined
setFilterState((prev) => ({ : JSON.parse(value.toLowerCase());
...prev,
[key]: value,
}));
},
250,
)();
};
const handleBoolChange = <K extends keyof FilterState>(
key: K,
value: string,
) => {
const typedValue = value === ""
? undefined
: JSON.parse(value.toLowerCase());
setFilterState((prev) => ({ setFilterState((prev) => ({
...prev, ...prev,
[key]: typedValue, [key]: typedValue,
})); }));
}; },
[setFilterState],
);
const resetFilters = () => { const resetFilters = useCallback(() => {
setFilterState(defaultFilterValues); setFilterState(defaultFilterValues);
}; }, [defaultFilterValues, setFilterState]);
function formatTS(ts: number): ReactNode { const formatTS = useCallback(
return <TimeAgo timestamp={Date.now() - ts * 1000} />; (ts: number): ReactNode => <TimeAgo timestamp={Date.now() - ts * 1000} />,
} [],
function formatEnumLabel(label: string): string { );
return label.replace(/_/g, " ");
} const formatEnumLabel = useCallback(
(label: string): string => label.replace(/_/g, " "),
[],
);
return ( return (
<Popover> <Popover>
@ -130,7 +231,7 @@ export function FilterControl({
: "", : "",
parameters?.popoverTriggerClassName, parameters?.popoverTriggerClassName,
)} )}
aria-label="Filter" aria-label={t("filter.label")}
> >
{parameters?.triggerIcon ?? <FunnelIcon />} {parameters?.triggerIcon ?? <FunnelIcon />}
</button> </button>
@ -145,135 +246,112 @@ export function FilterControl({
<form className="space-y-4"> <form className="space-y-4">
<Accordion <Accordion
type="single" type="single"
defaultValue="General" defaultValue={t("general.label")}
collapsible collapsible
> >
<FilterAccordionItem label="General"> <FilterAccordionItem label={t("general.label")}>
{(parameters?.showTextSearch ?? true) && ( {(parameters?.showTextSearch ?? true) && (
<div className="flex flex-col space-y-1 pb-2"> <div className="flex flex-col space-y-1 pb-2">
<label htmlFor="nodeName" className="font-medium text-sm"> <label htmlFor="nodeName" className="font-medium text-sm">
Node name/number {t("nodeName.label")}
</label> </label>
<Input <Input
type="text" type="text"
id="nodeName"
value={filterState.nodeName} value={filterState.nodeName}
onChange={handleTextChange("nodeName")} onChange={handleTextChange("nodeName")}
showClearButton showClearButton
placeholder="Meshtastic 1234" placeholder={t("nodeName.placeholder")}
/> />
</div> </div>
)} )}
<FilterSlider <FilterSlider
label="Number of hops" label={t("hops.label")}
filterKey="hopsAway" filterKey="hopsAway"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange} onChange={handleRangeChange}
labelContent={ labelContent={
<> <HopsLabelContent
Number of hops: {localFilterState.hopsAway[0] === 0 hopsAway={localFilterState.hopsAway}
? "Direct" t={t}
: localFilterState.hopsAway[0]} />
{localFilterState.hopsAway[0] !==
localFilterState.hopsAway[1]
? " — " + localFilterState.hopsAway[1]
: ""}
</>
} }
/> />
<FilterSlider <FilterSlider
label="Last heard" label={t("lastHeard.label")}
filterKey="lastHeard" filterKey="lastHeard"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange} onChange={handleRangeChange}
labelContent={ labelContent={
<> <LastHeardLabelContent
Last heard: <br /> lastHeardRange={localFilterState.lastHeard}
{localFilterState.lastHeard[0] === 0 ? "Now" : ( defaultMaxLastHeard={defaultFilterValues.lastHeard[1]}
<> formatTS={formatTS}
{localFilterState.lastHeard[0] === t={t}
defaultFilterValues.lastHeard[1] && ">"} />
{formatTS(localFilterState.lastHeard[0])}
</>
)}
{localFilterState.lastHeard[0] !==
localFilterState.lastHeard[1] && (
<>
{" — "}
{localFilterState.lastHeard[1] ===
defaultFilterValues.lastHeard[1] && ">"}
{formatTS(localFilterState.lastHeard[1])}
</>
)}
</>
} }
/> />
<FilterToggle <FilterToggle
label="Favorites" label={t("favorites.label")}
filterKey="isFavorite" filterKey="isFavorite"
alternativeLabels={["Hide", "Show Only"]} alternativeLabels={[
t("hide.label"),
t("showOnly.label"),
]}
filterState={filterState} filterState={filterState}
onChange={handleBoolChange} onChange={handleBoolChange}
/> />
<FilterToggle <FilterToggle
label="Connected via MQTT" label={t("viaMqtt.label")}
filterKey="viaMqtt" filterKey="viaMqtt"
alternativeLabels={["Hide", "Show Only"]} alternativeLabels={[
t("hide.label"),
t("showOnly.label"),
]}
filterState={filterState} filterState={filterState}
onChange={handleBoolChange} onChange={handleBoolChange}
/> />
</FilterAccordionItem> </FilterAccordionItem>
<FilterAccordionItem label="Metrics"> <FilterAccordionItem label={t("metrics.label")}>
<FilterSlider <FilterSlider
label="SNR (db)" label={t("snr.label")}
filterKey="snr" filterKey="snr"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange} onChange={handleRangeChange}
/> />
<FilterSlider <FilterSlider
label="Channel Utilization (%)" label={t("channelUtilization.label")}
filterKey="channelUtilization" filterKey="channelUtilization"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange} onChange={handleRangeChange}
/> />
<FilterSlider <FilterSlider
label="Airtime Utilization (%)" label={t("airtimeUtilization.label")}
filterKey="airUtilTx" filterKey="airUtilTx"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange} onChange={handleRangeChange}
/> />
<FilterSlider <FilterSlider
label="Battery level (%)" label={t("batteryLevel.label")}
filterKey="batteryLevel" filterKey="batteryLevel"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange} onChange={handleRangeChange}
labelContent={ labelContent={
<> <BatteryLevelLabelContent
Battery level (%): {localFilterState.batteryLevel[0] === 101 batteryLevelRange={localFilterState.batteryLevel}
? "Plugged in" t={t}
: localFilterState.batteryLevel[0]} />
{localFilterState.batteryLevel[0] !==
localFilterState.batteryLevel[1] && (
<>
{" – "}
{localFilterState.batteryLevel[1] === 101
? "Plugged in"
: localFilterState.batteryLevel[1]}
</>
)}
</>
} }
/> />
<FilterSlider <FilterSlider
label="Battery voltage (V)" label={t("batteryVoltage.label")}
filterKey="voltage" filterKey="voltage"
filterState={localFilterState} filterState={localFilterState}
defaultFilterValues={defaultFilterValues} defaultFilterValues={defaultFilterValues}
@ -281,30 +359,29 @@ export function FilterControl({
/> />
</FilterAccordionItem> </FilterAccordionItem>
<FilterAccordionItem label="Role"> <FilterAccordionItem label={t("role.label")}>
<FilterMulti <FilterMulti
filterKey="role" filterKey="role"
filterState={filterState} filterState={filterState}
setFilterState={setFilterState} setFilterState={setFilterState}
options={Object.values(Protobuf.Config.Config_DeviceConfig_Role) options={Object.values(
.filter( Protobuf.Config.Config_DeviceConfig_Role,
(v): v is number => typeof v === "number", ).filter((v): v is number => typeof v === "number")}
)}
getLabel={(val) => getLabel={(val) =>
formatEnumLabel( formatEnumLabel(
Protobuf.Config.Config_DeviceConfig_Role[val], Protobuf.Config.Config_DeviceConfig_Role[val],
)} )}
/> />
</FilterAccordionItem> </FilterAccordionItem>
<FilterAccordionItem label="Hardware">
<FilterAccordionItem label={t("hardware.label")}>
<FilterMulti <FilterMulti
filterKey="hwModel" filterKey="hwModel"
filterState={filterState} filterState={filterState}
setFilterState={setFilterState} setFilterState={setFilterState}
options={Object.values(Protobuf.Mesh.HardwareModel) options={Object.values(Protobuf.Mesh.HardwareModel).filter(
.filter( (v): v is number => typeof v === "number",
(v): v is number => typeof v === "number", )}
)}
getLabel={(val) => getLabel={(val) =>
formatEnumLabel(Protobuf.Mesh.HardwareModel[val])} formatEnumLabel(Protobuf.Mesh.HardwareModel[val])}
/> />
@ -313,15 +390,11 @@ export function FilterControl({
<button <button
type="button" type="button"
onClick={resetFilters} onClick={resetFilters}
className="w-full py-1 shadow-sm hover:shadow-md bg-slate-600 dark:bg-slate-900 text-white rounded text-sm hover:text-slate-100 hover:bg-slate-700 active:bg-slate-950" className="w-full py-1 shadow-sm hover:shadow-md bg-slate-600 dark:bg-slate-900 text-white rounded text-sm hover:text-slate-100 hover:bg-slate-700 active:bg-slate-950"
> >
Reset Filters {t("button.reset")}
</button> </button>
{children && ( {children && <div className="mt-4 border-t pt-4">{children}</div>}
<div className="mt-4 border-t pt-4">
{children}
</div>
)}
</form> </form>
</PopoverContent> </PopoverContent>
</Popover> </Popover>

4
src/components/types.ts

@ -0,0 +1,4 @@
export type DeviceMetrics = {
batteryLevel?: number | null;
voltage?: number | null;
};

92
src/core/hooks/useLang.ts

@ -0,0 +1,92 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { LangCode } from "@app/i18n/config.ts";
import useLocalStorage from "./useLocalStorage.ts";
/**
* Hook to set the i18n language
*
* @returns The `set` function
*/
const STORAGE_KEY = "language";
type LanguageState = {
language: string;
};
function useLang() {
const { i18n } = useTranslation();
const [_, setLanguage] = useLocalStorage<LanguageState | null>(
STORAGE_KEY,
null,
);
const regionNames = useMemo(() => {
return new Intl.DisplayNames(i18n.language, {
type: "region",
fallback: "none",
style: "long",
});
}, [i18n.language]);
const collator = useMemo(() => {
return new Intl.Collator(i18n.language, {});
}, [i18n.language]);
/**
* Sets the i18n language.
*
* @param lng - The language tag to set
*/
const set = useCallback(
async (lng: LangCode, persist = true) => {
if (i18n.language === lng) {
return;
}
console.info("set language:", lng);
if (persist) {
try {
setLanguage({ language: lng });
} catch (e) {
console.warn(e);
}
await i18n.changeLanguage(lng);
}
},
[i18n],
);
/**
* Get the localized country name
*
* @param code - Two-letter country code
*/
const getCountryName = useCallback(
(code: LangCode) => {
let name = null;
try {
name = regionNames.of(code);
} catch (e) {
console.warn(e);
}
return name;
},
[regionNames],
);
/**
* Compare two strings according to the sort order of the current language
*
* @param a - The first string to compare
* @param b - The second string to compare
*/
const compare = useCallback(
(a: string, b: string) => {
return collator.compare(a, b);
},
[collator],
);
return { compare, set, getCountryName };
}
export default useLang;

99
src/core/hooks/usePositionFlags.ts

@ -1,27 +1,44 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
const FLAGS = { export const FLAGS_CONFIG = {
UNSET: 0, UNSET: { value: 0, i18nKey: "position.flags.unset" },
Altitude: 1, ALTITUDE: { value: 1, i18nKey: "position.flags.altitude" },
"Altitude is Mean Sea Level": 2, ALTITUDE_MSL: { value: 2, i18nKey: "position.flags.altitudeMsl" },
"Altitude Geoidal Seperation": 4, ALTITUDE_GEOIDAL_SEPARATION: {
"Dilution of precision (DOP) PDOP used by default": 8, value: 4,
"If DOP is set, use HDOP / VDOP values instead of PDOP": 16, i18nKey: "position.flags.altitudeGeoidalSeparation",
"Number of satellites": 32, },
"Sequence number": 64, DOP: {
Timestamp: 128, value: 8,
"Vehicle heading": 256, i18nKey: "position.flags.dop",
"Vehicle speed": 512, },
HDOP_VDOP: {
value: 16,
i18nKey: "position.flags.hdopVdop",
},
NUM_SATELLITES: {
value: 32,
i18nKey: "position.flags.numSatellites",
},
SEQUENCE_NUMBER: {
value: 64,
i18nKey: "position.flags.sequenceNumber",
},
TIMESTAMP: { value: 128, i18nKey: "position.flags.timestamp" },
VEHICLE_HEADING: {
value: 256,
i18nKey: "position.flags.vehicleHeading",
},
VEHICLE_SPEED: { value: 512, i18nKey: "position.flags.vehicleSpeed" },
} as const; } as const;
export type FlagName = keyof typeof FLAGS; export type FlagName = keyof typeof FLAGS_CONFIG;
type FlagsObject = typeof FLAGS;
type UsePositionFlagsProps = { type UsePositionFlagsProps = {
decode: (value: number) => FlagName[]; decode: (value: number) => FlagName[];
encode: (flagNames: FlagName[]) => number; encode: (flagNames: FlagName[]) => number;
hasFlag: (value: number, flagName: FlagName) => boolean; hasFlag: (value: number, flagName: FlagName) => boolean;
getAllFlags: () => FlagsObject; getAllFlags: () => typeof FLAGS_CONFIG;
isValidValue: (value: number) => boolean; isValidValue: (value: number) => boolean;
flagsValue: number; flagsValue: number;
activeFlags: FlagName[]; activeFlags: FlagName[];
@ -34,41 +51,52 @@ type UsePositionFlagsProps = {
export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => { export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => {
const [flagsValue, setFlagsValue] = useState<number>(initialValue); const [flagsValue, setFlagsValue] = useState<number>(initialValue);
const FLAGS_BITMASKS = useMemo(() => {
return Object.fromEntries(
Object.entries(FLAGS_CONFIG).map(([key, conf]) => [key, conf.value]),
) as { [K in FlagName]: typeof FLAGS_CONFIG[K]["value"] };
}, []);
const utils = useMemo(() => { const utils = useMemo(() => {
const decode = (value: number): FlagName[] => { const decode = (value: number): FlagName[] => {
if (value === 0) return ["UNSET"]; if (value === FLAGS_CONFIG.UNSET.value) return ["UNSET"];
const activeFlags: FlagName[] = []; const activeFlags: FlagName[] = [];
for (const [name, flagValue] of Object.entries(FLAGS)) { for (const key in FLAGS_CONFIG) {
if (flagValue !== 0 && (value & flagValue) === flagValue) { const flagName = key as FlagName;
activeFlags.push(name as FlagName); const flagConfig = FLAGS_CONFIG[flagName];
if (
flagConfig.value !== 0 &&
(value & flagConfig.value) === flagConfig.value
) {
activeFlags.push(flagName);
} }
} }
return activeFlags; return activeFlags;
}; };
const encode = (flagNames: FlagName[]): number => { const encode = (flagNames: FlagName[]): number => {
if (flagNames.includes("UNSET")) { if (flagNames.includes("UNSET") && flagNames.length === 1) {
return 0; return FLAGS_CONFIG.UNSET.value;
} }
return flagNames.reduce((acc, name) => { return flagNames.reduce((acc, name) => {
const value = FLAGS[name]; if (name === "UNSET") return acc;
return acc | value; return acc | FLAGS_CONFIG[name].value;
}, 0); }, 0);
}; };
const hasFlag = (value: number, flagName: FlagName): boolean => { const hasFlag = (value: number, flagName: FlagName): boolean => {
const flagValue = FLAGS[flagName]; return (value & FLAGS_CONFIG[flagName].value) ===
return (value & flagValue) === flagValue; FLAGS_CONFIG[flagName].value;
}; };
const getAllFlags = (): FlagsObject => { const getAllFlags = (): typeof FLAGS_CONFIG => {
return FLAGS; return FLAGS_CONFIG;
}; };
const isValidValue = (value: number): boolean => { const isValidValue = (value: number): boolean => {
const maxValue = Object.values(FLAGS) const maxValue = Object.values(FLAGS_BITMASKS)
.filter((val) => val !== 0) // Exclude UNSET (0) from the calculation .filter((val) => val !== 0)
.reduce((acc, val) => acc | val, 0); .reduce((acc, val) => acc | val, 0);
return Number.isInteger(value) && value >= 0 && value <= maxValue; return Number.isInteger(value) && value >= 0 && value <= maxValue;
}; };
@ -80,16 +108,17 @@ export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => {
getAllFlags, getAllFlags,
isValidValue, isValidValue,
}; };
}, []); }, [FLAGS_BITMASKS]);
const toggleFlag = useCallback((flagName: FlagName) => { const toggleFlag = useCallback((flagName: FlagName) => {
const flagValue = FLAGS[flagName]; setFlagsValue((prev) => prev ^ FLAGS_CONFIG[flagName].value);
setFlagsValue((prev) => prev ^ flagValue);
}, []); }, []);
const setFlag = useCallback((flagName: FlagName, enabled: boolean) => { const setFlag = useCallback((flagName: FlagName, enabled: boolean) => {
const flagValue = FLAGS[flagName]; const currentFlagValue = FLAGS_CONFIG[flagName].value;
setFlagsValue((prev) => (enabled ? prev | flagValue : prev & ~flagValue)); setFlagsValue((prev) =>
enabled ? prev | currentFlagValue : prev & ~currentFlagValue
);
}, []); }, []);
const setFlags = useCallback( const setFlags = useCallback(
@ -103,7 +132,7 @@ export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => {
); );
const clearFlags = useCallback(() => { const clearFlags = useCallback(() => {
setFlagsValue(0); setFlagsValue(FLAGS_CONFIG.UNSET.value);
}, []); }, []);
const activeFlags = utils.decode(flagsValue); const activeFlags = utils.decode(flagsValue);

52
src/i18n/config.ts

@ -0,0 +1,52 @@
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import Backend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";
export type Lang = { code: string; name: string; flag: string };
export type LangCode = Lang["code"];
export const supportedLanguages: Lang[] = [
// { code: "de", name: "Deutsch", flag: "🇩🇪" },
{ code: "en", name: "English", flag: "🇺🇸" },
// { code: "es", name: "Español", flag: "🇪🇸" },
// { code: "fr", name: "Français", flag: "🇫🇷" },
// { code: "zh", name: "中文", flag: "🇨🇳" },
];
i18next
.use(Backend)
.use(initReactI18next)
.use(LanguageDetector)
.init({
backend: {
// this will lazy load resources from the i8n folder
loadPath: "/src/i18n/locales/{{lng}}/{{ns}}.json",
},
react: {
useSuspense: true,
},
detection: {
order: ["navigator", "localStorage"],
},
fallbackLng: {
"en-US": ["en"],
"en-CA": ["en-US", "en"],
"default": ["en"],
},
fallbackNS: ["common", "ui", "dialog"],
debug: import.meta.env.DEV,
supportedLngs: supportedLanguages?.map((lang) => lang.code),
ns: [
"channels",
"commandPalette",
"common",
"deviceConfig",
"configModules",
"dashboard",
"dialog",
"messages",
"nodes",
"ui",
],
});

69
src/i18n/locales/en/channels.json

@ -0,0 +1,69 @@
{
"page": {
"sectionLabel": "Channels",
"channelName": "Channel: {{channelName}}",
"broadcastLabel": "Primary",
"channelIndex": "Ch {{index}}"
},
"validation": {
"pskInvalid": "Please enter a valid {{bits}} bit PSK."
},
"settings": {
"label": "Channel Settings",
"description": "Crypto, MQTT & misc settings"
},
"role": {
"label": "Role",
"description": "Device telemetry is sent over PRIMARY. Only one PRIMARY allowed",
"options": {
"primary": "PRIMARY",
"disabled": "DISABLED",
"secondary": "SECONDARY"
}
},
"psk": {
"label": "Pre-Shared Key",
"description": "Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
"generate": "Generate"
},
"name": {
"label": "Name",
"description": "A unique name for the channel <12 bytes, leave blank for default"
},
"uplinkEnabled": {
"label": "Uplink Enabled",
"description": "Send messages from the local mesh to MQTT"
},
"downlinkEnabled": {
"label": "Downlink Enabled",
"description": "Send messages from MQTT to the local mesh"
},
"positionPrecision": {
"label": "Location",
"description": "The precision of the location to share with the channel. Can be disabled.",
"options": {
"none": "Do not share location",
"precise": "Precise Location",
"metric_km23": "Within 23 kilometers",
"metric_km12": "Within 12 kilometers",
"metric_km5_8": "Within 5.8 kilometers",
"metric_km2_9": "Within 2.9 kilometers",
"metric_km1_5": "Within 1.5 kilometers",
"metric_m700": "Within 700 meters",
"metric_m350": "Within 350 meters",
"metric_m200": "Within 200 meters",
"metric_m90": "Within 90 meters",
"metric_m50": "Within 50 meters",
"imperial_mi15": "Within 15 miles",
"imperial_mi7_3": "Within 7.3 miles",
"imperial_mi3_6": "Within 3.6 miles",
"imperial_mi1_8": "Within 1.8 miles",
"imperial_mi0_9": "Within 0.9 miles",
"imperial_mi0_5": "Within 0.5 miles",
"imperial_mi0_2": "Within 0.2 miles",
"imperial_ft600": "Within 600 feet",
"imperial_ft300": "Within 300 feet",
"imperial_ft150": "Within 150 feet"
}
}
}

50
src/i18n/locales/en/commandPalette.json

@ -0,0 +1,50 @@
{
"emptyState": "No results found.",
"page": {
"title": "Command Palette"
},
"pinGroup": {
"label": "Pin command group"
},
"unpinGroup": {
"label": "Unpin command group"
},
"goto": {
"label": "Goto",
"command": {
"messages": "Messages",
"map": "Map",
"config": "Config",
"channels": "Channels",
"nodes": "Nodes"
}
},
"manage": {
"label": "Manage",
"command": {
"switchNode": "Switch Node",
"connectNewNode": "Connect New Node"
}
},
"contextual": {
"label": "Contextual",
"command": {
"qrCode": "QR Code",
"qrGenerator": "Generator",
"qrImport": "Import",
"scheduleShutdown": "Schedule Shutdown",
"scheduleReboot": "Schedule Reboot",
"rebootToOtaMode": "Reboot To OTA Mode",
"resetNodeDb": "Reset Node DB",
"factoryResetDevice": "Factory Reset Device",
"factoryResetConfig": "Factory Reset Config"
}
},
"debug": {
"label": "Debug",
"command": {
"reconfigure": "Reconfigure",
"clearAllStoredMessages": "Clear All Stored Message"
}
}
}

73
src/i18n/locales/en/common.json

@ -0,0 +1,73 @@
{
"button": {
"apply": "Apply",
"backupKey": "Backup Key",
"cancel": "Cancel",
"clearMessages": "Clear Messages",
"close": "Close",
"confirm": "Confirm",
"delete": "Delete",
"dismiss": "Dismiss",
"download": "Download",
"export": "Export",
"generate": "Generate",
"regenerate": "Regenerate",
"import": "Import",
"message": "Message",
"now": "Now",
"ok": "OK",
"print": "Print",
"rebootOtaNow": "Reboot to OTA Mode Now",
"remove": "Remove",
"requestNewKeys": "Request New Keys",
"requestPosition": "Request Position",
"reset": "Reset",
"save": "Save",
"scanQr": "Scan QR Code",
"traceRoute": "Trace Route"
},
"app": {
"title": "Meshtastic",
"fullTitle": "Meshtastic Web Client"
},
"loading": "Loading...",
"unit": {
"cps": "CPS",
"dbm": "dBm",
"hertz": "Hz",
"hop": {
"one": "Hop",
"plural": "Hops"
},
"hopsAway": {
"one": "{{count}} hop away",
"plural": "{{count}} hops away",
"unknown": "Unknown hops away"
},
"megahertz": "MHz",
"raw": "raw",
"meter": { "one": "Meter", "plural": "Meters", "suffix": "m" },
"minute": { "one": "Minute", "plural": "Minutes" },
"millisecond": {
"one": "Millisecond",
"plural": "Milliseconds",
"suffix": "ms"
},
"second": { "one": "Second", "plural": "Seconds" },
"snr": "SNR",
"volt": { "one": "Volt", "plural": "Volts", "suffix": "V" },
"record": { "one": "Records", "plural": "Records" }
},
"security": {
"256bit": "256 bit"
},
"unknown": {
"longName": "Unknown",
"shortName": "UNK",
"notAvailable": "N/A",
"num": "??"
},
"nodeUnknownPrefix": "!",
"unset": "UNSET",
"fallbackName": "Meshtastic {{last4}}"
}

12
src/i18n/locales/en/dashboard.json

@ -0,0 +1,12 @@
{
"dashboard": {
"title": "Connected Devices",
"description": "Manage your connected Meshtastic devices.",
"connectionType_ble": "BLE",
"connectionType_serial": "Serial",
"connectionType_network": "Network",
"noDevicesTitle": "No devices connected",
"noDevicesDescription": "Connect a new device to get started.",
"button_newConnection": "New Connection"
}
}

442
src/i18n/locales/en/deviceConfig.json

@ -0,0 +1,442 @@
{
"page": {
"title": "Configuration",
"tabBluetooth": "Bluetooth",
"tabDevice": "Device",
"tabDisplay": "Display",
"tabLora": "LoRa",
"tabNetwork": "Network",
"tabPosition": "Position",
"tabPower": "Power",
"tabSecurity": "Security"
},
"sidebar": {
"label": "Modules"
},
"device": {
"title": "Device Settings",
"description": "Settings for the device",
"buttonPin": {
"description": "Button pin override",
"label": "Button Pin"
},
"buzzerPin": {
"description": "Buzzer pin override",
"label": "Buzzer Pin"
},
"disableTripleClick": {
"description": "Disable triple click",
"label": "Disable Triple Click"
},
"doubleTapAsButtonPress": {
"description": "Treat double tap as button press",
"label": "Double Tap as Button Press"
},
"ledHeartbeatDisabled": {
"description": "Disable default blinking LED",
"label": "LED Heartbeat Disabled"
},
"nodeInfoBroadcastInterval": {
"description": "How often to broadcast node info",
"label": "Node Info Broadcast Interval"
},
"posixTimezone": {
"description": "The POSIX timezone string for the device",
"label": "POSIX Timezone"
},
"rebroadcastMode": {
"description": "How to handle rebroadcasting",
"label": "Rebroadcast Mode"
},
"role": {
"description": "What role the device performs on the mesh",
"label": "Role"
}
},
"bluetooth": {
"title": "Bluetooth Settings",
"description": "Settings for the Bluetooth module",
"note": "Note: Some devices (ESP32) cannot use both Bluetooth and WiFi at the same time.",
"enabled": {
"description": "Enable or disable Bluetooth",
"label": "Enabled"
},
"pairingMode": {
"description": "Pin selection behaviour.",
"label": "Pairing mode"
},
"pin": {
"description": "Pin to use when pairing",
"label": "Pin"
},
"validation": {
"pinCannotStartWithZero": "Bluetooth Pin cannot start with 0",
"pinMustBeSixDigits": "Pin must be 6 digits",
"pinRequired": "Bluetooth Pin is required"
}
},
"display": {
"description": "Settings for the device display",
"title": "Display Settings",
"headingBold": {
"description": "Bolden the heading text",
"label": "Bold Heading"
},
"carouselDelay": {
"description": "How fast to cycle through windows",
"label": "Carousel Delay"
},
"compassNorthTop": {
"description": "Fix north to the top of compass",
"label": "Compass North Top"
},
"displayMode": {
"description": "Screen layout variant",
"label": "Display Mode"
},
"displayUnits": {
"description": "Display metric or imperial units",
"label": "Display Units"
},
"flipScreen": {
"description": "Flip display 180 degrees",
"label": "Flip Screen"
},
"gpsDisplayUnits": {
"description": "Coordinate display format",
"label": "GPS Display Units"
},
"oledType": {
"description": "Type of OLED screen attached to the device",
"label": "OLED Type"
},
"screenTimeout": {
"description": "Turn off the display after this long",
"label": "Screen Timeout"
},
"twelveHourClock": {
"description": "Use 12-hour clock format",
"label": "12-Hour Clock"
},
"wakeOnTapOrMotion": {
"description": "Wake the device on tap or motion",
"label": "Wake on Tap or Motion"
}
},
"lora": {
"title": "Mesh Settings",
"description": "Settings for the LoRa mesh",
"bandwidth": {
"description": "Channel bandwidth in MHz",
"label": "Bandwidth"
},
"boostedRxGain": {
"description": "Boosted RX gain",
"label": "Boosted RX Gain"
},
"codingRate": {
"description": "The denominator of the coding rate",
"label": "Coding Rate"
},
"frequencyOffset": {
"description": "Frequency offset to correct for crystal calibration errors",
"label": "Frequency Offset"
},
"frequencySlot": {
"description": "LoRa frequency channel number",
"label": "Frequency Slot"
},
"hopLimit": {
"description": "Maximum number of hops",
"label": "Hop Limit"
},
"ignoreMqtt": {
"description": "Don't forward MQTT messages over the mesh",
"label": "Ignore MQTT"
},
"modemPreset": {
"description": "Modem preset to use",
"label": "Modem Preset"
},
"okToMqtt": {
"description": "When set to true, this configuration indicates that the user approves the packet to be uploaded to MQTT. If set to false, remote nodes are requested not to forward packets to MQTT",
"label": "OK to MQTT"
},
"overrideDutyCycle": {
"description": "Override Duty Cycle",
"label": "Override Duty Cycle"
},
"overrideFrequency": {
"description": "Override frequency",
"label": "Override Frequency"
},
"region": {
"description": "Sets the region for your node",
"label": "Region"
},
"spreadingFactor": {
"description": "Indicates the number of chirps per symbol",
"label": "Spreading Factor"
},
"transmitEnabled": {
"description": "Enable/Disable transmit (TX) from the LoRa radio",
"label": "Transmit Enabled"
},
"transmitPower": {
"description": "Max transmit power",
"label": "Transmit Power"
},
"usePreset": {
"description": "Use one of the predefined modem presets",
"label": "Use Preset"
},
"meshSettings": {
"description": "Settings for the LoRa mesh",
"label": "Mesh Settings"
},
"waveformSettings": {
"description": "Settings for the LoRa waveform",
"label": "Waveform Settings"
},
"radioSettings": {
"label": "Radio Settings",
"description": "Settings for the LoRa radio"
}
},
"network": {
"title": "WiFi Config",
"description": "WiFi radio configuration",
"note": "Note: Some devices (ESP32) cannot use both Bluetooth and WiFi at the same time.",
"addressMode": {
"description": "Address assignment selection",
"label": "Address Mode"
},
"dns": {
"description": "DNS Server",
"label": "DNS"
},
"ethernetEnabled": {
"description": "Enable or disable the Ethernet port",
"label": "Enabled"
},
"gateway": {
"description": "Default Gateway",
"label": "Gateway"
},
"ip": {
"description": "IP Address",
"label": "IP"
},
"psk": {
"description": "Network password",
"label": "PSK"
},
"ssid": {
"description": "Network name",
"label": "SSID"
},
"subnet": {
"description": "Subnet Mask",
"label": "Subnet"
},
"wifiEnabled": {
"description": "Enable or disable the WiFi radio",
"label": "Enabled"
},
"meshViaUdp": {
"label": "Mesh via UDP"
},
"ntpServer": {
"label": "NTP Server"
},
"rsyslogServer": {
"label": "Rsyslog Server"
},
"ethernetConfigSettings": {
"description": "Ethernet port configuration",
"label": "Ethernet Config"
},
"ipConfigSettings": {
"description": "IP configuration",
"label": "IP Config"
},
"ntpConfigSettings": {
"description": "NTP configuration",
"label": "NTP Config"
},
"rsyslogConfigSettings": {
"description": "Rsyslog configuration",
"label": "Rsyslog Config"
},
"udpConfigSettings": {
"description": "UDP over Mesh configuration",
"label": "UDP Config"
}
},
"position": {
"title": "Position Settings",
"description": "Settings for the position module",
"broadcastInterval": {
"description": "How often your position is sent out over the mesh",
"label": "Broadcast Interval"
},
"enablePin": {
"description": "GPS module enable pin override",
"label": "Enable Pin"
},
"fixedPosition": {
"description": "Don't report GPS position, but a manually-specified one",
"label": "Fixed Position"
},
"gpsMode": {
"description": "Configure whether device GPS is Enabled, Disabled, or Not Present",
"label": "GPS Mode"
},
"gpsUpdateInterval": {
"description": "How often a GPS fix should be acquired",
"label": "GPS Update Interval"
},
"positionFlags": {
"description": "Optional fields to include when assembling position messages. The more fields are selected, the larger the message will be leading to longer airtime usage and a higher risk of packet loss.",
"label": "Position Flags"
},
"receivePin": {
"description": "GPS module RX pin override",
"label": "Receive Pin"
},
"smartPositionEnabled": {
"description": "Only send position when there has been a meaningful change in location",
"label": "Enable Smart Position"
},
"smartPositionMinDistance": {
"description": "Minimum distance (in meters) that must be traveled before a position update is sent",
"label": "Smart Position Minimum Distance"
},
"smartPositionMinInterval": {
"description": "Minimum interval (in seconds) that must pass before a position update is sent",
"label": "Smart Position Minimum Interval"
},
"transmitPin": {
"description": "GPS module TX pin override",
"label": "Transmit Pin"
},
"intervalsSettings": {
"description": "How often to send position updates",
"label": "Intervals"
},
"flags": {
"placeholder": "Select position flags...",
"altitude": "Altitude",
"altitudeGeoidalSeparation": "Altitude Geoidal Separation",
"altitudeMsl": "Altitude is Mean Sea Level",
"dop": "Dilution of precision (DOP) PDOP used by default",
"hdopVdop": "If DOP is set, use HDOP / VDOP values instead of PDOP",
"numSatellites": "Number of satellites",
"sequenceNumber": "Sequence number",
"timestamp": "Timestamp",
"unset": "Unset",
"vehicleHeading": "Vehicle heading",
"vehicleSpeed": "Vehicle speed"
}
},
"power": {
"adcMultiplierOverride": {
"description": "Used for tweaking battery voltage reading",
"label": "ADC Multiplier Override ratio"
},
"ina219Address": {
"description": "Address of the INA219 battery monitor",
"label": "INA219 Address"
},
"lightSleepDuration": {
"description": "How long the device will be in light sleep for",
"label": "Light Sleep Duration"
},
"minimumWakeTime": {
"description": "Minimum amount of time the device will stay awake for after receiving a packet",
"label": "Minimum Wake Time"
},
"noConnectionBluetoothDisabled": {
"description": "If the device does not receive a Bluetooth connection, the BLE radio will be disabled after this long",
"label": "No Connection Bluetooth Disabled"
},
"powerSavingEnabled": {
"description": "Select if powered from a low-current source (i.e. solar), to minimize power consumption as much as possible.",
"label": "Enable power saving mode"
},
"shutdownOnBatteryDelay": {
"description": "Automatically shutdown node after this long when on battery, 0 for indefinite",
"label": "Shutdown on battery delay"
},
"superDeepSleepDuration": {
"description": "How long the device will be in super deep sleep for",
"label": "Super Deep Sleep Duration"
},
"powerConfigSettings": {
"description": "Settings for the power module",
"label": "Power Config"
},
"sleepSettings": {
"description": "Sleep settings for the power module",
"label": "Sleep Settings"
}
},
"security": {
"description": "Settings for the Security configuration",
"title": "Security Settings",
"button_backupKey": "Backup Key",
"adminChannelEnabled": {
"description": "Allow incoming device control over the insecure legacy admin channel",
"label": "Allow Legacy Admin"
},
"enableDebugLogApi": {
"description": "Output live debug logging over serial, view and export position-redacted device logs over Bluetooth",
"label": "Enable Debug Log API"
},
"managed": {
"description": "If enabled, device configuration options are only able to be changed remotely by a Remote Admin node via admin messages. Do not enable this option unless at least one suitable Remote Admin node has been setup, and the public key is stored in one of the fields above.",
"label": "Managed"
},
"privateKey": {
"description": "Used to create a shared key with a remote device",
"label": "Private Key"
},
"publicKey": {
"description": "Sent out to other nodes on the mesh to allow them to compute a shared secret key",
"label": "Public Key"
},
"primaryAdminKey": {
"description": "The primary public key authorized to send admin messages to this node",
"label": "Primary Admin Key"
},
"secondaryAdminKey": {
"description": "The secondary public key authorized to send admin messages to this node",
"label": "Secondary Admin Key"
},
"serialOutputEnabled": {
"description": "Serial Console over the Stream API",
"label": "Serial Output Enabled"
},
"tertiaryAdminKey": {
"description": "The tertiary public key authorized to send admin messages to this node",
"label": "Tertiary Admin Key"
},
"adminSettings": {
"description": "Settings for Admin",
"label": "Admin Settings"
},
"loggingSettings": {
"description": "Settings for Logging",
"label": "Logging Settings"
},
"validation": {
"adminKeyMustBe256BitPsk": "Admin Key is required to be a 256 bit pre-shared key (PSK)",
"adminKeyRequiredWhenManaged": "At least one admin key is requred if the node is managed.",
"enterValid256BitPsk": "Please enter a valid 256 bit PSK",
"invalidAdminKeyFormat": "Invalid Admin Key format",
"invalidPrivateKeyFormat": "Invalid Private Key format",
"privateKeyMustBe256BitPsk": "Private Key is required to be a 256 bit pre-shared key (PSK)",
"privateKeyRequired": "Private Key is required"
}
}
}

160
src/i18n/locales/en/dialog.json

@ -0,0 +1,160 @@
{
"deleteMessages": {
"description": "This action will clear all message history. This cannot be undone. Are you sure you want to continue?",
"title": "Clear All Messages"
},
"deviceName": {
"description": "The Device will restart once the config is saved.",
"longName": "Long Name",
"shortName": "Short Name",
"title": "Change Device Name"
},
"import": {
"description": "The current LoRa configuration will be overridden.",
"error": {
"invalidUrl": "Invalid Meshtastic URL"
},
"channelPrefix": "Channel: ",
"channelSetUrl": "Channel Set/QR Code URL",
"channels": "Channels:",
"usePreset": "Use Preset?",
"title": "Import Channel Set"
},
"locationResponse": {
"altitude": "Altitude: ",
"coordinates": "Coordinates: ",
"title": "Location: {{identifier}}"
},
"pkiRegenerateDialog": {
"title": "Regenerate Pre-Shared Key?",
"description": "Are you sure you want to regenerate the pre-shared key?",
"regenerate": "Regenerate"
},
"newDeviceDialog": {
"title": "Connect New Device",
"https": "https",
"http": "http",
"tabHttp": "HTTP",
"tabBluetooth": "Bluetooth",
"tabSerial": "Serial",
"useHttps": "Use HTTPS",
"connecting": "Connecting...",
"connect": "Connect",
"connectionFailedAlert": {
"title": "Connection Failed",
"descriptionPrefix": "Could not connect to the device. ",
"httpsHint": "If using HTTPS, you may need to accept a self-signed certificate first. ",
"openLinkPrefix": "Please open ",
"openLinkSuffix": " in a new tab",
"acceptTlsWarningSuffix": ", accept any TLS warnings if prompted, then try again",
"learnMoreLink": "Learn more"
},
"httpConnection": {
"label": "IP Address/Hostname",
"placeholder": "000.000.000.000 / meshtastic.local"
},
"serialConnection": {
"noDevicesPaired": "No devices paired yet.",
"newDeviceButton": "New device",
"deviceIdentifier": "# {{index}} - {{vendorId}} - {{productId}}"
},
"bluetoothConnection": {
"noDevicesPaired": "No devices paired yet.",
"newDeviceButton": "New device"
},
"validation": {
"requiresFeatures": "This connection type requires <0></0>. Please use a supported browser, like Chrome or Edge.",
"requiresSecureContext": "This application requires a <0>secure context</0>. Please connect using HTTPS or localhost.",
"additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context</0>. Please connect using HTTPS or localhost."
}
},
"nodeDetails": {
"message": "Message",
"requestPosition": "Request Position",
"traceRoute": "Trace Route",
"airTxUtilization": "Air TX utilization",
"allRawMetrics": "All Raw Metrics:",
"batteryLevel": "Battery level",
"channelUtilization": "Channel utilization",
"details": "Details:",
"deviceMetrics": "Device Metrics:",
"hardware": "Hardware: ",
"lastHeard": "Last Heard: ",
"nodeHexPrefix": "Node Hex: !",
"nodeNumber": "Node Number: ",
"position": "Position:",
"role": "Role: ",
"uptime": "Uptime: ",
"voltage": "Voltage",
"title": "Node Details for {{identifier}}",
"ignoreNode": "Ignore node",
"removeNode": "Remove node",
"unignoreNode": "Unignore node"
},
"pkiBackup": {
"description": "We recommend backing up your key data regularly. Would you like to back up now?",
"loseKeysWarning": "If you lose your keys, you will need to reset your device.",
"secureBackup": "Its important to backup your public and private keys and store your backup securely!",
"footer": "=== END OF KEYS ===",
"header": "=== MESHTASTIC KEYS FOR {{longName}} ({{shortName}}) ===",
"privateKey": "Private Key:",
"publicKey": "Public Key:",
"fileName": "meshtastic_keys_{{longName}}_{{shortName}}.txt",
"title": "Backup Keys"
},
"pkiRegenerate": {
"description": "Are you sure you want to regenerate key pair?",
"title": "Regenerate Key Pair"
},
"qr": {
"addChannels": "Add Channels",
"replaceChannels": "Replace Channels",
"description": "The current LoRa configuration will also be shared.",
"sharableUrl": "Sharable URL",
"title": "Generate QR Code"
},
"rebootOta": {
"title": "Schedule Reboot",
"description": "Reboot the connected node after a delay into OTA (Over-the-Air) mode.",
"enterDelay": "Enter delay (sec)",
"scheduled": "Reboot has been scheduled"
},
"reboot": {
"title": "Schedule Reboot",
"description": "Reboot the connected node after x minutes."
},
"refreshKeys": {
"description": {
"acceptNewKeys": "This will remove the node from device and request new keys.",
"keyMismatchReasonSuffix": ". This is due to the remote node's current public key does not match the previously stored key for this node.",
"unableToSendDmPrefix": "Your node is unable to send a direct message to node: "
},
"acceptNewKeys": "Accept New Keys",
"title": "Keys Mismatch - {{identifier}}"
},
"removeNode": {
"description": "Are you sure you want to remove this Node?",
"title": "Remove Node?"
},
"shutdown": {
"title": "Schedule Shutdown",
"description": "Turn off the connected node after x minutes."
},
"traceRoute": {
"routeToDestination": "Route to destination:",
"routeBack": "Route back:"
},
"tracerouteResponse": {
"title": "Traceroute: {{identifier}}"
},
"unsafeRoles": {
"confirmUnderstanding": "Yes, I know what I'm doing",
"conjunction": " and the blog post about ",
"postamble": " and understand the implications of changing the role.",
"preamble": "I have read the ",
"choosingRightDeviceRole": "Choosing The Right Device Role",
"deviceRoleDocumentation": "Device Role Documentation",
"title": "Are you sure?"
}
}

37
src/i18n/locales/en/messages.json

@ -0,0 +1,37 @@
{
"page": {
"title": "Messages: {{chatName}}"
},
"emptyState": {
"title": "Select a Chat",
"text": "No messages yet."
},
"selectChatPrompt": {
"text": "Select a channel or node to start messaging."
},
"actionsMenu": {
"addReactionLabel": "Add Reaction",
"replyLabel": "Reply"
},
"item": {
"status": {
"delivered": {
"label": "Message delivered",
"displayText": "Message delivered"
},
"failed": {
"label": "Message delivery failed",
"displayText": "Delivery failed"
},
"unknown": {
"label": "Message status unknown",
"displayText": "Unknown state"
},
"waiting": {
"ariaLabel": "Sending message",
"displayText": "Waiting for delivery"
}
}
}
}

448
src/i18n/locales/en/moduleConfig.json

@ -0,0 +1,448 @@
{
"page": {
"tabAmbientLighting": "Ambient Lighting",
"tabAudio": "Audio",
"tabCannedMessage": "Canned",
"tabDetectionSensor": "Detection Sensor",
"tabExternalNotification": "Ext Notif",
"tabMqtt": "MQTT",
"tabNeighborInfo": "Neighbor Info",
"tabPaxcounter": "Paxcounter",
"tabRangeTest": "Range Test",
"tabSerial": "Serial",
"tabStoreAndForward": "S&F",
"tabTelemetry": "Telemetry"
},
"ambientLighting": {
"title": "Ambient Lighting Settings",
"description": "Settings for the Ambient Lighting module",
"ledState": {
"label": "LED State",
"description": "Sets LED to on or off"
},
"current": {
"label": "Current",
"description": "Sets the current for the LED output. Default is 10"
},
"red": {
"label": "Red",
"description": "Sets the red LED level. Values are 0-255"
},
"green": {
"label": "Green",
"description": "Sets the green LED level. Values are 0-255"
},
"blue": {
"label": "Blue",
"description": "Sets the blue LED level. Values are 0-255"
}
},
"audio": {
"title": "Audio Settings",
"description": "Settings for the Audio module",
"codec2Enabled": {
"label": "Codec 2 Enabled",
"description": "Enable Codec 2 audio encoding"
},
"pttPin": {
"label": "PTT Pin",
"description": "GPIO pin to use for PTT"
},
"bitrate": {
"label": "Bitrate",
"description": "Bitrate to use for audio encoding"
},
"i2sWs": {
"label": "i2S WS",
"description": "GPIO pin to use for i2S WS"
},
"i2sSd": {
"label": "i2S SD",
"description": "GPIO pin to use for i2S SD"
},
"i2sDin": {
"label": "i2S DIN",
"description": "GPIO pin to use for i2S DIN"
},
"i2sSck": {
"label": "i2S SCK",
"description": "GPIO pin to use for i2S SCK"
}
},
"cannedMessage": {
"title": "Canned Message Settings",
"description": "Settings for the Canned Message module",
"moduleEnabled": {
"label": "Module Enabled",
"description": "Enable Canned Message"
},
"rotary1Enabled": {
"label": "Rotary Encoder #1 Enabled",
"description": "Enable the rotary encoder"
},
"inputbrokerPinA": {
"label": "Encoder Pin A",
"description": "GPIO Pin Value (1-39) For encoder port A"
},
"inputbrokerPinB": {
"label": "Encoder Pin B",
"description": "GPIO Pin Value (1-39) For encoder port B"
},
"inputbrokerPinPress": {
"label": "Encoder Pin Press",
"description": "GPIO Pin Value (1-39) For encoder Press"
},
"inputbrokerEventCw": {
"label": "Clockwise event",
"description": "Select input event."
},
"inputbrokerEventCcw": {
"label": "Counter Clockwise event",
"description": "Select input event."
},
"inputbrokerEventPress": {
"label": "Press event",
"description": "Select input event"
},
"updown1Enabled": {
"label": "Up Down enabled",
"description": "Enable the up / down encoder"
},
"allowInputSource": {
"label": "Allow Input Source",
"description": "Select from: '_any', 'rotEnc1', 'upDownEnc1', 'cardkb'"
},
"sendBell": {
"label": "Send Bell",
"description": "Sends a bell character with each message"
}
},
"detectionSensor": {
"title": "Detection Sensor Settings",
"description": "Settings for the Detection Sensor module",
"enabled": {
"label": "Enabled",
"description": "Enable or disable Detection Sensor Module"
},
"minimumBroadcastSecs": {
"label": "Minimum Broadcast Seconds",
"description": "The interval in seconds of how often we can send a message to the mesh when a state change is detected"
},
"stateBroadcastSecs": {
"label": "State Broadcast Seconds",
"description": "The interval in seconds of how often we should send a message to the mesh with the current state regardless of changes"
},
"sendBell": {
"label": "Send Bell",
"description": "Send ASCII bell with alert message"
},
"name": {
"label": "Friendly Name",
"description": "Used to format the message sent to mesh, max 20 Characters"
},
"monitorPin": {
"label": "Monitor Pin",
"description": "The GPIO pin to monitor for state changes"
},
"detectionTriggeredHigh": {
"label": "Detection Triggered High",
"description": "Whether or not the GPIO pin state detection is triggered on HIGH (1), otherwise LOW (0)"
},
"usePullup": {
"label": "Use Pullup",
"description": "Whether or not use INPUT_PULLUP mode for GPIO pin"
}
},
"externalNotification": {
"title": "External Notification Settings",
"description": "Configure the external notification module",
"enabled": {
"label": "Module Enabled",
"description": "Enable External Notification"
},
"outputMs": {
"label": "Output MS",
"description": "Output MS"
},
"output": {
"label": "Output",
"description": "Output"
},
"outputVibra": {
"label": "Output Vibrate",
"description": "Output Vibrate"
},
"outputBuzzer": {
"label": "Output Buzzer",
"description": "Output Buzzer"
},
"active": {
"label": "Active",
"description": "Active"
},
"alertMessage": {
"label": "Alert Message",
"description": "Alert Message"
},
"alertMessageVibra": {
"label": "Alert Message Vibrate",
"description": "Alert Message Vibrate"
},
"alertMessageBuzzer": {
"label": "Alert Message Buzzer",
"description": "Alert Message Buzzer"
},
"alertBell": {
"label": "Alert Bell",
"description": "Should an alert be triggered when receiving an incoming bell?"
},
"alertBellVibra": {
"label": "Alert Bell Vibrate",
"description": "Alert Bell Vibrate"
},
"alertBellBuzzer": {
"label": "Alert Bell Buzzer",
"description": "Alert Bell Buzzer"
},
"usePwm": {
"label": "Use PWM",
"description": "Use PWM"
},
"nagTimeout": {
"label": "Nag Timeout",
"description": "Nag Timeout"
},
"useI2sAsBuzzer": {
"label": "Use I²S Pin as Buzzer",
"description": "Designate I²S Pin as Buzzer Output"
}
},
"mqtt": {
"title": "MQTT Settings",
"description": "Settings for the MQTT module",
"enabled": {
"label": "Enabled",
"description": "Enable or disable MQTT"
},
"address": {
"label": "MQTT Server Address",
"description": "MQTT server address to use for default/custom servers"
},
"username": {
"label": "MQTT Username",
"description": "MQTT username to use for default/custom servers"
},
"password": {
"label": "MQTT Password",
"description": "MQTT password to use for default/custom servers"
},
"encryptionEnabled": {
"label": "Encryption Enabled",
"description": "Enable or disable MQTT encryption. Note: All messages are sent to the MQTT broker unencrypted if this option is not enabled, even when your uplink channels have encryption keys set. This includes position data."
},
"jsonEnabled": {
"label": "JSON Enabled",
"description": "Whether to send/consume JSON packets on MQTT"
},
"tlsEnabled": {
"label": "TLS Enabled",
"description": "Enable or disable TLS"
},
"root": {
"label": "Root topic",
"description": "MQTT root topic to use for default/custom servers"
},
"proxyToClientEnabled": {
"label": "Proxy to Client Enabled",
"description": "Use the client's internet connection for MQTT (feature only active in mobile apps)"
},
"mapReportingEnabled": {
"label": "Map Reporting Enabled",
"description": "Enable or disable map reporting"
},
"mapReportSettings": {
"publishIntervalSecs": {
"label": "Map Report Publish Interval (s)",
"description": "Interval in seconds to publish map reports"
},
"positionPrecision": {
"label": "Approximate Location",
"description": "Position shared will be accurate within this distance",
"options": {
"metric_km23": "Within 23 km",
"metric_km12": "Within 12 km",
"metric_km5_8": "Within 5.8 km",
"metric_km2_9": "Within 2.9 km",
"metric_km1_5": "Within 1.5 km",
"metric_m700": "Within 700 m",
"metric_m350": "Within 350 m",
"metric_m200": "Within 200 m",
"metric_m90": "Within 90 m",
"metric_m50": "Within 50 m",
"imperial_mi15": "Within 15 miles",
"imperial_mi7_3": "Within 7.3 miles",
"imperial_mi3_6": "Within 3.6 miles",
"imperial_mi1_8": "Within 1.8 miles",
"imperial_mi0_9": "Within 0.9 miles",
"imperial_mi0_5": "Within 0.5 miles",
"imperial_mi0_2": "Within 0.2 miles",
"imperial_ft600": "Within 600 feet",
"imperial_ft300": "Within 300 feet",
"imperial_ft150": "Within 150 feet"
}
}
}
},
"neighborInfo": {
"title": "Neighbor Info Settings",
"description": "Settings for the Neighbor Info module",
"enabled": {
"label": "Enabled",
"description": "Enable or disable Neighbor Info Module"
},
"updateInterval": {
"label": "Update Interval",
"description": "Interval in seconds of how often we should try to send our Neighbor Info to the mesh"
}
},
"paxcounter": {
"title": "Paxcounter Settings",
"description": "Settings for the Paxcounter module",
"enabled": {
"label": "Module Enabled",
"description": "Enable Paxcounter"
},
"paxcounterUpdateInterval": {
"label": "Update Interval (seconds)",
"description": "How long to wait between sending paxcounter packets"
},
"wifiThreshold": {
"label": "WiFi RSSI Threshold",
"description": "At what WiFi RSSI level should the counter increase. Defaults to -80."
},
"bleThreshold": {
"label": "BLE RSSI Threshold",
"description": "At what BLE RSSI level should the counter increase. Defaults to -80."
}
},
"rangeTest": {
"title": "Range Test Settings",
"description": "Settings for the Range Test module",
"enabled": {
"label": "Module Enabled",
"description": "Enable Range Test"
},
"sender": {
"label": "Message Interval",
"description": "How long to wait between sending test packets"
},
"save": {
"label": "Save CSV to storage",
"description": "ESP32 Only"
}
},
"serial": {
"title": "Serial Settings",
"description": "Settings for the Serial module",
"enabled": {
"label": "Module Enabled",
"description": "Enable Serial output"
},
"echo": {
"label": "Echo",
"description": "Any packets you send will be echoed back to your device"
},
"rxd": {
"label": "Receive Pin",
"description": "Set the GPIO pin to the RXD pin you have set up."
},
"txd": {
"label": "Transmit Pin",
"description": "Set the GPIO pin to the TXD pin you have set up."
},
"baud": {
"label": "Baud Rate",
"description": "The serial baud rate"
},
"timeout": {
"label": "Timeout",
"description": "Seconds to wait before we consider your packet as 'done'"
},
"mode": {
"label": "Mode",
"description": "Select Mode"
},
"overrideConsoleSerialPort": {
"label": "Override Console Serial Port",
"description": "If you have a serial port connected to the console, this will override it."
}
},
"storeForward": {
"title": "Store & Forward Settings",
"description": "Settings for the Store & Forward module",
"enabled": {
"label": "Module Enabled",
"description": "Enable Store & Forward"
},
"heartbeat": {
"label": "Heartbeat Enabled",
"description": "Enable Store & Forward heartbeat"
},
"records": {
"label": "Number of records",
"description": "Number of records to store"
},
"historyReturnMax": {
"label": "History return max",
"description": "Max number of records to return"
},
"historyReturnWindow": {
"label": "History return window",
"description": "Max number of records to return"
}
},
"telemetry": {
"title": "Telemetry Settings",
"description": "Settings for the Telemetry module",
"deviceUpdateInterval": {
"label": "Device Metrics",
"description": "Device metrics update interval (seconds)"
},
"environmentUpdateInterval": {
"label": "Environment metrics update interval (seconds)",
"description": ""
},
"environmentMeasurementEnabled": {
"label": "Module Enabled",
"description": "Enable the Environment Telemetry"
},
"environmentScreenEnabled": {
"label": "Displayed on Screen",
"description": "Show the Telemetry Module on the OLED"
},
"environmentDisplayFahrenheit": {
"label": "Display Fahrenheit",
"description": "Display temp in Fahrenheit"
},
"airQualityEnabled": {
"label": "Air Quality Enabled",
"description": "Enable the Air Quality Telemetry"
},
"airQualityInterval": {
"label": "Air Quality Update Interval",
"description": "How often to send Air Quality data over the mesh"
},
"powerMeasurementEnabled": {
"label": "Power Measurement Enabled",
"description": "Enable the Power Measurement Telemetry"
},
"powerUpdateInterval": {
"label": "Power Update Interval",
"description": "How often to send Power data over the mesh"
},
"powerScreenEnabled": {
"label": "Power Screen Enabled",
"description": "Enable the Power Telemetry Screen"
}
}
}

51
src/i18n/locales/en/nodes.json

@ -0,0 +1,51 @@
{
"nodeDetail": {
"publicKeyEnabled": {
"label": "Public Key Enabled"
},
"noPublicKey": {
"label": "No Public Key"
},
"directMessage": {
"label": "Direct Message {{shortName}}"
},
"favorite": {
"label": "Favorite"
},
"notFavorite": {
"label": "Not a Favorite"
},
"status": {
"heard": "Heard",
"mqtt": "MQTT"
},
"elevation": {
"label": "Elevation"
},
"channelUtil": {
"label": "Channel Util"
},
"airtimeUtil": {
"label": "Airtime Util"
}
},
"nodesTable": {
"headings": {
"longName": "Long Name",
"connection": "Connection",
"lastHeard": "Last Heard",
"encryption": "Encryption",
"model": "Model",
"macAddress": "MAC Address"
},
"connectionStatus": {
"direct": "Direct",
"away": "away",
"unknown": "-",
"viaMqtt": ", via MQTT"
},
"lastHeardStatus": {
"never": "Never"
}
}
}

162
src/i18n/locales/en/ui.json

@ -0,0 +1,162 @@
{
"navigation": {
"title": "Navigation",
"messages": "Messages",
"map": "Map",
"config": "Config",
"radioConfig": "Radio Config",
"moduleConfig": "Module Config",
"channels": "Channels",
"nodes": "Nodes"
},
"app": {
"title": "Meshtastic",
"logo": "Meshtastic Logo"
},
"sidebar": {
"collapseToggle": {
"button": {
"open": "Open sidebar",
"close": "Close sidebar"
}
},
"deviceInfo": {
"volts": "{{voltage}} volts",
"firmware": {
"title": "Firmware",
"version": "v{{version}}",
"buildDate": "Build date: {{date}}"
},
"deviceName": {
"title": "Device Name",
"changeName": "Change Device Name",
"placeholder": "Enter device name"
},
"editDeviceName": "Edit device name"
}
},
"batteryStatus": {
"charging": "{{level}}% charging",
"pluggedIn": "Plugged in",
"title": "Battery"
},
"search": {
"nodes": "Search nodes...",
"channels": "Search channels...",
"commandPalette": "Search commands..."
},
"toast": {
"positionRequestSent": { "title": "Position request sent." },
"requestingPosition": { "title": "Requesting position, please wait..." },
"sendingTraceroute": { "title": "Sending Traceroute, please wait..." },
"tracerouteSent": { "title": "Traceroute sent." },
"savedChannel": { "title": "Saved Channel: {{channelName}}" },
"messages": {
"pkiEncryption": { "title": "Chat is using PKI encryption." },
"pskEncryption": { "title": "Chat is using PSK encryption." }
},
"configSaveError": {
"title": "Error Saving Config",
"description": "An error occurred while saving the configuration."
},
"validationError": {
"title": "Config Errors Exist",
"description": "Please fix the configuration errors before saving."
},
"saveSuccess": {
"title": "Saving Config",
"description": "The configuration change {{case}} has been saved."
}
},
"notifications": {
"copied": {
"label": "Copied!"
},
"copyToClipboard": {
"label": "Copy to clipboard"
},
"hidePassword": {
"label": "Hide password"
},
"showPassword": {
"label": "Show password"
}
},
"general": {
"label": "General"
},
"hardware": {
"label": "Hardware"
},
"metrics": {
"label": "Metrics"
},
"role": {
"label": "Role"
},
"filter": {
"label": "Filter"
},
"clearInput": {
"label": "Clear input"
},
"resetFilters": {
"label": "Reset Filters"
},
"nodeName": {
"label": "Node name/number",
"placeholder": "Meshtastic 1234"
},
"airtimeUtilization": {
"label": "Airtime Utilization (%)"
},
"batteryLevel": {
"label": "Battery level (%)",
"labelText": "Battery level (%): {{value}}"
},
"batteryVoltage": {
"label": "Battery voltage (V)",
"title": "Voltage"
},
"channelUtilization": {
"label": "Channel Utilization (%)"
},
"hops": {
"direct": "Direct",
"label": "Number of hops",
"text": "Number of hops: {{value}}"
},
"lastHeard": {
"label": "Last heard",
"labelText": "Last heard: {{value}}",
"nowLabel": "Now"
},
"snr": {
"label": "SNR (db)"
},
"favorites": {
"label": "Favorites"
},
"hide": {
"label": "Hide"
},
"showOnly": {
"label": "Show Only"
},
"viaMqtt": {
"label": "Connected via MQTT"
},
"language": {
"label": "Language",
"changeLanguage": "Change Language"
},
"theme": {
"dark": "Dark",
"light": "Light",
"system": "Automatic",
"changeTheme": "Change Color Scheme"
},
"footer": {
"text": "Powered by <0>▲ Vercel</0> | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information</1>"
}
}

7
src/index.tsx

@ -1,10 +1,11 @@
import "@app/index.css"; import "@app/index.css";
import { enableMapSet } from "immer"; import { enableMapSet } from "immer";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import { StrictMode } from "react"; import { StrictMode, Suspense } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { App } from "@app/App.tsx"; import { App } from "@app/App.tsx";
import "./i18n/config.ts";
const container = document.getElementById("root") as HTMLElement; const container = document.getElementById("root") as HTMLElement;
const root = createRoot(container); const root = createRoot(container);
@ -13,6 +14,8 @@ enableMapSet();
root.render( root.render(
<StrictMode> <StrictMode>
<App /> <Suspense fallback={null}>
<App />
</Suspense>,
</StrictMode>, </StrictMode>,
); );

29
src/pages/Channels.tsx

@ -10,17 +10,21 @@ import { Sidebar } from "@components/Sidebar.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { Types } from "@meshtastic/core"; import { Types } from "@meshtastic/core";
import type { Protobuf } from "@meshtastic/core"; import type { Protobuf } from "@meshtastic/core";
import { ImportIcon, QrCodeIcon } from "lucide-react"; import i18next from "i18next";
import { QrCodeIcon, UploadIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
export const getChannelName = (channel: Protobuf.Channel.Channel) => export const getChannelName = (channel: Protobuf.Channel.Channel) => {
channel.settings?.name.length return channel.settings?.name.length
? channel.settings?.name ? channel.settings?.name
: channel.index === 0 : channel.index === 0
? "Primary" ? i18next.t("page.broadcastLabel")
: `Ch ${channel.index}`; : i18next.t("page.channelIndex", { ns: "channels", index: channel.index });
};
const ChannelsPage = () => { const ChannelsPage = () => {
const { t } = useTranslation("channels");
const { channels, setDialogOpen } = useDevice(); const { channels, setDialogOpen } = useDevice();
const [activeChannel] = useState<Types.ChannelNumber>( const [activeChannel] = useState<Types.ChannelNumber>(
Types.ChannelNumber.Primary, Types.ChannelNumber.Primary,
@ -32,20 +36,21 @@ const ChannelsPage = () => {
return ( return (
<> <>
<PageLayout <PageLayout
contentClassName="overflow-auto"
leftBar={<Sidebar />} leftBar={<Sidebar />}
label={`Channel: ${ label={currentChannel
currentChannel ? getChannelName(currentChannel) : "Loading..." ? getChannelName(currentChannel)
}`} : t("loading", { ns: "common" })}
actions={[ actions={[
{ {
key: "search", key: "import",
icon: ImportIcon, icon: UploadIcon,
onClick() { onClick() {
setDialogOpen("import", true); setDialogOpen("import", true);
}, },
}, },
{ {
key: "import", key: "qr",
icon: QrCodeIcon, icon: QrCodeIcon,
onClick() { onClick() {
setDialogOpen("QR", true); setDialogOpen("QR", true);
@ -54,7 +59,7 @@ const ChannelsPage = () => {
]} ]}
> >
<Tabs defaultValue="0"> <Tabs defaultValue="0">
<TabsList className="dark:bg-slate-800 "> <TabsList className="dark:bg-slate-800">
{allChannels.map((channel) => ( {allChannels.map((channel) => (
<TabsTrigger <TabsTrigger
key={channel.index} key={channel.index}

26
src/pages/Config/DeviceConfig.tsx

@ -1,57 +1,59 @@
import { Bluetooth } from "@components/PageComponents/Config/Bluetooth.tsx"; import { Bluetooth } from "@components/PageComponents/Config/Bluetooth.tsx";
import { Device } from "../../components/PageComponents/Config/Device/index.tsx"; import { Device } from "@components/PageComponents/Config/Device/index.tsx";
import { Display } from "@components/PageComponents/Config/Display.tsx"; import { Display } from "@components/PageComponents/Config/Display.tsx";
import { LoRa } from "@components/PageComponents/Config/LoRa.tsx"; import { LoRa } from "@components/PageComponents/Config/LoRa.tsx";
import { Network } from "../../components/PageComponents/Config/Network/index.tsx"; import { Network } from "@components/PageComponents/Config/Network/index.tsx";
import { Position } from "@components/PageComponents/Config/Position.tsx"; import { Position } from "@components/PageComponents/Config/Position.tsx";
import { Power } from "@components/PageComponents/Config/Power.tsx"; import { Power } from "@components/PageComponents/Config/Power.tsx";
import { Security } from "../../components/PageComponents/Config/Security/Security.tsx"; import { Security } from "@components/PageComponents/Config/Security/Security.tsx";
import { import {
Tabs, Tabs,
TabsContent, TabsContent,
TabsList, TabsList,
TabsTrigger, TabsTrigger,
} from "@components/UI/Tabs.tsx"; } from "@components/UI/Tabs.tsx";
import { useTranslation } from "react-i18next";
export const DeviceConfig = () => { export const DeviceConfig = () => {
const { t } = useTranslation("deviceConfig");
const tabs = [ const tabs = [
{ {
label: "Device", label: t("page.tabDevice"),
element: Device, element: Device,
count: 0, count: 0,
}, },
{ {
label: "Position", label: t("page.tabPosition"),
element: Position, element: Position,
}, },
{ {
label: "Power", label: t("page.tabPower"),
element: Power, element: Power,
}, },
{ {
label: "Network", label: t("page.tabNetwork"),
element: Network, element: Network,
}, },
{ {
label: "Display", label: t("page.tabDisplay"),
element: Display, element: Display,
}, },
{ {
label: "LoRa", label: t("page.tabLora"),
element: LoRa, element: LoRa,
}, },
{ {
label: "Bluetooth", label: t("page.tabBluetooth"),
element: Bluetooth, element: Bluetooth,
}, },
{ {
label: "Security", label: t("page.tabSecurity"),
element: Security, element: Security,
}, },
]; ];
return ( return (
<Tabs defaultValue="Device"> <Tabs defaultValue={t("page.tabDevice")}>
<TabsList className="dark:bg-slate-700"> <TabsList className="dark:bg-slate-700">
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsTrigger <TabsTrigger

28
src/pages/Config/ModuleConfig.tsx

@ -16,61 +16,63 @@ import {
TabsList, TabsList,
TabsTrigger, TabsTrigger,
} from "@components/UI/Tabs.tsx"; } from "@components/UI/Tabs.tsx";
import { useTranslation } from "react-i18next";
export const ModuleConfig = () => { export const ModuleConfig = () => {
const { t } = useTranslation("moduleConfig");
const tabs = [ const tabs = [
{ {
label: "MQTT", label: t("page.tabMqtt"),
element: MQTT, element: MQTT,
}, },
{ {
label: "Serial", label: t("page.tabSerial"),
element: Serial, element: Serial,
}, },
{ {
label: "Ext Notif", label: t("page.tabExternalNotification"),
element: ExternalNotification, element: ExternalNotification,
}, },
{ {
label: "S&F", label: t("page.tabStoreAndForward"),
element: StoreForward, element: StoreForward,
}, },
{ {
label: "Range Test", label: t("page.tabRangeTest"),
element: RangeTest, element: RangeTest,
}, },
{ {
label: "Telemetry", label: t("page.tabTelemetry"),
element: Telemetry, element: Telemetry,
}, },
{ {
label: "Canned", label: t("page.tabCannedMessage"),
element: CannedMessage, element: CannedMessage,
}, },
{ {
label: "Audio", label: t("page.tabAudio"),
element: Audio, element: Audio,
}, },
{ {
label: "Neighbor Info", label: t("page.tabNeighborInfo"),
element: NeighborInfo, element: NeighborInfo,
}, },
{ {
label: "Ambient Lighting", label: t("page.tabAmbientLighting"),
element: AmbientLighting, element: AmbientLighting,
}, },
{ {
label: "Detection Sensor", label: t("page.tabDetectionSensor"),
element: DetectionSensor, element: DetectionSensor,
}, },
{ {
label: "Paxcounter", label: t("page.tabPaxcounter"),
element: Paxcounter, element: Paxcounter,
}, },
]; ];
return ( return (
<Tabs defaultValue="MQTT"> <Tabs defaultValue={t("page.tabMqtt")}>
<TabsList className="dark:bg-slate-800"> <TabsList className="dark:bg-slate-800">
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsTrigger <TabsTrigger

37
src/pages/Config/index.tsx

@ -9,6 +9,7 @@ import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx"; import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
import { BoxesIcon, SaveIcon, SaveOff, SettingsIcon } from "lucide-react"; import { BoxesIcon, SaveIcon, SaveOff, SettingsIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
const ConfigPage = () => { const ConfigPage = () => {
const { workingConfig, workingModuleConfig, connection } = useDevice(); const { workingConfig, workingModuleConfig, connection } = useDevice();
@ -19,12 +20,13 @@ const ConfigPage = () => {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const isError = hasErrors(); const isError = hasErrors();
const { t } = useTranslation("deviceConfig");
const handleSave = async () => { const handleSave = async () => {
if (hasErrors()) { if (hasErrors()) {
return toast({ return toast({
title: "Config Errors Exist", title: t("toast.validationError.title"),
description: "Please fix the configuration errors before saving.", description: t("toast.validationError.description"),
}); });
} }
@ -35,9 +37,10 @@ const ConfigPage = () => {
workingConfig.map((config) => workingConfig.map((config) =>
connection?.setConfig(config).then(() => connection?.setConfig(config).then(() =>
toast({ toast({
title: "Saving Config", title: t("toast.saveSuccess.title"),
description: description: t("toast.saveSuccess.description", {
`The configuration change ${config.payloadVariant.case} has been saved.`, case: config.payloadVariant.case,
}),
}) })
) )
), ),
@ -47,9 +50,10 @@ const ConfigPage = () => {
workingModuleConfig.map((moduleConfig) => workingModuleConfig.map((moduleConfig) =>
connection?.setModuleConfig(moduleConfig).then(() => connection?.setModuleConfig(moduleConfig).then(() =>
toast({ toast({
title: "Saving Config", title: t("toast.saveSuccess.title"),
description: description: t("toast.saveSuccess.description", {
`The configuration change ${moduleConfig.payloadVariant.case} has been saved.`, case: moduleConfig.payloadVariant.case,
}),
}) })
) )
), ),
@ -59,8 +63,8 @@ const ConfigPage = () => {
await connection?.commitEditSettings(); await connection?.commitEditSettings();
} catch (_error) { } catch (_error) {
toast({ toast({
title: "Error Saving Config", title: t("toast.configSaveError.title"),
description: "An error occurred while saving the configuration.", description: t("toast.configSaveError.description"),
}); });
} finally { } finally {
setIsSaving(false); setIsSaving(false);
@ -70,15 +74,18 @@ const ConfigPage = () => {
const leftSidebar = useMemo( const leftSidebar = useMemo(
() => ( () => (
<Sidebar> <Sidebar>
<SidebarSection label="Modules"> <SidebarSection
label={t("sidebar.label")}
className="py-2 px-0"
>
<SidebarButton <SidebarButton
label="Radio Config" label={t("navigation.radioConfig")}
active={activeConfigSection === "device"} active={activeConfigSection === "device"}
onClick={() => setActiveConfigSection("device")} onClick={() => setActiveConfigSection("device")}
Icon={SettingsIcon} Icon={SettingsIcon}
/> />
<SidebarButton <SidebarButton
label="Module Config" label={t("navigation.moduleConfig")}
active={activeConfigSection === "module"} active={activeConfigSection === "module"}
onClick={() => setActiveConfigSection("module")} onClick={() => setActiveConfigSection("module")}
Icon={BoxesIcon} Icon={BoxesIcon}
@ -95,8 +102,8 @@ const ConfigPage = () => {
contentClassName="overflow-auto" contentClassName="overflow-auto"
leftBar={leftSidebar} leftBar={leftSidebar}
label={activeConfigSection === "device" label={activeConfigSection === "device"
? "Radio Config" ? t("navigation.radioConfig")
: "Module Config"} : t("navigation.moduleConfig")}
actions={[ actions={[
{ {
key: "save", key: "save",

42
src/pages/Dashboard/index.tsx

@ -13,8 +13,11 @@ import {
UsersIcon, UsersIcon,
} from "lucide-react"; } from "lucide-react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import LanguageSwitcher from "@components/LanguageSwitcher.tsx";
export const Dashboard = () => { export const Dashboard = () => {
const { t } = useTranslation("dashboard");
const { setConnectDialogOpen, setSelectedDevice } = useAppStore(); const { setConnectDialogOpen, setSelectedDevice } = useAppStore();
const { getDevices } = useDeviceStore(); const { getDevices } = useDeviceStore();
@ -22,12 +25,17 @@ export const Dashboard = () => {
return ( return (
<> <>
<div className="flex flex-col gap-3 p-3"> <div className="flex flex-col gap-3 p-3 px-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-1"> <div className="space-y-1">
<Heading as="h3">Connected Devices</Heading> <Heading as="h3">
<Subtle>Manage, connect and disconnect devices</Subtle> {t("dashboard.title")}
</Heading>
<Subtle>
{t("dashboard.description")}
</Subtle>
</div> </div>
<LanguageSwitcher />
</div> </div>
<Separator /> <Separator />
@ -49,25 +57,32 @@ export const Dashboard = () => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="truncate text-sm font-medium text-accent"> <p className="truncate text-sm font-medium text-accent">
{device.getNode(device.hardware.myNodeNum)?.user {device.getNode(device.hardware.myNodeNum)?.user
?.longName ?? "UNK"} ?.longName ??
t("unknown.shortName")}
</p> </p>
<div className="inline-flex w-24 justify-center gap-2 rounded-full bg-slate-100 py-1 text-xs font-semibold text-slate-900 transition-colors hover:bg-slate-700 hover:text-slate-50"> <div className="inline-flex w-24 justify-center gap-2 rounded-full bg-slate-100 py-1 text-xs font-semibold text-slate-900 transition-colors hover:bg-slate-700 hover:text-slate-50">
{device.connection?.connType === "ble" && ( {device.connection?.connType === "ble" && (
<> <>
<BluetoothIcon size={16} /> <BluetoothIcon size={16} />
BLE {t(
"dashboard.connectionType_ble",
)}
</> </>
)} )}
{device.connection?.connType === "serial" && ( {device.connection?.connType === "serial" && (
<> <>
<UsbIcon size={16} /> <UsbIcon size={16} />
Serial {t(
"dashboard.connectionType_serial",
)}
</> </>
)} )}
{device.connection?.connType === "http" && ( {device.connection?.connType === "http" && (
<> <>
<NetworkIcon size={16} /> <NetworkIcon size={16} />
Network {t(
"dashboard.connectionType_network",
)}
</> </>
)} )}
</div> </div>
@ -96,15 +111,22 @@ export const Dashboard = () => {
size={48} size={48}
className="mx-auto text-text-secondary" className="mx-auto text-text-secondary"
/> />
<Heading as="h3">No Devices</Heading> <Heading as="h3">
<Subtle>Connect at least one device to get started</Subtle> {t("dashboard.noDevicesTitle")}
</Heading>
{/* <LanguageSwitcher /> */}
<Subtle>
{t("dashboard.noDevicesDescription")}
</Subtle>
<Button <Button
className="gap-2" className="gap-2"
variant="default" variant="default"
onClick={() => setConnectDialogOpen(true)} onClick={() => setConnectDialogOpen(true)}
> >
<PlusIcon size={16} /> <PlusIcon size={16} />
New Connection {t(
"dashboard.button_newConnection",
)}
</Button> </Button>
</div> </div>
)} )}

35
src/pages/Messages.tsx

@ -20,6 +20,7 @@ import {
import { useSidebar } from "@core/stores/sidebarStore.tsx"; import { useSidebar } from "@core/stores/sidebarStore.tsx";
import { Input } from "@components/UI/Input.tsx"; import { Input } from "@components/UI/Input.tsx";
import { randId } from "@core/utils/randId.ts"; import { randId } from "@core/utils/randId.ts";
import { useTranslation } from "react-i18next";
type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number }; type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number };
@ -45,6 +46,7 @@ export const MessagesPage = () => {
const { toast } = useToast(); const { toast } = useToast();
const { isCollapsed } = useSidebar(); const { isCollapsed } = useSidebar();
const [searchTerm, setSearchTerm] = useState<string>(""); const [searchTerm, setSearchTerm] = useState<string>("");
const { t } = useTranslation(["messages", "channels", "ui"]);
const deferredSearch = useDeferredValue(searchTerm); const deferredSearch = useDeferredValue(searchTerm);
const filteredNodes = (): NodeInfoWithUnread[] => { const filteredNodes = (): NodeInfoWithUnread[] => {
@ -163,7 +165,7 @@ export const MessagesPage = () => {
default: default:
return ( return (
<div className="flex-1 flex items-center justify-center text-slate-500 p-4"> <div className="flex-1 flex items-center justify-center text-slate-500 p-4">
Select a channel or node to start messaging. {t("messagesPage.selectChatPrompt")}
</div> </div>
); );
} }
@ -171,13 +173,21 @@ export const MessagesPage = () => {
const leftSidebar = useMemo(() => ( const leftSidebar = useMemo(() => (
<Sidebar> <Sidebar>
<SidebarSection label="Channels" className="py-2 px-0"> <SidebarSection
label={t("navigation.channels")}
className="py-2 px-0"
>
{filteredChannels?.map((channel) => ( {filteredChannels?.map((channel) => (
<SidebarButton <SidebarButton
key={channel.index} key={channel.index}
count={unreadCounts.get(channel.index)} count={unreadCounts.get(channel.index)}
label={channel.settings?.name || label={channel.settings?.name ||
(channel.index === 0 ? "Primary" : `Ch ${channel.index}`)} (channel.index === 0
? t("page.broadcastLabel", { ns: "channels" })
: t("page.channelLabel", {
index: channel.index,
ns: "channels",
}))}
active={activeChat === channel.index && active={activeChat === channel.index &&
chatType === MessageType.Broadcast} chatType === MessageType.Broadcast}
onClick={() => { onClick={() => {
@ -214,7 +224,7 @@ export const MessagesPage = () => {
<label className="p-2 block"> <label className="p-2 block">
<Input <Input
type="text" type="text"
placeholder="Search nodes..." placeholder={t("search.nodes")}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
showClearButton={!!searchTerm} showClearButton={!!searchTerm}
@ -229,7 +239,8 @@ export const MessagesPage = () => {
<SidebarButton <SidebarButton
key={node.num} key={node.num}
preventCollapse preventCollapse
label={node.user?.longName ?? `UNK`} label={node.user?.longName ??
t("unknown.shortName")}
count={node.unreadCount > 0 ? node.unreadCount : undefined} count={node.unreadCount > 0 ? node.unreadCount : undefined}
active={activeChat === node.num && active={activeChat === node.num &&
chatType === MessageType.Direct} chatType === MessageType.Direct}
@ -240,7 +251,8 @@ export const MessagesPage = () => {
}} }}
> >
<Avatar <Avatar
text={node.user?.shortName ?? "UNK"} text={node.user?.shortName ??
t("unknown.shortName")}
className={cn(hasNodeError(node.num) && "text-red-500")} className={cn(hasNodeError(node.num) && "text-red-500")}
showError={hasNodeError(node.num)} showError={hasNodeError(node.num)}
showFavorite={node.isFavorite} showFavorite={node.isFavorite}
@ -262,14 +274,15 @@ export const MessagesPage = () => {
hasNodeError, hasNodeError,
], ],
); );
return ( return (
<PageLayout <PageLayout
label={`Messages: ${ label={`Messages: ${
isBroadcast && currentChannel isBroadcast && currentChannel
? getChannelName(currentChannel) ? getChannelName(currentChannel)
: isDirect && otherNode : isDirect && otherNode
? (otherNode.user?.longName ?? "Unknown") ? (otherNode.user?.longName ?? t("unknown.longName"))
: "Select a Chat" : t("emptyState.title")
}`} }`}
rightBar={rightSidebar} rightBar={rightSidebar}
leftBar={leftSidebar} leftBar={leftSidebar}
@ -284,8 +297,8 @@ export const MessagesPage = () => {
onClick() { onClick() {
toast({ toast({
title: otherNode.user?.publicKey?.length title: otherNode.user?.publicKey?.length
? "Chat is using PKI encryption." ? t("toast.pkiEncryption")
: "Chat is using PSK encryption.", : t("toast.pskEncryption"),
}); });
}, },
}, },
@ -306,7 +319,7 @@ export const MessagesPage = () => {
) )
: ( : (
<div className="p-4 text-center text-slate-400 italic"> <div className="p-4 text-center text-slate-400 italic">
Select a chat to send a message. {t("messagesPage.sendMessagePrompt")}
</div> </div>
)} )}
</div> </div>

101
src/pages/Nodes.tsx

@ -1,5 +1,4 @@
import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog.tsx"; import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog.tsx";
import { NodeDetailsDialog } from "@app/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog.tsx"; import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog.tsx";
import { Sidebar } from "@components/Sidebar.tsx"; import { Sidebar } from "@components/Sidebar.tsx";
import { Avatar } from "@components/UI/Avatar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx";
@ -7,6 +6,7 @@ import { Mono } from "@components/generic/Mono.tsx";
import { Table } from "@components/generic/Table/index.tsx"; import { Table } from "@components/generic/Table/index.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx"; import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { Protobuf, type Types } from "@meshtastic/core"; import { Protobuf, type Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { LockIcon, LockOpenIcon } from "lucide-react"; import { LockIcon, LockOpenIcon } from "lucide-react";
@ -26,6 +26,7 @@ import {
useFilterNode, useFilterNode,
} from "@components/generic/Filter/useFilterNode.ts"; } from "@components/generic/Filter/useFilterNode.ts";
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx"; import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
import { useTranslation } from "react-i18next";
export interface DeleteNoteDialogProps { export interface DeleteNoteDialogProps {
open: boolean; open: boolean;
@ -33,11 +34,12 @@ export interface DeleteNoteDialogProps {
} }
const NodesPage = (): JSX.Element => { const NodesPage = (): JSX.Element => {
const { getNodes, hardware, connection, hasNodeError } = useDevice(); const { t } = useTranslation("nodes");
const { getNodes, hardware, connection, hasNodeError, setDialogOpen } =
useDevice();
const { setNodeNumDetails } = useAppStore();
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode(); const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode();
const [selectedNode, setSelectedNode] = useState<
Protobuf.Mesh.NodeInfo | undefined
>(undefined);
const [selectedTraceroute, setSelectedTraceroute] = useState< const [selectedTraceroute, setSelectedTraceroute] = useState<
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined
>(); >();
@ -86,6 +88,11 @@ const NodesPage = (): JSX.Element => {
[hardware.myNodeNum], [hardware.myNodeNum],
); );
function handleNodeInfoDialog(nodeNum: number): void {
setNodeNumDetails(nodeNum);
setDialogOpen("nodeDetails", true);
}
return ( return (
<> <>
<PageLayout <PageLayout
@ -95,7 +102,7 @@ const NodesPage = (): JSX.Element => {
<div className="pl-2 pt-2 flex flex-row"> <div className="pl-2 pt-2 flex flex-row">
<div className="flex-1 mr-2"> <div className="flex-1 mr-2">
<Input <Input
placeholder="Search nodes..." placeholder={t("search.nodes")}
value={filterState.nodeName} value={filterState.nodeName}
className="bg-transparent" className="bg-transparent"
showClearButton={!!filterState.nodeName} showClearButton={!!filterState.nodeName}
@ -128,27 +135,55 @@ const NodesPage = (): JSX.Element => {
<Table <Table
headings={[ headings={[
{ title: "", type: "blank", sortable: false }, { title: "", type: "blank", sortable: false },
{ title: "Long Name", type: "normal", sortable: true }, {
{ title: "Connection", type: "normal", sortable: true }, title: t("nodesTable.headings.longName"),
{ title: "Last Heard", type: "normal", sortable: true }, type: "normal",
{ title: "Encryption", type: "normal", sortable: false }, sortable: true,
{ title: "SNR", type: "normal", sortable: true }, },
{ title: "Model", type: "normal", sortable: true }, {
{ title: "MAC Address", type: "normal", sortable: true }, title: t("nodesTable.headings.connection"),
type: "normal",
sortable: true,
},
{
title: t("nodesTable.headings.lastHeard"),
type: "normal",
sortable: true,
},
{
title: t("nodesTable.headings.encryption"),
type: "normal",
sortable: false,
},
{
title: t("unit.snr"),
type: "normal",
sortable: true,
},
{
title: t("nodesTable.headings.model"),
type: "normal",
sortable: true,
},
{
title: t("nodesTable.headings.macAddress"),
type: "normal",
sortable: true,
},
]} ]}
rows={filteredNodes.map((node) => [ rows={filteredNodes.map((node) => [
<div key={node.num}> <div key={node.num}>
<Avatar <Avatar
text={node.user?.shortName ?? "UNK "} text={node.user?.shortName ?? t("unknown.shortName")}
showFavorite={node.isFavorite} showFavorite={node.isFavorite}
showError={hasNodeError(node.num)} showError={hasNodeError(node.num)}
/> />
</div>, </div>,
<h1 <h1
key="longName" key="longName"
onMouseDown={() => setSelectedNode(node)} onMouseDown={() => handleNodeInfoDialog(node.num)}
onKeyUp={(evt) => { onKeyUp={(evt) => {
evt.key === "Enter" && setSelectedNode(node); evt.key === "Enter" && handleNodeInfoDialog(node.num);
}} }}
className="cursor-pointer underline ml-2 whitespace-break-spaces" className="cursor-pointer underline ml-2 whitespace-break-spaces"
tabIndex={0} tabIndex={0}
@ -159,16 +194,20 @@ const NodesPage = (): JSX.Element => {
<Mono key="hops" className="w-16"> <Mono key="hops" className="w-16">
{node.hopsAway !== undefined {node.hopsAway !== undefined
? node?.viaMqtt === false && node.hopsAway === 0 ? node?.viaMqtt === false && node.hopsAway === 0
? "Direct" ? t("nodesTable.connectionStatus.direct")
: `${node.hopsAway?.toString()} ${ : `${node.hopsAway?.toString()} ${
node.hopsAway ?? 0 > 1 ? "hops" : "hop" node.hopsAway ?? 0 > 1
} away` ? t("unit.hop.plural")
: "-"} : t("unit.hops_one")
{node?.viaMqtt === true ? ", via MQTT" : ""} } ${t("nodesTable.connectionStatus.away")}`
: t("nodesTable.connectionStatus.unknown")}
{node?.viaMqtt === true
? t("nodesTable.connectionStatus.viaMqtt")
: ""}
</Mono>, </Mono>,
<Mono key="lastHeard"> <Mono key="lastHeard">
{node.lastHeard === 0 {node.lastHeard === 0
? <p>Never</p> ? <p>{t("nodesTable.lastHeardStatus.never")}</p>
: <TimeAgo timestamp={node.lastHeard * 1000} />} : <TimeAgo timestamp={node.lastHeard * 1000} />}
</Mono>, </Mono>,
<Mono key="pki"> <Mono key="pki">
@ -177,9 +216,14 @@ const NodesPage = (): JSX.Element => {
: <LockOpenIcon className="text-yellow-300 mx-auto" />} : <LockOpenIcon className="text-yellow-300 mx-auto" />}
</Mono>, </Mono>,
<Mono key="snr"> <Mono key="snr">
{node.snr}db/ {node.snr}
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/ {t("unit.dbm")}/
{(node.snr + 10) * 5}raw {Math.min(
Math.max((node.snr + 10) * 5, 0),
100,
)}%/{/* Percentage */}
{(node.snr + 10) * 5}
{t("unit.raw")}
</Mono>, </Mono>,
<Mono key="model"> <Mono key="model">
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]} {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
@ -188,15 +232,10 @@ const NodesPage = (): JSX.Element => {
{base16 {base16
.stringify(node.user?.macaddr ?? []) .stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g) .match(/.{1,2}/g)
?.join(":") ?? "UNK"} ?.join(":") ?? t("unknown.shortName")}
</Mono>, </Mono>,
])} ])}
/> />
<NodeDetailsDialog
node={selectedNode}
open={!!selectedNode}
onOpenChange={() => setSelectedNode(undefined)}
/>
<TracerouteResponseDialog <TracerouteResponseDialog
traceroute={selectedTraceroute} traceroute={selectedTraceroute}
open={!!selectedTraceroute} open={!!selectedTraceroute}

65
src/tests/setupTests.ts

@ -3,6 +3,18 @@ import { cleanup } from "@testing-library/react";
import { enableMapSet } from "immer"; import { enableMapSet } from "immer";
import "@testing-library/jest-dom"; import "@testing-library/jest-dom";
import "@testing-library/user-event"; import "@testing-library/user-event";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import channelsEN from "@app/i18n/locales/en/channels.json";
import commandPaletteEN from "@app/i18n/locales/en/commandPalette.json";
import commonEN from "@app/i18n/locales/en/common.json";
import deviceConfigEN from "@app/i18n/locales/en/deviceConfig.json";
import moduleConfigEN from "@app/i18n/locales/en/moduleConfig.json";
import dashboardEN from "@app/i18n/locales/en/dashboard.json";
import dialogEN from "@app/i18n/locales/en/dialog.json";
import messagesEN from "@app/i18n/locales/en/messages.json";
import nodesEN from "@app/i18n/locales/en/nodes.json";
import uiEN from "@app/i18n/locales/en/ui.json";
enableMapSet(); enableMapSet();
@ -14,12 +26,65 @@ vi.mock("idb-keyval", () => ({
keys: vi.fn(() => Promise.resolve([])), keys: vi.fn(() => Promise.resolve([])),
createStore: vi.fn(() => ({})), createStore: vi.fn(() => ({})),
})); }));
globalThis.ResizeObserver = class { globalThis.ResizeObserver = class {
observe() {} observe() {}
unobserve() {} unobserve() {}
disconnect() {} disconnect() {}
}; };
const appNamespaces = [
"channels",
"commandPalette",
"common",
"deviceConfig",
"moduleConfig",
"dashboard",
"dialog",
"messages",
"nodes",
"ui",
];
const appFallbackNS = ["common", "ui", "dialog"];
const appDefaultNS = "common";
i18n
.use(initReactI18next)
.init({
lng: "en",
fallbackLng: "en",
ns: appNamespaces,
defaultNS: appDefaultNS,
fallbackNS: appFallbackNS,
supportedLngs: ["en"],
resources: {
en: {
channels: channelsEN,
commandPalette: commandPaletteEN,
common: commonEN,
deviceConfig: deviceConfigEN,
moduleConfig: moduleConfigEN,
dashboard: dashboardEN,
dialog: dialogEN,
messages: messagesEN,
nodes: nodesEN,
ui: uiEN,
},
},
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
debug: false,
});
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
}); });

47
vite.config.ts

@ -1,48 +1,47 @@
import { defineConfig } from 'vite'; import { defineConfig } from "vite";
import react from '@vitejs/plugin-react'; import react from "@vitejs/plugin-react";
import { VitePWA } from 'vite-plugin-pwa'; import { VitePWA } from "vite-plugin-pwa";
import { execSync } from 'node:child_process'; import { execSync } from "node:child_process";
import process from "node:process"; import process from "node:process";
import path from 'node:path'; import path from "node:path";
let hash = "";
let hash = '';
try { try {
hash = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim(); hash = execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim();
} catch (error) { } catch (error) {
console.error('Error getting git hash:', error); console.error("Error getting git hash:", error);
hash = 'DEV'; hash = "DEV";
} }
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
react(), react(),
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: "autoUpdate",
strategies: 'generateSW', strategies: "generateSW",
devOptions: { devOptions: {
enabled: false enabled: false,
}, },
workbox: { workbox: {
cleanupOutdatedCaches: true, cleanupOutdatedCaches: true,
sourcemap: true sourcemap: true,
} },
}) }),
], ],
define: { define: {
'import.meta.env.VITE_COMMIT_HASH': JSON.stringify(hash), "import.meta.env.VITE_COMMIT_HASH": JSON.stringify(hash),
}, },
build: { build: {
emptyOutDir: true, emptyOutDir: true,
assetsDir: './', assetsDir: "./",
}, },
resolve: { resolve: {
alias: { alias: {
'@app': path.resolve(process.cwd(), './src'), "@app": path.resolve(process.cwd(), "./src"),
'@pages': path.resolve(process.cwd(), './src/pages'), "@pages": path.resolve(process.cwd(), "./src/pages"),
'@components': path.resolve(process.cwd(), './src/components'), "@components": path.resolve(process.cwd(), "./src/components"),
'@core': path.resolve(process.cwd(), './src/core'), "@core": path.resolve(process.cwd(), "./src/core"),
'@layouts': path.resolve(process.cwd(), './src/layouts'), "@layouts": path.resolve(process.cwd(), "./src/layouts"),
}, },
}, },
server: { server: {
@ -52,4 +51,4 @@ export default defineConfig({
"Cross-Origin-Embedder-Policy": "require-corp", "Cross-Origin-Embedder-Policy": "require-corp",
}, },
}, },
}); });

22
vitest.config.ts

@ -1,6 +1,6 @@
import path from "node:path"; import path from "node:path";
import react from '@vitejs/plugin-react'; import react from "@vitejs/plugin-react";
import { defineConfig } from 'vitest/config' import { defineConfig } from "vitest/config";
import { enableMapSet } from "immer"; import { enableMapSet } from "immer";
enableMapSet(); enableMapSet();
@ -10,21 +10,21 @@ export default defineConfig({
], ],
resolve: { resolve: {
alias: { alias: {
'@app': path.resolve(process.cwd(), './src'), "@app": path.resolve(process.cwd(), "./src"),
'@core': path.resolve(process.cwd(), './src/core'), "@core": path.resolve(process.cwd(), "./src/core"),
'@pages': path.resolve(process.cwd(), './src/pages'), "@pages": path.resolve(process.cwd(), "./src/pages"),
'@components': path.resolve(process.cwd(), './src/components'), "@components": path.resolve(process.cwd(), "./src/components"),
'@layouts': path.resolve(process.cwd(), './src/layouts'), "@layouts": path.resolve(process.cwd(), "./src/layouts"),
}, },
}, },
test: { test: {
environment: 'happy-dom', environment: "happy-dom",
globals: true, globals: true,
mockReset: true, mockReset: true,
clearMocks: true, clearMocks: true,
restoreMocks: true, restoreMocks: true,
root: path.resolve(process.cwd(), './src'), root: path.resolve(process.cwd(), "./src"),
include: ['**/*.{test,spec}.{ts,tsx}'], include: ["**/*.{test,spec}.{ts,tsx}"],
setupFiles: ["./src/tests/setupTests.ts"], setupFiles: ["./src/tests/setupTests.ts"],
}, },
}) });

Loading…
Cancel
Save