Browse Source

Remove deprecated meshtastic/js dependency (#638)

* Remove deprecated meshtastic/js dependency

* Bump dependency version

* Fix linting

---------

Co-authored-by: philon- <[email protected]>
pull/645/head
Jeremy Gallant 1 year ago
committed by “tpaulsoncdw”
parent
commit
2c9bd4e80b
  1. 4
      .github/ISSUE_TEMPLATE/bug.yml
  2. 2
      .github/ISSUE_TEMPLATE/feature.yml
  3. 24
      .github/workflows/crowdin-download.yml
  4. 22
      .github/workflows/crowdin-upload-sources.yml
  5. 4
      .github/workflows/nightly.yml
  6. 2
      .github/workflows/pr.yml
  7. 2
      .github/workflows/release.yml
  8. 10
      .github/workflows/update-stable-from-master.yml
  9. 4
      README.md
  10. 14
      deno.json
  11. 65
      deno.lock
  12. 7
      index.html
  13. 14495
      package-lock.json
  14. 10
      package.json
  15. 2
      postcss.config.cjs
  16. 22
      src/App.tsx
  17. 58
      src/components/ConnectionTabs/ConnectionTabs.tsx
  18. 224
      src/components/Dashboard/BluetoothSection.tsx
  19. 410
      src/components/Dashboard/HTTPServerSection.tsx
  20. 233
      src/components/Dashboard/SerialSection.tsx
  21. 38
      src/components/Dialog/NewConnectionDialog.tsx
  22. 237
      src/components/Dialog/Tabs/BluetoothTab.tsx
  23. 674
      src/components/Dialog/Tabs/HTTPTab.tsx
  24. 249
      src/components/Dialog/Tabs/SerialTab.tsx
  25. 12
      src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.test.tsx
  26. 15
      src/components/PageComponents/Connect/BLE.tsx
  27. 508
      src/components/PageComponents/Connect/HTTP.tsx
  28. 2
      src/components/PageComponents/Messages/MessageItem.tsx
  29. 2
      src/components/UI/Tabs.tsx
  30. 10
      src/core/dto/PacketToMessageDTO.ts
  31. 4
      src/core/hooks/useLocalStorage.ts
  32. 108
      src/core/stores/appStore.ts
  33. 60
      src/pages/Dashboard/index.tsx
  34. 2
      src/pages/Messages.tsx
  35. 6
      src/routes.tsx
  36. 6
      vite.config.ts

4
.github/ISSUE_TEMPLATE/bug.yml

@ -95,7 +95,7 @@ body:
- type: input
id: browser
attributes:
label: Browser
label: Browser
description: What browser are you using? Include version if possible.
placeholder: e.g., Chrome 108, Firefox 107, Safari 16.2
validations:
@ -153,4 +153,4 @@ body:
- type: markdown
attributes:
value: |
Thank you for helping improve our project by reporting this bug!
Thank you for helping improve our project by reporting this bug!

2
.github/ISSUE_TEMPLATE/feature.yml

@ -62,4 +62,4 @@ body:
- type: markdown
attributes:
value: |
Thank you for taking the time to fill out this feature request!
Thank you for taking the time to fill out this feature request!

24
.github/workflows/crowdin-download.yml

@ -1,9 +1,9 @@
name: Crowdin Download Translations Action
on:
schedule: # Every Sunday at midnight
- cron: '0 0 * * 0'
workflow_dispatch: # Allow manual triggering
schedule: # Every Sunday at midnight
- cron: "0 0 * * 0"
workflow_dispatch: # Allow manual triggering
jobs:
synchronize-with-crowdin:
@ -16,20 +16,20 @@ jobs:
- name: Download translations with Crowdin
uses: crowdin/github-action@v2
with:
base_url: 'https://meshtastic.crowdin.com/api/v2'
config: 'crowdin.yml'
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'
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'
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 }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

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

@ -5,9 +5,9 @@ on:
# 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
- "/src/i18n/locales/en/**/*.json"
branches: [main]
workflow_dispatch: # Allow manual triggering
jobs:
synchronize-with-crowdin:
@ -20,13 +20,13 @@ jobs:
- 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'
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 }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

4
.github/workflows/nightly.yml

@ -1,4 +1,4 @@
name: 'Nightly Release'
name: "Nightly Release"
on:
schedule:
@ -22,7 +22,7 @@ jobs:
- name: Install Dependencies
run: deno install
- name: Run tests
run: deno task test

2
.github/workflows/pr.yml

@ -24,7 +24,7 @@ jobs:
key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }}
restore-keys: |
${{ runner.os }}-deno-
- name: Install Dependencies
run: deno install

2
.github/workflows/release.yml

@ -22,7 +22,7 @@ jobs:
- name: Install Dependencies
run: deno install
- name: Run tests
run: deno task test

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

@ -2,14 +2,14 @@ name: Update Stable Branch from Main on Latest Release
on:
release:
types: [released]
types: [released]
permissions:
contents: write
contents: write
jobs:
update-stable-branch:
name: Update Stable Branch from Main
name: Update Stable Branch from Main
runs-on: ubuntu-latest
steps:
@ -43,8 +43,8 @@ jobs:
git checkout -b stable ${{ env.MAIN_SHA }}
fi
- name: Reset stable branch to latest main
- name: Reset stable branch to latest main
run: git reset --hard ${{ env.MAIN_SHA }}
- name: Force push stable branch
run: git push origin stable --force
run: git push origin stable --force

4
README.md

@ -60,8 +60,8 @@ Our release process follows these guidelines:
- **Pre-releases:** A pre-release is typically issued mid-month for testing and
early adoption.
- **Nightly Builds:** An experimental Docker image containing the latest
cutting-edge features and fixes is automatically built nightly from the
`main` branch.
cutting-edge features and fixes is automatically built nightly from the `main`
branch.
### Nightly Builds

14
deno.json

@ -46,5 +46,17 @@
},
"unstable": [
"sloppy-imports"
]
],
"tasks": {
"dev": "deno task dev:ui",
"dev:ui": "VITE_APP_VERSION=development deno run -A npm:vite dev",
"build": "deno run -A npm:vite build",
"build:analyze": "BUNDLE_ANALYZE=true deno task build",
"lint": "deno lint src/",
"lint:fix": "deno lint --fix src/",
"format": "deno fmt src/",
"test": "deno run -A npm:vitest",
"preview": "deno run -A npm:vite preview",
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ."
}
}

65
deno.lock

@ -5,10 +5,6 @@
"jsr:@std/path@^1.1.0": "1.1.0",
"npm:@bufbuild/protobuf@^2.2.5": "2.2.5",
"npm:@jsr/[email protected]": "2.6.2",
"npm:@jsr/[email protected]": "2.6.0-0",
"npm:@jsr/meshtastic__transport-http@*": "0.2.1",
"npm:@jsr/meshtastic__transport-web-bluetooth@*": "0.1.1",
"npm:@jsr/meshtastic__transport-web-serial@*": "0.2.1",
"npm:@noble/curves@^1.9.0": "1.9.0",
"npm:@radix-ui/react-accordion@^1.2.8": "1.2.8_@[email protected]_@[email protected]__@[email protected][email protected][email protected][email protected]",
"npm:@radix-ui/react-checkbox@^1.2.3": "1.2.3_@[email protected]_@[email protected]__@[email protected][email protected][email protected][email protected]",
@ -46,7 +42,6 @@
"npm:@vitejs/plugin-react@^4.4.1": "[email protected]__@[email protected][email protected]_@[email protected]_@[email protected]",
"npm:autoprefixer@^10.4.21": "[email protected]",
"npm:base64-js@^1.5.1": "1.5.1",
"npm:class-validator@~0.14.2": "0.14.2",
"npm:class-variance-authority@~0.7.1": "0.7.1",
"npm:clsx@^2.1.1": "2.1.1",
"npm:cmdk@^1.1.1": "[email protected][email protected][email protected]_@[email protected]_@[email protected]__@[email protected]",
@ -1131,17 +1126,6 @@
],
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__core/2.6.2.tgz"
},
"@jsr/[email protected]": {
"integrity": "sha512-+xpZpxK6oUIVOuEs7C+LyxRr2druvc7UNNNTK9Rl8ioXj63Jz1uQXlYe2Gj0xjnRAiSQLR7QVaPef21BR/YTxA==",
"dependencies": [
"@bufbuild/protobuf",
"@jsr/meshtastic__protobufs",
"crc",
"ste-simple-events",
"tslog"
],
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__js/2.6.0-0.tgz"
},
"@jsr/[email protected]": {
"integrity": "sha512-bIENtFnUEru28GrAeSdiBS9skp0hN/3HZunMbF/IjvUrXOlx2fptKVj3b+pzjOWnLBZxllrByV/W+XDmrxqJ6g==",
"dependencies": [
@ -1149,27 +1133,6 @@
],
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__protobufs/2.6.2.tgz"
},
"@jsr/[email protected]": {
"integrity": "sha512-lmQKr3aIINKvtGROU4HchmSVqbZSbkIHqajowRRC8IAjsnR0zNTyxz210QyY4pFUF9hpcW3GRjwq5h/VO2JuGg==",
"dependencies": [
"@jsr/meshtastic__core"
],
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-http/0.2.1.tgz"
},
"@jsr/[email protected]": {
"integrity": "sha512-eAj23n/Pxe8hMjO/uYbI/C+l1s0tLm41EzvcLWQtLQyEKJpPP+/Eqc5lUmDeF7FVPS2IYhllFJvV8Ili7okHtQ==",
"dependencies": [
"@jsr/meshtastic__core"
],
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-web-bluetooth/0.1.1.tgz"
},
"@jsr/[email protected]": {
"integrity": "sha512-yumjEGLkAuJYOC3aWKvZzbQqi/LnqaKfNpVCY7Ki7oLtAshNiZrBLiwiFhN7+ZR9FfMdJThyBMqREBDRRWTO1Q==",
"dependencies": [
"@jsr/meshtastic__core"
],
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-web-serial/0.2.1.tgz"
},
"@mapbox/[email protected]": {
"integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==",
"dependencies": [
@ -4000,9 +3963,6 @@
"@types/[email protected]": {
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
},
"@types/[email protected]": {
"integrity": "sha512-nh7nrWhLr6CBq9ldtw0wx+z9wKnnv/uTVLA9g/3/TcOYxbpOSZE+MhKPmWqU+K0NvThjhv12uD8MuqijB0WzEA=="
},
"@types/[email protected]": {
"integrity": "sha512-QQOT+bxQJhRGXoZDZGLs3ksLud1dMNnMiSQtBA0w8KXvLpXX4oM4TZb6J0GgJ8UbCaHo5s9/4VQT8uXy9JER2A=="
},
@ -4485,14 +4445,6 @@
"[email protected]"
]
},
"[email protected]": {
"integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==",
"dependencies": [
"@types/validator",
"libphonenumber-js",
"validator"
]
},
"[email protected]": {
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"dependencies": [
@ -5639,9 +5591,6 @@
"[email protected]": {
"integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="
},
"[email protected]": {
"integrity": "sha512-0nYZSNj/QEikyhcM5RZFXGlCB/mr4PVamnT1C2sKBnDDTYndrvbybYjvg+PMqAndQHlLbwQ3socolnL3WWTUFA=="
},
"[email protected]": {
"integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==",
"os": ["darwin"],
@ -7174,9 +7123,6 @@
"which-typed-array"
]
},
"[email protected]": {
"integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA=="
},
"[email protected]_@[email protected]": {
"integrity": "sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==",
"dependencies": [
@ -7581,10 +7527,9 @@
"dependencies": [
"npm:@bufbuild/protobuf@^2.2.5",
"npm:@jsr/[email protected]",
"npm:@jsr/[email protected]",
"npm:@jsr/meshtastic__transport-http@*",
"npm:@jsr/meshtastic__transport-web-bluetooth@*",
"npm:@jsr/meshtastic__transport-web-serial@*",
"npm:@jsr/[email protected]",
"npm:@jsr/[email protected]",
"npm:@jsr/[email protected]",
"npm:@noble/curves@^1.9.0",
"npm:@radix-ui/react-accordion@^1.2.8",
"npm:@radix-ui/react-checkbox@^1.2.3",
@ -7622,11 +7567,11 @@
"npm:@vitejs/plugin-react@^4.4.1",
"npm:autoprefixer@^10.4.21",
"npm:base64-js@^1.5.1",
"npm:class-validator@~0.14.2",
"npm:class-variance-authority@~0.7.1",
"npm:clsx@^2.1.1",
"npm:cmdk@^1.1.1",
"npm:crypto-random-string@5",
"npm:deno@^2.3.5",
"npm:gzipper@^8.2.1",
"npm:happy-dom@^17.4.6",
"npm:i18next-browser-languagedetector@^8.1.0",
@ -7659,7 +7604,7 @@
"npm:vite-plugin-static-copy@3",
"npm:vite@^6.3.4",
"npm:vitest@^3.1.2",
"npm:zod@^3.24.3",
"npm:zod@^3.25.0",
"npm:[email protected]"
]
}

7
index.html

@ -8,10 +8,15 @@
<link rel="manifest" href="site.webmanifest" />
<link rel="mask-icon" href="Logo_Black.svg" color="#67ea94" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<link
rel="stylesheet"
href="https://rsms.me/inter/inter.css"
crossorigin="anonymous"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@xz/fonts@1/serve/cascadia-code.min.css"
crossorigin="anonymous"
/>
<meta name="theme-color" content="#67ea94" />
<meta

14495
package-lock.json

File diff suppressed because it is too large

10
package.json

@ -1,5 +1,5 @@
{
"name": "meshtastic-web",
"name": "@meshtastic/web",
"version": "2.6.0-0",
"type": "module",
"description": "Meshtastic web client",
@ -36,10 +36,9 @@
"dependencies": {
"@bufbuild/protobuf": "^2.2.5",
"@meshtastic/core": "npm:@jsr/[email protected]",
"@meshtastic/js": "npm:@jsr/[email protected]",
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http",
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth",
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial",
"@meshtastic/transport-http": "npm:@jsr/[email protected]",
"@meshtastic/transport-web-bluetooth": "npm:@jsr/[email protected]",
"@meshtastic/transport-web-serial": "npm:@jsr/[email protected]",
"@noble/curves": "^1.9.0",
"@radix-ui/react-accordion": "^1.2.8",
"@radix-ui/react-checkbox": "^1.2.3",
@ -66,6 +65,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"crypto-random-string": "^5.0.0",
"deno": "^2.3.5",
"i18next": "^25.2.0",
"i18next-browser-languagedetector": "^8.1.0",
"i18next-http-backend": "^3.0.2",

2
postcss.config.cjs

@ -1,5 +1,5 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
"@tailwindcss/postcss": {},
},
};

22
src/App.tsx

@ -1,6 +1,5 @@
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
import { Toaster } from "@components/Toaster.tsx";
import Footer from "@components/UI/Footer.tsx";
@ -13,27 +12,30 @@ import { MapProvider } from "react-map-gl/maplibre";
import { CommandPalette } from "@components/CommandPalette/index.tsx";
import { SidebarProvider } from "@core/stores/sidebarStore.tsx";
import { useTheme } from "@core/hooks/useTheme.ts";
import { Outlet } from "@tanstack/react-router";
import { Outlet, useLocation, useNavigate } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { useEffect } from "react";
export function App() {
const { getDevice } = useDeviceStore();
const { selectedDevice, setConnectDialogOpen, connectDialogOpen } =
useAppStore();
const { selectedDevice } = useAppStore();
const navigate = useNavigate();
const location = useLocation();
const device = getDevice(selectedDevice);
// Sets up light/dark mode based on user preferences or system settings
useTheme();
// Redirect to messages when a device connects and we're on the dashboard
useEffect(() => {
if (device && location.pathname === "/") {
navigate({ to: "/messages/broadcast/0", replace: true });
}
}, [device, location.pathname, navigate]);
return (
<ErrorBoundary FallbackComponent={ErrorPage}>
<NewDeviceDialog
open={connectDialogOpen}
onOpenChange={(open) => {
setConnectDialogOpen(open);
}}
/>
<Toaster />
<TanStackRouterDevtools position="bottom-right" />
<DeviceWrapper device={device}>

58
src/components/ConnectionTabs/ConnectionTabs.tsx

@ -0,0 +1,58 @@
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import { HTTPTab } from "@components/Dialog/Tabs/HTTPTab.tsx";
import { BluetoothTab } from "@components/Dialog/Tabs/BluetoothTab.tsx";
import { SerialTab } from "@components/Dialog/Tabs/SerialTab.tsx";
import { Bluetooth, Server, Usb } from "lucide-react";
interface ConnectionTabsProps {
closeDialog?: () => void;
className?: string;
}
export const ConnectionTabs = (
{ closeDialog, className }: ConnectionTabsProps,
) => {
const handleClose = () => {
if (closeDialog) {
closeDialog();
}
};
return (
<Tabs defaultValue="http" className={`w-full ${className || ""}`}>
<TabsList className="grid w-full grid-cols-3 mb-4">
<TabsTrigger value="http" className="flex items-center gap-2">
<Server className="h-4 w-4" />
HTTP
</TabsTrigger>
<TabsTrigger value="bluetooth" className="flex items-center gap-2">
<Bluetooth className="h-4 w-4" />
Bluetooth
</TabsTrigger>
<TabsTrigger value="serial" className="flex items-center gap-2">
<Usb className="h-4 w-4" />
Serial
</TabsTrigger>
</TabsList>
<div className="min-h-[400px] max-h-[500px] overflow-y-auto">
<TabsContent value="http" className="mt-0">
<HTTPTab closeDialog={handleClose} />
</TabsContent>
<TabsContent value="bluetooth" className="mt-0">
<BluetoothTab closeDialog={handleClose} />
</TabsContent>
<TabsContent value="serial" className="mt-0">
<SerialTab closeDialog={handleClose} />
</TabsContent>
</div>
</Tabs>
);
};

224
src/components/Dashboard/BluetoothSection.tsx

@ -0,0 +1,224 @@
import { useCallback, useEffect, useState } from "react";
import { Button } from "@components/UI/Button.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
import { MeshDevice } from "@meshtastic/core";
import {
AlertTriangle,
Bluetooth,
Circle,
Clock,
Plus,
Trash2,
} from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
interface BluetoothSectionProps {
onConnect?: () => void;
}
export const BluetoothSection = ({ onConnect }: BluetoothSectionProps) => {
const [connectionInProgress, setConnectionInProgress] = useState(false);
const [connectingToDevice, setConnectingToDevice] = useState<string | null>(
null,
);
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
const [connectionError, setConnectionError] = useState<string | null>(null);
const { addDevice } = useDeviceStore();
const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore();
const updateBleDeviceList = useCallback(async (): Promise<void> => {
try {
setBleDevices(await navigator.bluetooth.getDevices());
} catch (error) {
console.error("Error getting Bluetooth devices:", error);
}
}, []);
useEffect(() => {
updateBleDeviceList();
}, [updateBleDeviceList]);
const connectToDevice = async (bleDevice: BluetoothDevice) => {
setConnectingToDevice(bleDevice.id);
setConnectionError(null);
try {
const id = randId();
const transport = await TransportWebBluetooth.createFromDevice(bleDevice);
const device = addDevice(id);
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id);
device.addConnection(connection);
subscribeAll(device, connection, messageStore);
onConnect?.();
} catch (error) {
console.error("Bluetooth connection error:", error);
setConnectionError(`Failed to connect to ${bleDevice.name || "device"}`);
} finally {
setConnectingToDevice(null);
}
};
const handlePairNewDevice = async () => {
setConnectionInProgress(true);
setConnectionError(null);
try {
const device = await navigator.bluetooth.requestDevice({
filters: [{ services: [TransportWebBluetooth.ServiceUuid] }],
});
const exists = bleDevices.findIndex((d) => d.id === device.id);
if (exists === -1) {
setBleDevices([...bleDevices, device]);
}
} catch (error) {
console.error("Error pairing device:", error);
if (error instanceof Error && !error.message.includes("cancelled")) {
setConnectionError("Failed to pair new device");
}
} finally {
setConnectionInProgress(false);
}
};
const removeBleDevice = (deviceId: string) => {
setBleDevices(bleDevices.filter((d) => d.id !== deviceId));
};
const getStatusIcon = (device: BluetoothDevice) => {
const isConnecting = connectingToDevice === device.id;
const isConnected = device.gatt?.connected;
if (isConnecting) {
return (
<Circle className="h-3 w-3 fill-yellow-500 text-yellow-500 animate-spin" />
);
} else if (isConnected) {
return <Circle className="h-3 w-3 fill-blue-500 text-blue-500" />;
} else {
return <Circle className="h-3 w-3 fill-slate-300 text-slate-300" />;
}
};
const getStatusText = (device: BluetoothDevice) => {
if (connectingToDevice === device.id) return "Connecting...";
return device.gatt?.connected ? "Connected" : "Paired";
};
return (
<div className="space-y-3">
{/* Header */}
<div className="flex items-center gap-2">
<Bluetooth className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-slate-900 dark:text-slate-100">
Bluetooth Devices
</h3>
</div>
{/* Device List */}
<div className="space-y-2 bg-slate-50 dark:bg-slate-800/50 rounded-lg p-4 border border-slate-200 dark:border-slate-700">
{bleDevices.length === 0
? (
<div className="text-center py-4 text-slate-500 dark:text-slate-400">
<Bluetooth className="h-8 w-8 mx-auto mb-2 text-slate-300" />
<p className="text-sm">No Bluetooth devices paired</p>
</div>
)
: (
bleDevices.map((device) => (
<div
key={device.id}
className="flex items-center gap-3 p-3 rounded-lg bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 hover:shadow-sm transition-shadow"
>
{/* Status */}
<div className="flex-shrink-0">
{getStatusIcon(device)}
</div>
{/* Device Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900 dark:text-slate-100 truncate">
{device.name || "Unknown Device"}
</span>
<Bluetooth className="h-4 w-4 text-blue-500" />
</div>
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{getStatusText(device)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={() => connectToDevice(device)}
disabled={connectingToDevice === device.id ||
device.gatt?.connected}
>
{connectingToDevice === device.id
? <Clock className="h-3 w-3 mr-1 animate-spin" />
: <Bluetooth className="h-3 w-3 mr-1" />}
{device.gatt?.connected ? "Connected" : "Connect"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => removeBleDevice(device.id)}
className="text-slate-400 hover:text-red-600 p-1"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))
)}
{/* Pair New Device Button */}
<Button
variant="outline"
onClick={handlePairNewDevice}
disabled={connectionInProgress}
className="w-full border-dashed hover:border-solid"
>
{connectionInProgress
? (
<>
<Clock className="h-4 w-4 mr-2 animate-spin" />
Pairing...
</>
)
: (
<>
<Plus className="h-4 w-4 mr-2" />
Pair New Device
</>
)}
</Button>
{/* Connection Error */}
{connectionError && (
<div className="p-3 rounded-md bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-800">
<div className="flex gap-2 items-start">
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-sm text-red-800 dark:text-red-200">
{connectionError}
</p>
</div>
</div>
)}
</div>
</div>
);
};

410
src/components/Dashboard/HTTPServerSection.tsx

@ -0,0 +1,410 @@
import { useEffect, useState } from "react";
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { useForm } from "react-hook-form";
import {
AlertTriangle,
Circle,
Clock,
Lock,
LockOpen,
Plus,
Server,
Trash2,
Users,
Wifi,
} from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import type { SavedServer } from "@core/stores/appStore.ts";
interface AddServerFormData {
hostname: string;
secure: boolean;
}
interface HTTPServerSectionProps {
onConnect?: () => void;
}
export const HTTPServerSection = ({ onConnect }: HTTPServerSectionProps) => {
const [connectionInProgress, setConnectionInProgress] = useState(false);
const [connectingToServer, setConnectingToServer] = useState<string | null>(
null,
);
const [addServerOpen, setAddServerOpen] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null);
const isURLHTTPS = location.protocol === "https:";
const { addDevice } = useDeviceStore();
const messageStore = useMessageStore();
const {
setSelectedDevice,
addSavedServer,
removeSavedServer,
clearSavedServers,
updateServerStatus,
getSavedServers,
} = useAppStore();
const savedServers = getSavedServers();
const { register, handleSubmit, reset, setValue, watch } = useForm<
AddServerFormData
>({
defaultValues: {
hostname: ["client.meshtastic.org", "localhost"].includes(
globalThis.location.hostname,
)
? "meshtastic.local"
: globalThis.location.host,
secure: isURLHTTPS,
},
});
const secureValue = watch("secure");
// Auto-check server status on component mount
useEffect(() => {
savedServers.forEach((server) => {
if (server.status !== "checking") {
checkServerStatus(server);
}
});
}, []);
const checkServerStatus = async (server: SavedServer) => {
updateServerStatus(server.url, "checking");
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const response = await fetch(`${server.url}/api/v1/fromradio?all=false`, {
method: "GET",
mode: "cors",
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok) {
updateServerStatus(server.url, "online");
} else {
const fallbackResponse = await fetch(`${server.url}/`, {
method: "GET",
mode: "cors",
signal: AbortSignal.timeout(3000),
});
updateServerStatus(
server.url,
fallbackResponse.ok ? "online" : "offline",
);
}
} catch {
updateServerStatus(server.url, "offline");
}
};
const connectToServer = async (server: SavedServer) => {
setConnectingToServer(server.url);
setConnectionError(null);
try {
const id = randId();
const transport = await TransportHTTP.create(
server.host,
server.protocol === "https",
);
const device = addDevice(id);
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id);
device.addConnection(connection);
subscribeAll(device, connection, messageStore);
addSavedServer(server.host, server.protocol);
updateServerStatus(server.url, "online");
onConnect?.();
} catch (error) {
console.error("Connection error:", error);
setConnectionError(`Failed to connect to ${server.host}`);
updateServerStatus(server.url, "offline");
} finally {
setConnectingToServer(null);
}
};
const handleAddServer = handleSubmit(async (data) => {
setConnectionInProgress(true);
setConnectionError(null);
try {
const protocol = data.secure ? "https" : "http";
const id = randId();
const transport = await TransportHTTP.create(data.hostname, data.secure);
const device = addDevice(id);
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id);
device.addConnection(connection);
subscribeAll(device, connection, messageStore);
addSavedServer(data.hostname, protocol);
setAddServerOpen(false);
reset();
onConnect?.();
} catch (error) {
console.error("Connection error:", error);
setConnectionError(`Failed to connect to ${data.hostname}`);
} finally {
setConnectionInProgress(false);
}
});
const getStatusIcon = (status?: string) => {
switch (status) {
case "online":
return <Circle className="h-3 w-3 fill-green-500 text-green-500" />;
case "offline":
return <Circle className="h-3 w-3 fill-red-500 text-red-500" />;
case "checking":
return (
<Circle className="h-3 w-3 fill-yellow-500 text-yellow-500 animate-spin" />
);
default:
return <Circle className="h-3 w-3 fill-slate-300 text-slate-300" />;
}
};
const getSecurityIcon = (protocol: "http" | "https") => {
return protocol === "https"
? <Lock className="h-4 w-4 text-green-600" />
: <LockOpen className="h-4 w-4 text-yellow-600" />;
};
const getStatusText = (status?: string) => {
switch (status) {
case "online":
return "Online";
case "offline":
return "Offline";
case "checking":
return "Checking...";
default:
return "Unknown";
}
};
return (
<>
<div className="space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Server className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-slate-900 dark:text-slate-100">
HTTP Servers
</h3>
</div>
{savedServers.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={clearSavedServers}
className="text-red-600 hover:text-red-700"
>
Clear All
</Button>
)}
</div>
{/* Server List */}
<div className="space-y-2 bg-slate-50 dark:bg-slate-800/50 rounded-lg p-4 border border-slate-200 dark:border-slate-700">
{savedServers.length === 0
? (
<div className="text-center py-4 text-slate-500 dark:text-slate-400">
<Server className="h-8 w-8 mx-auto mb-2 text-slate-300" />
<p className="text-sm">No HTTP servers added yet</p>
</div>
)
: (
savedServers.slice(0, 5).map((server) => (
<div
key={server.url}
className="flex items-center gap-3 p-3 rounded-lg bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 hover:shadow-sm transition-shadow"
>
{/* Status */}
<div className="flex-shrink-0">
{getStatusIcon(server.status)}
</div>
{/* Server Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900 dark:text-slate-100 truncate">
{server.host}
</span>
{getSecurityIcon(server.protocol)}
</div>
<div className="flex items-center gap-3 text-xs text-slate-500 dark:text-slate-400 mt-1">
<span>{getStatusText(server.status)}</span>
{server.deviceInfo?.model && (
<>
<span></span>
<span className="font-mono bg-slate-200 dark:bg-slate-600 px-1 rounded">
{server.deviceInfo.model}
</span>
</>
)}
{server.deviceInfo?.nodeCount && (
<>
<span></span>
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{server.deviceInfo.nodeCount}
</span>
</>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={() => connectToServer(server)}
disabled={connectingToServer === server.url}
>
{connectingToServer === server.url
? <Clock className="h-3 w-3 mr-1 animate-spin" />
: <Wifi className="h-3 w-3 mr-1" />}
Connect
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => removeSavedServer(server.url)}
className="text-slate-400 hover:text-red-600 p-1"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))
)}
{/* Add Server Button */}
<Button
variant="outline"
onClick={() => setAddServerOpen(true)}
className="w-full border-dashed hover:border-solid"
>
<Plus className="h-4 w-4 mr-2" />
Add HTTP Server
</Button>
{/* Connection Error */}
{connectionError && (
<div className="p-3 rounded-md bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-800">
<div className="flex gap-2 items-start">
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-sm text-red-800 dark:text-red-200">
{connectionError}
</p>
</div>
</div>
)}
</div>
</div>
{/* Add Server Dialog */}
<Dialog open={addServerOpen} onOpenChange={setAddServerOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Add HTTP Server
</DialogTitle>
</DialogHeader>
<form onSubmit={handleAddServer} className="space-y-4">
<div>
<Label htmlFor="hostname">Hostname or IP Address</Label>
<Input
id="hostname"
placeholder="meshtastic.local or 192.168.1.100"
className="mt-1"
{...register("hostname", { required: true })}
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={isURLHTTPS || secureValue}
onCheckedChange={(checked) => setValue("secure", checked)}
disabled={isURLHTTPS}
{...register("secure")}
/>
<Label className="flex items-center gap-2">
{secureValue
? <Lock className="h-4 w-4 text-green-600" />
: <LockOpen className="h-4 w-4 text-yellow-600" />}
Use HTTPS (Secure)
</Label>
</div>
<div className="flex gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => setAddServerOpen(false)}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={connectionInProgress}
className="flex-1"
>
{connectionInProgress
? (
<>
<Clock className="h-4 w-4 mr-2 animate-spin" />
Connecting...
</>
)
: (
<>
<Wifi className="h-4 w-4 mr-2" />
Connect
</>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</>
);
};

233
src/components/Dashboard/SerialSection.tsx

@ -0,0 +1,233 @@
import { useCallback, useEffect, useState } from "react";
import { Button } from "@components/UI/Button.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
import { TransportWebSerial } from "@meshtastic/transport-web-serial";
import { AlertTriangle, Circle, Clock, Plus, Trash2, Usb } from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
interface SerialSectionProps {
onConnect?: () => void;
}
export const SerialSection = ({ onConnect }: SerialSectionProps) => {
const [connectionInProgress, setConnectionInProgress] = useState(false);
const [connectingToPort, setConnectingToPort] = useState<SerialPort | null>(
null,
);
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
const [connectionError, setConnectionError] = useState<string | null>(null);
const { addDevice } = useDeviceStore();
const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore();
const updateSerialPortList = useCallback(async () => {
try {
setSerialPorts(await navigator?.serial?.getPorts() || []);
} catch (error) {
console.error("Error getting serial ports:", error);
}
}, []);
useEffect(() => {
const handleConnect = () => updateSerialPortList();
const handleDisconnect = () => updateSerialPortList();
navigator?.serial?.addEventListener("connect", handleConnect);
navigator?.serial?.addEventListener("disconnect", handleDisconnect);
updateSerialPortList();
return () => {
navigator?.serial?.removeEventListener("connect", handleConnect);
navigator?.serial?.removeEventListener("disconnect", handleDisconnect);
};
}, [updateSerialPortList]);
const connectToPort = async (port: SerialPort) => {
setConnectingToPort(port);
setConnectionError(null);
try {
const id = randId();
const device = addDevice(id);
setSelectedDevice(id);
const transport = await TransportWebSerial.createFromPort(port);
const connection = new MeshDevice(transport, id);
connection.configure();
device.addConnection(connection);
subscribeAll(device, connection, messageStore);
onConnect?.();
} catch (error) {
console.error("Serial connection error:", error);
setConnectionError("Failed to connect to serial device");
} finally {
setConnectingToPort(null);
}
};
const handleAddSerialDevice = async () => {
setConnectionInProgress(true);
setConnectionError(null);
try {
const port = await navigator.serial.requestPort();
setSerialPorts([...serialPorts, port]);
} catch (error) {
console.error("Error requesting port:", error);
if (error instanceof Error && !error.message.includes("cancelled")) {
setConnectionError("Failed to add serial device");
}
} finally {
setConnectionInProgress(false);
}
};
const removeSerialPort = (portToRemove: SerialPort) => {
setSerialPorts(serialPorts.filter((port) => port !== portToRemove));
};
const getPortInfo = (port: SerialPort, index: number) => {
const { usbProductId, usbVendorId } = port.getInfo();
const vendor = usbVendorId
? `0x${usbVendorId.toString(16).padStart(4, "0")}`
: "Unknown";
const product = usbProductId
? `0x${usbProductId.toString(16).padStart(4, "0")}`
: "Unknown";
return `Serial Port ${index + 1} (${vendor}:${product})`;
};
const getStatusIcon = (port: SerialPort) => {
const isConnecting = connectingToPort === port;
const isConnected = port.readable !== null;
if (isConnecting) {
return (
<Circle className="h-3 w-3 fill-yellow-500 text-yellow-500 animate-spin" />
);
} else if (isConnected) {
return <Circle className="h-3 w-3 fill-green-500 text-green-500" />;
} else {
return <Circle className="h-3 w-3 fill-slate-400 text-slate-400" />;
}
};
const getStatusText = (port: SerialPort) => {
if (connectingToPort === port) return "Connecting...";
return port.readable !== null ? "Connected" : "Available";
};
return (
<div className="space-y-3">
{/* Header */}
<div className="flex items-center gap-2">
<Usb className="h-5 w-5 text-green-600" />
<h3 className="font-semibold text-slate-900 dark:text-slate-100">
Serial Devices
</h3>
</div>
{/* Device List */}
<div className="space-y-2 bg-slate-50 dark:bg-slate-800/50 rounded-lg p-4 border border-slate-200 dark:border-slate-700">
{serialPorts.length === 0
? (
<div className="text-center py-4 text-slate-500 dark:text-slate-400">
<Usb className="h-8 w-8 mx-auto mb-2 text-slate-300" />
<p className="text-sm">No serial devices connected</p>
</div>
)
: (
serialPorts.map((port, index) => (
<div
key={`${port}-${index}`}
className="flex items-center gap-3 p-3 rounded-lg bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 hover:shadow-sm transition-shadow"
>
{/* Status */}
<div className="flex-shrink-0">
{getStatusIcon(port)}
</div>
{/* Port Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900 dark:text-slate-100 truncate">
{getPortInfo(port, index)}
</span>
<Usb className="h-4 w-4 text-green-500" />
</div>
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{getStatusText(port)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={() => connectToPort(port)}
disabled={connectingToPort === port ||
port.readable !== null}
>
{connectingToPort === port
? <Clock className="h-3 w-3 mr-1 animate-spin" />
: <Usb className="h-3 w-3 mr-1" />}
{port.readable !== null ? "Connected" : "Connect"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => removeSerialPort(port)}
className="text-slate-400 hover:text-red-600 p-1"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))
)}
{/* Add Serial Device Button */}
<Button
variant="outline"
onClick={handleAddSerialDevice}
disabled={connectionInProgress}
className="w-full border-dashed hover:border-solid"
>
{connectionInProgress
? (
<>
<Clock className="h-4 w-4 mr-2 animate-spin" />
Adding...
</>
)
: (
<>
<Plus className="h-4 w-4 mr-2" />
Add Serial Device
</>
)}
</Button>
{/* Connection Error */}
{connectionError && (
<div className="p-3 rounded-md bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-800">
<div className="flex gap-2 items-start">
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-sm text-red-800 dark:text-red-200">
{connectionError}
</p>
</div>
</div>
)}
</div>
</div>
);
};

38
src/components/Dialog/NewConnectionDialog.tsx

@ -0,0 +1,38 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { ConnectionTabs } from "@components/ConnectionTabs/ConnectionTabs.tsx";
import { useTranslation } from "react-i18next";
export interface NewConnectionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const NewConnectionDialog = ({
open,
onOpenChange,
}: NewConnectionDialogProps) => {
const { t } = useTranslation("dialog");
const handleClose = () => {
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-4xl max-h-[80vh] overflow-hidden">
<DialogHeader>
<DialogTitle className="text-xl">
{t("newDeviceDialog.title")}
</DialogTitle>
</DialogHeader>
<ConnectionTabs closeDialog={handleClose} />
</DialogContent>
</Dialog>
);
};

237
src/components/Dialog/Tabs/BluetoothTab.tsx

@ -0,0 +1,237 @@
import { useCallback, useEffect, useState } from "react";
import { Button } from "@components/UI/Button.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
import { MeshDevice } from "@meshtastic/core";
import {
AlertTriangle,
Bluetooth,
Circle,
Clock,
Plus,
Trash2,
} from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
interface BluetoothTabProps {
closeDialog: () => void;
}
export const BluetoothTab = ({ closeDialog }: BluetoothTabProps) => {
const [connectionInProgress, setConnectionInProgress] = useState(false);
const [connectingToDevice, setConnectingToDevice] = useState<string | null>(
null,
);
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
const [connectionError, setConnectionError] = useState<string | null>(null);
const { addDevice } = useDeviceStore();
const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore();
const updateBleDeviceList = useCallback(async (): Promise<void> => {
try {
// Check if Web Bluetooth API and getDevices method are available
if (
!navigator.bluetooth ||
typeof navigator.bluetooth.getDevices !== "function"
) {
console.warn(
"Web Bluetooth API getDevices() not supported in this browser",
);
return;
}
setBleDevices(await navigator.bluetooth.getDevices());
} catch (error) {
console.error("Error getting Bluetooth devices:", error);
}
}, []);
useEffect(() => {
updateBleDeviceList();
}, [updateBleDeviceList]);
const connectToDevice = async (bleDevice: BluetoothDevice) => {
setConnectingToDevice(bleDevice.id);
setConnectionError(null);
try {
const id = randId();
const transport = await TransportWebBluetooth.createFromDevice(bleDevice);
const device = addDevice(id);
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id);
device.addConnection(connection);
subscribeAll(device, connection, messageStore);
closeDialog();
} catch (error) {
console.error("Bluetooth connection error:", error);
setConnectionError(`Failed to connect to ${bleDevice.name ?? "device"}`);
} finally {
setConnectingToDevice(null);
}
};
const handlePairNewDevice = async () => {
setConnectionInProgress(true);
setConnectionError(null);
try {
const device = await navigator.bluetooth.requestDevice({
filters: [{ services: [TransportWebBluetooth.ServiceUuid] }],
});
const exists = bleDevices.findIndex((d) => d.id === device.id);
if (exists === -1) {
setBleDevices([...bleDevices, device]);
}
} catch (error) {
console.error("Error pairing device:", error);
if (error instanceof Error && !error.message.includes("cancelled")) {
setConnectionError("Failed to pair new device");
}
} finally {
setConnectionInProgress(false);
}
};
const removeBleDevice = (deviceId: string) => {
setBleDevices(bleDevices.filter((d) => d.id !== deviceId));
};
const getStatusIcon = (device: BluetoothDevice) => {
const isConnecting = connectingToDevice === device.id;
const isConnected = device.gatt?.connected;
if (isConnecting) {
return (
<Circle className="h-3 w-3 fill-yellow-500 text-yellow-500 animate-spin" />
);
}
if (isConnected) {
return <Circle className="h-3 w-3 fill-blue-500 text-blue-500" />;
}
return <Circle className="h-3 w-3 fill-slate-300 text-slate-300" />;
};
const getStatusText = (device: BluetoothDevice) => {
if (connectingToDevice === device.id) return "Connecting...";
return device.gatt?.connected ? "Connected" : "Paired";
};
return (
<div className="space-y-4 p-4">
{/* Header */}
<div className="flex items-center gap-2">
<Bluetooth className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-slate-900 dark:text-slate-100">
Bluetooth Devices
</h3>
</div>
{/* Device List */}
<div className="space-y-2 min-h-[300px]">
{bleDevices.length === 0
? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
<Bluetooth className="h-12 w-12 mx-auto mb-3 text-slate-300" />
<p className="text-sm mb-2">No Bluetooth devices paired</p>
<p className="text-xs text-slate-400">
Pair your first Meshtastic device
</p>
</div>
)
: (
bleDevices.map((device) => (
<div
key={device.id}
className="flex items-center gap-3 p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
{/* Status */}
<div className="flex-shrink-0">
{getStatusIcon(device)}
</div>
{/* Device Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900 dark:text-slate-100 truncate">
{device.name ?? "Unknown Device"}
</span>
<Bluetooth className="h-4 w-4 text-blue-500" />
</div>
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{getStatusText(device)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={() => connectToDevice(device)}
disabled={connectingToDevice === device.id ||
device.gatt?.connected}
>
{connectingToDevice === device.id
? <Clock className="h-3 w-3 mr-1 animate-spin" />
: <Bluetooth className="h-3 w-3 mr-1" />}
{device.gatt?.connected ? "Connected" : "Connect"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => removeBleDevice(device.id)}
className="text-slate-400 hover:text-red-600 p-1"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))
)}
{/* Pair New Device Button */}
<Button
variant="outline"
onClick={handlePairNewDevice}
disabled={connectionInProgress}
className="w-full border-dashed hover:border-solid"
>
{connectionInProgress
? (
<>
<Clock className="h-4 w-4 mr-2 animate-spin" />
Pairing...
</>
)
: (
<>
<Plus className="h-4 w-4 mr-2" />
Pair New Device
</>
)}
</Button>
{/* Connection Error */}
{connectionError && (
<div className="p-3 rounded-md bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-800">
<div className="flex gap-2 items-start">
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-sm text-red-800 dark:text-red-200">
{connectionError}
</p>
</div>
</div>
)}
</div>
</div>
);
};

674
src/components/Dialog/Tabs/HTTPTab.tsx

@ -0,0 +1,674 @@
import { useEffect, useState } from "react";
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { useForm } from "react-hook-form";
import {
AlertTriangle,
Circle,
Clock,
Edit,
Lock,
LockOpen,
Plus,
Server,
Trash2,
Users,
Wifi,
} from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import type { SavedServer } from "@core/stores/appStore.ts";
interface AddServerFormData {
hostname: string;
secure: boolean;
}
interface EditServerFormData {
hostname: string;
secure: boolean;
}
interface HTTPTabProps {
closeDialog: () => void;
}
export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
const [connectionInProgress, setConnectionInProgress] = useState(false);
const [connectingToServer, setConnectingToServer] = useState<string | null>(
null,
);
const [addServerOpen, setAddServerOpen] = useState(false);
const [editServerOpen, setEditServerOpen] = useState(false);
const [editingServer, setEditingServer] = useState<SavedServer | null>(null);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [addServerError, setAddServerError] = useState<string | null>(null);
const [pendingConnection, setPendingConnection] = useState<
AddServerFormData | null
>(null);
const isURLHTTPS = location.protocol === "https:";
const { addDevice } = useDeviceStore();
const messageStore = useMessageStore();
const {
setSelectedDevice,
addSavedServer,
removeSavedServer,
clearSavedServers,
updateServerStatus,
getSavedServers,
} = useAppStore();
const savedServers = getSavedServers();
const {
register: registerAdd,
handleSubmit: handleSubmitAdd,
reset: resetAdd,
setValue: setValueAdd,
watch: watchAdd,
} = useForm<AddServerFormData>({
defaultValues: {
hostname: ["client.meshtastic.org", "localhost"].includes(
globalThis.location.hostname,
)
? "meshtastic.local"
: globalThis.location.host,
secure: isURLHTTPS,
},
});
const {
register: registerEdit,
handleSubmit: handleSubmitEdit,
reset: resetEdit,
setValue: setValueEdit,
watch: watchEdit,
} = useForm<EditServerFormData>({
defaultValues: {
hostname: "",
secure: false,
},
});
const secureValueAdd = watchAdd("secure");
const secureValueEdit = watchEdit("secure");
// Auto-check server status on component mount
useEffect(() => {
for (const server of savedServers) {
if (server.status !== "checking") {
checkServerStatus(server);
}
}
}, [savedServers]);
const retryCertConnection = () => {
if (!pendingConnection) return;
setAddServerError(null);
const connectionData = pendingConnection;
setPendingConnection(null);
// Wait a moment for the certificate to be trusted, then retry
setTimeout(async () => {
try {
await attemptConnection(connectionData);
} catch (error) {
console.error("Retry connection failed:", error);
setPendingConnection(connectionData);
setAddServerError(
"Connection still failed. Make sure you accepted the certificate in the device page.",
);
}
}, 1000);
};
const attemptConnection = async (data: AddServerFormData) => {
const protocol = data.secure ? "https" : "http";
// Add default ports if not specified
let hostname = data.hostname;
if (!hostname.includes(":")) {
const defaultPort = data.secure ? "443" : "4403";
hostname = `${hostname}:${defaultPort}`;
}
const id = randId();
const transport = await TransportHTTP.create(hostname, data.secure);
const device = addDevice(id);
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id);
device.addConnection(connection);
subscribeAll(device, connection, messageStore);
addSavedServer(hostname, protocol);
setAddServerOpen(false);
resetAdd();
closeDialog();
};
const checkServerStatus = async (server: SavedServer) => {
updateServerStatus(server.url, "checking");
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const response = await fetch(`${server.url}/api/v1/fromradio?all=false`, {
method: "GET",
mode: "cors",
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok) {
updateServerStatus(server.url, "online");
} else {
const fallbackResponse = await fetch(`${server.url}/`, {
method: "GET",
mode: "cors",
signal: AbortSignal.timeout(3000),
});
updateServerStatus(
server.url,
fallbackResponse.ok ? "online" : "offline",
);
}
} catch (error) {
console.error(`Failed to check server status for ${server.url}:`, error);
updateServerStatus(server.url, "offline");
}
};
const connectToServer = async (server: SavedServer) => {
setConnectingToServer(server.url);
setConnectionError(null);
try {
const id = randId();
const transport = await TransportHTTP.create(
server.host,
server.protocol === "https",
);
const device = addDevice(id);
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id);
device.addConnection(connection);
subscribeAll(device, connection, messageStore);
addSavedServer(server.host, server.protocol);
updateServerStatus(server.url, "online");
closeDialog();
} catch (error) {
console.error("Connection error:", error);
setConnectionError(`Failed to connect to ${server.host}`);
updateServerStatus(server.url, "offline");
} finally {
setConnectingToServer(null);
}
};
const formatHostname = (hostname: string, secure: boolean) => {
if (hostname.includes(":")) return hostname;
const defaultPort = secure ? "443" : "4403";
return `${hostname}:${defaultPort}`;
};
const isCertificateError = (errorMessage: string, isSecure: boolean) => {
if (!isSecure) return false;
return errorMessage.includes("certificate") ||
errorMessage.includes("SSL") ||
errorMessage.includes("TLS") ||
errorMessage.includes("CERT_") ||
errorMessage.includes("net::ERR_CERT");
};
const isNetworkError = (errorMessage: string) => {
return errorMessage.includes("network") ||
errorMessage.includes("fetch") ||
errorMessage.includes("CONNECTION_REFUSED");
};
const handleConnectionError = (error: unknown, data: AddServerFormData) => {
const errorMessage = error instanceof Error
? error.message
: `Failed to connect to ${data.hostname}`;
const hostname = formatHostname(data.hostname, data.secure);
if (isCertificateError(errorMessage, data.secure)) {
setPendingConnection(data);
setAddServerError(
`SSL certificate error connecting to ${hostname}. Click "Open Device Page" to accept the certificate, then "Retry Connection".`,
);
} else if (isNetworkError(errorMessage)) {
const defaultPort = data.secure ? "443" : "4403";
const port = hostname.includes(":")
? hostname.split(":")[1]
: defaultPort;
setAddServerError(
`Network error connecting to ${hostname}. Check that the device is accessible and running on port ${port}.`,
);
} else {
setAddServerError(`Failed to connect to ${hostname}: ${errorMessage}`);
}
};
const handleAddServer = handleSubmitAdd(async (data) => {
setConnectionInProgress(true);
setAddServerError(null);
try {
await attemptConnection(data);
} catch (error) {
console.error("Connection error:", error);
handleConnectionError(error, data);
} finally {
setConnectionInProgress(false);
}
});
const handleEditServer = (server: SavedServer) => {
setEditingServer(server);
resetEdit({
hostname: server.host,
secure: server.protocol === "https",
});
setEditServerOpen(true);
};
const handleSaveEdit = handleSubmitEdit((data) => {
if (!editingServer) return;
// Remove old server and add updated one
removeSavedServer(editingServer.url);
addSavedServer(data.hostname, data.secure ? "https" : "http");
setEditServerOpen(false);
setEditingServer(null);
resetEdit();
});
const getStatusIcon = (status?: string) => {
switch (status) {
case "online":
return <Circle className="h-3 w-3 fill-green-500 text-green-500" />;
case "offline":
return <Circle className="h-3 w-3 fill-red-500 text-red-500" />;
case "checking":
return (
<Circle className="h-3 w-3 fill-yellow-500 text-yellow-500 animate-spin" />
);
default:
return <Circle className="h-3 w-3 fill-slate-300 text-slate-300" />;
}
};
const getSecurityIcon = (protocol: "http" | "https") => {
return protocol === "https"
? <Lock className="h-4 w-4 text-green-600" />
: <LockOpen className="h-4 w-4 text-yellow-600" />;
};
const getStatusText = (status?: string) => {
switch (status) {
case "online":
return "Online";
case "offline":
return "Offline";
case "checking":
return "Checking...";
default:
return "Unknown";
}
};
return (
<>
<div className="space-y-4 p-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Server className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-slate-900 dark:text-slate-100">
HTTP Servers
</h3>
</div>
{savedServers.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={clearSavedServers}
className="text-red-600 hover:text-red-700"
>
Clear All
</Button>
)}
</div>
{/* Server List */}
<div className="space-y-2 min-h-[300px]">
{savedServers.length === 0
? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
<Server className="h-12 w-12 mx-auto mb-3 text-slate-300" />
<p className="text-sm mb-2">No HTTP servers added yet</p>
<p className="text-xs text-slate-400">
Add your first Meshtastic server to get started
</p>
</div>
)
: (
savedServers.map((server) => (
<div
key={server.url}
className="flex items-center gap-3 p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
{/* Status */}
<div className="flex-shrink-0">
{getStatusIcon(server.status)}
</div>
{/* Server Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900 dark:text-slate-100 truncate">
{server.host?.replace(/[<>]/g, "")}
</span>
{getSecurityIcon(server.protocol)}
</div>
<div className="flex items-center gap-3 text-xs text-slate-500 dark:text-slate-400 mt-1">
<span>{getStatusText(server.status)}</span>
{server.deviceInfo?.model && (
<>
<span></span>
<span className="font-mono bg-slate-200 dark:bg-slate-600 px-1 rounded">
{server.deviceInfo.model.replace(/[<>]/g, "")}
</span>
</>
)}
{server.deviceInfo?.nodeCount && (
<>
<span></span>
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{typeof server.deviceInfo.nodeCount === "number"
? server.deviceInfo.nodeCount.toString()
: "0"}
</span>
</>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={() => connectToServer(server)}
disabled={connectingToServer === server.url}
>
{connectingToServer === server.url
? <Clock className="h-3 w-3 mr-1 animate-spin" />
: <Wifi className="h-3 w-3 mr-1" />}
Connect
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditServer(server)}
className="text-slate-400 hover:text-blue-600 p-1"
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => removeSavedServer(server.url)}
className="text-slate-400 hover:text-red-600 p-1"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))
)}
{/* Add Server Button */}
<Button
variant="outline"
onClick={() => {
setAddServerError(null);
setAddServerOpen(true);
}}
className="w-full border-dashed hover:border-solid"
>
<Plus className="h-4 w-4 mr-2" />
Add HTTP Server
</Button>
{/* Connection Error */}
{connectionError && (
<div className="p-3 rounded-md bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-800">
<div className="flex gap-2 items-start">
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-sm text-red-800 dark:text-red-200">
{connectionError?.replace(/[<>]/g, "")}
</p>
</div>
</div>
)}
</div>
</div>
{/* Add Server Dialog */}
<Dialog open={addServerOpen} onOpenChange={setAddServerOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Add HTTP Server
</DialogTitle>
<DialogDescription>
Enter the hostname or IP address of your Meshtastic device to
connect via HTTP/HTTPS.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleAddServer} className="space-y-4">
<div>
<Label htmlFor="hostname">Hostname or IP Address</Label>
<Input
id="hostname"
placeholder="meshtastic.local or 192.168.1.100"
className="mt-1"
{...registerAdd("hostname", { required: true })}
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={isURLHTTPS || secureValueAdd}
onCheckedChange={(checked) => setValueAdd("secure", checked)}
disabled={isURLHTTPS}
{...registerAdd("secure")}
/>
<Label className="flex items-center gap-2">
{secureValueAdd
? <Lock className="h-4 w-4 text-green-600" />
: <LockOpen className="h-4 w-4 text-yellow-600" />}
Use HTTPS (Secure)
</Label>
</div>
{/* Connection Error Display */}
{addServerError && (
<div className="p-3 rounded-md bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-800">
<div className="flex gap-2 items-start">
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-red-800 dark:text-red-200">
{addServerError?.replace(/[<>]/g, "")}
</p>
{pendingConnection && (
<div className="flex gap-2 mt-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const protocol = pendingConnection.secure
? "https"
: "http";
let hostname = pendingConnection.hostname;
if (!hostname.includes(":")) {
const defaultPort = pendingConnection.secure
? "443"
: "4403";
hostname = `${hostname}:${defaultPort}`;
}
// Open the node directly to accept certificate
const nodeUrl = `${protocol}://${hostname}`;
globalThis.open(
nodeUrl,
"_blank",
"noopener,noreferrer",
);
}}
className="text-xs"
>
Open Device Page
</Button>
<Button
type="button"
size="sm"
onClick={retryCertConnection}
className="text-xs"
>
Retry Connection
</Button>
</div>
)}
</div>
</div>
</div>
)}
<div className="flex gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => setAddServerOpen(false)}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={connectionInProgress}
className="flex-1"
>
{connectionInProgress
? (
<>
<Clock className="h-4 w-4 mr-2 animate-spin" />
Connecting...
</>
)
: (
<>
<Wifi className="h-4 w-4 mr-2" />
Connect
</>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
{/* Edit Server Dialog */}
<Dialog open={editServerOpen} onOpenChange={setEditServerOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Edit className="h-4 w-4" />
Edit HTTP Server
</DialogTitle>
<DialogDescription>
Update the hostname or connection settings for this Meshtastic
device.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSaveEdit} className="space-y-4">
<div>
<Label htmlFor="edit-hostname">Hostname or IP Address</Label>
<Input
id="edit-hostname"
placeholder="meshtastic.local or 192.168.1.100"
className="mt-1"
{...registerEdit("hostname", { required: true })}
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={secureValueEdit}
onCheckedChange={(checked) => setValueEdit("secure", checked)}
{...registerEdit("secure")}
/>
<Label className="flex items-center gap-2">
{secureValueEdit
? <Lock className="h-4 w-4 text-green-600" />
: <LockOpen className="h-4 w-4 text-yellow-600" />}
Use HTTPS (Secure)
</Label>
</div>
<div className="flex gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => setEditServerOpen(false)}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
>
Save Changes
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</>
);
};

249
src/components/Dialog/Tabs/SerialTab.tsx

@ -0,0 +1,249 @@
import { useCallback, useEffect, useState } from "react";
import { Button } from "@components/UI/Button.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
import { TransportWebSerial } from "@meshtastic/transport-web-serial";
import { AlertTriangle, Circle, Clock, Plus, Trash2, Usb } from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
interface SerialTabProps {
closeDialog: () => void;
}
export const SerialTab = ({ closeDialog }: SerialTabProps) => {
const [connectionInProgress, setConnectionInProgress] = useState(false);
const [connectingToPort, setConnectingToPort] = useState<SerialPort | null>(
null,
);
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
const [connectionError, setConnectionError] = useState<string | null>(null);
const { addDevice } = useDeviceStore();
const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore();
const updateSerialPortList = useCallback(async () => {
try {
setSerialPorts(await navigator?.serial?.getPorts() || []);
} catch (error) {
console.error("Error getting serial ports:", error);
}
}, []);
useEffect(() => {
const handleConnect = () => updateSerialPortList();
const handleDisconnect = () => updateSerialPortList();
navigator?.serial?.addEventListener("connect", handleConnect);
navigator?.serial?.addEventListener("disconnect", handleDisconnect);
updateSerialPortList();
return () => {
navigator?.serial?.removeEventListener("connect", handleConnect);
navigator?.serial?.removeEventListener("disconnect", handleDisconnect);
};
}, [updateSerialPortList]);
const connectToPort = async (port: SerialPort) => {
setConnectingToPort(port);
setConnectionError(null);
try {
const id = randId();
const device = addDevice(id);
setSelectedDevice(id);
const transport = await TransportWebSerial.createFromPort(port);
const connection = new MeshDevice(transport, id);
connection.configure();
device.addConnection(connection);
subscribeAll(device, connection, messageStore);
closeDialog();
} catch (error) {
console.error("Serial connection error:", error);
setConnectionError("Failed to connect to serial device");
} finally {
setConnectingToPort(null);
}
};
const handleAddSerialDevice = async () => {
setConnectionInProgress(true);
setConnectionError(null);
try {
const port = await navigator.serial.requestPort();
setSerialPorts([...serialPorts, port]);
} catch (error) {
console.error("Error requesting port:", error);
// Check for user cancellation - different browsers use different error types/messages
const isDOMCancellation = error instanceof DOMException &&
error.name === "NotFoundError";
const isErrorCancellation = error instanceof Error && (
error.message.includes("cancelled") ||
error.message.includes("canceled") ||
error.message.includes("User cancelled") ||
error.message.includes("No port selected")
);
const isUserCancellation = isDOMCancellation || isErrorCancellation;
if (!isUserCancellation) {
setConnectionError("Failed to add serial device");
}
// If it's user cancellation, we silently ignore it (no error shown)
} finally {
setConnectionInProgress(false);
}
};
const removeSerialPort = (portToRemove: SerialPort) => {
setSerialPorts(serialPorts.filter((port) => port !== portToRemove));
};
const getPortInfo = (port: SerialPort, index: number) => {
const { usbProductId, usbVendorId } = port.getInfo();
const vendor = usbVendorId
? `0x${usbVendorId.toString(16).padStart(4, "0")}`
: "Unknown";
const product = usbProductId
? `0x${usbProductId.toString(16).padStart(4, "0")}`
: "Unknown";
return `Serial Port ${index + 1} (${vendor}:${product})`;
};
const getStatusIcon = (port: SerialPort) => {
const isConnecting = connectingToPort === port;
const isConnected = port.readable !== null;
if (isConnecting) {
return (
<Circle className="h-3 w-3 fill-yellow-500 text-yellow-500 animate-spin" />
);
} else if (isConnected) {
return <Circle className="h-3 w-3 fill-green-500 text-green-500" />;
} else {
return <Circle className="h-3 w-3 fill-slate-400 text-slate-400" />;
}
};
const getStatusText = (port: SerialPort) => {
if (connectingToPort === port) return "Connecting...";
return port.readable !== null ? "Connected" : "Available";
};
return (
<div className="space-y-4 p-4">
{/* Header */}
<div className="flex items-center gap-2">
<Usb className="h-5 w-5 text-green-600" />
<h3 className="font-semibold text-slate-900 dark:text-slate-100">
Serial Devices
</h3>
</div>
{/* Device List */}
<div className="space-y-2 min-h-[300px]">
{serialPorts.length === 0
? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
<Usb className="h-12 w-12 mx-auto mb-3 text-slate-300" />
<p className="text-sm mb-2">No serial devices connected</p>
<p className="text-xs text-slate-400">
Connect your first Meshtastic device via USB
</p>
</div>
)
: (
serialPorts.map((port, index) => (
<div
key={`${port}-${index}`}
className="flex items-center gap-3 p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
{/* Status */}
<div className="flex-shrink-0">
{getStatusIcon(port)}
</div>
{/* Port Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900 dark:text-slate-100 truncate">
{getPortInfo(port, index)}
</span>
<Usb className="h-4 w-4 text-green-500" />
</div>
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{getStatusText(port)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={() => connectToPort(port)}
disabled={connectingToPort === port ||
port.readable !== null}
>
{connectingToPort === port
? <Clock className="h-3 w-3 mr-1 animate-spin" />
: <Usb className="h-3 w-3 mr-1" />}
{port.readable !== null ? "Connected" : "Connect"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => removeSerialPort(port)}
className="text-slate-400 hover:text-red-600 p-1"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))
)}
{/* Add Serial Device Button */}
<Button
variant="outline"
onClick={handleAddSerialDevice}
disabled={connectionInProgress}
className="w-full border-dashed hover:border-solid"
>
{connectionInProgress
? (
<>
<Clock className="h-4 w-4 mr-2 animate-spin" />
Adding...
</>
)
: (
<>
<Plus className="h-4 w-4 mr-2" />
Add Serial Device
</>
)}
</Button>
{/* Connection Error */}
{connectionError && (
<div className="p-3 rounded-md bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-800">
<div className="flex gap-2 items-start">
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-sm text-red-800 dark:text-red-200">
{connectionError}
</p>
</div>
</div>
)}
</div>
</div>
);
};

12
src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.test.tsx

@ -34,7 +34,7 @@ describe.skip("UnsafeRolesDialog", () => {
it("renders the dialog when open is true", () => {
renderWithProviders(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
<UnsafeRolesDialog open onOpenChange={vi.fn()} />,
);
const dialog = screen.getByRole("dialog");
@ -52,7 +52,7 @@ describe.skip("UnsafeRolesDialog", () => {
it("displays the correct links", () => {
renderWithProviders(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
<UnsafeRolesDialog open onOpenChange={vi.fn()} />,
);
const docLink = screen.getByRole("link", {
@ -74,7 +74,7 @@ describe.skip("UnsafeRolesDialog", () => {
it("does not allow confirmation until checkbox is checked", () => {
renderWithProviders(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
<UnsafeRolesDialog open onOpenChange={vi.fn()} />,
);
const confirmButton = screen.getByRole("button", { name: /confirm/i });
@ -90,7 +90,7 @@ describe.skip("UnsafeRolesDialog", () => {
it("emits the correct event when closing via close button", () => {
const eventSpy = vi.spyOn(eventBus, "emit");
renderWithProviders(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
<UnsafeRolesDialog open onOpenChange={vi.fn()} />,
);
const dismissButton = screen.getByRole("button", { name: /close/i });
@ -104,7 +104,7 @@ describe.skip("UnsafeRolesDialog", () => {
it("emits the correct event when dismissing", () => {
const eventSpy = vi.spyOn(eventBus, "emit");
renderWithProviders(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
<UnsafeRolesDialog open onOpenChange={vi.fn()} />,
);
const dismissButton = screen.getByRole("button", { name: /dismiss/i });
@ -118,7 +118,7 @@ describe.skip("UnsafeRolesDialog", () => {
it("emits the correct event when confirming", () => {
const eventSpy = vi.spyOn(eventBus, "emit");
renderWithProviders(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
<UnsafeRolesDialog open onOpenChange={vi.fn()} />,
);
const checkbox = screen.getByRole("checkbox");

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

@ -5,9 +5,10 @@ import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { BleConnection, ServiceUuid } from "@meshtastic/js";
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
import { MeshDevice } from "@meshtastic/core";
import { useCallback, useEffect, useState } from "react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
export const BLE = (
@ -30,15 +31,13 @@ export const BLE = (
const onConnect = async (bleDevice: BluetoothDevice) => {
const id = randId();
const transport = await TransportWebBluetooth.createFromDevice(bleDevice);
const device = addDevice(id);
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id);
const connection = new BleConnection(id);
await connection.connect({
device: bleDevice,
});
device.addConnection(connection);
subscribeAll(device, connection, messageStore);
closeDialog();
};
@ -71,7 +70,7 @@ export const BLE = (
onClick={async () => {
await navigator.bluetooth
.requestDevice({
filters: [{ services: [ServiceUuid] }],
filters: [{ services: [TransportWebBluetooth.ServiceUuid] }],
})
.then((device) => {
const exists = bleDevices.findIndex((d) => d.id === device.id);

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

@ -1,164 +1,468 @@
import type { TabElementProps } from "@components/Dialog/NewDeviceDialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { useState } from "react";
import { useController, useForm } from "react-hook-form";
import { AlertTriangle } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import {
AlertTriangle,
Circle,
Clock,
Lock,
LockOpen,
MessageCircle,
Plus,
Server,
Trash2,
Users,
Wifi,
} from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
import type { SavedServer } from "@core/stores/appStore.ts";
interface FormData {
ip: string;
tls: boolean;
interface AddServerFormData {
hostname: string;
secure: boolean;
}
export const HTTP = (
{ closeDialog }: TabElementProps,
) => {
const { t } = useTranslation("dialog");
export const HTTP = ({ closeDialog }: TabElementProps) => {
const [connectionInProgress, setConnectionInProgress] = useState(false);
const [connectingToServer, setConnectingToServer] = useState<string | null>(
null,
);
const [addServerOpen, setAddServerOpen] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null);
const isURLHTTPS = location.protocol === "https:";
const { addDevice } = useDeviceStore();
const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore();
const {
setSelectedDevice,
addSavedServer,
removeSavedServer,
clearSavedServers,
updateServerStatus,
getSavedServers,
} = useAppStore();
const { control, handleSubmit, register } = useForm<FormData>({
const savedServers = getSavedServers();
const { register, handleSubmit, reset, setValue, watch } = useForm<
AddServerFormData
>({
defaultValues: {
ip: ["client.meshtastic.org", "localhost"].includes(
hostname: ["client.meshtastic.org", "localhost"].includes(
globalThis.location.hostname,
)
? "meshtastic.local"
: globalThis.location.host,
tls: isURLHTTPS ? true : false,
secure: isURLHTTPS,
},
});
const {
field: { value: tlsValue, onChange: setTLS },
} = useController({ name: "tls", control });
const secureValue = watch("secure");
// Auto-check server status on component mount
useEffect(() => {
savedServers.forEach((server) => {
if (server.status !== "checking") {
checkServerStatus(server);
}
});
}, []);
const checkServerStatus = async (server: SavedServer) => {
updateServerStatus(server.url, "checking");
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
// Try to get actual device info from fromradio endpoint
const response = await fetch(`${server.url}/api/v1/fromradio?all=false`, {
method: "GET",
mode: "cors",
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok) {
// Try to get basic device info if available
try {
// First check if we can get a simple status response
const statusResponse = await fetch(`${server.url}/api/v1/fromradio`, {
method: "GET",
mode: "cors",
signal: AbortSignal.timeout(3000),
});
if (statusResponse.ok) {
// For now, just mark as online - we could parse protobuf later for device details
updateServerStatus(server.url, "online");
// TODO: Parse protobuf response to get device info
// This would require proper protobuf decoding which is complex
// For now, we'll show it as online and get device info after connection
} else {
updateServerStatus(server.url, "offline");
}
} catch {
// If fromradio fails, try the simpler status endpoint
const fallbackResponse = await fetch(`${server.url}/`, {
method: "GET",
mode: "cors",
signal: AbortSignal.timeout(3000),
});
updateServerStatus(
server.url,
fallbackResponse.ok ? "online" : "offline",
);
}
} else {
updateServerStatus(server.url, "offline");
}
} catch {
updateServerStatus(server.url, "offline");
}
};
const connectToServer = async (server: SavedServer) => {
setConnectingToServer(server.url);
setConnectionError(null);
const [connectionError, setConnectionError] = useState<
{ host: string; secure: boolean } | null
>(null);
try {
const id = randId();
const transport = await TransportHTTP.create(
server.host,
server.protocol === "https",
);
const device = addDevice(id);
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id);
device.addConnection(connection);
subscribeAll(device, connection, messageStore);
const onSubmit = handleSubmit(async (data) => {
// Update last used time
addSavedServer(server.host, server.protocol);
updateServerStatus(server.url, "online");
closeDialog();
} catch (error) {
console.error("Connection error:", error);
setConnectionError(`Failed to connect to ${server.host}`);
updateServerStatus(server.url, "offline");
} finally {
setConnectingToServer(null);
}
};
const handleAddServer = handleSubmit(async (data) => {
setConnectionInProgress(true);
setConnectionError(null);
try {
const protocol = data.secure ? "https" : "http";
const id = randId();
const transport = await TransportHTTP.create(data.ip, data.tls);
const transport = await TransportHTTP.create(data.hostname, data.secure);
const device = addDevice(id);
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id);
device.addConnection(connection);
subscribeAll(device, connection, messageStore);
// Save to history
addSavedServer(data.hostname, protocol);
setAddServerOpen(false);
reset();
closeDialog();
} catch (error) {
console.error("Connection error:", error);
// Capture all connection errors regardless of type
setConnectionError({ host: data.ip, secure: data.tls });
setConnectionError(`Failed to connect to ${data.hostname}`);
} finally {
setConnectionInProgress(false);
}
});
const getStatusIcon = (status?: string) => {
switch (status) {
case "online":
return <Circle className="h-3 w-3 fill-green-500 text-green-500" />;
case "offline":
return <Circle className="h-3 w-3 fill-red-500 text-red-500" />;
case "checking":
return (
<Circle className="h-3 w-3 fill-yellow-500 text-yellow-500 animate-spin" />
);
default:
return <Circle className="h-3 w-3 fill-slate-300 text-slate-300" />;
}
};
const getSecurityIcon = (protocol: "http" | "https") => {
return protocol === "https"
? <Lock className="h-4 w-4 text-green-600" />
: <LockOpen className="h-4 w-4 text-yellow-600" />;
};
const getStatusText = (status?: string) => {
switch (status) {
case "online":
return "Online";
case "offline":
return "Offline";
case "checking":
return "Checking...";
default:
return "Unknown";
}
};
return (
<form className="flex w-full flex-col gap-2 p-4" onSubmit={onSubmit}>
<fieldset
className="flex flex-col gap-2"
<>
<div
className="flex w-full flex-col gap-3 p-4"
style={{ minHeight: "12rem" }}
disabled={connectionInProgress}
>
<div>
<Label>{t("newDeviceDialog.httpConnection.label")}</Label>
<Input
prefix={tlsValue
? `${t("newDeviceDialog.https")}://`
: `${t("newDeviceDialog.http")}://`}
placeholder={t("newDeviceDialog.httpConnection.placeholder")}
className="text-slate-900 dark:text-slate-100"
{...register("ip")}
/>
</div>
<div className="mt-2 flex items-center gap-2">
<Switch
onCheckedChange={setTLS}
disabled={isURLHTTPS}
checked={isURLHTTPS || tlsValue}
{...register("tls")}
/>
<Label>{t("newDeviceDialog.useHttps")}</Label>
{/* Server List Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-slate-600 dark:text-slate-400" />
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
Meshtastic Servers
</span>
</div>
{savedServers.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={clearSavedServers}
className="h-7 px-2 text-xs text-red-600 hover:text-red-700"
>
<Trash2 className="h-3 w-3 mr-1" />
Clear All
</Button>
)}
</div>
{connectionError && (
<div className="mt-2 mb-2 p-3 rounded-md bg-amber-100 border border-amber-300 dark:bg-amber-100 dark:border-amber-300">
<div className="flex gap-2 items-start">
<AlertTriangle
className="shrink-0 mt-0.5 text-amber-600 dark:text-amber-600"
size={20}
/>
<div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-800">
{t("newDeviceDialog.connectionFailedAlert.title")}
{/* Server List */}
<div className="flex-1 space-y-2 max-h-48 overflow-y-auto">
{savedServers.length === 0
? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Server className="h-8 w-8 text-slate-300 mb-2" />
<p className="text-sm text-slate-500 dark:text-slate-400">
No saved servers yet
</p>
<p className="text-xs mt-1 text-amber-700 dark:text-amber-700">
{t("newDeviceDialog.connectionFailedAlert.descriptionPrefix")}
{connectionError.secure &&
t("newDeviceDialog.connectionFailedAlert.httpsHint")}
{t("newDeviceDialog.connectionFailedAlert.openLinkPrefix")}
<Link
href={`${
connectionError.secure
? t("newDeviceDialog.https")
: t("newDeviceDialog.http")
}://${connectionError.host}`}
className="underline font-medium text-amber-800 dark:text-amber-800"
>
{`${
connectionError.secure
? t("newDeviceDialog.https")
: t("newDeviceDialog.http")
}://${connectionError.host}`}
</Link>{" "}
{t("newDeviceDialog.connectionFailedAlert.openLinkSuffix")}
{connectionError.secure
? t(
"newDeviceDialog.connectionFailedAlert.acceptTlsWarningSuffix",
)
: ""}.{" "}
<Link
href="https://meshtastic.org/docs/software/web-client/#http"
className="underline font-medium text-amber-800 dark:text-amber-800"
>
{t("newDeviceDialog.connectionFailedAlert.learnMoreLink")}
</Link>
<p className="text-xs text-slate-400 dark:text-slate-500">
Add your first Meshtastic node to get started
</p>
</div>
)
: (
savedServers.slice(0, 5).map((server) => (
<div
key={server.url}
className="flex items-center gap-3 p-3 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
{/* Status Indicator */}
<div className="flex-shrink-0">
{getStatusIcon(server.status)}
</div>
{/* Server Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900 dark:text-slate-100 truncate">
{server.host}
</span>
{getSecurityIcon(server.protocol)}
</div>
<div className="flex items-center gap-3 text-xs text-slate-500 dark:text-slate-400 mt-1">
<span className="flex items-center gap-1">
<span
className={`w-2 h-2 rounded-full ${
server.status === "online"
? "bg-green-500"
: server.status === "offline"
? "bg-red-500"
: "bg-yellow-500"
}`}
/>
{getStatusText(server.status)}
</span>
{/* Device info if available */}
{server.deviceInfo?.model && (
<>
<span></span>
<span className="font-mono text-xs bg-slate-200 dark:bg-slate-700 px-1 rounded">
{server.deviceInfo.model}
</span>
</>
)}
{server.deviceInfo?.nodeCount && (
<>
<span></span>
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{server.deviceInfo.nodeCount}
</span>
</>
)}
{server.deviceInfo?.unreadCount &&
server.deviceInfo.unreadCount > 0 && (
<>
<span></span>
<span className="flex items-center gap-1 text-blue-600 dark:text-blue-400">
<MessageCircle className="h-3 w-3" />
{server.deviceInfo.unreadCount}
</span>
</>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
<Button
variant="default"
size="sm"
onClick={() => connectToServer(server)}
disabled={connectingToServer === server.url}
className="h-8 px-3"
>
{connectingToServer === server.url
? <Clock className="h-3 w-3 mr-1 animate-spin" />
: <Wifi className="h-3 w-3 mr-1" />}
Connect
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => removeSavedServer(server.url)}
className="h-8 w-8 p-0 text-slate-400 hover:text-red-600"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))
)}
</div>
{/* Add New Server Button */}
<Button
variant="outline"
onClick={() => setAddServerOpen(true)}
className="w-full h-10 border-dashed border-slate-300 dark:border-slate-600 hover:border-slate-400 dark:hover:border-slate-500"
>
<Plus className="h-4 w-4 mr-2" />
Add New Server
</Button>
{/* Connection Error */}
{connectionError && (
<div className="p-3 rounded-md bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-800">
<div className="flex gap-2 items-start">
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-sm text-red-800 dark:text-red-200">
{connectionError}
</p>
</div>
</div>
)}
</fieldset>
<Button
type="submit"
variant="default"
>
<span>
{connectionInProgress
? t("newDeviceDialog.connecting")
: t("newDeviceDialog.connect")}
</span>
</Button>
</form>
</div>
{/* Add Server Dialog */}
<Dialog open={addServerOpen} onOpenChange={setAddServerOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Add New Server
</DialogTitle>
</DialogHeader>
<form onSubmit={handleAddServer} className="space-y-4">
<div>
<Label htmlFor="hostname">Hostname or IP Address</Label>
<Input
id="hostname"
placeholder="meshtastic.local or 192.168.1.100"
className="mt-1"
{...register("hostname", { required: true })}
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={isURLHTTPS || secureValue}
onCheckedChange={(checked) => setValue("secure", checked)}
disabled={isURLHTTPS}
{...register("secure")}
/>
<Label className="flex items-center gap-2">
{secureValue
? <Lock className="h-4 w-4 text-green-600" />
: <LockOpen className="h-4 w-4 text-yellow-600" />}
Use HTTPS (Secure)
</Label>
</div>
<div className="flex gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => setAddServerOpen(false)}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={connectionInProgress}
className="flex-1"
>
{connectionInProgress
? (
<>
<Clock className="h-4 w-4 mr-2 animate-spin" />
Connecting...
</>
)
: (
<>
<Wifi className="h-4 w-4 mr-2" />
Connect
</>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</>
);
};

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

@ -15,7 +15,7 @@ import {
MessageState,
useMessageStore,
} from "@core/stores/messageStore/index.ts";
import { Protobuf, Types } from "@meshtastic/js";
import { Protobuf, Types } from "@meshtastic/core";
import { Message } from "@core/stores/messageStore/types.ts";
import { useTranslation } from "react-i18next";
// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // TODO: Uncomment when actions menu is implemented

2
src/components/UI/Tabs.tsx

@ -26,7 +26,7 @@ const TabsTrigger = React.forwardRef<
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
className={cn(
"inline-flex min-w-[100px] items-center justify-center rounded-[0.185rem] px-3 py-1.5 text-sm font-medium text-slate-900 transition-all disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-slate-900 data-[state=active]:shadow-xs cursor-pointer",
"inline-flex min-w-[100px] items-center justify-center rounded-[0.185rem] px-3 py-1.5 text-sm font-medium text-slate-900 transition-all disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-slate-900 data-[state=active]:shadow-md data-[state=active]:border data-[state=active]:border-slate-300 cursor-pointer hover:bg-slate-50",
className,
)}
{...props}

10
src/core/dto/PacketToMessageDTO.ts

@ -1,10 +1,6 @@
import type { Types } from "@meshtastic/js";
import {
Message,
MessageState,
MessageType,
} from "../stores/messageStore/index.ts";
import type { Types } from "@meshtastic/core";
import { MessageState, MessageType } from "@core/stores/messageStore/index.ts";
import { Message } from "@core/stores/messageStore/types.ts";
class PacketToMessageDTO {
channel: Types.ChannelNumber;
to: number;

4
src/core/hooks/useLocalStorage.ts

@ -151,7 +151,7 @@ export default function useLocalStorage<T>(
// We dispatch a custom event so every similar useLocalStorage hook is notified
globalThis.dispatchEvent(new StorageEvent("local-storage", { key }));
}, [key]);
}, [key, initialValue]);
useEffect(() => {
setStoredValue(readValue());
@ -176,7 +176,7 @@ export default function useLocalStorage<T>(
removeEventListener("storage", handleStorageChange);
removeEventListener("local-storage", handleStorageChange);
};
}, []);
}, [handleStorageChange]);
return [storedValue, setValue, removeValue];
}

108
src/core/stores/appStore.ts

@ -8,6 +8,20 @@ export interface RasterSource {
tileSize: number;
}
export interface SavedServer {
url: string;
protocol: "http" | "https";
host: string;
lastUsed: number;
status?: "online" | "offline" | "checking";
deviceInfo?: {
model?: string;
nodeCount?: number;
unreadCount?: number;
firmwareVersion?: string;
};
}
interface ErrorState {
field: string;
message: string;
@ -30,6 +44,7 @@ interface AppState {
connectDialogOpen: boolean;
nodeNumDetails: number;
errors: ErrorState[];
savedServers: SavedServer[];
setRasterSources: (sources: RasterSource[]) => void;
addRasterSource: (source: RasterSource) => void;
@ -42,6 +57,16 @@ interface AppState {
setConnectDialogOpen: (open: boolean) => void;
setNodeNumDetails: (nodeNum: number) => void;
// Server history management
addSavedServer: (host: string, protocol: "http" | "https") => void;
removeSavedServer: (url: string) => void;
clearSavedServers: () => void;
updateServerStatus: (
url: string,
status: "online" | "offline" | "checking",
) => void;
getSavedServers: () => SavedServer[];
// Error management
hasErrors: () => boolean;
getErrorMessage: (field: string) => string | undefined;
@ -62,6 +87,9 @@ export const useAppStore = create<AppState>()((set, get) => ({
nodeNumToBeRemoved: 0,
nodeNumDetails: 0,
errors: [],
savedServers: JSON.parse(
localStorage.getItem("meshtastic-saved-servers") || "[]",
),
setRasterSources: (sources: RasterSource[]) => {
set(
@ -162,4 +190,84 @@ export const useAppStore = create<AppState>()((set, get) => ({
}),
);
},
// Server history management
addSavedServer: (host: string, protocol: "http" | "https") => {
set(
produce<AppState>((draft) => {
const url = `${protocol}://${host}`;
const existingIndex = draft.savedServers.findIndex((s) =>
s.url === url
);
if (existingIndex >= 0) {
// Update last used time if server already exists
draft.savedServers[existingIndex].lastUsed = Date.now();
} else {
// Add new server
draft.savedServers.push({
url,
protocol,
host,
lastUsed: Date.now(),
status: "checking",
});
}
// Sort by last used (most recent first) and limit to 10 servers
draft.savedServers.sort((a, b) => b.lastUsed - a.lastUsed);
draft.savedServers = draft.savedServers.slice(0, 10);
// Persist to localStorage
localStorage.setItem(
"meshtastic-saved-servers",
JSON.stringify(draft.savedServers),
);
}),
);
},
removeSavedServer: (url: string) => {
set(
produce<AppState>((draft) => {
draft.savedServers = draft.savedServers.filter((s) => s.url !== url);
localStorage.setItem(
"meshtastic-saved-servers",
JSON.stringify(draft.savedServers),
);
}),
);
},
clearSavedServers: () => {
set(
produce<AppState>((draft) => {
draft.savedServers = [];
localStorage.removeItem("meshtastic-saved-servers");
}),
);
},
updateServerStatus: (
url: string,
status: "online" | "offline" | "checking",
) => {
set(
produce<AppState>((draft) => {
const server = draft.savedServers.find((s) => s.url === url);
if (server) {
server.status = status;
localStorage.setItem(
"meshtastic-saved-servers",
JSON.stringify(draft.savedServers),
);
}
}),
);
},
getSavedServers: () => {
const state = get();
return [...state.savedServers].sort((a, b) => b.lastUsed - a.lastUsed);
},
}));

60
src/pages/Dashboard/index.tsx

@ -4,27 +4,37 @@ import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { Button } from "@components/UI/Button.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { NewConnectionDialog } from "@components/Dialog/NewConnectionDialog.tsx";
import { ConnectionTabs } from "@components/ConnectionTabs/ConnectionTabs.tsx";
import {
BluetoothIcon,
ListPlusIcon,
NetworkIcon,
PlusIcon,
UsbIcon,
UsersIcon,
} from "lucide-react";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import LanguageSwitcher from "@components/LanguageSwitcher.tsx";
export const Dashboard = () => {
const { t } = useTranslation("dashboard");
const { setConnectDialogOpen, setSelectedDevice } = useAppStore();
const { setSelectedDevice } = useAppStore();
const { getDevices } = useDeviceStore();
const [connectionDialogOpen, setConnectionDialogOpen] = useState(false);
const devices = useMemo(() => getDevices(), [getDevices]);
// Show connection dialog only if user explicitly opens it
// When no devices exist, we show inline connection tabs instead
return (
<>
<NewConnectionDialog
open={connectionDialogOpen}
onOpenChange={setConnectionDialogOpen}
/>
<div className="flex flex-col gap-3 p-3 px-8">
<div className="flex items-center justify-between">
<div className="space-y-1">
@ -40,9 +50,9 @@ export const Dashboard = () => {
<Separator />
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
{devices.length
? (
{devices.length > 0
? (
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
<ul className="grow divide-y divide-slate-200">
{devices.map((device) => {
return (
@ -104,33 +114,31 @@ export const Dashboard = () => {
);
})}
</ul>
)
: (
<div className="m-auto flex flex-col gap-3 text-center">
<ListPlusIcon
size={48}
className="mx-auto text-text-secondary"
/>
<div className="flex items-end">
<Button
className="gap-2"
variant="outline"
onClick={() => setConnectionDialogOpen(true)}
>
<PlusIcon size={16} />
{t("dashboard.button_newConnection")}
</Button>
</div>
</div>
)
: (
<div className="space-y-6">
<div className="text-center">
<Heading as="h3">
{t("dashboard.noDevicesTitle")}
</Heading>
{/* <LanguageSwitcher /> */}
<Subtle>
{t("dashboard.noDevicesDescription")}
</Subtle>
<Button
className="gap-2"
variant="default"
onClick={() => setConnectDialogOpen(true)}
>
<PlusIcon size={16} />
{t(
"dashboard.button_newConnection",
)}
</Button>
</div>
)}
</div>
<ConnectionTabs />
</div>
)}
</div>
</>
);

2
src/pages/Messages.tsx

@ -140,7 +140,7 @@ export const MessagesPage = () => {
} else {
console.warn("sendText completed but messageId is undefined");
}
} catch (e: any) {
} catch (e: unknown) {
console.error("Failed to send message:", e);
const failedId = messageId ?? randId();
if (chatType === MessageType.Broadcast) {

6
src/routes.tsx

@ -1,4 +1,4 @@
import { createRoute, redirect } from "@tanstack/react-router";
import { createRoute } from "@tanstack/react-router";
import { Dashboard } from "@pages/Dashboard/index.tsx";
import MessagesPage from "@pages/Messages.tsx";
import MapPage from "@pages/Map/index.tsx";
@ -17,10 +17,6 @@ const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: Dashboard,
loader: () => {
// Redirect to the broadcast messages page on initial load
return redirect({ to: `/messages/broadcast/0`, replace: true });
},
});
const messagesRoute = createRoute({

6
vite.config.ts

@ -32,9 +32,9 @@ export default defineConfig({
targets: [
{
src: "src/i18n/locales/**/*",
dest: "src/i18n/locales"
}
]
dest: "src/i18n/locales",
},
],
}),
],
define: {

Loading…
Cancel
Save