EIS-BLE-S3/cue-ios/CueIOS/Transport/UDPManager.swift

211 lines
6.2 KiB
Swift

/// 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
guard let self else { return }
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)..<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
guard let self else { return }
self.send(buildSysexGetTemp())
}
timeoutTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
guard let self, self.state == .connected, !self.measuring else { return }
if Date().timeIntervalSince(self.lastReceived) > 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
}
}