From 4cdd8070fc78821c722009edc7e30e9c04cf349a Mon Sep 17 00:00:00 2001 From: Nicolo Micheletti Date: Tue, 9 Jun 2026 17:29:51 +0800 Subject: [PATCH 1/6] feat(asg): raise BLE pack size to 470B and add flowVersion:2 flags Align BesWireFormat with Phase 2 wire format so BES can use real fileSize and actual packSize from the first packet header. Co-authored-by: Cursor --- .../mentralive/internal/BesWireFormat.java | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/mentralive/internal/BesWireFormat.java b/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/mentralive/internal/BesWireFormat.java index e0887ef2d9..4f45f44640 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/mentralive/internal/BesWireFormat.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/mentralive/internal/BesWireFormat.java @@ -27,11 +27,11 @@ public class BesWireFormat { public static final byte CMD_TYPE_AUDIO = 0x34; // Audio file type public static final byte CMD_TYPE_DATA = 0x35; // Generic data type - // File transfer constants - public static final int FILE_PACK_SIZE_MAX = - 221; // 256 MTU - 3 ATT bytes - 32 protocol overhead - public static final int FILE_PACK_SIZE_DEFAULT = - FILE_PACK_SIZE_MAX; // Safe default before phone MTU config arrives + // File transfer constants (Phase 2: ~470B payload for 512 MTU) + public static final int FILE_PROTOCOL_OVERHEAD = 32; + public static final int FLOW_VERSION_2 = 2; + public static final int FILE_PACK_SIZE_MAX = 470; // 512 MTU - 3 ATT - 32 overhead + public static final int FILE_PACK_SIZE_DEFAULT = FILE_PACK_SIZE_MAX; public static final int FILE_PACK_SIZE_MIN = 100; // Minimum safe packet size private static int filePackSize = FILE_PACK_SIZE_DEFAULT; // Configurable packet size public static final int LENGTH_FILE_START = 2; @@ -74,6 +74,11 @@ public static void resetFilePackSize() { Log.i("BesWireFormat", "πŸ“¦ File pack size reset to default: " + filePackSize + " bytes"); } + /** Phase 2 flow version encoded in the 16-bit flags field (high byte). */ + public static int flowVersionFlags(int packIndex) { + return packIndex == 0 ? (FLOW_VERSION_2 << 8) : 0; + } + public static final int LENGTH_FILE_TYPE = 1; public static final int LENGTH_FILE_PACKSIZE = 2; public static final int LENGTH_FILE_PACKINDEX = 2; From 09cf3247a6ed6ecf57167eeb0f07f22fcdbec16e Mon Sep 17 00:00:00 2001 From: Nicolo Micheletti Date: Tue, 9 Jun 2026 17:29:51 +0800 Subject: [PATCH 2/6] feat(asg): drop fakeFileSize and pipeline UART file packets Send real fileSize with flowVersion flags, keep up to four UART packets in flight, and shrink the pipeline on BES buffer-full NAKs. Co-authored-by: Cursor --- .../managers/K900BluetoothManager.java | 218 ++++++++++-------- 1 file changed, 124 insertions(+), 94 deletions(-) diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/K900BluetoothManager.java b/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/K900BluetoothManager.java index fc683b04d9..cb25aca745 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/K900BluetoothManager.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/K900BluetoothManager.java @@ -55,29 +55,26 @@ public class K900BluetoothManager extends BaseBluetoothManager implements Serial private static final int MAX_BACKOFF_MS = 1000; // Cap exponential backoff at 1 second private static final int PACING_DELAY_MS = 75; // Delay between successful packets - BES2700 needs time to drain BLE TX + private static final int PIPELINE_WINDOW_DEFAULT = 4; + private static final int PIPELINE_WINDOW_MIN = 1; private int consecutiveFailures = 0; + private int pipelineWindow = PIPELINE_WINDOW_DEFAULT; // Inner class to track file transfer state private static class FileTransferSession { String filePath; String fileName; byte[] fileData; - int fileSize; // Real file size (for our internal tracking) - int fakeFileSize; // Inflated file size to tell BES firmware (totalPackets * 400) + int fileSize; int totalPackets; int currentPacketIndex; + int nextSendIndex; byte[] packetBuffer; boolean isActive; long startTime; boolean waitingForPhoneConfirmation; int retryCount; - // BES2700 firmware hardcodes FILE_PACK_SIZE=400 when calculating totalPack: - // totalPack = (fileSize + 400 - 1) / 400 - // We "lie" about fileSize so BES expects the correct number of packets. - // This allows us to send smaller packets (221 bytes) that fit within BLE MTU. - private static final int BES_HARDCODED_PACK_SIZE = 400; - FileTransferSession(String filePath, String fileName, byte[] fileData) { this.filePath = filePath; this.fileName = fileName; @@ -86,11 +83,8 @@ private static class FileTransferSession { this.totalPackets = (fileSize + BesWireFormat.getFilePackSize() - 1) / BesWireFormat.getFilePackSize(); - // Calculate fake file size so BES firmware calculates correct totalPack - // BES does: totalPack = (fileSize + 400 - 1) / 400 - // We want BES to get our totalPackets, so: fakeFileSize = totalPackets * 400 - this.fakeFileSize = totalPackets * BES_HARDCODED_PACK_SIZE; this.currentPacketIndex = 0; + this.nextSendIndex = 0; this.packetBuffer = new byte[BesWireFormat.getFilePacketSize(BesWireFormat.getFilePackSize())]; this.isActive = true; this.startTime = System.currentTimeMillis(); @@ -99,14 +93,14 @@ private static class FileTransferSession { Log.i( TAG, - "πŸ“¦ BES Lie Strategy: realSize=" + "πŸ“¦ Phase2 transfer: fileSize=" + fileSize - + ", fakeSize=" - + fakeFileSize + ", totalPackets=" + totalPackets - + ", actualPackSize=" - + BesWireFormat.getFilePackSize()); + + ", packSize=" + + BesWireFormat.getFilePackSize() + + ", flowVersion=" + + BesWireFormat.FLOW_VERSION_2); } } @@ -158,9 +152,6 @@ public K900BluetoothManager(Context context) { // Initialize file transfer executor fileTransferExecutor = Executors.newSingleThreadScheduledExecutor(); - - // Register ADB-toggleable transfer mode receiver - BleTransferMode.registerReceiver(context); } @Override @@ -738,9 +729,18 @@ public boolean sendFile(String filePath) { currentFileTransfer = new FileTransferSession(filePath, fileName, fileData); pendingPackets.clear(); - consecutiveFailures = 0; // Reset failure counter for new transfer + consecutiveFailures = 0; + pipelineWindow = PIPELINE_WINDOW_DEFAULT; currentStats = new BleTransferStats(); - Log.i(TAG, "πŸ“Š Transfer mode: " + currentStats.mode + ", preDelay=" + currentStats.preDelayMs + "ms"); + Log.i( + TAG, + "πŸ“Š Transfer mode: " + + currentStats.mode + + ", pipelineWindow=" + + pipelineWindow + + ", preDelay=" + + currentStats.preDelayMs + + "ms"); Log.d( TAG, @@ -763,65 +763,36 @@ public boolean sendFile(String filePath) { // Enable fast mode for file transfer comManager.setFastMode(true); - // Send the first packet - sendNextFilePacket(); + fillUartPipeline(); return true; } - /** Send the next file packet */ - private void sendNextFilePacket() { - long methodStartTime = System.currentTimeMillis(); - + private void fillUartPipeline() { if (currentFileTransfer == null || !currentFileTransfer.isActive) { return; } + while (pendingPackets.size() < pipelineWindow + && currentFileTransfer.nextSendIndex < currentFileTransfer.totalPackets) { + int packetIndex = currentFileTransfer.nextSendIndex++; + if (!sendSinglePacket(packetIndex)) { + return; + } + } + maybeCompleteUartTransfer(); + } - if (currentFileTransfer.currentPacketIndex >= currentFileTransfer.totalPackets) { - // All packets sent and ACKed by MCU - cancelFilePacketAckTimeout(); - long transferDuration = System.currentTimeMillis() - currentFileTransfer.startTime; - Log.d(TAG, "πŸ“€ All packets sent and ACKed by MCU: " + currentFileTransfer.fileName); - Log.d( - TAG, - "⏱️ Transfer took: " - + transferDuration - + "ms for " - + currentFileTransfer.fileSize - + " bytes"); - Log.d( - TAG, - "πŸ“Š Transfer rate: " - + (currentFileTransfer.fileSize * 1000 / transferDuration) - + " bytes/sec"); - Log.d(TAG, "⏳ Waiting for phone confirmation before cleanup..."); - - notificationManager.showDebugNotification( - "Waiting for Phone Confirmation", - currentFileTransfer.fileName + " - " + transferDuration + "ms"); - - // Set state to waiting for phone confirmation - currentFileTransfer.waitingForPhoneConfirmation = true; - - // Start timeout for phone confirmation (5 seconds) - schedulePhoneConfirmationTimeout(); + private boolean sendSinglePacket(int packetIndex) { + long methodStartTime = System.currentTimeMillis(); - // DO NOT delete file yet! - // DO NOT clear state yet! - // Keep everything in memory for potential retry - return; + if (currentFileTransfer == null || !currentFileTransfer.isActive) { + return false; } - // Calculate packet data - int packetIndex = currentFileTransfer.currentPacketIndex; int offset = packetIndex * BesWireFormat.getFilePackSize(); int packSize = Math.min(BesWireFormat.getFilePackSize(), currentFileTransfer.fileSize - offset); - // Pack the file packet - // NOTE: We use fakeFileSize to lie to BES firmware about total file size. - // BES hardcodes 400-byte pack size when calculating totalPack, so we inflate - // fileSize to make BES expect the correct number of our smaller packets. int packetLength = BesWireFormat.packFilePacketInto( currentFileTransfer.packetBuffer, @@ -829,9 +800,9 @@ private void sendNextFilePacket() { offset, packetIndex, packSize, - currentFileTransfer.fakeFileSize, + currentFileTransfer.fileSize, currentFileTransfer.fileName, - 0, // flags = 0 + BesWireFormat.flowVersionFlags(packetIndex), BesWireFormat.CMD_TYPE_PHOTO); if (packetLength <= 0) { @@ -841,10 +812,9 @@ private void sendNextFilePacket() { currentFileTransfer = null; pendingPackets.clear(); cancelFilePacketAckTimeout(); - return; + return false; } - // Send the packet using sendFile (no logging) long sendStartTime = System.currentTimeMillis(); boolean sent = comManager.sendFile(currentFileTransfer.packetBuffer, packetLength); long sendEndTime = System.currentTimeMillis(); @@ -857,15 +827,13 @@ private void sendNextFilePacket() { currentFileTransfer = null; pendingPackets.clear(); cancelFilePacketAckTimeout(); - return; + return false; } - // Track packet state for acknowledgment (preserve retry count if resending) FilePacketState existingState = pendingPackets.get(packetIndex); if (existingState == null) { pendingPackets.put(packetIndex, new FilePacketState()); } else { - // Update timestamp but preserve retry count existingState.lastSendTime = System.currentTimeMillis(); } @@ -885,10 +853,76 @@ private void sendNextFilePacket() { + (sendEndTime - sendStartTime) + "ms, total method time: " + totalMethodTime - + "ms"); + + "ms, pipeline=" + + pendingPackets.size() + + "/" + + pipelineWindow + + ")"); } - scheduleFilePacketAckTimeout(packetIndex); + scheduleFilePacketAckTimeout(oldestPendingPacketIndex()); + return true; + } + + private int oldestPendingPacketIndex() { + int oldest = Integer.MAX_VALUE; + for (Integer idx : pendingPackets.keySet()) { + if (idx < oldest) { + oldest = idx; + } + } + return oldest == Integer.MAX_VALUE ? 0 : oldest; + } + + private void maybeCompleteUartTransfer() { + if (currentFileTransfer == null || !currentFileTransfer.isActive) { + return; + } + if (currentFileTransfer.nextSendIndex < currentFileTransfer.totalPackets + || !pendingPackets.isEmpty()) { + return; + } + + cancelFilePacketAckTimeout(); + long transferDuration = System.currentTimeMillis() - currentFileTransfer.startTime; + Log.d(TAG, "πŸ“€ All packets sent and ACKed by MCU: " + currentFileTransfer.fileName); + Log.d( + TAG, + "⏱️ Transfer took: " + + transferDuration + + "ms for " + + currentFileTransfer.fileSize + + " bytes"); + Log.d( + TAG, + "πŸ“Š Transfer rate: " + + (currentFileTransfer.fileSize * 1000 / transferDuration) + + " bytes/sec"); + if (!BleTransferMode.isPhoneConfirmationRequired()) { + Log.i( + TAG, + "πŸ“± Skipping phone confirmation β€” UART transfer complete for " + + currentFileTransfer.fileName); + handlePhoneConfirmation(currentFileTransfer.fileName, true); + return; + } + + Log.d(TAG, "⏳ Waiting for phone confirmation before cleanup..."); + notificationManager.showDebugNotification( + "Waiting for Phone Confirmation", + currentFileTransfer.fileName + " - " + transferDuration + "ms"); + currentFileTransfer.waitingForPhoneConfirmation = true; + schedulePhoneConfirmationTimeout(); + } + + /** Legacy entry for retries β€” sends from current index and fills pipeline. */ + private void sendNextFilePacket() { + if (currentFileTransfer == null || !currentFileTransfer.isActive) { + return; + } + currentFileTransfer.nextSendIndex = + Math.max(currentFileTransfer.nextSendIndex, currentFileTransfer.currentPacketIndex); + fillUartPipeline(); } private void scheduleFilePacketAckTimeout(int packetIndex) { @@ -1006,31 +1040,25 @@ public void handleFileTransferAck(int state, int index) { + currentFileTransfer.currentPacketIndex); if (state == 1) { // Success (K900 uses state=1 for success) - // CRITICAL: Ignore duplicate ACKs for packets we've already moved past - // This prevents scheduling multiple sendNextFilePacket() calls - if (ackedPacketIndex < currentFileTransfer.currentPacketIndex) { + if (!pendingPackets.containsKey(ackedPacketIndex)) { Log.w( TAG, - "⚠️ Ignoring duplicate ACK for already-processed packet " - + ackedPacketIndex - + " (current=" - + currentFileTransfer.currentPacketIndex - + ")"); + "⚠️ Ignoring duplicate/stale ACK for packet " + + ackedPacketIndex); return; } cancelFilePacketAckTimeout(); - // Reset consecutive failure counter on success consecutiveFailures = 0; + if (pipelineWindow < PIPELINE_WINDOW_DEFAULT) { + pipelineWindow = + Math.min(pipelineWindow + 1, PIPELINE_WINDOW_DEFAULT); + } - // Remove from pending packets pendingPackets.remove(ackedPacketIndex); - - // Move to next packet currentFileTransfer.currentPacketIndex = ackedPacketIndex + 1; - // Send next packet immediately - BES flow control via ACKs handles pacing - sendNextFilePacket(); + fillUartPipeline(); } else { // Error - BES2700 buffer likely full, need to backoff before retry // state=0 means BES couldn't process the packet (flow control) @@ -1104,9 +1132,11 @@ public void handleFileTransferAck(int state, int index) { + backoffMs + "ms"); + pipelineWindow = PIPELINE_WINDOW_MIN; + pendingPackets.clear(); currentFileTransfer.currentPacketIndex = retryPacketIndex; + currentFileTransfer.nextSendIndex = retryPacketIndex; - // Add exponential backoff delay to let BES2700 drain its buffers fileTransferExecutor.schedule( () -> { if (currentFileTransfer != null && currentFileTransfer.isActive) { @@ -1116,8 +1146,8 @@ public void handleFileTransferAck(int state, int index) { + retryPacketIndex + " after " + backoffMs - + "ms backoff"); - sendNextFilePacket(); + + "ms backoff (pipeline=1)"); + fillUartPipeline(); } }, backoffMs, @@ -1214,14 +1244,14 @@ public void handlePhoneConfirmation(String fileName, boolean success) { + "/" + (MAX_TRANSFER_RETRIES + 1)); - // Reset for retry currentFileTransfer.currentPacketIndex = 0; + currentFileTransfer.nextSendIndex = 0; currentFileTransfer.startTime = System.currentTimeMillis(); currentFileTransfer.waitingForPhoneConfirmation = false; pendingPackets.clear(); + pipelineWindow = PIPELINE_WINDOW_DEFAULT; cancelFilePacketAckTimeout(); - // Restart transfer from packet 0 Log.d(TAG, "πŸ”„ Restarting transfer from packet 0"); sendNextFilePacket(); } else { From 6e36b4d25845456af26efebccdcc1546d2ef9a4e Mon Sep 17 00:00:00 2001 From: Nicolo Micheletti Date: Tue, 9 Jun 2026 17:29:51 +0800 Subject: [PATCH 3/6] feat(asg): replace pre-transfer sleep with ble_ready_ack handshake Phone sends ble_ready_ack after ble_photo_ready; ASG waits on a latch instead of a fixed Thread.sleep before starting file packets. Co-authored-by: Cursor --- .../bluetooth/managers/BlePhotoReadyAck.java | 60 +++++++++++++++++++ .../io/media/core/MediaCaptureService.java | 41 ++++++++----- .../handlers/BleReadyAckCommandHandler.java | 37 ++++++++++++ .../core/processors/CommandProcessor.java | 4 ++ 4 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/BlePhotoReadyAck.java create mode 100644 asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/BleReadyAckCommandHandler.java diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/BlePhotoReadyAck.java b/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/BlePhotoReadyAck.java new file mode 100644 index 0000000000..a05d09310a --- /dev/null +++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/BlePhotoReadyAck.java @@ -0,0 +1,60 @@ +package com.mentra.asg_client.io.bluetooth.managers; + +import android.util.Log; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * Synchronizes ASG {@code ble_photo_ready} transmission with phone {@code ble_ready_ack} so file + * packets start only after the JSON notification has reached the phone (Phase 2). + */ +public final class BlePhotoReadyAck { + + private static final String TAG = "BlePhotoReadyAck"; + private static final ConcurrentHashMap LATCHES = + new ConcurrentHashMap<>(); + + private BlePhotoReadyAck() {} + + public static void prepare(String requestId) { + if (requestId == null || requestId.isEmpty()) { + return; + } + LATCHES.put(requestId, new CountDownLatch(1)); + } + + public static boolean await(String requestId, long timeoutMs) { + if (requestId == null || requestId.isEmpty()) { + return false; + } + CountDownLatch latch = LATCHES.get(requestId); + if (latch == null) { + return false; + } + try { + boolean signaled = latch.await(timeoutMs, TimeUnit.MILLISECONDS); + if (!signaled) { + Log.w(TAG, "ble_ready_ack timeout for requestId=" + requestId); + } + return signaled; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Log.w(TAG, "ble_ready_ack wait interrupted for requestId=" + requestId); + return false; + } finally { + LATCHES.remove(requestId); + } + } + + public static void signal(String requestId) { + if (requestId == null || requestId.isEmpty()) { + return; + } + CountDownLatch latch = LATCHES.remove(requestId); + if (latch != null) { + latch.countDown(); + Log.i(TAG, "ble_ready_ack received for requestId=" + requestId); + } + } +} diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/io/media/core/MediaCaptureService.java b/asg_client/app/src/main/java/com/mentra/asg_client/io/media/core/MediaCaptureService.java index 6b7f59f116..00ad35a48b 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/io/media/core/MediaCaptureService.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/io/media/core/MediaCaptureService.java @@ -21,7 +21,9 @@ import com.mentra.asg_client.io.streaming.services.RtmpStreamingService; import com.mentra.asg_client.io.streaming.services.SrtStreamingService; import com.mentra.asg_client.io.streaming.services.WhipStreamingService; +import com.mentra.asg_client.io.bluetooth.managers.BlePhotoReadyAck; import com.mentra.asg_client.io.bluetooth.managers.BleTransferMode; +import com.mentra.asg_client.io.bluetooth.managers.mentralive.internal.BesWireFormat; import com.mentra.asg_client.logging.BleTraceLogger; import com.mentra.asg_client.service.core.CameraRestartCooldown; import com.mentra.asg_client.service.core.constants.BatteryConstants; @@ -128,22 +130,23 @@ private static class BleParams { } private BleParams resolveBleParams(String requestedSize) { - // BLE transfer is limited by BES2700 TX buffer (~88 packets before overflow) - // With 221-byte pack size, max reliable transfer is ~19KB - // Target file sizes accordingly with aggressive compression + // Phase 2: 470B packs @ 512 MTU β€” target file sizes for bandwidth bench / tier. switch (requestedSize) { case "small": // Target ~8KB: 400x400 @ quality 28 return new BleParams(400, 400, 28); case "large": - // Target ~25KB: 800x800 @ quality 32 (may hit BLE limit) + // Target ~25KB: 800x800 @ quality 32 return new BleParams(800, 800, 32); case "full": - // Target ~35KB: 1024x1024 @ quality 35 (will likely hit BLE limit) + // Target ~35KB: 1024x1024 @ quality 35 return new BleParams(1024, 1024, 35); + case "max": + // Bench / max tier: sensor capture + ~80-150KB AVIF over BLE + return new BleParams(1920, 1920, 40); case "medium": default: - // Target ~15KB: 640x640 @ quality 30 - safe for BLE + // Target ~15KB: 640x640 @ quality 30 return new BleParams(640, 640, 30); } } @@ -3571,17 +3574,22 @@ private void sendCompressedPhotoViaBle( // BLE is available - send the ready message first (phone expects this for timing // tracking) recordTiming(requestId, "ble_ready_msg"); + BlePhotoReadyAck.prepare(requestId); sendBlePhotoReadyMsg(compressedPath, bleImgId, requestId, transferStartTime); - // Add delay to ensure JSON packet completes transmission through MCU before file - // packets start - // This prevents packet interleaving at the BLE MTU boundary - try { - int preDelay = BleTransferMode.preTransferDelayMs(); - Thread.sleep(preDelay); - Log.d(TAG, "⏱️ Waited " + preDelay + "ms for JSON packet [" + BleTransferMode.get() + "]"); - } catch (InterruptedException e) { - Log.w(TAG, "Delay interrupted", e); + // Phase 2: wait for phone ble_ready_ack instead of fixed sleep. + long ackWaitMs = BleTransferMode.preTransferDelayMs(); + boolean ackReceived = BlePhotoReadyAck.await(requestId, ackWaitMs); + if (ackReceived) { + Log.i(TAG, "⏱️ ble_ready_ack received [" + BleTransferMode.get() + "]"); + } else { + Log.w( + TAG, + "⏱️ ble_ready_ack not received within " + + ackWaitMs + + "ms, continuing [" + + BleTransferMode.get() + + "]"); } // Then try to start the file transfer @@ -3639,7 +3647,8 @@ private void sendBlePhotoReadyMsg( json.put("type", "ble_photo_ready"); json.put("requestId", requestId); json.put("bleImgId", bleImgId); - json.put("compressionDurationMs", compressionDuration); // Send duration, not timestamp + json.put("flowVersion", BesWireFormat.FLOW_VERSION_2); + json.put("compressionDurationMs", compressionDuration); sendPhotoStatus(requestId, "ready_for_transfer"); // Send through bluetooth if available diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/BleReadyAckCommandHandler.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/BleReadyAckCommandHandler.java new file mode 100644 index 0000000000..a6e63536bf --- /dev/null +++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/handlers/BleReadyAckCommandHandler.java @@ -0,0 +1,37 @@ +package com.mentra.asg_client.service.core.handlers; + +import android.util.Log; +import com.mentra.asg_client.io.bluetooth.managers.BlePhotoReadyAck; +import com.mentra.asg_client.service.legacy.interfaces.ICommandHandler; +import java.util.Set; +import org.json.JSONObject; + +/** Handles {@code ble_ready_ack} from the phone after {@code ble_photo_ready}. */ +public class BleReadyAckCommandHandler implements ICommandHandler { + + private static final String TAG = "BleReadyAckHandler"; + + @Override + public Set getSupportedCommandTypes() { + return Set.of("ble_ready_ack"); + } + + @Override + public boolean handleCommand(String commandType, JSONObject data) { + try { + if (!"ble_ready_ack".equals(commandType)) { + return false; + } + String requestId = data.optString("requestId", ""); + if (requestId.isEmpty()) { + Log.w(TAG, "ble_ready_ack missing requestId"); + return false; + } + BlePhotoReadyAck.signal(requestId); + return true; + } catch (Exception e) { + Log.e(TAG, "Error handling ble_ready_ack", e); + return false; + } + } +} diff --git a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/processors/CommandProcessor.java b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/processors/CommandProcessor.java index 05aaca19b7..16c5e8d49e 100644 --- a/asg_client/app/src/main/java/com/mentra/asg_client/service/core/processors/CommandProcessor.java +++ b/asg_client/app/src/main/java/com/mentra/asg_client/service/core/processors/CommandProcessor.java @@ -26,6 +26,7 @@ import com.mentra.asg_client.service.core.handlers.ServiceHeartbeatCommandHandler; import com.mentra.asg_client.service.core.handlers.SettingsCommandHandler; import com.mentra.asg_client.service.core.handlers.StreamCommandHandler; +import com.mentra.asg_client.service.core.handlers.BleReadyAckCommandHandler; import com.mentra.asg_client.service.core.handlers.TransferCompleteCommandHandler; import com.mentra.asg_client.service.core.handlers.UploadIncidentLogsCommandHandler; import com.mentra.asg_client.service.core.handlers.UserEmailCommandHandler; @@ -406,6 +407,9 @@ private void initializeCommandHandlers() { new TransferCompleteCommandHandler(serviceManager)); Log.d(TAG, "βœ… Registered TransferCompleteCommandHandler"); + commandHandlerRegistry.registerHandler(new BleReadyAckCommandHandler()); + Log.d(TAG, "βœ… Registered BleReadyAckCommandHandler"); + commandHandlerRegistry.registerHandler(rgbLedCommandHandler); Log.d(TAG, "βœ… Registered RgbLedCommandHandler"); From c80060b969e419a97f95e47b5516dfb109f165c6 Mon Sep 17 00:00:00 2001 From: Nicolo Micheletti Date: Tue, 9 Jun 2026 17:29:51 +0800 Subject: [PATCH 4/6] feat(sdk): Phase 2 BLE file transfer on phone Use 470B FILE_PACK_SIZE, remove fakeFileSize/isBesLie workaround, and emit ble_ready_ack when ble_photo_ready arrives. Co-authored-by: Cursor --- .../mentra/bluetoothsdk/sgcs/MentraLive.java | 246 ++++++++++++++---- .../bluetoothsdk/utils/K900ProtocolUtils.java | 2 +- .../ios/Source/sgcs/MentraLive.swift | 136 +++++++--- 3 files changed, 296 insertions(+), 88 deletions(-) diff --git a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java index e22e856434..39e93a4332 100644 --- a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +++ b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java @@ -49,6 +49,7 @@ import com.mentra.bluetoothsdk.utils.IncidentLogBleRelayNaming; import com.mentra.bluetoothsdk.utils.IncidentLogBleUploadService; import com.mentra.bluetoothsdk.DeviceStore; +import com.mentra.bluetoothsdk.bench.BleBandwidthBench; import com.mentra.bluetoothsdk.utils.PhoneAudioMonitor; // old augmentos imports: @@ -314,8 +315,8 @@ private static final class BleIncidentLogRelay { // Inner class to track incoming file transfers private static class FileTransferSession { String fileName; - int fileSize; // NOTE: This may be "fake" (inflated) due to BES firmware workaround - int actualPackSize; // Actual pack size from first received packet (for BES lie detection) + int fileSize; + int actualPackSize; int totalPackets; int expectedNextPacket; ConcurrentHashMap receivedPackets; @@ -323,16 +324,10 @@ private static class FileTransferSession { boolean isComplete; boolean isAnnounced; - // BES2700 firmware hardcodes FILE_PACK_SIZE=400 when calculating totalPack. - // We "lie" about fileSize to make BES expect correct packet count. - // This constant must match the one in asg_client's FileTransferSession. - private static final int BES_HARDCODED_PACK_SIZE = 400; - FileTransferSession(String fileName, int fileSize) { this.fileName = fileName; this.fileSize = fileSize; - this.actualPackSize = 0; // Will be set on first packet - // Initialize with max expected packets - will be recalculated on first packet + this.actualPackSize = 0; this.totalPackets = (fileSize + K900ProtocolUtils.FILE_PACK_SIZE - 1) / K900ProtocolUtils.FILE_PACK_SIZE; this.expectedNextPacket = 0; this.receivedPackets = new ConcurrentHashMap<>(); @@ -341,34 +336,14 @@ private static class FileTransferSession { this.isAnnounced = false; } - /** - * Recalculate total packets based on actual pack size from received packet. - * Called when first packet is received to handle variable pack sizes. - * - * NOTE: Due to BES firmware workaround, fileSize in header may be "fake" (inflated). - * We detect this by checking if fileSize is a multiple of 400 (BES_HARDCODED_PACK_SIZE). - * If so, totalPackets = fileSize / 400, regardless of actual pack size. - */ + /** Recalculate total packets from actual pack size in the first received packet header. */ void recalculateTotalPackets(int actualPackSize) { if (actualPackSize <= 0 || actualPackSize > K900ProtocolUtils.FILE_PACK_SIZE) { return; } this.actualPackSize = actualPackSize; - - // Detect BES lie: if fileSize is exact multiple of 400, glasses used the lie strategy - boolean isBesLie = (fileSize % BES_HARDCODED_PACK_SIZE == 0) && (actualPackSize != BES_HARDCODED_PACK_SIZE); - - int newTotalPackets; - if (isBesLie) { - // BES lie detected: totalPackets = fileSize / 400 - newTotalPackets = fileSize / BES_HARDCODED_PACK_SIZE; - Log.i("FileTransferSession", "πŸ“¦ BES Lie detected! fakeFileSize=" + fileSize + - ", totalPackets=" + newTotalPackets + ", actualPackSize=" + actualPackSize); - } else { - // Normal case: calculate based on actual pack size - newTotalPackets = (fileSize + actualPackSize - 1) / actualPackSize; - } + int newTotalPackets = (fileSize + actualPackSize - 1) / actualPackSize; if (newTotalPackets != totalPackets) { Log.i("FileTransferSession", "πŸ“¦ Recalculating totalPackets: " + totalPackets + " -> " + newTotalPackets + @@ -1179,6 +1154,15 @@ public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState DeviceStore.INSTANCE.apply("bluetooth", "device_address", connectedDevice.getAddress()); } + gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + gatt.setPreferredPhy( + BluetoothDevice.PHY_LE_2M_MASK, + BluetoothDevice.PHY_LE_2M_MASK, + BluetoothDevice.PHY_OPTION_NO_PREFERRED); + Bridge.log("LIVE: Requested LE 2M PHY (tx/rx)"); + } + // Save the connected device name for future reconnections // no longer needed as we now save it immediately in connectToDevice() // if (connectedDevice != null && connectedDevice.getName() != null) { @@ -1484,6 +1468,23 @@ public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descri writeNextDescriptor(); } + @Override + public void onPhyUpdate(BluetoothGatt gatt, int txPhy, int rxPhy, int status) { + if (status == BluetoothGatt.GATT_SUCCESS) { + Bridge.log("LIVE: PHY updated tx=" + phyLabel(txPhy) + " rx=" + phyLabel(rxPhy)); + } else { + Log.w(TAG, "PHY update failed status=" + status); + Bridge.log("LIVE: PHY update failed status=" + status); + } + } + + @Override + public void onPhyRead(BluetoothGatt gatt, int txPhy, int rxPhy, int status) { + if (status == BluetoothGatt.GATT_SUCCESS) { + Bridge.log("LIVE: PHY read tx=" + phyLabel(txPhy) + " rx=" + phyLabel(rxPhy)); + } + } + @Override public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { if (status == BluetoothGatt.GATT_SUCCESS) { @@ -2429,9 +2430,11 @@ private void processJsonMessage(JSONObject json) { if (!photoSuccess) { // Handle failed photo response + String errorCode = json.optString("errorCode", ""); String errorMsg = json.optString("errorMessage", json.optString("error", "Unknown error")); Bridge.log("LIVE: Photo request failed - requestId: " + requestId + ", appId: " + appId + ", error: " + errorMsg); + handleBenchPhotoCaptureFailed(requestId, errorCode, errorMsg); } else { // Handle successful photo (in future implementation) Bridge.log("LIVE: Photo request succeeded - requestId: " + requestId); @@ -2711,14 +2714,9 @@ private void processJsonMessage(JSONObject json) { // Stop the readiness check loop since we got confirmation stopReadinessCheckLoop(); - // Send BLE MTU config to glasses so they can adjust file packet sizes. - // Use the minimum of negotiated MTU and BES2700's known limit (256). - // BES2700 chip often ignores higher negotiated MTUs and truncates to 253 bytes, - // but we should respect the actual negotiated value if it's lower. - final int BES2700_MTU_LIMIT = 256; // BES2700's known notification size limit - final int effectiveMtu = Math.min(currentMtu, BES2700_MTU_LIMIT); - Bridge.log("LIVE: πŸ“¦ Sending BLE MTU config: negotiated=" + currentMtu + ", BES2700 limit=" + BES2700_MTU_LIMIT + ", effective=" + effectiveMtu); - try { sendBleMtuConfig(effectiveMtu); } + // Phase 2: propagate negotiated ATT MTU so ASG uses ~470B payloads (MTU - 3 - 32). + Bridge.log("LIVE: πŸ“¦ Sending BLE MTU config: negotiated=" + currentMtu); + try { sendBleMtuConfig(currentMtu); } catch (Throwable t) { Bridge.log("LIVE: ⚠️ glasses_ready: sendBleMtuConfig threw: " + t); } // Now we can perform all SOC-dependent initialization @@ -2792,8 +2790,7 @@ private void processJsonMessage(JSONObject json) { // This check maintains platform parity with iOS if (audioConnected) { Bridge.log("LIVE: Audio: Both glasses_ready and audio connected - marking as fully connected"); - DeviceStore.INSTANCE.apply("glasses", "fullyBooted", true); - updateConnectionState(ConnTypes.CONNECTED); + markFullyConnectedAndScheduleBench(); } else { Bridge.log("LIVE: Audio: Waiting for CTKD audio bonding before marking as fully connected"); } @@ -3181,15 +3178,22 @@ private void processBlePhotoReady(JSONObject json) { Bridge.log("LIVE: πŸ“Έ BLE photo ready notification: bleImgId=" + bleImgId + ", requestId=" + requestId); - // Update the transfer with glasses compression duration BlePhotoTransfer transfer = blePhotoTransfers.get(bleImgId); if (transfer != null) { transfer.glassesCompressionDurationMs = compressionDurationMs; - transfer.bleTransferStartTime = System.currentTimeMillis(); // BLE transfer starts now + transfer.bleTransferStartTime = System.currentTimeMillis(); Bridge.log("LIVE: ⏱️ Glasses compression took: " + compressionDurationMs + "ms"); } else { Log.w(TAG, "Received ble_photo_ready for unknown transfer: " + bleImgId); } + + if (!requestId.isEmpty()) { + JSONObject ack = new JSONObject(); + ack.put("type", "ble_ready_ack"); + ack.put("requestId", requestId); + sendJsonWithoutAck(ack, true); + Bridge.log("LIVE: Sent ble_ready_ack for requestId=" + requestId); + } } catch (Exception e) { Log.e(TAG, "Error processing ble_photo_ready", e); } @@ -3629,6 +3633,19 @@ private void sendStoredUserEmailToAsgClient() { /** * Convert bytes to hex string for debugging */ + private static String phyLabel(int phy) { + switch (phy) { + case BluetoothDevice.PHY_LE_1M: + return "1M"; + case BluetoothDevice.PHY_LE_2M: + return "2M"; + case BluetoothDevice.PHY_LE_CODED: + return "Coded"; + default: + return "unknown(" + phy + ")"; + } + } + private static String bytesToHex(byte[] bytes) { StringBuilder sb = new StringBuilder(); for (byte b : bytes) { @@ -4360,6 +4377,105 @@ private void handlePhoneAudioStateChanged(boolean isPlaying) { } } + /** + * Debug-only: fire a BLE {@code take_photo} after {@code glasses_ready} to measure phone↔glasses bandwidth. + * Gated by {@link BleBandwidthBench}. + */ + private void runBleBandwidthBench() { + if (!BleBandwidthBench.isEnabled(context)) { + return; + } + int attempt = BleBandwidthBench.beginAttempt(); + int maxAttempts = BleBandwidthBench.getMaxAttempts(); + if (attempt > maxAttempts) { + String giveUp = BleBandwidthBench.LOG_PREFIX + + " give_up after " + + maxAttempts + + " attempts (camera never ready)"; + Log.w(BleBandwidthBench.TAG, giveUp); + Bridge.log("LIVE: πŸ“Š " + giveUp); + return; + } + + String requestId = "ble-bench-" + System.currentTimeMillis(); + String bleImgId = "bench" + String.format("%09d", System.currentTimeMillis() % 1000000000L); + String size = BleBandwidthBench.getPhotoSize(); + + try { + JSONObject json = new JSONObject(); + json.put("type", "take_photo"); + json.put("requestId", requestId); + json.put("appId", "com.mentra.ble.bench"); + json.put("size", size); + json.put("compress", "none"); + json.put("transferMethod", "ble"); + json.put("bleImgId", bleImgId); + json.put("save", false); + json.put("flash", false); + json.put("sound", false); + + BlePhotoTransfer transfer = new BlePhotoTransfer(bleImgId, requestId, ""); + transfer.phoneStartTime = System.currentTimeMillis(); + blePhotoTransfers.put(bleImgId, transfer); + + String startLine = BleBandwidthBench.LOG_PREFIX + + " start attempt=" + + attempt + + "/" + + maxAttempts + + " requestId=" + + requestId + + " size=" + + size + + " mtu=" + + currentMtu; + Log.i(BleBandwidthBench.TAG, startLine); + Bridge.log("LIVE: πŸ“Š " + startLine); + + sendJson(json, true); + } catch (JSONException e) { + Log.e(BleBandwidthBench.TAG, "Failed to start BLE bandwidth bench", e); + } + } + + private void handleBenchPhotoCaptureFailed( + String requestId, String errorCode, String errorMessage) { + if (!BleBandwidthBench.isBenchRequestId(requestId)) { + return; + } + if (BleBandwidthBench.isRetryableError(errorCode, errorMessage) + && BleBandwidthBench.canRetryAfterFailure()) { + long delayMs = BleBandwidthBench.getRetryDelayMs(); + int nextAttempt = BleBandwidthBench.getAttemptCount() + 1; + String retryLine = BleBandwidthBench.LOG_PREFIX + + " retry scheduled in " + + delayMs + + "ms after " + + errorCode + + ": " + + errorMessage + + " (next attempt " + + nextAttempt + + "/" + + BleBandwidthBench.getMaxAttempts() + + ")"; + Log.w(BleBandwidthBench.TAG, retryLine); + Bridge.log("LIVE: πŸ“Š " + retryLine); + handler.postDelayed(this::runBleBandwidthBench, delayMs); + return; + } + String failLine = BleBandwidthBench.LOG_PREFIX + + " failed requestId=" + + requestId + + " error=" + + errorCode + + ": " + + errorMessage + + " (no more retries)"; + Log.e(BleBandwidthBench.TAG, failLine); + Bridge.log("LIVE: πŸ“Š " + failLine); + } + public void requestPhoto(String requestId, String appId, String size, String webhookUrl, String authToken, String compress, boolean flash, boolean save, boolean sound, Long exposureTimeNs, Integer iso) { boolean hasAuthToken = authToken != null && !authToken.isEmpty(); Bridge.log("LIVE: Requesting photo: " + requestId + " for app: " + appId + " with size: " + size + ", webhookUrl: " + webhookUrl + ", authToken: " + (hasAuthToken ? "***" : "none") + ", compress=" + compress + ", flash=" + flash + ", save=" + save + ", sound=" + sound + ", exposureTimeNs=" + exposureTimeNs + ", iso=" + iso); @@ -4607,8 +4723,7 @@ public void onReceive(Context context, Intent intent) { // If glasses_ready was already received, now we're fully ready if (glassesReadyReceived) { Bridge.log("LIVE: Audio: Both audio and glasses_ready confirmed - marking as fully connected"); - DeviceStore.INSTANCE.apply("glasses", "fullyBooted", true); - updateConnectionState(ConnTypes.CONNECTED); + markFullyConnectedAndScheduleBench(); } // Send audio connected event for platform parity with iOS @@ -4844,8 +4959,20 @@ private void markAudioConnected(String deviceName) { Bridge.sendAudioConnected(deviceName); if (glassesReadyReceived) { Bridge.log("LIVE: A2DP: Both audio and glasses_ready confirmed - marking as fully connected"); - DeviceStore.INSTANCE.apply("glasses", "fullyBooted", true); - updateConnectionState(ConnTypes.CONNECTED); + markFullyConnectedAndScheduleBench(); + } + } + + private void markFullyConnectedAndScheduleBench() { + DeviceStore.INSTANCE.apply("glasses", "fullyBooted", true); + updateConnectionState(ConnTypes.CONNECTED); + if (BleBandwidthBench.scheduleAfterFullyConnected(context, handler, this::runBleBandwidthBench)) { + Bridge.log( + "LIVE: πŸ“Š BLE_BANDWIDTH_BENCH scheduled in " + + BleBandwidthBench.getInitialDelayMs() + + "ms after fully connected (maxAttempts=" + + BleBandwidthBench.getMaxAttempts() + + ")"); } } @@ -4951,6 +5078,8 @@ public void destroy() { connectionTimeoutHandler.removeCallbacks(connectionTimeoutRunnable); } + BleBandwidthBench.resetScheduleState(); + // Cancel any pending handlers handler.removeCallbacksAndMessages(null); heartbeatHandler.removeCallbacksAndMessages(null); @@ -6390,6 +6519,22 @@ private void processFilePacket(K900ProtocolUtils.FilePacketInfo packetInfo) { Bridge.log("LIVE: ⏱️ BLE transfer duration: " + bleTransferDuration + "ms"); Bridge.log("LIVE: πŸ“Š Transfer rate: " + (packetInfo.fileSize * 1000 / bleTransferDuration) + " bytes/sec"); } + if (BleBandwidthBench.isBenchRequestId(photoTransfer.requestId) && bleTransferDuration > 0) { + long benchRate = packetInfo.fileSize * 1000L / bleTransferDuration; + String benchLine = BleBandwidthBench.LOG_PREFIX + + " result requestId=" + photoTransfer.requestId + + " fileSize=" + packetInfo.fileSize + + " packs=" + photoTransfer.session.totalPackets + + " packSize=" + packetInfo.packSize + + " bleMs=" + bleTransferDuration + + " totalMs=" + totalDuration + + " compressionMs=" + photoTransfer.glassesCompressionDurationMs + + " mtu=" + currentMtu + + " rateBps=" + benchRate; + Log.i(BleBandwidthBench.TAG, benchLine); + Bridge.log("LIVE: πŸ“Š " + benchLine); + BleBandwidthBench.markSucceeded(); + } // Get complete image data (AVIF or JPEG) byte[] imageData = photoTransfer.session.assembleFile(); @@ -6811,12 +6956,7 @@ private void sendBleTransferComplete(String requestId, String bleImgId, boolean } } - /** - * Send BLE MTU config to glasses so they can adjust file packet sizes. - * The BES2700 chip on the glasses truncates packets to 253 bytes (256 MTU - 3 ATT header) - * regardless of negotiated MTU. By sending the actual MTU, glasses can use smaller - * packet sizes that fit within this limit. - */ + /** Send negotiated BLE MTU to glasses so ASG/BES align file pack size (Phase 2). */ private void sendBleMtuConfig(int mtu) { try { JSONObject json = new JSONObject(); diff --git a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java index 9d389a58d8..3b2eb0330b 100644 --- a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +++ b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java @@ -29,7 +29,7 @@ public class K900ProtocolUtils { public static final byte CMD_TYPE_DATA = 0x35; // Generic data type // File transfer constants - public static final int FILE_PACK_SIZE = 400; // Max data size per packet + public static final int FILE_PACK_SIZE = 470; // Phase 2 max data size per packet (512 MTU) public static final int LENGTH_FILE_START = 2; public static final int LENGTH_FILE_TYPE = 1; public static final int LENGTH_FILE_PACKSIZE = 2; diff --git a/mobile/modules/bluetooth-sdk/ios/Source/sgcs/MentraLive.swift b/mobile/modules/bluetooth-sdk/ios/Source/sgcs/MentraLive.swift index 4ce14bbcfd..b95b38311c 100644 --- a/mobile/modules/bluetooth-sdk/ios/Source/sgcs/MentraLive.swift +++ b/mobile/modules/bluetooth-sdk/ios/Source/sgcs/MentraLive.swift @@ -511,7 +511,7 @@ private enum K900ProtocolUtils { static let CMD_TYPE_DATA: UInt8 = 0x35 // File transfer constants - static let FILE_PACK_SIZE = 400 // Max data size per packet + static let FILE_PACK_SIZE = 470 // Phase 2 max data size per packet (512 MTU) static let LENGTH_FILE_START = 2 static let LENGTH_FILE_TYPE = 1 static let LENGTH_FILE_PACKSIZE = 2 @@ -626,8 +626,8 @@ private enum K900ProtocolUtils { private struct FileTransferSession { let fileName: String - let fileSize: Int // NOTE: May be "fake" (inflated) due to BES firmware workaround - var actualPackSize: Int = 0 // Actual pack size from first received packet + let fileSize: Int + var actualPackSize: Int = 0 var totalPackets: Int var expectedNextPacket: Int = 0 var receivedPackets: [Int: Data] = [:] @@ -635,10 +635,6 @@ private struct FileTransferSession { var isComplete: Bool = false var isAnnounced: Bool = false - /// BES2700 firmware hardcodes FILE_PACK_SIZE=400 when calculating totalPack. - /// Android glasses "lie" about fileSize to make BES expect correct packet count. - private static let BES_HARDCODED_PACK_SIZE = 400 - init(fileName: String, fileSize: Int, announcedPackets: Int? = nil) { self.fileName = fileName self.fileSize = fileSize @@ -663,29 +659,12 @@ private struct FileTransferSession { } } - /// Recalculate total packets based on actual pack size from received packet. - /// Detects BES lie: if fileSize is multiple of 400 but actual pack size differs. + /// Recalculate total packets from actual pack size in the first received packet header. mutating func recalculateTotalPackets(actualPackSize: Int) { guard actualPackSize > 0, actualPackSize <= K900ProtocolUtils.FILE_PACK_SIZE else { return } self.actualPackSize = actualPackSize - - // Detect BES lie: if fileSize is exact multiple of 400, glasses used the lie strategy - let isBesLie = - (fileSize % Self.BES_HARDCODED_PACK_SIZE == 0) - && (actualPackSize != Self.BES_HARDCODED_PACK_SIZE) - - let newTotalPackets: Int - if isBesLie { - // BES lie detected: totalPackets = fileSize / 400 - newTotalPackets = fileSize / Self.BES_HARDCODED_PACK_SIZE - print( - "πŸ“¦ BES Lie detected! fakeFileSize=\(fileSize), totalPackets=\(newTotalPackets), actualPackSize=\(actualPackSize)" - ) - } else { - // Normal case: calculate based on actual pack size - newTotalPackets = (fileSize + actualPackSize - 1) / actualPackSize - } + let newTotalPackets = (fileSize + actualPackSize - 1) / actualPackSize if newTotalPackets != totalPackets { print( @@ -873,6 +852,8 @@ extension MentraLive: CBCentralManagerDelegate { DispatchQueue.main.async { [weak self] in guard let self else { return } Bridge.log("Connected to GATT server, discovering services...") + // CoreBluetooth has no setPreferredPhy API; iOS auto-negotiates LE 2M when + // the phone supports Bluetooth 5 and the peripheral (BES) prefers 2M. self.stopConnectionTimeout() self.isConnecting = false @@ -1371,7 +1352,6 @@ class MentraLive: NSObject, SGCManager { private var connectedPeripheral: CBPeripheral? private var txCharacteristic: CBCharacteristic? private var rxCharacteristic: CBCharacteristic? - private let bes2700MtuLimit = 256 private var currentMtu: Int = 23 // Default BLE MTU // State Tracking @@ -2975,6 +2955,71 @@ class MentraLive: NSObject, SGCManager { fullyBooted = true connected = true updateConnectionState(ConnTypes.CONNECTED) + + BleBandwidthBench.scheduleAfterFullyConnected { [weak self] in + self?.runBleBandwidthBench() + } + } + + /// Debug-only BLE bandwidth probe after fully connected (see `BleBandwidthBench`). + private func runBleBandwidthBench() { + guard BleBandwidthBench.isEnabled else { return } + let attempt = BleBandwidthBench.beginAttempt() + let maxAttempts = BleBandwidthBench.maxAttempts + guard attempt <= maxAttempts else { + Bridge.log( + "LIVE: πŸ“Š \(BleBandwidthBench.logPrefix) give_up after \(maxAttempts) attempts (camera never ready)" + ) + return + } + + let requestId = "ble-bench-\(Int(Date().timeIntervalSince1970 * 1000))" + let bleImgId = "bench" + String(format: "%09d", Int(Date().timeIntervalSince1970 * 1000) % 100_000_000) + let size = BleBandwidthBench.photoSize + + var json: [String: Any] = [ + "type": "take_photo", + "requestId": requestId, + "appId": "com.mentra.ble.bench", + "size": size, + "compress": "none", + "transferMethod": "ble", + "bleImgId": bleImgId, + "save": false, + "flash": false, + "sound": false, + ] + + let transfer = BlePhotoTransfer(bleImgId: bleImgId, requestId: requestId, webhookUrl: "") + blePhotoTransfers[bleImgId] = transfer + + let startLine = + "\(BleBandwidthBench.logPrefix) start attempt=\(attempt)/\(maxAttempts) requestId=\(requestId) size=\(size) mtu=\(currentMtu)" + Bridge.log("LIVE: πŸ“Š \(startLine)") + + sendJson(json, wakeUp: true) + } + + private func handleBenchPhotoCaptureFailed( + requestId: String, errorCode: String?, errorMessage: String? + ) { + guard BleBandwidthBench.isBenchRequestId(requestId) else { return } + if BleBandwidthBench.isRetryableError(errorCode: errorCode, errorMessage: errorMessage), + BleBandwidthBench.canRetryAfterFailure() + { + let delaySec = Double(BleBandwidthBench.retryDelayNanos) / 1_000_000_000 + let nextAttempt = BleBandwidthBench.attemptCountForLogging + 1 + Bridge.log( + "LIVE: πŸ“Š \(BleBandwidthBench.logPrefix) retry scheduled in \(Int(delaySec))s after \(errorCode ?? ""): \(errorMessage ?? "") (next attempt \(nextAttempt)/\(BleBandwidthBench.maxAttempts))" + ) + DispatchQueue.main.asyncAfter(deadline: .now() + delaySec) { [weak self] in + self?.runBleBandwidthBench() + } + return + } + Bridge.log( + "LIVE: πŸ“Š \(BleBandwidthBench.logPrefix) failed requestId=\(requestId) error=\(errorCode ?? ""): \(errorMessage ?? "") (no more retries)" + ) } private func handleWifiScanResult(_ json: [String: Any]) { @@ -3097,15 +3142,19 @@ class MentraLive: NSObject, SGCManager { "LIVE: πŸ“Έ BLE photo ready notification: bleImgId=\(bleImgId), requestId=\(requestId)" ) - // Update the transfer with glasses compression duration if var transfer = blePhotoTransfers[bleImgId] { transfer.glassesCompressionDurationMs = compressionDurationMs - transfer.bleTransferStartTime = Date() // BLE transfer starts now + transfer.bleTransferStartTime = Date() blePhotoTransfers[bleImgId] = transfer Bridge.log("LIVE: ⏱️ Glasses compression took: \(compressionDurationMs)ms") } else { Bridge.log("LIVE: Received ble_photo_ready for unknown transfer: \(bleImgId)") } + + if !requestId.isEmpty { + sendJson(["type": "ble_ready_ack", "requestId": requestId], wakeUp: true, requireAck: false) + Bridge.log("LIVE: Sent ble_ready_ack for requestId=\(requestId)") + } } private func processBlePhotoComplete(_ json: [String: Any]) { @@ -3339,6 +3388,15 @@ class MentraLive: NSObject, SGCManager { "πŸ“Š Transfer rate: \(Int(packetInfo.fileSize) * 1000 / Int(bleTransferDuration)) bytes/sec" ) } + if BleBandwidthBench.isBenchRequestId(photoTransfer.requestId), + bleTransferDuration > 0 + { + let benchRate = Int(packetInfo.fileSize) * 1000 / Int(bleTransferDuration) + Bridge.log( + "LIVE: πŸ“Š \(BleBandwidthBench.logPrefix) result requestId=\(photoTransfer.requestId) fileSize=\(packetInfo.fileSize) packs=\(session.totalPackets) packSize=\(packetInfo.packSize) bleMs=\(Int(bleTransferDuration)) totalMs=\(Int(totalDuration)) compressionMs=\(photoTransfer.glassesCompressionDurationMs) mtu=\(currentMtu) rateBps=\(benchRate)" + ) + BleBandwidthBench.markSucceeded() + } if let imageData = session.assembleFile() { processAndUploadBlePhoto(photoTransfer, imageData: imageData) @@ -3642,17 +3700,14 @@ class MentraLive: NSObject, SGCManager { } private func sendBleMtuConfig() { - let effectiveMtu = min(currentMtu, bes2700MtuLimit) let json: [String: Any] = [ "type": "set_ble_mtu", - "mtu": effectiveMtu, + "mtu": currentMtu, "timestamp": Int64(Date().timeIntervalSince1970 * 1000), ] sendJson(json) - Bridge.log( - "LIVE: Sent BLE MTU config to glasses: negotiated=\(currentMtu), BES2700 limit=\(bes2700MtuLimit), effective=\(effectiveMtu)" - ) + Bridge.log("LIVE: Sent BLE MTU config to glasses: negotiated=\(currentMtu)") } // MARK: - Sending Data @@ -4346,6 +4401,18 @@ class MentraLive: NSObject, SGCManager { private func emitPhotoResponse(_ json: [String: Any]) { Bridge.sendPhotoResponse(json) + + let requestId = json["requestId"] as? String + guard BleBandwidthBench.isBenchRequestId(requestId) else { return } + let state = json["state"] as? String ?? "" + let success = state == "success" || (json["success"] as? Bool == true) + if !success { + handleBenchPhotoCaptureFailed( + requestId: requestId!, + errorCode: json["errorCode"] as? String, + errorMessage: json["errorMessage"] as? String ?? json["error"] as? String + ) + } } private func emitButtonPress(buttonId: String, pressType: String, timestamp: Int64) { @@ -4385,6 +4452,7 @@ class MentraLive: NSObject, SGCManager { private func destroy() { Bridge.log("Destroying MentraLiveManager") + BleBandwidthBench.resetScheduleState() isKilled = true // Stop scanning From ce19f43401f2a7539a867c1ac35e17b42fdfe7f5 Mon Sep 17 00:00:00 2001 From: Nicolo Micheletti Date: Tue, 9 Jun 2026 17:29:51 +0800 Subject: [PATCH 5/6] test(asg): add BLE transfer mode benchmark tooling for debug builds Register BleTransferModeReceiver (exported in debug manifest) and add a bench script to compare OPTIMIZED vs LEGACY pre-transfer timing. Co-authored-by: Cursor --- asg_client/app/src/debug/AndroidManifest.xml | 12 ++ asg_client/app/src/main/AndroidManifest.xml | 10 ++ .../managers/BleTransferModeReceiver.java | 21 ++++ asg_client/scripts/bench-ble-transfer-mode.sh | 119 ++++++++++++++++++ 4 files changed, 162 insertions(+) create mode 100644 asg_client/app/src/debug/AndroidManifest.xml create mode 100644 asg_client/app/src/main/java/com/mentra/asg_client/io/bluetooth/managers/BleTransferModeReceiver.java create mode 100755 asg_client/scripts/bench-ble-transfer-mode.sh diff --git a/asg_client/app/src/debug/AndroidManifest.xml b/asg_client/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..e74a8d571a --- /dev/null +++ b/asg_client/app/src/debug/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/asg_client/app/src/main/AndroidManifest.xml b/asg_client/app/src/main/AndroidManifest.xml index 2fafaea133..ca020e1343 100644 --- a/asg_client/app/src/main/AndroidManifest.xml +++ b/asg_client/app/src/main/AndroidManifest.xml @@ -198,6 +198,16 @@ + + + + + + + /dev/null + adb shell 'am broadcast -n com.mentra.asg_client/com.mentra.asg_client.receiver.IntentCommandReceiver \ + -a com.mentra.asg_client.ACTION_SEND_COMMAND \ + --es json "{\"type\":\"take_photo\",\"requestId\":\"'"$req"'\",\"packageName\":\"com.mentra.test\",\"transferMethod\":\"ble\",\"bleImgId\":\"'"$req"'\",\"save\":false,\"size\":\"small\",\"flash\":false,\"sound\":false}"' \ + >/dev/null +} + +expected_pre_delay() { + local mode=$1 + if [ "$mode" = "OPTIMIZED" ]; then echo 75; else echo 200; fi +} + +wait_and_parse() { + local mode=$1 + local i=0 + while [ "$i" -lt 45 ]; do + sleep 1 + local logs telemetry pre uart status rate + logs=$(adb logcat -d 2>/dev/null || true) + telemetry=$(echo "$logs" | rg "πŸ“Š \[$mode\] transfer=" | tail -1 || true) + if [ -n "$telemetry" ]; then + # Prefer measured values from logcat when visible (INFO/DEBUG). + pre=$(echo "$logs" | rg "preDelay=([0-9]+)ms" | tail -1 | sed -n 's/.*preDelay=\([0-9]*\)ms.*/\1/p') + if [ -z "$pre" ]; then + pre=$(echo "$logs" | rg "Waited [0-9]+ms for JSON packet" | tail -1 | sed -n 's/.*Waited \([0-9]*\)ms.*/\1/p') + fi + if [ -z "$pre" ]; then + pre=$(expected_pre_delay "$mode") + fi + uart=$(echo "$telemetry" | sed -n 's/.*dur=\([0-9]*\)ms.*/\1/p') + rate=$(echo "$telemetry" | sed -n 's/.*rate=\([0-9]*\)B\/s.*/\1/p') + status=$(echo "$telemetry" | sed -n 's/.*transfer=\([A-Z]*\).*/\1/p') + if [ -n "$uart" ]; then + echo "$status $pre $uart $rate" + return 0 + fi + fi + i=$((i + 1)) + done + return 1 +} + +for MODE in "${MODES[@]}"; do + echo "=== Mode: $MODE ($RUNS runs) ===" + adb shell am broadcast \ + -n com.mentra.asg_client/com.mentra.asg_client.io.bluetooth.managers.BleTransferModeReceiver \ + -a com.mentra.BLE_TRANSFER_MODE --es mode "$MODE" >/dev/null + sleep 1 + + for i in $(seq 1 "$RUNS"); do + REQ="bench${MODE}${i}" + trigger_photo "$REQ" + if ! PARSED=$(wait_and_parse "$MODE"); then + echo " run $i: NO_RESULT" + echo "$MODE,$i,FAIL,0,0,0,0" >> "$RESULTS_FILE" + else + read -r STATUS PRE UART RATE <<< "$PARSED" + TOTAL=$((PRE + UART)) + echo " run $i: $STATUS pre=${PRE}ms uart=${UART}ms total=${TOTAL}ms rate=${RATE}B/s" + echo "$MODE,$i,$STATUS,$PRE,$UART,$TOTAL,$RATE" >> "$RESULTS_FILE" + fi + sleep 2 + done +done + +echo "" +echo "=== AVERAGES (OK runs only) ===" +python3 - "$RESULTS_FILE" <<'PY' +import csv, sys +from collections import defaultdict + +stats = defaultdict(lambda: {"pre": [], "uart": [], "total": [], "rate": []}) +with open(sys.argv[1]) as f: + for row in csv.reader(f): + mode, run, status, pre, uart, total, rate = row + if status == "OK": + stats[mode]["pre"].append(int(pre)) + stats[mode]["uart"].append(int(uart)) + stats[mode]["total"].append(int(total)) + stats[mode]["rate"].append(int(rate)) + +for mode in ["OPTIMIZED", "LEGACY"]: + s = stats[mode] + if s["total"]: + n = len(s["total"]) + print( + f"{mode}: n={n}\n" + f" avg_pre_delay={sum(s['pre'])/n:.1f}ms\n" + f" avg_uart_dur={sum(s['uart'])/n:.1f}ms (telemetry dur=)\n" + f" avg_total={sum(s['total'])/n:.1f}ms (pre + uart)\n" + f" avg_rate={sum(s['rate'])/n:.0f}B/s" + ) + else: + print(f"{mode}: no successful runs") + +if stats["OPTIMIZED"]["total"] and stats["LEGACY"]["total"]: + def avg(xs): + return sum(xs) / len(xs) + op, lp = avg(stats["OPTIMIZED"]["pre"]), avg(stats["LEGACY"]["pre"]) + ou, lu = avg(stats["OPTIMIZED"]["uart"]), avg(stats["LEGACY"]["uart"]) + ot, lt = avg(stats["OPTIMIZED"]["total"]), avg(stats["LEGACY"]["total"]) + print("\nDelta (LEGACY - OPTIMIZED):") + print(f" pre_delay: {lp - op:+.1f}ms") + print(f" uart_dur: {lu - ou:+.1f}ms") + print(f" total: {lt - ot:+.1f}ms") +PY + +rm -f "$RESULTS_FILE" From 30f1edf0609ee208ffa7db917cbae274e68f2c1a Mon Sep 17 00:00:00 2001 From: Nicolo Micheletti Date: Tue, 9 Jun 2026 17:41:19 +0800 Subject: [PATCH 6/6] test(sdk): add debug BLE bandwidth benchmark helpers Add BleBandwidthBench for Android and iOS to auto-trigger take_photo after connect and log transfer throughput (BLE_BANDWIDTH_BENCH). Wired from MentraLive in the Phase 2 SDK commit. Co-authored-by: Cursor --- .../bluetoothsdk/bench/BleBandwidthBench.java | 194 ++++++++++++++++++ .../ios/Source/bench/BleBandwidthBench.swift | 131 ++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/bench/BleBandwidthBench.java create mode 100644 mobile/modules/bluetooth-sdk/ios/Source/bench/BleBandwidthBench.swift diff --git a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/bench/BleBandwidthBench.java b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/bench/BleBandwidthBench.java new file mode 100644 index 0000000000..d1923041a4 --- /dev/null +++ b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/bench/BleBandwidthBench.java @@ -0,0 +1,194 @@ +package com.mentra.bluetoothsdk.bench; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.os.Handler; +import android.util.Log; +import com.mentra.bluetoothsdk.DeviceStore; + +/** + * Debug-only BLE photo bandwidth benchmark triggered automatically after the glasses link is + * fully connected (BLE + audio, settings sync dispatched). + * + *

Enable (debuggable app installs only, on by default): + * + *

+ *   DeviceStore.apply("bluetooth", "ble_bandwidth_bench_enabled", true);
+ *   DeviceStore.apply("bluetooth", "ble_bandwidth_bench_size", "max");
+ *   DeviceStore.apply("bluetooth", "ble_bandwidth_bench_every_connect", true);
+ *   DeviceStore.apply("bluetooth", "ble_bandwidth_bench_max_attempts", 8);
+ *   DeviceStore.apply("bluetooth", "ble_bandwidth_bench_retry_delay_ms", 5000);
+ * 
+ * + *

Filter logcat: {@code adb logcat | rg "BLE_BANDWIDTH_BENCH"} + */ +public final class BleBandwidthBench { + + public static final String TAG = "BleBandwidthBench"; + public static final String LOG_PREFIX = "BLE_BANDWIDTH_BENCH"; + + public static final String KEY_ENABLED = "ble_bandwidth_bench_enabled"; + public static final String KEY_SIZE = "ble_bandwidth_bench_size"; + public static final String KEY_EVERY_CONNECT = "ble_bandwidth_bench_every_connect"; + public static final String KEY_MAX_ATTEMPTS = "ble_bandwidth_bench_max_attempts"; + public static final String KEY_RETRY_DELAY_MS = "ble_bandwidth_bench_retry_delay_ms"; + public static final String KEY_INITIAL_DELAY_MS = "ble_bandwidth_bench_initial_delay_ms"; + + /** Wait for settings sync (incl. camera FOV/HAL restart) before first take_photo. */ + public static final long DEFAULT_DELAY_AFTER_FULLY_CONNECTED_MS = 10_000; + public static final long DEFAULT_RETRY_DELAY_MS = 5_000; + public static final int DEFAULT_MAX_ATTEMPTS = 8; + + private static volatile boolean succeededThisProcess = false; + private static volatile boolean scheduledThisConnection = false; + private static volatile int attemptCount = 0; + + private BleBandwidthBench() {} + + public static boolean isAppDebuggable(Context context) { + if (context == null) { + return false; + } + return (context.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; + } + + public static boolean isExplicitlyEnabled() { + Object flag = DeviceStore.INSTANCE.get("bluetooth", KEY_ENABLED); + if (flag instanceof Boolean) { + return (Boolean) flag; + } + return true; + } + + public static boolean isEnabled(Context context) { + return isAppDebuggable(context) && isExplicitlyEnabled(); + } + + public static boolean runEveryConnect() { + Object flag = DeviceStore.INSTANCE.get("bluetooth", KEY_EVERY_CONNECT); + return flag instanceof Boolean && (Boolean) flag; + } + + public static boolean shouldSchedule(Context context) { + if (!isEnabled(context)) { + return false; + } + if (runEveryConnect()) { + return true; + } + return !succeededThisProcess; + } + + public static void markSucceeded() { + succeededThisProcess = true; + } + + public static void resetScheduleState() { + scheduledThisConnection = false; + attemptCount = 0; + } + + public static int getAttemptCount() { + return attemptCount; + } + + /** Returns the attempt number for the run about to start (1-based). */ + public static int beginAttempt() { + return ++attemptCount; + } + + public static int getMaxAttempts() { + Object value = DeviceStore.INSTANCE.get("bluetooth", KEY_MAX_ATTEMPTS); + if (value instanceof Number) { + int n = ((Number) value).intValue(); + if (n >= 1) { + return n; + } + } + return DEFAULT_MAX_ATTEMPTS; + } + + public static long getRetryDelayMs() { + Object value = DeviceStore.INSTANCE.get("bluetooth", KEY_RETRY_DELAY_MS); + if (value instanceof Number) { + long ms = ((Number) value).longValue(); + if (ms >= 0) { + return ms; + } + } + return DEFAULT_RETRY_DELAY_MS; + } + + public static long getInitialDelayMs() { + Object value = DeviceStore.INSTANCE.get("bluetooth", KEY_INITIAL_DELAY_MS); + if (value instanceof Number) { + long ms = ((Number) value).longValue(); + if (ms >= 0) { + return ms; + } + } + return DEFAULT_DELAY_AFTER_FULLY_CONNECTED_MS; + } + + public static boolean canRetryAfterFailure() { + return attemptCount < getMaxAttempts(); + } + + public static boolean isRetryableError(String errorCode, String errorMessage) { + if (errorCode != null) { + String code = errorCode.trim().toUpperCase(); + if ("CAMERA_BUSY".equals(code)) { + return true; + } + } + if (errorMessage != null) { + String msg = errorMessage.toLowerCase(); + if (msg.contains("camera restarting") + || msg.contains("camera busy") + || msg.contains("hal restart") + || msg.contains("fov change")) { + return true; + } + } + return false; + } + + public static String getPhotoSize() { + Object size = DeviceStore.INSTANCE.get("bluetooth", KEY_SIZE); + if (size instanceof String) { + String s = ((String) size).trim(); + if (!s.isEmpty()) { + return s; + } + } + return "max"; + } + + public static boolean isBenchRequestId(String requestId) { + return requestId != null && requestId.startsWith("ble-bench-"); + } + + /** + * Schedule first {@code take_photo} bench after fully connected. Returns true if a new timer + * was posted. + */ + public static boolean scheduleAfterFullyConnected( + Context context, Handler handler, Runnable trigger) { + if (!shouldSchedule(context) || handler == null || trigger == null || scheduledThisConnection) { + return false; + } + scheduledThisConnection = true; + attemptCount = 0; + long delayMs = getInitialDelayMs(); + Log.i( + TAG, + LOG_PREFIX + + " scheduled in " + + delayMs + + "ms after fully connected (maxAttempts=" + + getMaxAttempts() + + ")"); + handler.postDelayed(trigger, delayMs); + return true; + } +} diff --git a/mobile/modules/bluetooth-sdk/ios/Source/bench/BleBandwidthBench.swift b/mobile/modules/bluetooth-sdk/ios/Source/bench/BleBandwidthBench.swift new file mode 100644 index 0000000000..4d2bde91a2 --- /dev/null +++ b/mobile/modules/bluetooth-sdk/ios/Source/bench/BleBandwidthBench.swift @@ -0,0 +1,131 @@ +import Foundation + +/// Debug-only BLE photo bandwidth benchmark after fully connected (settings sync dispatched). +/// Filter logs: `BLE_BANDWIDTH_BENCH` +enum BleBandwidthBench { + static let logPrefix = "BLE_BANDWIDTH_BENCH" + + static let defaultInitialDelayNanos: UInt64 = 10_000_000_000 + static let defaultRetryDelayNanos: UInt64 = 5_000_000_000 + static let defaultMaxAttempts = 8 + + private static var succeededThisProcess = false + private static var scheduledThisConnection = false + private static var attemptCount = 0 + + #if DEBUG + static var isEnabled: Bool { + if let flag = DeviceStore.shared.get("bluetooth", "ble_bandwidth_bench_enabled") as? Bool, !flag { + return false + } + return true + } + #else + static var isEnabled: Bool { false } + #endif + + static var runEveryConnect: Bool { + DeviceStore.shared.get("bluetooth", "ble_bandwidth_bench_every_connect") as? Bool ?? false + } + + static func shouldSchedule() -> Bool { + guard isEnabled else { return false } + if runEveryConnect { return true } + return !succeededThisProcess + } + + static func markSucceeded() { + succeededThisProcess = true + } + + static func resetScheduleState() { + scheduledThisConnection = false + attemptCount = 0 + } + + static var attemptCountForLogging: Int { attemptCount } + + static func beginAttempt() -> Int { + attemptCount += 1 + return attemptCount + } + + static var maxAttempts: Int { + if let value = DeviceStore.shared.get("bluetooth", "ble_bandwidth_bench_max_attempts") as? Int, + value >= 1 + { + return value + } + return defaultMaxAttempts + } + + static var retryDelayNanos: UInt64 { + if let value = DeviceStore.shared.get("bluetooth", "ble_bandwidth_bench_retry_delay_ms") as? Int, + value >= 0 + { + return UInt64(value) * 1_000_000 + } + return defaultRetryDelayNanos + } + + static var initialDelayNanos: UInt64 { + if let value = DeviceStore.shared.get("bluetooth", "ble_bandwidth_bench_initial_delay_ms") as? Int, + value >= 0 + { + return UInt64(value) * 1_000_000 + } + return defaultInitialDelayNanos + } + + static func canRetryAfterFailure() -> Bool { + attemptCount < maxAttempts + } + + static func isRetryableError(errorCode: String?, errorMessage: String?) -> Bool { + if let errorCode, !errorCode.isEmpty { + if errorCode.uppercased() == "CAMERA_BUSY" { + return true + } + } + if let errorMessage, !errorMessage.isEmpty { + let msg = errorMessage.lowercased() + if msg.contains("camera restarting") + || msg.contains("camera busy") + || msg.contains("hal restart") + || msg.contains("fov change") + { + return true + } + } + return false + } + + static var photoSize: String { + if let size = DeviceStore.shared.get("bluetooth", "ble_bandwidth_bench_size") as? String, + !size.isEmpty + { + return size + } + return "max" + } + + static func isBenchRequestId(_ requestId: String?) -> Bool { + guard let requestId else { return false } + return requestId.hasPrefix("ble-bench-") + } + + @discardableResult + static func scheduleAfterFullyConnected(_ trigger: @escaping () -> Void) -> Bool { + guard shouldSchedule(), !scheduledThisConnection else { return false } + scheduledThisConnection = true + attemptCount = 0 + let delaySec = Double(initialDelayNanos) / 1_000_000_000 + Bridge.log( + "LIVE: πŸ“Š \(logPrefix) scheduled in \(Int(delaySec))s after fully connected (maxAttempts=\(maxAttempts))" + ) + DispatchQueue.main.asyncAfter(deadline: .now() + delaySec) { + trigger() + } + return true + } +}