// script.js const VIDEO = document.getElementById('webcam'); const CONNECT_SERIAL_BTN = document.getElementById('connectSerialBtn'); const DISCONNECT_SERIAL_BTN = document.getElementById('disconnectSerialBtn'); const LOAD_MODEL_BTN = document.getElementById('loadModelBtn'); const START_WEBCAM_BTN = document.getElementById('startWebcamBtn'); const STOP_WEBCAM_BTN = document.getElementById('stopWebcamBtn'); const MODEL_STATUS = document.getElementById('modelStatus'); const SERIAL_STATUS = document.getElementById('serialStatus'); const PREDICTION_OUTPUT = document.getElementById('prediction'); let mobilenet; // 这个变量将存储加载后的 MobileNet 模型实例 let knnClassifier; // 这个变量将存储 KNN 分类器实例 let classNames = []; // 类别名称将从加载的模型中获取 let webcamStream = null; let isPredicting = false; // Web Serial API 变量 let serialPort = null; let serialWriter = null; const SERIAL_BAUD_RATE = 9600; const SERIAL_SEND_MIN_INTERVAL = 500; // 最短发送间隔,500ms,用于内部节流 let lastSerialCommand = ''; // 用于sendToSerialPort内部的节流(避免重复命令+短间隔) let lastSerialSendTime = 0; // =================================== // 新增变量:追踪串口连接状态,并记录上一次发送到串口的类别命令 let isSerialConnectedState = false; // 跟踪串口的逻辑连接状态 let lastSentClassCommand = null; // 上一次"成功通过确认机制并实际发送"的命令 // =================================== // =================================== // 新增变量:用于实现“确认式”发送逻辑 let pendingCommandToSend = null; // 正在等待确认的命令 let pendingCommandTimerId = null; // 确认定时器ID const CONFIRMATION_DELAY_MS = 100; // 等待 100 毫秒确认 class 是否稳定 // =================================== // =================================== // Helper Functions (UI Status) // =================================== function showStatus(element, type, message) { element.className = `status-message status-${type}`; element.textContent = message; } function updateSerialUI(isConnected) { CONNECT_SERIAL_BTN.disabled = isConnected; DISCONNECT_SERIAL_BTN.disabled = !isConnected; isSerialConnectedState = isConnected; // 更新我们维护的串口连接状态 if (!isConnected) { showStatus(SERIAL_STATUS, 'info', '串口未连接。点击 "连接串口" 开始。'); } } function updateWebcamUI(isRunning) { START_WEBCAM_BTN.disabled = isRunning; STOP_WEBCAM_BTN.disabled = !isRunning; } function updateModelUI(isLoaded) { LOAD_MODEL_BTN.disabled = false; // 总是允许重新加载模型 START_WEBCAM_BTN.disabled = !isLoaded; // 模型加载后才能启动摄像头 } // =================================== // Core Logic: Model & Webcam // =================================== async function initModel() { showStatus(MODEL_STATUS, 'info', '正在加载 MobileNet 模型...'); try { // 确保 window.mobilenet 和 window.knnClassifier 库已加载 if (!window.mobilenet || !window.knnClassifier) { showStatus(MODEL_STATUS, 'error', 'TensorFlow.js 库或 KNN 分类器库未加载。请检查 HTML 引入。'); console.error('TensorFlow.js 库或 KNN 分类器库未加载。'); return; } mobilenet = await window.mobilenet.load({ version: 2, alpha: 1.0 }); knnClassifier = window.knnClassifier.create(); showStatus(MODEL_STATUS, 'success', 'MobileNet 模型和 KNN 分类器已加载。'); updateModelUI(false); // MobileNet 准备好,但KNN数据还未加载 // ===== 新增:尝试自动从 CDN 加载 KNN 模型 ===== // 请替换为你的实际 CDN 模型路径 const cdnModelBaseUrl = 'https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/models/'; // 例如:'https://example.com/models/' const cdnModelJsonFileName = 'knn-model.json'; // 你的模型json文件名 const cdnModelBinFileName = 'knn-model.bin'; // 你的模型bin文件名 const cdnJsonUrl = `${cdnModelBaseUrl}${cdnModelJsonFileName}`; const cdnBinUrl = `${cdnModelBaseUrl}${cdnModelBinFileName}`; console.log(`尝试从 CDN 加载模型: ${cdnJsonUrl}, ${cdnBinUrl}`); showStatus(MODEL_STATUS, 'info', '正在尝试从 CDN 自动加载 KNN 模型...'); try { await loadKNNModel(cdnJsonUrl, cdnBinUrl); console.log('CDN 模型自动加载成功。'); // 如果成功,loadKNNModel 会更新状态 } catch (cdnError) { showStatus(MODEL_STATUS, 'warning', `从 CDN 加载 KNN 模型失败: ${cdnError.message}。您可以尝试手动加载。`); console.warn('CDN KNN 模型加载失败:', cdnError); updateModelUI(false); // 即使 CDN 失败,用户仍然可以通过按钮加载 } // ============================================== } catch (error) { showStatus(MODEL_STATUS, 'error', `模型加载失败: ${error.message}`); console.error('MobileNet/KNN加载失败:', error); } } // 提取 MobileNet 特征 async function getFeatures(img) { if (!mobilenet) { throw new Error("MobileNet model is not loaded."); } return tf.tidy(() => { const embeddings = mobilenet.infer(img, true); const norm = tf.norm(embeddings); const normalized = tf.div(embeddings, norm); return normalized; }); } /** * 加载 KNN 模型文件,支持 '.json' 和 '.bin' 两部分文件。 * 支持从 URL 或用户选择的文件加载。 * @param {string} [jsonUrl] - 可选,KNN 模型 .json 文件的 URL。 * @param {string} [binUrl] - 可选,KNN 模型 .bin 文件的 URL。 */ async function loadKNNModel(jsonUrl = null, binUrl = null) { if (!knnClassifier) { showStatus(MODEL_STATUS, 'error', 'KNN 分类器未初始化。请先加载 MobileNet 模型。'); return; } let modelData = null; let binData = null; let modelName = '未知模型'; try { if (jsonUrl && binUrl) { // 从 CDN URL 加载 showStatus(MODEL_STATUS, 'info', `正在从 CDN 加载模型配置文件 (${jsonUrl})...`); const jsonResponse = await fetch(jsonUrl); if (!jsonResponse.ok) { throw new Error(`无法从 ${jsonUrl} 加载.json文件: ${jsonResponse.statusText}`); } modelData = await jsonResponse.json(); modelName = jsonUrl.split('/').pop(); showStatus(MODEL_STATUS, 'info', `正在从 CDN 加载模型权重 (${binUrl})...`); const binResponse = await fetch(binUrl); if (!binResponse.ok) { throw new Error(`无法从 ${binUrl} 加载.bin文件: ${binResponse.statusText}`); } const arrayBuffer = await binResponse.arrayBuffer(); binData = new Float32Array(arrayBuffer); // 验证 bin 文件名是否匹配(如果 json 中有定义) if (modelData.dataFile && !binUrl.endsWith(modelData.dataFile)) { console.warn(`CDN 加载警告:.bin URL (${binUrl}) 与 .json 中定义的 dataFile (${modelData.dataFile}) 不匹配。继续加载。`); } } else { // 从用户本地文件加载 (原逻辑不变) const inputJson = document.createElement('input'); inputJson.type = 'file'; inputJson.accept = '.json'; inputJson.multiple = false; showStatus(MODEL_STATUS, 'info', '请先选择 KNN 模型配置文件 (.json)...'); await new Promise((resolve, reject) => { inputJson.onchange = async (e) => { const jsonFile = e.target.files[0]; if (!jsonFile) { showStatus(MODEL_STATUS, 'info', '未选择 .json 文件。'); updateModelUI(false); return reject(new Error('No JSON file selected.')); } showStatus(MODEL_STATUS, 'info', `正在解析 ${jsonFile.name}...`); modelName = jsonFile.name; try { const reader = new FileReader(); const jsonText = await new Promise((res, rej) => { reader.onload = () => res(reader.result); reader.onerror = () => rej(reader.error); reader.readAsText(jsonFile); }); modelData = JSON.parse(jsonText); if (!modelData.dataFile) { console.warn('模型JSON文件不包含 "dataFile" 字段,尝试以旧的单文件JSON格式加载。'); // 对于旧的单文件模型,直接加载并结束 await loadSingleJsonModel(modelData); return resolve(); // 成功加载旧模型,返回 } } catch (error) { showStatus(MODEL_STATUS, 'error', `解析 .json 文件失败: ${error.message}`); console.error('解析 .json 失败:', error); updateModelUI(false); return reject(error); } const inputBin = document.createElement('input'); inputBin.type = 'file'; inputBin.accept = '.bin'; inputBin.multiple = false; showStatus(MODEL_STATUS, 'info', `已加载 .json 文件。请选择对应的权重文件 "${modelData.dataFile}" (.bin)...`); inputBin.onchange = async (eBin) => { const binFile = eBin.target.files[0]; if (!binFile) { showStatus(MODEL_STATUS, 'info', '未选择 .bin 文件。'); updateModelUI(false); return reject(new Error('No BIN file selected.')); } if (binFile.name !== modelData.dataFile) { showStatus(MODEL_STATUS, 'error', `选择的 .bin 文件名 "${binFile.name}" 与 .json 中定义的 "${modelData.dataFile}" 不匹配!请选择正确的文件。`); updateModelUI(false); return reject(new Error('BIN file name mismatch.')); } showStatus(MODEL_STATUS, 'info', `正在读取 ${binFile.name} (二进制权重文件)...`); try { const reader = new FileReader(); const arrayBuffer = await new Promise((res, rej) => { reader.onload = () => res(reader.result); reader.onerror = () => rej(reader.error); reader.readAsArrayBuffer(binFile); }); binData = new Float32Array(arrayBuffer); resolve(); // 成功获取到 binData,解析流程继续 } catch (error) { showStatus(MODEL_STATUS, 'error', `读取 .bin 文件失败: ${error.message}`); console.error('读取 .bin 失败:', error); updateModelUI(false); return reject(error); } }; inputBin.click(); }; inputJson.click(); }); // 结束 Promise 包装的回调 } // 如果 modelData 为 null (意味着旧的单文件JSON模型已在上面被处理并返回),则停止后续处理 if (!modelData) { return; } // 执行加载 KNN 分类器的核心逻辑 if (modelData && binData) { // 仅当同时有 modelData 和 binData 时才尝试加载 knnClassifier.clearAllClasses(); Object.keys(modelData.dataset).forEach(label => { const classDataMeta = modelData.dataset[label]; const startFloat32ElementIndex = classDataMeta.start; const numFloat32Elements = classDataMeta.length; const featureDim = modelData.featureDim || 1280; // 检查 binData 是否足够大以包含所需的数据 if (startFloat32ElementIndex + numFloat32Elements > binData.length) { throw new Error(`模型数据错误: 类别 ${label} 的数据超出 .bin 文件范围。`); } const classFeatures = binData.subarray(startFloat32ElementIndex, startFloat32ElementIndex + numFloat32Elements); if (classFeatures.length === 0) { console.warn(`类别 ${label} 没有找到特征数据,跳过。`); return; } if (classFeatures.length % featureDim !== 0) { const actualSamples = classFeatures.length / featureDim; console.error( `--- 类别: ${label} ---`, `起始 Float32 元素索引: ${startFloat32ElementIndex}`, `该类别 Float32 元素数量: ${numFloat32Elements}`, `ERROR: 特征数据长度 (${classFeatures.length} 个 Float32 元素) 与特征维度 (${featureDim}) 不匹配!` + `实际样本数计算为 ${actualSamples} (预期为整数)。`, `请检查您的模型导出逻辑和训练数据的完整性。` ); throw new Error("模型数据完整性错误:特征数据长度与维度不匹配。"); } const numSamples = classFeatures.length / featureDim; for (let i = 0; i < numSamples; i++) { const startIndex = i * featureDim; const endIndex = (i + 1) * featureDim; const sampleFeatures = classFeatures.subarray(startIndex, endIndex); const sampleTensor = tf.tensor(sampleFeatures, [1, featureDim]); knnClassifier.addExample(sampleTensor, parseInt(label)); tf.dispose(sampleTensor); // 及时释放 Tensor 内存 } }); if (modelData.classList && Array.isArray(modelData.classList)) { classNames = modelData.classList.map(c => c.name); } else { console.warn('模型JSON中未找到 classList 字段或格式不正确,使用默认类别名称。'); // 如果没有 classList,尝试从 dataset 的键值来生成 classNames = Object.keys(modelData.dataset).map(key => `Class ${parseInt(key) + 1}`); } showStatus(MODEL_STATUS, 'success', `KNN 模型 "${modelName}" 加载成功!类别: ${classNames.join(', ')}。`); updateModelUI(true); // 模型已加载,可以启动摄像头 } else if (modelData && !binData && !jsonUrl) { // 如果只有 modelData 且不是从 CDN 加载,说明可能是单文件旧格式,但之前的逻辑没成功处理 // 应该是由 loadSingleJsonModel 捕获,这里作为 fallback showStatus(MODEL_STATUS, 'error', '未知模型加载状态:仅有 JSON 数据,没有 BIN 数据。'); updateModelUI(false); } } catch (error) { showStatus(MODEL_STATUS, 'error', `加载 KNN 模型失败: ${error.message}`); console.error('加载 KNN 模型总失败:', error); updateModelUI(false); // 重新抛出错误,以便 initModel 可以捕获 CDN 加载失败的情况 throw error; } } /** * 辅助函数:处理旧的单文件JSON模型格式( dataset 字段直接包含数据而不是偏移量) * @param {object} modelData - 已解析的 JSON 模型数据 * @returns {Promise} */ async function loadSingleJsonModel(modelData) { try { knnClassifier.clearAllClasses(); Object.keys(modelData.dataset).forEach(key => { const data = modelData.dataset[key]; const featureDim = modelData.featureDim || 1280; if (data.length % featureDim !== 0) { throw new Error(`类别 ${key} 的特征数据长度 ${data.length} 与特征维度 ${featureDim} 不匹配!`); } const numSamples = data.length / featureDim; const tensor = tf.tensor(data, [numSamples, featureDim]); knnClassifier.addExample(tensor, parseInt(key)); tf.dispose(tensor); // 及时释放 Tensor 内存 }); if (modelData.classList && Array.isArray(modelData.classList)) { classNames = modelData.classList.map(c => c.name); } else if (modelData.classNames && Array.isArray(modelData.classNames)) { classNames = modelData.classNames; } else { console.warn('模型JSON中未找到 classList/classNames 字段,使用默认类别名称。'); classNames = Object.keys(modelData.dataset).map(key => `Class ${parseInt(key) + 1}`); } showStatus(MODEL_STATUS, 'success', `模型 (单文件JSON格式) 加载成功!类别: ${classNames.join(', ')}。`); updateModelUI(true); } catch (error) { showStatus(MODEL_STATUS, 'error', `加载单文件JSON模型失败: ${error.message}`); console.error('加载单文件JSON模型失败:', error); updateModelUI(false); throw error; // 重新抛出错误 } } async function startWebcam() { if (webcamStream) return; if (!knnClassifier || knnClassifier.getNumClasses() === 0) { showStatus(MODEL_STATUS, 'error', '请先加载训练好的模型!'); return; } try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' }, audio: false }); VIDEO.srcObject = stream; webcamStream = stream; updateWebcamUI(true); VIDEO.onloadeddata = () => { // =================================== // 启动摄像头时,重置所有发送状态,确保第一次识别结果可发送 if (pendingCommandTimerId) { clearTimeout(pendingCommandTimerId); pendingCommandTimerId = null; } pendingCommandToSend = null; lastSentClassCommand = null; // =================================== isPredicting = true; predictLoop(); showStatus(MODEL_STATUS, 'info', '摄像头已启动,开始实时预测...'); }; } catch (error) { showStatus(MODEL_STATUS, 'error', `无法访问摄像头: ${error.message}`); console.error('启动摄像头失败:', error); updateWebcamUI(false); } } function stopWebcam() { if (webcamStream) { webcamStream.getTracks().forEach(track => track.stop()); webcamStream = null; } isPredicting = false; VIDEO.srcObject = null; updateWebcamUI(false); PREDICTION_OUTPUT.textContent = '等待识别...'; showStatus(MODEL_STATUS, 'info', '摄像头已停止。'); // =================================== // 停止摄像头时,清除任何待确认的命令,并发送“停止”或“复位”命令 if (pendingCommandTimerId) { clearTimeout(pendingCommandTimerId); pendingCommandTimerId = null; } pendingCommandToSend = null; // 假设 '0' 是停止/复位命令,并且只有当上次发送的不是 '0' 时才发送 if (lastSentClassCommand !== '0') { sendToSerialPort('0'); } lastSentClassCommand = null; // 重置,确保下次启动摄像头时能发送初始状态 // =================================== } // ============== 重要的修改区域 start ============== // 定义一个全局变量,用于存储当前帧识别到的命令,供 setTimeout 回调使用 let currentCommandInFrame = '0'; // 初始值默认是 '0' (复位/无动作) async function predictLoop() { if (!isPredicting) return; if (VIDEO.readyState === 4 && VIDEO.videoWidth > 0 && VIDEO.videoHeight > 0) { let commandCandidate = '0'; // 每次循环开始时,将当前帧的候选命令初始化为'0' try { const features = await getFeatures(VIDEO); const k = 3; if (!knnClassifier || knnClassifier.getNumClasses() === 0) { features.dispose(); PREDICTION_OUTPUT.textContent = 'KNN 分类器未就绪或无数据。'; commandCandidate = '0'; // 使用默认命令 } else { const prediction = await knnClassifier.predictClass(features, k); features.dispose(); // 及时释放 Tensor 内存 if (prediction && prediction.confidences) { let maxConfidence = 0; let predictedClassIndex = -1; const confidencesArray = Object.entries(prediction.confidences).map(([key, value]) => ({ index: parseInt(key), confidence: value })); confidencesArray.forEach(({ index, confidence }) => { if (confidence > maxConfidence) { maxConfidence = confidence; predictedClassIndex = index; } }); const confidenceThreshold = 0.75; // 75%置信度 if (predictedClassIndex !== -1 && maxConfidence > confidenceThreshold) { const className = classNames[predictedClassIndex] || `Class ${predictedClassIndex + 1}`; const percentage = (maxConfidence * 100).toFixed(1); PREDICTION_OUTPUT.textContent = `识别为: ${className} (${percentage}%)`; // 根据类别设置本帧的候选命令 if (predictedClassIndex === 0) { commandCandidate = '1'; } else if (predictedClassIndex === 1) { commandCandidate = '2'; } else { commandCandidate = '0'; // 未匹配到特定类别,或默认复位 } } else { PREDICTION_OUTPUT.textContent = `未知或不确定... (最高置信度: ${(maxConfidence * 100).toFixed(1)}%)`; commandCandidate = '0'; // 不确定也发送'0'回退 } } else { PREDICTION_OUTPUT.textContent = '无法识别。'; commandCandidate = '0'; // 无法识别也发送 '0' 回退 } } } catch (error) { console.error('预测错误:', error); PREDICTION_OUTPUT.textContent = `预测错误: ${error.message}`; commandCandidate = '0'; // 错误时也发送'0' } // ========================================================= // 核心逻辑:确认式发送机制 (Persistence / Confirmation) // ========================================================= // 更新全局变量 currentCommandInFrame,指示当前帧的预测结果 currentCommandInFrame = commandCandidate; // 只有当当前帧的候选命令与上一次实际发送的命令不同时,才进入确认流程 if (currentCommandInFrame !== lastSentClassCommand) { // 如果目前没有待确认的命令,或者待确认的命令与当前不同 if (pendingCommandToSend === null || pendingCommandToSend !== currentCommandInFrame) { if (pendingCommandTimerId) { clearTimeout(pendingCommandTimerId); // 清除旧的等待定时器 pendingCommandTimerId = null; // console.log(`[${new Date().toLocaleTimeString()}] 命令在确认前再次变化,取消旧的确认定时器.`); } pendingCommandToSend = currentCommandInFrame; // 设置新的待确认命令 // 设置一个定时器,在 CONFIRMATION_DELAY_MS 后进行确认 pendingCommandTimerId = setTimeout(async () => { // 当定时器触发时,再次检查全局的 currentCommandInFrame // 如果它仍然与我们开始等待的命令相同,则确认并发送 if (currentCommandInFrame === pendingCommandToSend) { console.log(`[${new Date().toLocaleTimeString()}] 确认命令 "${pendingCommandToSend}" 稳定,正在发送。`); await sendToSerialPort(pendingCommandToSend); lastSentClassCommand = pendingCommandToSend; // 更新上一次实际发送的命令 } else { console.log(`[${new Date().toLocaleTimeString()}] 命令在确认期内再次变化 (${pendingCommandToSend} -> ${currentCommandInFrame}),不发送。`); } pendingCommandToSend = null; // 清理待确认状态 pendingCommandTimerId = null; // 清理定时器ID }, CONFIRMATION_DELAY_MS); // console.log(`[${new Date().toLocaleTimeString()}] 检测到新候选命令 "${currentCommandInFrame}",启动 ${CONFIRMATION_DELAY_MS}ms 确认计时器。`); } // 如果 currentCommandInFrame 1. 仍然与 pendingCommandToSend 相同 // 并且 2. 不同于 lastSentClassCommand // 说明它处在“正在等待确认”的状态,不需要做任何事情,让当前定时器继续运行 } else { // 当前帧的命令与上次实际发送的命令相同 // 如果已经发送了相同的命令,或者回到了已发送的命令,那么清除待确认定时器 if (pendingCommandTimerId) { clearTimeout(pendingCommandTimerId); pendingCommandTimerId = null; pendingCommandToSend = null; // 清理待确认状态 // console.log(`[${new Date().toLocaleTimeString()}] 当前命令 "${currentCommandInFrame}" 与上次发送命令相同,或回摆,取消所有待确认发送。`); } } // ========================================================= } requestAnimationFrame(predictLoop); // 继续下一帧预测 } // ============== 重要的修改区域 end ============== // =================================== // Web Serial API Logic // =================================== async function checkWebSerialCompatibility() { if ('serial' in navigator) { showStatus(SERIAL_STATUS, 'info', 'Web Serial API 可用!'); CONNECT_SERIAL_BTN.disabled = false; } else { showStatus(SERIAL_STATUS, 'error', 'Web Serial API 在此浏览器中不可用。请使用最新版 Chrome!'); CONNECT_SERIAL_BTN.disabled = true; } } async function connectSerial() { showStatus(SERIAL_STATUS, 'info', '正在请求连接串口...'); try { serialPort = await navigator.serial.requestPort(); serialPort.addEventListener('disconnect', () => { const disconnectTime = new Date().toLocaleString(); console.warn(`[${disconnectTime}] ❗️❗️❗️ 串口已断开连接事件触发! `); // 强调日志 showStatus(SERIAL_STATUS, 'error', '串口连接已丢失!'); disconnectSerial(); }); await serialPort.open({ baudRate: SERIAL_BAUD_RATE }); console.log('串口已成功打开。尝试获取写入器...'); serialWriter = serialPort.writable.getWriter(); console.log('写入器已获取。'); console.log('串口已连接并打开。'); showStatus(SERIAL_STATUS, 'success', `串口已连接 (Baud: ${SERIAL_BAUD_RATE})`); updateSerialUI(true); // 更新状态为已连接 } catch (error) { console.error('连接串口失败:', error); if (error.name === 'NotFoundError') { showStatus(SERIAL_STATUS, 'warning', '连接串口请求已取消或未选择端口。请选择一个设备。'); } else if (error.name === 'SecurityError') { showStatus(SERIAL_STATUS, 'error', `连接串口失败: ${error.message}。请确保您在安全上下文 (HTTPS 或 localhost) 中运行。`); } else { showStatus(SERIAL_STATUS, 'error', `连接串口失败: ${error.message}`); } updateSerialUI(false); // 更新状态为未连接 } } async function disconnectSerial() { console.trace(`[${new Date().toLocaleString()}] disconnectSerial() called`); if (!isSerialConnectedState && !serialPort && !serialWriter) { console.log('Already in disconnected state, skipping further cleanup.'); return; } // =================================== // 断开连接时,清除任何待确认的定时器和状态 if (pendingCommandTimerId) { clearTimeout(pendingCommandTimerId); pendingCommandTimerId = null; } pendingCommandToSend = null; // =================================== try { if (serialWriter) { await serialWriter.close(); serialWriter = null; } if (serialPort && serialPort.readable) { const reader = serialPort.readable.getReader(); if (reader) { await reader.cancel(); reader.releaseLock(); } } if (serialPort && serialPort.isOpen) { await serialPort.close(); } serialPort = null; console.log('串口已断开。'); showStatus(SERIAL_STATUS, 'info', '串口已断开。'); } catch (error) { console.error('断开串口失败或串口已处于非连接状态:', error); showStatus(SERIAL_STATUS, 'error', `断开串口失败: ${error.message}`); } finally { updateSerialUI(false); // 无论如何都确保UI和状态更新为断开 lastSentClassCommand = null; // 断开连接时,重置上一次发送的命令,确保重连后第一次变化能发送 } } /** * 低级串口发送函数。包含内部的频繁发送节流。 * @param {string} command - 要发送的命令字符串。 */ async function sendToSerialPort(command) { if (!isSerialConnectedState) { // console.warn('串口已断开(逻辑状态),无法发送命令。'); return; } if (!serialWriter) { console.warn(`[${new Date().toLocaleString()}] 串口连接状态为 true,但 serialWriter 不可用。尝试重置串口连接。`); disconnectSerial(); return; } // 避免频繁发送相同的命令 (sendToSerialPort 内部的节流) const currentTime = new Date().getTime(); if (command === lastSerialCommand && (currentTime - lastSerialSendTime < SERIAL_SEND_MIN_INTERVAL)) { return; // 命令相同且发送间隔太短,不发送 } try { const encoder = new TextEncoder(); const data = encoder.encode(command + '\n'); await serialWriter.write(data); console.log(`[${new Date().toLocaleTimeString()}] 实际发送串口命令: ${command}`); lastSerialCommand = command; // 更新内部节流用的命令 lastSerialSendTime = currentTime; } catch (error) { console.error(`[${new Date().toLocaleTimeString()}] 实际发送串口命令失败: ${error.message}`); showStatus(SERIAL_STATUS, 'error', `发送串口命令失败: ${error.message},正在断开串口。`); disconnectSerial(); } } // =================================== // Event Listeners // =================================== CONNECT_SERIAL_BTN.addEventListener('click', connectSerial); DISCONNECT_SERIAL_BTN.addEventListener('click', disconnectSerial); LOAD_MODEL_BTN.addEventListener('click', () => loadKNNModel(null, null)); // 手动加载时,不传 CDN URL START_WEBCAM_BTN.addEventListener('click', startWebcam); STOP_WEBCAM_BTN.addEventListener('click', stopWebcam); // =================================== // Initialization // =================================== document.addEventListener('DOMContentLoaded', () => { checkWebSerialCompatibility(); // 检查 Web Serial API 兼容性 initModel(); // 加载 MobileNet 和 KNN 分类器实例,并尝试自动加载 KNN 模型 });