mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 17:23:05 +02:00
Initial loopbacl
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
useAppSettings,
|
||||
useConversationRouter,
|
||||
useContactsAndChannels,
|
||||
useLoopback,
|
||||
} from './hooks';
|
||||
import * as messageCache from './messageCache';
|
||||
import { StatusBar } from './components/StatusBar';
|
||||
@@ -186,6 +187,8 @@ export function App() {
|
||||
refreshUnreads,
|
||||
} = useUnreadCounts(channels, contacts, activeConversation);
|
||||
|
||||
const loopback = useLoopback(handleHealthRefresh);
|
||||
|
||||
// Determine if active contact is a repeater (used for routing to dashboard)
|
||||
const activeContactIsRepeater = useMemo(() => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return false;
|
||||
@@ -481,7 +484,9 @@ export function App() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{SETTINGS_SECTION_ORDER.map((section) => (
|
||||
{SETTINGS_SECTION_ORDER.filter(
|
||||
(s) => s !== 'loopback' || (health?.loopback_eligible && !health?.radio_connected)
|
||||
).map((section) => (
|
||||
<button
|
||||
key={section}
|
||||
type="button"
|
||||
@@ -671,6 +676,7 @@ export function App() {
|
||||
config={config}
|
||||
health={health}
|
||||
appSettings={appSettings}
|
||||
loopback={loopback}
|
||||
onClose={handleCloseSettingsView}
|
||||
onSave={handleSaveConfig}
|
||||
onSaveAppSettings={handleSaveAppSettings}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { SettingsDatabaseSection } from './settings/SettingsDatabaseSection';
|
||||
import { SettingsBotSection } from './settings/SettingsBotSection';
|
||||
import { SettingsStatisticsSection } from './settings/SettingsStatisticsSection';
|
||||
import { SettingsAboutSection } from './settings/SettingsAboutSection';
|
||||
import { SettingsLoopbackSection } from './settings/SettingsLoopbackSection';
|
||||
import type { UseLoopbackReturn } from '../hooks/useLoopback';
|
||||
|
||||
interface SettingsModalBaseProps {
|
||||
open: boolean;
|
||||
@@ -24,6 +26,7 @@ interface SettingsModalBaseProps {
|
||||
config: RadioConfig | null;
|
||||
health: HealthStatus | null;
|
||||
appSettings: AppSettings | null;
|
||||
loopback?: UseLoopbackReturn;
|
||||
onClose: () => void;
|
||||
onSave: (update: RadioConfigUpdate) => Promise<void>;
|
||||
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
|
||||
@@ -57,6 +60,7 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
onHealthRefresh,
|
||||
onRefreshAppSettings,
|
||||
onLocalLabelChange,
|
||||
loopback,
|
||||
} = props;
|
||||
const externalSidebarNav = props.externalSidebarNav === true;
|
||||
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
|
||||
@@ -74,6 +78,7 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
radio: !isMobile,
|
||||
identity: false,
|
||||
connectivity: false,
|
||||
loopback: false,
|
||||
mqtt: false,
|
||||
database: false,
|
||||
bot: false,
|
||||
@@ -217,6 +222,18 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldRenderSection('loopback') &&
|
||||
health?.loopback_eligible &&
|
||||
!health?.radio_connected &&
|
||||
loopback && (
|
||||
<div className={sectionWrapperClass}>
|
||||
{renderSectionHeader('loopback')}
|
||||
{isSectionVisible('loopback') && (
|
||||
<SettingsLoopbackSection loopback={loopback} className={sectionContentClass} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldRenderSection('database') && (
|
||||
<div className={sectionWrapperClass}>
|
||||
{renderSectionHeader('database')}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Separator } from '../ui/separator';
|
||||
import type { UseLoopbackReturn } from '../../hooks/useLoopback';
|
||||
|
||||
export function SettingsLoopbackSection({
|
||||
loopback,
|
||||
className,
|
||||
}: {
|
||||
loopback: UseLoopbackReturn;
|
||||
className?: string;
|
||||
}) {
|
||||
const {
|
||||
status,
|
||||
error,
|
||||
transportType,
|
||||
serialAvailable,
|
||||
bluetoothAvailable,
|
||||
connectSerial,
|
||||
connectBluetooth,
|
||||
disconnect,
|
||||
} = loopback;
|
||||
|
||||
const [baudRate, setBaudRate] = useState('115200');
|
||||
const [selectedTransport, setSelectedTransport] = useState<'serial' | 'ble'>(
|
||||
serialAvailable ? 'serial' : 'ble'
|
||||
);
|
||||
|
||||
const isConnecting = status === 'connecting';
|
||||
const isConnected = status === 'connected';
|
||||
const busy = isConnecting || isConnected;
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (selectedTransport === 'serial') {
|
||||
await connectSerial(parseInt(baudRate, 10) || 115200);
|
||||
} else {
|
||||
await connectBluetooth();
|
||||
}
|
||||
};
|
||||
|
||||
const neitherAvailable = !serialAvailable && !bluetoothAvailable;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No direct radio connection detected. You can bridge a radio connected to{' '}
|
||||
<em>this browser's device</em> via Web Serial or Web Bluetooth.
|
||||
</p>
|
||||
|
||||
{neitherAvailable && (
|
||||
<div className="rounded-md bg-yellow-500/10 border border-yellow-500/30 p-3 text-sm text-yellow-200">
|
||||
Your browser does not support Web Serial or Web Bluetooth. Use Chrome or Edge on a secure
|
||||
context (HTTPS or localhost).
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!neitherAvailable && (
|
||||
<>
|
||||
{isConnected ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="text-sm">
|
||||
Connected via {transportType === 'serial' ? 'Serial' : 'Bluetooth'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" onClick={disconnect} className="w-full">
|
||||
Disconnect Loopback
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Transport selector */}
|
||||
<div className="space-y-2">
|
||||
<Label>Transport</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={selectedTransport === 'serial' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={!serialAvailable || busy}
|
||||
onClick={() => setSelectedTransport('serial')}
|
||||
>
|
||||
Serial
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedTransport === 'ble' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={!bluetoothAvailable || busy}
|
||||
onClick={() => setSelectedTransport('ble')}
|
||||
>
|
||||
Bluetooth
|
||||
</Button>
|
||||
</div>
|
||||
{!serialAvailable && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Web Serial not available in this browser
|
||||
</p>
|
||||
)}
|
||||
{!bluetoothAvailable && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Web Bluetooth not available in this browser
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Baud rate (serial only) */}
|
||||
{selectedTransport === 'serial' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="loopback-baud">Baud Rate</Label>
|
||||
<Input
|
||||
id="loopback-baud"
|
||||
type="number"
|
||||
value={baudRate}
|
||||
onChange={(e) => setBaudRate(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button onClick={handleConnect} disabled={busy} className="w-full">
|
||||
{isConnecting ? 'Connecting...' : 'Connect via Loopback'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export type SettingsSection =
|
||||
| 'radio'
|
||||
| 'identity'
|
||||
| 'connectivity'
|
||||
| 'loopback'
|
||||
| 'mqtt'
|
||||
| 'database'
|
||||
| 'bot'
|
||||
@@ -12,6 +13,7 @@ export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
|
||||
'radio',
|
||||
'identity',
|
||||
'connectivity',
|
||||
'loopback',
|
||||
'database',
|
||||
'bot',
|
||||
'mqtt',
|
||||
@@ -23,6 +25,7 @@ export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
|
||||
radio: '📻 Radio',
|
||||
identity: '🪪 Identity',
|
||||
connectivity: '📡 Connectivity',
|
||||
loopback: '🔁 Loopback',
|
||||
database: '🗄️ Database & Interface',
|
||||
bot: '🤖 Bots',
|
||||
mqtt: '📤 MQTT',
|
||||
|
||||
@@ -5,3 +5,4 @@ export { useRepeaterDashboard } from './useRepeaterDashboard';
|
||||
export { useAppSettings } from './useAppSettings';
|
||||
export { useConversationRouter } from './useConversationRouter';
|
||||
export { useContactsAndChannels } from './useContactsAndChannels';
|
||||
export { useLoopback } from './useLoopback';
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
export type LoopbackStatus = 'idle' | 'connecting' | 'connected' | 'error';
|
||||
export type LoopbackTransportType = 'serial' | 'ble';
|
||||
|
||||
// Nordic UART Service UUIDs (used by MeshCore BLE)
|
||||
const UART_SERVICE_UUID = '6e400001-b5a3-f393-e0a9-e50e24dcca9e';
|
||||
const UART_TX_CHAR_UUID = '6e400003-b5a3-f393-e0a9-e50e24dcca9e'; // notifications from radio
|
||||
const UART_RX_CHAR_UUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'; // write to radio
|
||||
|
||||
function getTransportWsUrl(): string {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const isDev = window.location.port === '5173';
|
||||
return isDev
|
||||
? `ws://localhost:8000/api/ws/transport`
|
||||
: `${protocol}//${window.location.host}/api/ws/transport`;
|
||||
}
|
||||
|
||||
export interface UseLoopbackReturn {
|
||||
status: LoopbackStatus;
|
||||
error: string | null;
|
||||
transportType: LoopbackTransportType | null;
|
||||
serialAvailable: boolean;
|
||||
bluetoothAvailable: boolean;
|
||||
connectSerial: (baudRate?: number) => Promise<void>;
|
||||
connectBluetooth: () => Promise<void>;
|
||||
disconnect: () => void;
|
||||
}
|
||||
|
||||
export function useLoopback(onConnected?: () => void): UseLoopbackReturn {
|
||||
const [status, setStatus] = useState<LoopbackStatus>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [transportType, setTransportType] = useState<LoopbackTransportType | null>(null);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const serialPortRef = useRef<SerialPort | null>(null);
|
||||
const serialReaderRef = useRef<ReadableStreamDefaultReader<Uint8Array> | null>(null);
|
||||
const bleDeviceRef = useRef<BluetoothDevice | null>(null);
|
||||
const cleaningUpRef = useRef(false);
|
||||
|
||||
const serialAvailable = typeof navigator !== 'undefined' && 'serial' in navigator;
|
||||
const bluetoothAvailable = typeof navigator !== 'undefined' && 'bluetooth' in navigator;
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (cleaningUpRef.current) return;
|
||||
cleaningUpRef.current = true;
|
||||
|
||||
// Close WebSocket
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState <= WebSocket.OPEN) {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
wsRef.current = null;
|
||||
|
||||
// Close serial reader and port
|
||||
const reader = serialReaderRef.current;
|
||||
if (reader) {
|
||||
try {
|
||||
reader.cancel();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
serialReaderRef.current = null;
|
||||
|
||||
const port = serialPortRef.current;
|
||||
if (port) {
|
||||
try {
|
||||
port.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
serialPortRef.current = null;
|
||||
|
||||
// Disconnect BLE
|
||||
const bleDevice = bleDeviceRef.current;
|
||||
if (bleDevice?.gatt?.connected) {
|
||||
try {
|
||||
bleDevice.gatt.disconnect();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
bleDeviceRef.current = null;
|
||||
|
||||
setTransportType(null);
|
||||
setStatus('idle');
|
||||
cleaningUpRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => cleanup, [cleanup]);
|
||||
|
||||
const connectSerial = useCallback(
|
||||
async (baudRate = 115200) => {
|
||||
setError(null);
|
||||
setStatus('connecting');
|
||||
setTransportType('serial');
|
||||
|
||||
try {
|
||||
// Request serial port from user
|
||||
const port = await navigator.serial!.requestPort();
|
||||
await port.open({ baudRate, flowControl: 'none' });
|
||||
|
||||
// Match meshcore serial behaviour
|
||||
try {
|
||||
await port.setSignals({ requestToSend: false });
|
||||
} catch {
|
||||
// Not all adapters support setSignals
|
||||
}
|
||||
|
||||
serialPortRef.current = port;
|
||||
|
||||
// Open transport WebSocket
|
||||
const ws = new WebSocket(getTransportWsUrl());
|
||||
ws.binaryType = 'arraybuffer';
|
||||
wsRef.current = ws;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.onopen = () => resolve();
|
||||
ws.onerror = () => reject(new Error('Transport WebSocket failed to connect'));
|
||||
// Timeout
|
||||
const timeout = setTimeout(() => reject(new Error('Transport WebSocket timeout')), 10000);
|
||||
ws.addEventListener('open', () => clearTimeout(timeout), { once: true });
|
||||
});
|
||||
|
||||
// Send init
|
||||
ws.send(JSON.stringify({ type: 'init', mode: 'serial' }));
|
||||
|
||||
// Start serial → WS read loop
|
||||
const reader = port.readable!.getReader();
|
||||
serialReaderRef.current = reader;
|
||||
|
||||
const readLoop = async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (value && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(value);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Reader cancelled or port closed — expected during disconnect
|
||||
if (!cleaningUpRef.current) {
|
||||
console.debug('Serial read loop ended:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
readLoop();
|
||||
|
||||
// WS → serial write
|
||||
ws.onmessage = async (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const writer = port.writable!.getWriter();
|
||||
try {
|
||||
await writer.write(new Uint8Array(event.data));
|
||||
} finally {
|
||||
writer.releaseLock();
|
||||
}
|
||||
} else if (typeof event.data === 'string') {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'disconnect') {
|
||||
cleanup();
|
||||
}
|
||||
} catch {
|
||||
// ignore non-JSON text
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!cleaningUpRef.current) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
if (!cleaningUpRef.current) {
|
||||
setError('Transport WebSocket error');
|
||||
cleanup();
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
setStatus('connected');
|
||||
onConnected?.();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Serial connection failed';
|
||||
// Don't show error for user-cancelled port picker
|
||||
if (err instanceof DOMException && err.name === 'NotFoundError') {
|
||||
setStatus('idle');
|
||||
setTransportType(null);
|
||||
return;
|
||||
}
|
||||
setError(message);
|
||||
cleanup();
|
||||
setStatus('error');
|
||||
}
|
||||
},
|
||||
[cleanup, onConnected]
|
||||
);
|
||||
|
||||
const connectBluetooth = useCallback(async () => {
|
||||
setError(null);
|
||||
setStatus('connecting');
|
||||
setTransportType('ble');
|
||||
|
||||
try {
|
||||
const device = await navigator.bluetooth!.requestDevice({
|
||||
filters: [{ namePrefix: 'MeshCore' }],
|
||||
optionalServices: [UART_SERVICE_UUID],
|
||||
});
|
||||
|
||||
if (!device.gatt) {
|
||||
throw new Error('Bluetooth GATT not available');
|
||||
}
|
||||
|
||||
bleDeviceRef.current = device;
|
||||
|
||||
const server = await device.gatt.connect();
|
||||
const service = await server.getPrimaryService(UART_SERVICE_UUID);
|
||||
const txChar = await service.getCharacteristic(UART_TX_CHAR_UUID);
|
||||
const rxChar = await service.getCharacteristic(UART_RX_CHAR_UUID);
|
||||
|
||||
// Open transport WebSocket
|
||||
const ws = new WebSocket(getTransportWsUrl());
|
||||
ws.binaryType = 'arraybuffer';
|
||||
wsRef.current = ws;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.onopen = () => resolve();
|
||||
ws.onerror = () => reject(new Error('Transport WebSocket failed to connect'));
|
||||
const timeout = setTimeout(() => reject(new Error('Transport WebSocket timeout')), 10000);
|
||||
ws.addEventListener('open', () => clearTimeout(timeout), { once: true });
|
||||
});
|
||||
|
||||
// Send init
|
||||
ws.send(JSON.stringify({ type: 'init', mode: 'ble' }));
|
||||
|
||||
// BLE RX notifications → WS
|
||||
await txChar.startNotifications();
|
||||
txChar.addEventListener('characteristicvaluechanged', (event) => {
|
||||
const value = (event.target as BluetoothRemoteGATTCharacteristic).value;
|
||||
if (value && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(value.buffer);
|
||||
}
|
||||
});
|
||||
|
||||
// WS → BLE TX
|
||||
ws.onmessage = async (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
await rxChar.writeValueWithResponse(new Uint8Array(event.data));
|
||||
} else if (typeof event.data === 'string') {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'disconnect') {
|
||||
cleanup();
|
||||
}
|
||||
} catch {
|
||||
// ignore non-JSON text
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!cleaningUpRef.current) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
if (!cleaningUpRef.current) {
|
||||
setError('Transport WebSocket error');
|
||||
cleanup();
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle BLE disconnect
|
||||
device.addEventListener('gattserverdisconnected', () => {
|
||||
if (!cleaningUpRef.current) {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
setStatus('connected');
|
||||
onConnected?.();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Bluetooth connection failed';
|
||||
// Don't show error for user-cancelled device picker
|
||||
if (err instanceof DOMException && err.name === 'NotFoundError') {
|
||||
setStatus('idle');
|
||||
setTransportType(null);
|
||||
return;
|
||||
}
|
||||
setError(message);
|
||||
cleanup();
|
||||
setStatus('error');
|
||||
}
|
||||
}, [cleanup, onConnected]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
// Send graceful disconnect before cleanup
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'disconnect' }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
cleanup();
|
||||
}, [cleanup]);
|
||||
|
||||
return {
|
||||
status,
|
||||
error,
|
||||
transportType,
|
||||
serialAvailable,
|
||||
bluetoothAvailable,
|
||||
connectSerial,
|
||||
connectBluetooth,
|
||||
disconnect,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { SettingsLoopbackSection } from '../components/settings/SettingsLoopbackSection';
|
||||
import type { UseLoopbackReturn } from '../hooks/useLoopback';
|
||||
|
||||
function makeLoopback(overrides?: Partial<UseLoopbackReturn>): UseLoopbackReturn {
|
||||
return {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
transportType: null,
|
||||
serialAvailable: true,
|
||||
bluetoothAvailable: true,
|
||||
connectSerial: vi.fn(async () => {}),
|
||||
connectBluetooth: vi.fn(async () => {}),
|
||||
disconnect: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SettingsLoopbackSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders transport selector and connect button when idle', () => {
|
||||
render(<SettingsLoopbackSection loopback={makeLoopback()} />);
|
||||
|
||||
expect(screen.getByText('Serial')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bluetooth')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Connect via Loopback' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows baud rate input when serial is selected', () => {
|
||||
render(<SettingsLoopbackSection loopback={makeLoopback()} />);
|
||||
|
||||
expect(screen.getByLabelText('Baud Rate')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides baud rate input when BLE is selected', () => {
|
||||
render(<SettingsLoopbackSection loopback={makeLoopback()} />);
|
||||
|
||||
// Click BLE button
|
||||
fireEvent.click(screen.getByText('Bluetooth'));
|
||||
|
||||
expect(screen.queryByLabelText('Baud Rate')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls connectSerial with baud rate on connect', () => {
|
||||
const connectSerial = vi.fn(async () => {});
|
||||
render(<SettingsLoopbackSection loopback={makeLoopback({ connectSerial })} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect via Loopback' }));
|
||||
|
||||
expect(connectSerial).toHaveBeenCalledWith(115200);
|
||||
});
|
||||
|
||||
it('calls connectBluetooth when BLE selected and connect clicked', () => {
|
||||
const connectBluetooth = vi.fn(async () => {});
|
||||
render(<SettingsLoopbackSection loopback={makeLoopback({ connectBluetooth })} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Bluetooth'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect via Loopback' }));
|
||||
|
||||
expect(connectBluetooth).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows connected state with disconnect button', () => {
|
||||
render(
|
||||
<SettingsLoopbackSection
|
||||
loopback={makeLoopback({ status: 'connected', transportType: 'serial' })}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Connected via Serial/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Disconnect Loopback' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Connect via Loopback' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls disconnect on disconnect button click', () => {
|
||||
const disconnect = vi.fn();
|
||||
render(
|
||||
<SettingsLoopbackSection
|
||||
loopback={makeLoopback({ status: 'connected', transportType: 'serial', disconnect })}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Disconnect Loopback' }));
|
||||
|
||||
expect(disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows connecting state', () => {
|
||||
render(<SettingsLoopbackSection loopback={makeLoopback({ status: 'connecting' })} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Connecting...' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows error message', () => {
|
||||
render(
|
||||
<SettingsLoopbackSection loopback={makeLoopback({ status: 'error', error: 'Port failed' })} />
|
||||
);
|
||||
|
||||
expect(screen.getByText('Port failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows warning when neither serial nor bluetooth available', () => {
|
||||
render(
|
||||
<SettingsLoopbackSection
|
||||
loopback={makeLoopback({ serialAvailable: false, bluetoothAvailable: false })}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/does not support Web Serial or Web Bluetooth/)).toBeInTheDocument();
|
||||
// Connect button should not appear
|
||||
expect(screen.queryByRole('button', { name: 'Connect via Loopback' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables serial button when serial not available', () => {
|
||||
render(<SettingsLoopbackSection loopback={makeLoopback({ serialAvailable: false })} />);
|
||||
|
||||
expect(screen.getByText('Serial')).toBeDisabled();
|
||||
expect(screen.getByText(/Web Serial not available/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables bluetooth button when bluetooth not available', () => {
|
||||
render(<SettingsLoopbackSection loopback={makeLoopback({ bluetoothAvailable: false })} />);
|
||||
|
||||
expect(screen.getByText('Bluetooth')).toBeDisabled();
|
||||
expect(screen.getByText(/Web Bluetooth not available/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults to BLE when serial is not available', () => {
|
||||
render(<SettingsLoopbackSection loopback={makeLoopback({ serialAvailable: false })} />);
|
||||
|
||||
// BLE should be selected, so baud rate should NOT be visible
|
||||
expect(screen.queryByLabelText('Baud Rate')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@ const baseHealth: HealthStatus = {
|
||||
database_size_mb: 1.2,
|
||||
oldest_undecrypted_timestamp: null,
|
||||
mqtt_status: null,
|
||||
loopback_eligible: false,
|
||||
};
|
||||
|
||||
const baseSettings: AppSettings = {
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface HealthStatus {
|
||||
database_size_mb: number;
|
||||
oldest_undecrypted_timestamp: number | null;
|
||||
mqtt_status: string | null;
|
||||
loopback_eligible: boolean;
|
||||
}
|
||||
|
||||
export interface MaintenanceResult {
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
// Type declarations for Web Serial API and Web Bluetooth API
|
||||
// These APIs are only available in Chrome/Edge and require secure context.
|
||||
|
||||
// --- Web Serial API ---
|
||||
|
||||
interface SerialPortRequestOptions {
|
||||
filters?: SerialPortFilter[];
|
||||
}
|
||||
|
||||
interface SerialPortFilter {
|
||||
usbVendorId?: number;
|
||||
usbProductId?: number;
|
||||
}
|
||||
|
||||
interface SerialOptions {
|
||||
baudRate: number;
|
||||
dataBits?: number;
|
||||
stopBits?: number;
|
||||
parity?: 'none' | 'even' | 'odd';
|
||||
bufferSize?: number;
|
||||
flowControl?: 'none' | 'hardware';
|
||||
}
|
||||
|
||||
interface SerialPort {
|
||||
readable: ReadableStream<Uint8Array> | null;
|
||||
writable: WritableStream<Uint8Array> | null;
|
||||
open(options: SerialOptions): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
setSignals(signals: { requestToSend?: boolean; dataTerminalReady?: boolean }): Promise<void>;
|
||||
}
|
||||
|
||||
interface Serial {
|
||||
requestPort(options?: SerialPortRequestOptions): Promise<SerialPort>;
|
||||
}
|
||||
|
||||
// --- Web Bluetooth API ---
|
||||
|
||||
interface BluetoothRequestDeviceFilter {
|
||||
namePrefix?: string;
|
||||
name?: string;
|
||||
services?: BluetoothServiceUUID[];
|
||||
}
|
||||
|
||||
type BluetoothServiceUUID = string | number;
|
||||
|
||||
interface RequestDeviceOptions {
|
||||
filters?: BluetoothRequestDeviceFilter[];
|
||||
optionalServices?: BluetoothServiceUUID[];
|
||||
acceptAllDevices?: boolean;
|
||||
}
|
||||
|
||||
interface BluetoothRemoteGATTCharacteristic extends EventTarget {
|
||||
value: DataView | null;
|
||||
startNotifications(): Promise<BluetoothRemoteGATTCharacteristic>;
|
||||
writeValueWithResponse(value: BufferSource): Promise<void>;
|
||||
}
|
||||
|
||||
interface BluetoothRemoteGATTService {
|
||||
getCharacteristic(uuid: string): Promise<BluetoothRemoteGATTCharacteristic>;
|
||||
}
|
||||
|
||||
interface BluetoothRemoteGATTServer {
|
||||
connected: boolean;
|
||||
connect(): Promise<BluetoothRemoteGATTServer>;
|
||||
disconnect(): void;
|
||||
getPrimaryService(uuid: string): Promise<BluetoothRemoteGATTService>;
|
||||
}
|
||||
|
||||
interface BluetoothDevice extends EventTarget {
|
||||
gatt?: BluetoothRemoteGATTServer;
|
||||
}
|
||||
|
||||
interface Bluetooth {
|
||||
requestDevice(options: RequestDeviceOptions): Promise<BluetoothDevice>;
|
||||
}
|
||||
|
||||
// Extend Navigator interface
|
||||
interface Navigator {
|
||||
serial?: Serial;
|
||||
bluetooth?: Bluetooth;
|
||||
}
|
||||
Reference in New Issue
Block a user