diff --git a/cue-ios/CueIOS/AppState.swift b/cue-ios/CueIOS/AppState.swift index ca48bee..d0ba5da 100644 --- a/cue-ios/CueIOS/AppState.swift +++ b/cue-ios/CueIOS/AppState.swift @@ -9,6 +9,7 @@ enum Tab: String, CaseIterable, Identifiable { case chlorine = "Chlorine" case ph = "pH" case sessions = "Sessions" + case connection = "Connection" var id: String { rawValue } } @@ -360,7 +361,7 @@ final class AppState { case .amp: ampRef = nil; status = "Amp reference cleared" case .chlorine: clRef = nil; status = "Chlorine reference cleared" case .ph: phRef = nil; status = "pH reference cleared" - case .sessions: break + case .sessions, .connection: break } } @@ -401,7 +402,7 @@ final class AppState { case .amp: ampRef != nil case .chlorine: clRef != nil case .ph: phRef != nil - case .sessions: false + case .sessions, .connection: false } } @@ -412,7 +413,7 @@ final class AppState { case .amp: !ampPoints.isEmpty case .chlorine: clResult != nil case .ph: phResult != nil - case .sessions: false + case .sessions, .connection: false } } diff --git a/cue-ios/CueIOS/Assets.xcassets/AppIcon.appiconset/appicon-1024.png b/cue-ios/CueIOS/Assets.xcassets/AppIcon.appiconset/appicon-1024.png new file mode 100644 index 0000000..8c130b0 Binary files /dev/null and b/cue-ios/CueIOS/Assets.xcassets/AppIcon.appiconset/appicon-1024.png differ diff --git a/cue-ios/CueIOS/BLE/BLEManager.swift b/cue-ios/CueIOS/BLE/BLEManager.swift index abe9663..5a90611 100644 --- a/cue-ios/CueIOS/BLE/BLEManager.swift +++ b/cue-ios/CueIOS/BLE/BLEManager.swift @@ -18,11 +18,20 @@ final class BLEManager: NSObject { case connected = "Connected" } + struct DiscoveredDevice: Identifiable { + let id: UUID + let peripheral: CBPeripheral + let name: String + let rssi: Int + var serviceUUIDs: [CBUUID] + } + var state: ConnectionState = .disconnected var lastMessage: EisMessage? + var discoveredDevices: [DiscoveredDevice] = [] private var centralManager: CBCentralManager! - private var peripheral: CBPeripheral? + private(set) var peripheral: CBPeripheral? private var midiCharacteristic: CBCharacteristic? private var onMessage: ((EisMessage) -> Void)? @@ -36,14 +45,32 @@ final class BLEManager: NSObject { } func startScanning() { - guard centralManager.state == .poweredOn else { return } + guard centralManager.state == .poweredOn else { + print("[BLE] can't scan, state: \(centralManager.state.rawValue)") + return + } + print("[BLE] starting scan (no filter)") state = .scanning + discoveredDevices.removeAll() centralManager.scanForPeripherals( - withServices: [Self.midiServiceUUID], - options: nil + withServices: nil, + options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] ) } + func stopScanning() { + centralManager.stopScan() + if state == .scanning { state = .disconnected } + } + + func connectTo(_ device: DiscoveredDevice) { + centralManager.stopScan() + peripheral = device.peripheral + state = .connecting + print("[BLE] connecting to \(device.name)") + centralManager.connect(device.peripheral, options: nil) + } + func disconnect() { if let p = peripheral { centralManager.cancelPeripheralConnection(p) @@ -105,6 +132,7 @@ final class BLEManager: NSObject { extension BLEManager: CBCentralManagerDelegate { func centralManagerDidUpdateState(_ central: CBCentralManager) { + print("[BLE] centralManager state: \(central.state.rawValue)") if central.state == .poweredOn { startScanning() } @@ -116,11 +144,21 @@ extension BLEManager: CBCentralManagerDelegate { advertisementData: [String: Any], rssi RSSI: NSNumber ) { - guard peripheral.name == "EIS4" else { return } - central.stopScan() - self.peripheral = peripheral - state = .connecting - central.connect(peripheral, options: nil) + let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "Unknown" + let svcUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? [] + + print("[BLE] found: \(name) rssi:\(RSSI) services:\(svcUUIDs)") + + if discoveredDevices.contains(where: { $0.id == peripheral.identifier }) { return } + + let device = DiscoveredDevice( + id: peripheral.identifier, + peripheral: peripheral, + name: name, + rssi: RSSI.intValue, + serviceUUIDs: svcUUIDs + ) + discoveredDevices.append(device) } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { diff --git a/cue-ios/CueIOS/Views/ConnectionView.swift b/cue-ios/CueIOS/Views/ConnectionView.swift new file mode 100644 index 0000000..d71abc1 --- /dev/null +++ b/cue-ios/CueIOS/Views/ConnectionView.swift @@ -0,0 +1,87 @@ +import SwiftUI + +struct ConnectionView: View { + var state: AppState + + var body: some View { + VStack(spacing: 0) { + header + Divider() + deviceList + } + .navigationTitle("Connection") + } + + private var header: some View { + HStack { + Circle() + .fill(statusColor) + .frame(width: 10, height: 10) + Text(state.ble.state.rawValue) + .font(.headline) + Spacer() + if state.ble.state == .connected { + Button("Disconnect") { state.ble.disconnect() } + .buttonStyle(.bordered) + .tint(.red) + } else if state.ble.state == .scanning { + Button("Stop") { state.ble.stopScanning() } + .buttonStyle(.bordered) + } else { + Button("Scan") { state.ble.startScanning() } + .buttonStyle(.borderedProminent) + } + } + .padding() + } + + private var statusColor: Color { + switch state.ble.state { + case .connected: .green + case .scanning: .orange + case .connecting: .yellow + case .disconnected: .red + } + } + + private var deviceList: some View { + List { + if state.ble.discoveredDevices.isEmpty && state.ble.state == .scanning { + HStack { + ProgressView() + Text("Scanning for devices...") + .foregroundStyle(.secondary) + } + } + + ForEach(state.ble.discoveredDevices) { device in + Button { + state.ble.connectTo(device) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(device.name) + .font(.body.weight(.medium)) + if !device.serviceUUIDs.isEmpty { + Text(device.serviceUUIDs.map(\.uuidString).joined(separator: ", ")) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer() + Text("\(device.rssi) dBm") + .font(.caption) + .foregroundStyle(.secondary) + if device.name == "EIS4" { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + .font(.caption) + } + } + } + .disabled(state.ble.state == .connecting) + } + } + } +} diff --git a/cue-ios/CueIOS/Views/ContentView.swift b/cue-ios/CueIOS/Views/ContentView.swift index 28e4c59..9ca81a4 100644 --- a/cue-ios/CueIOS/Views/ContentView.swift +++ b/cue-ios/CueIOS/Views/ContentView.swift @@ -40,6 +40,7 @@ struct ContentView: View { } Section("Data") { sidebarButton(.sessions, "Sessions", "folder") + sidebarButton(.connection, "Connection", "antenna.radiowaves.left.and.right") } Section { cleanControls @@ -126,6 +127,10 @@ struct ContentView: View { SessionView(state: state) .tabItem { Label("Sessions", systemImage: "folder") } .tag(Tab.sessions) + + ConnectionView(state: state) + .tabItem { Label("Connection", systemImage: "antenna.radiowaves.left.and.right") } + .tag(Tab.connection) } } @@ -140,6 +145,7 @@ struct ContentView: View { case .chlorine: ChlorineView(state: state) case .ph: PhView(state: state) case .sessions: SessionView(state: state) + case .connection: ConnectionView(state: state) } } } diff --git a/cue/assets/cue.icns b/cue/assets/cue.icns index 9a084d7..dd84582 100644 Binary files a/cue/assets/cue.icns and b/cue/assets/cue.icns differ diff --git a/main/ble.c b/main/ble.c index b2b2811..8da6d30 100644 --- a/main/ble.c +++ b/main/ble.c @@ -30,7 +30,7 @@ static const ble_uuid128_t midi_chr_uuid = BLE_UUID128_INIT( 0xf3, 0x6b, 0x10, 0x9d, 0x66, 0xf2, 0xa9, 0xa1, 0x12, 0x41, 0x68, 0x38, 0xdb, 0xe5, 0x72, 0x77); -#define MAX_CONNECTIONS 2 +#define MAX_CONNECTIONS 4 static EventGroupHandle_t ble_events; static QueueHandle_t cmd_queue; @@ -523,8 +523,13 @@ static int gap_event_cb(struct ble_gap_event *event, void *arg) return 0; } -static void start_adv(void) +static void adv_task(void *param) { + (void)param; + vTaskDelay(pdMS_TO_TICKS(200)); + + ble_gap_adv_stop(); + struct ble_hs_adv_fields fields = {0}; fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP; fields.name = (uint8_t *)DEVICE_NAME; @@ -539,23 +544,38 @@ static void start_adv(void) fields.uuids16 = adv_uuids; fields.num_uuids16 = 2; fields.uuids16_is_complete = 0; - ble_gap_adv_set_fields(&fields); + + int rc = ble_gap_adv_set_fields(&fields); + if (rc) { printf("BLE: set_fields failed: %d\n", rc); goto done; } struct ble_hs_adv_fields rsp = {0}; rsp.uuids128 = (ble_uuid128_t *)&midi_svc_uuid; rsp.num_uuids128 = 1; rsp.uuids128_is_complete = 1; - ble_gap_adv_rsp_set_fields(&rsp); + + rc = ble_gap_adv_rsp_set_fields(&rsp); + if (rc) { printf("BLE: set_rsp failed: %d\n", rc); goto done; } struct ble_gap_adv_params params = {0}; params.conn_mode = BLE_GAP_CONN_MODE_UND; params.disc_mode = BLE_GAP_DISC_MODE_GEN; - int rc = ble_gap_adv_start(BLE_OWN_ADDR_PUBLIC, NULL, BLE_HS_FOREVER, - ¶ms, gap_event_cb, NULL); + params.itvl_min = BLE_GAP_ADV_FAST_INTERVAL1_MIN; + params.itvl_max = BLE_GAP_ADV_FAST_INTERVAL1_MAX; + + rc = ble_gap_adv_start(BLE_OWN_ADDR_PUBLIC, NULL, BLE_HS_FOREVER, + ¶ms, gap_event_cb, NULL); if (rc) printf("BLE: adv_start failed: %d\n", rc); else - printf("BLE: advertising\n"); + printf("BLE: advertising (%d connected)\n", conn_count); + +done: + vTaskDelete(NULL); +} + +static void start_adv(void) +{ + xTaskCreate(adv_task, "adv", 2048, NULL, 5, NULL); } static void on_sync(void) diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 686f1a4..7ae2ec9 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -1,7 +1,7 @@ CONFIG_IDF_TARGET="esp32s3" CONFIG_BT_ENABLED=y CONFIG_BT_NIMBLE_ENABLED=y -CONFIG_BT_NIMBLE_MAX_CONNECTIONS=2 +CONFIG_BT_NIMBLE_MAX_CONNECTIONS=4 CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=y CONFIG_BT_NIMBLE_ROLE_CENTRAL=y CONFIG_BT_NIMBLE_ROLE_OBSERVER=n