Initial commit
This commit is contained in:
734
uni_modules/ak-sbsrv/utssdk/app-android/dfu_manager.uts
Normal file
734
uni_modules/ak-sbsrv/utssdk/app-android/dfu_manager.uts
Normal file
@@ -0,0 +1,734 @@
|
||||
import { BleService } from '../interface.uts'
|
||||
import type { WriteCharacteristicOptions, DfuOptions, ControlParserResult } from '../interface.uts'
|
||||
import { DeviceManager } from './device_manager.uts'
|
||||
import { ServiceManager } from './service_manager.uts'
|
||||
import BluetoothGatt from 'android.bluetooth.BluetoothGatt'
|
||||
import BluetoothGattCharacteristic from 'android.bluetooth.BluetoothGattCharacteristic'
|
||||
import BluetoothGattDescriptor from 'android.bluetooth.BluetoothGattDescriptor'
|
||||
import UUID from 'java.util.UUID'
|
||||
|
||||
// 通用 Nordic DFU UUIDs (常见设备可能使用这些;如厂商自定义请替换)
|
||||
const DFU_SERVICE_UUID = '0000fe59-0000-1000-8000-00805f9b34fb'
|
||||
const DFU_CONTROL_POINT_UUID = '8ec90001-f315-4f60-9fb8-838830daea50'
|
||||
const DFU_PACKET_UUID = '8ec90002-f315-4f60-9fb8-838830daea50'
|
||||
|
||||
type DfuSession = {
|
||||
resolve : () => void;
|
||||
reject : (err ?: any) => void;
|
||||
onProgress ?: (p : number) => void;
|
||||
onLog ?: (s : string) => void;
|
||||
controlParser ?: (data : Uint8Array) => ControlParserResult | null;
|
||||
// Nordic 专用字段
|
||||
bytesSent ?: number;
|
||||
totalBytes ?: number;
|
||||
useNordic ?: boolean;
|
||||
// PRN (packet receipt notification) support
|
||||
prn ?: number;
|
||||
packetsSincePrn ?: number;
|
||||
prnResolve ?: () => void;
|
||||
prnReject ?: (err ?: any) => void;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export class DfuManager {
|
||||
// 会话表,用于把 control-point 通知路由到当前 DFU 流程
|
||||
private sessions : Map<string, DfuSession> = new Map();
|
||||
|
||||
// 简化:只实现最基本的 GATT-based DFU 上传逻辑,需按设备协议调整 control point 的命令/解析
|
||||
|
||||
// Emit a DFU lifecycle event for a session. name should follow Nordic listener names
|
||||
private _emitDfuEvent(deviceId : string, name : string, payload ?: any) {
|
||||
console.log('[DFU][Event]', name, deviceId, payload ?? '');
|
||||
const s = this.sessions.get(deviceId);
|
||||
if (s == null) return;
|
||||
if (typeof s.onLog == 'function') {
|
||||
try {
|
||||
const logFn = s.onLog as (msg : string) => void;
|
||||
logFn(`[${name}] ${payload != null ? JSON.stringify(payload) : ''}`);
|
||||
} catch (e) { }
|
||||
}
|
||||
if (name == 'onProgress' && typeof s.onProgress == 'function' && typeof payload == 'number') {
|
||||
try { s.onProgress(payload); } catch (e) { }
|
||||
}
|
||||
}
|
||||
|
||||
async startDfu(deviceId : string, firmwareBytes : Uint8Array, options ?: DfuOptions) : Promise<void> {
|
||||
console.log('startDfu 0')
|
||||
const deviceManager = DeviceManager.getInstance();
|
||||
const serviceManager = ServiceManager.getInstance();
|
||||
console.log('startDfu 1')
|
||||
const gatt : BluetoothGatt | null = deviceManager.getGattInstance(deviceId);
|
||||
console.log('startDfu 2')
|
||||
if (gatt == null) throw new Error('Device not connected');
|
||||
console.log('[DFU] startDfu start deviceId=', deviceId, 'firmwareBytes=', firmwareBytes != null ? firmwareBytes.length : 0, 'options=', options);
|
||||
try {
|
||||
console.log('[DFU] requesting high connection priority for', deviceId);
|
||||
gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH);
|
||||
} catch (e) {
|
||||
console.warn('[DFU] requestConnectionPriority failed', e);
|
||||
}
|
||||
|
||||
// 发现服务并特征
|
||||
// ensure services discovered before accessing GATT; serviceManager exposes Promise-based API
|
||||
await serviceManager.getServices(deviceId, null);
|
||||
console.log('[DFU] services ensured for', deviceId);
|
||||
const dfuService = gatt.getService(UUID.fromString(DFU_SERVICE_UUID));
|
||||
if (dfuService == null) throw new Error('DFU service not found');
|
||||
const controlChar = dfuService.getCharacteristic(UUID.fromString(DFU_CONTROL_POINT_UUID));
|
||||
const packetChar = dfuService.getCharacteristic(UUID.fromString(DFU_PACKET_UUID));
|
||||
console.log('[DFU] dfuService=', dfuService != null ? dfuService.getUuid().toString() : null, 'controlChar=', controlChar != null ? controlChar.getUuid().toString() : null, 'packetChar=', packetChar != null ? packetChar.getUuid().toString() : null);
|
||||
if (controlChar == null || packetChar == null) throw new Error('DFU characteristics missing');
|
||||
const packetProps = packetChar.getProperties();
|
||||
const supportsWriteWithResponse = (packetProps & BluetoothGattCharacteristic.PROPERTY_WRITE) != 0;
|
||||
const supportsWriteNoResponse = (packetProps & BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0;
|
||||
console.log('[DFU] packet characteristic props mask=', packetProps, 'supportsWithResponse=', supportsWriteWithResponse, 'supportsNoResponse=', supportsWriteNoResponse);
|
||||
|
||||
// Allow caller to request a desired MTU via options for higher throughput
|
||||
const desiredMtu = (options != null && typeof options.mtu == 'number') ? options.mtu : 247;
|
||||
try {
|
||||
console.log('[DFU] requesting MTU=', desiredMtu, 'for', deviceId);
|
||||
await this._requestMtu(gatt, desiredMtu, 8000);
|
||||
console.log('[DFU] requestMtu completed for', deviceId);
|
||||
} catch (e) {
|
||||
console.warn('[DFU] requestMtu failed or timed out, continue with default.', e);
|
||||
}
|
||||
const mtu = desiredMtu; // 假定成功或使用期望值
|
||||
const chunkSize = Math.max(20, mtu - 3);
|
||||
|
||||
// small helper to convert a byte (possibly signed) to a two-digit hex string
|
||||
const byteToHex = (b : number) => {
|
||||
const v = (b < 0) ? (b + 256) : b;
|
||||
let s = v.toString(16);
|
||||
if (s.length < 2) s = '0' + s;
|
||||
return s;
|
||||
};
|
||||
|
||||
// Parameterize PRN window and timeout via options early so they are available
|
||||
// for session logging. Defaults: prn = 12 packets, prnTimeoutMs = 10000 ms
|
||||
let prnWindow = 0;
|
||||
if (options != null && typeof options.prn == 'number') {
|
||||
prnWindow = Math.max(0, Math.floor(options.prn));
|
||||
}
|
||||
const prnTimeoutMs = (options != null && typeof options.prnTimeoutMs == 'number') ? Math.max(1000, Math.floor(options.prnTimeoutMs)) : 8000;
|
||||
const disablePrnOnTimeout = !(options != null && options.disablePrnOnTimeout == false);
|
||||
|
||||
// 订阅 control point 通知并将通知路由到会话处理器
|
||||
const controlHandler = (data : Uint8Array) => {
|
||||
// 交给会话处理器解析并触发事件
|
||||
try {
|
||||
const hexParts: string[] = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const v = data[i] as number;
|
||||
hexParts.push(byteToHex(v));
|
||||
}
|
||||
const hex = hexParts.join(' ');
|
||||
console.log('[DFU] control notification callback invoked for', deviceId, 'raw=', Array.from(data), 'hex=', hex);
|
||||
} catch (e) {
|
||||
console.log('[DFU] control notification callback invoked for', deviceId, 'raw=', Array.from(data));
|
||||
}
|
||||
this._handleControlNotification(deviceId, data);
|
||||
};
|
||||
console.log('[DFU] subscribing control point for', deviceId);
|
||||
await serviceManager.subscribeCharacteristic(deviceId, DFU_SERVICE_UUID, DFU_CONTROL_POINT_UUID, controlHandler);
|
||||
console.log('[DFU] subscribeCharacteristic returned for', deviceId);
|
||||
|
||||
// 保存会话回调(用于 waitForControlEvent); 支持 Nordic 模式追踪已发送字节
|
||||
this.sessions.set(deviceId, {
|
||||
resolve: () => { },
|
||||
reject: (err ?: any) => {console.log(err) },
|
||||
onProgress: null,
|
||||
onLog: null,
|
||||
controlParser: (data : Uint8Array) => this._defaultControlParser(data),
|
||||
bytesSent: 0,
|
||||
totalBytes: firmwareBytes.length,
|
||||
useNordic: options != null && options.useNordic == true,
|
||||
prn: null,
|
||||
packetsSincePrn: 0,
|
||||
prnResolve: null,
|
||||
prnReject: null
|
||||
});
|
||||
console.log('[DFU] session created for', deviceId, 'totalBytes=', firmwareBytes.length);
|
||||
console.log('[DFU] DFU session details:', { deviceId: deviceId, totalBytes: firmwareBytes.length, chunkSize: chunkSize, prnWindow: prnWindow, prnTimeoutMs: prnTimeoutMs });
|
||||
|
||||
// wire options callbacks into the session (if provided)
|
||||
const sessRef = this.sessions.get(deviceId);
|
||||
if (sessRef != null) {
|
||||
sessRef.onProgress = (options != null && typeof options.onProgress == 'function') ? options.onProgress : null;
|
||||
sessRef.onLog = (options != null && typeof options.onLog == 'function') ? options.onLog : null;
|
||||
}
|
||||
|
||||
// emit initial lifecycle events (Nordic-like)
|
||||
this._emitDfuEvent(deviceId, 'onDeviceConnecting', null);
|
||||
|
||||
// 写入固件数据(非常保守的实现:逐包写入并等待短延迟)
|
||||
// --- PRN setup (optional, Nordic-style flow) ---
|
||||
// Parameterize PRN window and timeout via options: options.prn, options.prnTimeoutMs
|
||||
// Defaults were set earlier; build PRN payload using arithmetic to avoid
|
||||
// bitwise operators which don't map cleanly to generated Kotlin.
|
||||
if (prnWindow > 0) {
|
||||
try {
|
||||
// send Set PRN to device (format: [OP_CODE_SET_PRN, prn LSB, prn MSB])
|
||||
// WARNING: Ensure your device uses the same opcode/format; change if needed.
|
||||
const prnLsb = prnWindow % 256;
|
||||
const prnMsb = Math.floor(prnWindow / 256) % 256;
|
||||
const prnPayload = new Uint8Array([0x02, prnLsb, prnMsb]);
|
||||
await serviceManager.writeCharacteristic(deviceId, DFU_SERVICE_UUID, DFU_CONTROL_POINT_UUID, prnPayload, null);
|
||||
const sess0 = this.sessions.get(deviceId);
|
||||
if (sess0 != null) {
|
||||
sess0.useNordic = true;
|
||||
sess0.prn = prnWindow;
|
||||
sess0.packetsSincePrn = 0;
|
||||
sess0.controlParser = (data : Uint8Array) => this._nordicControlParser(data);
|
||||
}
|
||||
console.log('[DFU] Set PRN sent (prn=', prnWindow, ') for', deviceId);
|
||||
} catch (e) {
|
||||
console.warn('[DFU] Set PRN failed (continuing without PRN):', e);
|
||||
const sessFallback = this.sessions.get(deviceId);
|
||||
if (sessFallback != null) sessFallback.prn = 0;
|
||||
}
|
||||
} else {
|
||||
console.log('[DFU] PRN disabled (prnWindow=', prnWindow, ') for', deviceId);
|
||||
}
|
||||
|
||||
// 写入固件数据(逐包写入并根据 options.waitForResponse 选择是否等待响应)
|
||||
let offset = 0;
|
||||
const total = firmwareBytes.length;
|
||||
this._emitDfuEvent(deviceId, 'onDfuProcessStarted', null);
|
||||
this._emitDfuEvent(deviceId, 'onUploadingStarted', null);
|
||||
// Track outstanding write operations when using fire-and-forget mode so we can
|
||||
// log and throttle if the Android stack becomes overwhelmed.
|
||||
let outstandingWrites = 0;
|
||||
// read tuning parameters from options in a safe, generator-friendly way
|
||||
let configuredMaxOutstanding = 2;
|
||||
let writeSleepMs = 0;
|
||||
let writeRetryDelay = 100;
|
||||
let writeMaxAttempts = 12;
|
||||
let writeGiveupTimeout = 60000;
|
||||
let drainOutstandingTimeout = 3000;
|
||||
let failureBackoffMs = 0;
|
||||
|
||||
// throughput measurement
|
||||
let throughputWindowBytes = 0;
|
||||
let lastThroughputTime = Date.now();
|
||||
function _logThroughputIfNeeded(force ?: boolean) {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastThroughputTime;
|
||||
if (force == true || elapsed >= 1000) {
|
||||
const bytes = throughputWindowBytes;
|
||||
const bps = Math.floor((bytes * 1000) / Math.max(1, elapsed));
|
||||
// reset window
|
||||
throughputWindowBytes = 0;
|
||||
lastThroughputTime = now;
|
||||
const human = `${bps} B/s`;
|
||||
console.log('[DFU] throughput:', human, 'elapsedMs=', elapsed);
|
||||
const s = this.sessions.get(deviceId);
|
||||
if (s != null && typeof s.onLog == 'function') {
|
||||
try { s.onLog?.invoke('[DFU] throughput: ' + human); } catch (e) { }
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
function _safeErr(e ?: any) {
|
||||
try {
|
||||
if (e == null) return '';
|
||||
if (typeof e == 'string') return e;
|
||||
try { return JSON.stringify(e); } catch (e2) { }
|
||||
try { return (e as any).toString(); } catch (e3) { }
|
||||
return '';
|
||||
} catch (e4) { return ''; }
|
||||
}
|
||||
try {
|
||||
if (options != null) {
|
||||
try {
|
||||
if (options.maxOutstanding != null) {
|
||||
const parsed = Math.floor(options.maxOutstanding as number);
|
||||
if (!isNaN(parsed) && parsed > 0) configuredMaxOutstanding = parsed;
|
||||
}
|
||||
} catch (e) { }
|
||||
try {
|
||||
if (options.writeSleepMs != null) {
|
||||
const parsedWs = Math.floor(options.writeSleepMs as number);
|
||||
if (!isNaN(parsedWs) && parsedWs >= 0) writeSleepMs = parsedWs;
|
||||
}
|
||||
} catch (e) { }
|
||||
try {
|
||||
if (options.writeRetryDelayMs != null) {
|
||||
const parsedRetry = Math.floor(options.writeRetryDelayMs as number);
|
||||
if (!isNaN(parsedRetry) && parsedRetry >= 0) writeRetryDelay = parsedRetry;
|
||||
}
|
||||
} catch (e) { }
|
||||
try {
|
||||
if (options.writeMaxAttempts != null) {
|
||||
const parsedAttempts = Math.floor(options.writeMaxAttempts as number);
|
||||
if (!isNaN(parsedAttempts) && parsedAttempts > 0) writeMaxAttempts = parsedAttempts;
|
||||
}
|
||||
} catch (e) { }
|
||||
try {
|
||||
if (options.writeGiveupTimeoutMs != null) {
|
||||
const parsedGiveupTimeout = Math.floor(options.writeGiveupTimeoutMs as number);
|
||||
if (!isNaN(parsedGiveupTimeout) && parsedGiveupTimeout > 0) writeGiveupTimeout = parsedGiveupTimeout;
|
||||
}
|
||||
} catch (e) { }
|
||||
try {
|
||||
if (options.drainOutstandingTimeoutMs != null) {
|
||||
const parsedDrain = Math.floor(options.drainOutstandingTimeoutMs as number);
|
||||
if (!isNaN(parsedDrain) && parsedDrain >= 0) drainOutstandingTimeout = parsedDrain;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
if (configuredMaxOutstanding < 1) configuredMaxOutstanding = 1;
|
||||
if (writeSleepMs < 0) writeSleepMs = 0;
|
||||
} catch (e) { }
|
||||
if (supportsWriteWithResponse == false && supportsWriteNoResponse == true) {
|
||||
if (configuredMaxOutstanding > 1) configuredMaxOutstanding = 1;
|
||||
if (writeSleepMs < 15) writeSleepMs = 15;
|
||||
if (writeRetryDelay < 150) writeRetryDelay = 150;
|
||||
if (writeMaxAttempts < 40) writeMaxAttempts = 40;
|
||||
if (writeGiveupTimeout < 120000) writeGiveupTimeout = 120000;
|
||||
console.log('[DFU] packet char only supports WRITE_NO_RESPONSE; serializing writes with conservative pacing');
|
||||
}
|
||||
const maxOutstandingCeiling = configuredMaxOutstanding;
|
||||
let adaptiveMaxOutstanding = configuredMaxOutstanding;
|
||||
const minOutstandingWindow = 1;
|
||||
|
||||
while (offset < total) {
|
||||
const end = Math.min(offset + chunkSize, total);
|
||||
const slice = firmwareBytes.subarray(offset, end);
|
||||
// Decide whether to wait for response per-chunk. Honor characteristic support.
|
||||
// Generator-friendly: avoid 'undefined' and use explicit boolean check.
|
||||
let finalWaitForResponse = true;
|
||||
if (options != null) {
|
||||
try {
|
||||
const maybe = options.waitForResponse;
|
||||
if (maybe == true && supportsWriteWithResponse == false && supportsWriteNoResponse == true) {
|
||||
// caller requested response but characteristic cannot provide it; keep true to ensure we await the write promise.
|
||||
finalWaitForResponse = true;
|
||||
} else if (maybe == false) {
|
||||
finalWaitForResponse = false;
|
||||
} else if (maybe == true) {
|
||||
finalWaitForResponse = true;
|
||||
}
|
||||
} catch (e) { finalWaitForResponse = true; }
|
||||
}
|
||||
|
||||
const writeOpts: WriteCharacteristicOptions = {
|
||||
waitForResponse: finalWaitForResponse,
|
||||
retryDelayMs: writeRetryDelay,
|
||||
maxAttempts: writeMaxAttempts,
|
||||
giveupTimeoutMs: writeGiveupTimeout,
|
||||
forceWriteTypeNoResponse: finalWaitForResponse == false
|
||||
};
|
||||
console.log('[DFU] writing packet chunk offset=', offset, 'len=', slice.length, 'waitForResponse=', finalWaitForResponse, 'outstanding=', outstandingWrites);
|
||||
|
||||
// Fire-and-forget path: do not await the write if waitForResponse == false.
|
||||
if (finalWaitForResponse == false) {
|
||||
if (failureBackoffMs > 0) {
|
||||
console.log('[DFU] applying failure backoff', failureBackoffMs, 'ms before next write for', deviceId);
|
||||
await this._sleep(failureBackoffMs);
|
||||
failureBackoffMs = Math.floor(failureBackoffMs / 2);
|
||||
}
|
||||
while (outstandingWrites >= adaptiveMaxOutstanding) {
|
||||
await this._sleep(Math.max(1, writeSleepMs));
|
||||
}
|
||||
// increment outstanding counter and kick the write without awaiting.
|
||||
outstandingWrites = outstandingWrites + 1;
|
||||
// fire-and-forget: start the write but don't await its Promise
|
||||
const writeOffset = offset;
|
||||
serviceManager.writeCharacteristic(deviceId, DFU_SERVICE_UUID, DFU_PACKET_UUID, slice, writeOpts).then((res) => {
|
||||
outstandingWrites = Math.max(0, outstandingWrites - 1);
|
||||
if (res == true) {
|
||||
if (adaptiveMaxOutstanding < maxOutstandingCeiling) {
|
||||
adaptiveMaxOutstanding = Math.min(maxOutstandingCeiling, adaptiveMaxOutstanding + 1);
|
||||
}
|
||||
if (failureBackoffMs > 0) failureBackoffMs = Math.floor(failureBackoffMs / 2);
|
||||
}
|
||||
// log occasional completions
|
||||
if ((outstandingWrites & 0x1f) == 0) {
|
||||
console.log('[DFU] write completion callback, outstandingWrites=', outstandingWrites, 'adaptiveWindow=', adaptiveMaxOutstanding, 'device=', deviceId);
|
||||
}
|
||||
// detect write failure signaled by service manager
|
||||
if (res !== true) {
|
||||
adaptiveMaxOutstanding = Math.max(minOutstandingWindow, Math.floor(adaptiveMaxOutstanding / 2));
|
||||
failureBackoffMs = Math.min(200, Math.max(failureBackoffMs, Math.max(5, writeRetryDelay)));
|
||||
console.error('[DFU] writeCharacteristic returned false for device=', deviceId, 'offset=', writeOffset, 'adaptiveWindow now=', adaptiveMaxOutstanding);
|
||||
try { this._emitDfuEvent(deviceId, 'onError', { phase: 'write', offset: writeOffset, reason: 'write returned false' }); } catch (e) { }
|
||||
}
|
||||
}).catch((e) => {
|
||||
outstandingWrites = Math.max(0, outstandingWrites - 1);
|
||||
adaptiveMaxOutstanding = Math.max(minOutstandingWindow, Math.floor(adaptiveMaxOutstanding / 2));
|
||||
failureBackoffMs = Math.min(200, Math.max(failureBackoffMs, Math.max(5, writeRetryDelay)));
|
||||
console.warn('[DFU] fire-and-forget write failed for device=', deviceId, e, 'adaptiveWindow now=', adaptiveMaxOutstanding);
|
||||
try {
|
||||
const errMsg ='[DFU] fire-and-forget write failed for device=';
|
||||
this._emitDfuEvent(deviceId, 'onError', { phase: 'write', offset: writeOffset, reason: errMsg });
|
||||
} catch (e2) { }
|
||||
});
|
||||
// account bytes for throughput
|
||||
throughputWindowBytes += slice.length;
|
||||
_logThroughputIfNeeded(false);
|
||||
} else {
|
||||
console.log('[DFU] awaiting write for chunk offset=', offset);
|
||||
try {
|
||||
const writeResult = await serviceManager.writeCharacteristic(deviceId, DFU_SERVICE_UUID, DFU_PACKET_UUID, slice, writeOpts);
|
||||
if (writeResult !== true) {
|
||||
console.error('[DFU] writeCharacteristic(await) returned false at offset=', offset, 'device=', deviceId);
|
||||
try { this._emitDfuEvent(deviceId, 'onError', { phase: 'write', offset: offset, reason: 'write returned false' }); } catch (e) { }
|
||||
// abort DFU by throwing
|
||||
throw new Error('write failed');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[DFU] awaiting write failed at offset=', offset, 'device=', deviceId, e);
|
||||
try {
|
||||
const errMsg = '[DFU] awaiting write failed ';
|
||||
this._emitDfuEvent(deviceId, 'onError', { phase: 'write', offset: offset, reason: errMsg });
|
||||
} catch (e2) { }
|
||||
throw e;
|
||||
}
|
||||
// account bytes for throughput
|
||||
throughputWindowBytes += slice.length;
|
||||
_logThroughputIfNeeded(false);
|
||||
}
|
||||
// update PRN counters and wait when window reached
|
||||
const sessAfter = this.sessions.get(deviceId);
|
||||
if (sessAfter != null && sessAfter.useNordic == true && typeof sessAfter.prn == 'number' && (sessAfter.prn ?? 0) > 0) {
|
||||
sessAfter.packetsSincePrn = (sessAfter.packetsSincePrn ?? 0) + 1;
|
||||
if ((sessAfter.packetsSincePrn ?? 0) >= (sessAfter.prn ?? 0) && (sessAfter.prn ?? 0) > 0) {
|
||||
// wait for PRN (device notification) before continuing
|
||||
try {
|
||||
console.log('[DFU] reached PRN window, waiting for PRN for', deviceId, 'packetsSincePrn=', sessAfter.packetsSincePrn, 'prn=', sessAfter.prn);
|
||||
await this._waitForPrn(deviceId, prnTimeoutMs);
|
||||
console.log('[DFU] PRN received, resuming transfer for', deviceId);
|
||||
} catch (e) {
|
||||
console.warn('[DFU] PRN wait failed/timed out, continuing anyway for', deviceId, e);
|
||||
if (disablePrnOnTimeout) {
|
||||
console.warn('[DFU] disabling PRN waits after timeout for', deviceId);
|
||||
sessAfter.prn = 0;
|
||||
}
|
||||
}
|
||||
// reset counter
|
||||
sessAfter.packetsSincePrn = 0;
|
||||
}
|
||||
}
|
||||
offset = end;
|
||||
// 如果启用 nordic 模式,统计已发送字节
|
||||
const sess = this.sessions.get(deviceId);
|
||||
if (sess != null && typeof sess.bytesSent == 'number') {
|
||||
sess.bytesSent = (sess.bytesSent ?? 0) + slice.length;
|
||||
}
|
||||
// 简单节流与日志,避免过快。默认睡眠非常短以提高吞吐量; 可在设备上调节
|
||||
console.log('[DFU] wrote chunk for', deviceId, 'offset=', offset, '/', total, 'chunkSize=', slice.length, 'bytesSent=', sess != null ? sess.bytesSent : null, 'outstanding=', outstandingWrites);
|
||||
// emit upload progress event (percent) if available
|
||||
if (sess != null && typeof sess.bytesSent == 'number' && typeof sess.totalBytes == 'number') {
|
||||
const p = Math.floor((sess.bytesSent / sess.totalBytes) * 100);
|
||||
this._emitDfuEvent(deviceId, 'onProgress', p);
|
||||
}
|
||||
// yield to event loop and avoid starving the Android BLE stack
|
||||
await this._sleep(Math.max(0, writeSleepMs));
|
||||
}
|
||||
// wait for outstanding writes to drain before continuing with control commands
|
||||
if (outstandingWrites > 0) {
|
||||
const drainStart = Date.now();
|
||||
while (outstandingWrites > 0 && (Date.now() - drainStart) < drainOutstandingTimeout) {
|
||||
await this._sleep(Math.max(0, writeSleepMs));
|
||||
}
|
||||
if (outstandingWrites > 0) {
|
||||
console.warn('[DFU] outstandingWrites remain after drain timeout, continuing with', outstandingWrites);
|
||||
} else {
|
||||
console.log('[DFU] outstandingWrites drained before control phase');
|
||||
}
|
||||
}
|
||||
this._emitDfuEvent(deviceId, 'onUploadingCompleted', null);
|
||||
|
||||
// force final throughput log before activate/validate
|
||||
_logThroughputIfNeeded(true);
|
||||
|
||||
// 发送 activate/validate 命令到 control point(需根据设备协议实现)
|
||||
// 下面为占位:请替换为实际的 opcode
|
||||
// 在写入前先启动控制结果等待,防止设备快速响应导致丢失通知
|
||||
const controlTimeout = 20000;
|
||||
const controlResultPromise = this._waitForControlResult(deviceId, controlTimeout);
|
||||
try {
|
||||
// control writes: pass undefined options explicitly to satisfy the generator/typechecker
|
||||
const activatePayload = new Uint8Array([0x04]);
|
||||
console.log('[DFU] sending activate/validate payload=', Array.from(activatePayload));
|
||||
await serviceManager.writeCharacteristic(deviceId, DFU_SERVICE_UUID, DFU_CONTROL_POINT_UUID, activatePayload, null);
|
||||
console.log('[DFU] activate/validate write returned for', deviceId);
|
||||
} catch (e) {
|
||||
// 写入失败时取消控制等待,避免悬挂定时器
|
||||
try {
|
||||
const sessOnWriteFail = this.sessions.get(deviceId);
|
||||
if (sessOnWriteFail != null && typeof sessOnWriteFail.reject == 'function') {
|
||||
sessOnWriteFail.reject(e);
|
||||
}
|
||||
} catch (rejectErr) { }
|
||||
await controlResultPromise.catch(() => { });
|
||||
try { await serviceManager.unsubscribeCharacteristic(deviceId, DFU_SERVICE_UUID, DFU_CONTROL_POINT_UUID); } catch (e2) { }
|
||||
this.sessions.delete(deviceId);
|
||||
throw e;
|
||||
}
|
||||
console.log('[DFU] sent control activate/validate command to control point for', deviceId);
|
||||
this._emitDfuEvent(deviceId, 'onValidating', null);
|
||||
|
||||
// 等待 control-point 返回最终结果(成功或失败),超时可配置
|
||||
console.log('[DFU] waiting for control result (timeout=', controlTimeout, ') for', deviceId);
|
||||
try {
|
||||
await controlResultPromise;
|
||||
console.log('[DFU] control result resolved for', deviceId);
|
||||
} catch (err) {
|
||||
// 清理订阅后抛出
|
||||
try { await serviceManager.unsubscribeCharacteristic(deviceId, DFU_SERVICE_UUID, DFU_CONTROL_POINT_UUID); } catch (e) { }
|
||||
this.sessions.delete(deviceId);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 取消订阅
|
||||
try {
|
||||
await serviceManager.unsubscribeCharacteristic(deviceId, DFU_SERVICE_UUID, DFU_CONTROL_POINT_UUID);
|
||||
} catch (e) { }
|
||||
console.log('[DFU] unsubscribed control point for', deviceId);
|
||||
|
||||
// 清理会话
|
||||
this.sessions.delete(deviceId);
|
||||
console.log('[DFU] session cleared for', deviceId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async _requestMtu(gatt : BluetoothGatt, mtu : number, timeoutMs : number) : Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
// 在当前项目,BluetoothGattCallback.onMtuChanged 未被封装;简单发起请求并等待短超时
|
||||
try {
|
||||
const ok = gatt.requestMtu(Math.floor(mtu) as Int);
|
||||
if (!ok) {
|
||||
return reject(new Error('requestMtu failed'));
|
||||
}
|
||||
} catch (e) {
|
||||
return reject(e);
|
||||
}
|
||||
// 无 callback 监听时退回,等待一小段时间以便成功
|
||||
setTimeout(() => resolve(), Math.min(2000, timeoutMs));
|
||||
});
|
||||
}
|
||||
|
||||
_sleep(ms : number) {
|
||||
return new Promise<void>((r) => { setTimeout(() => { r() }, ms); });
|
||||
}
|
||||
|
||||
_waitForPrn(deviceId : string, timeoutMs : number) : Promise<void> {
|
||||
const session = this.sessions.get(deviceId);
|
||||
if (session == null) return Promise.reject(new Error('no dfu session'));
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
// timeout waiting for PRN
|
||||
// clear pending handlers
|
||||
session.prnResolve = null;
|
||||
session.prnReject = null;
|
||||
reject(new Error('PRN timeout'));
|
||||
}, timeoutMs);
|
||||
const prnResolve = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
const prnReject = (err ?: any) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
};
|
||||
session.prnResolve = prnResolve;
|
||||
session.prnReject = prnReject;
|
||||
});
|
||||
}
|
||||
|
||||
// 默认 control point 解析器(非常通用的尝试解析:如果设备发送 progress byte 或成功码)
|
||||
_defaultControlParser(data : Uint8Array) : ControlParserResult | null {
|
||||
// 假设协议:第一个字节为 opcode, 第二字节可为状态或进度
|
||||
if (data == null || data.length === 0) return null;
|
||||
const op = data[0];
|
||||
// Nordic-style response: [0x10, requestOp, resultCode]
|
||||
if (op === 0x10 && data.length >= 3) {
|
||||
const requestOp = data[1];
|
||||
const resultCode = data[2];
|
||||
if (resultCode === 0x01) {
|
||||
return { type: 'success' };
|
||||
}
|
||||
return { type: 'error', error: { requestOp: requestOp, resultCode: resultCode, raw: Array.from(data) } };
|
||||
}
|
||||
// Nordic PRN notification: [0x11, LSB, MSB] -> return as progress (bytes received)
|
||||
if (op === 0x11 && data.length >= 3) {
|
||||
const lsb = data[1];
|
||||
const msb = data[2];
|
||||
const received = (msb << 8) | lsb;
|
||||
return { type: 'progress', progress: received };
|
||||
}
|
||||
// vendor-specific opcode example: 0x60 may mean 'response/progress' for some firmwares
|
||||
if (op === 0x60) {
|
||||
if (data.length >= 3) {
|
||||
const requestOp = data[1];
|
||||
const status = data[2];
|
||||
// Known success/status codes observed in field devices
|
||||
if (status === 0x00 || status === 0x01 || status === 0x0A) {
|
||||
return { type: 'success' };
|
||||
}
|
||||
return { type: 'error', error: { requestOp: requestOp, resultCode: status, raw: Array.from(data) } };
|
||||
}
|
||||
if (data.length >= 2) {
|
||||
return { type: 'progress', progress: data[1] };
|
||||
}
|
||||
}
|
||||
// 通用进度回退:若第二字节位于 0-100 之间,当作百分比
|
||||
if (data.length >= 2) {
|
||||
const maybeProgress = data[1];
|
||||
if (maybeProgress >= 0 && maybeProgress <= 100) {
|
||||
return { type: 'progress', progress: maybeProgress };
|
||||
}
|
||||
}
|
||||
// 若找到明显的 success opcode (示例 0x01) 或 error 0xFF
|
||||
if (op === 0x01) return { type: 'success' };
|
||||
if (op === 0xFF) return { type: 'error', error: data };
|
||||
return { type: 'info' };
|
||||
}
|
||||
|
||||
// Nordic DFU control-parser(支持 Response and Packet Receipt Notification)
|
||||
_nordicControlParser(data : Uint8Array) : ControlParserResult | null {
|
||||
// Nordic opcodes (简化):
|
||||
// - 0x10 : Response (opcode, requestOp, resultCode)
|
||||
// - 0x11 : Packet Receipt Notification (opcode, value LSB, value MSB)
|
||||
if (data == null || data.length == 0) return null;
|
||||
const op = data[0];
|
||||
if (op == 0x11 && data.length >= 3) {
|
||||
// packet receipt notif: bytes received (little endian)
|
||||
const lsb = data[1];
|
||||
const msb = data[2];
|
||||
const received = (msb << 8) | lsb;
|
||||
// Return received bytes as progress value; parser does not resolve device-specific session here.
|
||||
return { type: 'progress', progress: received };
|
||||
}
|
||||
// Nordic vendor-specific progress/response opcode (example 0x60)
|
||||
if (op == 0x60) {
|
||||
if (data.length >= 3) {
|
||||
const requestOp = data[1];
|
||||
const status = data[2];
|
||||
if (status == 0x00 || status == 0x01 || status == 0x0A) {
|
||||
return { type: 'success' };
|
||||
}
|
||||
return { type: 'error', error: { requestOp, resultCode: status, raw: Array.from(data) } };
|
||||
}
|
||||
if (data.length >= 2) {
|
||||
return { type: 'progress', progress: data[1] };
|
||||
}
|
||||
}
|
||||
// Response: check result code for success (0x01 may indicate success in some stacks)
|
||||
if (op == 0x10 && data.length >= 3) {
|
||||
const requestOp = data[1];
|
||||
const resultCode = data[2];
|
||||
// Nordic resultCode 0x01 = SUCCESS typically
|
||||
if (resultCode == 0x01) return { type: 'success' };
|
||||
else return { type: 'error', error: { requestOp, resultCode } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_handleControlNotification(deviceId : string, data : Uint8Array) {
|
||||
const session = this.sessions.get(deviceId);
|
||||
if (session == null) {
|
||||
console.warn('[DFU] control notification received but no session for', deviceId, 'data=', Array.from(data));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// human readable opcode mapping
|
||||
let opcodeName = 'unknown';
|
||||
switch (data[0]) {
|
||||
case 0x10: opcodeName = 'Response'; break;
|
||||
case 0x11: opcodeName = 'PRN'; break;
|
||||
case 0x60: opcodeName = 'VendorProgress'; break;
|
||||
case 0x01: opcodeName = 'SuccessOpcode'; break;
|
||||
case 0xFF: opcodeName = 'ErrorOpcode'; break;
|
||||
}
|
||||
console.log('[DFU] _handleControlNotification deviceId=', deviceId, 'opcode=0x' + data[0].toString(16), 'name=', opcodeName, 'raw=', Array.from(data));
|
||||
const parsed = session.controlParser != null ? session.controlParser(data) : null;
|
||||
if (session.onLog != null) session.onLog('DFU control notify: ' + Array.from(data).join(','));
|
||||
console.log('[DFU] parsed control result=', parsed);
|
||||
if (parsed == null) return;
|
||||
if (parsed.type == 'progress' && parsed.progress != null) {
|
||||
// 如果在 nordic 模式 parsed.progress 可能是已接收字节数,则转换为百分比
|
||||
if (session.useNordic == true && session.totalBytes != null && session.totalBytes > 0) {
|
||||
const percent = Math.floor((parsed.progress / session.totalBytes) * 100);
|
||||
session.onProgress?.(percent);
|
||||
// If we have written all bytes locally, log that event
|
||||
if (session.bytesSent != null && session.totalBytes != null && session.bytesSent >= session.totalBytes) {
|
||||
console.log('[DFU] all bytes written locally for', deviceId, 'bytesSent=', session.bytesSent, 'total=', session.totalBytes);
|
||||
// emit uploading completed once
|
||||
this._emitDfuEvent(deviceId, 'onUploadingCompleted', null);
|
||||
}
|
||||
// If a PRN wait is pending, resolve it (PRN indicates device received packets)
|
||||
if (typeof session.prnResolve == 'function') {
|
||||
try { session.prnResolve(); } catch (e) { }
|
||||
session.prnResolve = null;
|
||||
session.prnReject = null;
|
||||
session.packetsSincePrn = 0;
|
||||
}
|
||||
} else {
|
||||
const progress = parsed.progress
|
||||
if (progress != null) {
|
||||
console.log('[DFU] progress for', deviceId, 'progress=', progress);
|
||||
session.onProgress?.(progress);
|
||||
// also resolve PRN if was waiting (in case device reports numeric progress)
|
||||
if (typeof session.prnResolve == 'function') {
|
||||
try { session.prnResolve(); } catch (e) { }
|
||||
session.prnResolve = null;
|
||||
session.prnReject = null;
|
||||
session.packetsSincePrn = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (parsed.type == 'success') {
|
||||
console.log('[DFU] parsed success for', deviceId, 'resolving session');
|
||||
session.resolve();
|
||||
// Log final device-acknowledged success
|
||||
console.log('[DFU] device reported DFU success for', deviceId);
|
||||
this._emitDfuEvent(deviceId, 'onDfuCompleted', null);
|
||||
} else if (parsed.type == 'error') {
|
||||
console.error('[DFU] parsed error for', deviceId, parsed.error);
|
||||
session.reject(parsed.error ?? new Error('DFU device error'));
|
||||
this._emitDfuEvent(deviceId, 'onError', parsed.error ?? {});
|
||||
} else {
|
||||
// info - just log
|
||||
}
|
||||
} catch (e) {
|
||||
session.onLog?.('control parse error: ' + e);
|
||||
console.error('[DFU] control parse exception for', deviceId, e);
|
||||
}
|
||||
}
|
||||
|
||||
_waitForControlResult(deviceId : string, timeoutMs : number) : Promise<void> {
|
||||
const session = this.sessions.get(deviceId);
|
||||
if (session == null) return Promise.reject(new Error('no dfu session'));
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
// wrap resolve/reject to clear timer
|
||||
const timer = setTimeout(() => {
|
||||
// 超时
|
||||
console.error('[DFU] _waitForControlResult timeout for', deviceId);
|
||||
reject(new Error('DFU control timeout'));
|
||||
}, timeoutMs);
|
||||
const origResolve = () => {
|
||||
clearTimeout(timer);
|
||||
console.log('[DFU] _waitForControlResult resolved for', deviceId);
|
||||
resolve();
|
||||
};
|
||||
const origReject = (err ?: any) => {
|
||||
clearTimeout(timer);
|
||||
console.error('[DFU] _waitForControlResult rejected for', deviceId, 'err=', err);
|
||||
reject(err);
|
||||
};
|
||||
// replace session handlers temporarily (guard nullable)
|
||||
if (session != null) {
|
||||
session.resolve = origResolve;
|
||||
session.reject = origReject;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const dfuManager = new DfuManager();
|
||||
Reference in New Issue
Block a user