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 ));
}
}