Merge branch 'ios-session-sync'

This commit is contained in:
jess 2026-04-03 07:18:54 -07:00
commit e4db734098
14 changed files with 1120 additions and 80 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

@ -11,6 +11,7 @@ struct Session: Codable, FetchableRecord, MutablePersistableRecord {
var startedAt: Date
var label: String?
var notes: String?
var firmwareSessionId: Int64?
mutating func didInsert(_ inserted: InsertionSuccess) {
id = inserted.rowID
@ -24,6 +25,7 @@ struct Measurement: Codable, FetchableRecord, MutablePersistableRecord {
var startedAt: Date
var config: Data?
var resultSummary: Data?
var espTimestamp: Int64?
mutating func didInsert(_ inserted: InsertionSuccess) {
id = inserted.rowID
@ -89,19 +91,34 @@ final class Storage: @unchecked Sendable {
}
}
migrator.registerMigration("v2") { db in
try db.alter(table: "measurement") { t in
t.add(column: "espTimestamp", .integer)
}
try db.alter(table: "session") { t in
t.add(column: "firmwareSessionId", .integer)
}
}
try migrator.migrate(dbQueue)
}
// MARK: - Sessions
func createSession(label: String? = nil) throws -> Session {
func createSession(label: String? = nil, firmwareSessionId: Int64? = nil) throws -> Session {
try dbQueue.write { db in
var s = Session(startedAt: Date(), label: label)
var s = Session(startedAt: Date(), label: label, firmwareSessionId: firmwareSessionId)
try s.insert(db)
return s
}
}
func fetchSession(_ id: Int64) -> Session? {
try? dbQueue.read { db in
try Session.fetchOne(db, key: id)
}
}
func fetchSessions() throws -> [Session] {
try dbQueue.read { db in
try Session.order(Column("startedAt").desc).fetchAll(db)
@ -123,13 +140,43 @@ final class Storage: @unchecked Sendable {
}
}
func updateSessionLabel(_ id: Int64, label: String) throws {
try dbQueue.write { db in
try db.execute(
sql: "UPDATE session SET label = ? WHERE id = ?",
arguments: [label, id]
)
}
}
func sessionByFirmwareId(_ fwId: Int64) -> Session? {
try? dbQueue.read { db in
try Session
.filter(Column("firmwareSessionId") == fwId)
.fetchOne(db)
}
}
// MARK: - Measurements
func measurementExists(sessionId: Int64, espTimestamp: Int64) -> Bool {
(try? dbQueue.read { db in
try Measurement
.filter(Column("sessionId") == sessionId)
.filter(Column("espTimestamp") == espTimestamp)
.fetchCount(db) > 0
}) ?? false
}
func addMeasurement(
sessionId: Int64,
type: MeasurementType,
config: (any Encodable)? = nil
config: (any Encodable)? = nil,
espTimestamp: Int64? = nil
) throws -> Measurement {
if let ts = espTimestamp, measurementExists(sessionId: sessionId, espTimestamp: ts) {
throw StorageError.duplicate
}
let configData: Data? = if let config {
try JSONEncoder().encode(config)
} else {
@ -140,7 +187,8 @@ final class Storage: @unchecked Sendable {
sessionId: sessionId,
type: type.rawValue,
startedAt: Date(),
config: configData
config: configData,
espTimestamp: espTimestamp
)
try m.insert(db)
return m
@ -460,6 +508,7 @@ final class Storage: @unchecked Sendable {
enum StorageError: Error {
case notFound
case duplicate
case parseError(String)
}

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

View File

@ -0,0 +1,641 @@
/// Measurement data viewer switches on type to show appropriate charts.
import SwiftUI
import Charts
// MARK: - Router
struct MeasurementDataView: View {
let measurement: Measurement
@State private var points: [DataPoint] = []
@State private var loaded = false
var body: some View {
Group {
if !loaded {
ProgressView()
} else {
content
}
}
.navigationTitle(typeLabel)
.onAppear { loadPoints() }
}
private func loadPoints() {
guard let mid = measurement.id else { return }
points = (try? Storage.shared.fetchDataPoints(measurementId: mid)) ?? []
loaded = true
}
@ViewBuilder
private var content: some View {
switch MeasurementType(rawValue: measurement.type) {
case .eis:
EisDataView(points: decodePoints(EisPoint.self))
case .lsv:
LsvDataView(points: decodePoints(LsvPoint.self))
case .amp:
AmpDataView(points: decodePoints(AmpPoint.self))
case .chlorine:
ClDataView(
points: decodePoints(ClPoint.self),
result: decodeResult(ClResult.self)
)
case .ph:
PhDataView(result: decodeResult(PhResult.self))
case nil:
Text("Unknown type: \(measurement.type)")
.foregroundStyle(.secondary)
}
}
private var typeLabel: String {
switch measurement.type {
case "eis": "EIS"
case "lsv": "LSV"
case "amp": "Amperometry"
case "chlorine": "Chlorine"
case "ph": "pH"
default: measurement.type
}
}
private func decodePoints<T: Decodable>(_ type: T.Type) -> [T] {
let decoder = JSONDecoder()
return points.compactMap { try? decoder.decode(T.self, from: $0.payload) }
}
private func decodeResult<T: Decodable>(_ type: T.Type) -> T? {
guard let data = measurement.resultSummary else { return nil }
return try? JSONDecoder().decode(T.self, from: data)
}
}
// MARK: - EIS data view
enum EisPlotMode: String, CaseIterable, Identifiable {
case nyquist = "Nyquist"
case bodeMag = "Bode |Z|"
case bodePhase = "Bode Phase"
var id: String { rawValue }
}
struct EisDataView: View {
let points: [EisPoint]
@State private var plotMode: EisPlotMode = .nyquist
@State private var showTable = false
var body: some View {
VStack(spacing: 0) {
Picker("Plot", selection: $plotMode) {
ForEach(EisPlotMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.vertical, 8)
Divider()
if points.isEmpty {
noData
} else {
plotView
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
if showTable && !points.isEmpty {
Divider()
eisTable
.frame(maxHeight: 250)
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(showTable ? "Hide Table" : "Show Table") {
showTable.toggle()
}
}
}
}
@ViewBuilder
private var plotView: some View {
switch plotMode {
case .nyquist:
nyquistChart
.padding()
case .bodeMag:
bodeMagChart
.padding()
case .bodePhase:
bodePhaseChart
.padding()
}
}
private var nyquistChart: some View {
Chart {
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
PointMark(
x: .value("Z'", Double(pt.zReal)),
y: .value("-Z''", Double(-pt.zImag))
)
.foregroundStyle(Color(red: 0.4, green: 1, blue: 0.4))
.symbolSize(20)
}
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
LineMark(
x: .value("Z'", Double(pt.zReal)),
y: .value("-Z''", Double(-pt.zImag))
)
.foregroundStyle(Color(red: 0.4, green: 1, blue: 0.4).opacity(0.6))
.lineStyle(StrokeStyle(lineWidth: 1.5))
}
}
.chartXAxisLabel("Z' (Ohm)")
.chartYAxisLabel("-Z'' (Ohm)")
.chartXAxis {
AxisMarks { _ in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
.foregroundStyle(Color.gray.opacity(0.3))
AxisValueLabel()
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.chartYAxis {
AxisMarks(position: .leading) { _ in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
.foregroundStyle(Color.gray.opacity(0.3))
AxisValueLabel()
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
private var bodeMagChart: some View {
Chart {
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
LineMark(
x: .value("Freq", log10(max(Double(pt.freqHz), 1))),
y: .value("|Z|", log10(max(Double(pt.magOhms), 0.01)))
)
.foregroundStyle(Color.cyan)
.lineStyle(StrokeStyle(lineWidth: 2))
}
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
PointMark(
x: .value("Freq", log10(max(Double(pt.freqHz), 1))),
y: .value("|Z|", log10(max(Double(pt.magOhms), 0.01)))
)
.foregroundStyle(Color.cyan)
.symbolSize(16)
}
}
.chartXAxisLabel("log10(Freq Hz)")
.chartYAxisLabel("log10(|Z| Ohm)")
.chartXAxis {
AxisMarks { _ in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
.foregroundStyle(Color.gray.opacity(0.3))
AxisValueLabel()
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.chartYAxis {
AxisMarks(position: .leading) { _ in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
.foregroundStyle(Color.gray.opacity(0.3))
AxisValueLabel()
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
private var bodePhaseChart: some View {
Chart {
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
LineMark(
x: .value("Freq", log10(max(Double(pt.freqHz), 1))),
y: .value("Phase", Double(pt.phaseDeg))
)
.foregroundStyle(Color.orange)
.lineStyle(StrokeStyle(lineWidth: 2))
}
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
PointMark(
x: .value("Freq", log10(max(Double(pt.freqHz), 1))),
y: .value("Phase", Double(pt.phaseDeg))
)
.foregroundStyle(Color.orange)
.symbolSize(16)
}
}
.chartXAxisLabel("log10(Freq Hz)")
.chartYAxisLabel("Phase (deg)")
.chartXAxis {
AxisMarks { _ in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
.foregroundStyle(Color.gray.opacity(0.3))
AxisValueLabel()
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.chartYAxis {
AxisMarks(position: .leading) { _ in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
.foregroundStyle(Color.gray.opacity(0.3))
AxisValueLabel()
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
private var eisTable: some View {
MeasurementTable(
columns: [
MeasurementColumn(header: "Freq (Hz)", width: 80, alignment: .trailing),
MeasurementColumn(header: "|Z| (Ohm)", width: 90, alignment: .trailing),
MeasurementColumn(header: "Phase", width: 70, alignment: .trailing),
MeasurementColumn(header: "Re", width: 80, alignment: .trailing),
MeasurementColumn(header: "Im", width: 80, alignment: .trailing),
],
rows: points,
cellText: { pt, col in
switch col {
case 0: String(format: "%.1f", pt.freqHz)
case 1: String(format: "%.2f", pt.magOhms)
case 2: String(format: "%.2f", pt.phaseDeg)
case 3: String(format: "%.2f", pt.zReal)
case 4: String(format: "%.2f", pt.zImag)
default: ""
}
}
)
}
private var noData: some View {
Text("No data points")
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - LSV data view
struct LsvDataView: View {
let points: [LsvPoint]
@State private var showTable = false
var body: some View {
VStack(spacing: 0) {
if points.isEmpty {
noData
} else {
ivChart
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
if showTable && !points.isEmpty {
Divider()
lsvTable
.frame(maxHeight: 250)
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(showTable ? "Hide Table" : "Show Table") {
showTable.toggle()
}
}
}
}
private var ivChart: some View {
Chart {
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
LineMark(
x: .value("V", Double(pt.vMv)),
y: .value("I", Double(pt.iUa))
)
.foregroundStyle(Color.yellow)
.lineStyle(StrokeStyle(lineWidth: 2))
}
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
PointMark(
x: .value("V", Double(pt.vMv)),
y: .value("I", Double(pt.iUa))
)
.foregroundStyle(Color.yellow)
.symbolSize(16)
}
}
.chartXAxisLabel("Voltage (mV)")
.chartYAxisLabel("Current (uA)")
.chartXAxis {
AxisMarks { _ in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
.foregroundStyle(Color.gray.opacity(0.3))
AxisValueLabel()
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.chartYAxis {
AxisMarks(position: .leading) { _ in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
.foregroundStyle(Color.gray.opacity(0.3))
AxisValueLabel()
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
private var lsvTable: some View {
MeasurementTable(
columns: [
MeasurementColumn(header: "V (mV)", width: 80, alignment: .trailing),
MeasurementColumn(header: "I (uA)", width: 90, alignment: .trailing),
],
rows: points,
cellText: { pt, col in
switch col {
case 0: String(format: "%.1f", pt.vMv)
case 1: String(format: "%.3f", pt.iUa)
default: ""
}
}
)
}
private var noData: some View {
Text("No data points")
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Amperometry data view
struct AmpDataView: View {
let points: [AmpPoint]
@State private var showTable = false
var body: some View {
VStack(spacing: 0) {
if points.isEmpty {
noData
} else {
ampChart
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
if showTable && !points.isEmpty {
Divider()
ampTable
.frame(maxHeight: 250)
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(showTable ? "Hide Table" : "Show Table") {
showTable.toggle()
}
}
}
}
private var ampChart: some View {
let ampColor = Color(red: 0.6, green: 0.6, blue: 1.0)
return Chart {
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
LineMark(
x: .value("t", Double(pt.tMs)),
y: .value("I", Double(pt.iUa))
)
.foregroundStyle(ampColor)
.lineStyle(StrokeStyle(lineWidth: 2))
}
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
PointMark(
x: .value("t", Double(pt.tMs)),
y: .value("I", Double(pt.iUa))
)
.foregroundStyle(ampColor)
.symbolSize(16)
}
}
.chartXAxisLabel("Time (ms)")
.chartYAxisLabel("Current (uA)")
.chartXAxis {
AxisMarks { _ in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
.foregroundStyle(Color.gray.opacity(0.3))
AxisValueLabel()
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.chartYAxis {
AxisMarks(position: .leading) { _ in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
.foregroundStyle(Color.gray.opacity(0.3))
AxisValueLabel()
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
private var ampTable: some View {
MeasurementTable(
columns: [
MeasurementColumn(header: "t (ms)", width: 80, alignment: .trailing),
MeasurementColumn(header: "I (uA)", width: 90, alignment: .trailing),
],
rows: points,
cellText: { pt, col in
switch col {
case 0: String(format: "%.1f", pt.tMs)
case 1: String(format: "%.3f", pt.iUa)
default: ""
}
}
)
}
private var noData: some View {
Text("No data points")
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Chlorine data view
struct ClDataView: View {
let points: [ClPoint]
let result: ClResult?
@State private var showTable = false
var body: some View {
VStack(spacing: 0) {
if let r = result {
resultBanner(r)
}
if points.isEmpty {
noData
} else {
clChart
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
if showTable && !points.isEmpty {
Divider()
clTable
.frame(maxHeight: 250)
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(showTable ? "Hide Table" : "Show Table") {
showTable.toggle()
}
}
}
}
private func resultBanner(_ r: ClResult) -> some View {
HStack(spacing: 16) {
Text(String(format: "Free: %.3f uA", r.iFreeUa))
.foregroundStyle(Color(red: 0.2, green: 1, blue: 0.5))
Text(String(format: "Total: %.3f uA", r.iTotalUa))
.foregroundStyle(Color(red: 1, green: 0.6, blue: 0.2))
Text(String(format: "Combined: %.3f uA", r.iTotalUa - r.iFreeUa))
.foregroundStyle(.secondary)
}
.font(.subheadline.monospacedDigit())
.padding(.horizontal)
.padding(.vertical, 6)
}
private var clChart: some View {
Chart {
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
LineMark(
x: .value("t", Double(pt.tMs)),
y: .value("I", Double(pt.iUa))
)
.foregroundStyle(by: .value("Phase", phaseLabel(pt.phase)))
.lineStyle(StrokeStyle(lineWidth: 2))
}
}
.chartForegroundStyleScale([
"Conditioning": Color.gray,
"Free": Color(red: 0.2, green: 1, blue: 0.5),
"Total": Color(red: 1, green: 0.6, blue: 0.2),
])
.chartXAxisLabel("Time (ms)")
.chartYAxisLabel("Current (uA)")
.chartXAxis {
AxisMarks { _ in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
.foregroundStyle(Color.gray.opacity(0.3))
AxisValueLabel()
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.chartYAxis {
AxisMarks(position: .leading) { _ in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
.foregroundStyle(Color.gray.opacity(0.3))
AxisValueLabel()
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.chartLegend(position: .top)
}
private func phaseLabel(_ phase: UInt8) -> String {
switch phase {
case 1: "Free"
case 2: "Total"
default: "Conditioning"
}
}
private var clTable: some View {
MeasurementTable(
columns: [
MeasurementColumn(header: "t (ms)", width: 80, alignment: .trailing),
MeasurementColumn(header: "I (uA)", width: 90, alignment: .trailing),
MeasurementColumn(header: "Phase", width: 70, alignment: .trailing),
],
rows: points,
cellText: { pt, col in
switch col {
case 0: String(format: "%.1f", pt.tMs)
case 1: String(format: "%.3f", pt.iUa)
case 2: phaseLabel(pt.phase)
default: ""
}
}
)
}
private var noData: some View {
Text("No data points")
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - pH data view
struct PhDataView: View {
let result: PhResult?
var body: some View {
VStack(spacing: 0) {
if let r = result {
VStack(alignment: .leading, spacing: 12) {
Text(String(format: "pH: %.2f", r.ph))
.font(.system(size: 56, weight: .bold, design: .rounded))
.foregroundStyle(.primary)
Text(String(format: "OCP: %.1f mV", r.vOcpMv))
.font(.title3)
.foregroundStyle(.secondary)
Text(String(format: "Temperature: %.1f C", r.tempC))
.font(.title3)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.padding()
} else {
Text("No pH result")
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
}

View File

@ -197,10 +197,12 @@ struct SessionDetailView: View {
@State private var exportFileURL: URL?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
measurementsList
NavigationStack {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
measurementsList
}
}
.onAppear { loadMeasurements() }
.onChange(of: session.id) { loadMeasurements() }
@ -308,7 +310,11 @@ struct SessionDetailView: View {
} else {
List {
ForEach(measurements, id: \.id) { meas in
MeasurementRow(measurement: meas, state: state)
NavigationLink {
MeasurementDataView(measurement: meas)
} label: {
MeasurementRow(measurement: meas, state: state)
}
}
.onDelete { indices in
for idx in indices {

View File

@ -255,6 +255,9 @@ pub struct App {
ph_result: Option<PhResult>,
ph_stabilize: String,
/* measurement dedup */
current_esp_ts: Option<u32>,
/* Reference baselines */
eis_ref: Option<Vec<EisPoint>>,
lsv_ref: Option<Vec<LsvPoint>>,
@ -501,6 +504,8 @@ impl App {
ph_result: None,
ph_stabilize: "30".into(),
current_esp_ts: None,
eis_ref: None,
lsv_ref: None,
amp_ref: None,
@ -556,7 +561,7 @@ impl App {
"rcal": format!("{}", self.rcal),
"electrode": format!("{}", self.electrode),
});
if let Ok(mid) = self.storage.create_measurement(session_id, "eis", &params.to_string()) {
if let Ok(mid) = self.storage.create_measurement(session_id, "eis", &params.to_string(), self.current_esp_ts) {
let pts: Vec<(i32, String)> = self.eis_points.iter().enumerate()
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
.collect();
@ -571,7 +576,7 @@ impl App {
"scan_rate": self.lsv_scan_rate,
"rtia": format!("{}", self.lsv_rtia),
});
if let Ok(mid) = self.storage.create_measurement(session_id, "lsv", &params.to_string()) {
if let Ok(mid) = self.storage.create_measurement(session_id, "lsv", &params.to_string(), self.current_esp_ts) {
let pts: Vec<(i32, String)> = self.lsv_points.iter().enumerate()
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
.collect();
@ -586,7 +591,7 @@ impl App {
"duration_s": self.amp_duration,
"rtia": format!("{}", self.amp_rtia),
});
if let Ok(mid) = self.storage.create_measurement(session_id, "amp", &params.to_string()) {
if let Ok(mid) = self.storage.create_measurement(session_id, "amp", &params.to_string(), self.current_esp_ts) {
let pts: Vec<(i32, String)> = self.amp_points.iter().enumerate()
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
.collect();
@ -604,7 +609,7 @@ impl App {
"meas_t": self.cl_meas_t,
"rtia": format!("{}", self.cl_rtia),
});
if let Ok(mid) = self.storage.create_measurement(session_id, "chlorine", &params.to_string()) {
if let Ok(mid) = self.storage.create_measurement(session_id, "chlorine", &params.to_string(), self.current_esp_ts) {
let mut pts: Vec<(i32, String)> = self.cl_points.iter().enumerate()
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
.collect();
@ -621,7 +626,7 @@ impl App {
let params = serde_json::json!({
"stabilize_s": self.ph_stabilize,
});
if let Ok(mid) = self.storage.create_measurement(session_id, "ph", &params.to_string()) {
if let Ok(mid) = self.storage.create_measurement(session_id, "ph", &params.to_string(), self.current_esp_ts) {
if let Ok(j) = serde_json::to_string(result) {
let _ = self.storage.add_data_point(mid, 0, &j);
}
@ -646,9 +651,9 @@ impl App {
self.status = s;
}
Message::DeviceData(msg) => match msg {
EisMessage::SweepStart { num_points, freq_start, freq_stop } => {
EisMessage::SweepStart { num_points, freq_start, freq_stop, esp_timestamp, .. } => {
self.current_esp_ts = esp_timestamp;
if self.collecting_refs {
/* ref collection: clear temp buffer */
self.eis_points.clear();
self.sweep_total = num_points;
} else {
@ -691,7 +696,8 @@ impl App {
self.electrode = cfg.electrode;
self.status = "Config received".into();
}
EisMessage::LsvStart { num_points, v_start, v_stop } => {
EisMessage::LsvStart { num_points, v_start, v_stop, esp_timestamp, .. } => {
self.current_esp_ts = esp_timestamp;
self.lsv_points.clear();
self.lsv_total = num_points;
self.lsv_data = text_editor::Content::with_text(&fmt_lsv(&self.lsv_points));
@ -739,7 +745,8 @@ impl App {
);
}
}
EisMessage::AmpStart { v_hold } => {
EisMessage::AmpStart { v_hold, esp_timestamp, .. } => {
self.current_esp_ts = esp_timestamp;
self.amp_points.clear();
self.amp_running = true;
self.amp_data = text_editor::Content::with_text(&fmt_amp(&self.amp_points));
@ -758,7 +765,8 @@ impl App {
}
self.status = format!("Amp complete: {} points", self.amp_points.len());
}
EisMessage::ClStart { num_points } => {
EisMessage::ClStart { num_points, esp_timestamp, .. } => {
self.current_esp_ts = esp_timestamp;
self.cl_points.clear();
self.cl_result = None;
self.cl_total = num_points;
@ -798,11 +806,12 @@ impl App {
self.status = format!("Chlorine complete: {} points", self.cl_points.len());
}
}
EisMessage::PhResult(r) => {
EisMessage::PhResult(r, esp_ts, _) => {
if self.collecting_refs {
self.ph_ref = Some(r);
} else {
if let Some(sid) = self.current_session {
self.current_esp_ts = esp_ts;
self.save_ph(sid, &r);
}
self.status = format!("pH: {:.2} (OCP={:.1} mV, T={:.1}C)",

View File

@ -244,21 +244,23 @@ pub struct EisConfig {
#[derive(Debug, Clone)]
pub enum EisMessage {
SweepStart { num_points: u16, freq_start: f32, freq_stop: f32 },
SweepStart { num_points: u16, freq_start: f32, freq_stop: f32,
esp_timestamp: Option<u32>, meas_id: Option<u16> },
DataPoint { _index: u16, point: EisPoint },
SweepEnd,
Config(EisConfig),
LsvStart { num_points: u16, v_start: f32, v_stop: f32 },
LsvStart { num_points: u16, v_start: f32, v_stop: f32,
esp_timestamp: Option<u32>, meas_id: Option<u16> },
LsvPoint { _index: u16, point: LsvPoint },
LsvEnd,
AmpStart { v_hold: f32 },
AmpStart { v_hold: f32, esp_timestamp: Option<u32>, meas_id: Option<u16> },
AmpPoint { _index: u16, point: AmpPoint },
AmpEnd,
ClStart { num_points: u16 },
ClStart { num_points: u16, esp_timestamp: Option<u32>, meas_id: Option<u16> },
ClPoint { _index: u16, point: ClPoint },
ClResult(ClResult),
ClEnd,
PhResult(PhResult),
PhResult(PhResult, Option<u32>, Option<u16>),
Temperature(f32),
RefFrame { mode: u8, rtia_idx: u8 },
RefLpRange { mode: u8, low_idx: u8, high_idx: u8 },
@ -285,6 +287,16 @@ fn decode_float(data: &[u8]) -> f32 {
f32::from_le_bytes([b0, b1, b2, b3])
}
fn decode_u32(data: &[u8]) -> u32 {
let b = [
data[1] | ((data[0] & 1) << 7),
data[2] | ((data[0] & 2) << 6),
data[3] | ((data[0] & 4) << 5),
data[4] | ((data[0] & 8) << 4),
];
u32::from_le_bytes(b)
}
fn encode_float(val: f32) -> [u8; 5] {
let p = val.to_le_bytes();
[
@ -303,10 +315,14 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
match data[1] {
RSP_SWEEP_START if data.len() >= 15 => {
let p = &data[2..];
let (ts, mid) = if p.len() >= 21 {
(Some(decode_u32(&p[13..18])), Some(decode_u16(&p[18..21])))
} else { (None, None) };
Some(EisMessage::SweepStart {
num_points: decode_u16(&p[0..3]),
freq_start: decode_float(&p[3..8]),
freq_stop: decode_float(&p[8..13]),
esp_timestamp: ts, meas_id: mid,
})
}
RSP_DATA_POINT if data.len() >= 30 => {
@ -342,10 +358,14 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
}
RSP_LSV_START if data.len() >= 15 => {
let p = &data[2..];
let (ts, mid) = if p.len() >= 21 {
(Some(decode_u32(&p[13..18])), Some(decode_u16(&p[18..21])))
} else { (None, None) };
Some(EisMessage::LsvStart {
num_points: decode_u16(&p[0..3]),
v_start: decode_float(&p[3..8]),
v_stop: decode_float(&p[8..13]),
esp_timestamp: ts, meas_id: mid,
})
}
RSP_LSV_POINT if data.len() >= 15 => {
@ -361,7 +381,11 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
RSP_LSV_END => Some(EisMessage::LsvEnd),
RSP_AMP_START if data.len() >= 7 => {
let p = &data[2..];
Some(EisMessage::AmpStart { v_hold: decode_float(&p[0..5]) })
let (ts, mid) = if p.len() >= 13 {
(Some(decode_u32(&p[5..10])), Some(decode_u16(&p[10..13])))
} else { (None, None) };
Some(EisMessage::AmpStart { v_hold: decode_float(&p[0..5]),
esp_timestamp: ts, meas_id: mid })
}
RSP_AMP_POINT if data.len() >= 15 => {
let p = &data[2..];
@ -376,7 +400,11 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
RSP_AMP_END => Some(EisMessage::AmpEnd),
RSP_CL_START if data.len() >= 5 => {
let p = &data[2..];
Some(EisMessage::ClStart { num_points: decode_u16(&p[0..3]) })
let (ts, mid) = if p.len() >= 11 {
(Some(decode_u32(&p[3..8])), Some(decode_u16(&p[8..11])))
} else { (None, None) };
Some(EisMessage::ClStart { num_points: decode_u16(&p[0..3]),
esp_timestamp: ts, meas_id: mid })
}
RSP_CL_POINT if data.len() >= 16 => {
let p = &data[2..];
@ -403,11 +431,14 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
}
RSP_PH_RESULT if data.len() >= 17 => {
let p = &data[2..];
let (ts, mid) = if p.len() >= 23 {
(Some(decode_u32(&p[15..20])), Some(decode_u16(&p[20..23])))
} else { (None, None) };
Some(EisMessage::PhResult(PhResult {
v_ocp_mv: decode_float(&p[0..5]),
ph: decode_float(&p[5..10]),
temp_c: decode_float(&p[10..15]),
}))
}, ts, mid))
}
RSP_REF_FRAME if data.len() >= 4 => {
Some(EisMessage::RefFrame { mode: data[2], rtia_idx: data[3] })

View File

@ -18,6 +18,7 @@ pub struct Measurement {
pub mtype: String,
pub params_json: String,
pub created_at: String,
pub esp_timestamp: Option<i64>,
}
#[derive(Debug, Clone)]
@ -41,9 +42,21 @@ impl Storage {
let conn = Connection::open(path)?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
conn.execute_batch(SCHEMA)?;
Self::migrate_v2(&conn)?;
Ok(Self { conn })
}
fn migrate_v2(conn: &Connection) -> Result<(), rusqlite::Error> {
let has_col: bool = conn.prepare("SELECT esp_timestamp FROM measurements LIMIT 0")
.is_ok();
if !has_col {
conn.execute_batch(
"ALTER TABLE measurements ADD COLUMN esp_timestamp INTEGER;"
)?;
}
Ok(())
}
pub fn create_session(&self, name: &str, notes: &str) -> Result<i64, rusqlite::Error> {
self.conn.execute(
"INSERT INTO sessions (name, notes) VALUES (?1, ?2)",
@ -74,10 +87,21 @@ impl Storage {
pub fn create_measurement(
&self, session_id: i64, mtype: &str, params_json: &str,
esp_timestamp: Option<u32>,
) -> Result<i64, rusqlite::Error> {
if let Some(ts) = esp_timestamp {
let exists: bool = self.conn.query_row(
"SELECT EXISTS(SELECT 1 FROM measurements WHERE session_id = ?1 AND esp_timestamp = ?2)",
params![session_id, ts as i64],
|row| row.get(0),
)?;
if exists {
return Err(rusqlite::Error::StatementChangedRows(0));
}
}
self.conn.execute(
"INSERT INTO measurements (session_id, type, params_json) VALUES (?1, ?2, ?3)",
params![session_id, mtype, params_json],
"INSERT INTO measurements (session_id, type, params_json, esp_timestamp) VALUES (?1, ?2, ?3, ?4)",
params![session_id, mtype, params_json, esp_timestamp.map(|t| t as i64)],
)?;
Ok(self.conn.last_insert_rowid())
}
@ -109,7 +133,7 @@ impl Storage {
pub fn get_measurements(&self, session_id: i64) -> Result<Vec<Measurement>, rusqlite::Error> {
let mut stmt = self.conn.prepare(
"SELECT id, session_id, type, params_json, created_at \
"SELECT id, session_id, type, params_json, created_at, esp_timestamp \
FROM measurements WHERE session_id = ?1 ORDER BY created_at DESC",
)?;
let rows = stmt.query_map(params![session_id], |row| {
@ -119,6 +143,7 @@ impl Storage {
mtype: row.get(2)?,
params_json: row.get(3)?,
created_at: row.get(4)?,
esp_timestamp: row.get(5)?,
})
})?;
rows.collect()
@ -244,7 +269,7 @@ impl Storage {
Some(t) => serde_json::to_string(&toml_table_to_json(t))?,
None => "{}".to_string(),
};
let mid = self.create_measurement(session_id, mtype, &params_json)?;
let mid = self.create_measurement(session_id, mtype, &params_json, None)?;
if let Some(toml::Value::Array(data)) = mt.get("data") {
let pts: Vec<(i32, String)> = data.iter().enumerate()

View File

@ -1,6 +1,6 @@
idf_component_register(SRCS "eis4.c" "eis.c" "echem.c" "protocol.c" "wifi_transport.c" "temp.c" "refs.c"
INCLUDE_DIRS "."
REQUIRES ad5941 ad5941_port nvs_flash esp_wifi esp_netif esp_event)
REQUIRES ad5941 ad5941_port nvs_flash esp_wifi esp_netif esp_event esp_timer)
if(DEFINED ENV{WIFI_SSID})
target_compile_definitions(${COMPONENT_LIB} PRIVATE

View File

@ -12,9 +12,11 @@
#include "nvs_flash.h"
#include "esp_netif.h"
#include "esp_event.h"
#include "esp_timer.h"
#define AD5941_EXPECTED_ADIID 0x4144
static EISConfig cfg;
static uint16_t measurement_counter = 0;
static EISPoint results[EIS_MAX_POINTS];
static LSVPoint lsv_results[ECHEM_MAX_POINTS];
static AmpPoint amp_results[ECHEM_MAX_POINTS];
@ -25,8 +27,10 @@ static void do_sweep(void)
{
eis_init(&cfg);
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
measurement_counter++;
uint32_t n = eis_calc_num_points(&cfg);
send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz);
send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz, ts_ms, measurement_counter);
int got = eis_sweep(results, n, send_eis_point);
printf("Sweep complete: %d points\n", got);
send_sweep_end();
@ -132,8 +136,10 @@ void app_main(void)
lsv_cfg.v_start, lsv_cfg.v_stop, lsv_cfg.scan_rate, lsv_cfg.lp_rtia,
(unsigned long)max_pts);
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
measurement_counter++;
uint32_t n = echem_lsv_calc_steps(&lsv_cfg, max_pts);
send_lsv_start(n, lsv_cfg.v_start, lsv_cfg.v_stop);
send_lsv_start(n, lsv_cfg.v_start, lsv_cfg.v_stop, ts_ms, measurement_counter);
int got = echem_lsv(&lsv_cfg, lsv_results, max_pts, send_lsv_point);
printf("LSV complete: %d points\n", got);
send_lsv_end();
@ -149,7 +155,11 @@ void app_main(void)
printf("Amp: %.0f mV, %.0f ms interval, %.0f s\n",
amp_cfg.v_hold, amp_cfg.interval_ms, amp_cfg.duration_s);
send_amp_start(amp_cfg.v_hold);
{
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
measurement_counter++;
send_amp_start(amp_cfg.v_hold, ts_ms, measurement_counter);
}
int got = echem_amp(&amp_cfg, amp_results, ECHEM_MAX_POINTS, send_amp_point);
printf("Amp complete: %d points\n", got);
send_amp_end();
@ -171,7 +181,12 @@ void app_main(void)
echem_ph_ocp(&ph_cfg, &ph_result);
printf("pH: OCP=%.1f mV, pH=%.2f\n",
ph_result.v_ocp_mv, ph_result.ph);
send_ph_result(ph_result.v_ocp_mv, ph_result.ph, ph_result.temp_c);
{
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
measurement_counter++;
send_ph_result(ph_result.v_ocp_mv, ph_result.ph, ph_result.temp_c,
ts_ms, measurement_counter);
}
break;
}
@ -197,8 +212,10 @@ void app_main(void)
case CMD_OPEN_CAL: {
printf("Open-circuit cal starting\n");
eis_init(&cfg);
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
measurement_counter++;
uint32_t n = eis_calc_num_points(&cfg);
send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz);
send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz, ts_ms, measurement_counter);
int got = eis_open_cal(results, n, send_eis_point);
printf("Open-circuit cal: %d points\n", got);
send_sweep_end();
@ -252,7 +269,11 @@ void app_main(void)
uint32_t n_per = (uint32_t)(cl_cfg.t_meas_ms / 50.0f + 0.5f);
if (n_per < 2) n_per = 2;
send_cl_start(2 * n_per);
{
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
measurement_counter++;
send_cl_start(2 * n_per, ts_ms, measurement_counter);
}
ClResult cl_result;
int got = echem_chlorine(&cl_cfg, cl_results, ECHEM_MAX_POINTS,
&cl_result, send_cl_point);

View File

@ -35,6 +35,29 @@ static void encode_u16(uint16_t val, uint8_t *out)
out[2] = p[1] & 0x7F;
}
void encode_u32(uint32_t val, uint8_t *out)
{
uint8_t *p = (uint8_t *)&val;
out[0] = ((p[0] >> 7) & 1) | ((p[1] >> 6) & 2) |
((p[2] >> 5) & 4) | ((p[3] >> 4) & 8);
out[1] = p[0] & 0x7F;
out[2] = p[1] & 0x7F;
out[3] = p[2] & 0x7F;
out[4] = p[3] & 0x7F;
}
uint32_t decode_u32(const uint8_t *d)
{
uint8_t b[4];
b[0] = d[1] | ((d[0] & 1) << 7);
b[1] = d[2] | ((d[0] & 2) << 6);
b[2] = d[3] | ((d[0] & 4) << 5);
b[3] = d[4] | ((d[0] & 8) << 4);
uint32_t v;
memcpy(&v, b, 4);
return v;
}
float decode_float(const uint8_t *d)
{
uint8_t b[4];
@ -154,14 +177,17 @@ int send_keepalive(void)
/* ---- outbound: EIS ---- */
int send_sweep_start(uint32_t num_points, float freq_start, float freq_stop)
int send_sweep_start(uint32_t num_points, float freq_start, float freq_stop,
uint32_t ts_ms, uint16_t meas_id)
{
uint8_t sx[20];
uint8_t sx[28];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_SWEEP_START;
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
encode_float(freq_start, &sx[p]); p += 5;
encode_float(freq_stop, &sx[p]); p += 5;
encode_u32(ts_ms, &sx[p]); p += 5;
encode_u16(meas_id, &sx[p]); p += 3;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
@ -209,14 +235,17 @@ int send_config(const EISConfig *cfg)
/* ---- outbound: LSV ---- */
int send_lsv_start(uint32_t num_points, float v_start, float v_stop)
int send_lsv_start(uint32_t num_points, float v_start, float v_stop,
uint32_t ts_ms, uint16_t meas_id)
{
uint8_t sx[20];
uint8_t sx[28];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_LSV_START;
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
encode_float(v_start, &sx[p]); p += 5;
encode_float(v_stop, &sx[p]); p += 5;
encode_u32(ts_ms, &sx[p]); p += 5;
encode_u16(meas_id, &sx[p]); p += 3;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
@ -241,12 +270,14 @@ int send_lsv_end(void)
/* ---- outbound: Amperometry ---- */
int send_amp_start(float v_hold)
int send_amp_start(float v_hold, uint32_t ts_ms, uint16_t meas_id)
{
uint8_t sx[12];
uint8_t sx[20];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_AMP_START;
encode_float(v_hold, &sx[p]); p += 5;
encode_u32(ts_ms, &sx[p]); p += 5;
encode_u16(meas_id, &sx[p]); p += 3;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
@ -271,12 +302,14 @@ int send_amp_end(void)
/* ---- outbound: Chlorine ---- */
int send_cl_start(uint32_t num_points)
int send_cl_start(uint32_t num_points, uint32_t ts_ms, uint16_t meas_id)
{
uint8_t sx[10];
uint8_t sx[18];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_CL_START;
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
encode_u32(ts_ms, &sx[p]); p += 5;
encode_u16(meas_id, &sx[p]); p += 3;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
@ -326,14 +359,17 @@ int send_ph_cal(float slope, float offset)
/* ---- outbound: pH ---- */
int send_ph_result(float v_ocp_mv, float ph, float temp_c)
int send_ph_result(float v_ocp_mv, float ph, float temp_c,
uint32_t ts_ms, uint16_t meas_id)
{
uint8_t sx[20];
uint8_t sx[28];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_PH_RESULT;
encode_float(v_ocp_mv, &sx[p]); p += 5;
encode_float(ph, &sx[p]); p += 5;
encode_float(temp_c, &sx[p]); p += 5;
encode_u32(ts_ms, &sx[p]); p += 5;
encode_u16(meas_id, &sx[p]); p += 3;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}

View File

@ -106,34 +106,39 @@ int protocol_init(void);
int protocol_recv_command(Command *cmd, uint32_t timeout_ms);
void protocol_push_command(const Command *cmd);
/* 7-bit decode helpers */
/* 7-bit encode/decode helpers */
void encode_u32(uint32_t val, uint8_t *out);
uint32_t decode_u32(const uint8_t *d);
float decode_float(const uint8_t *d);
uint16_t decode_u16(const uint8_t *d);
/* outbound: EIS */
int send_sweep_start(uint32_t num_points, float freq_start, float freq_stop);
int send_sweep_start(uint32_t num_points, float freq_start, float freq_stop,
uint32_t ts_ms, uint16_t meas_id);
int send_eis_point(uint16_t index, const EISPoint *pt);
int send_sweep_end(void);
int send_config(const EISConfig *cfg);
/* outbound: LSV */
int send_lsv_start(uint32_t num_points, float v_start, float v_stop);
int send_lsv_start(uint32_t num_points, float v_start, float v_stop,
uint32_t ts_ms, uint16_t meas_id);
int send_lsv_point(uint16_t index, float v_mv, float i_ua);
int send_lsv_end(void);
/* outbound: Amperometry */
int send_amp_start(float v_hold);
int send_amp_start(float v_hold, uint32_t ts_ms, uint16_t meas_id);
int send_amp_point(uint16_t index, float t_ms, float i_ua);
int send_amp_end(void);
/* outbound: Chlorine */
int send_cl_start(uint32_t num_points);
int send_cl_start(uint32_t num_points, uint32_t ts_ms, uint16_t meas_id);
int send_cl_point(uint16_t index, float t_ms, float i_ua, uint8_t phase);
int send_cl_result(float i_free_ua, float i_total_ua);
int send_cl_end(void);
/* outbound: pH */
int send_ph_result(float v_ocp_mv, float ph, float temp_c);
int send_ph_result(float v_ocp_mv, float ph, float temp_c,
uint32_t ts_ms, uint16_t meas_id);
/* outbound: temperature */
int send_temp(float temp_c);

View File

@ -7,6 +7,7 @@
#include <math.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_timer.h"
extern const uint32_t lp_rtia_map[];
extern const float lp_rtia_ohms[];
@ -232,7 +233,8 @@ void refs_collect(RefStore *store, const EISConfig *cfg)
send_ref_frame(REF_MODE_EIS, (uint8_t)r);
uint32_t n = eis_calc_num_points(&ref_cfg);
send_sweep_start(n, ref_cfg.freq_start_hz, ref_cfg.freq_stop_hz);
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
send_sweep_start(n, ref_cfg.freq_start_hz, ref_cfg.freq_stop_hz, ts_ms, 0);
int got = eis_sweep(store->eis[r].pts, n, send_eis_point);
store->eis[r].n_points = (uint32_t)got;
@ -270,7 +272,11 @@ void refs_collect(RefStore *store, const EISConfig *cfg)
ph_cfg.temp_c = temp_get();
echem_ph_ocp(&ph_cfg, &store->ph_ref);
store->ph_valid = 1;
send_ph_result(store->ph_ref.v_ocp_mv, store->ph_ref.ph, store->ph_ref.temp_c);
{
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
send_ph_result(store->ph_ref.v_ocp_mv, store->ph_ref.ph, store->ph_ref.temp_c,
ts_ms, 0);
}
store->has_refs = 1;
send_refs_done();
@ -291,7 +297,7 @@ void refs_send(const RefStore *store)
send_ref_frame(REF_MODE_EIS, (uint8_t)r);
uint32_t n = store->eis[r].n_points;
send_sweep_start(n, store->eis[r].pts[0].freq_hz,
store->eis[r].pts[n - 1].freq_hz);
store->eis[r].pts[n - 1].freq_hz, 0, 0);
for (uint32_t i = 0; i < n; i++)
send_eis_point((uint16_t)i, &store->eis[r].pts[i]);
send_sweep_end();
@ -306,7 +312,8 @@ void refs_send(const RefStore *store)
if (store->ph_valid) {
send_ref_frame(REF_MODE_PH, 0);
send_ph_result(store->ph_ref.v_ocp_mv, store->ph_ref.ph, store->ph_ref.temp_c);
send_ph_result(store->ph_ref.v_ocp_mv, store->ph_ref.ph, store->ph_ref.temp_c,
0, 0);
}
send_refs_done();