Initial loopbacl

This commit is contained in:
Jack Kingsman
2026-03-02 07:16:58 -08:00
parent ed83d1b2c4
commit d00bc68a83
17 changed files with 1429 additions and 1 deletions
+7 -1
View File
@@ -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
View File
@@ -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',
+1
View File
@@ -5,3 +5,4 @@ export { useRepeaterDashboard } from './useRepeaterDashboard';
export { useAppSettings } from './useAppSettings';
export { useConversationRouter } from './useConversationRouter';
export { useContactsAndChannels } from './useContactsAndChannels';
export { useLoopback } from './useLoopback';
+332
View File
@@ -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,
};
}
+139
View File
@@ -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();
});
});
+1
View File
@@ -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 = {
+1
View File
@@ -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
View File
@@ -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;
}