189 lines
5.6 KiB
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)
|
|
}
|
|
}
|