diff --git a/cue-ios/CueIOS/AppState.swift b/cue-ios/CueIOS/AppState.swift
index 210f244..e3243e2 100644
--- a/cue-ios/CueIOS/AppState.swift
+++ b/cue-ios/CueIOS/AppState.swift
@@ -1,6 +1,5 @@
import Foundation
import Observation
-import Combine
enum Tab: String, CaseIterable, Identifiable {
case eis = "EIS"
@@ -19,12 +18,13 @@ enum Tab: String, CaseIterable, Identifiable {
@Observable
final class AppState {
- let ble: BLEManager
+ let transport: UDPManager
var tab: Tab = .eis
var status: String = "Disconnected"
- var bleConnected: Bool = false
var tempC: Float = 25.0
+ var connected: Bool { transport.state == .connected }
+
// EIS
var eisPoints: [EisPoint] = []
var sweepTotal: UInt16 = 0
@@ -98,40 +98,17 @@ final class AppState {
var cleanV: String = "1200"
var cleanDur: String = "30"
- // Temperature polling
- private var tempTimer: Timer?
-
init() {
- ble = BLEManager()
- ble.setMessageHandler { [weak self] msg in
+ transport = UDPManager()
+ transport.setMessageHandler { [weak self] msg in
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
func send(_ sysex: [UInt8]) {
- ble.sendCommand(sysex)
+ transport.send(sysex)
}
// MARK: - Message handler
diff --git a/cue-ios/CueIOS/BLE/BLEManager.swift b/cue-ios/CueIOS/BLE/BLEManager.swift
deleted file mode 100644
index ce03e4c..0000000
--- a/cue-ios/CueIOS/BLE/BLEManager.swift
+++ /dev/null
@@ -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)
- }
-}
diff --git a/cue-ios/CueIOS/CueIOSApp.swift b/cue-ios/CueIOS/CueIOSApp.swift
index 6198e4a..07c1755 100644
--- a/cue-ios/CueIOS/CueIOSApp.swift
+++ b/cue-ios/CueIOS/CueIOSApp.swift
@@ -7,9 +7,6 @@ struct CueIOSApp: App {
var body: some Scene {
WindowGroup {
ContentView(state: state)
- .onReceive(Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()) { _ in
- state.updateConnectionState()
- }
}
}
}
diff --git a/cue-ios/CueIOS/Info.plist b/cue-ios/CueIOS/Info.plist
index e968a82..36984a8 100644
--- a/cue-ios/CueIOS/Info.plist
+++ b/cue-ios/CueIOS/Info.plist
@@ -14,12 +14,8 @@
$(EXECUTABLE_NAME)
CFBundlePackageType
APPL
- NSBluetoothAlwaysUsageDescription
- EIS4 uses Bluetooth to communicate with the impedance analyzer
- UIBackgroundModes
-
- bluetooth-central
-
+ NSLocalNetworkUsageDescription
+ Cue connects to the EIS4 impedance analyzer over WiFi
UISupportedInterfaceOrientations
UIInterfaceOrientationPortrait
diff --git a/cue-ios/CueIOS/Transport/UDPManager.swift b/cue-ios/CueIOS/Transport/UDPManager.swift
new file mode 100644
index 0000000..6bafac0
--- /dev/null
+++ b/cue-ios/CueIOS/Transport/UDPManager.swift
@@ -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).. Self.timeout {
+ self.state = .disconnected
+ self.stopTimers()
+ self.connection?.cancel()
+ self.connection = nil
+ }
+ }
+ }
+
+ private func stopTimers() {
+ keepaliveTimer?.invalidate()
+ keepaliveTimer = nil
+ timeoutTimer?.invalidate()
+ timeoutTimer = nil
+ }
+}
diff --git a/cue-ios/CueIOS/Views/Components/StatusBar.swift b/cue-ios/CueIOS/Views/Components/StatusBar.swift
index ae335ae..e0089eb 100644
--- a/cue-ios/CueIOS/Views/Components/StatusBar.swift
+++ b/cue-ios/CueIOS/Views/Components/StatusBar.swift
@@ -25,7 +25,7 @@ struct StatusBar: View {
private var connectionIndicator: some View {
Circle()
- .fill(state.bleConnected ? Color.green : Color.red)
+ .fill(state.connected ? Color.green : Color.red)
.frame(width: 8, height: 8)
}
diff --git a/cue-ios/CueIOS/Views/ConnectionView.swift b/cue-ios/CueIOS/Views/ConnectionView.swift
index d71abc1..0dc3caa 100644
--- a/cue-ios/CueIOS/Views/ConnectionView.swift
+++ b/cue-ios/CueIOS/Views/ConnectionView.swift
@@ -2,33 +2,38 @@ import SwiftUI
struct ConnectionView: View {
var state: AppState
+ @State private var addressField: String = ""
var body: some View {
VStack(spacing: 0) {
header
Divider()
- deviceList
+ connectionForm
+ Spacer()
}
.navigationTitle("Connection")
+ .onAppear { addressField = state.transport.address }
}
+ // MARK: - Header
+
private var header: some View {
HStack {
Circle()
.fill(statusColor)
.frame(width: 10, height: 10)
- Text(state.ble.state.rawValue)
+ Text(state.transport.state.rawValue)
.font(.headline)
Spacer()
- if state.ble.state == .connected {
- Button("Disconnect") { state.ble.disconnect() }
+ if state.transport.state == .connected {
+ Button("Disconnect") { state.transport.disconnect() }
.buttonStyle(.bordered)
.tint(.red)
- } else if state.ble.state == .scanning {
- Button("Stop") { state.ble.stopScanning() }
- .buttonStyle(.bordered)
+ } else if state.transport.state == .connecting {
+ ProgressView()
+ .controlSize(.small)
} else {
- Button("Scan") { state.ble.startScanning() }
+ Button("Connect") { connectToDevice() }
.buttonStyle(.borderedProminent)
}
}
@@ -36,52 +41,49 @@ struct ConnectionView: View {
}
private var statusColor: Color {
- switch state.ble.state {
+ switch state.transport.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 {
+ // MARK: - Connection form
+
+ 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 {
- ProgressView()
- Text("Scanning for devices...")
+ TextField("192.168.4.1", text: $addressField)
+ .textFieldStyle(.roundedBorder)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ .keyboardType(.numbersAndPunctuation)
+ Text(":\(state.transport.port)")
+ .font(.subheadline.monospacedDigit())
.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)
- }
+ Button("Connect") { connectToDevice() }
+ .buttonStyle(.borderedProminent)
+ .disabled(state.transport.state == .connecting)
+
+ Text("Connect to the EIS4 WiFi network, then tap Connect.")
+ .font(.caption)
+ .foregroundStyle(Color(white: 0.4))
}
+ .padding()
+ }
+
+ private func connectToDevice() {
+ let addr = addressField.trimmingCharacters(in: .whitespaces)
+ if !addr.isEmpty {
+ state.transport.address = addr
+ }
+ state.transport.connect()
}
}