EIS-BLE-S3/cue-ios/CueIOS/BLE/BLEManager.swift

189 lines
5.6 KiB
Swift

/// 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)
}
}