Browse Source
* Remove deprecated meshtastic/js dependency * Bump dependency version * Fix linting --------- Co-authored-by: philon- <[email protected]>pull/645/head
committed by
“tpaulsoncdw”
36 changed files with 17270 additions and 277 deletions
@ -5,10 +5,6 @@ |
|||||
"jsr:@std/path@^1.1.0": "1.1.0", |
"jsr:@std/path@^1.1.0": "1.1.0", |
||||
"npm:@bufbuild/protobuf@^2.2.5": "2.2.5", |
"npm:@bufbuild/protobuf@^2.2.5": "2.2.5", |
||||
"npm:@jsr/[email protected]": "2.6.2", |
"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:@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-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]", |
"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:@vitejs/plugin-react@^4.4.1": "[email protected]__@[email protected][email protected]_@[email protected]_@[email protected]", |
||||
"npm:autoprefixer@^10.4.21": "[email protected]", |
"npm:autoprefixer@^10.4.21": "[email protected]", |
||||
"npm:base64-js@^1.5.1": "1.5.1", |
"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:class-variance-authority@~0.7.1": "0.7.1", |
||||
"npm:clsx@^2.1.1": "2.1.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]", |
"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" |
"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]": { |
"@jsr/[email protected]": { |
||||
"integrity": "sha512-bIENtFnUEru28GrAeSdiBS9skp0hN/3HZunMbF/IjvUrXOlx2fptKVj3b+pzjOWnLBZxllrByV/W+XDmrxqJ6g==", |
"integrity": "sha512-bIENtFnUEru28GrAeSdiBS9skp0hN/3HZunMbF/IjvUrXOlx2fptKVj3b+pzjOWnLBZxllrByV/W+XDmrxqJ6g==", |
||||
"dependencies": [ |
"dependencies": [ |
||||
@ -1149,27 +1133,6 @@ |
|||||
], |
], |
||||
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__protobufs/2.6.2.tgz" |
"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]": { |
"@mapbox/[email protected]": { |
||||
"integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", |
"integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", |
||||
"dependencies": [ |
"dependencies": [ |
||||
@ -4000,9 +3963,6 @@ |
|||||
"@types/[email protected]": { |
"@types/[email protected]": { |
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" |
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" |
||||
}, |
}, |
||||
"@types/[email protected]": { |
|
||||
"integrity": "sha512-nh7nrWhLr6CBq9ldtw0wx+z9wKnnv/uTVLA9g/3/TcOYxbpOSZE+MhKPmWqU+K0NvThjhv12uD8MuqijB0WzEA==" |
|
||||
}, |
|
||||
"@types/[email protected]": { |
"@types/[email protected]": { |
||||
"integrity": "sha512-QQOT+bxQJhRGXoZDZGLs3ksLud1dMNnMiSQtBA0w8KXvLpXX4oM4TZb6J0GgJ8UbCaHo5s9/4VQT8uXy9JER2A==" |
"integrity": "sha512-QQOT+bxQJhRGXoZDZGLs3ksLud1dMNnMiSQtBA0w8KXvLpXX4oM4TZb6J0GgJ8UbCaHo5s9/4VQT8uXy9JER2A==" |
||||
}, |
}, |
||||
@ -4485,14 +4445,6 @@ |
|||||
"[email protected]" |
"[email protected]" |
||||
] |
] |
||||
}, |
}, |
||||
"[email protected]": { |
|
||||
"integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==", |
|
||||
"dependencies": [ |
|
||||
"@types/validator", |
|
||||
"libphonenumber-js", |
|
||||
"validator" |
|
||||
] |
|
||||
}, |
|
||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", |
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", |
||||
"dependencies": [ |
"dependencies": [ |
||||
@ -5639,9 +5591,6 @@ |
|||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==" |
"integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==" |
||||
}, |
}, |
||||
"[email protected]": { |
|
||||
"integrity": "sha512-0nYZSNj/QEikyhcM5RZFXGlCB/mr4PVamnT1C2sKBnDDTYndrvbybYjvg+PMqAndQHlLbwQ3socolnL3WWTUFA==" |
|
||||
}, |
|
||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", |
"integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", |
||||
"os": ["darwin"], |
"os": ["darwin"], |
||||
@ -7174,9 +7123,6 @@ |
|||||
"which-typed-array" |
"which-typed-array" |
||||
] |
] |
||||
}, |
}, |
||||
"[email protected]": { |
|
||||
"integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==" |
|
||||
}, |
|
||||
"[email protected]_@[email protected]": { |
"[email protected]_@[email protected]": { |
||||
"integrity": "sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==", |
"integrity": "sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==", |
||||
"dependencies": [ |
"dependencies": [ |
||||
@ -7581,10 +7527,9 @@ |
|||||
"dependencies": [ |
"dependencies": [ |
||||
"npm:@bufbuild/protobuf@^2.2.5", |
"npm:@bufbuild/protobuf@^2.2.5", |
||||
"npm:@jsr/[email protected]", |
"npm:@jsr/[email protected]", |
||||
"npm:@jsr/[email protected]", |
"npm:@jsr/[email protected]", |
||||
"npm:@jsr/meshtastic__transport-http@*", |
"npm:@jsr/[email protected]", |
||||
"npm:@jsr/meshtastic__transport-web-bluetooth@*", |
"npm:@jsr/[email protected]", |
||||
"npm:@jsr/meshtastic__transport-web-serial@*", |
|
||||
"npm:@noble/curves@^1.9.0", |
"npm:@noble/curves@^1.9.0", |
||||
"npm:@radix-ui/react-accordion@^1.2.8", |
"npm:@radix-ui/react-accordion@^1.2.8", |
||||
"npm:@radix-ui/react-checkbox@^1.2.3", |
"npm:@radix-ui/react-checkbox@^1.2.3", |
||||
@ -7622,11 +7567,11 @@ |
|||||
"npm:@vitejs/plugin-react@^4.4.1", |
"npm:@vitejs/plugin-react@^4.4.1", |
||||
"npm:autoprefixer@^10.4.21", |
"npm:autoprefixer@^10.4.21", |
||||
"npm:base64-js@^1.5.1", |
"npm:base64-js@^1.5.1", |
||||
"npm:class-validator@~0.14.2", |
|
||||
"npm:class-variance-authority@~0.7.1", |
"npm:class-variance-authority@~0.7.1", |
||||
"npm:clsx@^2.1.1", |
"npm:clsx@^2.1.1", |
||||
"npm:cmdk@^1.1.1", |
"npm:cmdk@^1.1.1", |
||||
"npm:crypto-random-string@5", |
"npm:crypto-random-string@5", |
||||
|
"npm:deno@^2.3.5", |
||||
"npm:gzipper@^8.2.1", |
"npm:gzipper@^8.2.1", |
||||
"npm:happy-dom@^17.4.6", |
"npm:happy-dom@^17.4.6", |
||||
"npm:i18next-browser-languagedetector@^8.1.0", |
"npm:i18next-browser-languagedetector@^8.1.0", |
||||
@ -7659,7 +7604,7 @@ |
|||||
"npm:vite-plugin-static-copy@3", |
"npm:vite-plugin-static-copy@3", |
||||
"npm:vite@^6.3.4", |
"npm:vite@^6.3.4", |
||||
"npm:vitest@^3.1.2", |
"npm:vitest@^3.1.2", |
||||
"npm:zod@^3.24.3", |
"npm:zod@^3.25.0", |
||||
"npm:[email protected]" |
"npm:[email protected]" |
||||
] |
] |
||||
} |
} |
||||
|
|||||
File diff suppressed because it is too large
@ -1,5 +1,5 @@ |
|||||
{ |
{ |
||||
"name": "meshtastic-web", |
"name": "@meshtastic/web", |
||||
"version": "2.6.0-0", |
"version": "2.6.0-0", |
||||
"type": "module", |
"type": "module", |
||||
"description": "Meshtastic web client", |
"description": "Meshtastic web client", |
||||
@ -36,10 +36,9 @@ |
|||||
"dependencies": { |
"dependencies": { |
||||
"@bufbuild/protobuf": "^2.2.5", |
"@bufbuild/protobuf": "^2.2.5", |
||||
"@meshtastic/core": "npm:@jsr/[email protected]", |
"@meshtastic/core": "npm:@jsr/[email protected]", |
||||
"@meshtastic/js": "npm:@jsr/[email protected]", |
"@meshtastic/transport-http": "npm:@jsr/[email protected]", |
||||
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http", |
"@meshtastic/transport-web-bluetooth": "npm:@jsr/[email protected]", |
||||
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth", |
"@meshtastic/transport-web-serial": "npm:@jsr/[email protected]", |
||||
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial", |
|
||||
"@noble/curves": "^1.9.0", |
"@noble/curves": "^1.9.0", |
||||
"@radix-ui/react-accordion": "^1.2.8", |
"@radix-ui/react-accordion": "^1.2.8", |
||||
"@radix-ui/react-checkbox": "^1.2.3", |
"@radix-ui/react-checkbox": "^1.2.3", |
||||
@ -66,6 +65,7 @@ |
|||||
"clsx": "^2.1.1", |
"clsx": "^2.1.1", |
||||
"cmdk": "^1.1.1", |
"cmdk": "^1.1.1", |
||||
"crypto-random-string": "^5.0.0", |
"crypto-random-string": "^5.0.0", |
||||
|
"deno": "^2.3.5", |
||||
"i18next": "^25.2.0", |
"i18next": "^25.2.0", |
||||
"i18next-browser-languagedetector": "^8.1.0", |
"i18next-browser-languagedetector": "^8.1.0", |
||||
"i18next-http-backend": "^3.0.2", |
"i18next-http-backend": "^3.0.2", |
||||
|
|||||
@ -1,5 +1,5 @@ |
|||||
module.exports = { |
module.exports = { |
||||
plugins: { |
plugins: { |
||||
'@tailwindcss/postcss': {}, |
"@tailwindcss/postcss": {}, |
||||
}, |
}, |
||||
}; |
}; |
||||
|
|||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
</> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
</> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -1,164 +1,468 @@ |
|||||
import type { TabElementProps } from "@components/Dialog/NewDeviceDialog.tsx"; |
import type { TabElementProps } from "@components/Dialog/NewDeviceDialog.tsx"; |
||||
import { Button } from "@components/UI/Button.tsx"; |
import { Button } from "@components/UI/Button.tsx"; |
||||
import { Input } from "@components/UI/Input.tsx"; |
import { Input } from "@components/UI/Input.tsx"; |
||||
import { Link } from "@components/UI/Typography/Link.tsx"; |
|
||||
import { Label } from "@components/UI/Label.tsx"; |
import { Label } from "@components/UI/Label.tsx"; |
||||
import { Switch } from "@components/UI/Switch.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 { useAppStore } from "@core/stores/appStore.ts"; |
||||
import { useDeviceStore } from "@core/stores/deviceStore.ts"; |
import { useDeviceStore } from "@core/stores/deviceStore.ts"; |
||||
import { subscribeAll } from "@core/subscriptions.ts"; |
import { subscribeAll } from "@core/subscriptions.ts"; |
||||
import { randId } from "@core/utils/randId.ts"; |
import { randId } from "@core/utils/randId.ts"; |
||||
import { MeshDevice } from "@meshtastic/core"; |
import { MeshDevice } from "@meshtastic/core"; |
||||
import { TransportHTTP } from "@meshtastic/transport-http"; |
import { TransportHTTP } from "@meshtastic/transport-http"; |
||||
import { useState } from "react"; |
import { useEffect, useState } from "react"; |
||||
import { useController, useForm } from "react-hook-form"; |
import { useForm } from "react-hook-form"; |
||||
import { AlertTriangle } from "lucide-react"; |
import { |
||||
|
AlertTriangle, |
||||
|
Circle, |
||||
|
Clock, |
||||
|
Lock, |
||||
|
LockOpen, |
||||
|
MessageCircle, |
||||
|
Plus, |
||||
|
Server, |
||||
|
Trash2, |
||||
|
Users, |
||||
|
Wifi, |
||||
|
} from "lucide-react"; |
||||
import { useMessageStore } from "@core/stores/messageStore/index.ts"; |
import { useMessageStore } from "@core/stores/messageStore/index.ts"; |
||||
import { useTranslation } from "react-i18next"; |
import type { SavedServer } from "@core/stores/appStore.ts"; |
||||
|
|
||||
interface FormData { |
interface AddServerFormData { |
||||
ip: string; |
hostname: string; |
||||
tls: boolean; |
secure: boolean; |
||||
} |
} |
||||
|
|
||||
export const HTTP = ( |
export const HTTP = ({ closeDialog }: TabElementProps) => { |
||||
{ closeDialog }: TabElementProps, |
|
||||
) => { |
|
||||
const { t } = useTranslation("dialog"); |
|
||||
const [connectionInProgress, setConnectionInProgress] = useState(false); |
const [connectionInProgress, setConnectionInProgress] = useState(false); |
||||
|
const [connectingToServer, setConnectingToServer] = useState<string | null>( |
||||
|
null, |
||||
|
); |
||||
|
const [addServerOpen, setAddServerOpen] = useState(false); |
||||
|
const [connectionError, setConnectionError] = useState<string | null>(null); |
||||
const isURLHTTPS = location.protocol === "https:"; |
const isURLHTTPS = location.protocol === "https:"; |
||||
|
|
||||
const { addDevice } = useDeviceStore(); |
const { addDevice } = useDeviceStore(); |
||||
const messageStore = useMessageStore(); |
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: { |
defaultValues: { |
||||
ip: ["client.meshtastic.org", "localhost"].includes( |
hostname: ["client.meshtastic.org", "localhost"].includes( |
||||
globalThis.location.hostname, |
globalThis.location.hostname, |
||||
) |
) |
||||
? "meshtastic.local" |
? "meshtastic.local" |
||||
: globalThis.location.host, |
: globalThis.location.host, |
||||
tls: isURLHTTPS ? true : false, |
secure: isURLHTTPS, |
||||
}, |
}, |
||||
}); |
}); |
||||
|
|
||||
const { |
const secureValue = watch("secure"); |
||||
field: { value: tlsValue, onChange: setTLS }, |
|
||||
} = useController({ name: "tls", control }); |
// 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< |
try { |
||||
{ host: string; secure: boolean } | null |
const id = randId(); |
||||
>(null); |
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); |
setConnectionInProgress(true); |
||||
setConnectionError(null); |
setConnectionError(null); |
||||
|
|
||||
try { |
try { |
||||
|
const protocol = data.secure ? "https" : "http"; |
||||
const id = randId(); |
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 device = addDevice(id); |
||||
const connection = new MeshDevice(transport, id); |
const connection = new MeshDevice(transport, id); |
||||
connection.configure(); |
connection.configure(); |
||||
setSelectedDevice(id); |
setSelectedDevice(id); |
||||
device.addConnection(connection); |
device.addConnection(connection); |
||||
subscribeAll(device, connection, messageStore); |
subscribeAll(device, connection, messageStore); |
||||
|
|
||||
|
// Save to history
|
||||
|
addSavedServer(data.hostname, protocol); |
||||
|
|
||||
|
setAddServerOpen(false); |
||||
|
reset(); |
||||
closeDialog(); |
closeDialog(); |
||||
} catch (error) { |
} catch (error) { |
||||
console.error("Connection error:", error); |
console.error("Connection error:", error); |
||||
// Capture all connection errors regardless of type
|
setConnectionError(`Failed to connect to ${data.hostname}`); |
||||
setConnectionError({ host: data.ip, secure: data.tls }); |
} finally { |
||||
setConnectionInProgress(false); |
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 ( |
return ( |
||||
<form className="flex w-full flex-col gap-2 p-4" onSubmit={onSubmit}> |
<> |
||||
<fieldset |
<div |
||||
className="flex flex-col gap-2" |
className="flex w-full flex-col gap-3 p-4" |
||||
style={{ minHeight: "12rem" }} |
style={{ minHeight: "12rem" }} |
||||
disabled={connectionInProgress} |
|
||||
> |
> |
||||
<div> |
{/* Server List Header */} |
||||
<Label>{t("newDeviceDialog.httpConnection.label")}</Label> |
<div className="flex items-center justify-between"> |
||||
<Input |
<div className="flex items-center gap-2"> |
||||
prefix={tlsValue |
<Server className="h-4 w-4 text-slate-600 dark:text-slate-400" /> |
||||
? `${t("newDeviceDialog.https")}://` |
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"> |
||||
: `${t("newDeviceDialog.http")}://`} |
Meshtastic Servers |
||||
placeholder={t("newDeviceDialog.httpConnection.placeholder")} |
</span> |
||||
className="text-slate-900 dark:text-slate-100" |
</div> |
||||
{...register("ip")} |
{savedServers.length > 0 && ( |
||||
/> |
<Button |
||||
</div> |
type="button" |
||||
<div className="mt-2 flex items-center gap-2"> |
variant="ghost" |
||||
<Switch |
size="sm" |
||||
onCheckedChange={setTLS} |
onClick={clearSavedServers} |
||||
disabled={isURLHTTPS} |
className="h-7 px-2 text-xs text-red-600 hover:text-red-700" |
||||
checked={isURLHTTPS || tlsValue} |
> |
||||
{...register("tls")} |
<Trash2 className="h-3 w-3 mr-1" /> |
||||
/> |
Clear All |
||||
<Label>{t("newDeviceDialog.useHttps")}</Label> |
</Button> |
||||
|
)} |
||||
</div> |
</div> |
||||
|
|
||||
{connectionError && ( |
{/* Server List */} |
||||
<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-1 space-y-2 max-h-48 overflow-y-auto"> |
||||
<div className="flex gap-2 items-start"> |
{savedServers.length === 0 |
||||
<AlertTriangle |
? ( |
||||
className="shrink-0 mt-0.5 text-amber-600 dark:text-amber-600" |
<div className="flex flex-col items-center justify-center py-8 text-center"> |
||||
size={20} |
<Server className="h-8 w-8 text-slate-300 mb-2" /> |
||||
/> |
<p className="text-sm text-slate-500 dark:text-slate-400"> |
||||
<div> |
No saved servers yet |
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-800"> |
|
||||
{t("newDeviceDialog.connectionFailedAlert.title")} |
|
||||
</p> |
</p> |
||||
<p className="text-xs mt-1 text-amber-700 dark:text-amber-700"> |
<p className="text-xs text-slate-400 dark:text-slate-500"> |
||||
{t("newDeviceDialog.connectionFailedAlert.descriptionPrefix")} |
Add your first Meshtastic node to get started |
||||
{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> |
</p> |
||||
</div> |
</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> |
||||
</div> |
</div> |
||||
)} |
)} |
||||
</fieldset> |
</div> |
||||
<Button |
|
||||
type="submit" |
{/* Add Server Dialog */} |
||||
variant="default" |
<Dialog open={addServerOpen} onOpenChange={setAddServerOpen}> |
||||
> |
<DialogContent className="sm:max-w-md"> |
||||
<span> |
<DialogHeader> |
||||
{connectionInProgress |
<DialogTitle className="flex items-center gap-2"> |
||||
? t("newDeviceDialog.connecting") |
<Plus className="h-4 w-4" /> |
||||
: t("newDeviceDialog.connect")} |
Add New Server |
||||
</span> |
</DialogTitle> |
||||
</Button> |
</DialogHeader> |
||||
</form> |
|
||||
|
<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> |
||||
|
</> |
||||
); |
); |
||||
}; |
}; |
||||
|
|||||
Loading…
Reference in new issue