initial commit

This commit is contained in:
backuppc
2026-01-23 11:41:59 +09:00
parent 6e459ac795
commit fa5a044fba
18 changed files with 2297 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -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?

769
App.tsx Normal file
View File

@@ -0,0 +1,769 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { ConnectionPanel } from './components/ConnectionPanel';
import { InventoryPanel } from './components/InventoryPanel';
import { SettingsPanel } from './components/SettingsPanel';
import { MemoryPanel } from './components/MemoryPanel';
import { QuickTestPanel } from './components/QuickTestPanel';
import { ConnectionStatus, TagData, ReaderCommand, FrequencyBand, MemoryBank, LogEntry, SerialPort, ReaderInfo, QuickTestConfig } from './types';
import { RfidProtocol } from './services/rfidService';
import { calculateCRC16, bytesToHexString, hexStringToBytes, hexToAscii, asciiToHex } from './utils/crc16';
import { Terminal, Settings, LayoutDashboard, Database, Zap, ChevronUp, ChevronDown } from 'lucide-react';
const App: React.FC = () => {
// Serial State
const [port, setPort] = useState<SerialPort | null>(null);
const [status, setStatus] = useState<ConnectionStatus>(ConnectionStatus.DISCONNECTED);
const [baudRate, setBaudRate] = useState(57600);
const [address, setAddress] = useState(0xFF); // Broadcast address default
// App State - Set Quick Test as Default
const [activeTab, setActiveTab] = useState<'inventory' | 'settings' | 'memory' | 'quicktest'>('quicktest');
const [logs, setLogs] = useState<LogEntry[]>([]);
const [tags, setTags] = useState<TagData[]>([]);
const [isScanning, setIsScanning] = useState(false);
const [readerInfo, setReaderInfo] = useState<ReaderInfo | null>(null);
const [readResult, setReadResult] = useState<string | null>(null);
// Log Visibility State
const [isLogExpanded, setIsLogExpanded] = useState(false);
// Quick Action Config (Persisted in localStorage)
const [quickTestConfig, setQuickTestConfig] = useState<QuickTestConfig>(() => {
const saved = localStorage.getItem('quickTestConfig');
return saved ? JSON.parse(saved) : { length: 3, format: 'ascii' };
});
// Quick Action State
const [pendingQuickAction, setPendingQuickAction] = useState<'read' | 'write' | null>(null);
const [quickWriteData, setQuickWriteData] = useState<string>(""); // Actual Hex data to write (State for UI consistency)
const [quickWriteInput, setQuickWriteInput] = useState<string>(""); // UI Input state
// References for streams and state tracking
const readerRef = useRef<ReadableStreamDefaultReader<Uint8Array> | null>(null);
const writerRef = useRef<WritableStreamDefaultWriter<Uint8Array> | null>(null);
const scanIntervalRef = useRef<number | null>(null);
const isConnectedRef = useRef<boolean>(false);
// Track if a Quick Write is in progress to trigger auto-read verification
const performingQuickWriteRef = useRef<boolean>(false);
const quickWriteDataRef = useRef<string>(""); // Ref to hold write data for verification inside async loop
const verificationRetriesRef = useRef<number>(0); // Track retries
// Track which EPC is currently being queried for TID
const currentTidReadEpc = useRef<string | null>(null);
// Stale Closure Fix: Ref to hold the latest processFrame function
const processFrameRef = useRef<(frame: Uint8Array) => void>(() => {});
const addLog = (type: LogEntry['type'], message: string, data?: string) => {
setLogs(prev => [{
id: Math.random().toString(36).substr(2, 9),
timestamp: new Date(),
type,
message,
data
}, ...prev].slice(0, 100)); // Keep last 100 logs
};
const saveQuickTestConfig = (config: QuickTestConfig) => {
setQuickTestConfig(config);
localStorage.setItem('quickTestConfig', JSON.stringify(config));
addLog('INFO', `Quick Test Settings Saved to Browser: Len=${config.length}, Fmt=${config.format}`);
alert("Quick Test settings saved to browser storage.");
};
// Auto-read settings when connected (With Warm-up Sequence)
useEffect(() => {
if (status === ConnectionStatus.CONNECTED && port) {
// Start a warm-up sequence to stabilize connection
const warmup = async () => {
// 1. Initial delay for hardware port opening
await new Promise(r => setTimeout(r, 300));
// 2. Send a "Sacrificial" command.
// Devices often error on the very first byte received after connection/power-up due to sync issues.
// We send this to clear the pipe, expecting it might fail (silent retry).
addLog('INFO', 'Initializing Handshake...');
await sendCommand(ReaderCommand.GET_INFO);
// 3. Wait for the error/response to clear
await new Promise(r => setTimeout(r, 500));
// 4. Send the actual Sync command
addLog('INFO', 'Syncing Reader Info...');
handleGetInfo();
};
warmup();
}
}, [status, port]);
// Quick Action Logic: Watch tags list updates
useEffect(() => {
if (pendingQuickAction && tags.length > 0) {
const targetTag = tags[0]; // Pick the first tag found
const action = pendingQuickAction;
const dataToWrite = quickWriteData;
// 1. Stop Scanning immediately
if (scanIntervalRef.current) {
clearInterval(scanIntervalRef.current);
scanIntervalRef.current = null;
setIsScanning(false);
}
// 2. Clear pending state so we don't fire again
setPendingQuickAction(null);
setQuickWriteData("");
// 3. Execute Memory Operation with small delay to ensure bus is clear
setTimeout(() => {
if (action === 'read') {
addLog('INFO', `Quick Read detected tag: ${targetTag.epc}`);
handleMemoryRead(MemoryBank.EPC, 2, quickTestConfig.length, "00000000", targetTag.epc);
} else if (action === 'write') {
// Set Flag to trigger verification read on success
performingQuickWriteRef.current = true;
verificationRetriesRef.current = 0; // Reset retries
addLog('INFO', `Quick Write detected tag: ${targetTag.epc}`);
handleMemoryWrite(MemoryBank.EPC, 2, dataToWrite, "00000000", targetTag.epc);
}
}, 200);
}
}, [tags, pendingQuickAction, quickWriteData, quickTestConfig]);
const connectSerial = async () => {
try {
setStatus(ConnectionStatus.CONNECTING);
// @ts-ignore
const p = await navigator.serial.requestPort();
await p.open({ baudRate });
setPort(p);
isConnectedRef.current = true;
setStatus(ConnectionStatus.CONNECTED);
addLog('INFO', `Connected to serial port at ${baudRate}`);
startReading(p);
} catch (err: any) {
console.error(err);
setStatus(ConnectionStatus.ERROR);
addLog('ERROR', 'Failed to connect: ' + err.message);
}
};
const disconnectSerial = async () => {
if (scanIntervalRef.current) {
clearInterval(scanIntervalRef.current);
scanIntervalRef.current = null;
setIsScanning(false);
}
isConnectedRef.current = false;
try {
if (readerRef.current) {
await readerRef.current.cancel().catch(() => {});
readerRef.current = null;
}
if (writerRef.current) {
await writerRef.current.releaseLock();
writerRef.current = null;
}
if (port) {
await port.close();
setPort(null);
}
setStatus(ConnectionStatus.DISCONNECTED);
addLog('INFO', 'Disconnected');
} catch (err: any) {
addLog('ERROR', 'Error disconnecting: ' + err.message);
}
};
// --- Serial I/O ---
const startReading = async (p: SerialPort) => {
if (!p.readable) return;
while (p.readable && isConnectedRef.current) {
const reader = p.readable.getReader();
readerRef.current = reader;
try {
let buffer = new Uint8Array(0);
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value && value.length > 0) {
addLog('INFO', 'RAW RX', bytesToHexString(value));
const newBuffer = new Uint8Array(buffer.length + value.length);
newBuffer.set(buffer);
newBuffer.set(value, buffer.length);
buffer = newBuffer;
while (buffer.length > 0) {
const lenByte = buffer[0];
if (lenByte === 0) {
buffer = buffer.slice(1);
continue;
}
const totalFrameSize = lenByte + 1;
if (buffer.length >= totalFrameSize) {
const frame = buffer.slice(0, totalFrameSize);
// Call the latest logic via Ref
processFrameRef.current(frame);
buffer = buffer.slice(totalFrameSize);
} else {
break;
}
}
}
}
} catch (error) {
console.error("Read Error:", error);
if (isConnectedRef.current) {
addLog('ERROR', 'Read Loop Error', String(error));
}
break;
} finally {
try {
reader.releaseLock();
} catch (e) { }
}
}
};
const sendCommand = async (cmd: ReaderCommand, data: number[] = []) => {
if (!port || !port.writable) {
addLog('ERROR', 'Port not writable or disconnected');
return;
}
try {
let targetAddr = address;
if (address === 0xFF && readerInfo && readerInfo.address !== 0xFF) {
targetAddr = readerInfo.address;
}
const frame = RfidProtocol.buildCommand(targetAddr, cmd, data);
addLog('TX', `CMD: 0x${cmd.toString(16).toUpperCase()}`, bytesToHexString(frame));
const writer = port.writable.getWriter();
writerRef.current = writer;
await writer.write(frame);
writer.releaseLock();
} catch (e: any) {
addLog('ERROR', 'Send Failed', e.message);
}
};
// --- Logic ---
const processFrame = (frame: Uint8Array) => {
try {
if (frame.length < 4) {
addLog('ERROR', 'Incomplete Frame', bytesToHexString(frame));
return;
}
const len = frame[0];
const addr = frame[1];
const reCmd = frame[2];
const status = frame[3];
let data = frame.length > 6 ? frame.slice(4, frame.length - 2) : new Uint8Array(0);
addLog('RX', `Cmd:0x${reCmd.toString(16).toUpperCase()} Stat:0x${status.toString(16).toUpperCase()}`, bytesToHexString(frame));
if (status === 0x00) {
if (reCmd === ReaderCommand.GET_INFO) {
handleGetInfoResponse(data, addr);
} else if (reCmd === ReaderCommand.READ_DATA_G2) {
if (data.length > 0 && data[0] === 0x00) {
data = data.slice(1);
}
const hexData = bytesToHexString(data);
let displayData = hexData;
if (quickTestConfig.format === 'ascii') {
displayData = hexToAscii(hexData);
}
setReadResult(displayData);
if (activeTab === 'quicktest') {
setQuickWriteInput(displayData);
}
addLog('INFO', 'Memory Read Success', hexData);
if (currentTidReadEpc.current) {
const targetEpc = currentTidReadEpc.current;
setTags(prev => prev.map(t =>
t.epc === targetEpc ? { ...t, tid: hexData } : t
));
}
// === Verification Logic ===
if (performingQuickWriteRef.current) {
// Compare Read Data (Hex) with Expected Write Data (Hex)
const normalize = (s: string) => s.replace(/[\s-]/g, '').toUpperCase();
const readHex = normalize(hexData);
const expectedHex = normalize(quickWriteDataRef.current);
if (readHex === expectedHex) {
addLog('INFO', 'VERIFICATION SUCCESS: Data matches written value.');
performingQuickWriteRef.current = false;
verificationRetriesRef.current = 0;
alert("Quick Test Successful: Write verified.");
} else {
if (verificationRetriesRef.current < 5) {
verificationRetriesRef.current += 1;
addLog('WARN', `Verification Mismatch (${verificationRetriesRef.current}/5). Retrying read...`, `Read: ${readHex}, Exp: ${expectedHex}`);
setTimeout(() => {
handleQuickRead();
}, 500);
} else {
addLog('ERROR', 'VERIFICATION FAILED: Data mismatch after 5 attempts.');
performingQuickWriteRef.current = false;
verificationRetriesRef.current = 0;
alert("Quick Test Failed: Verification mismatch after 5 attempts.");
}
}
}
} else if (reCmd === ReaderCommand.WRITE_DATA_G2) {
addLog('INFO', 'Memory Write Success');
if (performingQuickWriteRef.current) {
addLog('INFO', 'Verifying written data in 0.5s...');
setTimeout(() => {
handleQuickRead();
}, 500);
}
} else if (reCmd === ReaderCommand.WRITE_EPC_G2) {
addLog('INFO', 'EPC Write Success (Cmd 0x04)');
} else if (reCmd === ReaderCommand.SET_ACCESS_EPC_MATCH) {
addLog('INFO', 'Tag Selected Successfully');
}
}
else if (reCmd === ReaderCommand.INVENTORY_G2) {
if (status === 0x01 || status === 0x02 || status === 0x03 || status === 0x04) {
handleInventoryResponse(data);
}
} else {
let errorDetail = RfidProtocol.getStatusDescription(status);
if (status === 0xFC && data.length > 0) {
errorDetail += ` - ${RfidProtocol.getTagErrorDescription(data[0])}`;
}
addLog('ERROR', `Command 0x${reCmd.toString(16).toUpperCase()} Failed`, errorDetail);
if (reCmd === ReaderCommand.WRITE_DATA_G2 && performingQuickWriteRef.current) {
performingQuickWriteRef.current = false;
verificationRetriesRef.current = 0;
addLog('ERROR', 'Quick Write Verification Cancelled due to Write Error');
alert("Quick Test Failed during Write operation.");
}
}
} catch (err) {
console.error("Frame Process Error", err);
addLog('ERROR', 'Frame Parse Error', bytesToHexString(frame));
}
};
// Sync the latest processFrame to the ref so the async loop uses the latest closure
useEffect(() => {
processFrameRef.current = processFrame;
});
const handleInventoryResponse = (data: Uint8Array) => {
if (data.length === 0) return;
let numTags = data[0];
let offset = 1;
for (let i = 0; i < numTags; i++) {
if (offset >= data.length) break;
const epcLen = data[offset];
if (offset + 1 + epcLen > data.length) break;
const epcBytes = data.slice(offset + 1, offset + 1 + epcLen);
const epcString = bytesToHexString(epcBytes);
updateTag(epcString);
offset += 1 + epcLen;
}
};
const handleGetInfoResponse = (data: Uint8Array, sourceAddr: number) => {
let version = '1.0';
let protocol: ReaderInfo['protocol'] = 'EPCC1-G2';
let power = 0;
let band: FrequencyBand = FrequencyBand.US;
if (data.length > 0) {
const maj = data[0];
const min = data.length > 1 ? data[1] : 0;
version = `${maj}.${min.toString(16).toUpperCase().padStart(2, '0')}`;
}
if (data.length >= 8) {
if (data[3] >= 0 && data[3] <= 4) band = data[3] as FrequencyBand;
if (data.length > 6) power = data[6];
} else {
if (data.length > 2) power = data[2];
}
setReaderInfo({
model: 'RRU9809',
version: version,
address: sourceAddr,
power: power,
minFreq: 0,
maxFreq: 0,
protocol: protocol,
maxResponseTime: 0,
band: band
});
addLog('INFO', 'Reader Info Parsed', `Ver:${version}, Pwr:${power}dBm`);
};
const updateTag = (epc: string) => {
setTags(prev => {
const existing = prev.find(t => t.epc === epc);
if (existing) {
return prev.map(t => t.epc === epc ? { ...t, count: t.count + 1, timestamp: Date.now() } : t);
} else {
return [...prev, { epc, count: 1, timestamp: Date.now() }];
}
});
};
// --- Operations ---
const toggleScanning = useCallback(() => {
if (isScanning) {
if (scanIntervalRef.current) clearInterval(scanIntervalRef.current);
scanIntervalRef.current = null;
setIsScanning(false);
} else {
setIsScanning(true);
const runScan = () => sendCommand(ReaderCommand.INVENTORY_G2, []);
runScan();
scanIntervalRef.current = window.setInterval(runScan, 500);
}
}, [isScanning, address]);
const handleQuickRead = () => {
if (status !== ConnectionStatus.CONNECTED) {
if (!performingQuickWriteRef.current) {
alert("Reader is not connected! Please connect via serial port first.");
}
return;
}
setReadResult(null);
setTags([]);
setPendingQuickAction('read');
if (!isScanning) {
toggleScanning();
}
};
const handleQuickWrite = (inputValue: string) => {
if (status !== ConnectionStatus.CONNECTED) {
alert("Reader is not connected! Please connect via serial port first.");
return;
}
let finalHexData = inputValue;
if (quickTestConfig.format === 'ascii') {
finalHexData = asciiToHex(inputValue);
}
// Store in State for UI
setQuickWriteData(finalHexData);
// Store in Ref for Async Logic
quickWriteDataRef.current = finalHexData;
setTags([]);
setPendingQuickAction('write');
if (!isScanning) {
toggleScanning();
}
};
const handleCancelQuickAction = () => {
if (scanIntervalRef.current) {
clearInterval(scanIntervalRef.current);
scanIntervalRef.current = null;
}
setIsScanning(false);
setPendingQuickAction(null);
performingQuickWriteRef.current = false;
verificationRetriesRef.current = 0;
addLog('INFO', 'Quick Test Cancelled by user');
};
const clearTags = () => setTags([]);
const handleGetInfo = async () => {
addLog('INFO', 'Requesting Reader Info...');
await sendCommand(ReaderCommand.GET_INFO);
};
const handleSetParameters = async (settings: {
address: number;
baudRate: number;
power: number;
minFreq: number;
maxFreq: number;
band: FrequencyBand;
}) => {
await sendCommand(ReaderCommand.SET_POWER, [settings.power]);
if (settings.address !== address) {
await sendCommand(ReaderCommand.SET_ADDRESS, [settings.address]);
setAddress(settings.address);
}
addLog('INFO', 'Parameters update command sent');
};
const handleFactoryReset = async () => {
addLog('INFO', 'Sending Factory Reset...');
};
const getEpcData = (epc: string) => {
const bytes = hexStringToBytes(epc);
if (!bytes) return { bytes: [], words: 0 };
return {
bytes: Array.from(bytes),
words: Math.ceil(bytes.length / 2)
};
};
const handleFetchTids = async () => {
if (isScanning) {
if (scanIntervalRef.current) clearInterval(scanIntervalRef.current);
scanIntervalRef.current = null;
setIsScanning(false);
}
if (tags.length === 0) return;
addLog('INFO', 'Starting Batch TID Read...');
for (const tag of tags) {
currentTidReadEpc.current = tag.epc;
const { bytes: epcBytes, words: epcWords } = getEpcData(tag.epc);
if (epcBytes.length === 0) continue;
const payload = [
epcWords, ...epcBytes, MemoryBank.TID, 0, 6, 0,0,0,0, 0, 0
];
addLog('INFO', `Reading TID for ${tag.epc}...`);
await sendCommand(ReaderCommand.READ_DATA_G2, payload);
await new Promise(r => setTimeout(r, 100));
}
currentTidReadEpc.current = null;
addLog('INFO', 'Batch TID Read Complete');
};
const handleMemoryRead = async (bank: MemoryBank, ptr: number, len: number, pwd: string, targetEpc: string) => {
const { bytes: epcBytes, words: epcWords } = getEpcData(targetEpc);
let pwdBytes = hexStringToBytes(pwd) || new Uint8Array([0,0,0,0]);
const payload = [
epcWords, ...epcBytes, bank, ptr & 0xFF, len & 0xFF, ...Array.from(pwdBytes), 0, 0
];
addLog('INFO', `Read Req: Bank ${bank}, Ptr ${ptr}, Len ${len}`);
await sendCommand(ReaderCommand.READ_DATA_G2, payload);
};
const handleMemoryWrite = async (bank: MemoryBank, ptr: number, dataStr: string, pwd: string, targetEpc: string) => {
const { bytes: epcBytes, words: epcWords } = getEpcData(targetEpc);
let pwdBytes = hexStringToBytes(pwd) || new Uint8Array([0,0,0,0]);
const dataBytes = hexStringToBytes(dataStr);
if (!dataBytes) {
addLog('ERROR', 'Invalid Hex Data for Write');
return;
}
const wNum = Math.ceil(dataBytes.length / 2);
const payload = [
wNum, epcWords, ...epcBytes, bank, ptr & 0xFF, ...Array.from(dataBytes), ...Array.from(pwdBytes), 0, 0
];
addLog('INFO', `Write Req: Bank ${bank}, Ptr ${ptr}, Data ${dataStr}`);
await sendCommand(ReaderCommand.WRITE_DATA_G2, payload);
};
const handleWriteEpc = async (newEpc: string, pwd: string) => {
const { bytes: epcBytes, words: epcWords } = getEpcData(newEpc);
if (epcWords === 0) {
addLog('ERROR', 'Invalid EPC Data');
return;
}
let pwdBytes = hexStringToBytes(pwd) || new Uint8Array([0,0,0,0]);
const payload = [epcWords, ...Array.from(pwdBytes), ...epcBytes];
addLog('INFO', `Write EPC (0x04): ${newEpc}`);
await sendCommand(ReaderCommand.WRITE_EPC_G2, payload);
};
const getLogHeightClass = () => {
if (isLogExpanded) return 'h-96';
if (activeTab === 'quicktest' || activeTab === 'settings') return 'h-12';
return 'h-48';
};
return (
<div className="flex h-screen bg-slate-100">
{/* Sidebar */}
<div className="w-64 bg-white border-r border-slate-200 flex flex-col z-10">
<div className="p-6 border-b border-slate-100">
<h1 className="text-xl font-bold text-slate-800 flex items-center gap-2">
<Database className="text-blue-600" />
UHF Reader
</h1>
<p className="text-xs text-slate-400 mt-1">Web Serial Controller</p>
</div>
<div className="p-4 space-y-2 flex-1">
<button
onClick={() => setActiveTab('quicktest')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
activeTab === 'quicktest' ? 'bg-indigo-50 text-indigo-700' : 'text-slate-600 hover:bg-slate-50'
}`}
>
<Zap className="w-5 h-5" /> Quick Test
</button>
<button
onClick={() => setActiveTab('inventory')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
activeTab === 'inventory' ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50'
}`}
>
<LayoutDashboard className="w-5 h-5" /> Inventory
</button>
<button
onClick={() => setActiveTab('memory')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
activeTab === 'memory' ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50'
}`}
>
<Database className="w-5 h-5" /> Read / Write
</button>
<button
onClick={() => setActiveTab('settings')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
activeTab === 'settings' ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50'
}`}
>
<Settings className="w-5 h-5" /> Settings
</button>
</div>
<div className="p-4 border-t border-slate-100">
<ConnectionPanel
status={status}
onConnect={connectSerial}
onDisconnect={disconnectSerial}
baudRate={baudRate}
setBaudRate={setBaudRate}
address={address}
setAddress={setAddress}
/>
</div>
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden relative">
<main className="flex-1 p-6 overflow-auto">
{activeTab === 'inventory' && (
<InventoryPanel
tags={tags}
isScanning={isScanning}
onToggleScan={toggleScanning}
onClear={clearTags}
onFetchTids={handleFetchTids}
/>
)}
{activeTab === 'quicktest' && (
<QuickTestPanel
onQuickRead={handleQuickRead}
onQuickWrite={handleQuickWrite}
onCancel={handleCancelQuickAction}
writeInput={quickWriteInput}
onWriteInputChange={setQuickWriteInput}
result={readResult}
isPending={pendingQuickAction !== null}
isScanning={isScanning}
config={quickTestConfig}
/>
)}
{activeTab === 'memory' && (
<MemoryPanel
tags={tags}
isScanning={isScanning}
onScan={toggleScanning}
onRead={handleMemoryRead}
onWrite={handleMemoryWrite}
onWriteEpc={handleWriteEpc}
selectedEpc={tags.length > 0 ? tags[0].epc : ""}
readResult={readResult}
/>
)}
{activeTab === 'settings' && (
<SettingsPanel
readerInfo={readerInfo}
onGetInfo={handleGetInfo}
onSetParameters={handleSetParameters}
onFactoryReset={handleFactoryReset}
quickTestConfig={quickTestConfig}
onSaveQuickTestConfig={saveQuickTestConfig}
/>
)}
</main>
{/* Log Terminal */}
<div className={`${getLogHeightClass()} bg-white border-t border-slate-200 flex flex-col transition-all duration-300 ease-in-out`}>
<div
className="px-4 py-2 bg-slate-50 border-b border-slate-100 flex items-center justify-between cursor-pointer hover:bg-slate-100"
onClick={() => setIsLogExpanded(!isLogExpanded)}
>
<div className="flex items-center gap-2 text-xs font-semibold text-slate-700 uppercase tracking-wider">
<Terminal className="w-3 h-3" /> System Logs
</div>
<div>
{isLogExpanded ? <ChevronDown className="w-4 h-4 text-slate-400"/> : <ChevronUp className="w-4 h-4 text-slate-400"/>}
</div>
</div>
<div className="flex-1 overflow-auto p-4 font-mono text-xs space-y-1 text-slate-600">
{logs.length === 0 && <span className="text-slate-400 italic">No logs yet...</span>}
{logs.map(log => (
<div key={log.id} className="flex gap-2">
<span className="text-slate-400">[{log.timestamp.toLocaleTimeString()}]</span>
<span className={`font-bold ${
log.type === 'TX' ? 'text-blue-600' :
log.type.includes('RX') ? 'text-emerald-600' :
log.type === 'ERROR' ? 'text-red-600' :
log.type === 'WARN' ? 'text-amber-600' : 'text-slate-700'
}`}>
{log.type}
</span>
<span className="text-slate-800">{log.message}</span>
{log.data && <span className="text-slate-500 ml-2">{log.data}</span>}
</div>
))}
</div>
</div>
</div>
</div>
);
};
export default App;

20
README.md Normal file
View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# 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/1U9yufYhiMdLwsPQckfvHJfi-bwUGaL0c
## 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`

BIN
assets/protocol.pdf Normal file

Binary file not shown.

View File

@@ -0,0 +1,121 @@
import React, { useState, useEffect } from 'react';
import { ConnectionStatus } from '../types';
import { Wifi, WifiOff, Activity } from 'lucide-react';
interface Props {
status: ConnectionStatus;
onConnect: () => void;
onDisconnect: () => void;
baudRate: number;
setBaudRate: (rate: number) => void;
address: number;
setAddress: (addr: number) => void;
}
export const ConnectionPanel: React.FC<Props> = ({
status,
onConnect,
onDisconnect,
baudRate,
setBaudRate,
address,
setAddress
}) => {
const isConnected = status === ConnectionStatus.CONNECTED;
// Local state for address input to allow smooth typing without auto-formatting interference
const [localAddress, setLocalAddress] = useState(address.toString(16).toUpperCase().padStart(2, '0'));
const [isFocused, setIsFocused] = useState(false);
// Sync from props only when not editing
useEffect(() => {
if (!isFocused) {
setLocalAddress(address.toString(16).toUpperCase().padStart(2, '0'));
}
}, [address, isFocused]);
const handleAddressChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value.toUpperCase();
// Validate Hex and Length
if (/^[0-9A-F]*$/.test(val) && val.length <= 2) {
setLocalAddress(val);
// Update parent state if valid
if (val !== '') {
setAddress(parseInt(val, 16));
}
}
};
const handleBlur = () => {
setIsFocused(false);
// On blur, ensure the display matches the formatted prop value (pads single digits, etc.)
setLocalAddress(address.toString(16).toUpperCase().padStart(2, '0'));
};
return (
<div className="bg-white p-4 rounded-xl shadow-sm border border-slate-200">
<div className="flex items-center gap-2 mb-4">
{isConnected ? <Wifi className="text-emerald-500" /> : <WifiOff className="text-slate-400" />}
<h2 className="text-lg font-semibold text-slate-800">Connection</h2>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">Device Model</label>
<select
disabled={isConnected}
className="w-full bg-slate-50 border border-slate-300 text-slate-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 disabled:opacity-50"
>
<option value="CR100"> CR-100</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">Baud Rate</label>
<select
disabled={isConnected}
value={baudRate}
onChange={(e) => setBaudRate(Number(e.target.value))}
className="w-full bg-slate-50 border border-slate-300 text-slate-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 disabled:opacity-50"
>
<option value={9600}>9600</option>
<option value={19200}>19200</option>
<option value={38400}>38400</option>
<option value={57600}>57600 (Default)</option>
<option value={115200}>115200</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">Reader Address (Hex)</label>
<input
type="text"
value={localAddress}
onFocus={() => setIsFocused(true)}
onBlur={handleBlur}
onChange={handleAddressChange}
className="w-full bg-slate-50 border border-slate-300 text-slate-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 font-mono"
maxLength={2}
placeholder="FF"
/>
</div>
<button
onClick={isConnected ? onDisconnect : onConnect}
className={`w-full flex items-center justify-center gap-2 font-medium rounded-lg text-sm px-5 py-2.5 transition-colors ${
isConnected
? 'bg-red-50 text-red-600 hover:bg-red-100 border border-red-200'
: 'bg-blue-600 text-white hover:bg-blue-700 shadow-md shadow-blue-500/20'
}`}
>
{isConnected ? 'Disconnect' : 'Connect Serial'}
{status === ConnectionStatus.CONNECTING && <Activity className="animate-spin h-4 w-4" />}
</button>
<div className="text-xs text-slate-400 text-center">
{status}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,179 @@
import React, { useState } from 'react';
import { TagData } from '../types';
import { Play, Pause, Trash2, Download, Fingerprint, ScanBarcode, CircuitBoard } from 'lucide-react';
interface Props {
tags: TagData[];
isScanning: boolean;
onToggleScan: () => void;
onClear: () => void;
onFetchTids: () => void;
}
export const InventoryPanel: React.FC<Props> = ({
tags,
isScanning,
onToggleScan,
onClear,
onFetchTids
}) => {
const [viewMode, setViewMode] = useState<'epc' | 'tid'>('epc');
const exportCsv = () => {
const csvContent = "data:text/csv;charset=utf-8,"
+ "EPC,TID,Count,Timestamp\n"
+ tags.map(t => `${t.epc},${t.tid || ''},${t.count},${new Date(t.timestamp).toISOString()}`).join("\n");
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("download", "rfid_inventory.csv");
document.body.appendChild(link);
link.click();
};
return (
<div className="flex flex-col h-full gap-4">
{/* Main Inventory Table Card */}
<div className="flex flex-col flex-1 bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
{/* Header / Controls */}
<div className="p-4 border-b border-slate-100 flex flex-col xl:flex-row items-start xl:items-center justify-between bg-slate-50/50 gap-4">
<div className="flex items-center gap-4">
<div className="flex flex-col">
<h1 className="text-xl font-bold text-slate-800">EPC C1G2 Inventory</h1>
<span className="text-xs text-slate-500">{tags.length} Unique Tags Found</span>
</div>
{/* View Mode Switcher */}
<div className="bg-slate-200 p-1 rounded-lg flex items-center gap-1 ml-4">
<button
onClick={() => setViewMode('epc')}
className={`px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-2 transition-all ${viewMode === 'epc' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
<ScanBarcode className="w-3.5 h-3.5" /> EPC
</button>
<button
onClick={() => setViewMode('tid')}
className={`px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-2 transition-all ${viewMode === 'tid' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
<Fingerprint className="w-3.5 h-3.5" /> TID
</button>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{viewMode === 'tid' && (
<button
onClick={onFetchTids}
disabled={isScanning || tags.length === 0}
className="flex items-center gap-2 px-3 py-2 bg-indigo-50 text-indigo-700 border border-indigo-200 rounded-lg text-sm font-medium hover:bg-indigo-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors mr-2"
title="Read TID memory for all listed tags"
>
<CircuitBoard className="w-4 h-4" />
Read TIDs
</button>
)}
<button
onClick={onToggleScan}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
isScanning
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 border border-amber-200'
: 'bg-emerald-600 text-white hover:bg-emerald-700 shadow-md shadow-emerald-500/20'
}`}
>
{isScanning ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
{isScanning ? 'Stop Scan' : 'Start Scan'}
</button>
<div className="h-6 w-px bg-slate-300 mx-1"></div>
<button
onClick={onClear}
className="p-2 text-slate-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Clear List"
>
<Trash2 className="w-5 h-5" />
</button>
<button
onClick={exportCsv}
disabled={tags.length === 0}
className="p-2 text-slate-600 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors disabled:opacity-50"
title="Export CSV"
>
<Download className="w-5 h-5" />
</button>
</div>
</div>
{/* Table Area */}
<div className="flex-1 overflow-auto">
<table className="w-full text-sm text-left text-slate-600">
<thead className="text-xs text-slate-700 uppercase bg-slate-50 sticky top-0 z-10 shadow-sm">
<tr>
<th scope="col" className="px-6 py-3 w-16">#</th>
<th scope="col" className="px-6 py-3">
{viewMode === 'epc' ? 'EPC ID' : 'TID (Tag Identifier)'}
</th>
{viewMode === 'tid' && <th scope="col" className="px-6 py-3 text-slate-400">EPC Reference</th>}
<th scope="col" className="px-6 py-3 w-32">Length (Bits)</th>
<th scope="col" className="px-6 py-3 w-32 text-right">Count</th>
</tr>
</thead>
<tbody>
{tags.length === 0 ? (
<tr>
<td colSpan={viewMode === 'tid' ? 5 : 4} className="px-6 py-12 text-center text-slate-400 italic">
No tags scanned yet. Press "Start Scan" to begin.
</td>
</tr>
) : (
tags.map((tag, index) => (
<tr key={tag.epc} className="bg-white border-b border-slate-100 hover:bg-slate-50 transition-colors">
<td className="px-6 py-4 font-mono text-slate-400">{index + 1}</td>
{/* Main ID Column */}
<td className="px-6 py-4 font-mono font-medium text-slate-900">
{viewMode === 'epc' ? (
tag.epc
) : (
tag.tid ? (
<span className="text-indigo-700">{tag.tid}</span>
) : (
<span className="text-slate-300 italic text-xs">Not Read (Click "Read TIDs")</span>
)
)}
</td>
{/* EPC Reference Column (Only in TID view) */}
{viewMode === 'tid' && (
<td className="px-6 py-4 font-mono text-xs text-slate-400">
{tag.epc}
</td>
)}
{/* Length Column */}
<td className="px-6 py-4">
{viewMode === 'epc'
? tag.epc.replace(/\s/g, '').length * 4
: (tag.tid ? tag.tid.replace(/\s/g, '').length * 4 : '-')
}
</td>
<td className="px-6 py-4 text-right">
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
{tag.count}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
};

307
components/MemoryPanel.tsx Normal file
View File

@@ -0,0 +1,307 @@
import React, { useState, useEffect } from 'react';
import { MemoryBank, TagData } from '../types';
import { Lock, HardDriveDownload, HardDriveUpload, Search, RefreshCw, ChevronDown, Tag } from 'lucide-react';
import { hexStringToBytes, hexToAscii, asciiToHex } from '../utils/crc16';
interface Props {
tags: TagData[];
onScan: () => void;
isScanning: boolean;
onRead: (bank: MemoryBank, ptr: number, len: number, password: string, targetEpc: string) => Promise<void>;
onWrite: (bank: MemoryBank, ptr: number, data: string, password: string, targetEpc: string) => Promise<void>;
onWriteEpc: (newEpc: string, password: string) => Promise<void>;
selectedEpc: string; // From parent (legacy), we will use local state mainly
readResult: string | null;
}
export const MemoryPanel: React.FC<Props> = ({ tags, onScan, isScanning, onRead, onWrite, onWriteEpc, selectedEpc: initialEpc, readResult }) => {
const [targetEpc, setTargetEpc] = useState(initialEpc);
const [bank, setBank] = useState<MemoryBank>(MemoryBank.EPC);
const [ptr, setPtr] = useState(2); // Default to 2 for EPC bank
const [len, setLen] = useState(4);
const [password, setPassword] = useState("00000000");
const [writeData, setWriteData] = useState("");
// Unified Data Mode: Controls both Read display and Write input interpretation
const [dataMode, setDataMode] = useState<'hex' | 'ascii'>('hex');
// Update local state if parent prop changes (optional sync)
useEffect(() => {
if (initialEpc) setTargetEpc(initialEpc);
}, [initialEpc]);
// If tags list updates and we don't have a target, auto-select the first one
useEffect(() => {
if (!targetEpc && tags.length > 0) {
setTargetEpc(tags[0].epc);
}
}, [tags, targetEpc]);
// Auto-update Pointer default based on Bank to prevent Parameter Errors
useEffect(() => {
if (bank === MemoryBank.EPC) {
setPtr(2); // Start at 2 to skip CRC (0) and PC (1) which are often protected
} else {
setPtr(0);
}
}, [bank]);
const handleRead = () => {
if (!targetEpc) {
alert("Please select or scan a tag first.");
return;
}
onRead(bank, ptr, len, password, targetEpc);
};
const toggleDataMode = (newMode: 'hex' | 'ascii') => {
if (newMode === dataMode) return;
// Convert current Write Input Data to match new mode
// This prevents the user from having to manually convert what they just typed
if (newMode === 'ascii') {
// Switching Hex -> Ascii
setWriteData(hexToAscii(writeData));
} else {
// Switching Ascii -> Hex
setWriteData(asciiToHex(writeData));
}
setDataMode(newMode);
};
const getHexForWrite = () => {
if (dataMode === 'ascii') {
return asciiToHex(writeData);
}
return writeData;
};
const handleWrite = () => {
if (!targetEpc) {
alert("Please select or scan a tag first.");
return;
}
const hexPayload = getHexForWrite();
onWrite(bank, ptr, hexPayload, password, targetEpc);
};
const handleInitializeEpc = () => {
const hexPayload = getHexForWrite();
// Validate locally but allow error logging
if (!hexStringToBytes(hexPayload)) {
// Pass invalid data to parent to trigger the "Invalid EPC Data" log
onWriteEpc(hexPayload, password);
return;
}
// Confirm with user only if data looks valid
if (confirm("This will overwrite the EPC of a single tag in the field using Command 0x04. Proceed?")) {
onWriteEpc(hexPayload, password);
}
};
return (
<div className="bg-white p-6 rounded-xl shadow-sm border border-slate-200">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<HardDriveDownload className="text-blue-600" />
Memory Operations
</h2>
{/* Unified Mode Toggle */}
<div className="bg-slate-100 p-1 rounded-lg flex items-center self-start sm:self-auto">
<button
onClick={() => toggleDataMode('hex')}
className={`px-3 py-1.5 text-xs font-bold rounded-md transition-all ${dataMode === 'hex' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
HEX Mode
</button>
<button
onClick={() => toggleDataMode('ascii')}
className={`px-3 py-1.5 text-xs font-bold rounded-md transition-all ${dataMode === 'ascii' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
ASCII Mode
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Configuration Column */}
<div className="space-y-5">
{/* Tag Selection Area */}
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200">
<label className="block text-sm font-bold text-slate-700 mb-2">Target Tag (Filter)</label>
<div className="flex gap-2 mb-2">
<div className="relative flex-1">
<input
type="text"
value={targetEpc}
onChange={(e) => setTargetEpc(e.target.value)}
placeholder="Scan or type EPC..."
className="w-full bg-white border border-slate-300 text-slate-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 font-mono"
/>
{tags.length > 0 && (
<div className="absolute right-2 top-2.5 pointer-events-none text-slate-400">
<ChevronDown className="w-4 h-4" />
</div>
)}
{/* Invisible Select overlay for dropdown behavior */}
{tags.length > 0 && (
<select
onChange={(e) => setTargetEpc(e.target.value)}
value={targetEpc}
className="absolute inset-0 opacity-0 cursor-pointer"
>
<option value="" disabled>Select from list...</option>
{tags.map(t => (
<option key={t.epc} value={t.epc}>{t.epc} (Count: {t.count})</option>
))}
</select>
)}
</div>
<button
type="button"
onClick={onScan}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 whitespace-nowrap ${
isScanning
? 'bg-amber-100 text-amber-700 border border-amber-200'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{isScanning ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
{isScanning ? 'Stop' : 'Scan'}
</button>
</div>
<p className="text-xs text-slate-500">
{tags.length > 0
? `${tags.length} tags found. Select one to target.`
: "Click Scan to find nearby tags."}
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">Access Password (8 Hex Chars)</label>
<div className="relative">
<input
type="text"
value={password}
onChange={e => setPassword(e.target.value)}
maxLength={8}
className="w-full bg-white border border-slate-300 text-slate-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 pl-9 font-mono"
/>
<Lock className="w-4 h-4 text-slate-400 absolute left-3 top-3" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-600 mb-1">Memory Bank</label>
<select
value={bank}
onChange={e => setBank(Number(e.target.value))}
className="w-full bg-white border border-slate-300 text-slate-900 text-sm rounded-lg p-2.5"
>
<option value={MemoryBank.RESERVED}>Reserved</option>
<option value={MemoryBank.EPC}>EPC</option>
<option value={MemoryBank.TID}>TID</option>
<option value={MemoryBank.USER}>User</option>
</select>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Start (Word)</label>
<input
type="number"
value={ptr}
onChange={e => setPtr(Number(e.target.value))}
className="w-full bg-white border border-slate-300 text-sm rounded-lg p-2.5"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Len (Word)</label>
<input
type="number"
value={len}
onChange={e => setLen(Number(e.target.value))}
className="w-full bg-white border border-slate-300 text-sm rounded-lg p-2.5"
/>
</div>
</div>
</div>
{bank === MemoryBank.EPC && (
<div className="text-xs text-amber-600 bg-amber-50 p-2 rounded border border-amber-100">
<strong>Note:</strong> Start Address 0 is CRC, 1 is PC. Writing to these may fail (Error 0xFF). EPC data typically starts at address 2.
</div>
)}
</div>
{/* Action Column */}
<div className="flex flex-col gap-4">
{/* Read Section */}
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200 flex-1 flex flex-col">
<label className="text-sm font-semibold text-slate-700 mb-2">
Read Data <span className="text-xs font-normal text-slate-400">({dataMode.toUpperCase()})</span>
</label>
<div className="flex-1 bg-white border border-slate-300 rounded p-2 mb-2 font-mono text-xs text-slate-600 break-all overflow-y-auto min-h-[60px]">
{readResult
? (dataMode === 'hex' ? readResult : hexToAscii(readResult))
: "No data read..."
}
</div>
<button
type="button"
onClick={handleRead}
className="w-full bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 font-medium py-2 rounded-lg text-sm transition-colors flex items-center justify-center gap-2"
>
<HardDriveDownload className="w-4 h-4" /> Read Memory
</button>
</div>
{/* Write Section */}
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<label className="text-sm font-semibold text-slate-700 mb-2">
Write Data <span className="text-xs font-normal text-slate-400">({dataMode.toUpperCase()})</span>
</label>
<input
type="text"
value={writeData}
onChange={e => setWriteData(e.target.value)}
placeholder={dataMode === 'hex' ? "e.g., A1 B2 C3..." : "Type text here..."}
className="w-full bg-white border border-slate-300 text-sm rounded p-2 mb-2 font-mono"
/>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={handleWrite}
className="bg-slate-800 hover:bg-slate-900 text-white font-medium py-2 rounded-lg text-sm transition-colors flex items-center justify-center gap-2"
title="Writes data to the selected bank/address (Cmd 0x03)"
>
<HardDriveUpload className="w-4 h-4" /> Write Mem
</button>
{bank === MemoryBank.EPC && (
<button
type="button"
onClick={handleInitializeEpc}
className="bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 rounded-lg text-sm transition-colors flex items-center justify-center gap-2"
title="Overwrites EPC of a SINGLE tag in field (Cmd 0x04)"
>
<Tag className="w-4 h-4" /> Write EPC
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,127 @@
import React from 'react';
import { Zap, HardDriveUpload, HardDriveDownload, Activity, CheckCircle, AlertCircle, XCircle } from 'lucide-react';
import { QuickTestConfig } from '../types';
interface Props {
onQuickRead: () => void;
onQuickWrite: (data: string) => void;
onCancel: () => void;
writeInput: string;
onWriteInputChange: (value: string) => void;
result: string | null;
isPending: boolean;
isScanning: boolean;
config: QuickTestConfig;
}
export const QuickTestPanel: React.FC<Props> = ({
onQuickRead,
onQuickWrite,
onCancel,
writeInput,
onWriteInputChange,
result,
isPending,
isScanning,
config
}) => {
return (
<div className="h-full flex flex-col items-center justify-center p-6 max-w-4xl mx-auto">
<div className="text-center mb-10">
<div className="inline-flex items-center justify-center p-3 bg-indigo-100 rounded-full mb-4">
<Zap className="w-8 h-8 text-indigo-600" />
</div>
<h1 className="text-3xl font-bold text-slate-800 mb-2">Quick Test Mode</h1>
<p className="text-slate-500">
Automatically detects the first tag in the field and performs a Read or Write operation.<br/>
Target: <strong>EPC Bank</strong>, Start Address: <strong>2</strong><br/>
Length: <strong>{config.length} Words</strong>, Format: <strong>{config.format.toUpperCase()}</strong>
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
{/* Read Card */}
<div className="bg-white p-8 rounded-2xl shadow-sm border border-slate-200 hover:shadow-md transition-shadow flex flex-col items-center text-center">
<div className="mb-6 p-4 bg-blue-50 text-blue-600 rounded-full">
<HardDriveDownload className="w-8 h-8" />
</div>
<h2 className="text-xl font-bold text-slate-800 mb-2">Auto Read</h2>
<div className="w-full bg-slate-50 border border-slate-200 rounded-xl p-4 mb-6 min-h-[80px] flex items-center justify-center font-mono text-lg text-slate-700 break-all">
{isPending ? (
<div className="flex items-center gap-2 text-amber-500 animate-pulse">
<Activity className="w-5 h-5" /> Scanning & Reading...
</div>
) : (
result ? (
<span className="text-emerald-600 font-bold">{result}</span>
) : (
<span className="text-slate-300">No Data Read</span>
)
)}
</div>
{isPending ? (
<button
onClick={onCancel}
className="w-full py-4 bg-red-500 hover:bg-red-600 text-white rounded-xl font-bold text-lg shadow-lg shadow-red-500/30 transition-all flex items-center justify-center gap-2"
>
<XCircle className="w-5 h-5" /> Cancel
</button>
) : (
<button
onClick={onQuickRead}
disabled={isScanning && !isPending}
className="w-full py-4 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-300 disabled:cursor-not-allowed text-white rounded-xl font-bold text-lg shadow-lg shadow-blue-500/30 transition-all flex items-center justify-center gap-2"
>
Start Read Test
</button>
)}
</div>
{/* Write Card */}
<div className="bg-white p-8 rounded-2xl shadow-sm border border-slate-200 hover:shadow-md transition-shadow flex flex-col items-center text-center">
<div className="mb-6 p-4 bg-purple-50 text-purple-600 rounded-full">
<HardDriveUpload className="w-8 h-8" />
</div>
<h2 className="text-xl font-bold text-slate-800 mb-2">Auto Write</h2>
<input
type="text"
value={writeInput}
onChange={(e) => onWriteInputChange(e.target.value)}
placeholder={`${config.format === 'hex' ? 'Hex Data (e.g. A1 B2...)' : 'ASCII String'}`}
className="w-full bg-slate-50 border border-slate-300 text-center text-lg rounded-xl p-4 mb-6 font-mono focus:ring-2 focus:ring-purple-500 outline-none"
/>
{isPending ? (
<button
onClick={onCancel}
className="w-full py-4 bg-red-500 hover:bg-red-600 text-white rounded-xl font-bold text-lg shadow-lg shadow-red-500/30 transition-all flex items-center justify-center gap-2"
>
<XCircle className="w-5 h-5" /> Cancel
</button>
) : (
<button
onClick={() => onQuickWrite(writeInput)}
disabled={(isScanning && !isPending) || !writeInput}
className="w-full py-4 bg-slate-800 hover:bg-slate-900 disabled:bg-slate-300 disabled:cursor-not-allowed text-white rounded-xl font-bold text-lg shadow-lg shadow-slate-500/30 transition-all flex items-center justify-center gap-2"
>
Start Write Test
</button>
)}
</div>
</div>
{isScanning && !isPending && (
<div className="mt-8 flex items-center gap-2 px-4 py-2 bg-amber-50 text-amber-700 border border-amber-200 rounded-full text-sm animate-pulse">
<Activity className="w-4 h-4" /> Background scanning is active in Inventory...
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,371 @@
import React, { useState, useEffect } from 'react';
import { FrequencyBand, ReaderInfo, QuickTestConfig } from '../types';
import { RefreshCw, Save, RotateCcw, Cpu, Radio, Zap, Activity, HardDrive, Settings2 } from 'lucide-react';
interface Props {
readerInfo: ReaderInfo | null;
onGetInfo: () => void;
onSetParameters: (settings: {
address: number;
baudRate: number;
power: number;
minFreq: number;
maxFreq: number;
band: FrequencyBand;
}) => void;
onFactoryReset: () => void;
quickTestConfig: QuickTestConfig;
onSaveQuickTestConfig: (config: QuickTestConfig) => void;
}
export const SettingsPanel: React.FC<Props> = ({
readerInfo,
onGetInfo,
onSetParameters,
onFactoryReset,
quickTestConfig,
onSaveQuickTestConfig
}) => {
// Local state for the editable form
const [address, setAddress] = useState(0x00);
const [baudRate, setBaudRate] = useState(57600);
const [power, setPower] = useState(13);
const [maxResponseTime, setMaxResponseTime] = useState(0);
const [minFreq, setMinFreq] = useState(0);
const [maxFreq, setMaxFreq] = useState(0);
const [isSingleFreq, setIsSingleFreq] = useState(false);
const [band, setBand] = useState<FrequencyBand>(FrequencyBand.US);
// Quick Test Local State
const [qtLength, setQtLength] = useState<3 | 4>(4);
const [qtFormat, setQtFormat] = useState<'hex' | 'ascii'>('hex');
// Initialize form with reader info when available
useEffect(() => {
if (readerInfo) {
setAddress(readerInfo.address);
setPower(readerInfo.power);
setMinFreq(readerInfo.minFreq);
setMaxFreq(readerInfo.maxFreq);
setMaxResponseTime(readerInfo.maxResponseTime);
setBand(readerInfo.band);
setIsSingleFreq(readerInfo.minFreq === readerInfo.maxFreq);
}
}, [readerInfo]);
// Sync Quick Test config
useEffect(() => {
setQtLength(quickTestConfig.length);
setQtFormat(quickTestConfig.format);
}, [quickTestConfig]);
const handleApplyReaderSettings = () => {
onSetParameters({
address,
baudRate,
power,
minFreq,
maxFreq,
band
});
};
const handleSaveLocalSettings = () => {
onSaveQuickTestConfig({
length: qtLength,
format: qtFormat
});
};
const baudRates = [9600, 19200, 38400, 57600, 115200];
const powerLevels = Array.from({ length: 31 }, (_, i) => i); // 0-30 dBm
const isLoaded = !!readerInfo;
return (
<div className="bg-white p-8 rounded-xl shadow-sm border border-slate-200 h-full overflow-auto flex flex-col">
{/* Header */}
<div className="mb-8 pb-6 border-b border-slate-100">
<h2 className="text-2xl font-bold text-slate-800">System Configuration</h2>
<p className="text-slate-500 mt-1">
Manage device parameters and local browser settings.
</p>
</div>
{/* 1. Quick Test Settings Card */}
<div className="mb-10">
<div className="flex items-center gap-2 mb-4">
<Settings2 className="w-5 h-5 text-indigo-600" />
<h3 className="text-lg font-bold text-slate-800">Quick Test Settings (Local)</h3>
</div>
<div className="bg-indigo-50/50 p-6 rounded-xl border border-indigo-100">
<p className="text-sm text-indigo-900 mb-6 border-l-4 border-indigo-400 pl-3">
These settings are stored in your browser's local storage and control the behavior of the "Quick Test" tab.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-6">
<div className="space-y-3">
<label className="text-sm font-medium text-slate-600">Read/Write Length (Words)</label>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer bg-white px-4 py-2 rounded-lg border border-indigo-100 shadow-sm">
<input
type="radio"
checked={qtLength === 3}
onChange={() => setQtLength(3)}
className="w-4 h-4 text-indigo-600 focus:ring-indigo-500 border-gray-300"
/>
<span className="text-sm text-slate-700">3 Words (6 Bytes)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer bg-white px-4 py-2 rounded-lg border border-indigo-100 shadow-sm">
<input
type="radio"
checked={qtLength === 4}
onChange={() => setQtLength(4)}
className="w-4 h-4 text-indigo-600 focus:ring-indigo-500 border-gray-300"
/>
<span className="text-sm text-slate-700">4 Words (8 Bytes)</span>
</label>
</div>
</div>
<div className="space-y-3">
<label className="text-sm font-medium text-slate-600">Data Display Format</label>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer bg-white px-4 py-2 rounded-lg border border-indigo-100 shadow-sm">
<input
type="radio"
checked={qtFormat === 'hex'}
onChange={() => setQtFormat('hex')}
className="w-4 h-4 text-indigo-600 focus:ring-indigo-500 border-gray-300"
/>
<span className="text-sm text-slate-700">HEX</span>
</label>
<label className="flex items-center gap-2 cursor-pointer bg-white px-4 py-2 rounded-lg border border-indigo-100 shadow-sm">
<input
type="radio"
checked={qtFormat === 'ascii'}
onChange={() => setQtFormat('ascii')}
className="w-4 h-4 text-indigo-600 focus:ring-indigo-500 border-gray-300"
/>
<span className="text-sm text-slate-700">ASCII</span>
</label>
</div>
</div>
</div>
<button
onClick={handleSaveLocalSettings}
className="flex items-center justify-center gap-2 px-5 py-2.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 shadow-sm hover:shadow transition-colors font-medium"
title="Save these settings to browser"
>
<Save className="w-4 h-4" />
Save Local Settings
</button>
</div>
</div>
<hr className="border-slate-100 mb-10" />
{/* 2. Reader Configuration */}
<div className="flex-1">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-2">
<HardDrive className="w-5 h-5 text-blue-600" />
<h3 className="text-lg font-bold text-slate-800">Reader Configuration (Device)</h3>
</div>
{/* Device Actions */}
<div className="flex flex-wrap items-center gap-3">
<button
onClick={onGetInfo}
className="flex items-center justify-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-200 hover:text-slate-900 transition-all font-medium"
>
<RefreshCw className={`w-4 h-4 ${!isLoaded ? 'animate-pulse' : ''}`} />
Read Config
</button>
<button
onClick={handleApplyReaderSettings}
className="flex items-center justify-center gap-2 px-5 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 shadow-sm hover:shadow transition-colors font-medium"
>
<Save className="w-4 h-4" />
Apply Config
</button>
<button
onClick={onFactoryReset}
className="flex items-center justify-center gap-2 px-4 py-2 bg-white border border-red-200 text-red-600 rounded-lg hover:bg-red-50 hover:text-red-700 hover:border-red-300 transition-colors font-medium"
>
<RotateCcw className="w-4 h-4" />
Reset
</button>
</div>
</div>
{/* Status Info */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="bg-slate-50 border border-slate-100 rounded-lg p-4 flex items-center gap-3">
<div className="p-2 bg-white rounded-md shadow-sm text-blue-600"><Cpu className="w-5 h-5" /></div>
<div>
<div className="text-xs text-slate-400 font-semibold uppercase">Model</div>
<div className="text-sm font-bold text-slate-700">{readerInfo?.model || '--'}</div>
</div>
</div>
<div className="bg-slate-50 border border-slate-100 rounded-lg p-4 flex items-center gap-3">
<div className="p-2 bg-white rounded-md shadow-sm text-emerald-600"><Activity className="w-5 h-5" /></div>
<div>
<div className="text-xs text-slate-400 font-semibold uppercase">Firmware</div>
<div className="text-sm font-bold text-slate-700">{readerInfo?.version || '--'}</div>
</div>
</div>
<div className="bg-slate-50 border border-slate-100 rounded-lg p-4 flex items-center gap-3">
<div className="p-2 bg-white rounded-md shadow-sm text-violet-600"><Radio className="w-5 h-5" /></div>
<div>
<div className="text-xs text-slate-400 font-semibold uppercase">Protocol</div>
<div className="text-sm font-bold text-slate-700">{readerInfo?.protocol || 'EPCC1-G2'}</div>
</div>
</div>
<div className="bg-slate-50 border border-slate-100 rounded-lg p-4 flex items-center gap-3">
<div className="p-2 bg-white rounded-md shadow-sm text-amber-600"><Zap className="w-5 h-5" /></div>
<div>
<div className="text-xs text-slate-400 font-semibold uppercase">Power</div>
<div className="text-sm font-bold text-slate-700">{readerInfo ? `${readerInfo.power} dBm` : '--'}</div>
</div>
</div>
</div>
{/* Device Settings Form */}
<div className={`space-y-8 transition-all duration-300 ${isLoaded ? 'opacity-100' : 'opacity-50 pointer-events-none grayscale'}`}>
{/* 1. Communication Settings */}
<div>
<h3 className="text-sm font-bold text-slate-900 uppercase tracking-wider mb-4 flex items-center gap-2">
<span className="w-1 h-4 bg-blue-500 rounded-full"></span>
Communication Parameters
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-1">
<label className="text-sm font-medium text-slate-600">Reader Address (Hex)</label>
<input
type="text"
value={address.toString(16).toUpperCase()}
onChange={e => {
const val = parseInt(e.target.value, 16);
if (!isNaN(val) && val >= 0 && val <= 0xFF) setAddress(val);
}}
className="w-full bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all"
maxLength={2}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-slate-600">Baud Rate</label>
<select
value={baudRate}
onChange={e => setBaudRate(Number(e.target.value))}
className="w-full bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all"
>
{baudRates.map(b => <option key={b} value={b}>{b} bps</option>)}
</select>
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-slate-600">Max Response Time</label>
<select
value={maxResponseTime}
onChange={e => setMaxResponseTime(Number(e.target.value))}
className="w-full bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all"
>
<option value={0}>Auto</option>
<option value={10}>10ms</option>
<option value={20}>20ms</option>
</select>
</div>
</div>
</div>
{/* 2. RF Settings */}
<div>
<h3 className="text-sm font-bold text-slate-900 uppercase tracking-wider mb-4 flex items-center gap-2">
<span className="w-1 h-4 bg-emerald-500 rounded-full"></span>
RF Configuration
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div className="space-y-1">
<label className="text-sm font-medium text-slate-600">Power Output</label>
<select
value={power}
onChange={e => setPower(Number(e.target.value))}
className="w-full bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all"
>
{powerLevels.map(p => <option key={p} value={p}>{p} dBm</option>)}
</select>
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-slate-600">Min Frequency</label>
<select
value={minFreq}
onChange={e => setMinFreq(Number(e.target.value))}
disabled={isSingleFreq}
className="w-full bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all disabled:bg-slate-50 disabled:text-slate-400"
>
<option value={0}>Default</option>
<option value={1}>Custom 1</option>
</select>
</div>
<div className="space-y-1">
<div className="flex justify-between items-center">
<label className="text-sm font-medium text-slate-600">Max Frequency</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={isSingleFreq}
onChange={e => setIsSingleFreq(e.target.checked)}
className="w-3.5 h-3.5 rounded text-blue-600 focus:ring-blue-500 border-slate-300"
/>
<span className="text-xs text-slate-500">Single Freq</span>
</label>
</div>
<select
value={maxFreq}
onChange={e => setMaxFreq(Number(e.target.value))}
disabled={isSingleFreq}
className="w-full bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all disabled:bg-slate-50 disabled:text-slate-400"
>
<option value={0}>Default</option>
<option value={1}>Custom 1</option>
</select>
</div>
</div>
<div className="bg-slate-50/80 rounded-lg p-5 border border-slate-200">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wide mb-3 block">Frequency Region / Band</label>
<div className="flex flex-wrap gap-x-8 gap-y-4">
{[
{ label: 'User Defined', value: FrequencyBand.USER },
{ label: 'Chinese Band', value: FrequencyBand.CHINESE },
{ label: 'US Band', value: FrequencyBand.US },
{ label: 'Korean Band', value: FrequencyBand.KOREAN },
{ label: 'EU Band', value: FrequencyBand.EU },
].map((item) => (
<label key={item.value} className="flex items-center gap-2 cursor-pointer group">
<div className={`w-4 h-4 rounded-full border flex items-center justify-center transition-colors ${band === item.value ? 'border-blue-600 bg-blue-600' : 'border-slate-300 bg-white group-hover:border-blue-400'}`}>
{band === item.value && <div className="w-1.5 h-1.5 rounded-full bg-white" />}
</div>
<input
type="radio"
name="band"
checked={band === item.value}
onChange={() => setBand(item.value)}
className="hidden"
/>
<span className={`text-sm ${band === item.value ? 'text-slate-900 font-medium' : 'text-slate-600'}`}>{item.label}</span>
</label>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
};

33
index.html Normal file
View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UHF RFID Web Controller</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
/* Custom scrollbar for tables */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #f1f1f1; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
</style>
<script type="importmap">
{
"imports": {
"react-dom/": "https://esm.sh/react-dom@^19.2.3/",
"react/": "https://esm.sh/react@^19.2.3/",
"react": "https://esm.sh/react@^19.2.3",
"lucide-react": "https://esm.sh/lucide-react@^0.562.0"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-slate-50 text-slate-900 h-screen overflow-hidden">
<div id="root" class="h-full"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

15
index.tsx Normal file
View File

@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
);

7
metadata.json Normal file
View File

@@ -0,0 +1,7 @@
{
"name": "UHF RFID Web Controller",
"description": "A web-based controller for RRU9809 UHF RFID Readers using Web Serial API. Ports functionality from legacy WinForms software to a modern React interface.",
"requestFramePermissions": [
"serial"
]
}

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "uhf-rfid-web-controller",
"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",
"lucide-react": "^0.562.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

73
services/rfidService.ts Normal file
View File

@@ -0,0 +1,73 @@
import { ReaderCommand } from '../types';
import { calculateCRC16, getCRCBytes } from '../utils/crc16';
export class RfidProtocol {
/**
* Constructs a command frame: [Len, Adr, Cmd, Data..., CRC_LSB, CRC_MSB]
*/
static buildCommand(address: number, cmd: ReaderCommand, data: number[] = []): Uint8Array {
const len = data.length + 4; // Len byte itself is not included in length value logic usually, but manual says: "Len equals length of Data[] plus 4"
// Frame except CRC
const frameHeader = [len, address, cmd, ...data];
// Calculate CRC on [Len, Adr, Cmd, Data...]
const crc = calculateCRC16(new Uint8Array(frameHeader));
const [lsb, msb] = getCRCBytes(crc);
return new Uint8Array([...frameHeader, lsb, msb]);
}
/**
* Helper to interpret status codes from the reader
*/
static getStatusDescription(status: number): string {
switch (status) {
case 0x00: return "Success";
case 0x01: return "Return before Inventory finished";
case 0x02: return "Inventory Timeout";
case 0x03: return "More data follows";
case 0x04: return "Reader memory full";
case 0x05: return "Access Password Error";
case 0x09: return "Kill Tag Error";
case 0x0A: return "Kill Password cannot be zero";
case 0x0B: return "Tag does not support command";
case 0x0C: return "Access Password cannot be zero (Locked)";
case 0x0D: return "Tag is protected, cannot set again";
case 0x0E: return "Tag is unprotected, no need to reset";
case 0x10: return "Locked bytes, write fail";
case 0x11: return "Cannot lock tag";
case 0x12: return "Already locked, cannot lock again";
case 0x13: return "Save Parameter Failed";
case 0x14: return "Cannot adjust power";
case 0x15: return "Return before Inventory finished (6B)";
case 0x16: return "Inventory Timeout (6B)";
case 0x17: return "More data follows (6B)";
case 0x18: return "Reader memory full (6B)";
case 0x19: return "Not Support Command or Access Password Error";
case 0xF9: return "Command Execution Error";
case 0xFA: return "Poor Communication / Tag Inoperable";
case 0xFB: return "No Tag Operable";
case 0xFC: return "Tag Returned Error Code"; // Check data for specific code
case 0xFD: return "Command Length Wrong";
case 0xFE: return "Illegal Command / CRC Error";
case 0xFF: return "Parameter Error";
case 0x30: return "Communication Error";
default: return `Unknown Status (0x${status.toString(16).toUpperCase().padStart(2, '0')})`;
}
}
/**
* Helper to interpret EPC C1G2 Tag specific error codes (Status 0xFC)
*/
static getTagErrorDescription(errorCode: number): string {
switch (errorCode) {
case 0x00: return "Other error";
case 0x03: return "Memory overrun or EPC length not supported";
case 0x04: return "Memory locked";
case 0x0B: return "Insufficient power";
case 0x0F: return "Non-specific error";
default: return `Unknown Tag Error (0x${errorCode.toString(16).toUpperCase().padStart(2, '0')})`;
}
}
}

29
tsconfig.json Normal file
View File

@@ -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
}
}

88
types.ts Normal file
View File

@@ -0,0 +1,88 @@
export enum ConnectionStatus {
DISCONNECTED = 'Disconnected',
CONNECTING = 'Connecting',
CONNECTED = 'Connected',
ERROR = 'Error'
}
export enum MemoryBank {
RESERVED = 0x00,
EPC = 0x01,
TID = 0x02,
USER = 0x03
}
export interface SerialPortConfig {
baudRate: number;
}
export interface TagData {
epc: string;
tid?: string; // Added TID field
count: number;
rssi?: number;
timestamp: number;
}
export interface ReaderInfo {
model: string;
version: string;
address: number;
power: number;
minFreq: number;
maxFreq: number;
protocol: 'ISO18000-6B' | 'EPCC1-G2' | 'Both' | 'None';
maxResponseTime: number;
band: FrequencyBand;
}
export interface LogEntry {
id: string;
timestamp: Date;
type: 'TX' | 'RX' | 'INFO' | 'ERROR' | 'WARN';
message: string;
data?: string;
}
export interface QuickTestConfig {
length: 3 | 4;
format: 'hex' | 'ascii';
}
// Command Codes from Manual
export enum ReaderCommand {
GET_INFO = 0x21,
SET_REGION = 0x22,
SET_ADDRESS = 0x24,
SET_SCAN_TIME = 0x25,
SET_BAUD_RATE = 0x28,
SET_POWER = 0x2F,
INVENTORY_G2 = 0x01,
READ_DATA_G2 = 0x02,
WRITE_DATA_G2 = 0x03,
WRITE_EPC_G2 = 0x04,
KILL_TAG_G2 = 0x05,
LOCK_G2 = 0x06,
ERASE_G2 = 0x07,
SET_ACCESS_EPC_MATCH = 0x85, // Added for targeting specific tags
EAS_ALARM_G2 = 0x0C,
CHECK_EAS_ALARM = 0x0D,
LOCK_USER_BLOCK = 0x0E
}
export enum FrequencyBand {
USER = 0,
CHINESE = 1,
US = 2,
KOREAN = 3,
EU = 4
}
// Web Serial API Type Definition
export interface SerialPort {
readable: ReadableStream<Uint8Array> | null;
writable: WritableStream<Uint8Array> | null;
open(options: { baudRate: number }): Promise<void>;
close(): Promise<void>;
}

89
utils/crc16.ts Normal file
View File

@@ -0,0 +1,89 @@
/**
* Calculates CRC16 according to the UHF Reader Manual.
* Polynomial: 0x8408
* Preset: 0xFFFF
*/
export const calculateCRC16 = (data: Uint8Array): number => {
const PRESET_VALUE = 0xFFFF;
const POLYNOMIAL = 0x8408;
let uiCrcValue = PRESET_VALUE;
for (let i = 0; i < data.length; i++) {
uiCrcValue = uiCrcValue ^ data[i];
for (let j = 0; j < 8; j++) {
if ((uiCrcValue & 0x0001) !== 0) {
uiCrcValue = (uiCrcValue >> 1) ^ POLYNOMIAL;
} else {
uiCrcValue = (uiCrcValue >> 1);
}
}
}
return uiCrcValue;
};
/**
* Splits a 16-bit CRC into LSB and MSB bytes
*/
export const getCRCBytes = (crc: number): [number, number] => {
const lsb = crc & 0xFF;
const msb = (crc >> 8) & 0xFF;
return [lsb, msb];
};
/**
* Converts a hex string (e.g., "A1 B2") to Uint8Array
*/
export const hexStringToBytes = (hex: string): Uint8Array | null => {
const cleanHex = hex.replace(/[\s-]/g, '');
if (cleanHex.length % 2 !== 0 || !/^[0-9A-Fa-f]+$/.test(cleanHex)) {
return null;
}
const bytes = new Uint8Array(cleanHex.length / 2);
for (let i = 0; i < cleanHex.length; i += 2) {
bytes[i / 2] = parseInt(cleanHex.substring(i, i + 2), 16);
}
return bytes;
};
/**
* Converts bytes to Hex String
*/
export const bytesToHexString = (bytes: Uint8Array | number[]): string => {
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
.join(' ');
};
/**
* Converts a hex string (with spaces) to ASCII string
*/
export const hexToAscii = (hex: string): string => {
if (!hex) return "";
const cleanHex = hex.replace(/[\s-]/g, '');
let str = "";
for (let i = 0; i < cleanHex.length; i += 2) {
const charCode = parseInt(cleanHex.substring(i, i + 2), 16);
// Replace non-printable characters with dots
if (charCode >= 32 && charCode <= 126) {
str += String.fromCharCode(charCode);
} else {
str += ".";
}
}
return str;
};
/**
* Converts an ASCII string to Hex string
*/
export const asciiToHex = (ascii: string): string => {
let hex = "";
for (let i = 0; i < ascii.length; i++) {
const code = ascii.charCodeAt(i);
hex += code.toString(16).padStart(2, "0").toUpperCase();
}
return hex;
};

23
vite.config.ts Normal file
View File

@@ -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, '.'),
}
}
};
});