Merge branch 'ios-session-sync'
This commit is contained in:
commit
e4db734098
|
|
@ -100,6 +100,9 @@ final class AppState {
|
||||||
|
|
||||||
// Session
|
// Session
|
||||||
var currentSessionId: Int64? = nil
|
var currentSessionId: Int64? = nil
|
||||||
|
var firmwareSessionMap: [UInt8: Int64] = [:]
|
||||||
|
var sessionListReceived: Bool = false
|
||||||
|
private var pendingEspTimestamp: Int64? = nil
|
||||||
|
|
||||||
// Calibration
|
// Calibration
|
||||||
var calVolumeGal: Double = 25
|
var calVolumeGal: Double = 25
|
||||||
|
|
@ -125,6 +128,10 @@ final class AppState {
|
||||||
transport.setMessageHandler { [weak self] msg in
|
transport.setMessageHandler { [weak self] msg in
|
||||||
self?.handleMessage(msg)
|
self?.handleMessage(msg)
|
||||||
}
|
}
|
||||||
|
transport.setDisconnectHandler { [weak self] in
|
||||||
|
self?.sessionListReceived = false
|
||||||
|
self?.firmwareSessionMap.removeAll()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Send helper
|
// MARK: - Send helper
|
||||||
|
|
@ -138,7 +145,8 @@ final class AppState {
|
||||||
private func handleMessage(_ msg: EisMessage) {
|
private func handleMessage(_ msg: EisMessage) {
|
||||||
switch msg {
|
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 {
|
if collectingRefs {
|
||||||
eisPoints.removeAll()
|
eisPoints.removeAll()
|
||||||
sweepTotal = numPoints
|
sweepTotal = numPoints
|
||||||
|
|
@ -172,9 +180,14 @@ final class AppState {
|
||||||
rtia = cfg.rtia
|
rtia = cfg.rtia
|
||||||
rcal = cfg.rcal
|
rcal = cfg.rcal
|
||||||
electrode = cfg.electrode
|
electrode = cfg.electrode
|
||||||
|
if !sessionListReceived {
|
||||||
|
sessionListReceived = true
|
||||||
|
send(buildSysexSessionList())
|
||||||
|
}
|
||||||
status = "Config received"
|
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()
|
lsvPoints.removeAll()
|
||||||
lsvTotal = numPoints
|
lsvTotal = numPoints
|
||||||
status = String(format: "LSV: %d pts, %.0f--%.0f mV", numPoints, vStart, vStop)
|
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)
|
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()
|
ampPoints.removeAll()
|
||||||
ampRunning = true
|
ampRunning = true
|
||||||
status = String(format: "Amp: %.0f mV", vHold)
|
status = String(format: "Amp: %.0f mV", vHold)
|
||||||
|
|
@ -229,7 +243,8 @@ final class AppState {
|
||||||
saveAmp()
|
saveAmp()
|
||||||
status = "Amp complete: \(ampPoints.count) points"
|
status = "Amp complete: \(ampPoints.count) points"
|
||||||
|
|
||||||
case .clStart(let numPoints):
|
case .clStart(let numPoints, let espTs, _):
|
||||||
|
pendingEspTimestamp = espTs.map { Int64($0) }
|
||||||
clPoints.removeAll()
|
clPoints.removeAll()
|
||||||
clResult = nil
|
clResult = nil
|
||||||
clTotal = numPoints
|
clTotal = numPoints
|
||||||
|
|
@ -324,11 +339,70 @@ final class AppState {
|
||||||
phOffset = Double(offset)
|
phOffset = Double(offset)
|
||||||
status = String(format: "pH cal: slope=%.4f offset=%.4f", slope, 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:
|
case .keepalive:
|
||||||
break
|
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
|
// MARK: - Actions
|
||||||
|
|
||||||
func applyEISSettings() {
|
func applyEISSettings() {
|
||||||
|
|
@ -533,6 +607,8 @@ final class AppState {
|
||||||
|
|
||||||
private func saveEis() {
|
private func saveEis() {
|
||||||
guard let sid = currentSessionId else { return }
|
guard let sid = currentSessionId else { return }
|
||||||
|
let ts = pendingEspTimestamp
|
||||||
|
pendingEspTimestamp = nil
|
||||||
let params: [String: String] = [
|
let params: [String: String] = [
|
||||||
"freq_start": freqStart,
|
"freq_start": freqStart,
|
||||||
"freq_stop": freqStop,
|
"freq_stop": freqStop,
|
||||||
|
|
@ -542,7 +618,7 @@ final class AppState {
|
||||||
"electrode": electrode.label,
|
"electrode": electrode.label,
|
||||||
]
|
]
|
||||||
guard let configData = try? JSONEncoder().encode(params) else { return }
|
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
|
meas.config = configData
|
||||||
guard let mid = meas.id else { return }
|
guard let mid = meas.id else { return }
|
||||||
let indexed = eisPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
let indexed = eisPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
||||||
|
|
@ -551,6 +627,8 @@ final class AppState {
|
||||||
|
|
||||||
private func saveLsv() {
|
private func saveLsv() {
|
||||||
guard let sid = currentSessionId else { return }
|
guard let sid = currentSessionId else { return }
|
||||||
|
let ts = pendingEspTimestamp
|
||||||
|
pendingEspTimestamp = nil
|
||||||
let params: [String: String] = [
|
let params: [String: String] = [
|
||||||
"v_start": lsvStartV,
|
"v_start": lsvStartV,
|
||||||
"v_stop": lsvStopV,
|
"v_stop": lsvStopV,
|
||||||
|
|
@ -558,7 +636,7 @@ final class AppState {
|
||||||
"rtia": lsvRtia.label,
|
"rtia": lsvRtia.label,
|
||||||
]
|
]
|
||||||
guard let configData = try? JSONEncoder().encode(params) else { return }
|
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
|
meas.config = configData
|
||||||
guard let mid = meas.id else { return }
|
guard let mid = meas.id else { return }
|
||||||
let indexed = lsvPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
let indexed = lsvPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
||||||
|
|
@ -567,6 +645,8 @@ final class AppState {
|
||||||
|
|
||||||
private func saveAmp() {
|
private func saveAmp() {
|
||||||
guard let sid = currentSessionId else { return }
|
guard let sid = currentSessionId else { return }
|
||||||
|
let ts = pendingEspTimestamp
|
||||||
|
pendingEspTimestamp = nil
|
||||||
let params: [String: String] = [
|
let params: [String: String] = [
|
||||||
"v_hold": ampVHold,
|
"v_hold": ampVHold,
|
||||||
"interval_ms": ampInterval,
|
"interval_ms": ampInterval,
|
||||||
|
|
@ -574,7 +654,7 @@ final class AppState {
|
||||||
"rtia": ampRtia.label,
|
"rtia": ampRtia.label,
|
||||||
]
|
]
|
||||||
guard let configData = try? JSONEncoder().encode(params) else { return }
|
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
|
meas.config = configData
|
||||||
guard let mid = meas.id else { return }
|
guard let mid = meas.id else { return }
|
||||||
let indexed = ampPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
let indexed = ampPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
||||||
|
|
@ -583,6 +663,8 @@ final class AppState {
|
||||||
|
|
||||||
private func saveCl() {
|
private func saveCl() {
|
||||||
guard let sid = currentSessionId else { return }
|
guard let sid = currentSessionId else { return }
|
||||||
|
let ts = pendingEspTimestamp
|
||||||
|
pendingEspTimestamp = nil
|
||||||
let params: [String: String] = [
|
let params: [String: String] = [
|
||||||
"cond_v": clCondV,
|
"cond_v": clCondV,
|
||||||
"cond_t": clCondT,
|
"cond_t": clCondT,
|
||||||
|
|
@ -593,7 +675,7 @@ final class AppState {
|
||||||
"rtia": clRtia.label,
|
"rtia": clRtia.label,
|
||||||
]
|
]
|
||||||
guard let configData = try? JSONEncoder().encode(params) else { return }
|
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
|
meas.config = configData
|
||||||
guard let mid = meas.id else { return }
|
guard let mid = meas.id else { return }
|
||||||
let indexed = clPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
let indexed = clPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
||||||
|
|
@ -605,11 +687,13 @@ final class AppState {
|
||||||
|
|
||||||
private func savePh(_ result: PhResult) {
|
private func savePh(_ result: PhResult) {
|
||||||
guard let sid = currentSessionId else { return }
|
guard let sid = currentSessionId else { return }
|
||||||
|
let ts = pendingEspTimestamp
|
||||||
|
pendingEspTimestamp = nil
|
||||||
let params: [String: String] = [
|
let params: [String: String] = [
|
||||||
"stabilize_s": phStabilize,
|
"stabilize_s": phStabilize,
|
||||||
]
|
]
|
||||||
guard let configData = try? JSONEncoder().encode(params) else { return }
|
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
|
meas.config = configData
|
||||||
guard let mid = meas.id else { return }
|
guard let mid = meas.id else { return }
|
||||||
try? Storage.shared.addDataPoint(measurementId: mid, index: 0, point: result)
|
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_REF_STATUS: UInt8 = 0x23
|
||||||
let RSP_CL_FACTOR: UInt8 = 0x24
|
let RSP_CL_FACTOR: UInt8 = 0x24
|
||||||
let RSP_PH_CAL: UInt8 = 0x25
|
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
|
let RSP_KEEPALIVE: UInt8 = 0x50
|
||||||
|
|
||||||
// Cue -> ESP32
|
// Cue -> ESP32
|
||||||
|
|
@ -56,6 +60,10 @@ let CMD_GET_PH_CAL: UInt8 = 0x36
|
||||||
let CMD_START_REFS: UInt8 = 0x30
|
let CMD_START_REFS: UInt8 = 0x30
|
||||||
let CMD_GET_REFS: UInt8 = 0x31
|
let CMD_GET_REFS: UInt8 = 0x31
|
||||||
let CMD_CLEAR_REFS: UInt8 = 0x32
|
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
|
// MARK: - 7-bit MIDI encoding
|
||||||
|
|
||||||
|
|
@ -94,6 +102,31 @@ func encodeU16(_ val: UInt16) -> [UInt8] {
|
||||||
return [mask, p[0] & 0x7F, p[1] & 0x7F]
|
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.
|
/// Decode 3 MIDI-safe bytes back into a UInt16.
|
||||||
func decodeU16(_ d: [UInt8], at offset: Int = 0) -> UInt16 {
|
func decodeU16(_ d: [UInt8], at offset: Int = 0) -> UInt16 {
|
||||||
let m = d[offset]
|
let m = d[offset]
|
||||||
|
|
@ -109,17 +142,17 @@ func decodeU16(_ d: [UInt8], at offset: Int = 0) -> UInt16 {
|
||||||
// MARK: - Message enum
|
// MARK: - Message enum
|
||||||
|
|
||||||
enum EisMessage {
|
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 dataPoint(index: UInt16, point: EisPoint)
|
||||||
case sweepEnd
|
case sweepEnd
|
||||||
case config(EisConfig)
|
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 lsvPoint(index: UInt16, point: LsvPoint)
|
||||||
case lsvEnd
|
case lsvEnd
|
||||||
case ampStart(vHold: Float)
|
case ampStart(vHold: Float, espTimestamp: UInt32?, espMeasId: UInt16?)
|
||||||
case ampPoint(index: UInt16, point: AmpPoint)
|
case ampPoint(index: UInt16, point: AmpPoint)
|
||||||
case ampEnd
|
case ampEnd
|
||||||
case clStart(numPoints: UInt16)
|
case clStart(numPoints: UInt16, espTimestamp: UInt32?, espMeasId: UInt16?)
|
||||||
case clPoint(index: UInt16, point: ClPoint)
|
case clPoint(index: UInt16, point: ClPoint)
|
||||||
case clResult(ClResult)
|
case clResult(ClResult)
|
||||||
case clEnd
|
case clEnd
|
||||||
|
|
@ -132,6 +165,10 @@ enum EisMessage {
|
||||||
case cellK(Float)
|
case cellK(Float)
|
||||||
case clFactor(Float)
|
case clFactor(Float)
|
||||||
case phCal(slope: Float, offset: 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
|
case keepalive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,10 +183,13 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? {
|
||||||
switch data[1] {
|
switch data[1] {
|
||||||
|
|
||||||
case RSP_SWEEP_START where p.count >= 13:
|
case RSP_SWEEP_START where p.count >= 13:
|
||||||
|
let hasExt = p.count >= 21
|
||||||
return .sweepStart(
|
return .sweepStart(
|
||||||
numPoints: decodeU16(p, at: 0),
|
numPoints: decodeU16(p, at: 0),
|
||||||
freqStart: decodeFloat(p, at: 3),
|
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:
|
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:
|
case RSP_LSV_START where p.count >= 13:
|
||||||
|
let hasExt = p.count >= 21
|
||||||
return .lsvStart(
|
return .lsvStart(
|
||||||
numPoints: decodeU16(p, at: 0),
|
numPoints: decodeU16(p, at: 0),
|
||||||
vStart: decodeFloat(p, at: 3),
|
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:
|
case RSP_LSV_POINT where p.count >= 13:
|
||||||
|
|
@ -203,7 +246,12 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? {
|
||||||
return .lsvEnd
|
return .lsvEnd
|
||||||
|
|
||||||
case RSP_AMP_START where p.count >= 5:
|
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:
|
case RSP_AMP_POINT where p.count >= 13:
|
||||||
return .ampPoint(
|
return .ampPoint(
|
||||||
|
|
@ -218,7 +266,12 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? {
|
||||||
return .ampEnd
|
return .ampEnd
|
||||||
|
|
||||||
case RSP_CL_START where p.count >= 3:
|
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:
|
case RSP_CL_POINT where p.count >= 14:
|
||||||
return .clPoint(
|
return .clPoint(
|
||||||
|
|
@ -273,6 +326,46 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? {
|
||||||
offset: decodeFloat(p, at: 5)
|
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:
|
case RSP_KEEPALIVE:
|
||||||
return .keepalive
|
return .keepalive
|
||||||
|
|
||||||
|
|
@ -417,3 +510,29 @@ func buildSysexSetPhCal(_ slope: Float, _ offset: Float) -> [UInt8] {
|
||||||
func buildSysexGetPhCal() -> [UInt8] {
|
func buildSysexGetPhCal() -> [UInt8] {
|
||||||
[0xF0, sysexMfr, CMD_GET_PH_CAL, 0xF7]
|
[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 startedAt: Date
|
||||||
var label: String?
|
var label: String?
|
||||||
var notes: String?
|
var notes: String?
|
||||||
|
var firmwareSessionId: Int64?
|
||||||
|
|
||||||
mutating func didInsert(_ inserted: InsertionSuccess) {
|
mutating func didInsert(_ inserted: InsertionSuccess) {
|
||||||
id = inserted.rowID
|
id = inserted.rowID
|
||||||
|
|
@ -24,6 +25,7 @@ struct Measurement: Codable, FetchableRecord, MutablePersistableRecord {
|
||||||
var startedAt: Date
|
var startedAt: Date
|
||||||
var config: Data?
|
var config: Data?
|
||||||
var resultSummary: Data?
|
var resultSummary: Data?
|
||||||
|
var espTimestamp: Int64?
|
||||||
|
|
||||||
mutating func didInsert(_ inserted: InsertionSuccess) {
|
mutating func didInsert(_ inserted: InsertionSuccess) {
|
||||||
id = inserted.rowID
|
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)
|
try migrator.migrate(dbQueue)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Sessions
|
// MARK: - Sessions
|
||||||
|
|
||||||
func createSession(label: String? = nil) throws -> Session {
|
func createSession(label: String? = nil, firmwareSessionId: Int64? = nil) throws -> Session {
|
||||||
try dbQueue.write { db in
|
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)
|
try s.insert(db)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fetchSession(_ id: Int64) -> Session? {
|
||||||
|
try? dbQueue.read { db in
|
||||||
|
try Session.fetchOne(db, key: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func fetchSessions() throws -> [Session] {
|
func fetchSessions() throws -> [Session] {
|
||||||
try dbQueue.read { db in
|
try dbQueue.read { db in
|
||||||
try Session.order(Column("startedAt").desc).fetchAll(db)
|
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
|
// 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(
|
func addMeasurement(
|
||||||
sessionId: Int64,
|
sessionId: Int64,
|
||||||
type: MeasurementType,
|
type: MeasurementType,
|
||||||
config: (any Encodable)? = nil
|
config: (any Encodable)? = nil,
|
||||||
|
espTimestamp: Int64? = nil
|
||||||
) throws -> Measurement {
|
) throws -> Measurement {
|
||||||
|
if let ts = espTimestamp, measurementExists(sessionId: sessionId, espTimestamp: ts) {
|
||||||
|
throw StorageError.duplicate
|
||||||
|
}
|
||||||
let configData: Data? = if let config {
|
let configData: Data? = if let config {
|
||||||
try JSONEncoder().encode(config)
|
try JSONEncoder().encode(config)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -140,7 +187,8 @@ final class Storage: @unchecked Sendable {
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
type: type.rawValue,
|
type: type.rawValue,
|
||||||
startedAt: Date(),
|
startedAt: Date(),
|
||||||
config: configData
|
config: configData,
|
||||||
|
espTimestamp: espTimestamp
|
||||||
)
|
)
|
||||||
try m.insert(db)
|
try m.insert(db)
|
||||||
return m
|
return m
|
||||||
|
|
@ -460,6 +508,7 @@ final class Storage: @unchecked Sendable {
|
||||||
|
|
||||||
enum StorageError: Error {
|
enum StorageError: Error {
|
||||||
case notFound
|
case notFound
|
||||||
|
case duplicate
|
||||||
case parseError(String)
|
case parseError(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ final class UDPManager: @unchecked Sendable {
|
||||||
private var connection: NWConnection?
|
private var connection: NWConnection?
|
||||||
private let queue = DispatchQueue(label: "udp.eis4", qos: .userInitiated)
|
private let queue = DispatchQueue(label: "udp.eis4", qos: .userInitiated)
|
||||||
private var onMessage: ((EisMessage) -> Void)?
|
private var onMessage: ((EisMessage) -> Void)?
|
||||||
|
private var onDisconnect: (() -> Void)?
|
||||||
private var keepaliveTimer: Timer?
|
private var keepaliveTimer: Timer?
|
||||||
private var timeoutTimer: Timer?
|
private var timeoutTimer: Timer?
|
||||||
private var lastReceived: Date = .distantPast
|
private var lastReceived: Date = .distantPast
|
||||||
|
|
@ -49,6 +50,10 @@ final class UDPManager: @unchecked Sendable {
|
||||||
onMessage = handler
|
onMessage = handler
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setDisconnectHandler(_ handler: @escaping () -> Void) {
|
||||||
|
onDisconnect = handler
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Connection
|
// MARK: - Connection
|
||||||
|
|
||||||
func connect() {
|
func connect() {
|
||||||
|
|
@ -81,6 +86,7 @@ final class UDPManager: @unchecked Sendable {
|
||||||
connection = nil
|
connection = nil
|
||||||
measuring = false
|
measuring = false
|
||||||
state = .disconnected
|
state = .disconnected
|
||||||
|
onDisconnect?()
|
||||||
}
|
}
|
||||||
|
|
||||||
func send(_ sysex: [UInt8]) {
|
func send(_ sysex: [UInt8]) {
|
||||||
|
|
@ -188,6 +194,7 @@ final class UDPManager: @unchecked Sendable {
|
||||||
self.stopTimers()
|
self.stopTimers()
|
||||||
self.connection?.cancel()
|
self.connection?.cancel()
|
||||||
self.connection = nil
|
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?
|
@State private var exportFileURL: URL?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
header
|
header
|
||||||
Divider()
|
Divider()
|
||||||
measurementsList
|
measurementsList
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.onAppear { loadMeasurements() }
|
.onAppear { loadMeasurements() }
|
||||||
.onChange(of: session.id) { loadMeasurements() }
|
.onChange(of: session.id) { loadMeasurements() }
|
||||||
.sheet(isPresented: $editing) { editSheet }
|
.sheet(isPresented: $editing) { editSheet }
|
||||||
|
|
@ -308,8 +310,12 @@ struct SessionDetailView: View {
|
||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
ForEach(measurements, id: \.id) { meas in
|
ForEach(measurements, id: \.id) { meas in
|
||||||
|
NavigationLink {
|
||||||
|
MeasurementDataView(measurement: meas)
|
||||||
|
} label: {
|
||||||
MeasurementRow(measurement: meas, state: state)
|
MeasurementRow(measurement: meas, state: state)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.onDelete { indices in
|
.onDelete { indices in
|
||||||
for idx in indices {
|
for idx in indices {
|
||||||
guard let mid = measurements[idx].id else { continue }
|
guard let mid = measurements[idx].id else { continue }
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,9 @@ pub struct App {
|
||||||
ph_result: Option<PhResult>,
|
ph_result: Option<PhResult>,
|
||||||
ph_stabilize: String,
|
ph_stabilize: String,
|
||||||
|
|
||||||
|
/* measurement dedup */
|
||||||
|
current_esp_ts: Option<u32>,
|
||||||
|
|
||||||
/* Reference baselines */
|
/* Reference baselines */
|
||||||
eis_ref: Option<Vec<EisPoint>>,
|
eis_ref: Option<Vec<EisPoint>>,
|
||||||
lsv_ref: Option<Vec<LsvPoint>>,
|
lsv_ref: Option<Vec<LsvPoint>>,
|
||||||
|
|
@ -501,6 +504,8 @@ impl App {
|
||||||
ph_result: None,
|
ph_result: None,
|
||||||
ph_stabilize: "30".into(),
|
ph_stabilize: "30".into(),
|
||||||
|
|
||||||
|
current_esp_ts: None,
|
||||||
|
|
||||||
eis_ref: None,
|
eis_ref: None,
|
||||||
lsv_ref: None,
|
lsv_ref: None,
|
||||||
amp_ref: None,
|
amp_ref: None,
|
||||||
|
|
@ -556,7 +561,7 @@ impl App {
|
||||||
"rcal": format!("{}", self.rcal),
|
"rcal": format!("{}", self.rcal),
|
||||||
"electrode": format!("{}", self.electrode),
|
"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()
|
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)))
|
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -571,7 +576,7 @@ impl App {
|
||||||
"scan_rate": self.lsv_scan_rate,
|
"scan_rate": self.lsv_scan_rate,
|
||||||
"rtia": format!("{}", self.lsv_rtia),
|
"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()
|
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)))
|
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -586,7 +591,7 @@ impl App {
|
||||||
"duration_s": self.amp_duration,
|
"duration_s": self.amp_duration,
|
||||||
"rtia": format!("{}", self.amp_rtia),
|
"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()
|
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)))
|
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -604,7 +609,7 @@ impl App {
|
||||||
"meas_t": self.cl_meas_t,
|
"meas_t": self.cl_meas_t,
|
||||||
"rtia": format!("{}", self.cl_rtia),
|
"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()
|
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)))
|
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -621,7 +626,7 @@ impl App {
|
||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
"stabilize_s": self.ph_stabilize,
|
"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) {
|
if let Ok(j) = serde_json::to_string(result) {
|
||||||
let _ = self.storage.add_data_point(mid, 0, &j);
|
let _ = self.storage.add_data_point(mid, 0, &j);
|
||||||
}
|
}
|
||||||
|
|
@ -646,9 +651,9 @@ impl App {
|
||||||
self.status = s;
|
self.status = s;
|
||||||
}
|
}
|
||||||
Message::DeviceData(msg) => match msg {
|
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 {
|
if self.collecting_refs {
|
||||||
/* ref collection: clear temp buffer */
|
|
||||||
self.eis_points.clear();
|
self.eis_points.clear();
|
||||||
self.sweep_total = num_points;
|
self.sweep_total = num_points;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -691,7 +696,8 @@ impl App {
|
||||||
self.electrode = cfg.electrode;
|
self.electrode = cfg.electrode;
|
||||||
self.status = "Config received".into();
|
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_points.clear();
|
||||||
self.lsv_total = num_points;
|
self.lsv_total = num_points;
|
||||||
self.lsv_data = text_editor::Content::with_text(&fmt_lsv(&self.lsv_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_points.clear();
|
||||||
self.amp_running = true;
|
self.amp_running = true;
|
||||||
self.amp_data = text_editor::Content::with_text(&fmt_amp(&self.amp_points));
|
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());
|
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_points.clear();
|
||||||
self.cl_result = None;
|
self.cl_result = None;
|
||||||
self.cl_total = num_points;
|
self.cl_total = num_points;
|
||||||
|
|
@ -798,11 +806,12 @@ impl App {
|
||||||
self.status = format!("Chlorine complete: {} points", self.cl_points.len());
|
self.status = format!("Chlorine complete: {} points", self.cl_points.len());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EisMessage::PhResult(r) => {
|
EisMessage::PhResult(r, esp_ts, _) => {
|
||||||
if self.collecting_refs {
|
if self.collecting_refs {
|
||||||
self.ph_ref = Some(r);
|
self.ph_ref = Some(r);
|
||||||
} else {
|
} else {
|
||||||
if let Some(sid) = self.current_session {
|
if let Some(sid) = self.current_session {
|
||||||
|
self.current_esp_ts = esp_ts;
|
||||||
self.save_ph(sid, &r);
|
self.save_ph(sid, &r);
|
||||||
}
|
}
|
||||||
self.status = format!("pH: {:.2} (OCP={:.1} mV, T={:.1}C)",
|
self.status = format!("pH: {:.2} (OCP={:.1} mV, T={:.1}C)",
|
||||||
|
|
|
||||||
|
|
@ -244,21 +244,23 @@ pub struct EisConfig {
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum EisMessage {
|
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 },
|
DataPoint { _index: u16, point: EisPoint },
|
||||||
SweepEnd,
|
SweepEnd,
|
||||||
Config(EisConfig),
|
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 },
|
LsvPoint { _index: u16, point: LsvPoint },
|
||||||
LsvEnd,
|
LsvEnd,
|
||||||
AmpStart { v_hold: f32 },
|
AmpStart { v_hold: f32, esp_timestamp: Option<u32>, meas_id: Option<u16> },
|
||||||
AmpPoint { _index: u16, point: AmpPoint },
|
AmpPoint { _index: u16, point: AmpPoint },
|
||||||
AmpEnd,
|
AmpEnd,
|
||||||
ClStart { num_points: u16 },
|
ClStart { num_points: u16, esp_timestamp: Option<u32>, meas_id: Option<u16> },
|
||||||
ClPoint { _index: u16, point: ClPoint },
|
ClPoint { _index: u16, point: ClPoint },
|
||||||
ClResult(ClResult),
|
ClResult(ClResult),
|
||||||
ClEnd,
|
ClEnd,
|
||||||
PhResult(PhResult),
|
PhResult(PhResult, Option<u32>, Option<u16>),
|
||||||
Temperature(f32),
|
Temperature(f32),
|
||||||
RefFrame { mode: u8, rtia_idx: u8 },
|
RefFrame { mode: u8, rtia_idx: u8 },
|
||||||
RefLpRange { mode: u8, low_idx: u8, high_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])
|
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] {
|
fn encode_float(val: f32) -> [u8; 5] {
|
||||||
let p = val.to_le_bytes();
|
let p = val.to_le_bytes();
|
||||||
[
|
[
|
||||||
|
|
@ -303,10 +315,14 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
|
||||||
match data[1] {
|
match data[1] {
|
||||||
RSP_SWEEP_START if data.len() >= 15 => {
|
RSP_SWEEP_START if data.len() >= 15 => {
|
||||||
let p = &data[2..];
|
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 {
|
Some(EisMessage::SweepStart {
|
||||||
num_points: decode_u16(&p[0..3]),
|
num_points: decode_u16(&p[0..3]),
|
||||||
freq_start: decode_float(&p[3..8]),
|
freq_start: decode_float(&p[3..8]),
|
||||||
freq_stop: decode_float(&p[8..13]),
|
freq_stop: decode_float(&p[8..13]),
|
||||||
|
esp_timestamp: ts, meas_id: mid,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
RSP_DATA_POINT if data.len() >= 30 => {
|
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 => {
|
RSP_LSV_START if data.len() >= 15 => {
|
||||||
let p = &data[2..];
|
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 {
|
Some(EisMessage::LsvStart {
|
||||||
num_points: decode_u16(&p[0..3]),
|
num_points: decode_u16(&p[0..3]),
|
||||||
v_start: decode_float(&p[3..8]),
|
v_start: decode_float(&p[3..8]),
|
||||||
v_stop: decode_float(&p[8..13]),
|
v_stop: decode_float(&p[8..13]),
|
||||||
|
esp_timestamp: ts, meas_id: mid,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
RSP_LSV_POINT if data.len() >= 15 => {
|
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_LSV_END => Some(EisMessage::LsvEnd),
|
||||||
RSP_AMP_START if data.len() >= 7 => {
|
RSP_AMP_START if data.len() >= 7 => {
|
||||||
let p = &data[2..];
|
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 => {
|
RSP_AMP_POINT if data.len() >= 15 => {
|
||||||
let p = &data[2..];
|
let p = &data[2..];
|
||||||
|
|
@ -376,7 +400,11 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
|
||||||
RSP_AMP_END => Some(EisMessage::AmpEnd),
|
RSP_AMP_END => Some(EisMessage::AmpEnd),
|
||||||
RSP_CL_START if data.len() >= 5 => {
|
RSP_CL_START if data.len() >= 5 => {
|
||||||
let p = &data[2..];
|
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 => {
|
RSP_CL_POINT if data.len() >= 16 => {
|
||||||
let p = &data[2..];
|
let p = &data[2..];
|
||||||
|
|
@ -403,11 +431,14 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
|
||||||
}
|
}
|
||||||
RSP_PH_RESULT if data.len() >= 17 => {
|
RSP_PH_RESULT if data.len() >= 17 => {
|
||||||
let p = &data[2..];
|
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 {
|
Some(EisMessage::PhResult(PhResult {
|
||||||
v_ocp_mv: decode_float(&p[0..5]),
|
v_ocp_mv: decode_float(&p[0..5]),
|
||||||
ph: decode_float(&p[5..10]),
|
ph: decode_float(&p[5..10]),
|
||||||
temp_c: decode_float(&p[10..15]),
|
temp_c: decode_float(&p[10..15]),
|
||||||
}))
|
}, ts, mid))
|
||||||
}
|
}
|
||||||
RSP_REF_FRAME if data.len() >= 4 => {
|
RSP_REF_FRAME if data.len() >= 4 => {
|
||||||
Some(EisMessage::RefFrame { mode: data[2], rtia_idx: data[3] })
|
Some(EisMessage::RefFrame { mode: data[2], rtia_idx: data[3] })
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ pub struct Measurement {
|
||||||
pub mtype: String,
|
pub mtype: String,
|
||||||
pub params_json: String,
|
pub params_json: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
pub esp_timestamp: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -41,9 +42,21 @@ impl Storage {
|
||||||
let conn = Connection::open(path)?;
|
let conn = Connection::open(path)?;
|
||||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
|
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
|
||||||
conn.execute_batch(SCHEMA)?;
|
conn.execute_batch(SCHEMA)?;
|
||||||
|
Self::migrate_v2(&conn)?;
|
||||||
Ok(Self { 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> {
|
pub fn create_session(&self, name: &str, notes: &str) -> Result<i64, rusqlite::Error> {
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"INSERT INTO sessions (name, notes) VALUES (?1, ?2)",
|
"INSERT INTO sessions (name, notes) VALUES (?1, ?2)",
|
||||||
|
|
@ -74,10 +87,21 @@ impl Storage {
|
||||||
|
|
||||||
pub fn create_measurement(
|
pub fn create_measurement(
|
||||||
&self, session_id: i64, mtype: &str, params_json: &str,
|
&self, session_id: i64, mtype: &str, params_json: &str,
|
||||||
|
esp_timestamp: Option<u32>,
|
||||||
) -> Result<i64, rusqlite::Error> {
|
) -> 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(
|
self.conn.execute(
|
||||||
"INSERT INTO measurements (session_id, type, params_json) VALUES (?1, ?2, ?3)",
|
"INSERT INTO measurements (session_id, type, params_json, esp_timestamp) VALUES (?1, ?2, ?3, ?4)",
|
||||||
params![session_id, mtype, params_json],
|
params![session_id, mtype, params_json, esp_timestamp.map(|t| t as i64)],
|
||||||
)?;
|
)?;
|
||||||
Ok(self.conn.last_insert_rowid())
|
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> {
|
pub fn get_measurements(&self, session_id: i64) -> Result<Vec<Measurement>, rusqlite::Error> {
|
||||||
let mut stmt = self.conn.prepare(
|
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",
|
FROM measurements WHERE session_id = ?1 ORDER BY created_at DESC",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map(params![session_id], |row| {
|
let rows = stmt.query_map(params![session_id], |row| {
|
||||||
|
|
@ -119,6 +143,7 @@ impl Storage {
|
||||||
mtype: row.get(2)?,
|
mtype: row.get(2)?,
|
||||||
params_json: row.get(3)?,
|
params_json: row.get(3)?,
|
||||||
created_at: row.get(4)?,
|
created_at: row.get(4)?,
|
||||||
|
esp_timestamp: row.get(5)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
rows.collect()
|
rows.collect()
|
||||||
|
|
@ -244,7 +269,7 @@ impl Storage {
|
||||||
Some(t) => serde_json::to_string(&toml_table_to_json(t))?,
|
Some(t) => serde_json::to_string(&toml_table_to_json(t))?,
|
||||||
None => "{}".to_string(),
|
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") {
|
if let Some(toml::Value::Array(data)) = mt.get("data") {
|
||||||
let pts: Vec<(i32, String)> = data.iter().enumerate()
|
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"
|
idf_component_register(SRCS "eis4.c" "eis.c" "echem.c" "protocol.c" "wifi_transport.c" "temp.c" "refs.c"
|
||||||
INCLUDE_DIRS "."
|
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})
|
if(DEFINED ENV{WIFI_SSID})
|
||||||
target_compile_definitions(${COMPONENT_LIB} PRIVATE
|
target_compile_definitions(${COMPONENT_LIB} PRIVATE
|
||||||
|
|
|
||||||
33
main/eis4.c
33
main/eis4.c
|
|
@ -12,9 +12,11 @@
|
||||||
#include "nvs_flash.h"
|
#include "nvs_flash.h"
|
||||||
#include "esp_netif.h"
|
#include "esp_netif.h"
|
||||||
#include "esp_event.h"
|
#include "esp_event.h"
|
||||||
|
#include "esp_timer.h"
|
||||||
|
|
||||||
#define AD5941_EXPECTED_ADIID 0x4144
|
#define AD5941_EXPECTED_ADIID 0x4144
|
||||||
static EISConfig cfg;
|
static EISConfig cfg;
|
||||||
|
static uint16_t measurement_counter = 0;
|
||||||
static EISPoint results[EIS_MAX_POINTS];
|
static EISPoint results[EIS_MAX_POINTS];
|
||||||
static LSVPoint lsv_results[ECHEM_MAX_POINTS];
|
static LSVPoint lsv_results[ECHEM_MAX_POINTS];
|
||||||
static AmpPoint amp_results[ECHEM_MAX_POINTS];
|
static AmpPoint amp_results[ECHEM_MAX_POINTS];
|
||||||
|
|
@ -25,8 +27,10 @@ static void do_sweep(void)
|
||||||
{
|
{
|
||||||
eis_init(&cfg);
|
eis_init(&cfg);
|
||||||
|
|
||||||
|
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||||
|
measurement_counter++;
|
||||||
uint32_t n = eis_calc_num_points(&cfg);
|
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);
|
int got = eis_sweep(results, n, send_eis_point);
|
||||||
printf("Sweep complete: %d points\n", got);
|
printf("Sweep complete: %d points\n", got);
|
||||||
send_sweep_end();
|
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,
|
lsv_cfg.v_start, lsv_cfg.v_stop, lsv_cfg.scan_rate, lsv_cfg.lp_rtia,
|
||||||
(unsigned long)max_pts);
|
(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);
|
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);
|
int got = echem_lsv(&lsv_cfg, lsv_results, max_pts, send_lsv_point);
|
||||||
printf("LSV complete: %d points\n", got);
|
printf("LSV complete: %d points\n", got);
|
||||||
send_lsv_end();
|
send_lsv_end();
|
||||||
|
|
@ -149,7 +155,11 @@ void app_main(void)
|
||||||
printf("Amp: %.0f mV, %.0f ms interval, %.0f s\n",
|
printf("Amp: %.0f mV, %.0f ms interval, %.0f s\n",
|
||||||
amp_cfg.v_hold, amp_cfg.interval_ms, amp_cfg.duration_s);
|
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);
|
int got = echem_amp(&_cfg, amp_results, ECHEM_MAX_POINTS, send_amp_point);
|
||||||
printf("Amp complete: %d points\n", got);
|
printf("Amp complete: %d points\n", got);
|
||||||
send_amp_end();
|
send_amp_end();
|
||||||
|
|
@ -171,7 +181,12 @@ void app_main(void)
|
||||||
echem_ph_ocp(&ph_cfg, &ph_result);
|
echem_ph_ocp(&ph_cfg, &ph_result);
|
||||||
printf("pH: OCP=%.1f mV, pH=%.2f\n",
|
printf("pH: OCP=%.1f mV, pH=%.2f\n",
|
||||||
ph_result.v_ocp_mv, ph_result.ph);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,8 +212,10 @@ void app_main(void)
|
||||||
case CMD_OPEN_CAL: {
|
case CMD_OPEN_CAL: {
|
||||||
printf("Open-circuit cal starting\n");
|
printf("Open-circuit cal starting\n");
|
||||||
eis_init(&cfg);
|
eis_init(&cfg);
|
||||||
|
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||||
|
measurement_counter++;
|
||||||
uint32_t n = eis_calc_num_points(&cfg);
|
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);
|
int got = eis_open_cal(results, n, send_eis_point);
|
||||||
printf("Open-circuit cal: %d points\n", got);
|
printf("Open-circuit cal: %d points\n", got);
|
||||||
send_sweep_end();
|
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);
|
uint32_t n_per = (uint32_t)(cl_cfg.t_meas_ms / 50.0f + 0.5f);
|
||||||
if (n_per < 2) n_per = 2;
|
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;
|
ClResult cl_result;
|
||||||
int got = echem_chlorine(&cl_cfg, cl_results, ECHEM_MAX_POINTS,
|
int got = echem_chlorine(&cl_cfg, cl_results, ECHEM_MAX_POINTS,
|
||||||
&cl_result, send_cl_point);
|
&cl_result, send_cl_point);
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,29 @@ static void encode_u16(uint16_t val, uint8_t *out)
|
||||||
out[2] = p[1] & 0x7F;
|
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)
|
float decode_float(const uint8_t *d)
|
||||||
{
|
{
|
||||||
uint8_t b[4];
|
uint8_t b[4];
|
||||||
|
|
@ -154,14 +177,17 @@ int send_keepalive(void)
|
||||||
|
|
||||||
/* ---- outbound: EIS ---- */
|
/* ---- 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;
|
uint16_t p = 0;
|
||||||
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_SWEEP_START;
|
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_SWEEP_START;
|
||||||
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
|
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
|
||||||
encode_float(freq_start, &sx[p]); p += 5;
|
encode_float(freq_start, &sx[p]); p += 5;
|
||||||
encode_float(freq_stop, &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;
|
sx[p++] = 0xF7;
|
||||||
return send_sysex(sx, p);
|
return send_sysex(sx, p);
|
||||||
}
|
}
|
||||||
|
|
@ -209,14 +235,17 @@ int send_config(const EISConfig *cfg)
|
||||||
|
|
||||||
/* ---- outbound: LSV ---- */
|
/* ---- 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;
|
uint16_t p = 0;
|
||||||
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_LSV_START;
|
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_LSV_START;
|
||||||
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
|
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
|
||||||
encode_float(v_start, &sx[p]); p += 5;
|
encode_float(v_start, &sx[p]); p += 5;
|
||||||
encode_float(v_stop, &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;
|
sx[p++] = 0xF7;
|
||||||
return send_sysex(sx, p);
|
return send_sysex(sx, p);
|
||||||
}
|
}
|
||||||
|
|
@ -241,12 +270,14 @@ int send_lsv_end(void)
|
||||||
|
|
||||||
/* ---- outbound: Amperometry ---- */
|
/* ---- 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;
|
uint16_t p = 0;
|
||||||
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_AMP_START;
|
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_AMP_START;
|
||||||
encode_float(v_hold, &sx[p]); p += 5;
|
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;
|
sx[p++] = 0xF7;
|
||||||
return send_sysex(sx, p);
|
return send_sysex(sx, p);
|
||||||
}
|
}
|
||||||
|
|
@ -271,12 +302,14 @@ int send_amp_end(void)
|
||||||
|
|
||||||
/* ---- outbound: Chlorine ---- */
|
/* ---- 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;
|
uint16_t p = 0;
|
||||||
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_CL_START;
|
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_CL_START;
|
||||||
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
|
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;
|
sx[p++] = 0xF7;
|
||||||
return send_sysex(sx, p);
|
return send_sysex(sx, p);
|
||||||
}
|
}
|
||||||
|
|
@ -326,14 +359,17 @@ int send_ph_cal(float slope, float offset)
|
||||||
|
|
||||||
/* ---- outbound: pH ---- */
|
/* ---- 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;
|
uint16_t p = 0;
|
||||||
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_PH_RESULT;
|
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_PH_RESULT;
|
||||||
encode_float(v_ocp_mv, &sx[p]); p += 5;
|
encode_float(v_ocp_mv, &sx[p]); p += 5;
|
||||||
encode_float(ph, &sx[p]); p += 5;
|
encode_float(ph, &sx[p]); p += 5;
|
||||||
encode_float(temp_c, &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;
|
sx[p++] = 0xF7;
|
||||||
return send_sysex(sx, p);
|
return send_sysex(sx, p);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -106,34 +106,39 @@ int protocol_init(void);
|
||||||
int protocol_recv_command(Command *cmd, uint32_t timeout_ms);
|
int protocol_recv_command(Command *cmd, uint32_t timeout_ms);
|
||||||
void protocol_push_command(const Command *cmd);
|
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);
|
float decode_float(const uint8_t *d);
|
||||||
uint16_t decode_u16(const uint8_t *d);
|
uint16_t decode_u16(const uint8_t *d);
|
||||||
|
|
||||||
/* outbound: EIS */
|
/* 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_eis_point(uint16_t index, const EISPoint *pt);
|
||||||
int send_sweep_end(void);
|
int send_sweep_end(void);
|
||||||
int send_config(const EISConfig *cfg);
|
int send_config(const EISConfig *cfg);
|
||||||
|
|
||||||
/* outbound: LSV */
|
/* 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_point(uint16_t index, float v_mv, float i_ua);
|
||||||
int send_lsv_end(void);
|
int send_lsv_end(void);
|
||||||
|
|
||||||
/* outbound: Amperometry */
|
/* 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_point(uint16_t index, float t_ms, float i_ua);
|
||||||
int send_amp_end(void);
|
int send_amp_end(void);
|
||||||
|
|
||||||
/* outbound: Chlorine */
|
/* 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_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_result(float i_free_ua, float i_total_ua);
|
||||||
int send_cl_end(void);
|
int send_cl_end(void);
|
||||||
|
|
||||||
/* outbound: pH */
|
/* 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 */
|
/* outbound: temperature */
|
||||||
int send_temp(float temp_c);
|
int send_temp(float temp_c);
|
||||||
|
|
|
||||||
15
main/refs.c
15
main/refs.c
|
|
@ -7,6 +7,7 @@
|
||||||
#include <math.h>
|
#include <math.h>
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
|
#include "esp_timer.h"
|
||||||
|
|
||||||
extern const uint32_t lp_rtia_map[];
|
extern const uint32_t lp_rtia_map[];
|
||||||
extern const float lp_rtia_ohms[];
|
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);
|
send_ref_frame(REF_MODE_EIS, (uint8_t)r);
|
||||||
|
|
||||||
uint32_t n = eis_calc_num_points(&ref_cfg);
|
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);
|
int got = eis_sweep(store->eis[r].pts, n, send_eis_point);
|
||||||
store->eis[r].n_points = (uint32_t)got;
|
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();
|
ph_cfg.temp_c = temp_get();
|
||||||
echem_ph_ocp(&ph_cfg, &store->ph_ref);
|
echem_ph_ocp(&ph_cfg, &store->ph_ref);
|
||||||
store->ph_valid = 1;
|
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;
|
store->has_refs = 1;
|
||||||
send_refs_done();
|
send_refs_done();
|
||||||
|
|
@ -291,7 +297,7 @@ void refs_send(const RefStore *store)
|
||||||
send_ref_frame(REF_MODE_EIS, (uint8_t)r);
|
send_ref_frame(REF_MODE_EIS, (uint8_t)r);
|
||||||
uint32_t n = store->eis[r].n_points;
|
uint32_t n = store->eis[r].n_points;
|
||||||
send_sweep_start(n, store->eis[r].pts[0].freq_hz,
|
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++)
|
for (uint32_t i = 0; i < n; i++)
|
||||||
send_eis_point((uint16_t)i, &store->eis[r].pts[i]);
|
send_eis_point((uint16_t)i, &store->eis[r].pts[i]);
|
||||||
send_sweep_end();
|
send_sweep_end();
|
||||||
|
|
@ -306,7 +312,8 @@ void refs_send(const RefStore *store)
|
||||||
|
|
||||||
if (store->ph_valid) {
|
if (store->ph_valid) {
|
||||||
send_ref_frame(REF_MODE_PH, 0);
|
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();
|
send_refs_done();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue