cue-ios: wire BLEManager to AppState -- commands, message handling, auto-save
This commit is contained in:
parent
0ff998c82c
commit
e12207b6e7
|
|
@ -1,5 +1,6 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
|
import Combine
|
||||||
|
|
||||||
enum Tab: String, CaseIterable, Identifiable {
|
enum Tab: String, CaseIterable, Identifiable {
|
||||||
case eis = "EIS"
|
case eis = "EIS"
|
||||||
|
|
@ -16,6 +17,7 @@ enum Tab: String, CaseIterable, Identifiable {
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class AppState {
|
final class AppState {
|
||||||
|
let ble: BLEManager
|
||||||
var tab: Tab = .eis
|
var tab: Tab = .eis
|
||||||
var status: String = "Disconnected"
|
var status: String = "Disconnected"
|
||||||
var bleConnected: Bool = false
|
var bleConnected: Bool = false
|
||||||
|
|
@ -74,52 +76,255 @@ final class AppState {
|
||||||
// Device reference collection
|
// Device reference collection
|
||||||
var collectingRefs: Bool = false
|
var collectingRefs: Bool = false
|
||||||
var hasDeviceRefs: 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
|
// Clean
|
||||||
var cleanV: String = "1200"
|
var cleanV: String = "1200"
|
||||||
var cleanDur: String = "30"
|
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
|
// MARK: - Actions
|
||||||
|
|
||||||
func applyEISSettings() {
|
func applyEISSettings() {
|
||||||
let fs = Float(freqStart) ?? 1000
|
let fs = Float(freqStart) ?? 1000
|
||||||
let fe = Float(freqStop) ?? 200000
|
let fe = Float(freqStop) ?? 200000
|
||||||
let p = UInt16(ppd) ?? 10
|
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() {
|
func startSweep() {
|
||||||
eisPoints.removeAll()
|
eisPoints.removeAll()
|
||||||
status = "Starting sweep..."
|
send(buildSysexGetTemp())
|
||||||
|
send(buildSysexStartSweep())
|
||||||
}
|
}
|
||||||
|
|
||||||
func startLSV() {
|
func startLSV() {
|
||||||
lsvPoints.removeAll()
|
lsvPoints.removeAll()
|
||||||
let vs = Float(lsvStartV) ?? 0
|
let vs = Float(lsvStartV) ?? 0
|
||||||
let ve = Float(lsvStopV) ?? 500
|
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() {
|
func startAmp() {
|
||||||
ampPoints.removeAll()
|
ampPoints.removeAll()
|
||||||
ampRunning = true
|
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() {
|
func stopAmp() {
|
||||||
ampRunning = false
|
send(buildSysexStopAmp())
|
||||||
status = "Stopping amperometry..."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func startChlorine() {
|
func startChlorine() {
|
||||||
clPoints.removeAll()
|
clPoints.removeAll()
|
||||||
clResult = nil
|
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() {
|
func startPh() {
|
||||||
phResult = nil
|
phResult = nil
|
||||||
status = "Starting pH measurement..."
|
let stab = Float(phStabilize) ?? 30
|
||||||
|
send(buildSysexGetTemp())
|
||||||
|
send(buildSysexStartPh(stabilizeS: stab))
|
||||||
}
|
}
|
||||||
|
|
||||||
func setReference() {
|
func setReference() {
|
||||||
|
|
@ -161,23 +366,31 @@ final class AppState {
|
||||||
|
|
||||||
func collectRefs() {
|
func collectRefs() {
|
||||||
collectingRefs = true
|
collectingRefs = true
|
||||||
|
eisRefs.removeAll()
|
||||||
status = "Starting reference collection..."
|
status = "Starting reference collection..."
|
||||||
|
send(buildSysexStartRefs())
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRefs() {
|
||||||
|
collectingRefs = true
|
||||||
|
eisRefs.removeAll()
|
||||||
|
send(buildSysexGetRefs())
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearRefs() {
|
func clearRefs() {
|
||||||
collectingRefs = false
|
collectingRefs = false
|
||||||
hasDeviceRefs = false
|
hasDeviceRefs = false
|
||||||
|
eisRefs.removeAll()
|
||||||
eisRef = nil
|
eisRef = nil
|
||||||
lsvRef = nil
|
|
||||||
ampRef = nil
|
|
||||||
clRef = nil
|
|
||||||
phRef = nil
|
phRef = nil
|
||||||
|
send(buildSysexClearRefs())
|
||||||
status = "Refs cleared"
|
status = "Refs cleared"
|
||||||
}
|
}
|
||||||
|
|
||||||
func startClean() {
|
func startClean() {
|
||||||
let v = Float(cleanV) ?? 1200
|
let v = Float(cleanV) ?? 1200
|
||||||
let d = Float(cleanDur) ?? 30
|
let d = Float(cleanDur) ?? 30
|
||||||
|
send(buildSysexStartClean(vMv: v, durationS: d))
|
||||||
status = String(format: "Cleaning: %.0f mV for %.0fs", v, 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
|
// MARK: - Measurement loading
|
||||||
|
|
||||||
func loadMeasurement(_ measurement: Measurement) {
|
func loadMeasurement(_ measurement: Measurement) {
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,14 @@ import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct CueIOSApp: App {
|
struct CueIOSApp: App {
|
||||||
@State private var ble = BLEManager()
|
@State private var state = AppState()
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView(state: state)
|
||||||
|
.onReceive(Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()) { _ in
|
||||||
|
state.updateConnectionState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@State private var state = AppState()
|
@Bindable var state: AppState
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue