734 lines
32 KiB
Plaintext
734 lines
32 KiB
Plaintext
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(); |