import request from '@/utils/request' import Common from '@/utils/Common.js' /** * App-Plus 下 uni.getFileSystemManager 往往仅占位,readFile 可能永不回调。 * 用 plus.io 判定 App(勿单依赖 plus.runtime,未就绪时会误走 FSM → 卡在 uploadVoiceFileBle 开始)。 * 仅非 App(如微信小程序)再尝试 FSM。 */ function tryGetUniFileSystemManager() { try { if (typeof uni === 'undefined') return null; if (typeof plus !== 'undefined' && plus.io) return null; if (typeof uni.getFileSystemManager !== 'function') return null; return uni.getFileSystemManager(); } catch (e) { return null; } } /** 从 readFile success 的 res.data 解析为 Uint8Array,失败返回 null */ function _bytesFromReadFileResult(res) { try { const raw = res && res.data; if (raw instanceof ArrayBuffer) return new Uint8Array(raw); if (raw instanceof Uint8Array) return raw; if (raw && ArrayBuffer.isView(raw) && raw.buffer instanceof ArrayBuffer) { return new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength); } } catch (e) {} return null; } /** 为 readFile 准备多路径:App 上 FSM 常需绝对路径,_downloads/_doc 相对路径需 convert */ function getLocalPathReadCandidates(path) { const s = String(path || '').trim(); if (!s) return []; const out = [s]; try { if (typeof plus !== 'undefined' && plus.io && typeof plus.io.convertLocalFileSystemURL === 'function') { if (/^_(?:downloads|doc|www)\//i.test(s)) { const c = plus.io.convertLocalFileSystemURL(s); if (c && typeof c === 'string' && c !== s) out.push(c); } } } catch (e) {} return [...new Set(out)]; } /** * App-Plus 上仍尝试 uni.getFileSystemManager().readFile(部分机型对 _downloads/_doc 路径可用), * 带超时避免 success/fail 永不回调;与 tryGetUniFileSystemManager 不同:此处不因 plus.io 存在而跳过。 */ function tryUniReadFileSinglePath(filePath, timeoutMs) { return new Promise((resolve) => { try { if (!filePath || typeof uni === 'undefined' || typeof uni.getFileSystemManager !== 'function') { resolve(null); return; } const fsm = uni.getFileSystemManager(); if (!fsm || typeof fsm.readFile !== 'function') { resolve(null); return; } let finished = false; const t = setTimeout(() => { if (finished) return; finished = true; resolve(null); }, timeoutMs); fsm.readFile({ filePath: filePath, success: (res) => { if (finished) return; finished = true; clearTimeout(t); const bytes = _bytesFromReadFileResult(res); resolve(bytes && bytes.length > 0 ? bytes : null); }, fail: () => { if (finished) return; finished = true; clearTimeout(t); resolve(null); } }); } catch (e) { resolve(null); } }); } function tryUniReadFileOnAppWithTimeout(path, timeoutMs) { const candidates = getLocalPathReadCandidates(path); if (!candidates.length) return Promise.resolve(null); const n = Math.min(candidates.length, 3); const per = Math.max(2800, Math.ceil(timeoutMs / n)); const tryIdx = (i) => { if (i >= candidates.length) return Promise.resolve(null); return tryUniReadFileSinglePath(candidates[i], per).then((bytes) => { if (bytes && bytes.length > 0) { if (i > 0) console.log('[100J-蓝牙] readFile 备用路径成功, idx=', i, 'pathHead=', String(candidates[i]).slice(0, 64)); return bytes; } return tryIdx(i + 1); }); }; return tryIdx(0); } // ================== 蓝牙协议封装类 ================== class HBY100JProtocol { constructor() { this.deviceId = ''; // 4G 接口所需的 deviceId this.isBleConnected = false; this.bleDeviceId = ''; // 小程序/APP中连接蓝牙的 deviceId // 蓝牙服务与特征值 UUID this.SERVICE_UUID = '0000AE30-0000-1000-8000-00805F9B34FB'; // 0xAE30 this.WRITE_UUID = '0000AE03-0000-1000-8000-00805F9B34FB'; // 0xAE03 this.NOTIFY_UUID = '0000AE02-0000-1000-8000-00805F9B34FB'; // 0xAE02 this.onNotifyCallback = null; this._fileResponseResolve = null; // 文件上传时等待设备 FB 05 响应 } // 等待设备 FB 05 响应,超时后仍 resolve(设备可能不响应每包) waitForFileResponse(timeoutMs = 2000) { return new Promise((resolve) => { const timer = setTimeout(() => { if (this._fileResponseResolve) { this._fileResponseResolve = null; resolve(null); } }, timeoutMs); this._fileResponseResolve = (result) => { clearTimeout(timer); this._fileResponseResolve = null; resolve(result); }; }); } setBleConnectionStatus(status, bleDeviceId = '') { this.isBleConnected = !!status; if (bleDeviceId) { this.bleDeviceId = bleDeviceId; } else if (!status) { // 断开时必须清空,否则 execWithBleFirst 误判「有 id 未连」且 uploadVoiceFileBle 仍可能带旧 id this.bleDeviceId = ''; } } /** 协议单字节:界面常传字符串或 -1,必须转成 0~255 */ _u8(val, fallback = 0) { const n = Number(val); if (!Number.isFinite(n) || n < 0) return fallback & 0xFF; return n & 0xFF; } onNotify(callback) { this.onNotifyCallback = callback; } /** * @param {Uint8Array|ArrayBuffer} buffer * @param {{ skipSideEffects?: boolean }} [options] skipSideEffects=true:仅解析字段,不打日志、不触发 onNotify/文件回调(供 BleReceive 与设备页 bleValueNotify 双订阅时避免重复) */ parseBleData(buffer, options = {}) { const skipSideEffects = !!options.skipSideEffects; const view = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); if (view.length < 3) return null; const header = view[0]; const tail = view[view.length - 1]; // 5.1 连接蓝牙设备主动上报 MAC 地址: FC + 6字节MAC + FF if (header === 0xFC && tail === 0xFF && view.length >= 8) { const macBytes = view.slice(1, 7); const macAddress = Array.from(macBytes).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(':'); const result = { type: 'mac', macAddress }; if (!skipSideEffects) { console.log('[100J-蓝牙] 设备上报MAC:', macAddress, '原始:', Array.from(view).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ')); if (this.onNotifyCallback) this.onNotifyCallback(result); } return result; } if (header !== 0xFB || tail !== 0xFF) return null; // 校验头尾 const funcCode = view[1]; const data = view.slice(2, view.length - 1); const hexStr = Array.from(view).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' '); let result = { funcCode, rawData: data }; switch (funcCode) { case 0x01: result.resetType = data[0]; break; case 0x02: break; case 0x03: // 5.4 获取设备位置:经度8B+纬度8B 均为 float64,设备主动上报(1分钟)与主动查询响应格式相同 if (data.length >= 16) { const lonBuf = new ArrayBuffer(8); const latBuf = new ArrayBuffer(8); new Uint8Array(lonBuf).set(data.slice(0, 8)); new Uint8Array(latBuf).set(data.slice(8, 16)); result.longitude = new DataView(lonBuf).getFloat64(0, true); result.latitude = new DataView(latBuf).getFloat64(0, true); } break; case 0x05: // 05: 文件更新响应 FB 05 [fileType] [status] FF,status: 1=成功 2=失败 if (data.length >= 1) result.fileType = data[0]; if (data.length >= 2) result.fileStatus = data[1]; // 1=Success, 2=Failure if (!skipSideEffects && this._fileResponseResolve) this._fileResponseResolve(result); break; case 0x04: // 5.5 获取设备电源状态: 容量8B + 电压8B + 百分比1B + 车载1B + 续航2B(分钟) + [充电状态1B] + FF // 充电状态为固件新增,在续航之后;旧固件仅 20 字节 payload,不影响 [16..19] 字段 if (data.length >= 20) { result.batteryPercentage = data[16]; result.vehiclePower = data[17]; result.batteryRemainingTime = data[18] | (data[19] << 8); // 小端序,单位分钟 } if (data.length >= 21) { result.chargingStatus = data[20]; // 0未充电 1充电中 2已充满 } break; case 0x06: // 06: 语音播报响应 result.voiceBroadcast = data[0]; break; case 0x09: // 09: 修改音量响应 result.volume = data[0]; break; case 0x0A: // 0A: 爆闪模式响应 result.strobeEnable = data[0]; result.strobeMode = data[1]; break; case 0x0B: // 0B: 修改警示灯爆闪频率响应 result.strobeFrequency = data[0]; break; case 0x0C: // 0C: 强制声光报警响应 result.alarmEnable = data[0]; result.alarmMode = data[1]; break; case 0x0D: // 0D: 警示灯 LED 亮度调节响应 result.redBrightness = data[0]; result.blueBrightness = data[1]; result.yellowBrightness = data[2]; break; case 0x0E: // 0E: 获取当前工作方式响应 result.voiceBroadcast = data[0]; result.alarmEnable = data[1]; result.alarmMode = data[2]; result.strobeEnable = data[3]; result.strobeMode = data[4]; result.strobeFrequency = data[5]; result.volume = data[6]; result.redBrightness = data[7]; result.blueBrightness = data[8]; result.yellowBrightness = data[9]; break; } const funcNames = { 0x01: '复位', 0x02: '基础信息', 0x03: '位置', 0x04: '电源状态', 0x05: '文件更新', 0x06: '语音播报', 0x09: '音量', 0x0A: '爆闪模式', 0x0B: '爆闪频率', 0x0C: '强制报警', 0x0D: 'LED亮度', 0x0E: '工作方式' }; const name = funcNames[funcCode] || ('0x' + funcCode.toString(16)); if (!skipSideEffects) { console.log('[100J-蓝牙] 设备响应 FB:', name, '解析:', JSON.stringify(result), '原始:', hexStr); if (this.onNotifyCallback) { this.onNotifyCallback(result); } } return result; } sendBleData(funcCode, dataBytes = []) { return new Promise((resolve, reject) => { if (!this.isBleConnected || !this.bleDeviceId) { return reject(new Error('蓝牙未连接')); } const buffer = new ArrayBuffer(dataBytes.length + 3); const view = new Uint8Array(buffer); view[0] = 0xFA; // 数据头 view[1] = funcCode; // 功能码 for (let i = 0; i < dataBytes.length; i++) { view[2 + i] = dataBytes[i]; } view[view.length - 1] = 0xFF; // 结尾 const sendHex = Array.from(view).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' '); console.log('[100J-蓝牙] 下发指令 FA:', '0x' + funcCode.toString(16).toUpperCase(), sendHex); // 使用项目中统一的 BleHelper 发送数据 import('@/utils/BleHelper.js').then(module => { const bleTool = module.default.getBleTool(); bleTool.sendData(this.bleDeviceId, buffer, this.SERVICE_UUID, this.WRITE_UUID) .then(res => resolve(res)) .catch(err => reject(err)); }); }); } // 纯蓝牙指令发送方法 deviceReset(type = 0) { return this.sendBleData(0x01, [type]); } getBasicInfo() { return this.sendBleData(0x02, []); } getLocation() { return this.sendBleData(0x03, []); } getPowerStatus() { return this.sendBleData(0x04, []); } setVoiceBroadcast(enable) { return this.sendBleData(0x06, [this._u8(enable)]); } setVolume(volume) { return this.sendBleData(0x09, [this._u8(volume)]); } setStrobeMode(enable, mode) { return this.sendBleData(0x0A, [this._u8(enable), this._u8(mode)]); } setStrobeFrequency(frequency) { return this.sendBleData(0x0B, [this._u8(frequency)]); } setForceAlarm(enable, mode) { return this.sendBleData(0x0C, [this._u8(enable), this._u8(mode)]); } setLightBrightness(red, blue = 0, yellow = 0) { return this.sendBleData(0x0D, [this._u8(red), this._u8(blue), this._u8(yellow)]); } getCurrentWorkMode() { return this.sendBleData(0x0E, []); } // 0x05 文件上传:分片传输,协议 FA 05 [fileType] [phase] [data...] FF // fileType: 1=语音 2=图片 3=动图 4=OTA // phase: 0=开始 1=数据 2=结束 // 每包最大负载见 uploadVoiceFileBle 内 CHUNK_SIZE(需与 MTU 匹配) // 支持 fileUrl(需网络下载) 或 localPath(无网络时本地文件) // 说明:下发的是已录制/已落盘的 MP3(等)二进制分片,经 GATT 写特征;非 A2DP/HFP 等「蓝牙录音实时流」 // meta.voiceListId:若存在 uni 中 100J_voice_b64_* 缓存(与 HBY100 存 Storage 同理),优先用缓存字节下发 uploadVoiceFileBle(fileUrlOrLocalPath, fileType = 1, onProgress, meta = null) { // 协议 5.6:单包负载最大 500B,加 FA/05/FF 等约 507B;真机需已协商足够 MTU(见 BleHelper setBLEMTU 512) const CHUNK_SIZE = 500; const BLE_CACHE_SENTINEL = '__100J_BLE_CACHE__'; return new Promise((resolve, reject) => { const srcStr = String(fileUrlOrLocalPath || ''); const isLocalPath = !/^https?:\/\//i.test(srcStr); const voiceListId = meta && meta.voiceListId != null ? meta.voiceListId : ''; const did = this.deviceId || ''; console.log('[100J-蓝牙] uploadVoiceFileBle 开始', { isBleConnected: this.isBleConnected, hasBleDeviceId: !!this.bleDeviceId, isLocalPath, sourceHead: srcStr.slice(0, 96), voiceListId: voiceListId !== '' ? String(voiceListId) : '' }); if (!this.isBleConnected || !this.bleDeviceId) { return reject(new Error('蓝牙未连接')); } if (voiceListId !== '' && did) { const cached = get100JVoiceBleCacheBytes(did, voiceListId); if (cached && cached.length > 0) { if (onProgress) onProgress(1); console.log('[100J-蓝牙] 使用 uni 存储缓存下发(类比 HBY100 getStorage),字节:', cached.length); this._sendVoiceChunks(cached, fileType, CHUNK_SIZE, onProgress).then(resolve).catch(reject); return; } console.warn('[100J-蓝牙] 未命中 uni 缓存,将尝试读本地文件。key=', voiceBleCacheStorageKey(did, voiceListId), '若保存后立刻使用仍失败,请稍等再试或重新保存'); } if (fileUrlOrLocalPath === BLE_CACHE_SENTINEL) { return reject(new Error('本地语音缓存不存在或已失效,请重新保存录音')); } if (!fileUrlOrLocalPath) { return reject(new Error('缺少文件地址或本地路径')); } if (onProgress) onProgress(1); let localReadWatchdog = null; const clearLocalReadWatchdog = () => { if (localReadWatchdog) { clearTimeout(localReadWatchdog); localReadWatchdog = null; } }; const readFromPath = (path) => { const doSend = (bytes) => { clearLocalReadWatchdog(); if (voiceListId !== '' && did && bytes && bytes.length) { put100JVoiceBleCache(did, voiceListId, bytes); } this._sendVoiceChunks(bytes, fileType, CHUNK_SIZE, onProgress) .then(resolve).catch(reject); }; const onReadErr = (err) => { clearLocalReadWatchdog(); if (onProgress) onProgress(0); reject(err); }; // HBY100 下发语音:不读 MP3 文件,而是 uni.getStorageSync(pcmStorageKey_id) 取服务端返回的 PCM 十六进制串再分包发蓝牙(见 pages/100/HBY100.vue audioApply)。 // 100J 协议为 FA 05 传原始 MP3 二进制,必须从本地路径读文件;优先 readFile,失败再走 plus.io(部分机型 plus 回调不归位会触发外层看门狗)。 readFromPathPlus(path, doSend, onReadErr); }; const readFileEntry = (entry, doSend, reject) => { entry.file((file) => { const reader = new plus.io.FileReader(); reader.onloadend = (e) => { try { const buf = e.target.result; const bytes = new Uint8Array(buf); doSend(bytes); } catch (err) { console.error('[100J-蓝牙] 读取ArrayBuffer异常:', err); reject(err); } }; reader.onerror = () => reject(new Error('读取文件失败')); reader.readAsArrayBuffer(file); }, (err) => reject(err)); }; const readFromPathPlus = (path, doSend, reject) => { const tryUniReadFile = (onFail) => { // 先走 uni readFile(含 App),带超时;旧逻辑在 plus 存在时完全跳过 FSM,导致只能卡 plus.io。 tryUniReadFileOnAppWithTimeout(path, 5000).then((bytes) => { if (bytes && bytes.length > 0) { console.log('[100J-蓝牙] readFile 已读出本地语音,字节:', bytes.length); doSend(bytes); return; } try { const fsm = tryGetUniFileSystemManager(); if (!fsm || typeof fsm.readFile !== 'function') { onFail(); return; } fsm.readFile({ filePath: path, success: (res) => { const b = _bytesFromReadFileResult(res); if (b && b.length > 0) { console.log('[100J-蓝牙] readFile(非App) 已读出本地语音,字节:', b.length); doSend(b); return; } onFail(); }, fail: () => onFail() }); } catch (e) { onFail(); } }); }; // _downloads/:resolve 与 requestFileSystem 并行竞速,避免单一路径在部分机型上长期无回调 if (path && path.startsWith('_downloads/')) { const fileName = path.replace(/^_downloads\//, ''); tryUniReadFile(() => { if (typeof plus === 'undefined' || !plus.io) { reject(new Error('当前环境不支持文件读取')); return; } console.log('[100J-蓝牙] _downloads 并行 resolve+PUBLIC_DOWNLOADS, fileName=', fileName); let finished = false; const outerMs = 20000; const outerT = setTimeout(() => { if (finished) return; finished = true; reject(new Error('读取下载目录语音超时,请重新保存录音后重试')); }, outerMs); const win = (bytes) => { if (finished) return; if (!bytes || !(bytes.length > 0)) return; finished = true; clearTimeout(outerT); doSend(bytes); }; let url = path; try { if (plus.io.convertLocalFileSystemURL) { const c = plus.io.convertLocalFileSystemURL(path); if (c) url = c; } } catch (e) {} if (typeof url === 'string' && url.startsWith('/') && !url.startsWith('file://')) { url = 'file://' + url; } plus.io.resolveLocalFileSystemURL(url, (entry) => { if (finished) return; readFileEntry(entry, win, () => {}); }, () => {}); plus.io.requestFileSystem(plus.io.PUBLIC_DOWNLOADS, (fs) => { if (finished) return; fs.root.getFile(fileName, {}, (entry) => { if (finished) return; readFileEntry(entry, win, () => {}); }, () => {}); }, () => {}); }); return; } // _doc/:PRIVATE_DOC 逐级与 resolve 并行,谁先读到有效字节谁胜出 if (path && path.startsWith('_doc/')) { const relPath = path.replace(/^_doc\//, ''); const parts = relPath.split('/'); const fileName = parts.pop(); const dirs = parts; tryUniReadFile(() => { if (typeof plus === 'undefined' || !plus.io) { reject(new Error('当前环境不支持文件读取')); return; } console.log('[100J-蓝牙] _doc 并行 resolve+PRIVATE_DOC, path=', path.slice(0, 96)); let finished = false; const outerMs = 20000; const outerT = setTimeout(() => { if (finished) return; finished = true; reject(new Error('读取本地语音超时(文档目录),请重新保存录音后重试')); }, outerMs); const win = (bytes) => { if (finished) return; if (!bytes || !(bytes.length > 0)) return; finished = true; clearTimeout(outerT); doSend(bytes); }; let url = path; try { if (plus.io.convertLocalFileSystemURL) { const c = plus.io.convertLocalFileSystemURL(path); if (c) url = c; } } catch (e) {} if (typeof url === 'string' && url.startsWith('/') && !url.startsWith('file://')) { url = 'file://' + url; } plus.io.resolveLocalFileSystemURL(url, (entry) => { if (finished) return; readFileEntry(entry, win, () => {}); }, () => {}); plus.io.requestFileSystem(plus.io.PRIVATE_DOC, (fs) => { if (finished) return; let cur = fs.root; const next = (i) => { if (finished) return; if (i >= dirs.length) { cur.getFile(fileName, { create: false }, (entry) => { if (finished) return; readFileEntry(entry, win, () => {}); }, () => {}); return; } cur.getDirectory(dirs[i], { create: false }, (dir) => { cur = dir; next(i + 1); }, () => {}); }; next(0); }, () => {}); }); return; } if (typeof plus === 'undefined' || !plus.io) { console.error('[100J-蓝牙] 当前环境不支持 plus.io,且非 _doc/_downloads 相对路径'); reject(new Error('当前环境不支持文件读取')); return; } // 其他路径兜底:部分机型 resolve 长期无回调,限时避免与外层读流程叠死 // 与 Common.moveFileToDownloads 一致:uni 临时路径先 convert 再 resolve let resolvePath = path; try { if (resolvePath && typeof plus !== 'undefined' && plus.io && plus.io.convertLocalFileSystemURL) { const isRel = /^(?:_doc\/|_downloads\/|https?:)/i.test(resolvePath); if (!isRel) { const conv = plus.io.convertLocalFileSystemURL(resolvePath); if (conv && typeof conv === 'string') resolvePath = conv; } } } catch (e) {} if (resolvePath && resolvePath.startsWith('/') && !resolvePath.startsWith('file://')) resolvePath = 'file://' + resolvePath; let resolveDone = false; const t = setTimeout(() => { if (resolveDone) return; resolveDone = true; reject(new Error('解析本地文件路径超时,请重新保存录音')); }, 12000); plus.io.resolveLocalFileSystemURL(resolvePath, (entry) => { if (resolveDone) return; resolveDone = true; clearTimeout(t); readFileEntry(entry, doSend, reject); }, (err) => { if (resolveDone) return; resolveDone = true; clearTimeout(t); reject(err); }); }; const startUrlFetch = () => { let fetchUrl = fileUrlOrLocalPath; if (fetchUrl.startsWith('http://')) fetchUrl = 'https://' + fetchUrl.slice(7); if (onProgress) onProgress(2); const fallbackDownload = () => { uni.downloadFile({ url: fetchUrl, success: (res) => { if (res.statusCode !== 200 || !res.tempFilePath) { if (onProgress) onProgress(0); reject(new Error('下载失败: ' + (res.statusCode || '无路径'))); return; } Common.moveFileToDownloads(res.tempFilePath).then((p) => readFromPath(p)).catch(() => readFromPath(res.tempFilePath)); }, fail: (err) => { if (onProgress) onProgress(0); reject(err || new Error('下载失败')); } }); }; const reqTimeoutMs = 20000; const reqPromise = new Promise((resolveReq, rejectReq) => { uni.request({ url: fetchUrl, method: 'GET', responseType: 'arraybuffer', timeout: reqTimeoutMs, success: (res) => resolveReq(res), fail: (e) => rejectReq(e || new Error('请求失败')) }); }); const timeoutPromise = new Promise((_, rejectT) => { setTimeout(() => rejectT(new Error('拉取语音超时')), reqTimeoutMs + 2000); }); Promise.race([reqPromise, timeoutPromise]) .then((res) => { if (res.statusCode === 200 && res.data) { const bytes = res.data instanceof ArrayBuffer ? new Uint8Array(res.data) : new Uint8Array(res.data || []); if (bytes.length > 0) { const doSend = (b) => { if (voiceListId !== '' && did && b && b.length) { put100JVoiceBleCache(did, voiceListId, b); } this._sendVoiceChunks(b, fileType, CHUNK_SIZE, onProgress).then(resolve).catch(reject); }; doSend(bytes); return; } } fallbackDownload(); }) .catch(() => fallbackDownload()); }; if (isLocalPath) { // 本地路径:无网络时直接读取;plus 回调偶发不返回时避免进度永远停在 1% localReadWatchdog = setTimeout(() => { localReadWatchdog = null; if (onProgress) onProgress(0); reject(new Error('读取本地语音超时,请重新录制')); }, 60000); readFromPath(fileUrlOrLocalPath); } else { // 网络 URL:无网时立即失败,避免卡在 1%~2% uni.getNetworkType({ success: (net) => { if (net.networkType === 'none') { if (onProgress) onProgress(0); reject(new Error('无网络,无法下载云端语音,请连接网络后重试')); return; } startUrlFetch(); }, fail: () => { if (onProgress) onProgress(0); reject(new Error('无法检测网络,请连接网络后重试')); } }); } }); } _sendVoiceChunks(bytes, fileType, chunkSize, onProgress) { const total = bytes.length; const ft = (fileType & 0xFF) || 1; const DELAY_AFTER_START = 80; // 开始包后、等设备响应后再发的缓冲(ms) const DELAY_PACKET = 80; // 数据包间延时(ms),参考6155 const toHex = (arr) => Array.from(arr).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' '); console.log('[100J-蓝牙] 语音下发总大小:', total, '字节, fileType=', ft); // 进度单调递增:前段固定 2→8,数据段占 8~95,结束包 99→100,避免先 5% 再掉回 1% 的错觉 let progressPeak = 0; const emitProgress = (raw) => { const n = Math.round(Number(raw)); if (!Number.isFinite(n)) return; const v = Math.min(100, Math.max(progressPeak, n)); progressPeak = v; if (onProgress) onProgress(v); }; if (total <= 0) { emitProgress(100); return Promise.resolve({ code: 200, msg: '语音文件已通过蓝牙上传' }); } emitProgress(2); const bleToolPromise = import('@/utils/BleHelper.js').then(m => m.default.getBleTool()); let bleRef = null; const send = (dataBytes, label = '') => { const buf = new ArrayBuffer(dataBytes.length + 3); const v = new Uint8Array(buf); v[0] = 0xFA; v[1] = 0x05; for (let i = 0; i < dataBytes.length; i++) v[2 + i] = dataBytes[i]; v[v.length - 1] = 0xFF; const hex = toHex(v); const preview = v.length <= 32 ? hex : hex.slice(0, 96) + '...'; console.log(`[100J-蓝牙] 下发${label} 共${v.length}字节:`, preview); return bleToolPromise.then(ble => ble.sendData(this.bleDeviceId, buf, this.SERVICE_UUID, this.WRITE_UUID)); }; const delay = (ms) => new Promise(r => setTimeout(r, ms)); // 开始包: FA 05 [fileType] [phase=0] [size 4B LE] FF const startData = [ft, 0, total & 0xFF, (total >> 8) & 0xFF, (total >> 16) & 0xFF, (total >> 24) & 0xFF]; // 单包约 507B(500 负载),依赖 MTU;Android 上为整包 write const waitPromise = this.waitForFileResponse(2500); const prepMtuThenSend = (ble) => { const run = () => { bleRef = ble; ble.setVoiceUploading(true); return send(startData, ' 开始包') .then(() => waitPromise) .then(() => delay(DELAY_AFTER_START)) .then(() => { emitProgress(8); let seq = 0; const sendNext = (offset) => { if (offset >= total) { return delay(DELAY_PACKET) .then(() => send([ft, 2], ' 结束包')) .then(() => { emitProgress(99); }); } const chunk = bytes.slice(offset, Math.min(offset + chunkSize, total)); const chunkData = [ft, 1, seq & 0xFF, (seq >> 8) & 0xFF, ...chunk]; return send(chunkData, ` #${seq} 数据包`).then(() => { seq++; const doneRatio = (offset + chunk.length) / total; emitProgress(8 + Math.round(doneRatio * 87)); return delay(DELAY_PACKET).then(() => sendNext(offset + chunk.length)); }); }; return sendNext(0); }) .then(() => { emitProgress(100); return { code: 200, msg: '语音文件已通过蓝牙上传' }; }); }; try { if (typeof plus !== 'undefined' && plus.os && plus.os.name === 'Android' && ble.setMtu) { return ble.setMtu(this.bleDeviceId) .catch((e) => { console.warn('[100J-蓝牙] setBLEMTU 失败,大数据包可能无法一次写入,已用较小分片缓解:', e && (e.message || e)); }) .then(() => new Promise((r) => setTimeout(r, 350))) .then(run); } } catch (e) {} return run(); }; return bleToolPromise.then(ble => prepMtuThenSend(ble)).finally(() => { if (bleRef) bleRef.setVoiceUploading(false); }); } } // ================== 全局单例与状态管理 ================== const protocolInstance = new HBY100JProtocol(); // 暴露给页面:更新蓝牙连接状态 export function updateBleStatus(isConnected, bleDeviceId, deviceId) { protocolInstance.setBleConnectionStatus(isConnected, bleDeviceId); protocolInstance.deviceId = deviceId; console.log('[100J] 蓝牙状态:', isConnected ? '已连接(后续指令走蓝牙)' : '已断开(后续指令走4G)', { bleDeviceId: bleDeviceId || '-', deviceId }); } // 暴露给页面:获取当前蓝牙连接状态(用于跨页面传递,确保语音管理等子页走蓝牙优先) export function getBleStatus() { return { isConnected: protocolInstance.isBleConnected, bleDeviceId: protocolInstance.bleDeviceId, deviceId: protocolInstance.deviceId }; } /** * 按当前设备 MAC 与 BleHelper.LinkedList 对齐协议层连接状态。 * 语音列表页进入时 eventChannel 携带的 getBleStatus() 可能过期;点「使用」前调用,确保真走 uploadVoiceFileBle。 */ export function sync100JBleProtocolFromHelper(deviceRow) { if (!deviceRow || !deviceRow.deviceId) return Promise.resolve(); const did = deviceRow.deviceId; protocolInstance.deviceId = did; return import('@/utils/BleHelper.js').then((m) => { const bleTool = m.default.getBleTool(); const mac = deviceRow.deviceMac || deviceRow.device_mac || ''; if (!bleTool.data.available) { updateBleStatus(false, '', did); return; } const macNorm = (s) => String(s || '').replace(/:/g, '').toUpperCase(); const target = macNorm(mac); const last6 = target.length >= 6 ? target.slice(-6) : ''; const list = bleTool.data.LinkedList || []; const item = list.find((v) => { if (!v) return false; const mm = macNorm(v.macAddress || ''); if (!mm) return false; if (target && mm === target) return true; return !!(last6 && mm.length >= 6 && mm.slice(-6) === last6); }); if (item && item.Linked && item.deviceId) { updateBleStatus(true, item.deviceId, did); } else { updateBleStatus(false, '', did); } }).catch(() => {}); } /** 与 HBY100 一致:语音内容进 uni 存储,下发时取出再发蓝牙。100 存 PCM 十六进制串;100J 存整文件 base64(比 hex 省空间),取出后转 Uint8Array,仍走 FA 05 二进制分片(设备协议未变)。 */ export function voiceBleCacheStorageKey(deviceId, voiceListId) { return '100J_voice_b64_' + String(deviceId) + '_' + String(voiceListId); } function _uint8ToBase64Chunk(u8) { if (!u8 || !u8.length) return ''; try { const buf = u8.buffer instanceof ArrayBuffer ? u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) : u8; if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') { return uni.arrayBufferToBase64(buf); } } catch (e) {} let binary = ''; for (let i = 0; i < u8.length; i++) binary += String.fromCharCode(u8[i]); return btoa(binary); } function _base64ToUint8Array(b64) { if (!b64 || typeof b64 !== 'string') return null; const s = b64.replace(/\s/g, ''); try { if (typeof uni !== 'undefined' && typeof uni.base64ToArrayBuffer === 'function') { const buf = uni.base64ToArrayBuffer(s); return new Uint8Array(buf); } } catch (e) {} try { const binary = atob(s); const len = binary.length; const u8 = new Uint8Array(len); for (let i = 0; i < len; i++) u8[i] = binary.charCodeAt(i); return u8; } catch (e) { return null; } } export function put100JVoiceBleCache(deviceId, voiceListId, uint8Array) { if (!deviceId || voiceListId == null || !uint8Array || !uint8Array.length) return; try { const b64 = _uint8ToBase64Chunk(uint8Array); if (b64) uni.setStorageSync(voiceBleCacheStorageKey(deviceId, voiceListId), b64); } catch (e) {} } export function get100JVoiceBleCacheBytes(deviceId, voiceListId) { if (!deviceId || voiceListId == null) return null; try { const b64 = uni.getStorageSync(voiceBleCacheStorageKey(deviceId, voiceListId)); return _base64ToUint8Array(b64); } catch (e) { return null; } } export function remove100JVoiceBleCache(deviceId, voiceListId) { if (!deviceId || voiceListId == null) return; try { uni.removeStorageSync(voiceBleCacheStorageKey(deviceId, voiceListId)); } catch (e) {} } /** 保存录音/上传成功后:读落盘路径并写入缓存,点「使用」时可与 HBY100 一样不再依赖 plus 读文件 */ export function cache100JVoiceFileForBle(deviceId, voiceListId, filePath) { return new Promise((resolve) => { if (!deviceId || voiceListId == null || !filePath) { resolve(); return; } // 仅用 uni readFile + 超时;App 上不再走 tryGetUniFileSystemManager 跳过逻辑。 // 旧版在此用 plus.requestFileSystem 且无总超时,部分机型永不回调 → Promise 挂死。 tryUniReadFileOnAppWithTimeout(filePath, 4000).then((bytes) => { if (bytes && bytes.length) { put100JVoiceBleCache(deviceId, voiceListId, bytes); console.log('[100J] cache100JVoiceFileForBle 已写入 uni 缓存(readFile),字节:', bytes.length); } resolve(); }); }); } // 暴露给页面:解析蓝牙接收到的数据(options 见类上 parseBleData 注释) export function parseBleData(buffer, options) { return protocolInstance.parseBleData(buffer, options); } // 暴露给页面:蓝牙连接后主动拉取电源状态(电量、续航) export function fetchBlePowerStatus() { if (!protocolInstance.isBleConnected) return Promise.reject(new Error('蓝牙未连接')); console.log('[100J-蓝牙] 拉取电源状态 已通过蓝牙发送 FA 04 FF'); return protocolInstance.getPowerStatus(); } // 暴露给页面:蓝牙连接后主动拉取定位(优先蓝牙,设备也会每1分钟主动上报) export function fetchBleLocation() { if (!protocolInstance.isBleConnected) return Promise.reject(new Error('蓝牙未连接')); console.log('[100J-蓝牙] 拉取定位 已通过蓝牙发送 FA 03 FF'); return protocolInstance.getLocation(); } // 100J 设备页扫描最长约 15s,指令侧不宜空等过久;适配器关闭时不应再轮询 const WAIT_BLE_CONNECTED_MS = 5000; const BLE_POLL_INTERVAL_MS = 400; const BLE_RECONNECT_MS = 2000; /** * 系统蓝牙是否可用。fail/available=false 时立即走 4G。 * 若系统报开启,再与 BleHelper.data.available 交叉校验(关蓝牙后助手往往先变为 false,避免仍弹「连蓝牙」类误导)。 */ function getBleAdapterAvailable() { return new Promise((resolve) => { if (typeof uni.getBluetoothAdapterState !== 'function') { resolve(true); return; } uni.getBluetoothAdapterState({ success: (res) => { if (!res.available) { resolve(false); return; } import('@/utils/BleHelper.js').then((m) => { try { const ble = m.default.getBleTool(); if (ble && ble.data && ble.data.available === false) { resolve(false); return; } } catch (e) {} resolve(true); }).catch(() => resolve(true)); }, fail: () => resolve(false) }); }); } // 等待协议层出现 bleDeviceId(页面后台扫描/连接中),超时则走 4G function waitForBleConnection(maxWaitMs = WAIT_BLE_CONNECTED_MS, intervalMs = BLE_POLL_INTERVAL_MS) { if (protocolInstance.isBleConnected && protocolInstance.bleDeviceId) return Promise.resolve(true); return new Promise((resolve) => { const start = Date.now(); const tick = () => { if (protocolInstance.isBleConnected && protocolInstance.bleDeviceId) { console.log('[100J] 等待蓝牙连接成功'); resolve(true); return; } if (Date.now() - start >= maxWaitMs) { console.log('[100J] 等待蓝牙连接超时,将走4G'); resolve(false); return; } setTimeout(tick, intervalMs); }; console.log('[100J] 蓝牙未连接,短时等待扫描/连接…', maxWaitMs, 'ms'); tick(); }); } // 暴露给页面:尝试重连蓝牙(优先策略:断线后发指令前先尝试重连) export function tryReconnectBle(timeoutMs = BLE_RECONNECT_MS) { if (protocolInstance.isBleConnected) return Promise.resolve(true); if (!protocolInstance.bleDeviceId) return Promise.resolve(false); return new Promise((resolve) => { import('@/utils/BleHelper.js').then(module => { const bleTool = module.default.getBleTool(); const deviceId = protocolInstance.bleDeviceId; const f = bleTool.data.LinkedList.find(v => v.deviceId === deviceId); if (!f) { resolve(false); return; } const svc = f.writeServiceId || '0000AE30-0000-1000-8000-00805F9B34FB'; const write = f.wirteCharactId || '0000AE03-0000-1000-8000-00805F9B34FB'; const notify = f.notifyCharactId || '0000AE02-0000-1000-8000-00805F9B34FB'; const timer = setTimeout(() => { resolve(protocolInstance.isBleConnected); }, timeoutMs); console.log('[100J] 蓝牙优先:尝试重连', deviceId); bleTool.LinkBlue(deviceId, svc, write, notify, 1).then(() => { clearTimeout(timer); protocolInstance.setBleConnectionStatus(true, deviceId); console.log('[100J] 蓝牙重连成功'); resolve(true); }).catch(() => { clearTimeout(timer); resolve(false); }); }); }); } // ================== API 接口 (拦截层) ================== // 获取语音管理列表 export function deviceVoliceList(params) { return request({ url: `/app/video/queryAudioFileList`, method: 'get', data:params }) } // 重命名 export function videRenameAudioFile(data) { return request({ url: `/app/video/renameAudioFile`, method: 'post', data:data }) } // 删除语音文件列表 export function deviceDeleteAudioFile(params) { return request({ url: `/app/video/deleteAudioFile`, method: 'get', data:params }) } // 更新语音/使用语音:蓝牙优先;云端 fileUrl 蓝牙失败时可 4G 仅传 id 触发设备拉流 // 本地 localPath 仅能通过蓝牙下发,禁止回退 4G(否则服务端成功但设备无文件) function isHttpUrlString(s) { return !!(s && /^https?:\/\//i.test(String(s).trim())); } /** 与后端约定:communicationMode 0=4G,1=蓝牙(云端记录本次「使用」语音的通讯方式) */ export function deviceUpdateVoice(data) { const httpExec = (communicationMode) => request({ url: `/app/hby100j/device/updateVoice`, method: 'post', data: { id: data.id, communicationMode: communicationMode === 1 ? 1 : 0 } }); const lp = (data.localPath && String(data.localPath).trim()) || ''; const fu = (data.fileUrl && String(data.fileUrl).trim()) || ''; // mergeLocal 会把本地路径塞进 fileUrl;列表项若丢 localPath 仍可能只剩「非 http」的 fileUrl const effectiveLocal = (lp && !isHttpUrlString(lp)) ? lp : ((fu && !isHttpUrlString(fu)) ? fu : ''); const BLE_CACHE_SENTINEL = '__100J_BLE_CACHE__'; const hasBleCache = protocolInstance.deviceId && data.id != null && (() => { const u8 = get100JVoiceBleCacheBytes(protocolInstance.deviceId, data.id); return !!(u8 && u8.length); })(); const hasLocalPath = effectiveLocal.length > 0 || hasBleCache; const remoteUrl = isHttpUrlString(fu) ? fu : (isHttpUrlString(lp) ? lp : ''); const fileSource = hasLocalPath ? (effectiveLocal || BLE_CACHE_SENTINEL) : (remoteUrl || null); if (!fileSource) { console.log('[100J] 语音上传:无 fileUrl/localPath,仅 HTTP updateVoice(不会走蓝牙传文件)'); return httpExec(0).then((res) => { if (res && typeof res === 'object') res._channel = '4g'; return res; }); } console.log('[100J] 语音上传:有文件源,尝试蓝牙下发文件', { isBleConnected: protocolInstance.isBleConnected, hasBleDeviceId: !!protocolInstance.bleDeviceId, local: !!hasLocalPath }); // 蓝牙传完文件后再调 updateVoice(communicationMode=1)。若仅 HTTP 失败而文件已下发,仍返回 _channel=ble, // 切勿让整链 reject 触发 execWithBleFirst 走 4G+MQTT,否则界面等不到进度会误报「音频进度同步超时」。 const bleExec = () => protocolInstance.uploadVoiceFileBle(fileSource, 1, data.onProgress, { voiceListId: data.id }) .then(() => httpExec(1) .then((res) => { if (res && typeof res === 'object') res._channel = 'ble'; return res; }) .catch((e) => { console.warn('[100J] 蓝牙已传完语音文件,updateVoice(communicationMode=1) 失败:', e && (e.message || e)); return { code: 200, msg: '语音已通过蓝牙下发', _channel: 'ble', _updateVoiceAfterBleFailed: true }; }) ); const http4g = () => httpExec(0); // 本地文件:禁止一切 4G 兜底(含蓝牙未开时),避免仅传 id 假成功 return execWithBleFirst(bleExec, http4g, '语音文件上传', data.onWaiting, { no4GFallback: hasLocalPath }); } // 100J信息 export function deviceDetail(id) { return request({ url: `/app/hby100j/device/${id}`, method: 'get', }) } // 蓝牙优先、4G 兜底:未连接时先等待扫描/连接,再尝试重连;蓝牙失败时回退 4G // opts.no4GFallback:本地语音等场景禁止走 HTTP,避免无网/假成功 function execWithBleFirst(bleExec, httpExec, logName, onWaiting, opts = {}) { const no4G = !!opts.no4GFallback; const localBleOnlyMsg = '本地语音需通过蓝牙下发,请连接设备蓝牙后重试'; const doBle = () => bleExec().then(res => ({ ...(res || {}), _channel: 'ble' })); const do4G = () => httpExec().then((res) => { console.log('[100J] 语音上传:已改走 HTTP(4G) updateVoice,未执行蓝牙文件分片'); if (res && typeof res === 'object') res._channel = '4g'; return res; }); const go4GOrReject = (reasonLog) => { if (no4G) { return Promise.reject(new Error(localBleOnlyMsg)); } if (reasonLog) console.log('[100J]', logName || '指令', reasonLog); return do4G(); }; const onBleSendFail = (e) => { if (no4G) { return Promise.reject(e instanceof Error && e.message ? e : new Error(String((e && e.message) || '蓝牙发送语音失败,请靠近设备后重试'))); } console.log('[100J]', logName || '指令', '蓝牙失败,回退4G'); return do4G(); }; const hideWaitUi = () => { if (typeof onWaiting === 'function') return; try { uni.hideLoading(); } catch (e) {} }; const showWaitUi = (title) => { if (typeof onWaiting === 'function') return; try { uni.hideLoading(); uni.showLoading({ title, mask: false }); } catch (e) {} }; // 协议层认为已连:仍可能被系统蓝牙关闭/底层已断而滞后,先校验适配器,避免先发蓝牙卡超时再回退 if (protocolInstance.isBleConnected && protocolInstance.bleDeviceId) { return getBleAdapterAvailable().then((adapterOk) => { if (!adapterOk) { protocolInstance.setBleConnectionStatus(false, ''); return go4GOrReject('系统蓝牙已关闭,走4G'); } console.log('[100J]', logName || '指令', '协议层已连接,走蓝牙'); return doBle().catch(onBleSendFail); }); } console.log('[100J]', logName || '指令', '协议层未就绪', { isBleConnected: protocolInstance.isBleConnected, hasBleDeviceId: !!protocolInstance.bleDeviceId }); // 无 bleDeviceId:本地仅蓝牙场景仍弹「请稍候」并等扫描;可走 4G 时不弹 loading、不白等 5s,直接 4G if (!protocolInstance.bleDeviceId) { return getBleAdapterAvailable().then((adapterOk) => { if (!adapterOk) { protocolInstance.setBleConnectionStatus(false, ''); return go4GOrReject('系统蓝牙未开启,走4G'); } if (no4G) { if (typeof onWaiting === 'function') onWaiting(); else showWaitUi('请稍候…'); return waitForBleConnection() .then((connected) => { return connected ? doBle().catch(onBleSendFail) : go4GOrReject('蓝牙未连接'); }) .finally(hideWaitUi); } return waitForBleConnection(0, BLE_POLL_INTERVAL_MS).then((connected) => connected ? doBle().catch(onBleSendFail) : go4GOrReject('无蓝牙连接,走4G') ); }); } // 有 bleDeviceId 但未连:用户刚关蓝牙/超出范围时,不再弹「请稍候」等重连 ~2s,双通道在线时直接 4G return getBleAdapterAvailable().then((adapterOk) => { if (!adapterOk) { protocolInstance.setBleConnectionStatus(false, ''); return go4GOrReject('系统蓝牙未开启,走4G'); } console.log('[100J]', logName || '指令', '蓝牙已断开,直接走4G(不阻塞重连)'); return go4GOrReject(null); }); } // 爆闪模式 export function deviceStrobeMode(data) { return execWithBleFirst( () => protocolInstance.setStrobeMode(data.enable, data.mode).then(() => ({ code: 200, msg: '操作成功(蓝牙)' })), () => request({ url: `/app/hby100j/device/strobeMode`, method: 'post', data }), '爆闪模式' ); } // 强制报警 export function deviceForceAlarmActivation(data) { return execWithBleFirst( () => protocolInstance.setForceAlarm(data.voiceStrobeAlarm, data.mode).then(() => ({ code: 200, msg: '操作成功(蓝牙)' })), () => request({ url: `/app/hby100j/device/forceAlarmActivation`, method: 'post', data }), '强制报警' ); } // 爆闪频率 export function deviceStrobeFrequency(data) { return execWithBleFirst( () => protocolInstance.setStrobeFrequency(data.frequency).then(() => ({ code: 200, msg: '操作成功(蓝牙)' })), () => request({ url: `/app/hby100j/device/strobeFrequency`, method: 'post', data }), '爆闪频率' ); } // 灯光调节亮度 export function deviceLightAdjustment(data) { return execWithBleFirst( () => protocolInstance.setLightBrightness(data.brightness, data.brightness, data.brightness).then(() => ({ code: 200, msg: '操作成功(蓝牙)' })), () => request({ url: `/app/hby100j/device/lightAdjustment`, method: 'post', data }), '灯光亮度' ); } // 调节音量 export function deviceUpdateVolume(data) { return execWithBleFirst( () => protocolInstance.setVolume(data.volume).then(() => ({ code: 200, msg: '操作成功(蓝牙)' })), () => request({ url: `/app/hby100j/device/updateVolume`, method: 'post', data }), '调节音量' ); } /** 蓝牙侧语音播报:自定义音(mode=7)且强制报警仍开时,需先 0x0C 关报警+模式7 再 0x06;已解除则不必重复下发 0x0C */ function bleVoiceBroadcastChain(data) { const on = Number(data.voiceBroadcast) === 1; const mode = data.mode != null ? String(data.mode) : ''; if (on && mode === '7') { const alarmOn = Number(data.voiceStrobeAlarm) === 1; if (alarmOn) { return protocolInstance.setForceAlarm(0, 7).then(() => protocolInstance.setVoiceBroadcast(1)); } return protocolInstance.setVoiceBroadcast(1); } return protocolInstance.setVoiceBroadcast(on ? 1 : 0); } // 语音播放(HTTP 透传 data,便于后端识别 mode) export function deviceVoiceBroadcast(data) { const httpData = data && typeof data === 'object' ? { ...data } : data; if (httpData && typeof httpData === 'object') delete httpData.voiceStrobeAlarm; return execWithBleFirst( () => bleVoiceBroadcastChain(data).then(() => ({ code: 200, msg: '操作成功(蓝牙)' })), () => request({ url: `/app/hby100j/device/voiceBroadcast`, method: 'post', data: httpData }), '语音播报' ); }