/// CoreBluetooth BLE MIDI connection manager. /// Scans for "EIS4", connects, subscribes to MIDI notifications, /// parses incoming BLE MIDI packets into EisMessage values. import CoreBluetooth import Foundation @Observable final class BLEManager: NSObject { static let midiServiceUUID = CBUUID(string: "03B80E5A-EDE8-4B33-A751-6CE34EC4C700") static let midiCharUUID = CBUUID(string: "7772E5DB-3868-4112-A1A9-F2669D106BF3") enum ConnectionState: String { case disconnected = "Disconnected" case scanning = "Scanning..." case connecting = "Connecting..." case connected = "Connected" } var state: ConnectionState = .disconnected var lastMessage: EisMessage? private var centralManager: CBCentralManager! private var peripheral: CBPeripheral? private var midiCharacteristic: CBCharacteristic? private var onMessage: ((EisMessage) -> Void)? override init() { super.init() centralManager = CBCentralManager(delegate: self, queue: nil) } func setMessageHandler(_ handler: @escaping (EisMessage) -> Void) { onMessage = handler } func startScanning() { guard centralManager.state == .poweredOn else { return } state = .scanning centralManager.scanForPeripherals( withServices: [Self.midiServiceUUID], options: nil ) } func disconnect() { if let p = peripheral { centralManager.cancelPeripheralConnection(p) } peripheral = nil midiCharacteristic = nil state = .disconnected } func sendCommand(_ sysex: [UInt8]) { guard let char = midiCharacteristic, let p = peripheral else { return } let packet = Self.wrapBLEMIDI(sysex) p.writeValue(packet, for: char, type: .withoutResponse) } // MARK: - BLE MIDI packet handling /// Wrap raw SysEx into a BLE MIDI packet. /// Input: [F0, 7D, CMD, ...payload, F7] /// Output: [0x80, 0x80, F0, 7D, CMD, ...payload, 0x80, F7] static func wrapBLEMIDI(_ sysex: [UInt8]) -> Data { var pkt: [UInt8] = [0x80, 0x80] pkt.append(contentsOf: sysex.dropLast()) pkt.append(0x80) pkt.append(0xF7) return Data(pkt) } /// Extract SysEx payload from BLE MIDI notification. /// Returns bytes between F0 and F7 exclusive, stripping timestamps. /// Result: [7D, RSP_ID, ...data] static func extractSysEx(from data: Data) -> [UInt8]? { let bytes = Array(data) guard bytes.count >= 5 else { return nil } var i = 1 // skip header while i < bytes.count { let b = bytes[i] if b == 0xF0 { break } if b & 0x80 != 0 && b != 0xF7 { i += 1; continue } i += 1 } guard i < bytes.count, bytes[i] == 0xF0 else { return nil } i += 1 var payload: [UInt8] = [] while i < bytes.count && bytes[i] != 0xF7 { if bytes[i] & 0x80 != 0 { i += 1; continue } payload.append(bytes[i]) i += 1 } guard !payload.isEmpty else { return nil } return payload } } // MARK: - CBCentralManagerDelegate extension BLEManager: CBCentralManagerDelegate { func centralManagerDidUpdateState(_ central: CBCentralManager) { if central.state == .poweredOn { startScanning() } } func centralManager( _ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber ) { guard peripheral.name == "EIS4" else { return } central.stopScan() self.peripheral = peripheral state = .connecting central.connect(peripheral, options: nil) } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { peripheral.delegate = self peripheral.discoverServices([Self.midiServiceUUID]) } func centralManager( _ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error? ) { self.peripheral = nil self.midiCharacteristic = nil state = .disconnected startScanning() } } // MARK: - CBPeripheralDelegate extension BLEManager: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { guard let service = peripheral.services?.first(where: { $0.uuid == Self.midiServiceUUID }) else { return } peripheral.discoverCharacteristics([Self.midiCharUUID], for: service) } func peripheral( _ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error? ) { guard let char = service.characteristics?.first(where: { $0.uuid == Self.midiCharUUID }) else { return } midiCharacteristic = char peripheral.setNotifyValue(true, for: char) } func peripheral( _ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error? ) { if characteristic.isNotifying { state = .connected sendCommand(buildSysexGetConfig()) } } func peripheral( _ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error? ) { guard let data = characteristic.value, let sysex = Self.extractSysEx(from: data), let msg = parseSysex(sysex) else { return } lastMessage = msg onMessage?(msg) } }