initial commit
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
769
App.tsx
Normal 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
20
README.md
Normal 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
BIN
assets/protocol.pdf
Normal file
Binary file not shown.
121
components/ConnectionPanel.tsx
Normal file
121
components/ConnectionPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
179
components/InventoryPanel.tsx
Normal file
179
components/InventoryPanel.tsx
Normal 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
307
components/MemoryPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
127
components/QuickTestPanel.tsx
Normal file
127
components/QuickTestPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
371
components/SettingsPanel.tsx
Normal file
371
components/SettingsPanel.tsx
Normal 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
33
index.html
Normal 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
15
index.tsx
Normal 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
7
metadata.json
Normal 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
22
package.json
Normal 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
73
services/rfidService.ts
Normal 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
29
tsconfig.json
Normal 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
88
types.ts
Normal 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
89
utils/crc16.ts
Normal 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
23
vite.config.ts
Normal 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, '.'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user