Merge branch 'ios-session-sync'
This commit is contained in:
commit
e4db734098
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ 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_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
|
||||
|
|
@ -56,6 +60,10 @@ 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_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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,11 +197,13 @@ struct SessionDetailView: View {
|
|||
@State private var exportFileURL: URL?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
measurementsList
|
||||
}
|
||||
}
|
||||
.onAppear { loadMeasurements() }
|
||||
.onChange(of: session.id) { loadMeasurements() }
|
||||
.sheet(isPresented: $editing) { editSheet }
|
||||
|
|
@ -308,8 +310,12 @@ struct SessionDetailView: View {
|
|||
} else {
|
||||
List {
|
||||
ForEach(measurements, id: \.id) { meas in
|
||||
NavigationLink {
|
||||
MeasurementDataView(measurement: meas)
|
||||
} label: {
|
||||
MeasurementRow(measurement: meas, state: state)
|
||||
}
|
||||
}
|
||||
.onDelete { indices in
|
||||
for idx in indices {
|
||||
guard let mid = measurements[idx].id else { continue }
|
||||
|
|
|
|||
|
|
@ -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", ¶ms.to_string()) {
|
||||
if let Ok(mid) = self.storage.create_measurement(session_id, "eis", ¶ms.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", ¶ms.to_string()) {
|
||||
if let Ok(mid) = self.storage.create_measurement(session_id, "lsv", ¶ms.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", ¶ms.to_string()) {
|
||||
if let Ok(mid) = self.storage.create_measurement(session_id, "amp", ¶ms.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", ¶ms.to_string()) {
|
||||
if let Ok(mid) = self.storage.create_measurement(session_id, "chlorine", ¶ms.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", ¶ms.to_string()) {
|
||||
if let Ok(mid) = self.storage.create_measurement(session_id, "ph", ¶ms.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)",
|
||||
|
|
|
|||
|
|
@ -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] })
|
||||
|
|
|
|||
|
|
@ -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, ¶ms_json)?;
|
||||
let mid = self.create_measurement(session_id, mtype, ¶ms_json, None)?;
|
||||
|
||||
if let Some(toml::Value::Array(data)) = mt.get("data") {
|
||||
let pts: Vec<(i32, String)> = data.iter().enumerate()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
33
main/eis4.c
33
main/eis4.c
|
|
@ -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(&_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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
15
main/refs.c
15
main/refs.c
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue