EIS-BLE-S3/cue-ios/CueIOS/AppState.swift

774 lines
26 KiB
Swift

import Foundation
import Observation
enum ClAutoState: Equatable {
case idle, lsvRunning, measureRunning
}
enum LsvDensityMode: String, CaseIterable, Identifiable {
case ptsPerMv = "pts/mV"
case ptsPerSec = "pts/s"
var id: String { rawValue }
}
enum Tab: String, CaseIterable, Identifiable {
case eis = "EIS"
case lsv = "LSV"
case amp = "Amperometry"
case chlorine = "Chlorine"
case ph = "pH"
case calibrate = "Calibrate"
case sessions = "Sessions"
case connection = "Connection"
var id: String { rawValue }
}
// MARK: - App State
@Observable
final class AppState {
let transport: UDPManager
var tab: Tab = .eis
var status: String = "Disconnected"
var tempC: Float = 25.0
var connected: Bool { transport.state == .connected }
// EIS
var eisPoints: [EisPoint] = []
var sweepTotal: UInt16 = 0
var freqStart: String = "1000"
var freqStop: String = "200000"
var ppd: String = "10"
var rtia: Rtia = .r5K
var rcal: Rcal = .r3K
var electrode: Electrode = .fourWire
// LSV
var lsvPoints: [LsvPoint] = []
var lsvPeaks: [LsvPeak] = []
var lsvTotal: UInt16 = 0
var lsvStartV: String = "0"
var lsvStopV: String = "500"
var lsvScanRate: String = "50"
var lsvRtia: LpRtia = .r10K
var lsvDensityMode: LsvDensityMode = .ptsPerMv
var lsvDensity: String = "1"
// Amperometry
var ampPoints: [AmpPoint] = []
var ampTotal: UInt16 = 0
var ampRunning: Bool = false
var ampVHold: String = "200"
var ampInterval: String = "100"
var ampDuration: String = "60"
var ampRtia: LpRtia = .r10K
// Chlorine
var clPoints: [ClPoint] = []
var clResult: ClResult? = nil
var clTotal: UInt16 = 0
var clCondV: String = "800"
var clCondT: String = "2000"
var clFreeV: String = "100"
var clTotalV: String = "-200"
var clDepT: String = "5000"
var clMeasT: String = "5000"
var clRtia: LpRtia = .r10K
var clManualPeaks: Bool = false
var clAutoState: ClAutoState = .idle
var clAutoPotentials: ClPotentials? = nil
// pH
var phResult: PhResult? = nil
var phStabilize: String = "30"
// Reference baselines
var eisRef: [EisPoint]? = nil
var lsvRef: [LsvPoint]? = nil
var ampRef: [AmpPoint]? = nil
var clRef: (points: [ClPoint], result: ClResult)? = nil
var phRef: PhResult? = nil
// Device reference collection
var collectingRefs: Bool = false
var hasDeviceRefs: Bool = false
private var eisRefs: [Int: [EisPoint]] = [:]
private var refMode: UInt8?
private var refRtia: UInt8?
// Session
var currentSessionId: Int64? = nil
var firmwareSessionMap: [UInt8: Int64] = [:]
var sessionListReceived: Bool = false
private var pendingEspTimestamp: Int64? = nil
// Calibration
var calVolumeGal: Double = 25
var calNaclPpm: String = "2500"
var calClPpm: String = "5"
var calBleachPct: String = "7.825"
var calTempC: String = "40"
var calCellConstant: Double? = nil
var calRs: Double? = nil
var clFactor: Double? = nil
var clCalKnownPpm: String = "5"
var phSlope: Double? = nil
var phOffset: Double? = nil
var phCalPoints: [(ph: Double, mV: Double)] = []
var phCalKnown: String = "7.00"
// Clean
var cleanV: String = "1200"
var cleanDur: String = "30"
init() {
transport = UDPManager()
transport.setMessageHandler { [weak self] msg in
self?.handleMessage(msg)
}
transport.setDisconnectHandler { [weak self] in
self?.sessionListReceived = false
self?.firmwareSessionMap.removeAll()
}
}
// MARK: - Send helper
func send(_ sysex: [UInt8]) {
transport.send(sysex)
}
// MARK: - Message handler
private func handleMessage(_ msg: EisMessage) {
switch msg {
case .sweepStart(let numPoints, let freqStart, let freqStop, let espTs, _):
pendingEspTimestamp = espTs.map { Int64($0) }
if collectingRefs {
eisPoints.removeAll()
sweepTotal = numPoints
} else {
eisPoints.removeAll()
sweepTotal = numPoints
status = String(format: "Sweep: %d pts, %.0f--%.0f Hz", numPoints, freqStart, freqStop)
}
case .dataPoint(_, let point):
eisPoints.append(point)
if !collectingRefs {
status = "Receiving: \(eisPoints.count)/\(sweepTotal)"
}
case .sweepEnd:
if collectingRefs {
if let r = refRtia, r < 8 {
eisRefs[Int(r)] = eisPoints
}
eisPoints.removeAll()
} else {
saveEis()
status = "Sweep complete: \(eisPoints.count) points"
}
case .config(let cfg):
freqStart = String(format: "%.0f", cfg.freqStart)
freqStop = String(format: "%.0f", cfg.freqStop)
ppd = "\(cfg.ppd)"
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, let espTs, _):
pendingEspTimestamp = espTs.map { Int64($0) }
lsvPoints.removeAll()
lsvTotal = numPoints
status = String(format: "LSV: %d pts, %.0f--%.0f mV", numPoints, vStart, vStop)
case .lsvPoint(_, let point):
lsvPoints.append(point)
status = "LSV: \(lsvPoints.count)/\(lsvTotal)"
case .lsvEnd:
saveLsv()
lsvPeaks = detectLsvPeaks(lsvPoints)
var st = "LSV complete: \(lsvPoints.count) points"
if let s = phSlope, let o = phOffset, abs(s) > 1e-6 {
if let peak = detectQhqPeak(lsvPoints) {
let ph = (Double(peak) - o) / s
st += String(format: " | pH=%.2f", ph)
}
}
status = st
if clAutoState == .lsvRunning {
let pots = deriveClPotentials(lsvPoints)
clFreeV = String(format: "%.0f", pots.vFree)
clTotalV = String(format: "%.0f", pots.vTotal)
clAutoPotentials = pots
clAutoState = .measureRunning
let vCond = Float(clCondV) ?? 800
let tCond = Float(clCondT) ?? 2000
let tDep = Float(clDepT) ?? 5000
let tMeas = Float(clMeasT) ?? 5000
send(buildSysexGetTemp())
send(buildSysexStartCl(
vCond: vCond, tCondMs: tCond, vFree: pots.vFree, vTotal: pots.vTotal,
tDepMs: tDep, tMeasMs: tMeas, lpRtia: clRtia
))
status = String(format: "Auto Cl: measuring (free=%.0f, total=%.0f)", pots.vFree, pots.vTotal)
}
case .ampStart(let vHold, let espTs, _):
pendingEspTimestamp = espTs.map { Int64($0) }
ampPoints.removeAll()
ampRunning = true
status = String(format: "Amp: %.0f mV", vHold)
case .ampPoint(let index, let point):
ampPoints.append(point)
ampTotal = index + 1
status = "Amp: \(ampPoints.count) pts"
case .ampEnd:
ampRunning = false
saveAmp()
status = "Amp complete: \(ampPoints.count) points"
case .clStart(let numPoints, let espTs, _):
pendingEspTimestamp = espTs.map { Int64($0) }
clPoints.removeAll()
clResult = nil
clTotal = numPoints
status = "Chlorine: \(numPoints) pts"
case .clPoint(_, let point):
clPoints.append(point)
status = "Chlorine: \(clPoints.count)/\(clTotal)"
case .clResult(let r):
clResult = r
status = String(format: "Chlorine: free=%.3f uA, total=%.3f uA", r.iFreeUa, r.iTotalUa)
case .clEnd:
saveCl()
if clAutoState == .measureRunning {
clAutoState = .idle
if let pots = clAutoPotentials {
let fd = pots.vFreeDetected ? "" : " dflt"
let td = pots.vTotalDetected ? "" : " dflt"
status = String(format: "Auto Cl complete: %d pts (free=%.0f%@, total=%.0f%@)",
clPoints.count, pots.vFree, fd, pots.vTotal, td)
} else {
status = "Chlorine complete: \(clPoints.count) points"
}
} else {
status = "Chlorine complete: \(clPoints.count) points"
}
case .phResult(let r):
transport.measuring = false
if collectingRefs {
phRef = r
} else {
savePh(r)
status = String(format: "pH: %.2f (OCP=%.1f mV, T=%.1fC)", r.ph, r.vOcpMv, r.tempC)
phResult = r
}
case .temperature(let t):
tempC = t
case .refFrame(let mode, let rtiaIdx):
refMode = mode
refRtia = rtiaIdx
let modeName: String
switch mode {
case 0: modeName = "EIS"
case 1: modeName = "LSV"
case 2: modeName = "Amp"
case 3: modeName = "Cl"
case 4: modeName = "pH"
default: modeName = "?"
}
if mode == 0 {
status = "Ref: \(modeName) RTIA \(rtiaIdx + 1)/8"
} else {
status = "Ref: \(modeName) range search"
}
case .refLpRange:
break
case .refsDone:
transport.measuring = false
collectingRefs = false
hasDeviceRefs = true
refMode = nil
refRtia = nil
let idx = Int(rtia.rawValue)
if idx < 8, let pts = eisRefs[idx] {
eisRef = pts
}
status = "Reference collection complete"
case .refStatus(let hasRefs):
hasDeviceRefs = hasRefs
if !hasRefs {
status = "No device refs"
}
case .cellK(let k):
calCellConstant = Double(k)
status = String(format: "Device cell constant: %.4f cm\u{207B}\u{00B9}", k)
case .clFactor(let f):
clFactor = Double(f)
status = String(format: "Device Cl factor: %.6f", f)
case .phCal(let slope, let offset):
phSlope = Double(slope)
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() {
let fs = Float(freqStart) ?? 1000
let fe = Float(freqStop) ?? 200000
let p = UInt16(ppd) ?? 10
send(buildSysexSetSweep(freqStart: fs, freqStop: fe, ppd: p))
send(buildSysexSetRtia(rtia))
send(buildSysexSetRcal(rcal))
send(buildSysexSetElectrode(electrode))
send(buildSysexGetConfig())
}
func startSweep() {
eisPoints.removeAll()
send(buildSysexGetTemp())
send(buildSysexStartSweep())
}
func lsvCalcPoints() -> UInt16 {
let vs = Float(lsvStartV) ?? 0
let ve = Float(lsvStopV) ?? 500
let sr = Float(lsvScanRate) ?? 50
let d = Float(lsvDensity) ?? 1
let range = abs(ve - vs)
let raw: Float
switch lsvDensityMode {
case .ptsPerMv:
raw = range * d
case .ptsPerSec:
raw = abs(sr) < 0.001 ? 2 : (range / abs(sr)) * d
}
return max(2, min(500, UInt16(raw)))
}
func startLSV() {
lsvPoints.removeAll()
let vs = Float(lsvStartV) ?? 0
let ve = Float(lsvStopV) ?? 500
let sr = Float(lsvScanRate) ?? 50
let n = lsvCalcPoints()
send(buildSysexGetTemp())
send(buildSysexStartLsv(vStart: vs, vStop: ve, scanRate: sr, lpRtia: lsvRtia, numPoints: n))
}
func startAmp() {
ampPoints.removeAll()
ampRunning = true
let vh = Float(ampVHold) ?? 200
let iv = Float(ampInterval) ?? 100
let dur = Float(ampDuration) ?? 60
send(buildSysexGetTemp())
send(buildSysexStartAmp(vHold: vh, intervalMs: iv, durationS: dur, lpRtia: ampRtia))
}
func stopAmp() {
send(buildSysexStopAmp())
}
func startChlorine() {
clPoints.removeAll()
clResult = nil
let vCond = Float(clCondV) ?? 800
let tCond = Float(clCondT) ?? 2000
let vFree = Float(clFreeV) ?? 100
let vTotal = Float(clTotalV) ?? -200
let tDep = Float(clDepT) ?? 5000
let tMeas = Float(clMeasT) ?? 5000
send(buildSysexGetTemp())
send(buildSysexStartCl(
vCond: vCond, tCondMs: tCond, vFree: vFree, vTotal: vTotal,
tDepMs: tDep, tMeasMs: tMeas, lpRtia: clRtia
))
}
func lsvCalcPointsFor(vStart: Float, vStop: Float, scanRate: Float) -> UInt16 {
let d = Float(lsvDensity) ?? 1
let range = abs(vStop - vStart)
let raw: Float
switch lsvDensityMode {
case .ptsPerMv:
raw = range * d
case .ptsPerSec:
raw = abs(scanRate) < 0.001 ? 2 : (range / abs(scanRate)) * d
}
return max(2, min(500, UInt16(raw)))
}
func startClAuto() {
clAutoState = .lsvRunning
clAutoPotentials = nil
lsvPoints.removeAll()
let n = lsvCalcPointsFor(vStart: -1100, vStop: 1100, scanRate: 50)
send(buildSysexStartLsv(vStart: -1100, vStop: 1100, scanRate: 50, lpRtia: lsvRtia, numPoints: n))
status = "Auto Cl: running LSV sweep..."
}
func startPh() {
phResult = nil
transport.measuring = true
let stab = Float(phStabilize) ?? 30
send(buildSysexGetTemp())
send(buildSysexStartPh(stabilizeS: stab))
}
func setReference() {
switch tab {
case .eis where !eisPoints.isEmpty:
eisRef = eisPoints
status = "EIS reference set (\(eisPoints.count) pts)"
case .lsv where !lsvPoints.isEmpty:
lsvRef = lsvPoints
status = "LSV reference set (\(lsvPoints.count) pts)"
case .amp where !ampPoints.isEmpty:
ampRef = ampPoints
status = "Amp reference set (\(ampPoints.count) pts)"
case .chlorine where !clPoints.isEmpty:
if let r = clResult {
clRef = (clPoints, r)
status = "Chlorine reference set"
}
case .ph:
if let r = phResult {
phRef = r
status = String(format: "pH reference set (%.2f)", r.ph)
}
default:
break
}
}
func clearReference() {
switch tab {
case .eis: eisRef = nil; status = "EIS reference cleared"
case .lsv: lsvRef = nil; status = "LSV reference cleared"
case .amp: ampRef = nil; status = "Amp reference cleared"
case .chlorine: clRef = nil; status = "Chlorine reference cleared"
case .ph: phRef = nil; status = "pH reference cleared"
case .calibrate, .sessions, .connection: break
}
}
func collectRefs() {
collectingRefs = true
transport.measuring = true
eisRefs.removeAll()
status = "Starting reference collection..."
send(buildSysexStartRefs())
}
func getRefs() {
collectingRefs = true
transport.measuring = true
eisRefs.removeAll()
send(buildSysexGetRefs())
}
func clearRefs() {
collectingRefs = false
hasDeviceRefs = false
eisRefs.removeAll()
eisRef = nil
phRef = nil
send(buildSysexClearRefs())
status = "Refs cleared"
}
func startClean() {
let v = Float(cleanV) ?? 1200
let d = Float(cleanDur) ?? 30
transport.measuring = true
send(buildSysexStartClean(vMv: v, durationS: d))
status = String(format: "Cleaning: %.0f mV for %.0fs", v, d)
let t = transport
DispatchQueue.main.asyncAfter(deadline: .now() + Double(d) + 2) {
t.measuring = false
}
}
var hasCurrentRef: Bool {
switch tab {
case .eis: eisRef != nil
case .lsv: lsvRef != nil
case .amp: ampRef != nil
case .chlorine: clRef != nil
case .ph: phRef != nil
case .calibrate, .sessions, .connection: false
}
}
var hasCurrentData: Bool {
switch tab {
case .eis: !eisPoints.isEmpty
case .lsv: !lsvPoints.isEmpty
case .amp: !ampPoints.isEmpty
case .chlorine: clResult != nil
case .ph: phResult != nil
case .calibrate, .sessions, .connection: false
}
}
// MARK: - Auto-save
private func saveEis() {
guard let sid = currentSessionId else { return }
let ts = pendingEspTimestamp
pendingEspTimestamp = nil
let params: [String: String] = [
"freq_start": freqStart,
"freq_stop": freqStop,
"ppd": ppd,
"rtia": rtia.label,
"rcal": rcal.label,
"electrode": electrode.label,
]
guard let configData = try? JSONEncoder().encode(params) 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) }
try? Storage.shared.addDataPoints(measurementId: mid, points: indexed)
}
private func saveLsv() {
guard let sid = currentSessionId else { return }
let ts = pendingEspTimestamp
pendingEspTimestamp = nil
let params: [String: String] = [
"v_start": lsvStartV,
"v_stop": lsvStopV,
"scan_rate": lsvScanRate,
"rtia": lsvRtia.label,
]
guard let configData = try? JSONEncoder().encode(params) 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) }
try? Storage.shared.addDataPoints(measurementId: mid, points: indexed)
}
private func saveAmp() {
guard let sid = currentSessionId else { return }
let ts = pendingEspTimestamp
pendingEspTimestamp = nil
let params: [String: String] = [
"v_hold": ampVHold,
"interval_ms": ampInterval,
"duration_s": ampDuration,
"rtia": ampRtia.label,
]
guard let configData = try? JSONEncoder().encode(params) 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) }
try? Storage.shared.addDataPoints(measurementId: mid, points: indexed)
}
private func saveCl() {
guard let sid = currentSessionId else { return }
let ts = pendingEspTimestamp
pendingEspTimestamp = nil
let params: [String: String] = [
"cond_v": clCondV,
"cond_t": clCondT,
"free_v": clFreeV,
"total_v": clTotalV,
"dep_t": clDepT,
"meas_t": clMeasT,
"rtia": clRtia.label,
]
guard let configData = try? JSONEncoder().encode(params) 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) }
try? Storage.shared.addDataPoints(measurementId: mid, points: indexed)
if let r = clResult {
try? Storage.shared.setMeasurementResult(mid, result: r)
}
}
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, espTimestamp: ts) else { return }
meas.config = configData
guard let mid = meas.id else { return }
try? Storage.shared.addDataPoint(measurementId: mid, index: 0, point: result)
try? Storage.shared.setMeasurementResult(mid, result: result)
}
// MARK: - Measurement loading
func loadMeasurement(_ measurement: Measurement) {
guard let id = measurement.id,
let type = MeasurementType(rawValue: measurement.type) else { return }
do {
switch type {
case .eis:
eisPoints = try Storage.shared.fetchTypedPoints(measurementId: id, as: EisPoint.self)
tab = .eis
status = "Loaded EIS (\(eisPoints.count) pts)"
case .lsv:
lsvPoints = try Storage.shared.fetchTypedPoints(measurementId: id, as: LsvPoint.self)
tab = .lsv
status = "Loaded LSV (\(lsvPoints.count) pts)"
case .amp:
ampPoints = try Storage.shared.fetchTypedPoints(measurementId: id, as: AmpPoint.self)
tab = .amp
status = "Loaded Amp (\(ampPoints.count) pts)"
case .chlorine:
clPoints = try Storage.shared.fetchTypedPoints(measurementId: id, as: ClPoint.self)
if let summary = measurement.resultSummary {
clResult = try JSONDecoder().decode(ClResult.self, from: summary)
}
tab = .chlorine
status = "Loaded Chlorine (\(clPoints.count) pts)"
case .ph:
if let summary = measurement.resultSummary {
phResult = try JSONDecoder().decode(PhResult.self, from: summary)
}
tab = .ph
status = "Loaded pH result"
}
} catch {
status = "Load failed: \(error.localizedDescription)"
}
}
func loadAsReference(_ measurement: Measurement) {
guard let id = measurement.id,
let type = MeasurementType(rawValue: measurement.type) else { return }
do {
switch type {
case .eis:
eisRef = try Storage.shared.fetchTypedPoints(measurementId: id, as: EisPoint.self)
status = "EIS reference loaded (\(eisRef?.count ?? 0) pts)"
case .lsv:
lsvRef = try Storage.shared.fetchTypedPoints(measurementId: id, as: LsvPoint.self)
status = "LSV reference loaded (\(lsvRef?.count ?? 0) pts)"
case .amp:
ampRef = try Storage.shared.fetchTypedPoints(measurementId: id, as: AmpPoint.self)
status = "Amp reference loaded (\(ampRef?.count ?? 0) pts)"
case .chlorine:
let pts = try Storage.shared.fetchTypedPoints(measurementId: id, as: ClPoint.self)
if let summary = measurement.resultSummary {
let result = try JSONDecoder().decode(ClResult.self, from: summary)
clRef = (pts, result)
status = "Chlorine reference loaded"
}
case .ph:
if let summary = measurement.resultSummary {
phRef = try JSONDecoder().decode(PhResult.self, from: summary)
status = String(format: "pH reference loaded (%.2f)", phRef?.ph ?? 0)
}
}
} catch {
status = "Reference load failed: \(error.localizedDescription)"
}
}
}