完善100J蓝牙

This commit is contained in:
微微一笑
2026-03-19 11:41:17 +08:00
parent a9848bd299
commit ebe126d826
3 changed files with 192 additions and 80 deletions

View File

@ -1,4 +1,5 @@
import request from '@/utils/request'
import Common from '@/utils/Common.js'
// ================== 蓝牙协议封装类 ==================
class HBY100JProtocol {
@ -205,45 +206,110 @@ class HBY100JProtocol {
return reject(new Error('缺少文件地址或本地路径'));
}
const isLocalPath = !/^https?:\/\//i.test(fileUrlOrLocalPath);
if (onProgress) onProgress(1);
const readFromPath = (path) => {
if (typeof plus === 'undefined' || !plus.io) {
reject(new Error('当前环境不支持文件读取'));
return;
}
plus.io.resolveLocalFileSystemURL(path, (entry) => {
const doSend = (bytes) => {
this._sendVoiceChunks(bytes, fileType, CHUNK_SIZE, onProgress)
.then(resolve).catch(reject);
};
// App 端 getFileSystemManager 未实现,直接用 plus.io.requestFileSystem+getFile
readFromPathPlus(path, doSend, reject);
};
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);
this._sendVoiceChunks(bytes, fileType, CHUNK_SIZE, onProgress)
.then(resolve).catch(reject);
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) => {
if (typeof plus === 'undefined' || !plus.io) {
console.error('[100J-蓝牙] 当前环境不支持文件读取(plus.io)');
reject(new Error('当前环境不支持文件读取'));
return;
}
// _downloads/ 用 requestFileSystem+getFile避免 resolveLocalFileSystemURL 卡住)
if (path && path.startsWith('_downloads/')) {
const fileName = path.replace(/^_downloads\//, '');
plus.io.requestFileSystem(plus.io.PUBLIC_DOWNLOADS, (fs) => {
fs.root.getFile(fileName, {}, (entry) => readFileEntry(entry, doSend, reject), (err) => reject(err));
}, (err) => reject(err));
return;
}
// _doc/ 用 requestFileSystem(PRIVATE_DOC),逐级 getDirectory 再 getFile嵌套路径兼容
if (path && path.startsWith('_doc/')) {
const relPath = path.replace(/^_doc\//, '');
const parts = relPath.split('/');
const fileName = parts.pop();
const dirs = parts;
plus.io.requestFileSystem(plus.io.PRIVATE_DOC, (fs) => {
let cur = fs.root;
const next = (i) => {
if (i >= dirs.length) {
cur.getFile(fileName, { create: false }, (entry) => readFileEntry(entry, doSend, reject), (err) => reject(err));
return;
}
cur.getDirectory(dirs[i], { create: false }, (dir) => { cur = dir; next(i + 1); }, (err) => reject(err));
};
next(0);
}, (err) => reject(err));
return;
}
// 其他路径兜底
let resolvePath = path;
if (path && path.startsWith('/') && !path.startsWith('file://')) resolvePath = 'file://' + path;
plus.io.resolveLocalFileSystemURL(resolvePath, (entry) => readFileEntry(entry, doSend, reject), (err) => reject(err));
};
if (isLocalPath) {
// 本地路径:无网络时直接读取
readFromPath(fileUrlOrLocalPath);
} else {
// 网络 URL需下载后读取
uni.downloadFile({
url: fileUrlOrLocalPath,
// 网络 URL优先用 uni.request 直接拉取 ArrayBuffer类似 100 设备,无文件 IO失败再走 downloadFile
let fetchUrl = fileUrlOrLocalPath;
if (fetchUrl.startsWith('http://')) fetchUrl = 'https://' + fetchUrl.slice(7);
uni.request({
url: fetchUrl,
method: 'GET',
responseType: 'arraybuffer',
timeout: 60000,
success: (res) => {
if (res.statusCode !== 200) {
reject(new Error('下载失败: ' + res.statusCode));
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) => {
this._sendVoiceChunks(b, fileType, CHUNK_SIZE, onProgress).then(resolve).catch(reject);
};
doSend(bytes);
return;
}
readFromPath(res.tempFilePath);
}
fallbackDownload();
},
fail: () => fallbackDownload()
});
const fallbackDownload = () => {
uni.downloadFile({
url: fetchUrl,
success: (res) => {
if (res.statusCode !== 200 || !res.tempFilePath) {
reject(new Error('下载失败: ' + (res.statusCode || '无路径')));
return;
}
Common.moveFileToDownloads(res.tempFilePath).then((p) => readFromPath(p)).catch(() => readFromPath(res.tempFilePath));
},
fail: (err) => reject(err)
});
};
}
});
}
@ -251,41 +317,49 @@ class HBY100JProtocol {
_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 DELAY_AFTER_START = 200; // 开始包后、等设备响应后再发的缓冲(ms)
const DELAY_PACKET = 200; // 数据包间延时(ms)设备收不全时适当加大
const toHex = (arr) => Array.from(arr).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ');
console.log('[100J-蓝牙] 语音文件总大小:', total, '字节, fileType=', ft);
if (onProgress) onProgress(0);
const bleToolPromise = import('@/utils/BleHelper.js').then(m => m.default.getBleTool());
const send = (dataBytes) => {
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];
const waitPromise = this.waitForFileResponse(1000);
return send(startData)
.then(() => waitPromise)
.then(() => delay(DELAY_AFTER_START))
return send(startData, ' 开始包')
.then(() => { if (onProgress) onProgress(1); return waitPromise; })
.then(() => { if (onProgress) onProgress(2); return delay(DELAY_AFTER_START); })
.then(() => {
let seq = 0;
const sendNext = (offset) => {
if (offset >= total) {
return delay(DELAY_PACKET).then(() => send([ft, 2]));
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(() => {
return send(chunkData, ` #${seq} 数据包`).then(() => {
seq++;
if (onProgress) onProgress(Math.min(100, Math.floor((offset + chunk.length) / total * 100)));
const pct = Math.round((offset + chunk.length) / total * 100);
if (onProgress) onProgress(Math.min(99, Math.max(3, pct)));
return delay(DELAY_PACKET).then(() => sendNext(offset + chunk.length));
});
};
return sendNext(0);
})
.then(() => delay(DELAY_PACKET))
.then(() => {
if (onProgress) onProgress(100);
return { code: 200, msg: '语音文件已通过蓝牙上传' };
@ -382,8 +456,8 @@ export function deviceDeleteAudioFile(params) {
})
}
// 更新语音,使用语音(优先蓝牙:有 fileUrl 或 localPath 且蓝牙连接时通过蓝牙上传,否则走 4G
// localPath无网络时本地文件路径可直接通过蓝牙发送
// 更新语音/使用语音蓝牙优先4G 兜底(不影响原有 4G 音频下发
// 有 fileUrl 或 localPath 且蓝牙可用时走蓝牙;否则或蓝牙失败时走 4G与原先逻辑一致
export function deviceUpdateVoice(data) {
const httpExec = () => request({
url: `/app/hby100j/device/updateVoice`,
@ -396,9 +470,9 @@ export function deviceUpdateVoice(data) {
const hasFileUrl = fileUrl && typeof fileUrl === 'string' && fileUrl.length > 0;
const fileSource = hasLocalPath ? localPath : (hasFileUrl ? fileUrl : null);
if (!fileSource) {
return httpExec(); // 无文件源直接 4G
return httpExec(); // 无文件源直接 4G(原有逻辑)
}
const bleExec = () => protocolInstance.uploadVoiceFileBle(fileSource, 1, data.onProgress); // fileType=1 语音
const bleExec = () => protocolInstance.uploadVoiceFileBle(fileSource, 1, data.onProgress);
return execWithBleFirst(bleExec, httpExec, '语音文件上传');
}
// 100J信息
@ -409,31 +483,15 @@ export function deviceDetail(id) {
})
}
// 优先蓝牙:未连接时尝试重连;蓝牙发送失败时回退4G
// 蓝牙优先、4G 兜底:未连接时尝试重连;蓝牙失败时回退 4G(保持原有 4G 通讯不变)
function execWithBleFirst(bleExec, httpExec, logName) {
const doBle = () => {
return bleExec().then(res => ({ ...(res || {}), _channel: 'ble' }));
};
const do4G = () => {
console.log('[100J-4G]', logName, '已通过HTTP发送', '(蓝牙不可用)');
return httpExec().then(res => { res._channel = '4g'; return res; });
};
const doBle = () => bleExec().then(res => ({ ...(res || {}), _channel: 'ble' }));
const do4G = () => httpExec().then(res => { res._channel = '4g'; return res; });
if (protocolInstance.isBleConnected) {
console.log('[100J-蓝牙]', logName, '(连接正常)');
return doBle().catch(err => {
console.log('[100J] 蓝牙发送失败回退4G', err);
return do4G();
});
return doBle().catch(() => { console.log('[100J] 蓝牙失败回退4G'); return do4G(); });
}
return tryReconnectBle(2500).then(reconnected => {
if (reconnected) {
console.log('[100J-蓝牙]', logName, '(重连成功)');
return doBle().catch(err => {
console.log('[100J] 蓝牙发送失败回退4G', err);
return do4G();
});
}
return do4G();
return reconnected ? doBle().catch(() => { console.log('[100J] 蓝牙失败回退4G'); return do4G(); }) : do4G();
});
}

View File

@ -106,6 +106,7 @@
deviceDeleteAudioFile,
deviceUpdateVoice
} from '@/api/100J/HBY100-J.js'
import { baseURL } from '@/utils/request.js'
import {
showLoading,
hideLoading,
@ -237,7 +238,9 @@
if (!deviceId) return;
const mergeLocal = (serverList) => {
const key = `100J_local_audio_${deviceId}`;
const cacheKey = `100J_local_path_cache_${deviceId}`;
const localList = uni.getStorageSync(key) || [];
const pathCache = uni.getStorageSync(cacheKey) || {};
const localMapped = localList.map(item => ({
...item,
fileNameExt: item.name || '本地语音',
@ -246,7 +249,12 @@
useStatus: 0,
_isLocal: true
}));
return [...localMapped, ...(serverList || [])];
const enriched = (serverList || []).map(item => {
const urlKey = item.fileUrl || item.url || item.filePath || item.audioUrl || item.ossUrl;
const localPath = pathCache[urlKey] || pathCache[item.id];
return localPath ? { ...item, localPath } : item;
});
return [...localMapped, ...enriched];
};
deviceVoliceList({ deviceId }).then((res) => {
if (res.code == 200) {
@ -486,10 +494,17 @@
Apply(item, index) {
this.updateProgress = 0;
this.isUpdating = true;
// 本地项优先用 localPath云端项用 fileUrl兼容多种字段名相对路径补全 baseURL
let fileUrl = '';
if (!item._isLocal) {
const raw = item.fileUrl || item.url || item.filePath || item.audioUrl || item.ossUrl || '';
fileUrl = (typeof raw === 'string' && raw) ? (raw.startsWith('/') ? (baseURL + raw) : raw) : '';
}
const localPath = (item.localPath && typeof item.localPath === 'string') ? item.localPath : '';
const data = {
id: item.id,
fileUrl: item._isLocal ? '' : (item.fileUrl || item.url),
localPath: item._isLocal ? item.localPath : '',
fileUrl,
localPath,
onProgress: (p) => { this.updateProgress = p; }
};
// 整体超时 60 秒仅影响蓝牙上传4G HTTP 很快返回)
@ -502,11 +517,15 @@
}, 60000);
deviceUpdateVoice(data).then((RES) => {
clearTimeout(overallTimer);
console.log(RES, 'RES');
if (RES.code == 200) {
// 更新列表选中状态:当前项设为使用中,其他项取消
const targetId = item.id || item.Id;
this.dataListA.forEach(it => {
it.useStatus = ((it.id || it.Id) === targetId) ? 1 : 0;
});
// 蓝牙上传:进度已由 onProgress 更新,直接完成
if (RES._channel === 'ble') {
uni.showToast({ title: '升级完成!', icon: 'success', duration: 2000 });
uni.showToast({ title: '音频上传成功', icon: 'success', duration: 2000 });
this.isUpdating = false;
setTimeout(() => { uni.navigateBack(); }, 1500);
return;
@ -514,7 +533,7 @@
// 4G订阅 MQTT 获取设备端进度6 秒超时
this.upgradeTimer = setTimeout(() => {
if (this.isUpdating) {
uni.showToast({ title: '升级进度同步超时', icon: 'none', duration: 2000 });
uni.showToast({ title: '音频进度同步超时', icon: 'none', duration: 2000 });
this.isUpdating = false;
this.updateProgress = 0;
}
@ -530,7 +549,12 @@
this.updateProgress = progress;
if (progress === 100) {
clearTimeout(this.upgradeTimer);
uni.showToast({ title: '升级完成!', icon: 'success', duration: 2000 });
// 更新列表选中状态4G 成功时)
const targetId = item.id || item.Id;
this.dataListA.forEach(it => {
it.useStatus = ((it.id || it.Id) === targetId) ? 1 : 0;
});
uni.showToast({ title: '音频上传成功', icon: 'success', duration: 2000 });
this.isUpdating = false;
setTimeout(() => { uni.navigateBack(); }, 1500);
}

View File

@ -422,12 +422,14 @@
}, 1200);
},
// 无网络时保存到本地,供蓝牙直接发送(不依赖 OSS
// 将临时文件复制到持久化目录 _doc/100J_audio/,避免被系统清理
saveLocalForBle(filePath) {
const deviceId = these.Status.ID;
if (!deviceId) return;
const doSave = (persistentPath) => {
const item = {
...these.cEdit,
localPath: filePath,
localPath: persistentPath,
fileUrl: '',
deviceId,
id: 'local_' + these.cEdit.Id,
@ -441,6 +443,21 @@
these.AudioData.tempFilePath = "";
these.Status.isRecord = false;
uni.navigateBack();
};
if (typeof plus !== 'undefined' && plus.io) {
const fileName = 'audio_' + (these.cEdit.Id || Date.now()) + '.mp3';
plus.io.resolveLocalFileSystemURL(filePath, (entry) => {
plus.io.resolveLocalFileSystemURL('_doc/', (docEntry) => {
docEntry.getDirectory('100J_audio', { create: true }, (dirEntry) => {
entry.copyTo(dirEntry, fileName, (newEntry) => {
doSave(newEntry.fullPath);
}, () => { doSave(filePath); });
}, () => { doSave(filePath); });
}, () => { doSave(filePath); });
}, () => { doSave(filePath); });
} else {
doSave(filePath);
}
},
// 保存录音并上传(已修复文件格式问题)
uploadLuYin() {
@ -507,6 +524,19 @@
}
const resData = JSON.parse(res.data);
if (resData.code === 200) {
// 缓存本地路径Apply 时优先用本地文件走蓝牙,避免下载失败
const deviceId = these.Status.ID;
if (deviceId) {
const cacheKey = `100J_local_path_cache_${deviceId}`;
const d = resData.data;
const fileUrl = (d && typeof d === 'object' && d.fileUrl) || (typeof d === 'string' ? d : '');
if (filePath) {
let cache = uni.getStorageSync(cacheKey) || {};
if (fileUrl) cache[fileUrl] = filePath;
if (d && typeof d === 'object' && d.id) cache[d.id] = filePath;
uni.setStorageSync(cacheKey, cache);
}
}
// 合并两个存储操作
Promise.all([
new Promise((resolve, reject) => {