Files
beamboxctl/src/lib/ble/ble-client.ts
T

832 lines
25 KiB
TypeScript
Raw Normal View History

2026-02-02 18:23:31 +07:00
import { EventEmitter } from "node:events";
EventEmitter.defaultMaxListeners = 20;
2026-02-04 11:35:44 +07:00
import noble from "@abandonware/noble";
import type { Peripheral, Characteristic } from "@abandonware/noble";
2026-02-02 18:23:31 +07:00
import type { BLEConfig, ProtocolConfig } from "../protocol/index.ts";
import { PacketType } from "../protocol/index.ts";
import { DeviceNotFoundError, ConnectionError } from "../utils/errors.ts";
import { PayloadBuilder, ResponseParser } from "../protocol/index.ts";
import { logger, LogEventType } from "../utils/logger.ts";
/**
* Handles notifications from the BeamBox device
*/
class NotificationHandler {
waitingForAck = false;
packetSuccessCount = 0;
errorFlag = false;
lastNotification: Record<string, unknown> | null = null;
deviceStatus: Record<string, unknown> | null = null;
deviceReady = false;
deviceStatusReceived = false;
allNotifications: Array<{ time: number; data: Buffer; parsed: any }> = [];
private notificationResolve: (() => void) | null = null;
private statusResolve: (() => void) | null = null;
2026-02-04 11:36:58 +07:00
public expectedAckCount = 0;
2026-02-02 18:23:31 +07:00
constructor(private verbose: boolean = false) {}
/**
* Handle incoming notification data from device
* @param data Notification data buffer
*/
public handleNotification(data: Buffer): void {
try {
const response = ResponseParser.parse(data);
// Verbose mode logging
if (this.verbose) {
const timestamp = new Date().toISOString();
this.allNotifications.push({
time: Date.now(),
data,
parsed: response,
});
logger.debug(
`[RECV] ${timestamp} | Bytes: ${data.length} | Hex: ${data.toString("hex")} | Text: ${response.rawText || "(empty)"}`,
);
if (response.jsonData) {
logger.debug(`[RECV] JSON: ${JSON.stringify(response.jsonData)}`);
}
}
if (!response.rawText) {
return;
}
if (ResponseParser.isSuccess(response)) {
this.packetSuccessCount++;
2026-02-04 11:36:58 +07:00
logger.info(
`Device ack received: GetPacketSuccess (${this.packetSuccessCount}/${this.expectedAckCount})`,
);
if (
this.waitingForAck &&
this.notificationResolve &&
this.packetSuccessCount >= this.expectedAckCount
) {
2026-02-02 18:23:31 +07:00
this.notificationResolve();
this.notificationResolve = null;
}
return;
}
if (ResponseParser.isFail(response)) {
logger.warning(`Device reported packet fail: ${response.rawText}`);
if (this.notificationResolve) {
this.notificationResolve();
this.notificationResolve = null;
}
return;
}
if (ResponseParser.isError(response)) {
this.errorFlag = true;
logger.error("Device error flag reported: 1111111111");
if (this.notificationResolve) {
this.notificationResolve();
this.notificationResolve = null;
}
return;
}
// Handle JSON responses
if (response.jsonData) {
this.lastNotification = response.jsonData;
// Handle PacketType.DEVICE_STATUS messages
if (response.isStatus) {
this.deviceStatus = response.jsonData;
this.deviceReady = true;
// Only resolve the first time we get status
if (!this.deviceStatusReceived) {
this.deviceStatusReceived = true;
logger.info(
`Device status received: ${JSON.stringify(response.jsonData)}`,
LogEventType.STATUS_RECEIVED,
response.jsonData,
);
} else {
logger.debug("Duplicate status notification, ignoring");
}
if (this.statusResolve) {
this.statusResolve();
this.statusResolve = null;
}
} else {
logger.debug(
`Device notification payload: ${JSON.stringify(response.jsonData)}`,
);
}
if (this.notificationResolve) {
this.notificationResolve();
this.notificationResolve = null;
}
return;
}
// Fallback: print raw text if we couldn't parse anything
logger.debug(`Device notification text: ${response.rawText}`);
if (this.notificationResolve) {
this.notificationResolve();
this.notificationResolve = null;
}
} catch (error) {
logger.error(`Error handling notification: ${error}`);
}
}
/**
* Log sent packet in verbose mode
* @param packet Sent packet buffer
* @param description Description of the packet
*/
public logSentPacket(packet: Buffer, description: string): void {
if (this.verbose) {
const timestamp = new Date().toISOString();
logger.debug(
`[SEND] ${timestamp} | Bytes: ${packet.length} | Hex: ${packet.toString("hex")} | ${description}`,
);
}
}
/**
* Get all received notifications
* @returns Array of notifications with timestamp, data, and parsed content
*/
2026-02-04 11:35:44 +07:00
public getNotifications(): Array<{
time: number;
data: Buffer;
parsed: any;
}> {
2026-02-02 18:23:31 +07:00
return this.allNotifications;
}
/**
* Wait for device status to be received
* @returns Promise that resolves when status is received
*/
public waitForStatus(): Promise<void> {
return new Promise((resolve) => {
if (this.deviceStatusReceived) {
resolve();
} else {
this.statusResolve = resolve;
}
});
}
/**
* Wait for any notification from device
* @returns Promise that resolves when a notification is received
*/
public waitForNotification(): Promise<void> {
return new Promise((resolve) => {
this.notificationResolve = resolve;
});
}
/**
* Reset internal state
*/
public reset(): void {
this.notificationResolve = null;
this.statusResolve = null;
this.errorFlag = false;
}
2026-02-04 11:36:58 +07:00
/**
* Set the expected number of acknowledgments to wait for
* @param count Number of packets that will be sent
*/
public setExpectedAckCount(count: number): void {
this.expectedAckCount = count;
this.packetSuccessCount = 0;
}
2026-02-02 18:23:31 +07:00
}
/**
* Manages BLE connection and data transfer to BeamBox device
*/
export class BleUploader {
2026-02-04 11:35:44 +07:00
private peripheral: Peripheral | null = null;
private writeCharacteristic: Characteristic | null = null;
private notifyCharacteristic: Characteristic | null = null;
2026-02-02 18:23:31 +07:00
private notificationHandler: NotificationHandler;
private payloadBuilder: PayloadBuilder;
private chunkDelay: number;
private verbose: boolean = false;
2026-02-04 11:35:44 +07:00
private isInitialized: boolean = false;
2026-02-02 18:23:31 +07:00
constructor(
private deviceAddress: string | null,
chunkDelay: number | null,
private bleConfig: BLEConfig,
private protocolConfig: ProtocolConfig,
verbose: boolean = false,
) {
this.chunkDelay = chunkDelay ?? protocolConfig.packetDelay;
this.verbose = verbose;
this.notificationHandler = new NotificationHandler(verbose);
this.payloadBuilder = new PayloadBuilder(protocolConfig);
}
/**
2026-02-04 11:35:44 +07:00
* Initialize Bluetooth adapter and wait for powered on state
2026-02-02 18:23:31 +07:00
*/
private async initBluetooth(): Promise<void> {
2026-02-04 11:35:44 +07:00
if (this.isInitialized) {
return;
2026-02-02 18:23:31 +07:00
}
2026-02-04 11:35:44 +07:00
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new ConnectionError("Bluetooth adapter initialization timeout"));
}, 10000);
const checkState = (state: string) => {
if (state === "poweredOn") {
clearTimeout(timeout);
this.isInitialized = true;
resolve();
} else if (state === "poweredOff") {
clearTimeout(timeout);
reject(new ConnectionError("Bluetooth adapter is not powered on"));
} else if (state === "unsupported") {
clearTimeout(timeout);
reject(
new ConnectionError("Bluetooth is not supported on this device"),
);
} else if (state === "unauthorized") {
clearTimeout(timeout);
reject(new ConnectionError("Bluetooth access not authorized"));
}
};
// Check current state first
if ((noble as any).state === "poweredOn") {
clearTimeout(timeout);
this.isInitialized = true;
resolve();
return;
}
noble.on("stateChange", checkState);
});
}
/**
* Normalize UUID for comparison
* Handles both full 128-bit UUIDs and short 16/32-bit UUIDs
* Short UUIDs use the Bluetooth Base UUID: 00000000-0000-1000-8000-00805f9b34fb
*/
private normalizeUUID(uuid: string): string {
// Remove dashes and lowercase
const cleaned = uuid.replace(/-/g, "").toLowerCase();
// If it's a full 128-bit UUID using Bluetooth Base UUID, extract the short form
// Bluetooth Base UUID pattern: 0000XXXX-0000-1000-8000-00805f9b34fb
const bluetoothBasePattern = /^0000([0-9a-f]{4})00001000800000805f9b34fb$/;
const match = cleaned.match(bluetoothBasePattern);
if (match && match[1]) {
return match[1]; // Return short UUID part
}
return cleaned;
2026-02-02 18:23:31 +07:00
}
/**
* Scan for the BeamBox device
* @returns Device address or null if not found
*/
public async findDevice(): Promise<string | null> {
2026-02-04 11:35:44 +07:00
await this.initBluetooth();
2026-02-02 18:23:31 +07:00
logger.info("Starting device scan...", LogEventType.SCAN_START);
2026-02-04 11:35:44 +07:00
return new Promise((resolve) => {
const timeout = this.bleConfig.scanTimeout * 1000;
let found = false;
2026-02-02 18:23:31 +07:00
2026-02-04 11:35:44 +07:00
const timeoutId = setTimeout(async () => {
if (!found) {
await noble.stopScanningAsync();
noble.removeListener("discover", onDiscover);
resolve(null);
}
}, timeout);
2026-02-02 18:23:31 +07:00
2026-02-04 11:35:44 +07:00
const onDiscover = async (peripheral: Peripheral) => {
const name = peripheral.advertisement.localName;
2026-02-02 18:23:31 +07:00
if (
name &&
name.toLowerCase().includes(this.bleConfig.deviceName.toLowerCase())
) {
2026-02-04 11:35:44 +07:00
found = true;
clearTimeout(timeoutId);
await noble.stopScanningAsync();
noble.removeListener("discover", onDiscover);
const address = peripheral.address || peripheral.id;
2026-02-02 18:23:31 +07:00
logger.info(
2026-02-04 11:35:44 +07:00
`Found device: ${name} (${address})`,
2026-02-02 18:23:31 +07:00
LogEventType.DEVICE_FOUND,
2026-02-04 11:35:44 +07:00
{ name, address },
2026-02-02 18:23:31 +07:00
);
2026-02-04 11:35:44 +07:00
this.peripheral = peripheral;
resolve(address);
2026-02-02 18:23:31 +07:00
}
2026-02-04 11:35:44 +07:00
};
2026-02-02 18:23:31 +07:00
2026-02-04 11:35:44 +07:00
noble.on("discover", onDiscover);
2026-02-02 18:23:31 +07:00
2026-02-04 11:35:44 +07:00
noble.startScanningAsync([], false).catch((err: Error) => {
clearTimeout(timeoutId);
logger.error(`Scan error: ${err}`);
resolve(null);
});
});
2026-02-02 18:23:31 +07:00
}
/**
* Connect to the BeamBox device and wait for device status
* @returns True if connected successfully
*/
public async connect(): Promise<boolean> {
try {
await this.initBluetooth();
2026-02-04 11:35:44 +07:00
if (!this.peripheral) {
2026-02-02 18:23:31 +07:00
if (!this.deviceAddress) {
2026-02-04 11:35:44 +07:00
logger.info("Scanning for device...", LogEventType.SCAN_START);
const address = await this.findDevice();
if (!address) {
throw new DeviceNotFoundError(
`Could not find '${this.bleConfig.deviceName}'`,
);
}
this.deviceAddress = address;
} else {
logger.info(
"Scanning for device by address...",
LogEventType.SCAN_START,
2026-02-02 18:23:31 +07:00
);
2026-02-04 11:35:44 +07:00
const foundPeripheral = await new Promise<Peripheral | null>(
(resolve) => {
const timeout = this.bleConfig.scanTimeout * 1000;
let found = false;
const timeoutId = setTimeout(async () => {
if (!found) {
await noble.stopScanningAsync();
noble.removeListener("discover", onDiscover);
resolve(null);
}
}, timeout);
const onDiscover = async (peripheral: Peripheral) => {
const address = peripheral.address || peripheral.id;
if (
address.toLowerCase() === this.deviceAddress?.toLowerCase()
) {
found = true;
clearTimeout(timeoutId);
await noble.stopScanningAsync();
noble.removeListener("discover", onDiscover);
resolve(peripheral);
}
};
noble.on("discover", onDiscover);
noble.startScanningAsync([], false).catch(() => {
clearTimeout(timeoutId);
resolve(null);
});
},
);
if (!foundPeripheral) {
throw new DeviceNotFoundError(
`Could not find device with address '${this.deviceAddress}'`,
);
}
this.peripheral = foundPeripheral;
2026-02-02 18:23:31 +07:00
}
}
2026-02-04 11:35:44 +07:00
// Stop scanning before connecting
await noble.stopScanningAsync().catch(() => {});
2026-02-02 18:23:31 +07:00
// Connect to device
logger.info("Connecting to device...", LogEventType.CONNECT_START);
2026-02-04 11:35:44 +07:00
await this.peripheral!.connectAsync();
2026-02-02 18:23:31 +07:00
logger.info("Connected to device", LogEventType.CONNECTED);
2026-02-04 11:35:44 +07:00
// Handle disconnect events
this.peripheral!.once("disconnect", () => {
logger.info("Device disconnected");
this.peripheral = null;
this.writeCharacteristic = null;
this.notifyCharacteristic = null;
});
2026-02-02 18:23:31 +07:00
2026-02-04 11:35:44 +07:00
// Discover services and characteristics
2026-02-02 18:23:31 +07:00
await this.discoverCharacteristics();
// Setup notifications
if (this.notifyCharacteristic) {
2026-02-04 11:35:44 +07:00
await this.notifyCharacteristic.subscribeAsync();
2026-02-02 18:23:31 +07:00
2026-02-04 11:35:44 +07:00
// Listen for notifications
this.notifyCharacteristic.on("data", (data: Buffer) => {
this.notificationHandler.handleNotification(data);
2026-02-02 18:23:31 +07:00
});
}
// Wait for device status (PacketType.DEVICE_STATUS) to be received
logger.info("Waiting for device status...", LogEventType.STATUS_WAIT);
const statusPromise = this.notificationHandler.waitForStatus();
const timeoutPromise = this.sleep(5000);
await Promise.race([statusPromise, timeoutPromise]);
if (this.notificationHandler.deviceReady) {
logger.info("Device status received", LogEventType.STATUS_RECEIVED);
} else {
logger.warning(
"Device status not received within timeout, proceeding anyway",
);
}
return true;
} catch (error) {
logger.error(`Connection error: ${error}`);
2026-02-04 11:35:44 +07:00
await this.disconnect();
2026-02-02 18:23:31 +07:00
return false;
}
}
/**
* Discover required characteristics on the device
*/
private async discoverCharacteristics(): Promise<void> {
2026-02-04 11:35:44 +07:00
if (!this.peripheral) {
throw new ConnectionError("Not connected to device");
}
2026-02-02 18:23:31 +07:00
2026-02-04 11:35:44 +07:00
const { services } =
await this.peripheral.discoverAllServicesAndCharacteristicsAsync();
2026-02-02 18:23:31 +07:00
2026-02-04 11:35:44 +07:00
const normalizedWriteUuid = this.normalizeUUID(
this.bleConfig.writeCharacteristicUUID,
);
const normalizedNotifyUuid = this.normalizeUUID(
this.bleConfig.notifyCharacteristicUUID,
);
2026-02-02 18:23:31 +07:00
2026-02-04 11:35:44 +07:00
logger.debug(`Looking for write UUID: ${normalizedWriteUuid}`);
logger.debug(`Looking for notify UUID: ${normalizedNotifyUuid}`);
// Iterate through services and their characteristics
for (const service of services) {
logger.debug(`Service: ${service.uuid}`);
for (const char of service.characteristics) {
const normalizedCharUuid = this.normalizeUUID(char.uuid);
logger.debug(
` Characteristic: ${char.uuid} (normalized: ${normalizedCharUuid})`,
);
2026-02-02 18:23:31 +07:00
if (normalizedCharUuid === normalizedWriteUuid) {
this.writeCharacteristic = char;
logger.debug(
2026-02-04 11:35:44 +07:00
`Found write characteristic: ${char.uuid}`,
2026-02-02 18:23:31 +07:00
LogEventType.DISCOVER_CHAR,
2026-02-04 11:35:44 +07:00
{ type: "write", uuid: char.uuid },
2026-02-02 18:23:31 +07:00
);
}
if (normalizedCharUuid === normalizedNotifyUuid) {
this.notifyCharacteristic = char;
logger.debug(
2026-02-04 11:35:44 +07:00
`Found notify characteristic: ${char.uuid}`,
2026-02-02 18:23:31 +07:00
LogEventType.DISCOVER_CHAR,
2026-02-04 11:35:44 +07:00
{ type: "notify", uuid: char.uuid },
2026-02-02 18:23:31 +07:00
);
}
}
}
if (!this.writeCharacteristic || !this.notifyCharacteristic) {
throw new ConnectionError("Could not find required characteristics");
}
}
/**
* Disconnect from the device
*/
public async disconnect(): Promise<void> {
try {
2026-02-04 11:35:44 +07:00
if (this.notifyCharacteristic) {
this.notifyCharacteristic.removeAllListeners();
await this.notifyCharacteristic.unsubscribeAsync().catch(() => {});
2026-02-02 18:23:31 +07:00
}
2026-02-04 11:35:44 +07:00
if (this.peripheral) {
this.peripheral.removeAllListeners();
await this.peripheral.disconnectAsync().catch(() => {});
2026-02-02 18:23:31 +07:00
}
2026-02-04 11:35:44 +07:00
noble.removeAllListeners();
await noble.stopScanningAsync().catch(() => {});
2026-02-02 18:23:31 +07:00
} catch (error) {
logger.warning(`Error during disconnect: ${error}`);
}
}
/**
* Send image info packet to device
* Tells device how many images to expect
* @param payload Image info payload bytes (e.g., {"type":6,"number":1})
*/
public async sendImageInfo(payload: Buffer): Promise<void> {
if (!this.writeCharacteristic) {
throw new ConnectionError("BLE client not connected");
}
if (!this.notificationHandler.deviceReady) {
logger.warning("Device status not received, but proceeding with upload");
}
const packet = this.payloadBuilder.createPacket(
payload,
0,
0,
PacketType.IMAGE,
);
logger.debug(
`Sending image info packet (type ${PacketType.IMAGE}): ${payload.length} bytes`,
LogEventType.IMAGE_INFO_SEND,
{ size: payload.length },
);
if (this.verbose) {
const imageInfoHex = packet.toString("hex");
logger.debug(
`Full image info packet hex (${packet.length} bytes): ${imageInfoHex}`,
);
}
this.notificationHandler.logSentPacket(packet, "Image info packet");
2026-02-04 11:35:44 +07:00
// Write without response
await this.writeCharacteristic.writeAsync(packet, true);
2026-02-02 18:23:31 +07:00
await this.sleep(this.protocolConfig.imageInfoDelay * 1000);
}
/**
2026-02-04 11:36:58 +07:00
* Send image data packets to device with batched acknowledgment waiting
2026-02-02 18:23:31 +07:00
* @param fullData Complete image data payload
2026-02-04 11:36:58 +07:00
* @param onProgress Progress callback with (progress, status)
2026-02-02 18:23:31 +07:00
* @returns True if successful
*/
public async sendData(
fullData: Buffer,
2026-02-04 11:36:58 +07:00
onProgress?: (progress: number, status?: string) => void,
2026-02-02 18:23:31 +07:00
): Promise<boolean> {
if (!this.writeCharacteristic) {
throw new ConnectionError("BLE client not connected");
}
const totalSize = fullData.length;
const chunkSize = this.protocolConfig.chunkSize;
const totalChunks = Math.ceil(totalSize / chunkSize);
2026-02-04 11:36:58 +07:00
const batchSize = 10; // Send 10 packets then wait for acks
// Set expected acknowledgment count before sending
this.notificationHandler.setExpectedAckCount(totalChunks);
2026-02-02 18:23:31 +07:00
logger.info(
2026-02-04 11:36:58 +07:00
`Starting data transfer: ${totalChunks} packets in batches of ${batchSize}`,
2026-02-02 18:23:31 +07:00
LogEventType.DATA_SEND_START,
2026-02-04 11:36:58 +07:00
{ totalChunks, totalSize, batchSize },
2026-02-02 18:23:31 +07:00
);
if (this.verbose) {
const firstChunkPreview = fullData
.subarray(0, Math.min(64, fullData.length))
.toString("hex");
logger.debug(`First 64 bytes of payload: ${firstChunkPreview}`);
}
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, totalSize);
const chunk = fullData.subarray(start, end);
const remainingPackets = totalChunks - 1 - i;
const packet = this.payloadBuilder.createPacket(
chunk,
totalChunks,
remainingPackets,
this.protocolConfig.cmdSubtype,
);
logger.info(
`Sending packet ${i + 1}/${totalChunks} (remaining=${remainingPackets}, bytes=${chunk.length})`,
LogEventType.DATA_SEND_PROGRESS,
{ current: i + 1, total: totalChunks, remaining: remainingPackets },
);
if (this.verbose) {
this.notificationHandler.logSentPacket(
packet,
`Data packet ${i + 1}/${totalChunks}`,
);
if (i === 0) {
const headerHex = packet.subarray(0, 8).toString("hex");
const checksumHex = packet[packet.length - 1]
?.toString(16)
.padStart(2, "0");
logger.debug(`First packet header: ${headerHex}`);
logger.debug(`First packet checksum: ${checksumHex}`);
}
}
2026-02-04 11:35:44 +07:00
// Write without response
await this.writeCharacteristic.writeAsync(packet, true);
2026-02-02 18:23:31 +07:00
if (this.notificationHandler.errorFlag) {
logger.error("Device error flag set; aborting send.");
return false;
}
// Report progress
if (onProgress) {
2026-02-04 11:36:58 +07:00
// Sending contributes 50% of this packet's progress
const sendProgress = ((i + 1) / totalChunks) * 50;
// Acks contribute the other 50%
const ackProgress =
(this.notificationHandler.packetSuccessCount / totalChunks) * 50;
const combinedProgress = sendProgress + ackProgress;
onProgress(
combinedProgress,
`Uploading (${i + 1} sent, ${this.notificationHandler.packetSuccessCount} confirmed)`,
);
2026-02-02 18:23:31 +07:00
}
await this.sleep(this.chunkDelay * 1000);
2026-02-04 11:36:58 +07:00
// Wait for acks every batchSize packets or at the end
if ((i + 1) % batchSize === 0 || i === totalChunks - 1) {
const expectedAcksAtThisPoint = i + 1;
const ackTimeout = 5.0; // 5 seconds to wait for batch acks
const startTime = Date.now();
logger.info(`Waiting for acknowledgments up to packet ${i + 1}...`);
while (
this.notificationHandler.packetSuccessCount < expectedAcksAtThisPoint
) {
if ((Date.now() - startTime) / 1000 > ackTimeout) {
logger.warning(
`Ack timeout: expected ${expectedAcksAtThisPoint}, received ${this.notificationHandler.packetSuccessCount}`,
);
break;
}
if (onProgress) {
// Update progress as acks come in
const sendProgress = ((i + 1) / totalChunks) * 50;
const ackProgress =
(this.notificationHandler.packetSuccessCount / totalChunks) * 50;
const combinedProgress = sendProgress + ackProgress;
onProgress(
combinedProgress,
`Uploading (${i + 1} sent, ${this.notificationHandler.packetSuccessCount} confirmed)`,
);
}
await this.sleep(100);
}
}
2026-02-02 18:23:31 +07:00
}
logger.info("Data transfer complete", LogEventType.DATA_SEND_COMPLETE);
return !this.notificationHandler.errorFlag;
}
/**
2026-02-04 11:36:58 +07:00
* Wait for all remaining device acknowledgments after upload
* @param onProgress Optional progress callback
* @returns True if all acks received without error
2026-02-02 18:23:31 +07:00
*/
2026-02-04 11:36:58 +07:00
public async waitForResponse(
onProgress?: (progress: number, status?: string) => void,
): Promise<boolean> {
this.notificationHandler.waitingForAck = true;
2026-02-02 18:23:31 +07:00
2026-02-04 11:36:58 +07:00
// Dynamic timeout: 0.1s per expected packet, minimum 5s, maximum 30s
const dynamicTimeout = Math.min(
Math.max(this.notificationHandler.expectedAckCount * 0.1, 5.0),
30.0,
);
logger.info(
`Waiting for final acknowledgments (${this.notificationHandler.packetSuccessCount}/${this.notificationHandler.expectedAckCount}), timeout: ${dynamicTimeout.toFixed(1)}s`,
);
const startTime = Date.now();
while (
this.notificationHandler.packetSuccessCount <
this.notificationHandler.expectedAckCount
) {
if ((Date.now() - startTime) / 1000 > dynamicTimeout) {
logger.warning(
`Timeout waiting for acks: received ${this.notificationHandler.packetSuccessCount}/${this.notificationHandler.expectedAckCount}`,
);
break;
}
if (onProgress) {
// All packets sent (50%), waiting for remaining acks (0-50%)
const sendProgress = 50; // All packets already sent
const ackProgress =
(this.notificationHandler.packetSuccessCount /
this.notificationHandler.expectedAckCount) *
50;
const combinedProgress = sendProgress + ackProgress;
onProgress(
combinedProgress,
`Uploading (${this.notificationHandler.expectedAckCount} sent, ${this.notificationHandler.packetSuccessCount} confirmed)`,
);
}
await this.sleep(100); // Check every 100ms
}
const success =
!this.notificationHandler.errorFlag &&
this.notificationHandler.packetSuccessCount >=
this.notificationHandler.expectedAckCount;
if (success) {
logger.info("All acknowledgments received");
if (onProgress) {
onProgress(100, "Upload complete");
}
} else if (
this.notificationHandler.packetSuccessCount <
this.notificationHandler.expectedAckCount
) {
logger.warning(
`Incomplete acknowledgments: received ${this.notificationHandler.packetSuccessCount}/${this.notificationHandler.expectedAckCount}`,
);
}
this.notificationHandler.waitingForAck = false;
2026-02-02 18:23:31 +07:00
this.notificationHandler.reset();
2026-02-04 11:36:58 +07:00
return success;
2026-02-02 18:23:31 +07:00
}
public hasError(): boolean {
return this.notificationHandler.errorFlag;
}
public isDeviceReady(): boolean {
return this.notificationHandler.deviceReady;
}
public getDeviceStatus(): Record<string, unknown> | null {
return this.notificationHandler.deviceStatus;
}
2026-02-04 11:35:44 +07:00
public getNotifications(): Array<{
time: number;
data: Buffer;
parsed: any;
}> {
2026-02-02 18:23:31 +07:00
return this.notificationHandler.getNotifications();
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}