Files
RS232Monitor/App.tsx
2026-01-16 14:57:07 +09:00

353 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>(ViewMode.ASCII);
const [viewModeB, setViewModeB] = useState<ViewMode>(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<SerialLogEntry[]>([]);
const [logsB, setLogsB] = useState<SerialLogEntry[]>([]);
// 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");
setLogsA([]);
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");
setLogsB([]);
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
) => (
<>
<div className="flex items-center gap-2">
<span className="text-[10px] text-gray-500 font-bold uppercase hidden xl:inline">Baud</span>
<select
className="bg-gray-800 text-xs text-white border border-gray-700 rounded px-1 py-0.5 focus:ring-1 focus:ring-indigo-500 outline-none w-20"
value={baud}
onChange={(e) => setBaud(Number(e.target.value))}
disabled={connected}
>
{BAUD_RATES.map(rate => (
<option key={rate} value={rate}>{rate}</option>
))}
</select>
</div>
{connected ? (
<button onClick={onDisconnect} className="bg-red-900/30 text-red-400 border border-red-800/50 hover:bg-red-900/50 px-2 py-0.5 rounded text-xs font-bold transition-all flex items-center gap-1">
DISCONNECT
</button>
) : (
<button
onClick={onConnect}
disabled={!isSupported}
title={!isSupported ? "Web Serial API not supported" : "Connect to Serial Port"}
className="bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-0.5 rounded text-xs font-bold transition-all shadow-lg shadow-indigo-900/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSupported ? "CONNECT" : "NO SERIAL API"}
</button>
)}
{/* View Mode Toggles */}
<div className="flex bg-gray-800 rounded border border-gray-700 ml-2">
<button
onClick={() => setViewMode(ViewMode.ASCII)}
className={`px-2 py-0.5 text-[10px] font-bold rounded-l transition-all ${viewMode === ViewMode.ASCII ? 'bg-gray-600 text-white' : 'text-gray-400 hover:text-white'
}`}
>
ASCII
</button>
<button
onClick={() => setViewMode(ViewMode.HEX)}
className={`px-2 py-0.5 text-[10px] font-bold rounded-r transition-all ${viewMode === ViewMode.HEX ? 'bg-gray-600 text-white' : 'text-gray-400 hover:text-white'
}`}
>
HEX
</button>
</div>
</>
);
return (
<div className="flex flex-col h-screen bg-gray-950 text-gray-200">
<ControlPanel
isDualMode={isDualMode}
setDualMode={setIsDualMode}
isAttached={isAttached}
setIsAttached={setIsAttached}
>
<div className="w-full max-w-[728px] h-[90px] bg-gray-800/50 border-2 border-dashed border-gray-700 rounded-lg flex items-center justify-center text-gray-500 text-xs font-mono uppercase tracking-widest relative overflow-hidden group">
<span className="relative z-10">Google AdSense Space (728x90)</span>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-gray-700/10 to-transparent translate-x-[-100%] group-hover:animate-[shimmer_2s_infinite]"></div>
</div>
</ControlPanel>
{!isSupported && (
<div className="bg-red-900/50 border-b border-red-800 text-red-200 text-xs text-center py-1">
Your browser does not support the Web Serial API. Please use Chrome, Edge, or Opera.
</div>
)}
{/* Main Content Area */}
<div className={`flex-1 flex overflow-hidden p-2 md:p-4 gap-2 md:gap-4 ${isDualMode ? 'flex-col md:flex-row' : 'flex-col'}`}>
{/* Port A Terminal */}
<div className={`flex flex-col transition-all duration-300 ease-in-out ${isDualMode
? 'w-full h-1/2 md:w-1/2 md:h-full'
: 'w-full h-full'
}`}>
<Terminal
title="Primary Port (A)"
logs={logsA}
viewMode={viewModeA}
isConnected={isConnectedA}
onClear={() => setLogsA([])}
onSave={() => handleSaveLogs("A", logsA)}
className="flex-1"
headerControls={renderControls(
baudRateA, setBaudRateA, isConnectedA, handleConnectA, handleDisconnectA, viewModeA, setViewModeA
)}
/>
<div className="mt-2 shrink-0">
<InputBar
onSend={handleSendA}
disabled={!isConnectedA}
/>
</div>
</div>
{/* Port B Terminal (Only if Dual Mode) */}
{isDualMode && (
<div className="flex flex-col w-full h-1/2 md:w-1/2 md:h-full animate-fade-in-right">
<Terminal
title="Secondary Port (B)"
logs={logsB}
viewMode={viewModeB}
isConnected={isConnectedB}
onClear={() => 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
)}
/>
<div className="mt-2 shrink-0">
<InputBar
onSend={handleSendB}
disabled={!isConnectedB}
/>
</div>
</div>
)}
</div>
{/* Footer / Status */}
<div className="h-6 bg-gray-900 border-t border-gray-800 flex items-center justify-between px-4 text-[10px] text-gray-500 font-mono select-none shrink-0">
<span>&copy; 2026 SIMP</span>
<span className="hidden sm:inline">
{isDualMode
? (isAttached ? "MODE: DUAL MONITOR (ATTACHED: A <-> B)" : "MODE: DUAL MONITOR (INDEPENDENT)")
: "MODE: SINGLE MONITOR (A)"}
</span>
</div>
</div>
);
};
export default App;