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