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 // 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) } } // 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): 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 status = "Config received" case .lsvStart(let numPoints, let vStart, let vStop): 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): 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): 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 .keepalive: break } } // 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) DispatchQueue.main.asyncAfter(deadline: .now() + Double(d) + 2) { [weak self] in self?.transport.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 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) 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 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) 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 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) 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 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) 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 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 } 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)" } } }