diff --git a/api/100J/HBY100-J.js b/api/100J/HBY100-J.js index 63f2d03..6e7cbf9 100644 --- a/api/100J/HBY100-J.js +++ b/api/100J/HBY100-J.js @@ -13,6 +13,24 @@ class HBY100JProtocol { 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 = '') { @@ -54,7 +72,23 @@ class HBY100JProtocol { switch (funcCode) { case 0x01: result.resetType = data[0]; break; case 0x02: break; - case 0x03: 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 (this._fileResponseResolve) this._fileResponseResolve(result); + break; case 0x04: // 5.5 获取设备电源状态: 电池容量8B + 电压8B + 百分比1B + 车载电源1B + 续航时间2B(分钟) if (data.length >= 20) { @@ -155,6 +189,108 @@ class HBY100JProtocol { setForceAlarm(enable, mode) { return this.sendBleData(0x0C, [enable, mode]); } setLightBrightness(red, blue = 0, yellow = 0) { return this.sendBleData(0x0D, [red, blue, yellow]); } getCurrentWorkMode() { return this.sendBleData(0x0E, []); } + + // 0x05 文件上传:分片传输,协议 FA 05 [fileType] [phase] [data...] FF + // fileType: 1=语音 2=图片 3=动图 4=OTA + // phase: 0=开始 1=数据 2=结束 + // 每包最大字节 蓝牙:CHUNK_SIZE=500 + // 支持 fileUrl(需网络下载) 或 localPath(无网络时本地文件) + uploadVoiceFileBle(fileUrlOrLocalPath, fileType = 1, onProgress) { + const CHUNK_SIZE = 500; // 每包有效数据,参考 6155 deviceDetail.vue + return new Promise((resolve, reject) => { + if (!this.isBleConnected || !this.bleDeviceId) { + return reject(new Error('蓝牙未连接')); + } + if (!fileUrlOrLocalPath) { + return reject(new Error('缺少文件地址或本地路径')); + } + const isLocalPath = !/^https?:\/\//i.test(fileUrlOrLocalPath); + const readFromPath = (path) => { + if (typeof plus === 'undefined' || !plus.io) { + reject(new Error('当前环境不支持文件读取')); + return; + } + plus.io.resolveLocalFileSystemURL(path, (entry) => { + entry.file((file) => { + const reader = new plus.io.FileReader(); + reader.onloadend = (e) => { + try { + const buf = e.target.result; + const bytes = new Uint8Array(buf); + this._sendVoiceChunks(bytes, fileType, CHUNK_SIZE, onProgress) + .then(resolve).catch(reject); + } catch (err) { + reject(err); + } + }; + reader.onerror = () => reject(new Error('读取文件失败')); + reader.readAsArrayBuffer(file); + }, (err) => reject(err)); + }, (err) => reject(err)); + }; + if (isLocalPath) { + // 本地路径:无网络时直接读取 + readFromPath(fileUrlOrLocalPath); + } else { + // 网络 URL:需下载后读取 + uni.downloadFile({ + url: fileUrlOrLocalPath, + success: (res) => { + if (res.statusCode !== 200) { + reject(new Error('下载失败: ' + res.statusCode)); + return; + } + readFromPath(res.tempFilePath); + }, + fail: (err) => reject(err) + }); + } + }); + } + + _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 bleToolPromise = import('@/utils/BleHelper.js').then(m => m.default.getBleTool()); + const send = (dataBytes) => { + 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; + 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]; + const waitPromise = this.waitForFileResponse(1000); + return send(startData) + .then(() => waitPromise) + .then(() => delay(DELAY_AFTER_START)) + .then(() => { + let seq = 0; + const sendNext = (offset) => { + if (offset >= total) { + return delay(DELAY_PACKET).then(() => send([ft, 2])); + } + const chunk = bytes.slice(offset, Math.min(offset + chunkSize, total)); + const chunkData = [ft, 1, seq & 0xFF, (seq >> 8) & 0xFF, ...chunk]; + return send(chunkData).then(() => { + seq++; + if (onProgress) onProgress(Math.min(100, Math.floor((offset + chunk.length) / total * 100))); + return delay(DELAY_PACKET).then(() => sendNext(offset + chunk.length)); + }); + }; + return sendNext(0); + }) + .then(() => { + if (onProgress) onProgress(100); + return { code: 200, msg: '语音文件已通过蓝牙上传' }; + }); + } } // ================== 全局单例与状态管理 ================== @@ -179,6 +315,13 @@ export function fetchBlePowerStatus() { return protocolInstance.getPowerStatus(); } +// 暴露给页面:蓝牙连接后主动拉取定位(优先蓝牙,设备也会每1分钟主动上报) +export function fetchBleLocation() { + if (!protocolInstance.isBleConnected) return Promise.reject(new Error('蓝牙未连接')); + console.log('[100J-蓝牙] 拉取定位 已通过蓝牙发送 FA 03 FF'); + return protocolInstance.getLocation(); +} + // 暴露给页面:尝试重连蓝牙(优先策略:断线后发指令前先尝试重连) export function tryReconnectBle(timeoutMs = 2500) { if (protocolInstance.isBleConnected) return Promise.resolve(true); @@ -239,13 +382,24 @@ export function deviceDeleteAudioFile(params) { }) } -// 更新语音,使用语音 +// 更新语音,使用语音(优先蓝牙:有 fileUrl 或 localPath 且蓝牙连接时通过蓝牙上传,否则走 4G) +// localPath:无网络时本地文件路径,可直接通过蓝牙发送 export function deviceUpdateVoice(data) { - return request({ + const httpExec = () => request({ url: `/app/hby100j/device/updateVoice`, method: 'post', - data:data - }) + data: { id: data.id } + }); + const localPath = data.localPath; + const fileUrl = data.fileUrl; + const hasLocalPath = localPath && typeof localPath === 'string' && localPath.length > 0; + const hasFileUrl = fileUrl && typeof fileUrl === 'string' && fileUrl.length > 0; + const fileSource = hasLocalPath ? localPath : (hasFileUrl ? fileUrl : null); + if (!fileSource) { + return httpExec(); // 无文件源直接走 4G + } + const bleExec = () => protocolInstance.uploadVoiceFileBle(fileSource, 1, data.onProgress); // fileType=1 语音 + return execWithBleFirst(bleExec, httpExec, '语音文件上传'); } // 100J信息 export function deviceDetail(id) { diff --git a/pages/100J/HBY100-J.vue b/pages/100J/HBY100-J.vue index 929253c..67deec8 100644 --- a/pages/100J/HBY100-J.vue +++ b/pages/100J/HBY100-J.vue @@ -244,7 +244,8 @@ deviceVoiceBroadcast, updateBleStatus, parseBleData, - fetchBlePowerStatus + fetchBlePowerStatus, + fetchBleLocation } from '@/api/100J/HBY100-J.js' import BleHelper from '@/utils/BleHelper.js'; var bleTool = BleHelper.getBleTool(); @@ -613,7 +614,7 @@ this.createThrottledFunctions(); // 注册蓝牙相关事件 - bleTool.addReceiveCallback(this.bleValueNotify, "HBY100J"); + bleTool.addReceiveCallback(this.bleValueNotify.bind(this), "HBY100J"); bleTool.addDisposeCallback(this.bleStateBreak, "HBY100J"); bleTool.addRecoveryCallback(this.bleStateRecovry, "HBY100J"); bleTool.addStateBreakCallback(this.bleStateBreak, "HBY100J"); @@ -1082,11 +1083,17 @@ } let bleDeviceId = res.deviceId; updateBleStatus(true, bleDeviceId, this.deviceInfo.deviceId); - // 蓝牙连接成功后主动拉取电源状态(电量、续航时间) - fetchBlePowerStatus().catch(() => {}); + // 蓝牙连接成功后主动拉取电源状态、定位(优先蓝牙,设备也会每1分钟主动上报) + // 两指令间隔 150ms,避免 writeBLECharacteristicValue:fail property not support + fetchBlePowerStatus() + .then(() => new Promise(r => setTimeout(r, 150))) + .then(() => fetchBleLocation()) + .catch(() => {}); }, previewImg(img) {}, bleValueNotify: function(receive, device, path, recArr) { //订阅消息 + // 仅处理当前设备的数据(device 为 LinkedList 中匹配 receive.deviceId 的项) + if (device && device.device && this.deviceInfo.deviceId && device.device.id != this.deviceInfo.deviceId) return; // 解析蓝牙上报数据 (协议: FC=MAC主动上报, FB=指令响应) if (!receive.bytes || receive.bytes.length < 3) return; const parsedData = parseBleData(receive.bytes); @@ -1096,16 +1103,23 @@ if (parsedData.type === 'mac' && parsedData.macAddress) { this.formData.macAddress = parsedData.macAddress; this.device.deviceMac = parsedData.macAddress; - this.deviceInfo.deviceMac = parsedData.macAddress; + this.$set(this.deviceInfo, 'deviceMac', parsedData.macAddress); return; } + // 5.4 设备位置 (0x03):主动查询响应或设备定时上报(1分钟),优先蓝牙 + // 使用 $set 确保 Vue2 能检测新增属性并触发视图更新 + if (parsedData.longitude !== undefined && parsedData.latitude !== undefined) { + this.$set(this.deviceInfo, 'longitude', parsedData.longitude); + this.$set(this.deviceInfo, 'latitude', parsedData.latitude); + } + // 5.5 获取设备电源状态 (0x04) if (parsedData.batteryPercentage !== undefined) { - this.deviceInfo.batteryPercentage = parsedData.batteryPercentage; + this.$set(this.deviceInfo, 'batteryPercentage', parsedData.batteryPercentage); } if (parsedData.batteryRemainingTime !== undefined) { - this.deviceInfo.batteryRemainingTime = parsedData.batteryRemainingTime; + this.$set(this.deviceInfo, 'batteryRemainingTime', parsedData.batteryRemainingTime); } if (this.deviceInfo.batteryPercentage <= 20) { diff --git a/pages/100J/audioManager/AudioList.vue b/pages/100J/audioManager/AudioList.vue index cedf3e5..e744e95 100644 --- a/pages/100J/audioManager/AudioList.vue +++ b/pages/100J/audioManager/AudioList.vue @@ -231,25 +231,39 @@ } }, methods: { - //语音管理列表 + //语音管理列表(合并云端 + 本地无网络保存的语音) getinitData(val, isLoadMore = false) { - let data = { - deviceId: this.device.deviceId - } - deviceVoliceList(data).then((res) => { + const deviceId = this.device.deviceId; + if (!deviceId) return; + const mergeLocal = (serverList) => { + const key = `100J_local_audio_${deviceId}`; + const localList = uni.getStorageSync(key) || []; + const localMapped = localList.map(item => ({ + ...item, + fileNameExt: item.name || '本地语音', + createTime: item._createTime || item.createTime || Common.DateFormat(new Date(), "yyyy年MM月dd日"), + fileUrl: item.fileUrl || item.localPath, + useStatus: 0, + _isLocal: true + })); + return [...localMapped, ...(serverList || [])]; + }; + deviceVoliceList({ deviceId }).then((res) => { if (res.code == 200) { this.total = res.total; - const list = res.data.map(item => ({ + const list = (res.data || []).map(item => ({ ...item, createTime: item.createTime || Common.DateFormat(new Date(), "yyyy年MM月dd日") })); - this.dataListA = list; - // 通知mescroll加载完成 - if (this.mescroll) { - this.mescroll.endBySize(list.length, this.total); - } + this.dataListA = mergeLocal(list); + if (this.mescroll) this.mescroll.endBySize(this.dataListA.length, this.total + (this.dataListA.length - list.length)); } - }) + }).catch(() => { + // 无网络时仅显示本地保存的语音 + this.dataListA = mergeLocal([]); + this.total = this.dataListA.length; + if (this.mescroll) this.mescroll.endBySize(this.dataListA.length, this.total); + }); }, createAudioPlayer(localPath) { if (innerAudioContext) { @@ -401,22 +415,24 @@ return; } let task = () => { - let data = { - fileId: item.fileId, - deviceId: this.device.deviceId + if (item._isLocal) { + // 本地项:从本地存储移除 + const key = `100J_local_audio_${this.device.deviceId}`; + let list = uni.getStorageSync(key) || []; + list = list.filter(l => l.id !== item.id && l.Id !== item.Id); + uni.setStorageSync(key, list); + uni.showToast({ title: '已删除', icon: 'none', duration: 1000 }); + this.getinitData(); + this.$refs.swipeAction.closeAll(); + return; } - deviceDeleteAudioFile(data).then((res) => { + deviceDeleteAudioFile({ fileId: item.fileId, deviceId: this.device.deviceId }).then((res) => { if (res.code == 200) { - uni.showToast({ - title: res.msg, - icon: 'none', - duration: 1000 - }); - this.getinitData() - // 关闭所有滑动项 + uni.showToast({ title: res.msg, icon: 'none', duration: 1000 }); + this.getinitData(); this.$refs.swipeAction.closeAll(); } - }) + }); } this.showPop({ showPop: true, //是否显示弹窗 @@ -468,71 +484,72 @@ } }, Apply(item, index) { - this.mqttClient = new MqttClient(); - let data = { - id: item.id - } - deviceUpdateVoice(data).then((RES) => { - console.log(RES,'RES'); - if (RES.code == 200) { + this.updateProgress = 0; + this.isUpdating = true; + const data = { + id: item.id, + fileUrl: item._isLocal ? '' : (item.fileUrl || item.url), + localPath: item._isLocal ? item.localPath : '', + onProgress: (p) => { this.updateProgress = p; } + }; + // 整体超时 60 秒(仅影响蓝牙上传,4G HTTP 很快返回) + const overallTimer = setTimeout(() => { + if (this.isUpdating) { + uni.showToast({ title: '操作超时', icon: 'none', duration: 2000 }); + this.isUpdating = false; this.updateProgress = 0; - this.isUpdating = true; + } + }, 60000); + deviceUpdateVoice(data).then((RES) => { + clearTimeout(overallTimer); + console.log(RES, 'RES'); + if (RES.code == 200) { + // 蓝牙上传:进度已由 onProgress 更新,直接完成 + if (RES._channel === 'ble') { + uni.showToast({ title: '升级完成!', icon: 'success', duration: 2000 }); + this.isUpdating = false; + setTimeout(() => { uni.navigateBack(); }, 1500); + return; + } + // 4G:订阅 MQTT 获取设备端进度,6 秒超时 + this.upgradeTimer = setTimeout(() => { + if (this.isUpdating) { + uni.showToast({ title: '升级进度同步超时', icon: 'none', duration: 2000 }); + this.isUpdating = false; + this.updateProgress = 0; + } + }, 6000); + this.mqttClient = this.mqttClient || new MqttClient(); this.mqttClient.connect(() => { - // 订阅来自设备的状态更新 const statusTopic = `status/894078/HBY100/${this.device.deviceImei}`; this.mqttClient.subscribe(statusTopic, (payload) => { - console.log(payload, 'payloadpayloadpayload'); try { - // 解析MQTT返回的payload - const payloadObj = typeof payload === 'string' ? JSON.parse( - payload) : payload; - // 取出进度值(用可选链避免字段不存在报错) + const payloadObj = typeof payload === 'string' ? JSON.parse(payload) : payload; const progress = payloadObj.data?.progress; - if (progress !== undefined && !isNaN(progress) && progress >= - 0 && progress <= 100) { + if (progress !== undefined && !isNaN(progress) && progress >= 0 && progress <= 100) { this.updateProgress = progress; - console.log('当前升级进度:', progress + '%'); - // 进度到100%时,触发升级完成逻辑 if (progress === 100) { clearTimeout(this.upgradeTimer); - uni.showToast({ - title: '升级完成!', - icon: 'success', - duration: 2000 - }); + uni.showToast({ title: '升级完成!', icon: 'success', duration: 2000 }); this.isUpdating = false; - setTimeout(() => { - uni.navigateBack(); - }, 1500); + setTimeout(() => { uni.navigateBack(); }, 1500); } } } catch (e) { - clearTimeout(this.upgradeTimer); + clearTimeout(this.upgradeTimer); console.error('解析MQTT payload失败:', e); } - }) - }) - } else { - uni.showToast({ - title: RES.msg, - icon: 'none', - duration: 1000 + }); }); - + } else { + this.isUpdating = false; + uni.showToast({ title: RES.msg || '操作失败', icon: 'none', duration: 1000 }); } - }) - this.upgradeTimer = setTimeout(() => { - // 超时后执行:隐藏进度条+提示超时+重置进度 - uni.showToast({ - title: '升级进度同步超时', - icon: 'none', - duration: 2000 - }); - this.isUpdating = false; // 关闭进度条 - this.updateProgress = 0; // 重置进度值 - // 可选:如果需要取消MQTT订阅,加这行(根据需求选择) - // this.mqttClient.unsubscribe(statusTopic); - }, 6000); // 6000ms = 6秒,时间可直接修改 + }).catch((err) => { + clearTimeout(overallTimer); + this.isUpdating = false; + uni.showToast({ title: err.message || '操作失败', icon: 'none', duration: 2000 }); + }); }, closePop: function() { this.Status.Pop.showPop = false; diff --git a/pages/100J/audioManager/Recording.vue b/pages/100J/audioManager/Recording.vue index 4847162..02efba2 100644 --- a/pages/100J/audioManager/Recording.vue +++ b/pages/100J/audioManager/Recording.vue @@ -421,6 +421,27 @@ hideLoading(these); }, 1200); }, + // 无网络时保存到本地,供蓝牙直接发送(不依赖 OSS) + saveLocalForBle(filePath) { + const deviceId = these.Status.ID; + if (!deviceId) return; + const item = { + ...these.cEdit, + localPath: filePath, + fileUrl: '', + deviceId, + id: 'local_' + these.cEdit.Id, + _createTime: these.cEdit.createTime || Common.DateFormat(new Date(), "yyyy年MM月dd日"), + _isLocal: true + }; + const key = `100J_local_audio_${deviceId}`; + let list = uni.getStorageSync(key) || []; + list.unshift(item); + uni.setStorageSync(key, list); + these.AudioData.tempFilePath = ""; + these.Status.isRecord = false; + uni.navigateBack(); + }, // 保存录音并上传(已修复文件格式问题) uploadLuYin() { // 文件类型验证 @@ -541,8 +562,10 @@ }, fail: (err) => { console.error('上传文件失败:', err); + // 无网络时保存到本地,供蓝牙直接发送 + these.saveLocalForBle(filePath); uni.showToast({ - title: '上传失败,请检查网络', + title: '网络不可用,已保存到本地,可通过蓝牙发送', icon: 'none', duration: 3000 }); diff --git a/utils/BleReceive.js b/utils/BleReceive.js index e4a7caf..1cfd593 100644 --- a/utils/BleReceive.js +++ b/utils/BleReceive.js @@ -1,4 +1,5 @@ import Common from '@/utils/Common.js' +import { parseBleData } from '@/api/100J/HBY100-J.js' class BleReceive { constructor() { @@ -11,6 +12,7 @@ class BleReceive { '/pages/670/HBY670': this.Receive_670.bind(this), '/pages/4877/BJQ4877': this.Receive_4877.bind(this), '/pages/100/HBY100': this.Receive_100.bind(this), + '/pages/100J/HBY100-J': this.Receive_100J.bind(this), '/pages/102/HBY102': this.Receive_102.bind(this) }; } @@ -670,6 +672,21 @@ class BleReceive { } + Receive_100J(receive, f, path, recArr) { + let receiveData = {}; + try { + if (!receive.bytes || receive.bytes.length < 3) return receiveData; + const parsed = parseBleData(receive.bytes); + if (!parsed) return receiveData; + if (parsed.longitude !== undefined) receiveData.longitude = parsed.longitude; + if (parsed.latitude !== undefined) receiveData.latitude = parsed.latitude; + if (parsed.batteryPercentage !== undefined) receiveData.batteryPercentage = parsed.batteryPercentage; + if (parsed.batteryRemainingTime !== undefined) receiveData.batteryRemainingTime = parsed.batteryRemainingTime; + } catch (e) { + console.log('[100J] BleReceive 解析失败', e); + } + return receiveData; + } Receive_102(receive, f, path, recArr) { let receiveData = {};