/// 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 /// Suppress keepalive timeout during blocking firmware operations (pH, clean, refs) var measuring: Bool = false private var connection: NWConnection? private let queue = DispatchQueue(label: "udp.eis4", qos: .userInitiated) private var onMessage: ((EisMessage) -> Void)? private var onDisconnect: (() -> 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 } func setDisconnectHandler(_ handler: @escaping () -> Void) { onDisconnect = 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 measuring = false state = .disconnected onDisconnect?() } 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()) send(buildSysexGetClFactor()) send(buildSysexGetPhCal()) 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 self.onDisconnect?() } } } private func stopTimers() { keepaliveTimer?.invalidate() keepaliveTimer = nil timeoutTimer?.invalidate() timeoutTimer = nil } }