Initial commit

This commit is contained in:
2026-03-16 10:37:46 +08:00
commit c052a67816
508 changed files with 22987 additions and 0 deletions

View 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();