From 4010ba8870b912b003de5489675fbb7b74f0ca1d Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sat, 10 Jan 2026 14:23:39 +0000 Subject: [PATCH] add wifi request ble characteristic --- .../iris/Bluetooth/BlePeripheralManager.swift | 82 ++++++++++- IrisCompanion/iris/Views/BleStatusView.swift | 12 +- IrisGlass/app/src/main/AndroidManifest.xml | 1 + .../sh/nym/irisglass/BleCentralClient.java | 133 ++++++++++++------ .../java/sh/nym/irisglass/BleLinkService.java | 14 ++ .../main/java/sh/nym/irisglass/Constants.java | 2 + 6 files changed, 197 insertions(+), 47 deletions(-) diff --git a/IrisCompanion/iris/Bluetooth/BlePeripheralManager.swift b/IrisCompanion/iris/Bluetooth/BlePeripheralManager.swift index 3692605..ecd7cd9 100644 --- a/IrisCompanion/iris/Bluetooth/BlePeripheralManager.swift +++ b/IrisCompanion/iris/Bluetooth/BlePeripheralManager.swift @@ -16,6 +16,8 @@ final class BlePeripheralManager: NSObject, ObservableObject { static let serviceUUID = CBUUID(string: "A0B0C0D0-E0F0-4A0B-9C0D-0E0F1A2B3C4D") static let feedTxUUID = CBUUID(string: "A0B0C0D1-E0F0-4A0B-9C0D-0E0F1A2B3C4D") static let controlRxUUID = CBUUID(string: "A0B0C0D2-E0F0-4A0B-9C0D-0E0F1A2B3C4D") + // Read/Notify: 1-byte value (0x00=OFF, 0x01=ON) that requests Glass to enable Wi‑Fi. + static let wifiRequestTxUUID = CBUUID(string: "A0B0C0D3-E0F0-4A0B-9C0D-0E0F1A2B3C4D") private static let restoreIdentifier = "iris.ble.peripheral.v1" @Published private(set) var bluetoothState: CBManagerState = .unknown @@ -30,6 +32,7 @@ final class BlePeripheralManager: NSObject, ObservableObject { @Published private(set) var droppedNotifyPackets: Int = 0 @Published private(set) var lastNotifyAt: Date? = nil @Published private(set) var lastDataAt: Date? = nil + @Published private(set) var wifiRequested: Bool = false private let queue = DispatchQueue(label: "iris.ble.peripheral.queue") private lazy var peripheral = CBPeripheralManager( @@ -43,10 +46,13 @@ final class BlePeripheralManager: NSObject, ObservableObject { private var service: CBMutableService? private var feedTx: CBMutableCharacteristic? private var controlRx: CBMutableCharacteristic? + private var wifiRequestTx: CBMutableCharacteristic? private var subscribedCentralIds = Set() private var centralMaxUpdateLength: [UUID: Int] = [:] private var lastReadValue: Data = Data() + private var wifiRequestValue: Data = Data([0x00]) + private var wifiRequestNotifyPending: Bool = false private struct PendingMessage { let msgId: UInt32 @@ -101,11 +107,23 @@ final class BlePeripheralManager: NSObject, ObservableObject { } } + func setWifiRequested(_ requested: Bool) { + queue.async { [weak self] in + guard let self else { return } + let newValue = Data([requested ? 0x01 : 0x00]) + guard newValue != self.wifiRequestValue else { return } + self.wifiRequestValue = newValue + self.publish { self.wifiRequested = requested } + self.flushWifiRequestNotifyIfPossible() + } + } + func copyUUIDsToPasteboard() { let text = """ SERVICE_UUID=\(Self.serviceUUID.uuidString) FEED_TX_UUID=\(Self.feedTxUUID.uuidString) CONTROL_RX_UUID=\(Self.controlRxUUID.uuidString) + WIFI_REQUEST_TX_UUID=\(Self.wifiRequestTxUUID.uuidString) """ #if canImport(UIKit) DispatchQueue.main.async { @@ -116,6 +134,18 @@ final class BlePeripheralManager: NSObject, ObservableObject { private func ensureService() { guard peripheral.state == .poweredOn else { return } + + if let existingService = service { + let hasWifiChar = (existingService.characteristics ?? []).contains { $0.uuid == Self.wifiRequestTxUUID } + if wifiRequestTx == nil || !hasWifiChar { + stopAdvertising() + peripheral.removeAllServices() + service = nil + feedTx = nil + controlRx = nil + wifiRequestTx = nil + } + } guard service == nil else { return } let feedTx = CBMutableCharacteristic( @@ -130,11 +160,18 @@ final class BlePeripheralManager: NSObject, ObservableObject { value: nil, permissions: [.writeable] ) + let wifiRequestTx = CBMutableCharacteristic( + type: Self.wifiRequestTxUUID, + properties: [.notify, .read], + value: nil, + permissions: [.readable] + ) let service = CBMutableService(type: Self.serviceUUID, primary: true) - service.characteristics = [feedTx, controlRx] + service.characteristics = [feedTx, controlRx, wifiRequestTx] self.service = service self.feedTx = feedTx self.controlRx = controlRx + self.wifiRequestTx = wifiRequestTx peripheral.add(service) } @@ -278,6 +315,20 @@ final class BlePeripheralManager: NSObject, ObservableObject { publishNotifyQueueDepth() } + private func flushWifiRequestNotifyIfPossible() { + guard let wifiRequestTx else { return } + let hasSubscribers = !(wifiRequestTx.subscribedCentrals ?? []).isEmpty + guard hasSubscribers else { + wifiRequestNotifyPending = false + return + } + if peripheral.updateValue(wifiRequestValue, for: wifiRequestTx, onSubscribedCentrals: nil) { + wifiRequestNotifyPending = false + } else { + wifiRequestNotifyPending = true + } + } + private func enqueuePendingMessage(_ message: PendingMessage) { pendingMessages.append(message) enforceNotifyQueueLimits() @@ -334,6 +385,7 @@ extension BlePeripheralManager: CBPeripheralManagerDelegate { self.service = restoredMutableService self.feedTx = characteristics.first(where: { $0.uuid == Self.feedTxUUID }) as? CBMutableCharacteristic self.controlRx = characteristics.first(where: { $0.uuid == Self.controlRxUUID }) as? CBMutableCharacteristic + self.wifiRequestTx = characteristics.first(where: { $0.uuid == Self.wifiRequestTxUUID }) as? CBMutableCharacteristic } self.publish { @@ -362,6 +414,7 @@ extension BlePeripheralManager: CBPeripheralManagerDelegate { self.service = nil self.feedTx = nil self.controlRx = nil + self.wifiRequestTx = nil return } self.ensureService() @@ -380,6 +433,12 @@ extension BlePeripheralManager: CBPeripheralManagerDelegate { } func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { + if characteristic.uuid == Self.wifiRequestTxUUID { + queue.async { [weak self] in + self?.flushWifiRequestNotifyIfPossible() + } + return + } guard characteristic.uuid == Self.feedTxUUID else { return } queue.async { [weak self] in guard let self = self else { return } @@ -400,6 +459,9 @@ extension BlePeripheralManager: CBPeripheralManagerDelegate { } func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { + if characteristic.uuid == Self.wifiRequestTxUUID { + return + } guard characteristic.uuid == Self.feedTxUUID else { return } queue.async { [weak self] in guard let self = self else { return } @@ -420,17 +482,25 @@ extension BlePeripheralManager: CBPeripheralManagerDelegate { func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { queue.async { [weak self] in self?.flushPendingMessages() + if let self, self.wifiRequestNotifyPending { + self.flushWifiRequestNotifyIfPossible() + } } } func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) { - guard request.characteristic.uuid == Self.feedTxUUID else { - peripheral.respond(to: request, withResult: .requestNotSupported) + let maxLen = max(0, request.central.maximumUpdateValueLength) + if request.characteristic.uuid == Self.feedTxUUID { + request.value = maxLen > 0 ? lastReadValue.prefix(maxLen) : lastReadValue + peripheral.respond(to: request, withResult: .success) return } - let maxLen = max(0, request.central.maximumUpdateValueLength) - request.value = maxLen > 0 ? lastReadValue.prefix(maxLen) : lastReadValue - peripheral.respond(to: request, withResult: .success) + if request.characteristic.uuid == Self.wifiRequestTxUUID { + request.value = maxLen > 0 ? wifiRequestValue.prefix(maxLen) : wifiRequestValue + peripheral.respond(to: request, withResult: .success) + return + } + peripheral.respond(to: request, withResult: .requestNotSupported) } func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { diff --git a/IrisCompanion/iris/Views/BleStatusView.swift b/IrisCompanion/iris/Views/BleStatusView.swift index 18f75fc..e622a10 100644 --- a/IrisCompanion/iris/Views/BleStatusView.swift +++ b/IrisCompanion/iris/Views/BleStatusView.swift @@ -34,6 +34,16 @@ struct BleStatusView: View { ble.start() } + Toggle(isOn: Binding(get: { ble.wifiRequested }, set: { ble.setWifiRequested($0) })) { + VStack(alignment: .leading, spacing: 2) { + Text("Request Wi-Fi (Glass)") + .font(.headline) + Text(ble.wifiRequested ? "On" : "Off") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + VStack(alignment: .leading, spacing: 8) { Text("Connection") .font(.headline) @@ -86,7 +96,7 @@ struct BleStatusView: View { VStack(alignment: .leading, spacing: 8) { Text("UUIDs") .font(.headline) - Text("Service: \(BlePeripheralManager.serviceUUID.uuidString)\nFEED_TX: \(BlePeripheralManager.feedTxUUID.uuidString)\nCONTROL_RX: \(BlePeripheralManager.controlRxUUID.uuidString)") + Text("Service: \(BlePeripheralManager.serviceUUID.uuidString)\nFEED_TX: \(BlePeripheralManager.feedTxUUID.uuidString)\nCONTROL_RX: \(BlePeripheralManager.controlRxUUID.uuidString)\nWIFI_REQUEST_TX: \(BlePeripheralManager.wifiRequestTxUUID.uuidString)") .font(.caption) .textSelection(.enabled) } diff --git a/IrisGlass/app/src/main/AndroidManifest.xml b/IrisGlass/app/src/main/AndroidManifest.xml index 3315714..8901353 100644 --- a/IrisGlass/app/src/main/AndroidManifest.xml +++ b/IrisGlass/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + 1000L) { - lastUnparsedLogAtMs = lastNotificationAtMs; - String hint = ""; - if (value != null && value.length >= 9) { - long msgId = ((long) (value[0] & 0xFF)) - | ((long) (value[1] & 0xFF) << 8) - | ((long) (value[2] & 0xFF) << 16) - | ((long) (value[3] & 0xFF) << 24); - int type = value[4] & 0xFF; - int idx = (value[5] & 0xFF) | ((value[6] & 0xFF) << 8); - int cnt = (value[7] & 0xFF) | ((value[8] & 0xFF) << 8); - hint = " msgId=" + (msgId & 0xFFFFFFFFL) + " type=" + type + " chunk=" + idx + "/" + cnt; + if (pendingWifiCccd != null) { + boolean wifiWriteOk = gatt.writeDescriptor(pendingWifiCccd); + Log.i(Constants.TAG_BLE, "writeDescriptor(WIFI CCCD)=" + wifiWriteOk); + if (!wifiWriteOk) { + Log.w(Constants.TAG_BLE, "WIFI_REQUEST_TX CCCD write failed to start"); + pendingWifiCccd = null; } - Log.d(Constants.TAG_FEED, "Unparsed notify len=" + (value != null ? value.length : 0) + hint + " hex=" + hexPrefix(value, 16)); } return; } - if (result.isPing) { - Log.d(Constants.TAG_FEED, "PING"); - lastPingAtMs = lastNotificationAtMs; - if (callback != null) callback.onPing(); - return; + if (Constants.WIFI_REQUEST_TX_UUID.equals(chUuid)) { + Log.i(Constants.TAG_BLE, "Subscribed to WIFI_REQUEST_TX"); + pendingWifiCccd = null; } - if (result.jsonOrNull != null) { - Log.i(Constants.TAG_FEED, "Reassembled JSON (" + result.jsonOrNull.length() + " bytes)"); - Log.i(Constants.TAG_FEED, "RAW_JSON_BEGIN"); - logLarge(Constants.TAG_FEED, result.jsonOrNull); - Log.i(Constants.TAG_FEED, "RAW_JSON_END"); - if (callback != null) callback.onFeedJson(result.jsonOrNull); + } + + private void handleCharacteristicChanged(UUID uuid, byte[] value) { + if (Constants.WIFI_REQUEST_TX_UUID.equals(uuid)) { + boolean requested = value != null && value.length > 0 && (value[0] & 0xFF) != 0; + Log.i(Constants.TAG_BLE, "WIFI_REQUEST_TX notify=" + requested + " raw=" + hexPrefix(value, 1)); + if (callback != null) callback.onWifiRequest(requested); + } else if (Constants.FEED_TX_UUID.equals(uuid)) { + lastNotificationAtMs = System.currentTimeMillis(); + + FeedReassembler.Result result = reassembler.onNotification(value, lastNotificationAtMs); + if (result == null) { + // Diagnostic: log occasionally so we can infer the framing coming from iPhone. + if (lastNotificationAtMs - lastUnparsedLogAtMs > 1000L) { + lastUnparsedLogAtMs = lastNotificationAtMs; + String hint = ""; + if (value != null && value.length >= 9) { + long msgId = ((long) (value[0] & 0xFF)) + | ((long) (value[1] & 0xFF) << 8) + | ((long) (value[2] & 0xFF) << 16) + | ((long) (value[3] & 0xFF) << 24); + int type = value[4] & 0xFF; + int idx = (value[5] & 0xFF) | ((value[6] & 0xFF) << 8); + int cnt = (value[7] & 0xFF) | ((value[8] & 0xFF) << 8); + hint = " msgId=" + (msgId & 0xFFFFFFFFL) + " type=" + type + " chunk=" + idx + "/" + cnt; + } + Log.d(Constants.TAG_FEED, "Unparsed notify len=" + (value != null ? value.length : 0) + hint + " hex=" + hexPrefix(value, 16)); + } + } else if (result.isPing) { + Log.d(Constants.TAG_FEED, "PING"); + lastPingAtMs = lastNotificationAtMs; + if (callback != null) callback.onPing(); + } else if (result.jsonOrNull != null) { + Log.i(Constants.TAG_FEED, "Reassembled JSON (" + result.jsonOrNull.length() + " bytes)"); + Log.i(Constants.TAG_FEED, "RAW_JSON_BEGIN"); + logLarge(Constants.TAG_FEED, result.jsonOrNull); + Log.i(Constants.TAG_FEED, "RAW_JSON_END"); + if (callback != null) callback.onFeedJson(result.jsonOrNull); + } } } private void closeGattInternal() { subscribed = false; + pendingWifiCccd = null; stopLivenessCheck(); handler.removeCallbacks(connectTimeoutRunnable); if (gatt != null) { diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/BleLinkService.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/BleLinkService.java index 6767f48..69ebec0 100644 --- a/IrisGlass/app/src/main/java/sh/nym/irisglass/BleLinkService.java +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/BleLinkService.java @@ -1,8 +1,10 @@ package sh.nym.irisglass; import android.app.Service; +import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; +import android.net.wifi.WifiManager; import android.os.HandlerThread; import android.os.IBinder; import android.preference.PreferenceManager; @@ -92,6 +94,11 @@ public final class BleLinkService extends Service implements BleCentralClient.Ca } } + @Override + public void onWifiRequest(boolean requested) { + setWifiEnabled(requested); + } + private void maybeNudgeWinnerChanged(FeedEnvelope env) { if (env == null) return; @@ -122,4 +129,11 @@ public final class BleLinkService extends Service implements BleCentralClient.Ca i.putExtra(Constants.EXTRA_WINNER_ID, winnerId); startService(i); } + + private void setWifiEnabled(boolean enabled) { + WifiManager wifiManager = (WifiManager) this.getSystemService(Context.WIFI_SERVICE); + if (wifiManager != null) { + wifiManager.setWifiEnabled(enabled); + } + } } diff --git a/IrisGlass/app/src/main/java/sh/nym/irisglass/Constants.java b/IrisGlass/app/src/main/java/sh/nym/irisglass/Constants.java index 5e793de..58c9ca2 100644 --- a/IrisGlass/app/src/main/java/sh/nym/irisglass/Constants.java +++ b/IrisGlass/app/src/main/java/sh/nym/irisglass/Constants.java @@ -12,10 +12,12 @@ public final class Constants { public static final String SERVICE_UUID_STR = "A0B0C0D0-E0F0-4A0B-9C0D-0E0F1A2B3C4D"; public static final String FEED_TX_UUID_STR = "A0B0C0D1-E0F0-4A0B-9C0D-0E0F1A2B3C4D"; public static final String CONTROL_RX_UUID_STR = "A0B0C0D2-E0F0-4A0B-9C0D-0E0F1A2B3C4D"; + public static final String WIFI_REQUEST_TX_UUID_STR = "A0B0C0D3-E0F0-4A0B-9C0D-0E0F1A2B3C4D"; public static final UUID SERVICE_UUID = UUID.fromString(SERVICE_UUID_STR); public static final UUID FEED_TX_UUID = UUID.fromString(FEED_TX_UUID_STR); public static final UUID CONTROL_RX_UUID = UUID.fromString(CONTROL_RX_UUID_STR); + public static final UUID WIFI_REQUEST_TX_UUID = UUID.fromString(WIFI_REQUEST_TX_UUID_STR); public static final String PERIPHERAL_NAME_HINT = "Aris"; public static final String LEGACY_PERIPHERAL_NAME_HINT = "GlassNow";