Files
APP/api/100J/HBY100-J.js

1248 lines
57 KiB
JavaScript
Raw Normal View History

2026-02-02 18:11:52 +08:00
import request from '@/utils/request'
2026-03-19 11:41:17 +08:00
import Common from '@/utils/Common.js'
2026-03-26 19:20:52 +08:00
/**
* 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;
}
}
2026-03-27 09:53:17 +08:00
/** 从 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;
2026-03-18 18:09:31 +08:00
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 = '') {
2026-03-26 19:20:52 +08:00
this.isBleConnected = !!status;
if (bleDeviceId) {
this.bleDeviceId = bleDeviceId;
2026-03-26 19:20:52 +08:00
} else if (!status) {
// 断开时必须清空,否则 execWithBleFirst 误判「有 id 未连」且 uploadVoiceFileBle 仍可能带旧 id
this.bleDeviceId = '';
}
}
2026-03-24 16:18:17 +08:00
/** 协议单字节:界面常传字符串或 -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;
}
2026-03-31 13:57:21 +08:00
/**
* @param {Uint8Array|ArrayBuffer} buffer
* @param {{ skipSideEffects?: boolean }} [options] skipSideEffects=true仅解析字段不打日志不触发 onNotify/文件回调 BleReceive 与设备页 bleValueNotify 双订阅时避免重复
*/
parseBleData(buffer, options = {}) {
const skipSideEffects = !!options.skipSideEffects;
2026-03-18 15:04:49 +08:00
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];
2026-03-18 15:04:49 +08:00
// 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 };
2026-03-31 13:57:21 +08:00
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);
}
2026-03-18 15:04:49 +08:00
return result;
}
if (header !== 0xFB || tail !== 0xFF) return null; // 校验头尾
const funcCode = view[1];
const data = view.slice(2, view.length - 1);
2026-03-18 15:04:49 +08:00
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;
2026-03-18 18:09:31 +08:00
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] FFstatus: 1=成功 2=失败
if (data.length >= 1) result.fileType = data[0];
if (data.length >= 2) result.fileStatus = data[1]; // 1=Success, 2=Failure
2026-03-31 13:57:21 +08:00
if (!skipSideEffects && this._fileResponseResolve) this._fileResponseResolve(result);
2026-03-18 18:09:31 +08:00
break;
2026-03-11 14:08:14 +08:00
case 0x04:
2026-03-30 17:45:49 +08:00
// 5.5 获取设备电源状态: 容量8B + 电压8B + 百分比1B + 车载1B + 续航2B(分钟) + [充电状态1B] + FF
// 充电状态为固件新增,在续航之后;旧固件仅 20 字节 payload不影响 [16..19] 字段
2026-03-18 15:04:49 +08:00
if (data.length >= 20) {
result.batteryPercentage = data[16];
result.vehiclePower = data[17];
result.batteryRemainingTime = data[18] | (data[19] << 8); // 小端序,单位分钟
2026-03-11 14:08:14 +08:00
}
2026-03-30 17:45:49 +08:00
if (data.length >= 21) {
result.chargingStatus = data[20]; // 0未充电 1充电中 2已充满
}
2026-03-11 14:08:14 +08:00
break;
case 0x06:
// 06: 语音播报响应
result.voiceBroadcast = data[0];
break;
case 0x09:
// 09: 修改音量响应
result.volume = data[0];
break;
case 0x0A:
2026-03-11 14:08:14 +08:00
// 0A: 爆闪模式响应
result.strobeEnable = data[0];
result.strobeMode = data[1];
break;
2026-03-11 14:08:14 +08:00
case 0x0B:
// 0B: 修改警示灯爆闪频率响应
result.strobeFrequency = data[0];
break;
case 0x0C:
2026-03-11 14:08:14 +08:00
// 0C: 强制声光报警响应
result.alarmEnable = data[0];
result.alarmMode = data[1];
break;
case 0x0D:
2026-03-11 14:08:14 +08:00
// 0D: 警示灯 LED 亮度调节响应
result.redBrightness = data[0];
result.blueBrightness = data[1];
result.yellowBrightness = data[2];
break;
case 0x0E:
2026-03-11 14:08:14 +08:00
// 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;
}
2026-03-18 15:04:49 +08:00
const funcNames = { 0x01: '复位', 0x02: '基础信息', 0x03: '位置', 0x04: '电源状态', 0x05: '文件更新', 0x06: '语音播报', 0x09: '音量', 0x0A: '爆闪模式', 0x0B: '爆闪频率', 0x0C: '强制报警', 0x0D: 'LED亮度', 0x0E: '工作方式' };
const name = funcNames[funcCode] || ('0x' + funcCode.toString(16));
2026-03-31 13:57:21 +08:00
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; // 结尾
2026-03-18 15:04:49 +08:00
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]); }
2026-03-18 15:04:49 +08:00
getBasicInfo() { return this.sendBleData(0x02, []); }
getLocation() { return this.sendBleData(0x03, []); }
getPowerStatus() { return this.sendBleData(0x04, []); }
2026-03-24 16:18:17 +08:00
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, []); }
2026-03-18 18:09:31 +08:00
// 0x05 文件上传:分片传输,协议 FA 05 [fileType] [phase] [data...] FF
// fileType: 1=语音 2=图片 3=动图 4=OTA
// phase: 0=开始 1=数据 2=结束
2026-03-27 09:53:17 +08:00
// 每包最大负载见 uploadVoiceFileBle 内 CHUNK_SIZE需与 MTU 匹配)
2026-03-18 18:09:31 +08:00
// 支持 fileUrl(需网络下载) 或 localPath(无网络时本地文件)
2026-03-26 19:20:52 +08:00
// 说明:下发的是已录制/已落盘的 MP3二进制分片经 GATT 写特征;非 A2DP/HFP 等「蓝牙录音实时流」
// meta.voiceListId若存在 uni 中 100J_voice_b64_* 缓存(与 HBY100 存 Storage 同理),优先用缓存字节下发
uploadVoiceFileBle(fileUrlOrLocalPath, fileType = 1, onProgress, meta = null) {
2026-03-27 09:53:17 +08:00
// 协议 5.6:单包负载最大 500B加 FA/05/FF 等约 507B真机需已协商足够 MTU见 BleHelper setBLEMTU 512
const CHUNK_SIZE = 500;
2026-03-26 19:20:52 +08:00
const BLE_CACHE_SENTINEL = '__100J_BLE_CACHE__';
2026-03-18 18:09:31 +08:00
return new Promise((resolve, reject) => {
2026-03-26 19:20:52 +08:00
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) : ''
});
2026-03-18 18:09:31 +08:00
if (!this.isBleConnected || !this.bleDeviceId) {
return reject(new Error('蓝牙未连接'));
}
2026-03-26 19:20:52 +08:00
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;
}
2026-03-27 09:53:17 +08:00
console.warn('[100J-蓝牙] 未命中 uni 缓存将尝试读本地文件。key=', voiceBleCacheStorageKey(did, voiceListId), '若保存后立刻使用仍失败,请稍等再试或重新保存');
2026-03-26 19:20:52 +08:00
}
if (fileUrlOrLocalPath === BLE_CACHE_SENTINEL) {
return reject(new Error('本地语音缓存不存在或已失效,请重新保存录音'));
}
2026-03-18 18:09:31 +08:00
if (!fileUrlOrLocalPath) {
return reject(new Error('缺少文件地址或本地路径'));
}
2026-03-19 11:41:17 +08:00
if (onProgress) onProgress(1);
2026-03-26 19:20:52 +08:00
let localReadWatchdog = null;
const clearLocalReadWatchdog = () => {
if (localReadWatchdog) {
clearTimeout(localReadWatchdog);
localReadWatchdog = null;
}
};
2026-03-18 18:09:31 +08:00
const readFromPath = (path) => {
2026-03-19 11:41:17 +08:00
const doSend = (bytes) => {
2026-03-26 19:20:52 +08:00
clearLocalReadWatchdog();
if (voiceListId !== '' && did && bytes && bytes.length) {
put100JVoiceBleCache(did, voiceListId, bytes);
}
2026-03-19 11:41:17 +08:00
this._sendVoiceChunks(bytes, fileType, CHUNK_SIZE, onProgress)
.then(resolve).catch(reject);
};
2026-03-26 19:20:52 +08:00
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);
2026-03-19 11:41:17 +08:00
};
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) => {
2026-03-26 19:20:52 +08:00
const tryUniReadFile = (onFail) => {
2026-03-27 09:53:17 +08:00
// 先走 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);
2026-03-26 19:20:52 +08:00
return;
}
2026-03-27 09:53:17 +08:00
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);
2026-03-26 19:20:52 +08:00
return;
}
2026-03-27 09:53:17 +08:00
onFail();
},
fail: () => onFail()
});
} catch (e) {
onFail();
}
});
2026-03-26 19:20:52 +08:00
};
2026-03-27 09:53:17 +08:00
// _downloads/resolve 与 requestFileSystem 并行竞速,避免单一路径在部分机型上长期无回调
2026-03-19 11:41:17 +08:00
if (path && path.startsWith('_downloads/')) {
const fileName = path.replace(/^_downloads\//, '');
2026-03-26 19:20:52 +08:00
tryUniReadFile(() => {
if (typeof plus === 'undefined' || !plus.io) {
reject(new Error('当前环境不支持文件读取'));
return;
}
2026-03-27 09:53:17 +08:00
console.log('[100J-蓝牙] _downloads 并行 resolve+PUBLIC_DOWNLOADS, fileName=', fileName);
2026-03-26 19:20:52 +08:00
let finished = false;
const outerMs = 20000;
const outerT = setTimeout(() => {
if (finished) return;
finished = true;
reject(new Error('读取下载目录语音超时,请重新保存录音后重试'));
}, outerMs);
2026-03-27 09:53:17 +08:00
const win = (bytes) => {
2026-03-26 19:20:52 +08:00
if (finished) return;
2026-03-27 09:53:17 +08:00
if (!bytes || !(bytes.length > 0)) return;
2026-03-26 19:20:52 +08:00
finished = true;
clearTimeout(outerT);
doSend(bytes);
};
2026-03-27 09:53:17 +08:00
let url = path;
try {
if (plus.io.convertLocalFileSystemURL) {
const c = plus.io.convertLocalFileSystemURL(path);
if (c) url = c;
2026-03-26 19:20:52 +08:00
}
2026-03-27 09:53:17 +08:00
} 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, () => {});
}, () => {});
2026-03-26 19:20:52 +08:00
plus.io.requestFileSystem(plus.io.PUBLIC_DOWNLOADS, (fs) => {
2026-03-27 09:53:17 +08:00
if (finished) return;
2026-03-26 19:20:52 +08:00
fs.root.getFile(fileName, {}, (entry) => {
if (finished) return;
2026-03-27 09:53:17 +08:00
readFileEntry(entry, win, () => {});
}, () => {});
}, () => {});
2026-03-26 19:20:52 +08:00
});
2026-03-19 11:41:17 +08:00
return;
}
2026-03-27 09:53:17 +08:00
// _doc/PRIVATE_DOC 逐级与 resolve 并行,谁先读到有效字节谁胜出
2026-03-19 11:41:17 +08:00
if (path && path.startsWith('_doc/')) {
const relPath = path.replace(/^_doc\//, '');
const parts = relPath.split('/');
const fileName = parts.pop();
const dirs = parts;
2026-03-26 19:20:52 +08:00
tryUniReadFile(() => {
if (typeof plus === 'undefined' || !plus.io) {
reject(new Error('当前环境不支持文件读取'));
return;
}
2026-03-27 09:53:17 +08:00
console.log('[100J-蓝牙] _doc 并行 resolve+PRIVATE_DOC, path=', path.slice(0, 96));
2026-03-26 19:20:52 +08:00
let finished = false;
const outerMs = 20000;
const outerT = setTimeout(() => {
if (finished) return;
finished = true;
reject(new Error('读取本地语音超时(文档目录),请重新保存录音后重试'));
}, outerMs);
2026-03-27 09:53:17 +08:00
const win = (bytes) => {
2026-03-26 19:20:52 +08:00
if (finished) return;
2026-03-27 09:53:17 +08:00
if (!bytes || !(bytes.length > 0)) return;
2026-03-26 19:20:52 +08:00
finished = true;
clearTimeout(outerT);
doSend(bytes);
2026-03-18 18:09:31 +08:00
};
2026-03-26 19:20:52 +08:00
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) => {
2026-03-27 09:53:17 +08:00
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);
}, () => {});
2026-03-26 19:20:52 +08:00
});
return;
}
if (typeof plus === 'undefined' || !plus.io) {
console.error('[100J-蓝牙] 当前环境不支持 plus.io且非 _doc/_downloads 相对路径');
reject(new Error('当前环境不支持文件读取'));
2026-03-19 11:41:17 +08:00
return;
}
2026-03-26 19:20:52 +08:00
// 其他路径兜底:部分机型 resolve 长期无回调,限时避免与外层读流程叠死
// 与 Common.moveFileToDownloads 一致uni 临时路径先 convert 再 resolve
2026-03-19 11:41:17 +08:00
let resolvePath = path;
2026-03-26 19:20:52 +08:00
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);
});
2026-03-18 18:09:31 +08:00
};
2026-03-26 19:20:52 +08:00
const startUrlFetch = () => {
2026-03-19 11:41:17 +08:00
let fetchUrl = fileUrlOrLocalPath;
if (fetchUrl.startsWith('http://')) fetchUrl = 'https://' + fetchUrl.slice(7);
2026-03-19 12:37:29 +08:00
if (onProgress) onProgress(2);
2026-03-19 11:41:17 +08:00
const fallbackDownload = () => {
uni.downloadFile({
url: fetchUrl,
success: (res) => {
if (res.statusCode !== 200 || !res.tempFilePath) {
2026-03-24 16:18:17 +08:00
if (onProgress) onProgress(0);
2026-03-19 11:41:17 +08:00
reject(new Error('下载失败: ' + (res.statusCode || '无路径')));
return;
}
Common.moveFileToDownloads(res.tempFilePath).then((p) => readFromPath(p)).catch(() => readFromPath(res.tempFilePath));
},
2026-03-24 16:18:17 +08:00
fail: (err) => {
if (onProgress) onProgress(0);
reject(err || new Error('下载失败'));
}
2026-03-19 11:41:17 +08:00
});
};
2026-03-24 16:18:17 +08:00
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) => {
2026-03-26 19:20:52 +08:00
if (voiceListId !== '' && did && b && b.length) {
put100JVoiceBleCache(did, voiceListId, b);
}
2026-03-24 16:18:17 +08:00
this._sendVoiceChunks(b, fileType, CHUNK_SIZE, onProgress).then(resolve).catch(reject);
};
doSend(bytes);
return;
}
}
fallbackDownload();
})
.catch(() => fallbackDownload());
2026-03-26 19:20:52 +08:00
};
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('无法检测网络,请连接网络后重试'));
}
});
2026-03-18 18:09:31 +08:00
}
});
}
_sendVoiceChunks(bytes, fileType, chunkSize, onProgress) {
const total = bytes.length;
const ft = (fileType & 0xFF) || 1;
2026-03-19 12:37:29 +08:00
const DELAY_AFTER_START = 80; // 开始包后、等设备响应后再发的缓冲(ms)
const DELAY_PACKET = 80; // 数据包间延时(ms)参考6155
2026-03-19 14:36:17 +08:00
const toHex = (arr) => Array.from(arr).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ');
console.log('[100J-蓝牙] 语音下发总大小:', total, '字节, fileType=', ft);
2026-03-27 10:13:52 +08:00
// 进度单调递增:前段固定 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);
2026-03-18 18:09:31 +08:00
const bleToolPromise = import('@/utils/BleHelper.js').then(m => m.default.getBleTool());
2026-03-19 14:36:17 +08:00
let bleRef = null;
const send = (dataBytes, label = '') => {
2026-03-18 18:09:31 +08:00
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;
2026-03-19 14:36:17 +08:00
const hex = toHex(v);
const preview = v.length <= 32 ? hex : hex.slice(0, 96) + '...';
console.log(`[100J-蓝牙] 下发${label}${v.length}字节:`, preview);
2026-03-18 18:09:31 +08:00
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];
2026-03-27 09:53:17 +08:00
// 单包约 507B500 负载),依赖 MTUAndroid 上为整包 write
2026-03-26 19:20:52 +08:00
const waitPromise = this.waitForFileResponse(2500);
const prepMtuThenSend = (ble) => {
const run = () => {
bleRef = ble;
ble.setVoiceUploading(true);
return send(startData, ' 开始包')
2026-03-27 10:13:52 +08:00
.then(() => waitPromise)
.then(() => delay(DELAY_AFTER_START))
2026-03-19 14:36:17 +08:00
.then(() => {
2026-03-27 10:13:52 +08:00
emitProgress(8);
2026-03-19 14:36:17 +08:00
let seq = 0;
const sendNext = (offset) => {
if (offset >= total) {
2026-03-27 10:13:52 +08:00
return delay(DELAY_PACKET)
.then(() => send([ft, 2], ' 结束包'))
.then(() => { emitProgress(99); });
2026-03-19 14:36:17 +08:00
}
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++;
2026-03-27 10:13:52 +08:00
const doneRatio = (offset + chunk.length) / total;
emitProgress(8 + Math.round(doneRatio * 87));
2026-03-19 14:36:17 +08:00
return delay(DELAY_PACKET).then(() => sendNext(offset + chunk.length));
});
};
return sendNext(0);
})
.then(() => {
2026-03-27 10:13:52 +08:00
emitProgress(100);
2026-03-19 14:36:17 +08:00
return { code: 200, msg: '语音文件已通过蓝牙上传' };
});
2026-03-26 19:20:52 +08:00
};
try {
if (typeof plus !== 'undefined' && plus.os && plus.os.name === 'Android' && ble.setMtu) {
return ble.setMtu(this.bleDeviceId)
2026-03-27 09:53:17 +08:00
.catch((e) => {
console.warn('[100J-蓝牙] setBLEMTU 失败,大数据包可能无法一次写入,已用较小分片缓解:', e && (e.message || e));
})
2026-03-26 19:20:52 +08:00
.then(() => new Promise((r) => setTimeout(r, 350)))
.then(run);
}
} catch (e) {}
return run();
};
return bleToolPromise.then(ble => prepMtuThenSend(ble)).finally(() => {
2026-03-19 14:36:17 +08:00
if (bleRef) bleRef.setVoiceUploading(false);
});
2026-03-18 18:09:31 +08:00
}
}
// ================== 全局单例与状态管理 ==================
const protocolInstance = new HBY100JProtocol();
// 暴露给页面:更新蓝牙连接状态
export function updateBleStatus(isConnected, bleDeviceId, deviceId) {
protocolInstance.setBleConnectionStatus(isConnected, bleDeviceId);
protocolInstance.deviceId = deviceId;
2026-03-18 15:04:49 +08:00
console.log('[100J] 蓝牙状态:', isConnected ? '已连接(后续指令走蓝牙)' : '已断开(后续指令走4G)', { bleDeviceId: bleDeviceId || '-', deviceId });
}
2026-03-19 12:37:29 +08:00
// 暴露给页面:获取当前蓝牙连接状态(用于跨页面传递,确保语音管理等子页走蓝牙优先)
export function getBleStatus() {
return { isConnected: protocolInstance.isBleConnected, bleDeviceId: protocolInstance.bleDeviceId, deviceId: protocolInstance.deviceId };
}
2026-03-26 19:20:52 +08:00
/**
* 按当前设备 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) {
2026-03-27 09:53:17 +08:00
resolve();
2026-03-26 19:20:52 +08:00
return;
}
2026-03-27 09:53:17 +08:00
// 仅用 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);
2026-03-26 19:20:52 +08:00
}
2026-03-27 09:53:17 +08:00
resolve();
});
2026-03-26 19:20:52 +08:00
});
}
2026-03-31 13:57:21 +08:00
// 暴露给页面解析蓝牙接收到的数据options 见类上 parseBleData 注释)
export function parseBleData(buffer, options) {
return protocolInstance.parseBleData(buffer, options);
}
2026-03-18 15:04:49 +08:00
// 暴露给页面:蓝牙连接后主动拉取电源状态(电量、续航)
export function fetchBlePowerStatus() {
if (!protocolInstance.isBleConnected) return Promise.reject(new Error('蓝牙未连接'));
console.log('[100J-蓝牙] 拉取电源状态 已通过蓝牙发送 FA 04 FF');
return protocolInstance.getPowerStatus();
}
2026-03-18 18:09:31 +08:00
// 暴露给页面:蓝牙连接后主动拉取定位(优先蓝牙设备也会每1分钟主动上报)
export function fetchBleLocation() {
if (!protocolInstance.isBleConnected) return Promise.reject(new Error('蓝牙未连接'));
console.log('[100J-蓝牙] 拉取定位 已通过蓝牙发送 FA 03 FF');
return protocolInstance.getLocation();
}
2026-03-25 10:08:28 +08:00
// 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避免仍弹连蓝牙类误导
*/
2026-03-25 10:08:28 +08:00
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));
},
2026-03-25 10:08:28 +08:00
fail: () => resolve(false)
});
});
}
// 等待协议层出现 bleDeviceId页面后台扫描/连接中),超时则走 4G
function waitForBleConnection(maxWaitMs = WAIT_BLE_CONNECTED_MS, intervalMs = BLE_POLL_INTERVAL_MS) {
2026-03-19 12:37:29 +08:00
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);
};
2026-03-25 10:08:28 +08:00
console.log('[100J] 蓝牙未连接,短时等待扫描/连接…', maxWaitMs, 'ms');
2026-03-19 12:37:29 +08:00
tick();
});
}
2026-03-18 15:04:49 +08:00
// 暴露给页面:尝试重连蓝牙(优先策略:断线后发指令前先尝试重连)
2026-03-25 10:08:28 +08:00
export function tryReconnectBle(timeoutMs = BLE_RECONNECT_MS) {
2026-03-18 15:04:49 +08:00
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 接口 (拦截层) ==================
2026-02-03 18:55:48 +08:00
// 获取语音管理列表
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
})
}
2026-03-26 19:20:52 +08:00
// 更新语音/使用语音:蓝牙优先;云端 fileUrl 蓝牙失败时可 4G 仅传 id 触发设备拉流
// 本地 localPath 仅能通过蓝牙下发,禁止回退 4G否则服务端成功但设备无文件
function isHttpUrlString(s) {
return !!(s && /^https?:\/\//i.test(String(s).trim()));
}
2026-03-27 09:53:17 +08:00
/** 与后端约定communicationMode 0=4G1=蓝牙(云端记录本次「使用」语音的通讯方式) */
2026-02-04 15:27:43 +08:00
export function deviceUpdateVoice(data) {
2026-03-27 09:53:17 +08:00
const httpExec = (communicationMode) => request({
2026-02-04 15:27:43 +08:00
url: `/app/hby100j/device/updateVoice`,
method: 'post',
2026-03-27 09:53:17 +08:00
data: {
id: data.id,
communicationMode: communicationMode === 1 ? 1 : 0
}
2026-03-18 18:09:31 +08:00
});
2026-03-26 19:20:52 +08:00
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);
2026-03-18 18:09:31 +08:00
if (!fileSource) {
2026-03-26 19:20:52 +08:00
console.log('[100J] 语音上传:无 fileUrl/localPath仅 HTTP updateVoice不会走蓝牙传文件');
2026-03-27 09:53:17 +08:00
return httpExec(0).then((res) => { if (res && typeof res === 'object') res._channel = '4g'; return res; });
2026-03-18 18:09:31 +08:00
}
2026-03-26 19:20:52 +08:00
console.log('[100J] 语音上传:有文件源,尝试蓝牙下发文件', { isBleConnected: protocolInstance.isBleConnected, hasBleDeviceId: !!protocolInstance.bleDeviceId, local: !!hasLocalPath });
2026-03-27 09:53:17 +08:00
// 蓝牙传完文件后再调 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);
2026-03-26 19:20:52 +08:00
// 本地文件:禁止一切 4G 兜底(含蓝牙未开时),避免仅传 id 假成功
2026-03-27 09:53:17 +08:00
return execWithBleFirst(bleExec, http4g, '语音文件上传', data.onWaiting, { no4GFallback: hasLocalPath });
2026-02-04 15:27:43 +08:00
}
2026-02-03 18:55:48 +08:00
// 100J信息
2026-02-02 18:11:52 +08:00
export function deviceDetail(id) {
return request({
2026-02-04 15:27:43 +08:00
url: `/app/hby100j/device/${id}`,
2026-02-02 18:11:52 +08:00
method: 'get',
})
2026-02-04 15:27:43 +08:00
}
2026-03-19 12:37:29 +08:00
// 蓝牙优先、4G 兜底:未连接时先等待扫描/连接,再尝试重连;蓝牙失败时回退 4G
2026-03-26 19:20:52 +08:00
// opts.no4GFallback本地语音等场景禁止走 HTTP避免无网/假成功
function execWithBleFirst(bleExec, httpExec, logName, onWaiting, opts = {}) {
const no4G = !!opts.no4GFallback;
const localBleOnlyMsg = '本地语音需通过蓝牙下发,请连接设备蓝牙后重试';
2026-03-19 11:41:17 +08:00
const doBle = () => bleExec().then(res => ({ ...(res || {}), _channel: 'ble' }));
2026-03-26 19:20:52 +08:00
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();
};
2026-03-24 16:26:37 +08:00
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) {}
};
2026-03-30 16:05:03 +08:00
// 协议层认为已连:仍可能被系统蓝牙关闭/底层已断而滞后,先校验适配器,避免先发蓝牙卡超时再回退
2026-03-19 12:37:29 +08:00
if (protocolInstance.isBleConnected && protocolInstance.bleDeviceId) {
2026-03-30 16:05:03 +08:00
return getBleAdapterAvailable().then((adapterOk) => {
if (!adapterOk) {
protocolInstance.setBleConnectionStatus(false, '');
return go4GOrReject('系统蓝牙已关闭走4G');
}
console.log('[100J]', logName || '指令', '协议层已连接,走蓝牙');
return doBle().catch(onBleSendFail);
});
}
2026-03-30 16:05:03 +08:00
console.log('[100J]', logName || '指令', '协议层未就绪', { isBleConnected: protocolInstance.isBleConnected, hasBleDeviceId: !!protocolInstance.bleDeviceId });
// 无 bleDeviceId本地仅蓝牙场景仍弹「请稍候」并等扫描可走 4G 时不弹 loading、不白等 5s直接 4G
2026-03-19 12:37:29 +08:00
if (!protocolInstance.bleDeviceId) {
2026-03-25 10:08:28 +08:00
return getBleAdapterAvailable().then((adapterOk) => {
if (!adapterOk) {
2026-03-30 16:05:03 +08:00
protocolInstance.setBleConnectionStatus(false, '');
2026-03-26 19:20:52 +08:00
return go4GOrReject('系统蓝牙未开启走4G');
2026-03-25 10:08:28 +08:00
}
2026-03-30 16:05:03 +08:00
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')
);
2026-03-25 10:08:28 +08:00
});
}
2026-03-30 16:05:03 +08:00
// 有 bleDeviceId 但未连:用户刚关蓝牙/超出范围时,不再弹「请稍候」等重连 ~2s双通道在线时直接 4G
2026-03-25 10:08:28 +08:00
return getBleAdapterAvailable().then((adapterOk) => {
if (!adapterOk) {
2026-03-30 16:05:03 +08:00
protocolInstance.setBleConnectionStatus(false, '');
2026-03-26 19:20:52 +08:00
return go4GOrReject('系统蓝牙未开启走4G');
2026-03-25 10:08:28 +08:00
}
2026-03-30 16:05:03 +08:00
console.log('[100J]', logName || '指令', '蓝牙已断开直接走4G不阻塞重连');
return go4GOrReject(null);
2026-03-25 10:08:28 +08:00
});
2026-03-18 15:04:49 +08:00
}
// 爆闪模式
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 }),
'爆闪模式'
);
2026-02-04 15:27:43 +08:00
}
// 强制报警
export function deviceForceAlarmActivation(data) {
2026-03-18 15:04:49 +08:00
return execWithBleFirst(
() => protocolInstance.setForceAlarm(data.voiceStrobeAlarm, data.mode).then(() => ({ code: 200, msg: '操作成功(蓝牙)' })),
() => request({ url: `/app/hby100j/device/forceAlarmActivation`, method: 'post', data }),
'强制报警'
);
2026-02-04 15:27:43 +08:00
}
2026-02-04 15:27:43 +08:00
// 爆闪频率
export function deviceStrobeFrequency(data) {
2026-03-18 15:04:49 +08:00
return execWithBleFirst(
() => protocolInstance.setStrobeFrequency(data.frequency).then(() => ({ code: 200, msg: '操作成功(蓝牙)' })),
() => request({ url: `/app/hby100j/device/strobeFrequency`, method: 'post', data }),
'爆闪频率'
);
2026-02-04 15:27:43 +08:00
}
2026-02-04 15:27:43 +08:00
// 灯光调节亮度
export function deviceLightAdjustment(data) {
2026-03-18 15:04:49 +08:00
return execWithBleFirst(
() => protocolInstance.setLightBrightness(data.brightness, data.brightness, data.brightness).then(() => ({ code: 200, msg: '操作成功(蓝牙)' })),
() => request({ url: `/app/hby100j/device/lightAdjustment`, method: 'post', data }),
'灯光亮度'
);
2026-02-04 15:27:43 +08:00
}
// 调节音量
export function deviceUpdateVolume(data) {
2026-03-18 15:04:49 +08:00
return execWithBleFirst(
() => protocolInstance.setVolume(data.volume).then(() => ({ code: 200, msg: '操作成功(蓝牙)' })),
() => request({ url: `/app/hby100j/device/updateVolume`, method: 'post', data }),
'调节音量'
);
2026-02-04 15:27:43 +08:00
}
2026-03-27 18:07:59 +08:00
/** 蓝牙侧语音播报:自定义音(mode=7)且强制报警仍开时,需先 0x0C 关报警+模式7 再 0x06已解除则不必重复下发 0x0C */
2026-03-26 15:39:50 +08:00
function bleVoiceBroadcastChain(data) {
const on = Number(data.voiceBroadcast) === 1;
const mode = data.mode != null ? String(data.mode) : '';
if (on && mode === '7') {
2026-03-27 18:07:59 +08:00
const alarmOn = Number(data.voiceStrobeAlarm) === 1;
if (alarmOn) {
return protocolInstance.setForceAlarm(0, 7).then(() => protocolInstance.setVoiceBroadcast(1));
}
return protocolInstance.setVoiceBroadcast(1);
2026-03-26 15:39:50 +08:00
}
return protocolInstance.setVoiceBroadcast(on ? 1 : 0);
}
// 语音播放HTTP 透传 data便于后端识别 mode
2026-02-05 11:40:56 +08:00
export function deviceVoiceBroadcast(data) {
2026-03-27 18:07:59 +08:00
const httpData = data && typeof data === 'object' ? { ...data } : data;
if (httpData && typeof httpData === 'object') delete httpData.voiceStrobeAlarm;
2026-03-18 15:04:49 +08:00
return execWithBleFirst(
2026-03-26 15:39:50 +08:00
() => bleVoiceBroadcastChain(data).then(() => ({ code: 200, msg: '操作成功(蓝牙)' })),
2026-03-27 18:07:59 +08:00
() => request({ url: `/app/hby100j/device/voiceBroadcast`, method: 'post', data: httpData }),
2026-03-18 15:04:49 +08:00
'语音播报'
);
2026-02-05 11:40:56 +08:00
}