You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
261 lines
9.2 KiB
261 lines
9.2 KiB
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";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
interface SerialTabProps {
|
|
closeDialog: () => void;
|
|
}
|
|
|
|
export const SerialTab = ({ closeDialog }: SerialTabProps) => {
|
|
const { t } = useTranslation("dialog");
|
|
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" />
|
|
);
|
|
}
|
|
if (isConnected) {
|
|
return <Circle className="h-3 w-3 fill-green-500 text-green-500" />;
|
|
}
|
|
return <Circle className="h-3 w-3 fill-slate-400 text-slate-400" />;
|
|
};
|
|
|
|
const getStatusText = (port: SerialPort) => {
|
|
if (connectingToPort === port) {
|
|
return t("newDeviceDialog.tabs.status.connecting");
|
|
}
|
|
return port.readable !== null
|
|
? t("newDeviceDialog.tabs.status.connected")
|
|
: t("newDeviceDialog.tabs.status.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">
|
|
{t("newDeviceDialog.tabs.serial.title")}
|
|
</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">
|
|
{t("newDeviceDialog.tabs.serial.noDevices")}
|
|
</p>
|
|
<p className="text-xs text-slate-400">
|
|
{t("newDeviceDialog.tabs.serial.connectFirst")}
|
|
</p>
|
|
</div>
|
|
)
|
|
: (
|
|
serialPorts.map((port, index) => (
|
|
<div
|
|
key={`serial-${port.getInfo().usbVendorId ?? "unknown"}-${
|
|
port.getInfo().usbProductId ?? "unknown"
|
|
}`}
|
|
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
|
|
? t("newDeviceDialog.tabs.status.connected")
|
|
: t("newDeviceDialog.tabs.actions.connect")}
|
|
</Button>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeSerialPort(port)}
|
|
className="text-slate-400 hover:text-red-600 p-1"
|
|
>
|
|
<Trash2 className="size-5" />
|
|
</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" />
|
|
{t("newDeviceDialog.tabs.serial.adding")}
|
|
</>
|
|
)
|
|
: (
|
|
<>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
{t("newDeviceDialog.tabs.serial.addDevice")}
|
|
</>
|
|
)}
|
|
</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>
|
|
);
|
|
};
|
|
|