@@ -15,7 +15,10 @@ const ProtocolMessageType = {
1515 REQUEST : 0x01 ,
1616 RESPONSE : 0x02 ,
1717 EVENT : 0x03 ,
18- CONTROL : 0x04
18+ CONTROL : 0x04 ,
19+ STREAM_DATA : 0x05 ,
20+ STREAM_START : 0x06 ,
21+ STREAM_END : 0x07
1922} ;
2023
2124// Control Message Types (INTERNAL - for control channel)
@@ -26,7 +29,9 @@ const ControlMessageType = {
2629 BUFFER_FULL : 0x04 ,
2730 RESET : 0x05 ,
2831 MTU_INFO : 0x06 ,
29- REQUEST_MTU_INFO : 0x07
32+ REQUEST_MTU_INFO : 0x07 ,
33+ STREAM_ACK : 0x07 ,
34+ STREAM_ERROR : 0x09
3035} ;
3136
3237// Fragment Flags (INTERNAL)
@@ -69,6 +74,9 @@ export class BLEClient {
6974
7075 // Disconnect listener
7176 this . onDisconnectCallback = null ;
77+
78+ // Streaming file transfer state
79+ this . activeStreams = new Map ( ) ;
7280 }
7381
7482 // Logging helpers
@@ -458,11 +466,93 @@ export class BLEClient {
458466 this . fragmentBuffers . clear ( ) ;
459467 break ;
460468
469+ case ControlMessageType . STREAM_ACK :
470+ if ( dataView . byteLength >= 4 ) {
471+ const streamId = dataView . getUint8 ( 1 ) ;
472+ const chunkIndex = dataView . getUint16 ( 2 , true ) ; // little-endian
473+ this . handleStreamAck ( streamId , chunkIndex ) ;
474+ }
475+ break ;
476+
477+ case ControlMessageType . STREAM_ERROR :
478+ if ( dataView . byteLength >= 3 ) {
479+ const streamId = dataView . getUint8 ( 1 ) ;
480+ const errorCode = dataView . getUint8 ( 2 ) ;
481+ this . handleStreamError ( streamId , errorCode ) ;
482+ }
483+ break ;
484+
461485 default :
462486 this . logWarn ( `Unknown control message type: ${ ctrlType } ` ) ;
463487 }
464488 }
465489
490+ /**
491+ * Handle stream ACK (INTERNAL)
492+ * @param {number } streamId
493+ * @param {number } chunkIndex - 0xFFFF = start ACK, 0xFFFE = completion ACK
494+ */
495+ handleStreamAck ( streamId , chunkIndex ) {
496+ const stream = this . activeStreams . get ( streamId ) ;
497+ if ( ! stream ) {
498+ this . logWarn ( `STREAM_ACK for unknown stream ${ streamId } ` ) ;
499+ return ;
500+ }
501+
502+ if ( chunkIndex === 0xFFFF ) {
503+ this . logVerbose ( `Stream ${ streamId } start acknowledged` ) ;
504+ stream . startAcked = true ;
505+ if ( stream . startResolve ) {
506+ stream . startResolve ( ) ;
507+ }
508+ } else if ( chunkIndex === 0xFFFE ) {
509+ this . logInfo ( `Stream ${ streamId } complete` ) ;
510+ stream . complete = true ;
511+ if ( stream . completeResolve ) {
512+ stream . completeResolve ( ) ;
513+ }
514+ } else {
515+ stream . ackedChunks . add ( chunkIndex ) ;
516+ stream . lastAckedChunk = chunkIndex ;
517+ if ( stream . onProgress ) {
518+ const bytesAcked = Math . min ( ( chunkIndex + 1 ) * stream . chunkPayloadSize , stream . totalSize ) ;
519+ stream . onProgress ( bytesAcked , stream . totalSize ) ;
520+ }
521+ }
522+ }
523+
524+ /**
525+ * Handle stream error (INTERNAL)
526+ * @param {number } streamId
527+ * @param {number } errorCode
528+ */
529+ handleStreamError ( streamId , errorCode ) {
530+ const stream = this . activeStreams . get ( streamId ) ;
531+ if ( ! stream ) {
532+ this . logWarn ( `STREAM_ERROR for unknown stream ${ streamId } ` ) ;
533+ return ;
534+ }
535+
536+ const errorMessages = {
537+ 0x01 : 'Invalid header' ,
538+ 0x02 : 'Invalid metadata' ,
539+ 0x03 : 'Mutex error' ,
540+ 0x04 : 'Too many concurrent streams' ,
541+ 0x05 : 'Cannot create file' ,
542+ 0x06 : 'Unknown stream' ,
543+ 0x07 : 'SD write failed' ,
544+ 0x08 : 'Timeout'
545+ } ;
546+
547+ const message = errorMessages [ errorCode ] || `Unknown error (0x${ errorCode . toString ( 16 ) } )` ;
548+ this . logError ( `Stream ${ streamId } error: ${ message } ` ) ;
549+
550+ stream . error = new Error ( `Stream error: ${ message } ` ) ;
551+ if ( stream . errorReject ) {
552+ stream . errorReject ( stream . error ) ;
553+ }
554+ }
555+
466556 /**
467557 * Send fragmented message (INTERNAL)
468558 * @param {BLECharacteristic } characteristic
@@ -632,6 +722,193 @@ export class BLEClient {
632722 await this . controlChar . writeValue ( data ) ;
633723 }
634724
725+ /**
726+ * Upload file using streaming binary protocol (memory-efficient)
727+ * @param {string } projectId - Project identifier
728+ * @param {string } filename - Target filename
729+ * @param {ArrayBuffer|Uint8Array|string } content - File content
730+ * @param {function } onProgress - Progress callback (bytesUploaded, totalBytes)
731+ * @returns {Promise<boolean> } - Success status
732+ */
733+ async uploadFileStreaming ( projectId , filename , content , onProgress = null ) {
734+ if ( ! this . connected ) {
735+ throw new Error ( 'Not connected' ) ;
736+ }
737+
738+ // Convert content to Uint8Array
739+ let data ;
740+ if ( typeof content === 'string' ) {
741+ data = new TextEncoder ( ) . encode ( content ) ;
742+ } else if ( content instanceof ArrayBuffer ) {
743+ data = new Uint8Array ( content ) ;
744+ } else {
745+ data = content ;
746+ }
747+
748+ const streamId = this . currentMessageId ++ & 0xFF ;
749+ const totalSize = data . length ;
750+ const chunkPayloadSize = this . mtu - 5 ; // 5-byte header for STREAM_DATA
751+ const chunkCount = Math . ceil ( totalSize / chunkPayloadSize ) || 1 ;
752+
753+ this . logInfo ( `Starting streaming upload: stream=${ streamId } , file=${ filename } , size=${ totalSize } , chunks=${ chunkCount } ` ) ;
754+
755+ // Initialize stream state
756+ const streamState = {
757+ streamId : streamId ,
758+ totalSize : totalSize ,
759+ chunkPayloadSize : chunkPayloadSize ,
760+ ackedChunks : new Set ( ) ,
761+ lastAckedChunk : - 1 ,
762+ startAcked : false ,
763+ complete : false ,
764+ error : null ,
765+ onProgress : onProgress ,
766+ startResolve : null ,
767+ completeResolve : null ,
768+ errorReject : null
769+ } ;
770+ this . activeStreams . set ( streamId , streamState ) ;
771+
772+ try {
773+ // 1. Send STREAM_START
774+ const metadata = JSON . stringify ( { project : projectId , filename : filename } ) ;
775+ const metadataBytes = new TextEncoder ( ) . encode ( metadata ) ;
776+ const startPacket = new Uint8Array ( 9 + metadataBytes . length ) ;
777+ startPacket [ 0 ] = ProtocolMessageType . STREAM_START ;
778+ startPacket [ 1 ] = streamId ;
779+ // Total size (little-endian uint32)
780+ startPacket [ 2 ] = totalSize & 0xFF ;
781+ startPacket [ 3 ] = ( totalSize >> 8 ) & 0xFF ;
782+ startPacket [ 4 ] = ( totalSize >> 16 ) & 0xFF ;
783+ startPacket [ 5 ] = ( totalSize >> 24 ) & 0xFF ;
784+ // Chunk count (little-endian uint16)
785+ startPacket [ 6 ] = chunkCount & 0xFF ;
786+ startPacket [ 7 ] = ( chunkCount >> 8 ) & 0xFF ;
787+ startPacket [ 8 ] = 0x01 ; // Flags: overwrite
788+ startPacket . set ( metadataBytes , 9 ) ;
789+
790+ await this . requestChar . writeValueWithResponse ( startPacket ) ;
791+ this . logVerbose ( `Stream ${ streamId } : STREAM_START sent` ) ;
792+
793+ // Wait for start ACK
794+ await this . waitForStreamEvent ( streamState , 'start' , 5000 ) ;
795+ this . logVerbose ( `Stream ${ streamId } : Start acknowledged` ) ;
796+
797+ // 2. Send STREAM_DATA chunks
798+ for ( let i = 0 ; i < chunkCount ; i ++ ) {
799+ if ( streamState . error ) {
800+ throw streamState . error ;
801+ }
802+
803+ const start = i * chunkPayloadSize ;
804+ const end = Math . min ( start + chunkPayloadSize , totalSize ) ;
805+ const chunkData = data . slice ( start , end ) ;
806+ const isLast = ( i === chunkCount - 1 ) ;
807+
808+ const dataPacket = new Uint8Array ( 5 + chunkData . length ) ;
809+ dataPacket [ 0 ] = ProtocolMessageType . STREAM_DATA ;
810+ dataPacket [ 1 ] = streamId ;
811+ dataPacket [ 2 ] = i & 0xFF ;
812+ dataPacket [ 3 ] = ( i >> 8 ) & 0xFF ;
813+ dataPacket [ 4 ] = isLast ? 0x01 : 0x00 ;
814+ dataPacket . set ( chunkData , 5 ) ;
815+
816+ await this . requestChar . writeValueWithResponse ( dataPacket ) ;
817+
818+ // Flow control: wait every 10 chunks if we're getting too far ahead
819+ if ( i % 10 === 9 && i < chunkCount - 1 ) {
820+ const targetAck = Math . max ( 0 , i - 5 ) ;
821+ try {
822+ await this . waitForCondition (
823+ ( ) => streamState . lastAckedChunk >= targetAck || streamState . error ,
824+ 2000
825+ ) ;
826+ } catch ( e ) {
827+ // Timeout on flow control is a warning, not an error
828+ this . logWarn ( `Stream ${ streamId } : Flow control timeout, continuing...` ) ;
829+ }
830+ }
831+
832+ if ( streamState . error ) {
833+ throw streamState . error ;
834+ }
835+ }
836+
837+ this . logVerbose ( `Stream ${ streamId } : All chunks sent` ) ;
838+
839+ // 3. Send STREAM_END
840+ const endPacket = new Uint8Array ( 6 ) ;
841+ endPacket [ 0 ] = ProtocolMessageType . STREAM_END ;
842+ endPacket [ 1 ] = streamId ;
843+ endPacket [ 2 ] = totalSize & 0xFF ;
844+ endPacket [ 3 ] = ( totalSize >> 8 ) & 0xFF ;
845+ endPacket [ 4 ] = ( totalSize >> 16 ) & 0xFF ;
846+ endPacket [ 5 ] = ( totalSize >> 24 ) & 0xFF ;
847+
848+ await this . requestChar . writeValueWithResponse ( endPacket ) ;
849+ this . logVerbose ( `Stream ${ streamId } : STREAM_END sent` ) ;
850+
851+ // Wait for completion ACK
852+ await this . waitForStreamEvent ( streamState , 'complete' , 5000 ) ;
853+
854+ this . logInfo ( `Stream ${ streamId } completed successfully` ) ;
855+ return true ;
856+
857+ } catch ( error ) {
858+ this . logError ( `Stream ${ streamId } failed:` , error ) ;
859+ throw error ;
860+ } finally {
861+ this . activeStreams . delete ( streamId ) ;
862+ }
863+ }
864+
865+ /**
866+ * Wait for a stream event (start ACK, completion, or error)
867+ * @param {object } streamState
868+ * @param {string } eventType - 'start' or 'complete'
869+ * @param {number } timeoutMs
870+ */
871+ async waitForStreamEvent ( streamState , eventType , timeoutMs ) {
872+ return new Promise ( ( resolve , reject ) => {
873+ // Set up error rejection
874+ streamState . errorReject = reject ;
875+
876+ if ( eventType === 'start' ) {
877+ if ( streamState . startAcked ) {
878+ resolve ( ) ;
879+ return ;
880+ }
881+ streamState . startResolve = resolve ;
882+ } else if ( eventType === 'complete' ) {
883+ if ( streamState . complete ) {
884+ resolve ( ) ;
885+ return ;
886+ }
887+ streamState . completeResolve = resolve ;
888+ }
889+
890+ // Timeout
891+ setTimeout ( ( ) => {
892+ reject ( new Error ( `Stream ${ eventType } timeout (${ timeoutMs } ms)` ) ) ;
893+ } , timeoutMs ) ;
894+ } ) ;
895+ }
896+
897+ /**
898+ * Wait for a condition to become true
899+ * @param {function } condition - Function that returns boolean
900+ * @param {number } timeoutMs
901+ */
902+ async waitForCondition ( condition , timeoutMs ) {
903+ const startTime = Date . now ( ) ;
904+ while ( ! condition ( ) ) {
905+ if ( Date . now ( ) - startTime > timeoutMs ) {
906+ throw new Error ( 'Condition timeout' ) ;
907+ }
908+ await this . sleep ( 50 ) ;
909+ }
910+ }
911+
635912 /**
636913 * Helper function: Sleep
637914 * @param {number } ms
0 commit comments