211 lines
6.2 KiB
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
|
|
}
|
|
}
|