diff --git a/src/App.tsx b/src/App.tsx index 976f166..76e7026 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -142,6 +142,7 @@ function App() {
void onConnectSerial: (config: SerialConfig) => Promise + onConnectHttp: (address: string) => Promise onConnectGenerator: (config: GeneratorConfig) => Promise isConnecting: boolean isSupported: boolean @@ -24,6 +25,7 @@ export function ConnectModal({ isOpen, onClose, onConnectSerial, + onConnectHttp, onConnectGenerator, isConnecting, isSupported, @@ -32,6 +34,7 @@ export function ConnectModal({ const [activeTab, setActiveTab] = useState('serial') const [serialConfig, setSerialConfig] = useState(DEFAULT_SERIAL_CONFIG) const [localGeneratorConfig, setLocalGeneratorConfig] = useState(generatorConfig) + const [httpConfig, setHttpConfig] = useState<{ address: string }>({ address: '' }) if (!isOpen) return null @@ -44,6 +47,16 @@ export function ConnectModal({ } } + const handleHttpConnect = async () => { + try { + await onConnectHttp(httpConfig.address) + onClose() + } catch (e) { + // Keep modal open on error so user can try again + console.log(e) + } + } + const handleGeneratorConnect = async () => { try { await onConnectGenerator(localGeneratorConfig) @@ -80,6 +93,17 @@ export function ConnectModal({ Serial Port + + + + )} + {activeTab === 'generator' && (
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 15b82b8..c6c10ee 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' +import { PlayIcon, StopIcon, XCircleIcon, CircleStackIcon } from '@heroicons/react/24/outline' import Button from './ui/Button' -import { PlayIcon, StopIcon, XCircleIcon } from '@heroicons/react/24/outline' import ConnectModal from './ConnectModal' import type { ConnectionState, ConnectionType, SerialConfig } from '../hooks/useDataConnection' import type { GeneratorConfig } from '../hooks/useSignalGenerator' @@ -8,6 +8,7 @@ import type { GeneratorConfig } from '../hooks/useSignalGenerator' interface Props { connectionState: ConnectionState onConnectSerial: (config: SerialConfig) => Promise + onConnectHttp: (address: string) => Promise onConnectGenerator: (config: GeneratorConfig) => Promise onDisconnect: () => Promise generatorConfig: GeneratorConfig @@ -41,18 +42,18 @@ function getButtonVariant(state: ConnectionState) { export function Header({ connectionState, onConnectSerial, + onConnectHttp, onConnectGenerator, onDisconnect, generatorConfig }: Props) { - const [showModal, setShowModal] = useState(false) - + const [showConnectModal, setShowConnectModal] = useState(false) const handleButtonClick = () => { if (connectionState.isConnected) { onDisconnect() } else { - setShowModal(true) + setShowConnectModal(true) } } @@ -60,6 +61,7 @@ export function Header({ <>
+
Web Serial Plotter
@@ -85,9 +87,10 @@ export function Header({
setShowModal(false)} + isOpen={showConnectModal} + onClose={() => setShowConnectModal(false)} onConnectSerial={onConnectSerial} + onConnectHttp={onConnectHttp} onConnectGenerator={onConnectGenerator} isConnecting={connectionState.isConnecting} isSupported={connectionState.isSupported} diff --git a/src/hooks/useDataConnection.ts b/src/hooks/useDataConnection.ts index 3526c69..c843316 100644 --- a/src/hooks/useDataConnection.ts +++ b/src/hooks/useDataConnection.ts @@ -1,6 +1,7 @@ import { useCallback, useState, useEffect } from 'react' import { useSerial } from './useSerial' import { useSignalGenerator, type GeneratorConfig } from './useSignalGenerator' +import { useHttp } from './useHttp' export interface SerialConfig { baudRate: number @@ -10,7 +11,7 @@ export interface SerialConfig { flowControl: 'none' | 'hardware' } -export type ConnectionType = 'serial' | 'generator' +export type ConnectionType = 'serial' | 'http' | 'generator' export interface ConnectionState { type: ConnectionType | null @@ -23,6 +24,7 @@ export interface ConnectionState { export interface UseDataConnection { state: ConnectionState connectSerial: (config: SerialConfig) => Promise + connectHttp: (address: string) => Promise connectGenerator: (config: GeneratorConfig) => Promise disconnect: () => Promise write: (data: string) => Promise @@ -43,23 +45,26 @@ export function useDataConnection(onLine: (line: string) => void): UseDataConnec const [isConnecting, setIsConnecting] = useState(false) const [error, setError] = useState(null) - const serial = useSerial() const generator = useSignalGenerator(onLine) + const http = useHttp() const state: ConnectionState = { type: connectionType, - isConnecting: isConnecting || serial.state.isConnecting, - isConnected: serial.state.isConnected || generator.isRunning, - isSupported: serial.state.isSupported, - error: error || serial.state.error + isConnecting: isConnecting || serial.state.isConnecting || http.state.isConnecting, + isConnected: serial.state.isConnected || generator.isRunning || http.state.isConnected, + isSupported: serial.state.isSupported && http.state.isSupported, + error: error || serial.state.error || http.state.error } - const connectSerial = useCallback(async (config: SerialConfig) => { if (generator.isRunning) { generator.stop() } + + if (http.state.isConnected) { + await http.disconnect() + } setIsConnecting(true) setError(null) @@ -79,12 +84,41 @@ export function useDataConnection(onLine: (line: string) => void): UseDataConnec } finally { setIsConnecting(false) } - }, [serial, generator]) + }, [serial, generator, http]) + + const connectHttp = useCallback(async (address: string) => { + if (serial.state.isConnected) { + await serial.disconnect() + } + + if (generator.isRunning) { + generator.stop() + } + + setIsConnecting(true) + setError(null) + + try { + await http.connect(address) + setConnectionType('http') + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to connect to HTTP stream' + setError(message) + setConnectionType(null) + throw err + } finally { + setIsConnecting(false) + } + }, [serial, generator, http]) const connectGenerator = useCallback(async (config: GeneratorConfig) => { if (serial.state.isConnected) { await serial.disconnect() } + + if (http.state.isConnected) { + await http.disconnect() + } setError(null) setConnectionType('generator') @@ -97,7 +131,7 @@ export function useDataConnection(onLine: (line: string) => void): UseDataConnec setError(message) setConnectionType(null) } - }, [serial, generator]) + }, [serial, generator, http]) const disconnect = useCallback(async () => { setError(null) @@ -109,25 +143,34 @@ export function useDataConnection(onLine: (line: string) => void): UseDataConnec if (generator.isRunning) { generator.stop() } + + if (http.state.isConnected) { + await http.disconnect() + } setConnectionType(null) - }, [serial, generator]) + }, [serial, generator, http]) - // Set up serial line handler + // Set up serial and http line handlers useEffect(() => { serial.onLine(onLine) - }, [serial, onLine]) + http.onLine(onLine) + }, [serial, http, onLine]) const write = useCallback(async (data: string) => { - if (connectionType !== 'serial' || !serial.state.isConnected) { - throw new Error('Serial port not connected') + if (connectionType === 'serial' && serial.state.isConnected) { + await serial.write(data) + } else if (connectionType === 'http' && http.state.isConnected) { + await http.write(data) + } else { + throw new Error('Not connected') } - await serial.write(data) - }, [connectionType, serial]) + }, [connectionType, serial, http]) return { state, connectSerial, + connectHttp, connectGenerator, disconnect, write, diff --git a/src/hooks/useHttp.ts b/src/hooks/useHttp.ts new file mode 100644 index 0000000..8746d07 --- /dev/null +++ b/src/hooks/useHttp.ts @@ -0,0 +1,172 @@ +import { useCallback, useRef, useState } from 'react' + +export interface HttpState { + isSupported: boolean + isConnecting: boolean + isConnected: boolean + error: string | null + address: string +} + +export interface UseHttp { + state: HttpState + connect: (address: string) => Promise + disconnect: () => Promise + onLine: (handler: (line: string) => void) => void + write: (data: string) => Promise +} + +export function useHttp(): UseHttp { + const [state, setState] = useState({ + isSupported: typeof fetch !== 'undefined' && 'body' in Response.prototype, // TODO: make this actually check if streaming fetch responses specifically are not supported + isConnecting: false, + isConnected: false, + error: null, + address: '' + }) + + const lineHandlerRef = useRef<((line: string) => void) | null>(null) + const abortControllerRef = useRef(null) + const writerRef = useRef(null) + const socketRef = useRef(null) + + const onLine = useCallback((handler: (line: string) => void) => { + lineHandlerRef.current = handler + }, []) + + const disconnect = useCallback(async () => { + try { + abortControllerRef.current?.abort() + abortControllerRef.current = null + + if (writerRef.current) { + try { + await writerRef.current.close() + } catch { + // ignore close errors + } + } + writerRef.current = null + + if (socketRef.current) { + socketRef.current.close() + socketRef.current = null + } + + } catch { + // swallow + } finally { + setState((s) => ({ ...s, isConnected: false })) + } + }, []) + + const connect = useCallback(async (address: string) => { + if (!state.isSupported) { + setState((s) => ({ ...s, error: 'Streaming fetch not supported in this browser.' })) + return + } + + // Make sure we're fully disconnected first + await disconnect() + + setState((s) => ({ ...s, isConnecting: true, error: null, address })) + + try { + const abortController = new AbortController() + abortControllerRef.current = abortController + + // Try to detect if it's a WebSocket URL + const isWebSocket = address.startsWith('ws://') || address.startsWith('wss://') + + if (isWebSocket) { + // Handle WebSocket connection + const socket = new WebSocket(address) + socketRef.current = socket + + socket.onmessage = (event) => { + lineHandlerRef.current?.(event.data) + } + + socket.onclose = () => { + setState((s) => ({ ...s, isConnected: false })) + } + + socket.onerror = (error) => { + setState((s) => ({ ...s, error: 'WebSocket error: ' + error })) + } + + // Wait for connection + await new Promise((resolve, reject) => { + socket.onopen = () => resolve() + socket.onerror = () => reject(new Error('Failed to connect to WebSocket')) + // Add timeout + setTimeout(() => reject(new Error('Connection timeout')), 5000) + }) + + } else { + // Handle HTTP streaming + const response = await fetch(address, { + signal: abortController.signal + }) + + if (!response.body) { + throw new Error('Response has no body') + } + + const reader = response.body.getReader() + const textDecoder = new TextDecoder() + let buffer = '' + + // Process the stream + ;(async () => { + try { + while (true) { + const { value, done } = await reader.read() + if (done) break + + if (value) { + buffer += textDecoder.decode(value, { stream: true }) + let index + while ((index = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, index).replace(/\r$/, '') + buffer = buffer.slice(index + 1) + lineHandlerRef.current?.(line) + } + } + } + } catch { + if (!abortController.signal.aborted) { + setState((s) => ({ ...s, error: 'Stream ended unexpectedly', isConnected: false })) + } + } + })() + } + + setState((s) => ({ ...s, isConnected: true })) + + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to connect.' + setState((s) => ({ ...s, error: message })) + throw err + } finally { + setState((s) => ({ ...s, isConnecting: false })) + } + }, [state.isSupported, disconnect]) + + const write = useCallback(async (data: string) => { + if (!state.isConnected) { + throw new Error('Not connected') + } + + if (socketRef.current) { + socketRef.current.send(data) + } else { + // TODO: implement this, I think it might require an additional HTTP connection for a streaming request/upload. + throw new Error('write on HTTP connection not supported'); + } + }, [state.isConnected]) + + return { state, connect, disconnect, onLine, write } +} + +