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..703018e --- /dev/null +++ b/App.tsx @@ -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(null); + const [status, setStatus] = useState(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([]); + const [tags, setTags] = useState([]); + const [isScanning, setIsScanning] = useState(false); + const [readerInfo, setReaderInfo] = useState(null); + const [readResult, setReadResult] = useState(null); + + // Log Visibility State + const [isLogExpanded, setIsLogExpanded] = useState(false); + + // Quick Action Config (Persisted in localStorage) + const [quickTestConfig, setQuickTestConfig] = useState(() => { + 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(""); // Actual Hex data to write (State for UI consistency) + const [quickWriteInput, setQuickWriteInput] = useState(""); // UI Input state + + // References for streams and state tracking + const readerRef = useRef | null>(null); + const writerRef = useRef | null>(null); + const scanIntervalRef = useRef(null); + const isConnectedRef = useRef(false); + + // Track if a Quick Write is in progress to trigger auto-read verification + const performingQuickWriteRef = useRef(false); + const quickWriteDataRef = useRef(""); // Ref to hold write data for verification inside async loop + const verificationRetriesRef = useRef(0); // Track retries + + // Track which EPC is currently being queried for TID + const currentTidReadEpc = useRef(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 ( +
+ {/* Sidebar */} +
+
+

+ + UHF Reader +

+

Web Serial Controller

+
+ +
+ + + + +
+ +
+ +
+
+ + {/* Main Content */} +
+
+ {activeTab === 'inventory' && ( + + )} + {activeTab === 'quicktest' && ( + + )} + {activeTab === 'memory' && ( + 0 ? tags[0].epc : ""} + readResult={readResult} + /> + )} + {activeTab === 'settings' && ( + + )} +
+ + {/* Log Terminal */} +
+
setIsLogExpanded(!isLogExpanded)} + > +
+ System Logs +
+
+ {isLogExpanded ? : } +
+
+ +
+ {logs.length === 0 && No logs yet...} + {logs.map(log => ( +
+ [{log.timestamp.toLocaleTimeString()}] + + {log.type} + + {log.message} + {log.data && {log.data}} +
+ ))} +
+
+
+
+ ); +}; + +export default App; diff --git a/README.md b/README.md new file mode 100644 index 0000000..a72977c --- /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/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` diff --git a/assets/protocol.pdf b/assets/protocol.pdf new file mode 100644 index 0000000..33e4c5c Binary files /dev/null and b/assets/protocol.pdf differ diff --git a/components/ConnectionPanel.tsx b/components/ConnectionPanel.tsx new file mode 100644 index 0000000..2bb3ccf --- /dev/null +++ b/components/ConnectionPanel.tsx @@ -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 = ({ + 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) => { + 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 ( +
+
+ {isConnected ? : } +

Connection

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + 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" + /> +
+ + + +
+ {status} +
+
+
+ ); +}; \ No newline at end of file diff --git a/components/InventoryPanel.tsx b/components/InventoryPanel.tsx new file mode 100644 index 0000000..aee560e --- /dev/null +++ b/components/InventoryPanel.tsx @@ -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 = ({ + 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 ( +
+ + {/* Main Inventory Table Card */} +
+ {/* Header / Controls */} +
+
+
+

EPC C1G2 Inventory

+ {tags.length} Unique Tags Found +
+ + {/* View Mode Switcher */} +
+ + +
+
+ +
+ {viewMode === 'tid' && ( + + )} + + + +
+ + + + +
+
+ + {/* Table Area */} +
+ + + + + + {viewMode === 'tid' && } + + + + + + {tags.length === 0 ? ( + + + + ) : ( + tags.map((tag, index) => ( + + + + {/* Main ID Column */} + + + {/* EPC Reference Column (Only in TID view) */} + {viewMode === 'tid' && ( + + )} + + {/* Length Column */} + + + + + )) + )} + +
# + {viewMode === 'epc' ? 'EPC ID' : 'TID (Tag Identifier)'} + EPC ReferenceLength (Bits)Count
+ No tags scanned yet. Press "Start Scan" to begin. +
{index + 1} + {viewMode === 'epc' ? ( + tag.epc + ) : ( + tag.tid ? ( + {tag.tid} + ) : ( + Not Read (Click "Read TIDs") + ) + )} + + {tag.epc} + + {viewMode === 'epc' + ? tag.epc.replace(/\s/g, '').length * 4 + : (tag.tid ? tag.tid.replace(/\s/g, '').length * 4 : '-') + } + + + {tag.count} + +
+
+
+
+ ); +}; diff --git a/components/MemoryPanel.tsx b/components/MemoryPanel.tsx new file mode 100644 index 0000000..07ed675 --- /dev/null +++ b/components/MemoryPanel.tsx @@ -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; + onWrite: (bank: MemoryBank, ptr: number, data: string, password: string, targetEpc: string) => Promise; + onWriteEpc: (newEpc: string, password: string) => Promise; + selectedEpc: string; // From parent (legacy), we will use local state mainly + readResult: string | null; +} + +export const MemoryPanel: React.FC = ({ tags, onScan, isScanning, onRead, onWrite, onWriteEpc, selectedEpc: initialEpc, readResult }) => { + const [targetEpc, setTargetEpc] = useState(initialEpc); + const [bank, setBank] = useState(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 ( +
+
+

+ + Memory Operations +

+ + {/* Unified Mode Toggle */} +
+ + +
+
+ +
+ {/* Configuration Column */} +
+ + {/* Tag Selection Area */} +
+ +
+
+ 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 && ( +
+ +
+ )} + {/* Invisible Select overlay for dropdown behavior */} + {tags.length > 0 && ( + + )} +
+ + +
+

+ {tags.length > 0 + ? `${tags.length} tags found. Select one to target.` + : "Click Scan to find nearby tags."} +

+
+ +
+ +
+ 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" + /> + +
+
+ +
+
+ + +
+
+
+ + setPtr(Number(e.target.value))} + className="w-full bg-white border border-slate-300 text-sm rounded-lg p-2.5" + /> +
+
+ + setLen(Number(e.target.value))} + className="w-full bg-white border border-slate-300 text-sm rounded-lg p-2.5" + /> +
+
+
+ + {bank === MemoryBank.EPC && ( +
+ Note: Start Address 0 is CRC, 1 is PC. Writing to these may fail (Error 0xFF). EPC data typically starts at address 2. +
+ )} +
+ + {/* Action Column */} +
+ {/* Read Section */} +
+ + +
+ {readResult + ? (dataMode === 'hex' ? readResult : hexToAscii(readResult)) + : "No data read..." + } +
+ + +
+ + {/* Write Section */} +
+ + + 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" + /> +
+ + + {bank === MemoryBank.EPC && ( + + )} +
+
+
+
+
+ ); +}; diff --git a/components/QuickTestPanel.tsx b/components/QuickTestPanel.tsx new file mode 100644 index 0000000..c21d1f5 --- /dev/null +++ b/components/QuickTestPanel.tsx @@ -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 = ({ + onQuickRead, + onQuickWrite, + onCancel, + writeInput, + onWriteInputChange, + result, + isPending, + isScanning, + config +}) => { + + return ( +
+
+
+ +
+

Quick Test Mode

+

+ Automatically detects the first tag in the field and performs a Read or Write operation.
+ Target: EPC Bank, Start Address: 2
+ Length: {config.length} Words, Format: {config.format.toUpperCase()} +

+
+ +
+ + {/* Read Card */} +
+
+ +
+

Auto Read

+ +
+ {isPending ? ( +
+ Scanning & Reading... +
+ ) : ( + result ? ( + {result} + ) : ( + No Data Read + ) + )} +
+ + {isPending ? ( + + ) : ( + + )} +
+ + {/* Write Card */} +
+
+ +
+

Auto Write

+ + 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 ? ( + + ) : ( + + )} +
+ +
+ + {isScanning && !isPending && ( +
+ Background scanning is active in Inventory... +
+ )} +
+ ); +}; diff --git a/components/SettingsPanel.tsx b/components/SettingsPanel.tsx new file mode 100644 index 0000000..89b5fe2 --- /dev/null +++ b/components/SettingsPanel.tsx @@ -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 = ({ + 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.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 ( +
+ + {/* Header */} +
+

System Configuration

+

+ Manage device parameters and local browser settings. +

+
+ + {/* 1. Quick Test Settings Card */} +
+
+ +

Quick Test Settings (Local)

+
+ +
+

+ These settings are stored in your browser's local storage and control the behavior of the "Quick Test" tab. +

+
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+ + +
+
+ +
+ + {/* 2. Reader Configuration */} +
+
+
+ +

Reader Configuration (Device)

+
+ + {/* Device Actions */} +
+ + + + + +
+
+ + {/* Status Info */} +
+
+
+
+
Model
+
{readerInfo?.model || '--'}
+
+
+
+
+
+
Firmware
+
{readerInfo?.version || '--'}
+
+
+
+
+
+
Protocol
+
{readerInfo?.protocol || 'EPCC1-G2'}
+
+
+
+
+
+
Power
+
{readerInfo ? `${readerInfo.power} dBm` : '--'}
+
+
+
+ + {/* Device Settings Form */} +
+ {/* 1. Communication Settings */} +
+

+ + Communication Parameters +

+
+
+ + { + 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} + /> +
+
+ + +
+
+ + +
+
+
+ + {/* 2. RF Settings */} +
+

+ + RF Configuration +

+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ +
+ +
+ {[ + { 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) => ( +
+
+
+
+
+ ); +}; diff --git a/index.html b/index.html new file mode 100644 index 0000000..56d2a77 --- /dev/null +++ b/index.html @@ -0,0 +1,33 @@ + + + + + + UHF RFID Web Controller + + + + + + + +
+ + + \ 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..a548c3c --- /dev/null +++ b/metadata.json @@ -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" + ] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..b767b6a --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/services/rfidService.ts b/services/rfidService.ts new file mode 100644 index 0000000..ae4a0e9 --- /dev/null +++ b/services/rfidService.ts @@ -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')})`; + } + } +} \ 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..6692e5a --- /dev/null +++ b/types.ts @@ -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 | null; + writable: WritableStream | null; + open(options: { baudRate: number }): Promise; + close(): Promise; +} \ No newline at end of file diff --git a/utils/crc16.ts b/utils/crc16.ts new file mode 100644 index 0000000..24d337d --- /dev/null +++ b/utils/crc16.ts @@ -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; +}; 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, '.'), + } + } + }; +});