From 550b8263db63f0c5e678c41a4d9682038963389a Mon Sep 17 00:00:00 2001 From: backuppc Date: Fri, 16 Jan 2026 14:35:56 +0900 Subject: [PATCH] Initial commit --- .gitignore | 24 +++ App.tsx | 354 ++++++++++++++++++++++++++++++++++++ README.md | 20 ++ components/ControlPanel.tsx | 87 +++++++++ components/InputBar.tsx | 245 +++++++++++++++++++++++++ components/Terminal.tsx | 112 ++++++++++++ index.html | 59 ++++++ index.tsx | 15 ++ metadata.json | 7 + package.json | 21 +++ services/serialService.ts | 135 ++++++++++++++ tsconfig.json | 29 +++ types.ts | 23 +++ utils.ts | 29 +++ vite.config.ts | 23 +++ 15 files changed, 1183 insertions(+) create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 README.md create mode 100644 components/ControlPanel.tsx create mode 100644 components/InputBar.tsx create mode 100644 components/Terminal.tsx create mode 100644 index.html create mode 100644 index.tsx create mode 100644 metadata.json create mode 100644 package.json create mode 100644 services/serialService.ts create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 utils.ts create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..b00a118 --- /dev/null +++ b/App.tsx @@ -0,0 +1,354 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { SerialManager } from './services/serialService'; +import { SerialLogEntry, ViewMode, NavigatorSerial } from './types'; +import { generateId, formatTime, uInt8ArrayToHex, uInt8ArrayToAscii } from './utils'; +import Terminal from './components/Terminal'; +import ControlPanel from './components/ControlPanel'; +import InputBar from './components/InputBar'; + +const BAUD_RATES = [9600, 19200, 38400, 57600, 115200]; + +const App: React.FC = () => { + // Check for Browser Support + const [isSupported, setIsSupported] = useState(true); + + // State for Configuration + const [isDualMode, setIsDualMode] = useState(false); + const [isAttached, setIsAttached] = useState(false); + + // Refs for State Access inside Callbacks (Fixes stale closures) + const isAttachedRef = useRef(isAttached); + const isDualModeRef = useRef(isDualMode); + + // Independent View Modes + const [viewModeA, setViewModeA] = useState(ViewMode.ASCII); + const [viewModeB, setViewModeB] = useState(ViewMode.ASCII); + + // Independent Baud Rates + const [baudRateA, setBaudRateA] = useState(115200); + const [baudRateB, setBaudRateB] = useState(115200); + + // State for Connections + const [isConnectedA, setIsConnectedA] = useState(false); + const [isConnectedB, setIsConnectedB] = useState(false); + + // Logs + const [logsA, setLogsA] = useState([]); + const [logsB, setLogsB] = useState([]); + + // Refs to hold SerialManager instances (persisted across renders) + const serialA = useRef(new SerialManager('A')); + const serialB = useRef(new SerialManager('B')); + + useEffect(() => { + // Check if Web Serial is supported + const nav = navigator as unknown as { serial: NavigatorSerial }; + if (!nav.serial) { + setIsSupported(false); + console.warn("Web Serial API is not supported in this browser."); + } + + return () => { + serialA.current.disconnect(); + serialB.current.disconnect(); + }; + }, []); + + // Sync Refs with State + useEffect(() => { + isAttachedRef.current = isAttached; + }, [isAttached]); + + useEffect(() => { + isDualModeRef.current = isDualMode; + }, [isDualMode]); + + // Handle Bridging Logic + useEffect(() => { + if (isAttached && isDualMode) { + // Connect bridges in the Service layer + console.log("Bridging Enabled: A <-> B"); + serialA.current.setBridgeTarget(serialB.current); + serialB.current.setBridgeTarget(serialA.current); + } else { + // Disconnect bridges + if (serialA.current['bridgeTarget'] !== null) console.log("Bridging Disabled"); + serialA.current.setBridgeTarget(null); + serialB.current.setBridgeTarget(null); + } + }, [isAttached, isDualMode]); + + // Safety: If Dual Mode is disabled, automatically detach + useEffect(() => { + if (!isDualMode && isAttached) { + setIsAttached(false); + } + }, [isDualMode]); + + // --- Helper to add logs --- + const addLog = useCallback((port: 'A' | 'B', direction: 'TX' | 'RX', data: Uint8Array) => { + const entry: SerialLogEntry = { + id: generateId(), + timestamp: new Date(), + direction, + data + }; + + if (port === 'A') { + setLogsA(prev => [...prev.slice(-1000), entry]); // Keep last 1000 logs + } else { + setLogsB(prev => [...prev.slice(-1000), entry]); + } + }, []); + + // --- Log Saving Logic --- + const handleSaveLogs = (portName: string, logs: SerialLogEntry[]) => { + if (logs.length === 0) { + alert(`No logs to save for ${portName}.`); + return; + } + + // Header + let content = `SerialNexus Log - Port ${portName}\n`; + content += `Exported at: ${new Date().toLocaleString()}\n`; + content += `--------------------------------------------------------------------------------\n`; + content += `TIMESTAMP | DIR | HEX DATA | ASCII\n`; + content += `--------------------------------------------------------------------------------\n`; + + // Data + content += logs.map(log => { + const time = formatTime(log.timestamp).padEnd(23, ' '); + const dir = log.direction.padEnd(3, ' '); + const hex = uInt8ArrayToHex(log.data).padEnd(40, ' '); // simple padding + const ascii = uInt8ArrayToAscii(log.data); + return `${time} | ${dir} | ${hex} | ${ascii}`; + }).join('\n'); + + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `serial-log-${portName.toLowerCase()}-${new Date().toISOString().slice(0,19).replace(/[:T]/g,'-')}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + // --- Connection Handlers --- + + const handleConnectA = async () => { + try { + console.log("Connect A Clicked"); + await serialA.current.connect(baudRateA); + setIsConnectedA(true); + serialA.current.startReading((data) => { + // Log RX on A + addLog('A', 'RX', data); + + // Visual Feedback for Bridge: If Attached, we assume it was sent to B + // Note: The actual sending happens in SerialManager, here we just update the UI + if (isAttachedRef.current && isDualModeRef.current && serialB.current.port) { + addLog('B', 'TX', data); + } + }); + } catch (err) { + console.error("Connection Error A:", err); + alert("Failed to connect to Port A. Check console for details."); + } + }; + + const handleDisconnectA = async () => { + await serialA.current.disconnect(); + setIsConnectedA(false); + }; + + const handleConnectB = async () => { + try { + console.log("Connect B Clicked"); + await serialB.current.connect(baudRateB); + setIsConnectedB(true); + serialB.current.startReading((data) => { + // Log RX on B + addLog('B', 'RX', data); + + // Visual Feedback for Bridge: If Attached, we assume it was sent to A + if (isAttachedRef.current && isDualModeRef.current && serialA.current.port) { + addLog('A', 'TX', data); + } + }); + } catch (err) { + console.error("Connection Error B:", err); + alert("Failed to connect to Port B. Check console for details."); + } + }; + + const handleDisconnectB = async () => { + await serialB.current.disconnect(); + setIsConnectedB(false); + }; + + // --- Send Handlers (Receives Uint8Array from InputBar) --- + + const handleSendA = async (data: Uint8Array) => { + if (!isConnectedA) return; + await serialA.current.send(data); + addLog('A', 'TX', data); + }; + + const handleSendB = async (data: Uint8Array) => { + if (!isConnectedB) return; + await serialB.current.send(data); + addLog('B', 'TX', data); + }; + + // --- Helper Component for Controls --- + const renderControls = ( + baud: number, + setBaud: (r: number) => void, + connected: boolean, + onConnect: () => void, + onDisconnect: () => void, + viewMode: ViewMode, + setViewMode: (v: ViewMode) => void + ) => ( + <> +
+ Baud + +
+ + {connected ? ( + + ) : ( + + )} + + {/* View Mode Toggles */} +
+ + +
+ + ); + + return ( +
+ +
+ Google AdSense Space (728x90) +
+
+
+ + {!isSupported && ( +
+ ⚠️ Your browser does not support the Web Serial API. Please use Chrome, Edge, or Opera. +
+ )} + + {/* Main Content Area */} +
+ + {/* Port A Terminal */} +
+ setLogsA([])} + onSave={() => handleSaveLogs("A", logsA)} + className="flex-1" + headerControls={renderControls( + baudRateA, setBaudRateA, isConnectedA, handleConnectA, handleDisconnectA, viewModeA, setViewModeA + )} + /> +
+ +
+
+ + {/* Port B Terminal (Only if Dual Mode) */} + {isDualMode && ( +
+ setLogsB([])} + onSave={() => handleSaveLogs("B", logsB)} + className="flex-1 border-indigo-900/50 shadow-indigo-900/10" + headerControls={renderControls( + baudRateB, setBaudRateB, isConnectedB, handleConnectB, handleDisconnectB, viewModeB, setViewModeB + )} + /> +
+ +
+
+ )} +
+ + {/* Footer / Status */} +
+ © 2026 SIMP + + {isDualMode + ? (isAttached ? "MODE: DUAL MONITOR (ATTACHED: A <-> B)" : "MODE: DUAL MONITOR (INDEPENDENT)") + : "MODE: SINGLE MONITOR (A)"} + +
+
+ ); +}; + +export default App; \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b1ed01 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +
+GHBanner +
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/drive/1mwwZRLGTPE76wHsg2Mh5ks9vrdtLnPNE + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/components/ControlPanel.tsx b/components/ControlPanel.tsx new file mode 100644 index 0000000..ca73f45 --- /dev/null +++ b/components/ControlPanel.tsx @@ -0,0 +1,87 @@ +import React from 'react'; + +interface ControlPanelProps { + isDualMode: boolean; + setDualMode: (val: boolean) => void; + isAttached: boolean; + setIsAttached: (val: boolean) => void; + children?: React.ReactNode; +} + +const ControlPanel: React.FC = ({ + isDualMode, + setDualMode, + isAttached, + setIsAttached, + children +}) => { + return ( +
+
+ + {/* Left Column: Brand & Controls */} +
+ {/* Brand */} +
+
+ + + +
+
+

SerialNexus

+ Bypass & Monitor +
+
+ + {/* Controls Row */} +
+ {/* Dual Monitor Toggle */} +
+ +
+ + {/* Attach Button */} + +
+
+ + {/* Right Column: Sponsor Space */} +
+ {children} +
+
+
+ ); +}; + +export default ControlPanel; \ No newline at end of file diff --git a/components/InputBar.tsx b/components/InputBar.tsx new file mode 100644 index 0000000..20ba659 --- /dev/null +++ b/components/InputBar.tsx @@ -0,0 +1,245 @@ +import React, { useState, KeyboardEvent, useEffect } from 'react'; + +type InputMode = 'ASCII' | 'HEX'; + +interface InputBarProps { + onSend: (data: Uint8Array) => void; + disabled: boolean; +} + +const InputBar: React.FC = ({ onSend, disabled }) => { + const [text, setText] = useState(''); + const [stx, setStx] = useState(''); + const [etx, setEtx] = useState(''); + const [mode, setMode] = useState('ASCII'); + + // Error states for validation + const [errors, setErrors] = useState({ stx: false, text: false, etx: false }); + + // Command History State + const [history, setHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); + + // Clear errors when mode changes + useEffect(() => { + setErrors({ stx: false, text: false, etx: false }); + }, [mode]); + + const isValidHex = (str: string): boolean => { + // Allow empty string, hex digits, and spaces. + // Checks if there is any character that is NOT 0-9, a-f, A-F, or whitespace + return !/[^0-9A-Fa-f\s]/.test(str); + }; + + const parseString = (str: string, mode: InputMode): Uint8Array => { + if (mode === 'HEX') { + // Remove spaces and non-hex characters (cleaning is still done for safe parsing) + const clean = str.replace(/[^0-9A-Fa-f]/g, ''); + if (clean.length === 0) return new Uint8Array(0); + + const bytes = new Uint8Array(Math.ceil(clean.length / 2)); + for (let i = 0; i < bytes.length; i++) { + const hexPair = clean.slice(i * 2, i * 2 + 2).padEnd(2, '0'); + bytes[i] = parseInt(hexPair, 16); + } + return bytes; + } else { + // ASCII Mode with Escape Sequence Parsing + const unescaped = str + .replace(/\\r/g, '\r') + .replace(/\\n/g, '\n') + .replace(/\\t/g, '\t') + .replace(/\\0/g, '\0') + .replace(/\\x([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))); + + return new TextEncoder().encode(unescaped); + } + }; + + const handleSend = () => { + // Validation for HEX mode + if (mode === 'HEX') { + const stxValid = isValidHex(stx); + const textValid = isValidHex(text); + const etxValid = isValidHex(etx); + + setErrors({ + stx: !stxValid, + text: !textValid, + etx: !etxValid + }); + + if (!stxValid || !textValid || !etxValid) { + return; // Stop sending if validation fails + } + } + + try { + const stxBytes = stx ? parseString(stx, mode) : new Uint8Array(0); + const bodyBytes = text ? parseString(text, mode) : new Uint8Array(0); + const etxBytes = etx ? parseString(etx, mode) : new Uint8Array(0); + + if (stxBytes.length === 0 && bodyBytes.length === 0 && etxBytes.length === 0) return; + + const totalLength = stxBytes.length + bodyBytes.length + etxBytes.length; + const result = new Uint8Array(totalLength); + + result.set(stxBytes, 0); + result.set(bodyBytes, stxBytes.length); + result.set(etxBytes, stxBytes.length + bodyBytes.length); + + onSend(result); + + // Add to history + if (text.trim() !== '') { + setHistory(prev => { + const newHistory = [...prev]; + if (newHistory[newHistory.length - 1] !== text) { + newHistory.push(text); + } + if (newHistory.length > 50) return newHistory.slice(-50); + return newHistory; + }); + } + + setText(''); + setHistoryIndex(-1); + + } catch (e) { + console.error("Parse error", e); + alert("Error parsing input data."); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + handleSend(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (history.length === 0) return; + + setHistoryIndex(prevIndex => { + const newIndex = prevIndex === -1 ? history.length - 1 : Math.max(0, prevIndex - 1); + setText(history[newIndex]); + // Clear text error when recalling history + setErrors(prev => ({ ...prev, text: false })); + return newIndex; + }); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (history.length === 0 || historyIndex === -1) return; + + setHistoryIndex(prevIndex => { + if (prevIndex >= history.length - 1) { + setText(''); + return -1; + } else { + const newIndex = prevIndex + 1; + setText(history[newIndex]); + // Clear text error when recalling history + setErrors(prev => ({ ...prev, text: false })); + return newIndex; + } + }); + } + }; + + const getBorderClass = (hasError: boolean) => { + return hasError + ? "border-red-500 focus:ring-red-500 text-red-300 placeholder-red-300/50" + : "border-gray-700 focus:ring-indigo-500 text-white placeholder-gray-700"; + }; + + return ( +
+
+ {/* Mode Toggle */} +
+ + +
+
+ +
+ {/* STX Input */} + { + setStx(e.target.value); + if (errors.stx) setErrors(prev => ({ ...prev, stx: false })); + }} + disabled={disabled} + placeholder="STX" + className={`w-14 bg-gray-900 text-xs border rounded px-2 py-2 focus:ring-1 outline-none text-center font-mono transition-colors ${ + errors.stx + ? "border-red-500 focus:ring-red-500 text-red-400 placeholder-red-400/50" + : "border-gray-700 focus:ring-indigo-500 text-amber-200 placeholder-gray-700" + }`} + title="Start Byte (e.g. \x02 or 02)" + /> + + {/* Main Input */} + { + setText(e.target.value); + setHistoryIndex(-1); + if (errors.text) setErrors(prev => ({ ...prev, text: false })); + }} + onKeyDown={handleKeyDown} + disabled={disabled} + placeholder={disabled ? "Disconnected..." : (mode === 'HEX' ? (errors.text ? "Invalid HEX!" : "AA BB CC...") : "Type message (supports \\r\\n)...")} + className={`flex-1 bg-gray-900 text-sm border rounded px-3 py-2 focus:ring-2 outline-none disabled:opacity-50 font-mono transition-colors ${getBorderClass(errors.text)}`} + /> + + {/* ETX Input */} + { + setEtx(e.target.value); + if (errors.etx) setErrors(prev => ({ ...prev, etx: false })); + }} + disabled={disabled} + placeholder="ETX" + className={`w-14 bg-gray-900 text-xs border rounded px-2 py-2 focus:ring-1 outline-none text-center font-mono transition-colors ${ + errors.etx + ? "border-red-500 focus:ring-red-500 text-red-400 placeholder-red-400/50" + : "border-gray-700 focus:ring-indigo-500 text-amber-200 placeholder-gray-700" + }`} + title="End Byte (e.g. \r\n or 0D 0A)" + /> + + +
+
+ ); +}; + +export default InputBar; \ No newline at end of file diff --git a/components/Terminal.tsx b/components/Terminal.tsx new file mode 100644 index 0000000..77f23ea --- /dev/null +++ b/components/Terminal.tsx @@ -0,0 +1,112 @@ +import React, { useEffect, useRef } from 'react'; +import { SerialLogEntry, ViewMode } from '../types'; +import { formatTime, uInt8ArrayToAscii, uInt8ArrayToHex } from '../utils'; + +interface TerminalProps { + title: string; + logs: SerialLogEntry[]; + viewMode: ViewMode; + isConnected: boolean; + onClear: () => void; + onSave: () => void; + className?: string; + headerControls?: React.ReactNode; +} + +const Terminal: React.FC = ({ + title, + logs, + viewMode, + isConnected, + onClear, + onSave, + className, + headerControls +}) => { + const bottomRef = useRef(null); + + useEffect(() => { + // Auto-scroll to bottom on new logs + if (bottomRef.current) { + bottomRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [logs]); + + return ( +
+ {/* Terminal Header */} +
+
+
+
+

{title}

+
+ + {/* Custom Header Controls (e.g. Baud Rate, Connect Button) */} + {headerControls && ( +
+ {headerControls} +
+ )} +
+ +
+ {/* Save Button (Icon Only) */} + + + {/* Clear Button (Icon Only - Trash) */} + +
+
+ + {/* Log Area */} +
+ {logs.length === 0 && ( +
+

Waiting for data...

+
+ )} + + {logs.map((log) => ( +
+ + [{formatTime(log.timestamp)}] + + + {log.direction} + + + {viewMode === ViewMode.ASCII + ? uInt8ArrayToAscii(log.data) + : uInt8ArrayToHex(log.data) + } + +
+ ))} +
+
+
+ ); +}; + +export default Terminal; \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..2b65c7e --- /dev/null +++ b/index.html @@ -0,0 +1,59 @@ + + + + + + + SerialNexus Bridge + + + + + + + +
+ + + \ No newline at end of file diff --git a/index.tsx b/index.tsx new file mode 100644 index 0000000..6ca5361 --- /dev/null +++ b/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +const rootElement = document.getElementById('root'); +if (!rootElement) { + throw new Error("Could not find root element to mount to"); +} + +const root = ReactDOM.createRoot(rootElement); +root.render( + + + +); \ No newline at end of file diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..b28de04 --- /dev/null +++ b/metadata.json @@ -0,0 +1,7 @@ +{ + "name": "Serial Monitor", + "description": "A modern RS232 Serial Port Monitor and Bypass tool. Visualize TX/RX packets in Hex or ASCII, and bridge data between two ports in real-time.", + "requestFramePermissions": [ + "serial" + ] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..3ee87ee --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "serial-monitor", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react-dom": "^19.2.3", + "react": "^19.2.3" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } +} diff --git a/services/serialService.ts b/services/serialService.ts new file mode 100644 index 0000000..a8abcb7 --- /dev/null +++ b/services/serialService.ts @@ -0,0 +1,135 @@ +import { SerialPort, NavigatorSerial } from '../types'; + +export class SerialManager { + port: SerialPort | null = null; + private reader: ReadableStreamDefaultReader | null = null; + private writer: WritableStreamDefaultWriter | null = null; + private isReading = false; + + // Callback for receiving data + private onDataCallback: ((data: Uint8Array) => void) | null = null; + // Optional bridge target to automatically forward read data to + private bridgeTarget: SerialManager | null = null; + + constructor(public id: string) {} + + async connect(baudRate: number): Promise { + const nav = navigator as unknown as { serial: NavigatorSerial }; + if (!nav.serial) { + console.error("Web Serial API is not supported in this environment."); + throw new Error("Web Serial API not supported in this browser."); + } + + try { + console.log(`[${this.id}] Requesting port...`); + // Explicitly passing filters: [] allows the user to see all available ports + this.port = await nav.serial.requestPort({ filters: [] }); + console.log(`[${this.id}] Port selected. Opening with baudRate ${baudRate}...`); + await this.port.open({ baudRate }); + console.log(`[${this.id}] Port opened successfully.`); + } catch (err) { + console.error(`[${this.id}] Failed to connect:`, err); + throw err; + } + } + + async disconnect(): Promise { + this.isReading = false; + + if (this.reader) { + try { + await this.reader.cancel(); + // The loop will catch the cancel and release the lock + } catch (e) { + console.warn("Error cancelling reader", e); + } + } + + if (this.writer) { + try { + this.writer.releaseLock(); + } catch (e) { + console.warn("Error releasing writer lock", e); + } + this.writer = null; + } + + if (this.port) { + // Wait a tick for lock release + setTimeout(async () => { + try { + await this.port?.close(); + console.log(`[${this.id}] Port closed.`); + } catch(e) { console.error("Error closing port", e); } + this.port = null; + }, 100); + } + } + + setBridgeTarget(target: SerialManager | null) { + this.bridgeTarget = target; + } + + startReading(onData: (data: Uint8Array) => void) { + if (!this.port || !this.port.readable) return; + + this.onDataCallback = onData; + this.isReading = true; + this.readLoop(); + } + + private async readLoop() { + while (this.port && this.port.readable && this.isReading) { + try { + this.reader = this.port.readable.getReader(); + while (true) { + const { value, done } = await this.reader.read(); + if (done) { + break; + } + if (value) { + // 1. Notify UI + if (this.onDataCallback) this.onDataCallback(value); + + // 2. Bypass/Bridge: If a target is set, write data immediately to it + if (this.bridgeTarget) { + this.bridgeTarget.send(value).catch(err => console.error("Bridge write error:", err)); + } + } + } + } catch (error) { + console.error(`Read error on ${this.id}:`, error); + break; + } finally { + if (this.reader) { + this.reader.releaseLock(); + this.reader = null; + } + } + } + } + + async send(data: Uint8Array): Promise { + if (!this.port || !this.port.writable) { + console.warn(`[${this.id}] Cannot send: Port not writable or disconnected.`); + return; + } + + if (!this.writer) { + this.writer = this.port.writable.getWriter(); + } + + try { + await this.writer.write(data); + } catch (e) { + console.error("Write error:", e); + // If writer is dead, release and retry next time + this.writer.releaseLock(); + this.writer = null; + throw e; + } + // We intentionally keep the writer locked for performance in high-throughput + // scenarios, but for a general app, you might releaseLock here if you expect + // other things to grab it. For this app, SerialManager owns the writer. + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c6eed5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "types": [ + "node" + ], + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "allowJs": true, + "jsx": "react-jsx", + "paths": { + "@/*": [ + "./*" + ] + }, + "allowImportingTsExtensions": true, + "noEmit": true + } +} \ No newline at end of file diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..e74abed --- /dev/null +++ b/types.ts @@ -0,0 +1,23 @@ +export enum ViewMode { + ASCII = 'ASCII', + HEX = 'HEX' +} + +export interface SerialLogEntry { + id: string; + timestamp: Date; + direction: 'TX' | 'RX'; + data: Uint8Array; +} + +// Minimal Web Serial API type definitions since they aren't in all TS configs yet +export interface SerialPort { + open(options: { baudRate: number }): Promise; + close(): Promise; + readable: ReadableStream | null; + writable: WritableStream | null; +} + +export interface NavigatorSerial { + requestPort(options?: { filters: any[] }): Promise; +} diff --git a/utils.ts b/utils.ts new file mode 100644 index 0000000..3b3a986 --- /dev/null +++ b/utils.ts @@ -0,0 +1,29 @@ +export const generateId = (): string => { + return Math.random().toString(36).substring(2, 9); +}; + +export const formatTime = (date: Date): string => { + const h = date.getHours().toString().padStart(2, '0'); + const m = date.getMinutes().toString().padStart(2, '0'); + const s = date.getSeconds().toString().padStart(2, '0'); + const ms = date.getMilliseconds().toString().padStart(3, '0'); + return `${h}:${m}:${s}.${ms}`; +}; + +export const uInt8ArrayToHex = (arr: Uint8Array): string => { + return Array.from(arr) + .map(b => b.toString(16).padStart(2, '0').toUpperCase()) + .join(' '); +}; + +export const uInt8ArrayToAscii = (arr: Uint8Array): string => { + // Replace non-printable characters with a dot or special symbol to avoid rendering issues + return Array.from(arr) + .map(b => { + if (b >= 32 && b <= 126) { + return String.fromCharCode(b); + } + return '.'; // Placeholder for non-printable chars + }) + .join(''); +}; \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..ee5fb8d --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,23 @@ +import path from 'path'; +import { defineConfig, loadEnv } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, '.', ''); + return { + server: { + port: 3000, + host: '0.0.0.0', + }, + plugins: [react()], + define: { + 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY), + 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY) + }, + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + } + } + }; +});