cue-ios: strip CoreBluetooth, replace with UDP/WiFi transport

This commit is contained in:
jess 2026-04-01 00:37:12 -07:00
parent d13909c400
commit 3f91159596
7 changed files with 249 additions and 309 deletions

View File

@ -1,6 +1,5 @@
import Foundation import Foundation
import Observation import Observation
import Combine
enum Tab: String, CaseIterable, Identifiable { enum Tab: String, CaseIterable, Identifiable {
case eis = "EIS" case eis = "EIS"
@ -19,12 +18,13 @@ enum Tab: String, CaseIterable, Identifiable {
@Observable @Observable
final class AppState { final class AppState {
let ble: BLEManager let transport: UDPManager
var tab: Tab = .eis var tab: Tab = .eis
var status: String = "Disconnected" var status: String = "Disconnected"
var bleConnected: Bool = false
var tempC: Float = 25.0 var tempC: Float = 25.0
var connected: Bool { transport.state == .connected }
// EIS // EIS
var eisPoints: [EisPoint] = [] var eisPoints: [EisPoint] = []
var sweepTotal: UInt16 = 0 var sweepTotal: UInt16 = 0
@ -98,40 +98,17 @@ final class AppState {
var cleanV: String = "1200" var cleanV: String = "1200"
var cleanDur: String = "30" var cleanDur: String = "30"
// Temperature polling
private var tempTimer: Timer?
init() { init() {
ble = BLEManager() transport = UDPManager()
ble.setMessageHandler { [weak self] msg in transport.setMessageHandler { [weak self] msg in
self?.handleMessage(msg) self?.handleMessage(msg)
} }
startTempPolling()
}
// MARK: - BLE connection tracking
func updateConnectionState() {
let connected = ble.state == .connected
if connected != bleConnected {
bleConnected = connected
status = ble.state.rawValue
}
}
// MARK: - Temperature polling
private func startTempPolling() {
tempTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
guard let self, self.bleConnected else { return }
self.send(buildSysexGetTemp())
}
} }
// MARK: - Send helper // MARK: - Send helper
func send(_ sysex: [UInt8]) { func send(_ sysex: [UInt8]) {
ble.sendCommand(sysex) transport.send(sysex)
} }
// MARK: - Message handler // MARK: - Message handler

View File

@ -1,227 +0,0 @@
/// 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 {
nonisolated(unsafe) static let midiServiceUUID = CBUUID(string: "03B80E5A-EDE8-4B33-A751-6CE34EC4C700")
nonisolated(unsafe) 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"
}
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(set) 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 {
print("[BLE] can't scan, state: \(centralManager.state.rawValue)")
return
}
print("[BLE] starting scan (no filter)")
state = .scanning
discoveredDevices.removeAll()
centralManager.scanForPeripherals(
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)
}
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) {
print("[BLE] centralManager state: \(central.state.rawValue)")
if central.state == .poweredOn {
startScanning()
}
}
func centralManager(
_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
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) {
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())
sendCommand(buildSysexGetCellK())
}
}
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)
}
}

View File

@ -7,9 +7,6 @@ struct CueIOSApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView(state: state) ContentView(state: state)
.onReceive(Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()) { _ in
state.updateConnectionState()
}
} }
} }
} }

View File

@ -14,12 +14,8 @@
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>NSBluetoothAlwaysUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>EIS4 uses Bluetooth to communicate with the impedance analyzer</string> <string>Cue connects to the EIS4 impedance analyzer over WiFi</string>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
</array>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>

View File

@ -0,0 +1,195 @@
/// UDP transport for EIS4 firmware communication.
/// Matches the desktop Cue protocol: raw SysEx frames over UDP port 5941.
import Foundation
import Network
@Observable
final class UDPManager: @unchecked Sendable {
enum ConnectionState: String {
case disconnected = "Disconnected"
case connecting = "Connecting..."
case connected = "Connected"
}
private static let defaultAddr = "192.168.4.1"
private static let defaultPort: UInt16 = 5941
private static let keepaliveInterval: TimeInterval = 5
private static let timeout: TimeInterval = 10
private static let addrKey = "eis4_udp_addr"
var state: ConnectionState = .disconnected
var address: String
var port: UInt16
private var connection: NWConnection?
private let queue = DispatchQueue(label: "udp.eis4", qos: .userInitiated)
private var onMessage: ((EisMessage) -> Void)?
private var keepaliveTimer: Timer?
private var timeoutTimer: Timer?
private var lastReceived: Date = .distantPast
init() {
let saved = UserDefaults.standard.string(forKey: Self.addrKey) ?? ""
if saved.isEmpty {
address = Self.defaultAddr
port = Self.defaultPort
} else {
let parts = saved.split(separator: ":")
address = String(parts[0])
port = parts.count > 1 ? UInt16(parts[1]) ?? Self.defaultPort : Self.defaultPort
}
}
func setMessageHandler(_ handler: @escaping (EisMessage) -> Void) {
onMessage = handler
}
// MARK: - Connection
func connect() {
disconnect()
state = .connecting
let addrStr = "\(address):\(port)"
UserDefaults.standard.set(addrStr, forKey: Self.addrKey)
let host = NWEndpoint.Host(address)
let nwPort = NWEndpoint.Port(rawValue: port) ?? NWEndpoint.Port(rawValue: Self.defaultPort)!
let params = NWParameters.udp
params.allowLocalEndpointReuse = true
let conn = NWConnection(host: host, port: nwPort, using: params)
connection = conn
conn.stateUpdateHandler = { [weak self] newState in
DispatchQueue.main.async {
self?.handleStateChange(newState)
}
}
conn.start(queue: queue)
}
func disconnect() {
stopTimers()
connection?.cancel()
connection = nil
state = .disconnected
}
func send(_ sysex: [UInt8]) {
guard let conn = connection else { return }
let data = Data(sysex)
conn.send(content: data, completion: .contentProcessed({ _ in }))
}
// MARK: - State handling
private func handleStateChange(_ newState: NWConnection.State) {
switch newState {
case .ready:
state = .connected
lastReceived = Date()
send(buildSysexGetTemp())
send(buildSysexGetConfig())
send(buildSysexGetCellK())
startTimers()
receiveLoop()
case .failed, .cancelled:
state = .disconnected
stopTimers()
case .waiting:
state = .connecting
default:
break
}
}
// MARK: - Receive
private func receiveLoop() {
guard let conn = connection else { return }
conn.receiveMessage { [weak self] content, _, _, error in
guard let self else { return }
if error != nil {
DispatchQueue.main.async {
if self.state == .connected {
self.state = .disconnected
}
}
return
}
guard let data = content, !data.isEmpty else {
self.receiveLoop()
return
}
DispatchQueue.main.async {
self.lastReceived = Date()
}
let frames = Self.extractSysExFrames(data)
for frame in frames {
if let msg = parseSysex(frame) {
DispatchQueue.main.async {
self.onMessage?(msg)
}
}
}
self.receiveLoop()
}
}
/// Extract SysEx payloads from a UDP datagram.
/// Scans for F0..F7 boundaries. Returns inner bytes (between F0 and F7, exclusive).
static func extractSysExFrames(_ data: Data) -> [[UInt8]] {
let bytes = Array(data)
var frames: [[UInt8]] = []
var i = 0
while i < bytes.count {
if bytes[i] == 0xF0 {
if let end = bytes[i...].firstIndex(of: 0xF7) {
let payload = Array(bytes[(i + 1)..<end])
if !payload.isEmpty {
frames.append(payload)
}
i = end + 1
continue
}
}
i += 1
}
return frames
}
// MARK: - Timers
private func startTimers() {
keepaliveTimer = Timer.scheduledTimer(withTimeInterval: Self.keepaliveInterval, repeats: true) { [weak self] _ in
self?.send(buildSysexGetTemp())
}
timeoutTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
guard let self, self.state == .connected else { return }
if Date().timeIntervalSince(self.lastReceived) > Self.timeout {
self.state = .disconnected
self.stopTimers()
self.connection?.cancel()
self.connection = nil
}
}
}
private func stopTimers() {
keepaliveTimer?.invalidate()
keepaliveTimer = nil
timeoutTimer?.invalidate()
timeoutTimer = nil
}
}

View File

@ -25,7 +25,7 @@ struct StatusBar: View {
private var connectionIndicator: some View { private var connectionIndicator: some View {
Circle() Circle()
.fill(state.bleConnected ? Color.green : Color.red) .fill(state.connected ? Color.green : Color.red)
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
} }

View File

@ -2,33 +2,38 @@ import SwiftUI
struct ConnectionView: View { struct ConnectionView: View {
var state: AppState var state: AppState
@State private var addressField: String = ""
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
header header
Divider() Divider()
deviceList connectionForm
Spacer()
} }
.navigationTitle("Connection") .navigationTitle("Connection")
.onAppear { addressField = state.transport.address }
} }
// MARK: - Header
private var header: some View { private var header: some View {
HStack { HStack {
Circle() Circle()
.fill(statusColor) .fill(statusColor)
.frame(width: 10, height: 10) .frame(width: 10, height: 10)
Text(state.ble.state.rawValue) Text(state.transport.state.rawValue)
.font(.headline) .font(.headline)
Spacer() Spacer()
if state.ble.state == .connected { if state.transport.state == .connected {
Button("Disconnect") { state.ble.disconnect() } Button("Disconnect") { state.transport.disconnect() }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(.red) .tint(.red)
} else if state.ble.state == .scanning { } else if state.transport.state == .connecting {
Button("Stop") { state.ble.stopScanning() } ProgressView()
.buttonStyle(.bordered) .controlSize(.small)
} else { } else {
Button("Scan") { state.ble.startScanning() } Button("Connect") { connectToDevice() }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
} }
} }
@ -36,52 +41,49 @@ struct ConnectionView: View {
} }
private var statusColor: Color { private var statusColor: Color {
switch state.ble.state { switch state.transport.state {
case .connected: .green case .connected: .green
case .scanning: .orange
case .connecting: .yellow case .connecting: .yellow
case .disconnected: .red case .disconnected: .red
} }
} }
private var deviceList: some View { // MARK: - Connection form
List {
if state.ble.discoveredDevices.isEmpty && state.ble.state == .scanning { private var connectionForm: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("Firmware Address")
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
HStack { HStack {
ProgressView() TextField("192.168.4.1", text: $addressField)
Text("Scanning for devices...") .textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.keyboardType(.numbersAndPunctuation)
Text(":\(state.transport.port)")
.font(.subheadline.monospacedDigit())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
ForEach(state.ble.discoveredDevices) { device in Button("Connect") { connectToDevice() }
Button { .buttonStyle(.borderedProminent)
state.ble.connectTo(device) .disabled(state.transport.state == .connecting)
} label: {
HStack { Text("Connect to the EIS4 WiFi network, then tap Connect.")
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) .font(.caption)
} .foregroundStyle(Color(white: 0.4))
} }
} .padding()
.disabled(state.ble.state == .connecting) }
}
} private func connectToDevice() {
let addr = addressField.trimmingCharacters(in: .whitespaces)
if !addr.isEmpty {
state.transport.address = addr
}
state.transport.connect()
} }
} }