add session sync protocol, extended START fields, and session management handlers

This commit is contained in:
jess 2026-04-03 07:05:40 -07:00
parent dcde79cf08
commit d061a17e54
3 changed files with 231 additions and 21 deletions

View File

@ -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)

View File

@ -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..<count {
guard off < p.count else { break }
let sid = p[off]; off += 1
guard off < p.count else { break }
let nameLen = Int(p[off]); off += 1
let name: String
if nameLen > 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
}

View File

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