diff --git a/cue-ios/CueIOS/AppState.swift b/cue-ios/CueIOS/AppState.swift index be80fca..ca48bee 100644 --- a/cue-ios/CueIOS/AppState.swift +++ b/cue-ios/CueIOS/AppState.swift @@ -1,5 +1,6 @@ import Foundation import Observation +import Combine enum Tab: String, CaseIterable, Identifiable { case eis = "EIS" @@ -16,6 +17,7 @@ enum Tab: String, CaseIterable, Identifiable { @Observable final class AppState { + let ble: BLEManager var tab: Tab = .eis var status: String = "Disconnected" var bleConnected: Bool = false @@ -74,52 +76,255 @@ final class AppState { // 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 // Clean var cleanV: String = "1200" var cleanDur: String = "30" + // Temperature polling + private var tempTimer: Timer? + + init() { + ble = BLEManager() + ble.setMessageHandler { [weak self] msg in + self?.handleMessage(msg) + } + startTempPolling() + } + + // MARK: - BLE connection tracking + + func updateConnectionState() { + let connected = ble.state == .connected + if connected != bleConnected { + bleConnected = connected + status = ble.state.rawValue + } + } + + // MARK: - Temperature polling + + private func startTempPolling() { + tempTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + guard let self, self.bleConnected else { return } + self.send(buildSysexGetTemp()) + } + } + + // MARK: - Send helper + + func send(_ sysex: [UInt8]) { + ble.sendCommand(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() + status = "LSV complete: \(lsvPoints.count) points" + + 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() + status = "Chlorine complete: \(clPoints.count) points" + + case .phResult(let r): + 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: + 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" + } + } + } + // MARK: - Actions func applyEISSettings() { let fs = Float(freqStart) ?? 1000 let fe = Float(freqStop) ?? 200000 let p = UInt16(ppd) ?? 10 - status = "Applying: \(fs)-\(fe) Hz, \(p) PPD" + send(buildSysexSetSweep(freqStart: fs, freqStop: fe, ppd: p)) + send(buildSysexSetRtia(rtia)) + send(buildSysexSetRcal(rcal)) + send(buildSysexSetElectrode(electrode)) + send(buildSysexGetConfig()) } func startSweep() { eisPoints.removeAll() - status = "Starting sweep..." + send(buildSysexGetTemp()) + send(buildSysexStartSweep()) } func startLSV() { lsvPoints.removeAll() let vs = Float(lsvStartV) ?? 0 let ve = Float(lsvStopV) ?? 500 - status = "Starting LSV: \(vs)-\(ve) mV" + let sr = Float(lsvScanRate) ?? 50 + send(buildSysexGetTemp()) + send(buildSysexStartLsv(vStart: vs, vStop: ve, scanRate: sr, lpRtia: lsvRtia)) } func startAmp() { ampPoints.removeAll() ampRunning = true - status = "Starting amperometry..." + 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() { - ampRunning = false - status = "Stopping amperometry..." + send(buildSysexStopAmp()) } func startChlorine() { clPoints.removeAll() clResult = nil - status = "Starting chlorine measurement..." + 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 startPh() { phResult = nil - status = "Starting pH measurement..." + let stab = Float(phStabilize) ?? 30 + send(buildSysexGetTemp()) + send(buildSysexStartPh(stabilizeS: stab)) } func setReference() { @@ -161,23 +366,31 @@ final class AppState { func collectRefs() { collectingRefs = true + eisRefs.removeAll() status = "Starting reference collection..." + send(buildSysexStartRefs()) + } + + func getRefs() { + collectingRefs = true + eisRefs.removeAll() + send(buildSysexGetRefs()) } func clearRefs() { collectingRefs = false hasDeviceRefs = false + eisRefs.removeAll() eisRef = nil - lsvRef = nil - ampRef = nil - clRef = nil phRef = nil + send(buildSysexClearRefs()) status = "Refs cleared" } func startClean() { let v = Float(cleanV) ?? 1200 let d = Float(cleanDur) ?? 30 + send(buildSysexStartClean(vMv: v, durationS: d)) status = String(format: "Cleaning: %.0f mV for %.0fs", v, d) } @@ -203,6 +416,93 @@ final class AppState { } } + // 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) { diff --git a/cue-ios/CueIOS/CueIOSApp.swift b/cue-ios/CueIOS/CueIOSApp.swift index 07a8c00..6198e4a 100644 --- a/cue-ios/CueIOS/CueIOSApp.swift +++ b/cue-ios/CueIOS/CueIOSApp.swift @@ -2,11 +2,14 @@ import SwiftUI @main struct CueIOSApp: App { - @State private var ble = BLEManager() + @State private var state = AppState() var body: some Scene { WindowGroup { - ContentView() + ContentView(state: state) + .onReceive(Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()) { _ in + state.updateConnectionState() + } } } } diff --git a/cue-ios/CueIOS/Views/ContentView.swift b/cue-ios/CueIOS/Views/ContentView.swift index 41c9996..28e4c59 100644 --- a/cue-ios/CueIOS/Views/ContentView.swift +++ b/cue-ios/CueIOS/Views/ContentView.swift @@ -1,7 +1,7 @@ import SwiftUI struct ContentView: View { - @State private var state = AppState() + @Bindable var state: AppState @Environment(\.horizontalSizeClass) private var sizeClass var body: some View {