diff --git a/cue-ios/CueIOS/AppState.swift b/cue-ios/CueIOS/AppState.swift index fa6aec9..295e8be 100644 --- a/cue-ios/CueIOS/AppState.swift +++ b/cue-ios/CueIOS/AppState.swift @@ -100,6 +100,9 @@ final class AppState { // Session var currentSessionId: Int64? = nil + var firmwareSessionMap: [UInt8: Int64] = [:] + var sessionListReceived: Bool = false + private var pendingEspTimestamp: Int64? = nil // Calibration var calVolumeGal: Double = 25 @@ -125,6 +128,10 @@ final class AppState { transport.setMessageHandler { [weak self] msg in self?.handleMessage(msg) } + transport.setDisconnectHandler { [weak self] in + self?.sessionListReceived = false + self?.firmwareSessionMap.removeAll() + } } // MARK: - Send helper @@ -138,7 +145,8 @@ final class AppState { private func handleMessage(_ msg: EisMessage) { switch msg { - case .sweepStart(let numPoints, let freqStart, let freqStop): + case .sweepStart(let numPoints, let freqStart, let freqStop, let espTs, _): + pendingEspTimestamp = espTs.map { Int64($0) } if collectingRefs { eisPoints.removeAll() sweepTotal = numPoints @@ -172,9 +180,14 @@ final class AppState { rtia = cfg.rtia rcal = cfg.rcal electrode = cfg.electrode + if !sessionListReceived { + sessionListReceived = true + send(buildSysexSessionList()) + } status = "Config received" - case .lsvStart(let numPoints, let vStart, let vStop): + case .lsvStart(let numPoints, let vStart, let vStop, let espTs, _): + pendingEspTimestamp = espTs.map { Int64($0) } lsvPoints.removeAll() lsvTotal = numPoints status = String(format: "LSV: %d pts, %.0f--%.0f mV", numPoints, vStart, vStop) @@ -214,7 +227,8 @@ final class AppState { status = String(format: "Auto Cl: measuring (free=%.0f, total=%.0f)", pots.vFree, pots.vTotal) } - case .ampStart(let vHold): + case .ampStart(let vHold, let espTs, _): + pendingEspTimestamp = espTs.map { Int64($0) } ampPoints.removeAll() ampRunning = true status = String(format: "Amp: %.0f mV", vHold) @@ -229,7 +243,8 @@ final class AppState { saveAmp() status = "Amp complete: \(ampPoints.count) points" - case .clStart(let numPoints): + case .clStart(let numPoints, let espTs, _): + pendingEspTimestamp = espTs.map { Int64($0) } clPoints.removeAll() clResult = nil clTotal = numPoints @@ -324,11 +339,70 @@ final class AppState { phOffset = Double(offset) status = String(format: "pH cal: slope=%.4f offset=%.4f", slope, offset) + case .sessionCreated(let fwId, let name): + handleSessionCreated(fwId: fwId, name: name) + + case .sessionSwitched(let fwId): + handleSessionSwitched(fwId: fwId) + + case .sessionList(_, let currentId, let sessions): + handleSessionList(currentId: currentId, sessions: sessions) + + case .sessionRenamed(let fwId, let name): + handleSessionRenamed(fwId: fwId, name: name) + case .keepalive: break } } + // MARK: - Session sync + + private func handleSessionCreated(fwId: UInt8, name: String) { + let fwId64 = Int64(fwId) + if let existing = Storage.shared.sessionByFirmwareId(fwId64) { + firmwareSessionMap[fwId] = existing.id + } else if let session = try? Storage.shared.createSession( + label: name.isEmpty ? nil : name, + firmwareSessionId: fwId64 + ) { + firmwareSessionMap[fwId] = session.id + currentSessionId = session.id + } + } + + private func handleSessionSwitched(fwId: UInt8) { + if let localId = firmwareSessionMap[fwId] { + currentSessionId = localId + } else { + let fwId64 = Int64(fwId) + if let existing = Storage.shared.sessionByFirmwareId(fwId64) { + firmwareSessionMap[fwId] = existing.id + currentSessionId = existing.id + } + } + } + + private func handleSessionList(currentId: UInt8, sessions: [(id: UInt8, name: String)]) { + for entry in sessions { + let fwId64 = Int64(entry.id) + if let existing = Storage.shared.sessionByFirmwareId(fwId64) { + firmwareSessionMap[entry.id] = existing.id + } else if let session = try? Storage.shared.createSession( + label: entry.name.isEmpty ? nil : entry.name, + firmwareSessionId: fwId64 + ) { + firmwareSessionMap[entry.id] = session.id + } + } + handleSessionSwitched(fwId: currentId) + } + + private func handleSessionRenamed(fwId: UInt8, name: String) { + guard let localId = firmwareSessionMap[fwId] else { return } + try? Storage.shared.updateSessionLabel(localId, label: name) + } + // MARK: - Actions func applyEISSettings() { @@ -533,6 +607,8 @@ final class AppState { private func saveEis() { guard let sid = currentSessionId else { return } + let ts = pendingEspTimestamp + pendingEspTimestamp = nil let params: [String: String] = [ "freq_start": freqStart, "freq_stop": freqStop, @@ -542,7 +618,7 @@ final class AppState { "electrode": electrode.label, ] guard let configData = try? JSONEncoder().encode(params) else { return } - guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .eis) else { return } + guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .eis, espTimestamp: ts) else { return } meas.config = configData guard let mid = meas.id else { return } let indexed = eisPoints.enumerated().map { (index: $0.offset, value: $0.element) } @@ -551,6 +627,8 @@ final class AppState { private func saveLsv() { guard let sid = currentSessionId else { return } + let ts = pendingEspTimestamp + pendingEspTimestamp = nil let params: [String: String] = [ "v_start": lsvStartV, "v_stop": lsvStopV, @@ -558,7 +636,7 @@ final class AppState { "rtia": lsvRtia.label, ] guard let configData = try? JSONEncoder().encode(params) else { return } - guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .lsv) else { return } + guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .lsv, espTimestamp: ts) else { return } meas.config = configData guard let mid = meas.id else { return } let indexed = lsvPoints.enumerated().map { (index: $0.offset, value: $0.element) } @@ -567,6 +645,8 @@ final class AppState { private func saveAmp() { guard let sid = currentSessionId else { return } + let ts = pendingEspTimestamp + pendingEspTimestamp = nil let params: [String: String] = [ "v_hold": ampVHold, "interval_ms": ampInterval, @@ -574,7 +654,7 @@ final class AppState { "rtia": ampRtia.label, ] guard let configData = try? JSONEncoder().encode(params) else { return } - guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .amp) else { return } + guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .amp, espTimestamp: ts) else { return } meas.config = configData guard let mid = meas.id else { return } let indexed = ampPoints.enumerated().map { (index: $0.offset, value: $0.element) } @@ -583,6 +663,8 @@ final class AppState { private func saveCl() { guard let sid = currentSessionId else { return } + let ts = pendingEspTimestamp + pendingEspTimestamp = nil let params: [String: String] = [ "cond_v": clCondV, "cond_t": clCondT, @@ -593,7 +675,7 @@ final class AppState { "rtia": clRtia.label, ] guard let configData = try? JSONEncoder().encode(params) else { return } - guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .chlorine) else { return } + guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .chlorine, espTimestamp: ts) else { return } meas.config = configData guard let mid = meas.id else { return } let indexed = clPoints.enumerated().map { (index: $0.offset, value: $0.element) } @@ -605,11 +687,13 @@ final class AppState { private func savePh(_ result: PhResult) { guard let sid = currentSessionId else { return } + let ts = pendingEspTimestamp + pendingEspTimestamp = nil let params: [String: String] = [ "stabilize_s": phStabilize, ] guard let configData = try? JSONEncoder().encode(params) else { return } - guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .ph) else { return } + guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .ph, espTimestamp: ts) else { return } meas.config = configData guard let mid = meas.id else { return } try? Storage.shared.addDataPoint(measurementId: mid, index: 0, point: result) diff --git a/cue-ios/CueIOS/Models/Protocol.swift b/cue-ios/CueIOS/Models/Protocol.swift index 34c3664..6dd6718 100644 --- a/cue-ios/CueIOS/Models/Protocol.swift +++ b/cue-ios/CueIOS/Models/Protocol.swift @@ -31,7 +31,11 @@ let RSP_REFS_DONE: UInt8 = 0x22 let RSP_REF_STATUS: UInt8 = 0x23 let RSP_CL_FACTOR: UInt8 = 0x24 let RSP_PH_CAL: UInt8 = 0x25 -let RSP_KEEPALIVE: UInt8 = 0x50 +let RSP_SESSION_CREATED: UInt8 = 0x40 +let RSP_SESSION_SWITCHED: UInt8 = 0x41 +let RSP_SESSION_LIST: UInt8 = 0x42 +let RSP_SESSION_RENAMED: UInt8 = 0x43 +let RSP_KEEPALIVE: UInt8 = 0x50 // Cue -> ESP32 let CMD_SET_SWEEP: UInt8 = 0x10 @@ -53,9 +57,13 @@ let CMD_SET_CL_FACTOR: UInt8 = 0x33 let CMD_GET_CL_FACTOR: UInt8 = 0x34 let CMD_SET_PH_CAL: UInt8 = 0x35 let CMD_GET_PH_CAL: UInt8 = 0x36 -let CMD_START_REFS: UInt8 = 0x30 -let CMD_GET_REFS: UInt8 = 0x31 -let CMD_CLEAR_REFS: UInt8 = 0x32 +let CMD_START_REFS: UInt8 = 0x30 +let CMD_GET_REFS: UInt8 = 0x31 +let CMD_CLEAR_REFS: UInt8 = 0x32 +let CMD_SESSION_CREATE: UInt8 = 0x40 +let CMD_SESSION_SWITCH: UInt8 = 0x41 +let CMD_SESSION_LIST: UInt8 = 0x42 +let CMD_SESSION_RENAME: UInt8 = 0x43 // MARK: - 7-bit MIDI encoding @@ -94,6 +102,31 @@ func encodeU16(_ val: UInt16) -> [UInt8] { return [mask, p[0] & 0x7F, p[1] & 0x7F] } +/// Encode a UInt32 into 5 MIDI-safe bytes. +func encodeU32(_ val: UInt32) -> [UInt8] { + var v = val + let p = withUnsafeBytes(of: &v) { Array($0) } + let mask: UInt8 = ((p[0] >> 7) & 1) + | ((p[1] >> 6) & 2) + | ((p[2] >> 5) & 4) + | ((p[3] >> 4) & 8) + return [mask, p[0] & 0x7F, p[1] & 0x7F, p[2] & 0x7F, p[3] & 0x7F] +} + +/// Decode 5 MIDI-safe bytes back into a UInt32. +func decodeU32(_ d: [UInt8], at offset: Int = 0) -> UInt32 { + let m = d[offset] + let b0 = d[offset + 1] | ((m & 1) << 7) + let b1 = d[offset + 2] | ((m & 2) << 6) + let b2 = d[offset + 3] | ((m & 4) << 5) + let b3 = d[offset + 4] | ((m & 8) << 4) + var val: UInt32 = 0 + withUnsafeMutableBytes(of: &val) { buf in + buf[0] = b0; buf[1] = b1; buf[2] = b2; buf[3] = b3 + } + return val +} + /// Decode 3 MIDI-safe bytes back into a UInt16. func decodeU16(_ d: [UInt8], at offset: Int = 0) -> UInt16 { let m = d[offset] @@ -109,17 +142,17 @@ func decodeU16(_ d: [UInt8], at offset: Int = 0) -> UInt16 { // MARK: - Message enum enum EisMessage { - case sweepStart(numPoints: UInt16, freqStart: Float, freqStop: Float) + case sweepStart(numPoints: UInt16, freqStart: Float, freqStop: Float, espTimestamp: UInt32?, espMeasId: UInt16?) case dataPoint(index: UInt16, point: EisPoint) case sweepEnd case config(EisConfig) - case lsvStart(numPoints: UInt16, vStart: Float, vStop: Float) + case lsvStart(numPoints: UInt16, vStart: Float, vStop: Float, espTimestamp: UInt32?, espMeasId: UInt16?) case lsvPoint(index: UInt16, point: LsvPoint) case lsvEnd - case ampStart(vHold: Float) + case ampStart(vHold: Float, espTimestamp: UInt32?, espMeasId: UInt16?) case ampPoint(index: UInt16, point: AmpPoint) case ampEnd - case clStart(numPoints: UInt16) + case clStart(numPoints: UInt16, espTimestamp: UInt32?, espMeasId: UInt16?) case clPoint(index: UInt16, point: ClPoint) case clResult(ClResult) case clEnd @@ -132,6 +165,10 @@ enum EisMessage { case cellK(Float) case clFactor(Float) case phCal(slope: Float, offset: Float) + case sessionCreated(id: UInt8, name: String) + case sessionSwitched(id: UInt8) + case sessionList(count: UInt8, currentId: UInt8, sessions: [(id: UInt8, name: String)]) + case sessionRenamed(id: UInt8, name: String) case keepalive } @@ -146,10 +183,13 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? { switch data[1] { case RSP_SWEEP_START where p.count >= 13: + let hasExt = p.count >= 21 return .sweepStart( numPoints: decodeU16(p, at: 0), freqStart: decodeFloat(p, at: 3), - freqStop: decodeFloat(p, at: 8) + freqStop: decodeFloat(p, at: 8), + espTimestamp: hasExt ? decodeU32(p, at: 13) : nil, + espMeasId: hasExt ? decodeU16(p, at: 18) : nil ) case RSP_DATA_POINT where p.count >= 28: @@ -184,10 +224,13 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? { )) case RSP_LSV_START where p.count >= 13: + let hasExt = p.count >= 21 return .lsvStart( numPoints: decodeU16(p, at: 0), vStart: decodeFloat(p, at: 3), - vStop: decodeFloat(p, at: 8) + vStop: decodeFloat(p, at: 8), + espTimestamp: hasExt ? decodeU32(p, at: 13) : nil, + espMeasId: hasExt ? decodeU16(p, at: 18) : nil ) case RSP_LSV_POINT where p.count >= 13: @@ -203,7 +246,12 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? { return .lsvEnd case RSP_AMP_START where p.count >= 5: - return .ampStart(vHold: decodeFloat(p, at: 0)) + let hasExt = p.count >= 13 + return .ampStart( + vHold: decodeFloat(p, at: 0), + espTimestamp: hasExt ? decodeU32(p, at: 5) : nil, + espMeasId: hasExt ? decodeU16(p, at: 10) : nil + ) case RSP_AMP_POINT where p.count >= 13: return .ampPoint( @@ -218,7 +266,12 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? { return .ampEnd case RSP_CL_START where p.count >= 3: - return .clStart(numPoints: decodeU16(p, at: 0)) + let hasExt = p.count >= 11 + return .clStart( + numPoints: decodeU16(p, at: 0), + espTimestamp: hasExt ? decodeU32(p, at: 3) : nil, + espMeasId: hasExt ? decodeU16(p, at: 8) : nil + ) case RSP_CL_POINT where p.count >= 14: return .clPoint( @@ -273,6 +326,46 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? { offset: decodeFloat(p, at: 5) ) + case RSP_SESSION_CREATED where p.count >= 2: + let sid = p[0] + let nameLen = Int(p[1]) + let name = nameLen > 0 && p.count >= 2 + nameLen + ? String(bytes: p[2..<(2 + nameLen)], encoding: .utf8) ?? "" + : "" + return .sessionCreated(id: sid, name: name) + + case RSP_SESSION_SWITCHED where p.count >= 1: + return .sessionSwitched(id: p[0]) + + case RSP_SESSION_LIST where p.count >= 2: + let count = p[0] + let currentId = p[1] + var sessions: [(id: UInt8, name: String)] = [] + var off = 2 + for _ in 0.. 0 && off + nameLen <= p.count { + name = String(bytes: p[off..<(off + nameLen)], encoding: .utf8) ?? "" + off += nameLen + } else { + name = "" + } + sessions.append((id: sid, name: name)) + } + return .sessionList(count: count, currentId: currentId, sessions: sessions) + + case RSP_SESSION_RENAMED where p.count >= 2: + let sid = p[0] + let nameLen = Int(p[1]) + let name = nameLen > 0 && p.count >= 2 + nameLen + ? String(bytes: p[2..<(2 + nameLen)], encoding: .utf8) ?? "" + : "" + return .sessionRenamed(id: sid, name: name) + case RSP_KEEPALIVE: return .keepalive @@ -417,3 +510,29 @@ func buildSysexSetPhCal(_ slope: Float, _ offset: Float) -> [UInt8] { func buildSysexGetPhCal() -> [UInt8] { [0xF0, sysexMfr, CMD_GET_PH_CAL, 0xF7] } + +// MARK: - Session commands + +func buildSysexSessionCreate(name: String) -> [UInt8] { + let nameBytes = Array(name.utf8.prefix(32)) + var sx: [UInt8] = [0xF0, sysexMfr, CMD_SESSION_CREATE, UInt8(nameBytes.count)] + sx.append(contentsOf: nameBytes) + sx.append(0xF7) + return sx +} + +func buildSysexSessionSwitch(id: UInt8) -> [UInt8] { + [0xF0, sysexMfr, CMD_SESSION_SWITCH, id, 0xF7] +} + +func buildSysexSessionList() -> [UInt8] { + [0xF0, sysexMfr, CMD_SESSION_LIST, 0xF7] +} + +func buildSysexSessionRename(id: UInt8, name: String) -> [UInt8] { + let nameBytes = Array(name.utf8.prefix(32)) + var sx: [UInt8] = [0xF0, sysexMfr, CMD_SESSION_RENAME, id, UInt8(nameBytes.count)] + sx.append(contentsOf: nameBytes) + sx.append(0xF7) + return sx +} diff --git a/cue-ios/CueIOS/Transport/UDPManager.swift b/cue-ios/CueIOS/Transport/UDPManager.swift index 351aa91..a958418 100644 --- a/cue-ios/CueIOS/Transport/UDPManager.swift +++ b/cue-ios/CueIOS/Transport/UDPManager.swift @@ -29,6 +29,7 @@ final class UDPManager: @unchecked Sendable { 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 @@ -49,6 +50,10 @@ final class UDPManager: @unchecked Sendable { onMessage = handler } + func setDisconnectHandler(_ handler: @escaping () -> Void) { + onDisconnect = handler + } + // MARK: - Connection func connect() { @@ -81,6 +86,7 @@ final class UDPManager: @unchecked Sendable { connection = nil measuring = false state = .disconnected + onDisconnect?() } func send(_ sysex: [UInt8]) { @@ -188,6 +194,7 @@ final class UDPManager: @unchecked Sendable { self.stopTimers() self.connection?.cancel() self.connection = nil + self.onDisconnect?() } } }