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");
diff --git a/asg_client/scripts/bench-ble-transfer-mode.sh b/asg_client/scripts/bench-ble-transfer-mode.sh
new file mode 100755
index 0000000000..4937749260
--- /dev/null
+++ b/asg_client/scripts/bench-ble-transfer-mode.sh
@@ -0,0 +1,119 @@
+#!/bin/bash
+# Benchmark OPTIMIZED vs LEGACY BleTransferMode pre-delay (debug builds).
+# Reports UART dur (sendFileβcomplete) and end-to-end (pre-delay + UART dur).
+set -euo pipefail
+
+RUNS="${1:-5}"
+MODES=("OPTIMIZED" "LEGACY")
+RESULTS_FILE=$(mktemp)
+
+trigger_photo() {
+ local req=$1
+ adb logcat -c >/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"
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/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/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
+ }
+}
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