Compare commits

...

65 Commits

Author SHA1 Message Date
jess 8403ff349e Things are running really smoothly. Relief in the breathe 2026-04-03 08:28:18 -07:00
jess 1dca7b035e fix Swift 6 concurrency data races across AppState, UDPManager, SessionView 2026-04-03 08:17:08 -07:00
jess e4db734098 Merge branch 'ios-session-sync' 2026-04-03 07:18:54 -07:00
jess 35be164188 update refs.c call sites for new send_sweep_start and send_ph_result signatures 2026-04-03 07:14:02 -07:00
jess b6ff02bdb4 add esp_timer to CMakeLists REQUIRES for esp_timer_get_time 2026-04-03 07:13:24 -07:00
jess 95ca2f7cdc inline axis styling in measurement data views 2026-04-03 07:08:25 -07:00
jess 6eab85af57 wire measurement rows to data views via NavigationLink 2026-04-03 07:07:23 -07:00
jess f5394d01ca add measurement data views with charts for all measurement types 2026-04-03 07:06:47 -07:00
jess d061a17e54 add session sync protocol, extended START fields, and session management handlers 2026-04-03 07:05:40 -07:00
jess dcde79cf08 add esp_timestamp to DB schema with v2 migration and dedup 2026-04-03 07:05:29 -07:00
jess 1ba6772738 decode esp_timestamp and meas_id in desktop Rust parser 2026-04-03 07:02:50 -07:00
jess 8297773827 add v2 migration: espTimestamp on measurement, firmwareSessionId on session 2026-04-03 07:01:37 -07:00
jess 80dc8ef561 append esp timestamp and measurement ID to all START messages 2026-04-03 07:01:30 -07:00
jess bb894b42be add encode_u32/decode_u32 to firmware protocol 2026-04-03 06:59:54 -07:00
jess ea3356ac20 disable wifi power save mode to fix bcn_timeout disconnects 2026-04-03 06:55:10 -07:00
jess 3239eaf9c8 implement UDP zombie reaper task 2026-04-03 03:48:42 -07:00
jess 9825ddb287 add client_remove_by_index for reaper 2026-04-03 03:48:03 -07:00
jess a9f6f9f6ac add timestamp tracking to UDP client table 2026-04-03 03:47:51 -07:00
jess 3e0cbfd131 add mutex protection to UDP client table 2026-04-03 03:47:26 -07:00
jess 5f550f031a merge integration
# Conflicts:
#	cue-ios/CueIOS/AppState.swift
#	cue-ios/CueIOS/Models/Protocol.swift
#	cue/src/app.rs
#	cue/src/protocol.rs
#	main/protocol.h
#	main/wifi_transport.c
2026-04-03 02:30:58 -07:00
jess 618e9ed4c8 iOS: parse and discard RSP_KEEPALIVE messages 2026-04-03 02:27:43 -07:00
jess d409f3569e desktop: parse and discard RSP_KEEPALIVE messages 2026-04-03 02:27:09 -07:00
jess c1721dfd1f send keepalives during blocking waits (clean, pH, chlorine settling) 2026-04-03 02:26:35 -07:00
jess 292a1a2e87 add RSP_KEEPALIVE protocol message type 2026-04-03 02:25:49 -07:00
jess a2a48f47a3 remove arbitrary MAX_UDP_CLIENTS cap from wifi transport 2026-04-03 02:25:26 -07:00
jess 9bc5347c66 Merge branch 'main' into chlorine-auto 2026-04-03 02:16:04 -07:00
jess 804ea21d71 fix: suppress keepalive timeout during blocking firmware measurements 2026-04-03 02:15:43 -07:00
jess 399ee4229b fix Swift type-checker errors in LsvAnalysis chained closures 2026-04-03 02:13:27 -07:00
jess 91a361732d add auto-mode chlorine flow to iOS 2026-04-03 02:09:38 -07:00
jess 03d10ab678 add auto-mode chlorine flow to desktop 2026-04-03 02:07:51 -07:00
jess c6bbaa5bc4 add ClPotentials derivation from LSV cathodic peaks 2026-04-03 02:07:47 -07:00
jess 01edb88e0b fix: iOS ChlorineView lsvManualPeaks -> clManualPeaks, Storage concurrency 2026-04-03 01:54:18 -07:00
jess cabf04551c Merge branch 'chlorine-tab-expand' 2026-04-02 23:16:39 -07:00
jess 34f8bda191 iOS: add LSV sweep and voltammogram to chlorine tab 2026-04-02 23:12:09 -07:00
jess 73899beaa5 add LSV sweep and voltammogram to chlorine tab 2026-04-02 23:11:49 -07:00
jess 8de67ca66e 2 iOS: add LSV point density config, remove auto/manual toggle 2026-04-02 23:10:44 -07:00
jess 090fcfa2f5 1 desktop: add LSV point density config, remove auto/manual toggle 2026-04-02 23:09:31 -07:00
jess a21b014d89 0 firmware: add num_points parameter to CMD_START_LSV protocol 2026-04-02 23:07:29 -07:00
jess 8e1153585b wire TOML export/import to iOS session browser 2026-04-02 21:16:29 -07:00
jess 311fb8ecc7 wire TOML export/import to desktop browse UI 2026-04-02 21:15:18 -07:00
jess c0a0904a44 display computed pH in status when LSV sweep completes 2026-04-02 19:34:58 -07:00
jess 1441c5ec42 iOS: add pH calibration UI in Calibrate tab 2026-04-02 19:34:30 -07:00
jess 818c4ff7a2 iOS: add pH cal protocol, Q/HQ peak detection, and state 2026-04-02 19:34:02 -07:00
jess d5e1a7dd0f desktop: add pH calibration UI in Calibrate tab 2026-04-02 19:33:04 -07:00
jess bdb72a9917 desktop: add pH cal protocol, Q/HQ peak detection, and state 2026-04-02 19:32:27 -07:00
jess 5b051cfa20 firmware: add pH calibration NVS, protocol, and dispatch 2026-04-02 19:31:10 -07:00
jess 3c33c7806d Merge branch 'chlorine-calibration-ui' 2026-04-02 18:37:38 -07:00
jess 0cfeb287e6 add cl_factor UI and state to iOS cue 2026-04-02 18:30:31 -07:00
jess e8ce7eb98c add cl_factor protocol support to iOS cue 2026-04-02 18:29:29 -07:00
jess d84ed33c14 add cl_factor UI and state to desktop cue 2026-04-02 18:28:57 -07:00
jess 2e1a2f98f2 add cl_factor protocol support to desktop cue 2026-04-02 18:19:51 -07:00
jess b17d12195d clean up unused parameter in find_extrema 2026-04-02 18:19:15 -07:00
jess 4c914a5101 add cl_factor NVS storage and protocol commands to firmware 2026-04-02 18:19:04 -07:00
jess 95997e4fd5 fix Swift type-check complexity in LSV crossover detection 2026-04-02 18:18:45 -07:00
jess 491befa8c6 wire LSV peak detection into iOS AppState and LSVView with markers, labels, and toggle 2026-04-02 18:16:25 -07:00
jess 324c8a7f5a add iOS LSV peak detection mirroring Rust algorithm 2026-04-02 18:15:18 -07:00
jess df6268d2ac add LSV peak state, auto/manual toggle, and peak readout to app 2026-04-02 18:14:41 -07:00
jess 30cd80d03b wire peak markers into VoltammogramPlot with color-coded rendering and labels 2026-04-02 18:13:44 -07:00
jess e5fe1c9229 add LSV peak detection module with smoothing, extrema finding, and classification 2026-04-02 18:12:49 -07:00
jess 1abf46f0c3 add linear/circle Nyquist fit resolver to iOS 2026-04-02 17:14:34 -07:00
jess 6a09782d30 add linear/circle Nyquist fit resolver with cumulative turning detection 2026-04-02 17:13:35 -07:00
jess 9e9410a78e raise DHCP max station count to 12 2026-04-02 16:11:14 -07:00
jess e1209a89cc set AP inactive station timeout to 120 seconds 2026-04-02 16:11:07 -07:00
jess a611a72c48 clean up UDP sessions when WiFi station disconnects 2026-04-02 16:10:59 -07:00
jess 1ea5c760d7 raise softAP and UDP client limits to 10 2026-04-02 16:08:49 -07:00
32 changed files with 3791 additions and 305 deletions

35
build-all.sh Executable file
View File

@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
echo "=== Cue Desktop ==="
cd cue
cargo clean
./build.sh
cd ..
echo ""
echo "=== Cue iOS ==="
cd cue-ios
SVG_SRC="../cue/assets/cue.svg"
ICON_DIR="CueIOS/Assets.xcassets/AppIcon.appiconset"
if [ -f "$SVG_SRC" ] && command -v rsvg-convert &>/dev/null; then
echo "Regenerating iOS app icon"
rsvg-convert -w 1024 -h 1024 "$SVG_SRC" -o /tmp/cue-icon-raw.png
if command -v magick &>/dev/null; then
magick /tmp/cue-icon-raw.png -background black -flatten \
-gravity center -extent 1024x1024 "$ICON_DIR/appicon-1024.png"
else
magick convert /tmp/cue-icon-raw.png -background black -flatten \
"$ICON_DIR/appicon-1024.png" 2>/dev/null || \
cp /tmp/cue-icon-raw.png "$ICON_DIR/appicon-1024.png"
fi
rm -f /tmp/cue-icon-raw.png
fi
xcodegen generate
echo "Xcode project regenerated — open CueIOS.xcodeproj to build"
cd ..
echo ""
echo "=== Firmware ==="
make fcf

View File

@ -1,6 +1,16 @@
import Foundation
import Observation
enum ClAutoState: Equatable {
case idle, lsvRunning, measureRunning
}
enum LsvDensityMode: String, CaseIterable, Identifiable {
case ptsPerMv = "pts/mV"
case ptsPerSec = "pts/s"
var id: String { rawValue }
}
enum Tab: String, CaseIterable, Identifiable {
case eis = "EIS"
case lsv = "LSV"
@ -37,11 +47,14 @@ final class AppState {
// LSV
var lsvPoints: [LsvPoint] = []
var lsvPeaks: [LsvPeak] = []
var lsvTotal: UInt16 = 0
var lsvStartV: String = "0"
var lsvStopV: String = "500"
var lsvScanRate: String = "50"
var lsvRtia: LpRtia = .r10K
var lsvDensityMode: LsvDensityMode = .ptsPerMv
var lsvDensity: String = "1"
// Amperometry
var ampPoints: [AmpPoint] = []
@ -63,6 +76,9 @@ final class AppState {
var clDepT: String = "5000"
var clMeasT: String = "5000"
var clRtia: LpRtia = .r10K
var clManualPeaks: Bool = false
var clAutoState: ClAutoState = .idle
var clAutoPotentials: ClPotentials? = nil
// pH
var phResult: PhResult? = nil
@ -84,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
@ -93,6 +112,12 @@ final class AppState {
var calTempC: String = "40"
var calCellConstant: Double? = nil
var calRs: Double? = nil
var clFactor: Double? = nil
var clCalKnownPpm: String = "5"
var phSlope: Double? = nil
var phOffset: Double? = nil
var phCalPoints: [(ph: Double, mV: Double)] = []
var phCalKnown: String = "7.00"
// Clean
var cleanV: String = "1200"
@ -103,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
@ -116,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
@ -150,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)
@ -163,9 +198,37 @@ final class AppState {
case .lsvEnd:
saveLsv()
status = "LSV complete: \(lsvPoints.count) points"
lsvPeaks = detectLsvPeaks(lsvPoints)
var st = "LSV complete: \(lsvPoints.count) points"
if let s = phSlope, let o = phOffset, abs(s) > 1e-6 {
if let peak = detectQhqPeak(lsvPoints) {
let ph = (Double(peak) - o) / s
st += String(format: " | pH=%.2f", ph)
}
}
status = st
case .ampStart(let vHold):
if clAutoState == .lsvRunning {
let pots = deriveClPotentials(lsvPoints)
clFreeV = String(format: "%.0f", pots.vFree)
clTotalV = String(format: "%.0f", pots.vTotal)
clAutoPotentials = pots
clAutoState = .measureRunning
let vCond = Float(clCondV) ?? 800
let tCond = Float(clCondT) ?? 2000
let tDep = Float(clDepT) ?? 5000
let tMeas = Float(clMeasT) ?? 5000
send(buildSysexGetTemp())
send(buildSysexStartCl(
vCond: vCond, tCondMs: tCond, vFree: pots.vFree, vTotal: pots.vTotal,
tDepMs: tDep, tMeasMs: tMeas, lpRtia: clRtia
))
status = String(format: "Auto Cl: measuring (free=%.0f, total=%.0f)", pots.vFree, pots.vTotal)
}
case .ampStart(let vHold, let espTs, _):
pendingEspTimestamp = espTs.map { Int64($0) }
ampPoints.removeAll()
ampRunning = true
status = String(format: "Amp: %.0f mV", vHold)
@ -180,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
@ -196,9 +260,22 @@ final class AppState {
case .clEnd:
saveCl()
status = "Chlorine complete: \(clPoints.count) points"
if clAutoState == .measureRunning {
clAutoState = .idle
if let pots = clAutoPotentials {
let fd = pots.vFreeDetected ? "" : " dflt"
let td = pots.vTotalDetected ? "" : " dflt"
status = String(format: "Auto Cl complete: %d pts (free=%.0f%@, total=%.0f%@)",
clPoints.count, pots.vFree, fd, pots.vTotal, td)
} else {
status = "Chlorine complete: \(clPoints.count) points"
}
} else {
status = "Chlorine complete: \(clPoints.count) points"
}
case .phResult(let r):
transport.measuring = false
if collectingRefs {
phRef = r
} else {
@ -232,6 +309,7 @@ final class AppState {
break
case .refsDone:
transport.measuring = false
collectingRefs = false
hasDeviceRefs = true
refMode = nil
@ -251,9 +329,80 @@ final class AppState {
case .cellK(let k):
calCellConstant = Double(k)
status = String(format: "Device cell constant: %.4f cm\u{207B}\u{00B9}", k)
case .clFactor(let f):
clFactor = Double(f)
status = String(format: "Device Cl factor: %.6f", f)
case .phCal(let slope, let offset):
phSlope = Double(slope)
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() {
@ -273,13 +422,30 @@ final class AppState {
send(buildSysexStartSweep())
}
func lsvCalcPoints() -> UInt16 {
let vs = Float(lsvStartV) ?? 0
let ve = Float(lsvStopV) ?? 500
let sr = Float(lsvScanRate) ?? 50
let d = Float(lsvDensity) ?? 1
let range = abs(ve - vs)
let raw: Float
switch lsvDensityMode {
case .ptsPerMv:
raw = range * d
case .ptsPerSec:
raw = abs(sr) < 0.001 ? 2 : (range / abs(sr)) * d
}
return max(2, min(500, UInt16(raw)))
}
func startLSV() {
lsvPoints.removeAll()
let vs = Float(lsvStartV) ?? 0
let ve = Float(lsvStopV) ?? 500
let sr = Float(lsvScanRate) ?? 50
let n = lsvCalcPoints()
send(buildSysexGetTemp())
send(buildSysexStartLsv(vStart: vs, vStop: ve, scanRate: sr, lpRtia: lsvRtia))
send(buildSysexStartLsv(vStart: vs, vStop: ve, scanRate: sr, lpRtia: lsvRtia, numPoints: n))
}
func startAmp() {
@ -312,8 +478,31 @@ final class AppState {
))
}
func lsvCalcPointsFor(vStart: Float, vStop: Float, scanRate: Float) -> UInt16 {
let d = Float(lsvDensity) ?? 1
let range = abs(vStop - vStart)
let raw: Float
switch lsvDensityMode {
case .ptsPerMv:
raw = range * d
case .ptsPerSec:
raw = abs(scanRate) < 0.001 ? 2 : (range / abs(scanRate)) * d
}
return max(2, min(500, UInt16(raw)))
}
func startClAuto() {
clAutoState = .lsvRunning
clAutoPotentials = nil
lsvPoints.removeAll()
let n = lsvCalcPointsFor(vStart: -1100, vStop: 1100, scanRate: 50)
send(buildSysexStartLsv(vStart: -1100, vStop: 1100, scanRate: 50, lpRtia: lsvRtia, numPoints: n))
status = "Auto Cl: running LSV sweep..."
}
func startPh() {
phResult = nil
transport.measuring = true
let stab = Float(phStabilize) ?? 30
send(buildSysexGetTemp())
send(buildSysexStartPh(stabilizeS: stab))
@ -358,6 +547,7 @@ final class AppState {
func collectRefs() {
collectingRefs = true
transport.measuring = true
eisRefs.removeAll()
status = "Starting reference collection..."
send(buildSysexStartRefs())
@ -365,6 +555,7 @@ final class AppState {
func getRefs() {
collectingRefs = true
transport.measuring = true
eisRefs.removeAll()
send(buildSysexGetRefs())
}
@ -382,8 +573,13 @@ final class AppState {
func startClean() {
let v = Float(cleanV) ?? 1200
let d = Float(cleanDur) ?? 30
transport.measuring = true
send(buildSysexStartClean(vMv: v, durationS: d))
status = String(format: "Cleaning: %.0f mV for %.0fs", v, d)
let t = transport
DispatchQueue.main.asyncAfter(deadline: .now() + Double(d) + 2) {
t.measuring = false
}
}
var hasCurrentRef: Bool {
@ -412,6 +608,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,
@ -421,7 +619,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) }
@ -430,6 +628,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,
@ -437,7 +637,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) }
@ -446,6 +646,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,
@ -453,7 +655,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) }
@ -462,6 +664,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,
@ -472,7 +676,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) }
@ -484,11 +688,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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

After

Width:  |  Height:  |  Size: 179 KiB

View File

@ -0,0 +1,208 @@
import Foundation
enum PeakKind {
case freeCl, totalCl, crossover
}
struct LsvPeak {
var vMv: Float
var iUa: Float
var kind: PeakKind
}
func smoothLsv(_ data: [Float], window: Int) -> [Float] {
let n = data.count
if n == 0 || window < 2 { return data }
let half = window / 2
var out = [Float](repeating: 0, count: n)
for i in 0..<n {
let lo = max(0, i - half)
let hi = min(n - 1, i + half)
var sum: Float = 0
for j in lo...hi { sum += data[j] }
out[i] = sum / Float(hi - lo + 1)
}
return out
}
func findExtrema(_ v: [Float], _ iSmooth: [Float], minProminence: Float) -> [(Int, Bool)] {
let n = iSmooth.count
if n < 3 { return [] }
var candidates: [(Int, Bool)] = []
for i in 1..<(n - 1) {
let prev = iSmooth[i - 1]
let curr = iSmooth[i]
let next = iSmooth[i + 1]
if curr > prev && curr > next {
candidates.append((i, true))
} else if curr < prev && curr < next {
candidates.append((i, false))
}
}
var result: [(Int, Bool)] = []
for (idx, isMax) in candidates {
let val = iSmooth[idx]
let leftSlice = iSmooth[..<idx]
let rightSlice = iSmooth[(idx + 1)...]
if isMax {
let lb = leftSlice.min() ?? val
let rb = rightSlice.min() ?? val
if val - max(lb, rb) >= minProminence { result.append((idx, isMax)) }
} else {
let lb = leftSlice.max() ?? val
let rb = rightSlice.max() ?? val
if min(lb, rb) - val >= minProminence { result.append((idx, isMax)) }
}
}
return result
}
/// Detect Q/HQ redox peak in the -100 to +600 mV window.
/// Returns peak voltage in mV if found.
func detectQhqPeak(_ points: [LsvPoint]) -> Float? {
if points.count < 5 { return nil }
let iVals = points.map { $0.iUa }
let vVals = points.map { $0.vMv }
let window = max(5, points.count / 50)
let smoothed = smoothLsv(iVals, window: window)
guard let iMin = smoothed.min(), let iMax = smoothed.max() else { return nil }
let prominence = (iMax - iMin) * 0.05
let extrema = findExtrema(vVals, smoothed, minProminence: prominence)
var bestIdx: Int? = nil
var bestVal: Float = -.infinity
for (idx, isMax) in extrema {
guard isMax, vVals[idx] >= -100, vVals[idx] <= 600 else { continue }
if smoothed[idx] > bestVal {
bestVal = smoothed[idx]
bestIdx = idx
}
}
if let idx = bestIdx { return vVals[idx] }
return nil
}
struct ClPotentials: Equatable {
var vFree: Float
var vFreeDetected: Bool
var vTotal: Float
var vTotalDetected: Bool
}
func deriveClPotentials(_ points: [LsvPoint]) -> ClPotentials {
let dflt = ClPotentials(vFree: 100, vFreeDetected: false, vTotal: -200, vTotalDetected: false)
guard points.count >= 5 else { return dflt }
let iVals = points.map { $0.iUa }
let vVals = points.map { $0.vMv }
let window = max(5, points.count / 50)
let smoothed = smoothLsv(iVals, window: window)
guard let iMin = smoothed.min(), let iMax = smoothed.max() else { return dflt }
let prominence = (iMax - iMin) * 0.05
let extrema = findExtrema(vVals, smoothed, minProminence: prominence)
// v_free: most prominent cathodic peak (isMax==false) in +300 to -300 mV
var vFree: Float = 100
var vFreeDetected = false
var freeIdx: Int? = nil
var freeBest: Float = .infinity
for (idx, isMax) in extrema {
guard !isMax, vVals[idx] >= -300, vVals[idx] <= 300 else { continue }
if smoothed[idx] < freeBest {
freeBest = smoothed[idx]
vFree = vVals[idx]
vFreeDetected = true
freeIdx = idx
}
}
// v_total: secondary cathodic peak between (vFree-100) and -500, excluding free peak
let totalHi = vFree - 100
let totalLo: Float = -500
var vTotal: Float = vFree - 300
var vTotalDetected = false
var totalBest: Float = .infinity
for (idx, isMax) in extrema {
guard !isMax, vVals[idx] >= totalLo, vVals[idx] <= totalHi, idx != freeIdx else { continue }
if smoothed[idx] < totalBest {
totalBest = smoothed[idx]
vTotal = vVals[idx]
vTotalDetected = true
}
}
vTotal = max(vTotal, -400)
return ClPotentials(vFree: vFree, vFreeDetected: vFreeDetected, vTotal: vTotal, vTotalDetected: vTotalDetected)
}
func detectLsvPeaks(_ points: [LsvPoint]) -> [LsvPeak] {
if points.count < 5 { return [] }
let iVals = points.map { $0.iUa }
let vVals = points.map { $0.vMv }
let window = max(5, points.count / 50)
let smoothed = smoothLsv(iVals, window: window)
guard let iMin = smoothed.min(), let iMax = smoothed.max() else { return [] }
let prominence = (iMax - iMin) * 0.05
let extrema = findExtrema(vVals, smoothed, minProminence: prominence)
var peaks: [LsvPeak] = []
// crossover: where current changes sign
for i in 1..<smoothed.count {
let prev = smoothed[i - 1]
let curr = smoothed[i]
let crossed = (prev > 0 && curr < 0) || (prev < 0 && curr > 0)
if crossed {
let aPrev = abs(prev)
let aCurr = abs(curr)
let frac = aPrev / (aPrev + aCurr)
let dv = vVals[i] - vVals[i - 1]
let vCross = vVals[i - 1] + frac * dv
peaks.append(LsvPeak(vMv: vCross, iUa: 0, kind: .crossover))
break
}
}
// largest peak in positive voltage region -> freeCl
var freeClIdx: Int? = nil
var freeClVal: Float = -.infinity
for (idx, isMax) in extrema {
guard isMax, vVals[idx] >= 0 else { continue }
if smoothed[idx] > freeClVal {
freeClVal = smoothed[idx]
freeClIdx = idx
}
}
if let idx = freeClIdx {
peaks.append(LsvPeak(vMv: vVals[idx], iUa: smoothed[idx], kind: .freeCl))
}
// largest peak in negative voltage region -> totalCl
var totalClIdx: Int? = nil
var totalClVal: Float = -.infinity
for (idx, isMax) in extrema {
guard isMax, vVals[idx] < 0 else { continue }
if smoothed[idx] > totalClVal {
totalClVal = smoothed[idx]
totalClIdx = idx
}
}
if let idx = totalClIdx {
peaks.append(LsvPeak(vMv: vVals[idx], iUa: smoothed[idx], kind: .totalCl))
}
return peaks
}

View File

@ -29,6 +29,13 @@ let RSP_REF_FRAME: UInt8 = 0x20
let RSP_REF_LP_RANGE: UInt8 = 0x21
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
let CMD_SET_SWEEP: UInt8 = 0x10
@ -46,9 +53,17 @@ let CMD_START_PH: UInt8 = 0x24
let CMD_START_CLEAN: UInt8 = 0x25
let CMD_SET_CELL_K: UInt8 = 0x28
let CMD_GET_CELL_K: UInt8 = 0x29
let CMD_START_REFS: UInt8 = 0x30
let CMD_GET_REFS: UInt8 = 0x31
let CMD_CLEAR_REFS: UInt8 = 0x32
let CMD_SET_CL_FACTOR: UInt8 = 0x33
let CMD_GET_CL_FACTOR: UInt8 = 0x34
let CMD_SET_PH_CAL: UInt8 = 0x35
let CMD_GET_PH_CAL: UInt8 = 0x36
let CMD_START_REFS: UInt8 = 0x30
let CMD_GET_REFS: UInt8 = 0x31
let CMD_CLEAR_REFS: UInt8 = 0x32
let CMD_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
@ -87,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]
@ -102,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
@ -123,6 +163,13 @@ enum EisMessage {
case refsDone
case refStatus(hasRefs: Bool)
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
}
// MARK: - Response parser
@ -136,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:
@ -174,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:
@ -193,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(
@ -208,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(
@ -254,6 +317,58 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? {
case RSP_CELL_K where p.count >= 5:
return .cellK(decodeFloat(p, at: 0))
case RSP_CL_FACTOR where p.count >= 5:
return .clFactor(decodeFloat(p, at: 0))
case RSP_PH_CAL where p.count >= 10:
return .phCal(
slope: decodeFloat(p, at: 0),
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
default:
return nil
}
@ -290,12 +405,13 @@ func buildSysexGetConfig() -> [UInt8] {
[0xF0, sysexMfr, CMD_GET_CONFIG, 0xF7]
}
func buildSysexStartLsv(vStart: Float, vStop: Float, scanRate: Float, lpRtia: LpRtia) -> [UInt8] {
func buildSysexStartLsv(vStart: Float, vStop: Float, scanRate: Float, lpRtia: LpRtia, numPoints: UInt16) -> [UInt8] {
var sx: [UInt8] = [0xF0, sysexMfr, CMD_START_LSV]
sx.append(contentsOf: encodeFloat(vStart))
sx.append(contentsOf: encodeFloat(vStop))
sx.append(contentsOf: encodeFloat(scanRate))
sx.append(lpRtia.rawValue)
sx.append(contentsOf: encodeU16(numPoints))
sx.append(0xF7)
return sx
}
@ -371,3 +487,52 @@ func buildSysexSetCellK(_ k: Float) -> [UInt8] {
func buildSysexGetCellK() -> [UInt8] {
[0xF0, sysexMfr, CMD_GET_CELL_K, 0xF7]
}
func buildSysexSetClFactor(_ f: Float) -> [UInt8] {
var sx: [UInt8] = [0xF0, sysexMfr, CMD_SET_CL_FACTOR]
sx.append(contentsOf: encodeFloat(f))
sx.append(0xF7)
return sx
}
func buildSysexGetClFactor() -> [UInt8] {
[0xF0, sysexMfr, CMD_GET_CL_FACTOR, 0xF7]
}
func buildSysexSetPhCal(_ slope: Float, _ offset: Float) -> [UInt8] {
var sx: [UInt8] = [0xF0, sysexMfr, CMD_SET_PH_CAL]
sx.append(contentsOf: encodeFloat(slope))
sx.append(contentsOf: encodeFloat(offset))
sx.append(0xF7)
return sx
}
func buildSysexGetPhCal() -> [UInt8] {
[0xF0, sysexMfr, CMD_GET_PH_CAL, 0xF7]
}
// MARK: - Session commands
func buildSysexSessionCreate(name: String) -> [UInt8] {
let nameBytes = Array(name.utf8.prefix(32))
var sx: [UInt8] = [0xF0, sysexMfr, CMD_SESSION_CREATE, UInt8(nameBytes.count)]
sx.append(contentsOf: nameBytes)
sx.append(0xF7)
return sx
}
func buildSysexSessionSwitch(id: UInt8) -> [UInt8] {
[0xF0, sysexMfr, CMD_SESSION_SWITCH, id, 0xF7]
}
func buildSysexSessionList() -> [UInt8] {
[0xF0, sysexMfr, CMD_SESSION_LIST, 0xF7]
}
func buildSysexSessionRename(id: UInt8, name: String) -> [UInt8] {
let nameBytes = Array(name.utf8.prefix(32))
var sx: [UInt8] = [0xF0, sysexMfr, CMD_SESSION_RENAME, id, UInt8(nameBytes.count)]
sx.append(contentsOf: nameBytes)
sx.append(0xF7)
return sx
}

View File

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

View File

@ -23,9 +23,13 @@ final class UDPManager: @unchecked Sendable {
var address: String
var port: UInt16
/// Suppress keepalive timeout during blocking firmware operations (pH, clean, refs)
var measuring: Bool = false
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
@ -46,6 +50,10 @@ final class UDPManager: @unchecked Sendable {
onMessage = handler
}
func setDisconnectHandler(_ handler: @escaping () -> Void) {
onDisconnect = handler
}
// MARK: - Connection
func connect() {
@ -64,8 +72,9 @@ final class UDPManager: @unchecked Sendable {
connection = conn
conn.stateUpdateHandler = { [weak self] newState in
guard let self else { return }
DispatchQueue.main.async {
self?.handleStateChange(newState)
self.handleStateChange(newState)
}
}
@ -76,7 +85,9 @@ final class UDPManager: @unchecked Sendable {
stopTimers()
connection?.cancel()
connection = nil
measuring = false
state = .disconnected
onDisconnect?()
}
func send(_ sysex: [UInt8]) {
@ -95,6 +106,8 @@ final class UDPManager: @unchecked Sendable {
send(buildSysexGetTemp())
send(buildSysexGetConfig())
send(buildSysexGetCellK())
send(buildSysexGetClFactor())
send(buildSysexGetPhCal())
startTimers()
receiveLoop()
@ -172,16 +185,18 @@ final class UDPManager: @unchecked Sendable {
private func startTimers() {
keepaliveTimer = Timer.scheduledTimer(withTimeInterval: Self.keepaliveInterval, repeats: true) { [weak self] _ in
self?.send(buildSysexGetTemp())
guard let self else { return }
self.send(buildSysexGetTemp())
}
timeoutTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
guard let self, self.state == .connected else { return }
guard let self, self.state == .connected, !self.measuring else { return }
if Date().timeIntervalSince(self.lastReceived) > Self.timeout {
self.state = .disconnected
self.stopTimers()
self.connection?.cancel()
self.connection = nil
self.onDisconnect?()
}
}
}

View File

@ -14,6 +14,8 @@ struct CalibrateView: View {
inputSection
resultsSection
cellConstantSection
chlorineCalSection
phCalibrationSection
}
.navigationTitle("Calibrate")
}
@ -115,6 +117,122 @@ struct CalibrateView: View {
}
}
// MARK: - Chlorine calibration
private var chlorineCalSection: some View {
Section("Chlorine Calibration") {
if let f = state.clFactor {
Text(String(format: "Cl factor: %.6f ppm/\u{00B5}A", f))
}
if let r = state.clResult {
Text(String(format: "Last free Cl peak: %.3f \u{00B5}A", r.iFreeUa))
}
HStack {
Text("Known Cl ppm")
Spacer()
TextField("ppm", text: $state.clCalKnownPpm)
.multilineTextAlignment(.trailing)
.frame(width: 80)
#if os(iOS)
.keyboardType(.decimalPad)
#endif
}
Button("Set Cl Factor") {
guard let r = state.clResult else {
state.status = "No chlorine measurement"
return
}
let knownPpm = Double(state.clCalKnownPpm) ?? 0
let peak = abs(Double(r.iFreeUa))
guard peak > 0 else {
state.status = "Peak current is zero"
return
}
let factor = knownPpm / peak
state.clFactor = factor
state.send(buildSysexSetClFactor(Float(factor)))
state.status = String(format: "Cl factor: %.6f ppm/\u{00B5}A", factor)
}
.disabled(state.clResult == nil)
}
}
// MARK: - pH calibration
private var phCalibrationSection: some View {
Section("pH Calibration (Q/HQ peak-shift)") {
if let s = state.phSlope, let o = state.phOffset {
Text(String(format: "slope: %.4f mV/pH offset: %.4f mV", s, o))
if let peak = detectQhqPeak(state.lsvPoints) {
if abs(s) > 1e-6 {
let ph = (Double(peak) - o) / s
Text(String(format: "Computed pH: %.2f (peak at %.1f mV)", ph, peak))
}
}
}
HStack {
Text("Known pH")
Spacer()
TextField("7.00", text: $state.phCalKnown)
.multilineTextAlignment(.trailing)
.frame(width: 80)
#if os(iOS)
.keyboardType(.decimalPad)
#endif
}
Button("Add Calibration Point") {
guard let peak = detectQhqPeak(state.lsvPoints) else {
state.status = "No Q/HQ peak found in LSV data"
return
}
guard let ph = Double(state.phCalKnown) else { return }
state.phCalPoints.append((ph: ph, mV: Double(peak)))
state.status = String(format: "pH cal point: pH=%.2f peak=%.1f mV (%d pts)",
ph, peak, state.phCalPoints.count)
}
.disabled(state.lsvPoints.isEmpty)
ForEach(Array(state.phCalPoints.enumerated()), id: \.offset) { i, pt in
Text(String(format: "%d. pH=%.2f peak=%.1f mV", i + 1, pt.ph, pt.mV))
.font(.caption)
}
Button("Clear Points") {
state.phCalPoints.removeAll()
state.status = "pH cal points cleared"
}
.disabled(state.phCalPoints.isEmpty)
Button("Compute & Set pH Cal") {
let pts = state.phCalPoints
guard pts.count >= 2 else {
state.status = "Need at least 2 calibration points"
return
}
let n = Double(pts.count)
let meanPh = pts.map(\.ph).reduce(0, +) / n
let meanV = pts.map(\.mV).reduce(0, +) / n
let num = pts.map { ($0.ph - meanPh) * ($0.mV - meanV) }.reduce(0, +)
let den = pts.map { ($0.ph - meanPh) * ($0.ph - meanPh) }.reduce(0, +)
guard abs(den) > 1e-12 else {
state.status = "Degenerate calibration data"
return
}
let slope = num / den
let offset = meanV - slope * meanPh
state.phSlope = slope
state.phOffset = offset
state.send(buildSysexSetPhCal(Float(slope), Float(offset)))
state.status = String(format: "pH cal set: slope=%.4f offset=%.4f", slope, offset)
}
.disabled(state.phCalPoints.count < 2)
}
}
// MARK: - Calculations
private func saltGrams(volumeGal: Double, ppm: Double) -> Double {

View File

@ -7,11 +7,13 @@ struct ChlorineView: View {
var body: some View {
VStack(spacing: 0) {
controlsRow
clPeakLabels
Divider()
GeometryReader { geo in
if geo.size.width > 700 {
HSplitLayout(ratio: 0.55) {
VStack(spacing: 4) {
voltammogramPlot
resultBanner
chlorinePlot
}
@ -21,8 +23,9 @@ struct ChlorineView: View {
} else {
ScrollView {
VStack(spacing: 12) {
voltammogramPlot.frame(height: 250)
resultBanner
chlorinePlot.frame(height: 350)
chlorinePlot.frame(height: 250)
clTable.frame(height: 300)
}
.padding()
@ -37,18 +40,54 @@ struct ChlorineView: View {
private var controlsRow: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
LabeledField("Cond mV", text: $state.clCondV, width: 70)
LabeledField("Cond ms", text: $state.clCondT, width: 70)
LabeledField("Free mV", text: $state.clFreeV, width: 70)
LabeledField("Total mV", text: $state.clTotalV, width: 70)
LabeledField("Settle ms", text: $state.clDepT, width: 70)
LabeledField("Meas ms", text: $state.clMeasT, width: 70)
if state.clManualPeaks {
Button("Start LSV") { state.startLSV() }
.buttonStyle(ActionButtonStyle(color: .green))
LabeledPicker("RTIA", selection: $state.clRtia, items: LpRtia.allCases) { $0.label }
.frame(width: 120)
Button("Manual") {
state.clManualPeaks = false
state.lsvPeaks = detectLsvPeaks(state.lsvPoints)
}
.font(.caption)
Button("Measure") { state.startChlorine() }
.buttonStyle(ActionButtonStyle(color: .green))
Divider().frame(height: 24)
LabeledField("Cond mV", text: $state.clCondV, width: 70)
LabeledField("Cond ms", text: $state.clCondT, width: 70)
LabeledField("Free mV", text: $state.clFreeV, width: 70)
LabeledField("Total mV", text: $state.clTotalV, width: 70)
LabeledField("Settle ms", text: $state.clDepT, width: 70)
LabeledField("Meas ms", text: $state.clMeasT, width: 70)
LabeledPicker("RTIA", selection: $state.clRtia, items: LpRtia.allCases) { $0.label }
.frame(width: 120)
Button("Measure") { state.startChlorine() }
.buttonStyle(ActionButtonStyle(color: .green))
} else {
Button("Start Auto") { state.startClAuto() }
.buttonStyle(ActionButtonStyle(color: .green))
.disabled(state.clAutoState != .idle)
Button("Auto") {
state.clManualPeaks = true
state.lsvPeaks.removeAll()
}
.font(.caption)
LabeledPicker("RTIA", selection: $state.clRtia, items: LpRtia.allCases) { $0.label }
.frame(width: 120)
if let pots = state.clAutoPotentials {
Text(String(format: "free=%.0f%@ total=%.0f%@",
pots.vFree,
pots.vFreeDetected ? "" : "?",
pots.vTotal,
pots.vTotalDetected ? "" : "?"))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
.padding(.horizontal)
.padding(.vertical, 8)
@ -68,6 +107,12 @@ struct ChlorineView: View {
Text(String(format: "Combined: %.3f uA", r.iTotalUa - r.iFreeUa))
.foregroundStyle(.secondary)
if let f = state.clFactor {
let ppm = f * Double(abs(r.iFreeUa))
Text(String(format: "Free Cl: %.2f ppm", ppm))
.foregroundStyle(.cyan)
}
if let (_, refR) = state.clRef {
Divider().frame(height: 16)
Text(String(format: "vs Ref: dFree=%.3f dTotal=%.3f",
@ -82,7 +127,102 @@ struct ChlorineView: View {
}
}
// MARK: - Plot
// MARK: - Peak labels
@ViewBuilder
private var clPeakLabels: some View {
if !state.lsvPeaks.isEmpty {
HStack(spacing: 12) {
ForEach(Array(state.lsvPeaks.enumerated()), id: \.offset) { _, peak in
let label: String = {
switch peak.kind {
case .freeCl: return String(format: "Free: %.0fmV %.2fuA", peak.vMv, peak.iUa)
case .totalCl: return String(format: "Total: %.0fmV %.2fuA", peak.vMv, peak.iUa)
case .crossover: return String(format: "X-over: %.0fmV", peak.vMv)
}
}()
Text(label)
.font(.caption)
.foregroundStyle(clPeakColor(peak.kind))
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
}
}
private func clPeakColor(_ kind: PeakKind) -> Color {
switch kind {
case .freeCl: .green
case .totalCl: .orange
case .crossover: .purple
}
}
// MARK: - Voltammogram
private var voltammogramPlot: some View {
Group {
if state.lsvPoints.isEmpty {
Text("No LSV data")
.foregroundStyle(Color(white: 0.4))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black.opacity(0.3))
} else {
PlotContainer(title: "") {
Chart {
if let ref = state.lsvRef {
ForEach(Array(ref.enumerated()), id: \.offset) { _, pt in
LineMark(x: .value("V", Double(pt.vMv)), y: .value("I", Double(pt.iUa)))
.foregroundStyle(Color.gray.opacity(0.5))
.lineStyle(StrokeStyle(lineWidth: 1.5))
}
}
ForEach(Array(state.lsvPoints.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(state.lsvPoints.enumerated()), id: \.offset) { _, pt in
PointMark(x: .value("V", Double(pt.vMv)), y: .value("I", Double(pt.iUa)))
.foregroundStyle(Color.yellow)
.symbolSize(16)
}
ForEach(Array(state.lsvPeaks.enumerated()), id: \.offset) { _, peak in
PointMark(x: .value("V", Double(peak.vMv)), y: .value("I", Double(peak.iUa)))
.foregroundStyle(clPeakColor(peak.kind))
.symbolSize(100)
.symbol(.diamond)
RuleMark(x: .value("V", Double(peak.vMv)))
.foregroundStyle(clPeakColor(peak.kind).opacity(0.3))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4]))
}
}
.chartXAxisLabel("V (mV)")
.chartYAxisLabel("I (uA)", position: .leading)
.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(Color.yellow)
}
}
.padding(8)
}
}
}
.background(Color.black.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
// MARK: - Chlorine plot
private var chlorinePlot: some View {
Group {

View File

@ -285,46 +285,68 @@ struct EISView: View {
with: .color(nyqColor))
}
// circle fit
if let fit = kasaCircleFit(points: points.map { (Double($0.zReal), Double(-$0.zImag)) }) {
let disc = fit.r * fit.r - fit.cy * fit.cy
if disc > 0 {
let sd = sqrt(disc)
let rs = fit.cx - sd
let rp = 2 * sd
// fit overlay
let fitColor = Color(red: 0.7, green: 0.3, blue: 0.9).opacity(0.5)
let fitPtColor = Color(red: 0.9, green: 0.4, blue: 0.9)
if rp > 0 {
let thetaR = atan2(-fit.cy, sd)
var thetaL = atan2(-fit.cy, -sd)
if thetaL < thetaR { thetaL += 2 * .pi }
if let result = fitNyquist(points: points.map { (Double($0.zReal), Double(-$0.zImag)) }) {
switch result {
case .circle(let fit):
let disc = fit.r * fit.r - fit.cy * fit.cy
if disc > 0 {
let sd = sqrt(disc)
let rs = fit.cx - sd
let rp = 2 * sd
let nArc = 120
var arcPath = Path()
for i in 0...nArc {
let t = thetaR + (thetaL - thetaR) * Double(i) / Double(nArc)
let ax = fit.cx + fit.r * cos(t)
let ay = fit.cy + fit.r * sin(t)
let pt = CGPoint(x: lx(CGFloat(ax)), y: ly(CGFloat(ay)))
if i == 0 { arcPath.move(to: pt) } else { arcPath.addLine(to: pt) }
if rp > 0 {
let thetaR = atan2(-fit.cy, sd)
var thetaL = atan2(-fit.cy, -sd)
if thetaL < thetaR { thetaL += 2 * .pi }
let nArc = 120
var arcPath = Path()
for i in 0...nArc {
let t = thetaR + (thetaL - thetaR) * Double(i) / Double(nArc)
let ax = fit.cx + fit.r * cos(t)
let ay = fit.cy + fit.r * sin(t)
let pt = CGPoint(x: lx(CGFloat(ax)), y: ly(CGFloat(ay)))
if i == 0 { arcPath.move(to: pt) } else { arcPath.addLine(to: pt) }
}
context.stroke(arcPath, with: .color(fitColor), lineWidth: 1.5)
let rsScr = CGPoint(x: lx(CGFloat(rs)), y: ly(0))
let rpScr = CGPoint(x: lx(CGFloat(rs + rp)), y: ly(0))
context.fill(Path(ellipseIn: CGRect(x: rsScr.x - 5, y: rsScr.y - 5, width: 10, height: 10)),
with: .color(fitPtColor))
context.fill(Path(ellipseIn: CGRect(x: rpScr.x - 5, y: rpScr.y - 5, width: 10, height: 10)),
with: .color(fitPtColor))
context.draw(
Text(String(format: "Rs=%.0f", rs)).font(.caption2).foregroundStyle(fitPtColor),
at: CGPoint(x: rsScr.x, y: rsScr.y + 14))
context.draw(
Text(String(format: "Rp=%.0f", rp)).font(.caption2).foregroundStyle(fitPtColor),
at: CGPoint(x: rpScr.x, y: rpScr.y + 14))
}
context.stroke(arcPath, with: .color(Color(red: 0.7, green: 0.3, blue: 0.9).opacity(0.5)),
lineWidth: 1.5)
// Rs and Rp markers
let fitPtColor = Color(red: 0.9, green: 0.4, blue: 0.9)
let rsScr = CGPoint(x: lx(CGFloat(rs)), y: ly(0))
let rpScr = CGPoint(x: lx(CGFloat(rs + rp)), y: ly(0))
context.fill(Path(ellipseIn: CGRect(x: rsScr.x - 5, y: rsScr.y - 5, width: 10, height: 10)),
with: .color(fitPtColor))
context.fill(Path(ellipseIn: CGRect(x: rpScr.x - 5, y: rpScr.y - 5, width: 10, height: 10)),
with: .color(fitPtColor))
context.draw(
Text(String(format: "Rs=%.0f", rs)).font(.caption2).foregroundStyle(fitPtColor),
at: CGPoint(x: rsScr.x, y: rsScr.y + 14))
context.draw(
Text(String(format: "Rp=%.0f", rp)).font(.caption2).foregroundStyle(fitPtColor),
at: CGPoint(x: rpScr.x, y: rpScr.y + 14))
}
case .linear(let fit):
let xVals = points.map { CGFloat($0.zReal) }.filter { $0.isFinite }
guard let xMin = xVals.min(), let xMax = xVals.max() else { break }
let pad = (xMax - xMin) * 0.1
let x0 = Double(xMin - pad)
let x1 = Double(xMax + pad)
let y0 = fit.slope * x0 + fit.yIntercept
let y1 = fit.slope * x1 + fit.yIntercept
var linePath = Path()
linePath.move(to: CGPoint(x: lx(CGFloat(x0)), y: ly(CGFloat(y0))))
linePath.addLine(to: CGPoint(x: lx(CGFloat(x1)), y: ly(CGFloat(y1))))
context.stroke(linePath, with: .color(fitColor), lineWidth: 1.5)
let rsScr = CGPoint(x: lx(CGFloat(fit.rs)), y: ly(0))
context.fill(Path(ellipseIn: CGRect(x: rsScr.x - 5, y: rsScr.y - 5, width: 10, height: 10)),
with: .color(fitPtColor))
context.draw(
Text(String(format: "Rs=%.0f", fit.rs)).font(.caption2).foregroundStyle(fitPtColor),
at: CGPoint(x: rsScr.x, y: rsScr.y + 14))
}
}
}
@ -370,7 +392,7 @@ struct EISView: View {
}
}
// MARK: - Kasa circle fit (ported from plot.rs)
// MARK: - Nyquist fit
struct CircleFitResult {
let cx: Double
@ -378,6 +400,17 @@ struct CircleFitResult {
let r: Double
}
struct LinearFitResult {
let slope: Double
let yIntercept: Double
let rs: Double
}
enum NyquistFitResult {
case circle(CircleFitResult)
case linear(LinearFitResult)
}
func kasaCircleFit(points: [(Double, Double)]) -> CircleFitResult? {
let all = points.filter { $0.0.isFinite && $0.1.isFinite }
guard all.count >= 4 else { return nil }
@ -463,6 +496,57 @@ private func kasaFitRaw(_ pts: [(Double, Double)]) -> (Double, Double, Double)?
return (cx, cy, sqrt(rSq))
}
private func cumulativeTurning(_ pts: [(Double, Double)]) -> Double {
guard pts.count >= 3 else { return 0 }
var total = 0.0
for i in 1..<(pts.count - 1) {
let (dx1, dy1) = (pts[i].0 - pts[i-1].0, pts[i].1 - pts[i-1].1)
let (dx2, dy2) = (pts[i+1].0 - pts[i].0, pts[i+1].1 - pts[i].1)
let cross = dx1 * dy2 - dy1 * dx2
let dot = dx1 * dx2 + dy1 * dy2
total += abs(atan2(cross, dot))
}
return total
}
private func fitLinear(_ pts: [(Double, Double)]) -> LinearFitResult? {
guard pts.count >= 2 else { return nil }
let n = Double(pts.count)
let sx = pts.map(\.0).reduce(0, +)
let sy = pts.map(\.1).reduce(0, +)
let sx2 = pts.map { $0.0 * $0.0 }.reduce(0, +)
let sxy = pts.map { $0.0 * $0.1 }.reduce(0, +)
let denom = n * sx2 - sx * sx
guard abs(denom) > 1e-20 else { return nil }
let slope = (n * sxy - sx * sy) / denom
let yInt = (sy - slope * sx) / n
let rs = abs(slope) > 1e-10 ? -yInt / slope : sx / n
return LinearFitResult(slope: slope, yIntercept: yInt, rs: rs)
}
func fitNyquist(points: [(Double, Double)]) -> NyquistFitResult? {
let all = points.filter { $0.0.isFinite && $0.1.isFinite }
guard all.count >= 4 else { return nil }
if cumulativeTurning(all) < 0.524 {
if let lin = fitLinear(all) { return .linear(lin) }
}
if let circle = kasaCircleFit(points: points) {
let avgErr = all.map { p in
abs(sqrt((p.0 - circle.cx) * (p.0 - circle.cx) +
(p.1 - circle.cy) * (p.1 - circle.cy)) - circle.r)
}.reduce(0, +) / (Double(all.count) * circle.r)
if avgErr > 0.15 {
if let lin = fitLinear(all) { return .linear(lin) }
}
return .circle(circle)
}
if let lin = fitLinear(all) { return .linear(lin) }
return nil
}
// MARK: - Canvas drawing helpers
private func drawPolyline(context: GraphicsContext, points: [CGPoint], color: Color, width: CGFloat) {

View File

@ -7,6 +7,7 @@ struct LSVView: View {
var body: some View {
VStack(spacing: 0) {
controlsRow
peakLabels
Divider()
GeometryReader { geo in
if geo.size.width > 700 {
@ -40,6 +41,15 @@ struct LSVView: View {
LabeledPicker("RTIA", selection: $state.lsvRtia, items: LpRtia.allCases) { $0.label }
.frame(width: 120)
LabeledPicker("Density", selection: $state.lsvDensityMode, items: LsvDensityMode.allCases) { $0.rawValue }
.frame(width: 100)
LabeledField("Value", text: $state.lsvDensity, width: 60)
Text("\(state.lsvCalcPoints()) pts")
.font(.caption)
.foregroundStyle(.secondary)
Button("Start LSV") { state.startLSV() }
.buttonStyle(ActionButtonStyle(color: .green))
}
@ -86,6 +96,18 @@ struct LSVView: View {
.foregroundStyle(Color.yellow)
.symbolSize(16)
}
ForEach(Array(state.lsvPeaks.enumerated()), id: \.offset) { _, peak in
PointMark(
x: .value("V", Double(peak.vMv)),
y: .value("I", Double(peak.iUa))
)
.foregroundStyle(peakColor(peak.kind))
.symbolSize(100)
.symbol(.diamond)
RuleMark(x: .value("V", Double(peak.vMv)))
.foregroundStyle(peakColor(peak.kind).opacity(0.3))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4]))
}
}
.chartXAxisLabel("V (mV)")
.chartYAxisLabel("I (uA)", position: .leading)
@ -115,6 +137,36 @@ struct LSVView: View {
.clipShape(RoundedRectangle(cornerRadius: 8))
}
@ViewBuilder
private var peakLabels: some View {
if !state.lsvPeaks.isEmpty {
HStack(spacing: 12) {
ForEach(Array(state.lsvPeaks.enumerated()), id: \.offset) { _, peak in
let label: String = {
switch peak.kind {
case .freeCl: return String(format: "Free: %.0fmV %.2fuA", peak.vMv, peak.iUa)
case .totalCl: return String(format: "Total: %.0fmV %.2fuA", peak.vMv, peak.iUa)
case .crossover: return String(format: "X-over: %.0fmV", peak.vMv)
}
}()
Text(label)
.font(.caption)
.foregroundStyle(peakColor(peak.kind))
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
}
}
private func peakColor(_ kind: PeakKind) -> Color {
switch kind {
case .freeCl: .green
case .totalCl: .orange
case .crossover: .purple
}
}
// MARK: - Table
private var lsvTable: some View {

View File

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

View File

@ -1,5 +1,6 @@
import SwiftUI
import GRDB
import UniformTypeIdentifiers
struct SessionView: View {
@Bindable var state: AppState
@ -27,7 +28,9 @@ struct SessionView: View {
private func startObserving() {
sessionCancellable = Storage.shared.observeSessions { sessions in
self.sessions = sessions
Task { @MainActor in
self.sessions = sessions
}
}
}
@ -191,16 +194,31 @@ struct SessionDetailView: View {
@State private var editing = false
@State private var editLabel = ""
@State private var editNotes = ""
@State private var showingFileImporter = false
@State private var showingShareSheet = false
@State private var exportFileURL: URL?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
measurementsList
NavigationStack {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
measurementsList
}
}
.onAppear { loadMeasurements() }
.onChange(of: session.id) { loadMeasurements() }
.sheet(isPresented: $editing) { editSheet }
.sheet(isPresented: $showingShareSheet) {
if let url = exportFileURL {
ShareSheet(items: [url])
}
}
.fileImporter(
isPresented: $showingFileImporter,
allowedContentTypes: [.plainText],
onCompletion: handleImportedFile
)
}
private func loadMeasurements() {
@ -208,6 +226,40 @@ struct SessionDetailView: View {
measurements = (try? Storage.shared.fetchMeasurements(sessionId: sid)) ?? []
}
private func exportSession() {
guard let sid = session.id else { return }
do {
let toml = try Storage.shared.exportSession(sid)
let name = (session.label ?? "session").replacingOccurrences(of: " ", with: "_")
let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(name).toml")
try toml.write(to: url, atomically: true, encoding: .utf8)
exportFileURL = url
showingShareSheet = true
} catch {
state.status = "Export failed: \(error.localizedDescription)"
}
}
private func handleImportedFile(_ result: Result<URL, Error>) {
switch result {
case .success(let url):
guard url.startAccessingSecurityScopedResource() else {
state.status = "Cannot access file"
return
}
defer { url.stopAccessingSecurityScopedResource() }
do {
let toml = try String(contentsOf: url, encoding: .utf8)
let _ = try Storage.shared.importSession(from: toml)
state.status = "Session imported"
} catch {
state.status = "Import failed: \(error.localizedDescription)"
}
case .failure(let error):
state.status = "File error: \(error.localizedDescription)"
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
@ -222,6 +274,14 @@ struct SessionDetailView: View {
Image(systemName: "pencil.circle")
.imageScale(.large)
}
Button(action: { exportSession() }) {
Image(systemName: "square.and.arrow.up")
.imageScale(.large)
}
Button(action: { showingFileImporter = true }) {
Image(systemName: "square.and.arrow.down")
.imageScale(.large)
}
}
HStack {
Text(session.startedAt, style: .date)
@ -252,7 +312,11 @@ struct SessionDetailView: View {
} else {
List {
ForEach(measurements, id: \.id) { meas in
MeasurementRow(measurement: meas, state: state)
NavigationLink {
MeasurementDataView(measurement: meas)
} label: {
MeasurementRow(measurement: meas, state: state)
}
}
.onDelete { indices in
for idx in indices {
@ -368,3 +432,11 @@ struct MeasurementRow: View {
return (try? Storage.shared.dataPointCount(measurementId: mid)) ?? 0
}
}
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
}

545
cue/Cargo.lock generated
View File

@ -132,6 +132,28 @@ dependencies = [
"libloading 0.7.4",
]
[[package]]
name = "ashpd"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
dependencies = [
"async-fs",
"async-net",
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.2",
"raw-window-handle",
"serde",
"serde_repr",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus 5.14.0",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@ -210,6 +232,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-net"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
dependencies = [
"async-io",
"blocking",
"futures-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
@ -340,6 +373,15 @@ dependencies = [
"objc2 0.5.2",
]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2 0.6.4",
]
[[package]]
name = "blocking"
version = "1.6.2"
@ -722,6 +764,7 @@ dependencies = [
"futures",
"iced",
"muda",
"rfd",
"rusqlite",
"serde",
"serde_json",
@ -760,7 +803,7 @@ dependencies = [
"rust-ini",
"web-sys",
"winreg",
"zbus",
"zbus 4.4.0",
]
[[package]]
@ -839,9 +882,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.11.0",
"block2 0.6.2",
"libc",
"objc2 0.6.4",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "dlib"
version = "0.5.3"
@ -1125,6 +1181,15 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures"
version = "0.3.32"
@ -1656,12 +1721,115 @@ dependencies = [
"winit",
]
[[package]]
name = "icu_collections"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "indexmap"
version = "2.13.0"
@ -1848,6 +2016,12 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "lock_api"
version = "0.4.14"
@ -2155,7 +2329,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
dependencies = [
"bitflags 2.11.0",
"block2",
"block2 0.5.1",
"libc",
"objc2 0.5.2",
"objc2-core-data",
@ -2171,6 +2345,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.11.0",
"block2 0.6.2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
@ -2183,7 +2358,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
dependencies = [
"bitflags 2.11.0",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-core-location",
"objc2-foundation 0.2.2",
@ -2195,7 +2370,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@ -2207,7 +2382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
dependencies = [
"bitflags 2.11.0",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@ -2242,7 +2417,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
"objc2-metal",
@ -2254,7 +2429,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-contacts",
"objc2-foundation 0.2.2",
@ -2273,7 +2448,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
dependencies = [
"bitflags 2.11.0",
"block2",
"block2 0.5.1",
"dispatch",
"libc",
"objc2 0.5.2",
@ -2307,7 +2482,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
@ -2320,7 +2495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
dependencies = [
"bitflags 2.11.0",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@ -2332,7 +2507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
dependencies = [
"bitflags 2.11.0",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
"objc2-metal",
@ -2367,7 +2542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
dependencies = [
"bitflags 2.11.0",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-cloud-kit",
"objc2-core-data",
@ -2387,7 +2562,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@ -2399,7 +2574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
dependencies = [
"bitflags 2.11.0",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-core-location",
"objc2-foundation 0.2.2",
@ -2566,7 +2741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand",
"rand 0.8.5",
]
[[package]]
@ -2673,6 +2848,21 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pollster"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "potential_utf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@ -2759,8 +2949,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
@ -2770,7 +2970,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
@ -2782,6 +2992,15 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "range-alloc"
version = "0.1.5"
@ -2883,6 +3102,30 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "rfd"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
dependencies = [
"ashpd",
"block2 0.6.2",
"dispatch2",
"js-sys",
"log",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"pollster",
"raw-window-handle",
"urlencoding",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
@ -3276,6 +3519,12 @@ dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_assertions"
version = "1.1.0"
@ -3327,6 +3576,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
@ -3437,6 +3697,16 @@ dependencies = [
"tracing",
]
[[package]]
name = "tinystr"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.10.0"
@ -3688,6 +3958,42 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
"serde_derive",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
dependencies = [
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
@ -4421,7 +4727,7 @@ dependencies = [
"android-activity",
"atomic-waker",
"bitflags 2.11.0",
"block2",
"block2 0.5.1",
"bytemuck",
"calloop 0.13.0",
"cfg_aliases 0.2.1",
@ -4578,6 +4884,12 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "writeable"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "x11-dl"
version = "2.21.0"
@ -4657,6 +4969,29 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1"
[[package]]
name = "yoke"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"synstructure",
]
[[package]]
name = "zbus"
version = "4.4.0"
@ -4681,7 +5016,7 @@ dependencies = [
"hex",
"nix",
"ordered-stream",
"rand",
"rand 0.8.5",
"serde",
"serde_repr",
"sha1",
@ -4690,9 +5025,44 @@ dependencies = [
"uds_windows",
"windows-sys 0.52.0",
"xdg-home",
"zbus_macros",
"zbus_names",
"zvariant",
"zbus_macros 4.4.0",
"zbus_names 3.0.0",
"zvariant 4.2.0",
]
[[package]]
name = "zbus"
version = "5.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix 1.1.4",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow",
"zbus_macros 5.14.0",
"zbus_names 4.3.1",
"zvariant 5.10.0",
]
[[package]]
@ -4705,7 +5075,22 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils",
"zvariant_utils 2.1.0",
]
[[package]]
name = "zbus_macros"
version = "5.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.117",
"zbus_names 4.3.1",
"zvariant 5.10.0",
"zvariant_utils 3.3.0",
]
[[package]]
@ -4716,7 +5101,18 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [
"serde",
"static_assertions",
"zvariant",
"zvariant 4.2.0",
]
[[package]]
name = "zbus_names"
version = "4.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
dependencies = [
"serde",
"winnow",
"zvariant 5.10.0",
]
[[package]]
@ -4745,6 +5141,60 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zerofrom"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"synstructure",
]
[[package]]
name = "zerotrie"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "zmij"
version = "1.0.21"
@ -4761,7 +5211,22 @@ dependencies = [
"enumflags2",
"serde",
"static_assertions",
"zvariant_derive",
"zvariant_derive 4.2.0",
]
[[package]]
name = "zvariant"
version = "5.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b"
dependencies = [
"endi",
"enumflags2",
"serde",
"url",
"winnow",
"zvariant_derive 5.10.0",
"zvariant_utils 3.3.0",
]
[[package]]
@ -4774,7 +5239,20 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils",
"zvariant_utils 2.1.0",
]
[[package]]
name = "zvariant_derive"
version = "5.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils 3.3.0",
]
[[package]]
@ -4787,3 +5265,16 @@ dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "zvariant_utils"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.117",
"winnow",
]

View File

@ -13,6 +13,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = { version = "0.8", features = ["preserve_order"] }
dirs-next = "2"
rfd = "0.15"
[target.'cfg(windows)'.build-dependencies]
winres = "0.1"

Binary file not shown.

View File

@ -1,7 +1,7 @@
use futures::SinkExt;
use iced::widget::{
button, canvas, column, container, pane_grid, pick_list, row, scrollable, text, text_editor,
text_input,
button, canvas, column, container, pane_grid, pick_list, row, rule, scrollable, text,
text_editor, text_input,
};
use iced::widget::button::Style as ButtonStyle;
use iced::{Border, Color, Element, Length, Subscription, Task, Theme};
@ -17,6 +17,32 @@ use crate::protocol::{
use crate::storage::{self, Session, Storage};
use crate::udp::UdpEvent;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClAutoState {
Idle,
LsvRunning,
MeasureRunning,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LsvDensityMode {
PtsPerMv,
PtsPerSec,
}
impl LsvDensityMode {
pub const ALL: &[Self] = &[Self::PtsPerMv, Self::PtsPerSec];
}
impl std::fmt::Display for LsvDensityMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PtsPerMv => f.write_str("pts/mV"),
Self::PtsPerSec => f.write_str("pts/s"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Eis,
@ -71,6 +97,8 @@ pub enum Message {
LsvStopVChanged(String),
LsvScanRateChanged(String),
LsvRtiaSelected(LpRtia),
LsvDensityModeSelected(LsvDensityMode),
LsvDensityChanged(String),
StartLsv,
/* Amperometry */
AmpVholdChanged(String),
@ -88,6 +116,8 @@ pub enum Message {
ClMeasTChanged(String),
ClRtiaSelected(LpRtia),
StartCl,
StartClAuto,
ClToggleManual,
/* pH */
PhStabilizeChanged(String),
StartPh,
@ -98,6 +128,13 @@ pub enum Message {
CalBleachChanged(String),
CalTempChanged(String),
CalComputeK,
ClCalKnownPpmChanged(String),
ClSetFactor,
/* pH calibration */
PhCalKnownChanged(String),
PhAddCalPoint,
PhClearCalPoints,
PhComputeAndSetCal,
/* Global */
PollTemp,
NativeMenuTick,
@ -124,11 +161,24 @@ pub enum Message {
BrowseLoadAsReference(i64),
BrowseDeleteMeasurement(i64),
BrowseBack,
ExportSession(i64),
ImportSession,
/* Misc */
Reconnect,
UdpAddrChanged(String),
}
fn lsv_calc_points(v_start: f32, v_stop: f32, scan_rate: f32, density: f32, mode: LsvDensityMode) -> u16 {
let range = (v_stop - v_start).abs();
let raw = match mode {
LsvDensityMode::PtsPerMv => range * density,
LsvDensityMode::PtsPerSec => {
if scan_rate.abs() < 0.001 { 2.0 } else { (range / scan_rate.abs()) * density }
}
};
(raw as u16).clamp(2, 500)
}
pub struct App {
tab: Tab,
status: String,
@ -170,6 +220,9 @@ pub struct App {
lsv_stop_v: String,
lsv_scan_rate: String,
lsv_rtia: LpRtia,
lsv_density_mode: LsvDensityMode,
lsv_density: String,
lsv_peaks: Vec<crate::lsv_analysis::LsvPeak>,
lsv_data: text_editor::Content,
/* Amp */
@ -194,11 +247,17 @@ pub struct App {
cl_meas_t: String,
cl_rtia: LpRtia,
cl_data: text_editor::Content,
cl_manual_peaks: bool,
cl_auto_state: ClAutoState,
cl_auto_potentials: Option<crate::lsv_analysis::ClPotentials>,
/* pH */
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>>,
@ -227,6 +286,12 @@ pub struct App {
cal_bleach_pct: String,
cal_temp_c: String,
cal_cell_constant: Option<f32>,
cl_factor: Option<f32>,
cl_cal_known_ppm: String,
ph_slope: Option<f32>,
ph_offset: Option<f32>,
ph_cal_points: Vec<(f32, f32)>,
ph_cal_known: String,
/* Global */
temp_c: f32,
@ -407,6 +472,9 @@ impl App {
lsv_stop_v: "500".into(),
lsv_scan_rate: "50".into(),
lsv_rtia: LpRtia::R10K,
lsv_density_mode: LsvDensityMode::PtsPerMv,
lsv_density: "1".into(),
lsv_peaks: Vec::new(),
lsv_data: text_editor::Content::with_text(&fmt_lsv(&[])),
amp_points: Vec::new(),
@ -429,10 +497,15 @@ impl App {
cl_meas_t: "5000".into(),
cl_rtia: LpRtia::R10K,
cl_data: text_editor::Content::with_text(&fmt_cl(&[])),
cl_manual_peaks: false,
cl_auto_state: ClAutoState::Idle,
cl_auto_potentials: None,
ph_result: None,
ph_stabilize: "30".into(),
current_esp_ts: None,
eis_ref: None,
lsv_ref: None,
amp_ref: None,
@ -457,6 +530,12 @@ impl App {
cal_bleach_pct: "7.825".into(),
cal_temp_c: "40".into(),
cal_cell_constant: None,
cl_factor: None,
cl_cal_known_ppm: String::from("5"),
ph_slope: None,
ph_offset: None,
ph_cal_points: vec![],
ph_cal_known: String::from("7.00"),
temp_c: 25.0,
conn_gen: 0,
@ -482,7 +561,7 @@ impl App {
"rcal": format!("{}", self.rcal),
"electrode": format!("{}", self.electrode),
});
if let Ok(mid) = self.storage.create_measurement(session_id, "eis", &params.to_string()) {
if let Ok(mid) = self.storage.create_measurement(session_id, "eis", &params.to_string(), self.current_esp_ts) {
let pts: Vec<(i32, String)> = self.eis_points.iter().enumerate()
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
.collect();
@ -497,7 +576,7 @@ impl App {
"scan_rate": self.lsv_scan_rate,
"rtia": format!("{}", self.lsv_rtia),
});
if let Ok(mid) = self.storage.create_measurement(session_id, "lsv", &params.to_string()) {
if let Ok(mid) = self.storage.create_measurement(session_id, "lsv", &params.to_string(), self.current_esp_ts) {
let pts: Vec<(i32, String)> = self.lsv_points.iter().enumerate()
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
.collect();
@ -512,7 +591,7 @@ impl App {
"duration_s": self.amp_duration,
"rtia": format!("{}", self.amp_rtia),
});
if let Ok(mid) = self.storage.create_measurement(session_id, "amp", &params.to_string()) {
if let Ok(mid) = self.storage.create_measurement(session_id, "amp", &params.to_string(), self.current_esp_ts) {
let pts: Vec<(i32, String)> = self.amp_points.iter().enumerate()
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
.collect();
@ -530,7 +609,7 @@ impl App {
"meas_t": self.cl_meas_t,
"rtia": format!("{}", self.cl_rtia),
});
if let Ok(mid) = self.storage.create_measurement(session_id, "chlorine", &params.to_string()) {
if let Ok(mid) = self.storage.create_measurement(session_id, "chlorine", &params.to_string(), self.current_esp_ts) {
let mut pts: Vec<(i32, String)> = self.cl_points.iter().enumerate()
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
.collect();
@ -547,7 +626,7 @@ impl App {
let params = serde_json::json!({
"stabilize_s": self.ph_stabilize,
});
if let Ok(mid) = self.storage.create_measurement(session_id, "ph", &params.to_string()) {
if let Ok(mid) = self.storage.create_measurement(session_id, "ph", &params.to_string(), self.current_esp_ts) {
if let Ok(j) = serde_json::to_string(result) {
let _ = self.storage.add_data_point(mid, 0, &j);
}
@ -561,6 +640,8 @@ impl App {
self.connected = true;
self.send_cmd(&protocol::build_sysex_get_config());
self.send_cmd(&protocol::build_sysex_get_cell_k());
self.send_cmd(&protocol::build_sysex_get_cl_factor());
self.send_cmd(&protocol::build_sysex_get_ph_cal());
}
Message::DeviceStatus(s) => {
if s.contains("Reconnecting") || s.contains("Connecting") {
@ -570,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 {
@ -615,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));
@ -630,9 +712,41 @@ impl App {
if let Some(sid) = self.current_session {
self.save_lsv(sid);
}
self.status = format!("LSV complete: {} points", self.lsv_points.len());
self.lsv_peaks = crate::lsv_analysis::detect_peaks(&self.lsv_points);
let mut st = format!("LSV complete: {} points", self.lsv_points.len());
if let (Some(s), Some(o)) = (self.ph_slope, self.ph_offset) {
if let Some(peak) = crate::lsv_analysis::detect_qhq_peak(&self.lsv_points) {
if s.abs() > 1e-6 {
let ph = (peak - o) / s;
write!(st, " | pH={:.2}", ph).ok();
}
}
}
self.status = st;
if self.cl_auto_state == ClAutoState::LsvRunning {
let pots = crate::lsv_analysis::derive_cl_potentials(&self.lsv_points);
self.cl_free_v = format!("{:.0}", pots.v_free);
self.cl_total_v = format!("{:.0}", pots.v_total);
self.cl_auto_potentials = Some(pots);
self.cl_auto_state = ClAutoState::MeasureRunning;
let v_cond = self.cl_cond_v.parse::<f32>().unwrap_or(800.0);
let t_cond = self.cl_cond_t.parse::<f32>().unwrap_or(2000.0);
let t_dep = self.cl_dep_t.parse::<f32>().unwrap_or(5000.0);
let t_meas = self.cl_meas_t.parse::<f32>().unwrap_or(5000.0);
self.send_cmd(&protocol::build_sysex_get_temp());
self.send_cmd(&protocol::build_sysex_start_cl(
v_cond, t_cond, pots.v_free, pots.v_total, t_dep, t_meas, self.cl_rtia,
));
self.status = format!(
"Auto Cl: measuring (free={:.0}, total={:.0})",
pots.v_free, pots.v_total
);
}
}
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));
@ -651,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;
@ -673,13 +788,30 @@ impl App {
if let Some(sid) = self.current_session {
self.save_cl(sid);
}
self.status = format!("Chlorine complete: {} points", self.cl_points.len());
if self.cl_auto_state == ClAutoState::MeasureRunning {
self.cl_auto_state = ClAutoState::Idle;
if let Some(pots) = &self.cl_auto_potentials {
self.status = format!(
"Auto Cl complete: {} pts (free={:.0}{}, total={:.0}{})",
self.cl_points.len(),
pots.v_free,
if pots.v_free_detected { "" } else { " dflt" },
pots.v_total,
if pots.v_total_detected { "" } else { " dflt" },
);
} else {
self.status = format!("Chlorine complete: {} points", self.cl_points.len());
}
} else {
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)",
@ -734,6 +866,16 @@ impl App {
self.cal_cell_constant = Some(k);
self.status = format!("Device cell constant: {:.4} cm-1", k);
}
EisMessage::ClFactor(f) => {
self.cl_factor = Some(f);
self.status = format!("Device Cl factor: {:.6}", f);
}
EisMessage::PhCal { slope, offset } => {
self.ph_slope = Some(slope);
self.ph_offset = Some(offset);
self.status = format!("pH cal: slope={:.4} offset={:.4}", slope, offset);
}
EisMessage::Keepalive => {}
},
Message::TabSelected(t) => {
if t == Tab::Browse {
@ -800,12 +942,22 @@ impl App {
Message::LsvStopVChanged(s) => self.lsv_stop_v = s,
Message::LsvScanRateChanged(s) => self.lsv_scan_rate = s,
Message::LsvRtiaSelected(r) => self.lsv_rtia = r,
Message::LsvDensityModeSelected(m) => {
self.lsv_density_mode = m;
self.lsv_density = match m {
LsvDensityMode::PtsPerMv => "1".into(),
LsvDensityMode::PtsPerSec => "100".into(),
};
}
Message::LsvDensityChanged(s) => self.lsv_density = s,
Message::StartLsv => {
let vs = self.lsv_start_v.parse::<f32>().unwrap_or(0.0);
let ve = self.lsv_stop_v.parse::<f32>().unwrap_or(500.0);
let sr = self.lsv_scan_rate.parse::<f32>().unwrap_or(50.0);
let density = self.lsv_density.parse::<f32>().unwrap_or(1.0);
let n = lsv_calc_points(vs, ve, sr, density, self.lsv_density_mode);
self.send_cmd(&protocol::build_sysex_get_temp());
self.send_cmd(&protocol::build_sysex_start_lsv(vs, ve, sr, self.lsv_rtia));
self.send_cmd(&protocol::build_sysex_start_lsv(vs, ve, sr, self.lsv_rtia, n));
}
/* Amp */
Message::AmpVholdChanged(s) => self.amp_v_hold = s,
@ -842,6 +994,17 @@ impl App {
v_cond, t_cond, v_free, v_total, t_dep, t_meas, self.cl_rtia,
));
}
Message::StartClAuto => {
self.cl_auto_state = ClAutoState::LsvRunning;
self.cl_auto_potentials = None;
let density = self.lsv_density.parse::<f32>().unwrap_or(1.0);
let n = lsv_calc_points(-1100.0, 1100.0, 50.0, density, self.lsv_density_mode);
self.send_cmd(&protocol::build_sysex_start_lsv(-1100.0, 1100.0, 50.0, self.lsv_rtia, n));
self.status = "Auto Cl: running LSV sweep...".into();
}
Message::ClToggleManual => {
self.cl_manual_peaks = !self.cl_manual_peaks;
}
/* pH */
Message::PhStabilizeChanged(s) => self.ph_stabilize = s,
Message::StartPh => {
@ -951,6 +1114,58 @@ impl App {
self.status = "No valid EIS data for Rs extraction".into();
}
}
Message::PhCalKnownChanged(s) => { self.ph_cal_known = s; }
Message::PhAddCalPoint => {
if let Some(peak_mv) = crate::lsv_analysis::detect_qhq_peak(&self.lsv_points) {
if let Ok(ph) = self.ph_cal_known.parse::<f32>() {
self.ph_cal_points.push((ph, peak_mv));
self.status = format!("pH cal point: pH={:.2} peak={:.1} mV ({} pts)",
ph, peak_mv, self.ph_cal_points.len());
}
} else {
self.status = "No Q/HQ peak found in LSV data".into();
}
}
Message::PhClearCalPoints => {
self.ph_cal_points.clear();
self.status = "pH cal points cleared".into();
}
Message::PhComputeAndSetCal => {
if self.ph_cal_points.len() < 2 {
self.status = "Need at least 2 calibration points".into();
} else {
let n = self.ph_cal_points.len() as f32;
let mean_ph: f32 = self.ph_cal_points.iter().map(|p| p.0).sum::<f32>() / n;
let mean_v: f32 = self.ph_cal_points.iter().map(|p| p.1).sum::<f32>() / n;
let num: f32 = self.ph_cal_points.iter()
.map(|p| (p.0 - mean_ph) * (p.1 - mean_v)).sum();
let den: f32 = self.ph_cal_points.iter()
.map(|p| (p.0 - mean_ph).powi(2)).sum();
if den.abs() < 1e-12 {
self.status = "Degenerate calibration data".into();
} else {
let slope = num / den;
let offset = mean_v - slope * mean_ph;
self.ph_slope = Some(slope);
self.ph_offset = Some(offset);
self.send_cmd(&protocol::build_sysex_set_ph_cal(slope, offset));
self.status = format!("pH cal set: slope={:.4} offset={:.4}", slope, offset);
}
}
}
Message::ClCalKnownPpmChanged(s) => { self.cl_cal_known_ppm = s; }
Message::ClSetFactor => {
let known_ppm = self.cl_cal_known_ppm.parse::<f32>().unwrap_or(0.0);
if let Some(r) = &self.cl_result {
let peak = r.i_free_ua.abs();
if peak > 0.0 {
let factor = known_ppm / peak;
self.cl_factor = Some(factor);
self.send_cmd(&protocol::build_sysex_set_cl_factor(factor));
self.status = format!("Cl factor: {:.6} ppm/uA", factor);
}
}
}
/* Clean */
Message::CleanVChanged(s) => self.clean_v = s,
Message::CleanDurChanged(s) => self.clean_dur = s,
@ -1068,6 +1283,53 @@ impl App {
self.browse_measurements.clear();
}
}
Message::ExportSession(sid) => {
match self.storage.export_session(sid) {
Ok(toml_str) => {
let name = self.browse_sessions.iter()
.find(|(s, _)| s.id == sid)
.map(|(s, _)| s.name.clone())
.unwrap_or_else(|| format!("session_{}", sid));
let filename = format!("{}.toml", name.replace(' ', "_"));
let dialog = rfd::FileDialog::new()
.set_file_name(&filename)
.add_filter("TOML", &["toml"]);
if let Some(path) = dialog.save_file() {
match std::fs::write(&path, &toml_str) {
Ok(_) => self.status = format!("Exported to {}", path.display()),
Err(e) => self.status = format!("Write failed: {}", e),
}
}
}
Err(e) => self.status = format!("Export failed: {}", e),
}
}
Message::ImportSession => {
let dialog = rfd::FileDialog::new()
.add_filter("TOML", &["toml"]);
if let Some(path) = dialog.pick_file() {
match std::fs::read_to_string(&path) {
Ok(toml_str) => {
match self.storage.import_session(&toml_str) {
Ok(_) => {
self.browse_sessions = self.storage.list_sessions()
.unwrap_or_default()
.into_iter()
.map(|s| {
let cnt = self.storage.measurement_count(s.id).unwrap_or(0);
(s, cnt)
})
.collect();
self.sessions = self.storage.list_sessions().unwrap_or_default();
self.status = format!("Imported from {}", path.display());
}
Err(e) => self.status = format!("Import failed: {}", e),
}
}
Err(e) => self.status = format!("Read failed: {}", e),
}
}
}
Message::Reconnect => {
self.conn_gen += 1;
self.cmd_tx = None;
@ -1383,28 +1645,47 @@ impl App {
.align_y(iced::Alignment::End)
.into(),
Tab::Lsv => row![
column![
text("Start mV").size(12),
text_input("0", &self.lsv_start_v).on_input(Message::LsvStartVChanged).width(80),
].spacing(2),
column![
text("Stop mV").size(12),
text_input("500", &self.lsv_stop_v).on_input(Message::LsvStopVChanged).width(80),
].spacing(2),
column![
text("Scan mV/s").size(12),
text_input("50", &self.lsv_scan_rate).on_input(Message::LsvScanRateChanged).width(80),
].spacing(2),
column![
text("RTIA").size(12),
pick_list(LpRtia::ALL, Some(self.lsv_rtia), Message::LsvRtiaSelected).width(Length::Shrink),
].spacing(2),
button(text("Start LSV").size(13))
.style(style_action())
.padding([6, 16])
.on_press(Message::StartLsv),
]
Tab::Lsv => {
let vs = self.lsv_start_v.parse::<f32>().unwrap_or(0.0);
let ve = self.lsv_stop_v.parse::<f32>().unwrap_or(500.0);
let sr = self.lsv_scan_rate.parse::<f32>().unwrap_or(50.0);
let d = self.lsv_density.parse::<f32>().unwrap_or(1.0);
let n = lsv_calc_points(vs, ve, sr, d, self.lsv_density_mode);
row![
column![
text("Start mV").size(12),
text_input("0", &self.lsv_start_v).on_input(Message::LsvStartVChanged).width(80),
].spacing(2),
column![
text("Stop mV").size(12),
text_input("500", &self.lsv_stop_v).on_input(Message::LsvStopVChanged).width(80),
].spacing(2),
column![
text("Scan mV/s").size(12),
text_input("50", &self.lsv_scan_rate).on_input(Message::LsvScanRateChanged).width(80),
].spacing(2),
column![
text("RTIA").size(12),
pick_list(LpRtia::ALL, Some(self.lsv_rtia), Message::LsvRtiaSelected).width(Length::Shrink),
].spacing(2),
column![
text("Density").size(12),
pick_list(LsvDensityMode::ALL, Some(self.lsv_density_mode), Message::LsvDensityModeSelected).width(Length::Shrink),
].spacing(2),
column![
text("Value").size(12),
text_input("1", &self.lsv_density).on_input(Message::LsvDensityChanged).width(60),
].spacing(2),
column![
text("Points").size(12),
text(format!("{}", n)).size(13),
].spacing(2),
button(text("Start LSV").size(13))
.style(style_action())
.padding([6, 16])
.on_press(Message::StartLsv),
]
}
.spacing(10)
.align_y(iced::Alignment::End)
.into(),
@ -1442,43 +1723,89 @@ impl App {
.align_y(iced::Alignment::End)
.into(),
Tab::Chlorine => row![
column![
text("Cond mV").size(12),
text_input("800", &self.cl_cond_v).on_input(Message::ClCondVChanged).width(70),
].spacing(2),
column![
text("Cond ms").size(12),
text_input("2000", &self.cl_cond_t).on_input(Message::ClCondTChanged).width(70),
].spacing(2),
column![
text("Free mV").size(12),
text_input("100", &self.cl_free_v).on_input(Message::ClFreeVChanged).width(70),
].spacing(2),
column![
text("Total mV").size(12),
text_input("-200", &self.cl_total_v).on_input(Message::ClTotalVChanged).width(70),
].spacing(2),
column![
text("Settle ms").size(12),
text_input("5000", &self.cl_dep_t).on_input(Message::ClDepTChanged).width(70),
].spacing(2),
column![
text("Meas ms").size(12),
text_input("5000", &self.cl_meas_t).on_input(Message::ClMeasTChanged).width(70),
].spacing(2),
column![
text("RTIA").size(12),
pick_list(LpRtia::ALL, Some(self.cl_rtia), Message::ClRtiaSelected).width(Length::Shrink),
].spacing(2),
button(text("Measure").size(13))
.style(style_action())
.padding([6, 16])
.on_press(Message::StartCl),
]
.spacing(8)
.align_y(iced::Alignment::End)
.into(),
Tab::Chlorine => if self.cl_manual_peaks {
row![
button(text("Start LSV").size(13))
.style(style_action())
.padding([6, 16])
.on_press(Message::StartLsv),
button(text("Manual").size(13))
.padding([6, 12])
.on_press(Message::ClToggleManual),
rule::Rule::vertical(1),
column![
text("Cond mV").size(12),
text_input("800", &self.cl_cond_v).on_input(Message::ClCondVChanged).width(70),
].spacing(2),
column![
text("Cond ms").size(12),
text_input("2000", &self.cl_cond_t).on_input(Message::ClCondTChanged).width(70),
].spacing(2),
column![
text("Free mV").size(12),
text_input("100", &self.cl_free_v).on_input(Message::ClFreeVChanged).width(70),
].spacing(2),
column![
text("Total mV").size(12),
text_input("-200", &self.cl_total_v).on_input(Message::ClTotalVChanged).width(70),
].spacing(2),
column![
text("Settle ms").size(12),
text_input("5000", &self.cl_dep_t).on_input(Message::ClDepTChanged).width(70),
].spacing(2),
column![
text("Meas ms").size(12),
text_input("5000", &self.cl_meas_t).on_input(Message::ClMeasTChanged).width(70),
].spacing(2),
column![
text("RTIA").size(12),
pick_list(LpRtia::ALL, Some(self.cl_rtia), Message::ClRtiaSelected).width(Length::Shrink),
].spacing(2),
button(text("Measure").size(13))
.style(style_action())
.padding([6, 16])
.on_press(Message::StartCl),
]
.spacing(8)
.align_y(iced::Alignment::End)
.into()
} else {
let auto_btn = {
let b = button(text("Start Auto").size(13))
.style(style_action())
.padding([6, 16]);
if self.cl_auto_state == ClAutoState::Idle {
b.on_press(Message::StartClAuto)
} else {
b
}
};
let pot_text = if let Some(pots) = &self.cl_auto_potentials {
format!(
"free={:.0}{} total={:.0}{}",
pots.v_free,
if pots.v_free_detected { "" } else { "?" },
pots.v_total,
if pots.v_total_detected { "" } else { "?" },
)
} else {
String::new()
};
row![
auto_btn,
button(text("Auto").size(13))
.padding([6, 12])
.on_press(Message::ClToggleManual),
column![
text("RTIA").size(12),
pick_list(LpRtia::ALL, Some(self.cl_rtia), Message::ClRtiaSelected).width(Length::Shrink),
].spacing(2),
text(pot_text).size(12),
]
.spacing(8)
.align_y(iced::Alignment::End)
.into()
},
Tab::Ph => row![
column![
@ -1515,29 +1842,65 @@ impl App {
.height(Length::Fill);
row![bode, nyquist].spacing(10).height(Length::Fill).into()
}
Tab::Lsv => canvas(crate::plot::VoltammogramPlot {
points: &self.lsv_points,
reference: self.lsv_ref.as_deref(),
})
.width(Length::Fill).height(Length::Fill).into(),
Tab::Lsv => {
let plot = canvas(crate::plot::VoltammogramPlot {
points: &self.lsv_points,
reference: self.lsv_ref.as_deref(),
peaks: &self.lsv_peaks,
})
.width(Length::Fill).height(Length::Fill);
if self.lsv_peaks.is_empty() {
plot.into()
} else {
let info: Vec<String> = self.lsv_peaks.iter().map(|p| {
use crate::lsv_analysis::PeakKind;
match p.kind {
PeakKind::FreeCl => format!("Free: {:.0}mV {:.2}uA", p.v_mv, p.i_ua),
PeakKind::TotalCl => format!("Total: {:.0}mV {:.2}uA", p.v_mv, p.i_ua),
PeakKind::Crossover => format!("X-over: {:.0}mV", p.v_mv),
}
}).collect();
column![text(info.join(" | ")).size(14), plot].spacing(4).height(Length::Fill).into()
}
}
Tab::Amp => canvas(crate::plot::AmperogramPlot {
points: &self.amp_points,
reference: self.amp_ref.as_deref(),
})
.width(Length::Fill).height(Length::Fill).into(),
Tab::Chlorine => {
let ref_pts = self.cl_ref.as_ref().map(|(pts, _)| pts.as_slice());
let plot = canvas(crate::plot::ChlorinePlot {
points: &self.cl_points,
reference: ref_pts,
let mut col = column![].spacing(4).height(Length::Fill);
if !self.lsv_peaks.is_empty() {
let info: Vec<String> = self.lsv_peaks.iter().map(|p| {
use crate::lsv_analysis::PeakKind;
match p.kind {
PeakKind::FreeCl => format!("Free: {:.0}mV {:.2}uA", p.v_mv, p.i_ua),
PeakKind::TotalCl => format!("Total: {:.0}mV {:.2}uA", p.v_mv, p.i_ua),
PeakKind::Crossover => format!("X-over: {:.0}mV", p.v_mv),
}
}).collect();
col = col.push(text(info.join(" | ")).size(14));
}
let voltammogram = canvas(crate::plot::VoltammogramPlot {
points: &self.lsv_points,
reference: self.lsv_ref.as_deref(),
peaks: &self.lsv_peaks,
})
.width(Length::Fill).height(Length::Fill);
.width(Length::Fill).height(Length::FillPortion(1));
col = col.push(voltammogram);
let mut result_parts: Vec<String> = Vec::new();
if let Some(r) = &self.cl_result {
result_parts.push(format!(
"Free: {:.3} uA | Total: {:.3} uA | Combined: {:.3} uA",
r.i_free_ua, r.i_total_ua, r.i_total_ua - r.i_free_ua
));
if let (Some(f), Some(r)) = (self.cl_factor, &self.cl_result) {
let ppm = f * r.i_free_ua.abs();
result_parts.push(format!("Free Cl: {:.2} ppm", ppm));
}
if let Some((_, ref_r)) = &self.cl_ref {
let df = r.i_free_ua - ref_r.i_free_ua;
let dt = r.i_total_ua - ref_r.i_total_ua;
@ -1547,12 +1910,19 @@ impl App {
));
}
}
if result_parts.is_empty() {
plot.into()
} else {
let result_text = text(result_parts.join(" ")).size(14);
column![result_text, plot].spacing(4).height(Length::Fill).into()
if !result_parts.is_empty() {
col = col.push(text(result_parts.join(" ")).size(14));
}
let ref_pts = self.cl_ref.as_ref().map(|(pts, _)| pts.as_slice());
let cl_plot = canvas(crate::plot::ChlorinePlot {
points: &self.cl_points,
reference: ref_pts,
})
.width(Length::Fill).height(Length::FillPortion(1));
col = col.push(cl_plot);
col.into()
}
Tab::Ph | Tab::Calibrate | Tab::Browse => text("").into(),
}
@ -1645,6 +2015,69 @@ impl App {
.on_press(Message::CalComputeK);
results = results.push(compute_btn);
results = results.push(iced::widget::horizontal_rule(1));
results = results.push(text("Chlorine Calibration").size(16));
if let Some(f) = self.cl_factor {
results = results.push(text(format!("Cl factor: {:.6} ppm/uA", f)).size(14));
}
if let Some(r) = &self.cl_result {
results = results.push(text(format!("Last free Cl peak: {:.3} uA", r.i_free_ua)).size(14));
}
results = results.push(
row![
column![
text("Known Cl ppm").size(12),
text_input("5", &self.cl_cal_known_ppm)
.on_input(Message::ClCalKnownPpmChanged).width(80),
].spacing(2),
button(text("Set Cl Factor").size(13))
.style(style_action())
.padding([6, 16])
.on_press(Message::ClSetFactor),
].spacing(10).align_y(iced::Alignment::End)
);
/* pH calibration */
results = results.push(iced::widget::horizontal_rule(1));
results = results.push(text("pH Calibration (Q/HQ peak-shift)").size(16));
if let (Some(s), Some(o)) = (self.ph_slope, self.ph_offset) {
results = results.push(text(format!("slope: {:.4} mV/pH offset: {:.4} mV", s, o)).size(14));
if let Some(peak) = crate::lsv_analysis::detect_qhq_peak(&self.lsv_points) {
if s.abs() > 1e-6 {
let ph = (peak - o) / s;
results = results.push(text(format!("Computed pH: {:.2} (peak at {:.1} mV)", ph, peak)).size(14));
}
}
}
results = results.push(
row![
column![
text("Known pH").size(12),
text_input("7.00", &self.ph_cal_known)
.on_input(Message::PhCalKnownChanged).width(80),
].spacing(2),
button(text("Add Point").size(13))
.style(style_action())
.padding([6, 16])
.on_press(Message::PhAddCalPoint),
].spacing(10).align_y(iced::Alignment::End)
);
for (i, (ph, mv)) in self.ph_cal_points.iter().enumerate() {
results = results.push(text(format!(" {}. pH={:.2} peak={:.1} mV", i + 1, ph, mv)).size(13));
}
results = results.push(
row![
button(text("Clear Points").size(13))
.style(style_action())
.padding([6, 16])
.on_press(Message::PhClearCalPoints),
button(text("Compute & Set pH Cal").size(13))
.style(style_action())
.padding([6, 16])
.on_press(Message::PhComputeAndSetCal),
].spacing(10)
);
row![
container(inputs).width(Length::FillPortion(2)),
iced::widget::vertical_rule(1),
@ -1716,7 +2149,14 @@ impl App {
fn view_browse_sessions(&self) -> Element<'_, Message> {
let mut items = column![
text("Sessions").size(16),
row![
text("Sessions").size(16),
iced::widget::horizontal_space(),
button(text("Import").size(11))
.style(style_apply())
.padding([4, 8])
.on_press(Message::ImportSession),
].align_y(iced::Alignment::Center),
iced::widget::horizontal_rule(1),
].spacing(4);
@ -1792,6 +2232,14 @@ impl App {
);
}
header = header.push(iced::widget::horizontal_space());
header = header.push(
button(text("Export").size(11))
.style(style_apply())
.padding([4, 12])
.on_press(Message::ExportSession(sid)),
);
let mut mlist = column![].spacing(2);
if self.browse_measurements.is_empty() {

215
cue/src/lsv_analysis.rs Normal file
View File

@ -0,0 +1,215 @@
use crate::protocol::LsvPoint;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PeakKind {
FreeCl,
TotalCl,
Crossover,
}
#[derive(Debug, Clone)]
pub struct LsvPeak {
pub v_mv: f32,
pub i_ua: f32,
pub kind: PeakKind,
}
pub fn smooth(data: &[f32], window: usize) -> Vec<f32> {
let n = data.len();
if n == 0 || window < 2 {
return data.to_vec();
}
let half = window / 2;
let mut out = Vec::with_capacity(n);
for i in 0..n {
let lo = i.saturating_sub(half);
let hi = (i + half).min(n - 1);
let sum: f32 = data[lo..=hi].iter().sum();
out.push(sum / (hi - lo + 1) as f32);
}
out
}
pub fn find_extrema(_v: &[f32], i_smooth: &[f32], min_prominence: f32) -> Vec<(usize, bool)> {
let n = i_smooth.len();
if n < 3 {
return Vec::new();
}
let mut candidates: Vec<(usize, bool)> = Vec::new();
for i in 1..n - 1 {
let prev = i_smooth[i - 1];
let curr = i_smooth[i];
let next = i_smooth[i + 1];
if curr > prev && curr > next {
candidates.push((i, true));
} else if curr < prev && curr < next {
candidates.push((i, false));
}
}
candidates.retain(|&(idx, is_max)| {
let val = i_smooth[idx];
let left_bound = i_smooth[..idx].iter().copied()
.reduce(if is_max { f32::min } else { f32::max })
.unwrap_or(val);
let right_bound = i_smooth[idx + 1..].iter().copied()
.reduce(if is_max { f32::min } else { f32::max })
.unwrap_or(val);
let prom = if is_max {
val - left_bound.max(right_bound)
} else {
left_bound.min(right_bound) - val
};
prom >= min_prominence
});
candidates
}
/// Detect Q/HQ redox peak in the -100 to +600 mV window.
/// Returns peak voltage in mV if found.
pub fn detect_qhq_peak(points: &[LsvPoint]) -> Option<f32> {
if points.len() < 5 {
return None;
}
let i_vals: Vec<f32> = points.iter().map(|p| p.i_ua).collect();
let v_vals: Vec<f32> = points.iter().map(|p| p.v_mv).collect();
let window = 5.max(points.len() / 50);
let smoothed = smooth(&i_vals, window);
let i_min = smoothed.iter().copied().fold(f32::INFINITY, f32::min);
let i_max = smoothed.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let prominence = (i_max - i_min) * 0.05;
let extrema = find_extrema(&v_vals, &smoothed, prominence);
extrema.iter()
.filter(|&&(idx, is_max)| is_max && v_vals[idx] >= -100.0 && v_vals[idx] <= 600.0)
.max_by(|a, b| smoothed[a.0].partial_cmp(&smoothed[b.0]).unwrap_or(std::cmp::Ordering::Equal))
.map(|&(idx, _)| v_vals[idx])
}
#[derive(Debug, Clone, Copy)]
pub struct ClPotentials {
pub v_free: f32,
pub v_free_detected: bool,
pub v_total: f32,
pub v_total_detected: bool,
}
pub fn derive_cl_potentials(points: &[LsvPoint]) -> ClPotentials {
let default = ClPotentials {
v_free: 100.0,
v_free_detected: false,
v_total: -200.0,
v_total_detected: false,
};
if points.len() < 5 {
return default;
}
let i_vals: Vec<f32> = points.iter().map(|p| p.i_ua).collect();
let v_vals: Vec<f32> = points.iter().map(|p| p.v_mv).collect();
let window = 5.max(points.len() / 50);
let smoothed = smooth(&i_vals, window);
let i_min = smoothed.iter().copied().fold(f32::INFINITY, f32::min);
let i_max = smoothed.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let prominence = (i_max - i_min) * 0.05;
let extrema = find_extrema(&v_vals, &smoothed, prominence);
// v_free: most prominent cathodic peak (is_max==false) in +300 to -300 mV
let free_peak = extrema.iter()
.filter(|&&(idx, is_max)| !is_max && v_vals[idx] >= -300.0 && v_vals[idx] <= 300.0)
.min_by(|a, b| smoothed[a.0].partial_cmp(&smoothed[b.0]).unwrap_or(std::cmp::Ordering::Equal));
let (v_free, v_free_detected, free_idx) = match free_peak {
Some(&(idx, _)) => (v_vals[idx], true, Some(idx)),
None => (100.0, false, None),
};
// v_total: secondary cathodic peak between (v_free - 100) and -500 mV, excluding free peak
let total_lo = -500.0_f32;
let total_hi = v_free - 100.0;
let total_peak = extrema.iter()
.filter(|&&(idx, is_max)| {
!is_max
&& v_vals[idx] >= total_lo
&& v_vals[idx] <= total_hi
&& Some(idx) != free_idx
})
.min_by(|a, b| smoothed[a.0].partial_cmp(&smoothed[b.0]).unwrap_or(std::cmp::Ordering::Equal));
let (v_total, v_total_detected) = match total_peak {
Some(&(idx, _)) => (v_vals[idx], true),
None => (v_free - 300.0, false),
};
let v_total = v_total.max(-400.0);
ClPotentials { v_free, v_free_detected, v_total, v_total_detected }
}
pub fn detect_peaks(points: &[LsvPoint]) -> Vec<LsvPeak> {
if points.len() < 5 {
return Vec::new();
}
let i_vals: Vec<f32> = points.iter().map(|p| p.i_ua).collect();
let v_vals: Vec<f32> = points.iter().map(|p| p.v_mv).collect();
let window = 5.max(points.len() / 50);
let smoothed = smooth(&i_vals, window);
let i_min = smoothed.iter().copied().fold(f32::INFINITY, f32::min);
let i_max = smoothed.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let prominence = (i_max - i_min) * 0.05;
let extrema = find_extrema(&v_vals, &smoothed, prominence);
let mut peaks: Vec<LsvPeak> = Vec::new();
// find crossover: where current changes sign
for i in 1..smoothed.len() {
if smoothed[i - 1].signum() != smoothed[i].signum() && smoothed[i - 1].signum() != 0.0 {
let frac = smoothed[i - 1].abs() / (smoothed[i - 1].abs() + smoothed[i].abs());
let v_cross = v_vals[i - 1] + frac * (v_vals[i] - v_vals[i - 1]);
peaks.push(LsvPeak {
v_mv: v_cross,
i_ua: 0.0,
kind: PeakKind::Crossover,
});
break;
}
}
// largest peak in positive voltage region -> FreeCl
let free_cl = extrema.iter()
.filter(|&&(idx, is_max)| is_max && v_vals[idx] >= 0.0)
.max_by(|a, b| smoothed[a.0].partial_cmp(&smoothed[b.0]).unwrap_or(std::cmp::Ordering::Equal));
if let Some(&(idx, _)) = free_cl {
peaks.push(LsvPeak {
v_mv: v_vals[idx],
i_ua: smoothed[idx],
kind: PeakKind::FreeCl,
});
}
// largest peak in negative voltage region -> TotalCl
let total_cl = extrema.iter()
.filter(|&&(idx, is_max)| is_max && v_vals[idx] < 0.0)
.max_by(|a, b| smoothed[a.0].partial_cmp(&smoothed[b.0]).unwrap_or(std::cmp::Ordering::Equal));
if let Some(&(idx, _)) = total_cl {
peaks.push(LsvPeak {
v_mv: v_vals[idx],
i_ua: smoothed[idx],
kind: PeakKind::TotalCl,
});
}
peaks
}

View File

@ -1,4 +1,5 @@
mod app;
mod lsv_analysis;
mod native_menu;
mod plot;
mod protocol;

View File

@ -3,6 +3,7 @@ use iced::{Color, Point, Rectangle, Renderer, Theme};
use iced::mouse;
use crate::app::Message;
use crate::lsv_analysis::{LsvPeak, PeakKind};
use crate::protocol::{AmpPoint, ClPoint, EisPoint, LsvPoint};
const MARGIN_L: f32 = 55.0;
@ -153,15 +154,60 @@ fn kasa_fit(pts: &[(f64, f64)]) -> Option<(f64, f64, f64)> {
Some((cx, cy, r_sq.sqrt()))
}
/// Fit the dominant Nyquist semicircle, trimming first-arc points from the
/// low-frequency end before falling back to outlier removal within each subset.
fn fit_nyquist_circle(points: &[EisPoint]) -> Option<CircleFit> {
fn cumulative_turning(pts: &[(f64, f64)]) -> f64 {
if pts.len() < 3 { return 0.0; }
let mut total = 0.0;
for i in 1..pts.len() - 1 {
let (dx1, dy1) = (pts[i].0 - pts[i-1].0, pts[i].1 - pts[i-1].1);
let (dx2, dy2) = (pts[i+1].0 - pts[i].0, pts[i+1].1 - pts[i].1);
let cross = dx1 * dy2 - dy1 * dx2;
let dot = dx1 * dx2 + dy1 * dy2;
total += cross.atan2(dot).abs();
}
total
}
struct LinearFit {
slope: f32,
y_intercept: f32,
rs: f32,
}
fn fit_linear(pts: &[(f64, f64)]) -> Option<LinearFit> {
if pts.len() < 2 { return None; }
let n = pts.len() as f64;
let sx: f64 = pts.iter().map(|p| p.0).sum();
let sy: f64 = pts.iter().map(|p| p.1).sum();
let sx2: f64 = pts.iter().map(|p| p.0 * p.0).sum();
let sxy: f64 = pts.iter().map(|p| p.0 * p.1).sum();
let denom = n * sx2 - sx * sx;
if denom.abs() < 1e-20 { return None; }
let slope = (n * sxy - sx * sy) / denom;
let y_int = (sy - slope * sx) / n;
let rs = if slope.abs() > 1e-10 { -y_int / slope } else { sx / n };
Some(LinearFit {
slope: slope as f32,
y_intercept: y_int as f32,
rs: rs as f32,
})
}
enum NyquistFit {
Circle(CircleFit),
Linear(LinearFit),
}
fn fit_nyquist(points: &[EisPoint]) -> Option<NyquistFit> {
let all: Vec<(f64, f64)> = points.iter()
.filter(|p| p.z_real.is_finite() && p.z_imag.is_finite())
.map(|p| (p.z_real as f64, -p.z_imag as f64))
.collect();
if all.len() < 4 { return None; }
if cumulative_turning(&all) < 0.524 {
return fit_linear(&all).map(NyquistFit::Linear);
}
let min_pts = 4.max(all.len() / 3);
let mut best: Option<CircleFit> = None;
let mut best_score = f64::MAX;
@ -210,7 +256,17 @@ fn fit_nyquist_circle(points: &[EisPoint]) -> Option<CircleFit> {
}
}
}
best
if let Some(circle) = best {
if best_score > 0.15 {
if let Some(lin) = fit_linear(&all) {
return Some(NyquistFit::Linear(lin));
}
}
Some(NyquistFit::Circle(circle))
} else {
fit_linear(&all).map(NyquistFit::Linear)
}
}
/* ---- Bode ---- */
@ -676,33 +732,66 @@ impl<'a> canvas::Program<Message> for NyquistPlot<'a> {
draw_polyline(&mut frame, &pts, COL_NYQ, 2.0);
draw_dots(&mut frame, &pts, COL_NYQ, 3.0);
if let Some(fit) = fit_nyquist_circle(self.points) {
let theta_r = (-fit.cy).atan2((fit.r * fit.r - fit.cy * fit.cy).sqrt());
let mut theta_l = (-fit.cy).atan2(-(fit.r * fit.r - fit.cy * fit.cy).sqrt());
if theta_l < theta_r { theta_l += std::f32::consts::TAU; }
match fit_nyquist(self.points) {
Some(NyquistFit::Circle(fit)) => {
let theta_r = (-fit.cy).atan2((fit.r * fit.r - fit.cy * fit.cy).sqrt());
let mut theta_l = (-fit.cy).atan2(-(fit.r * fit.r - fit.cy * fit.cy).sqrt());
if theta_l < theta_r { theta_l += std::f32::consts::TAU; }
let n_arc = 120;
let arc_pts: Vec<Point> = (0..=n_arc).map(|i| {
let t = theta_r + (theta_l - theta_r) * i as f32 / n_arc as f32;
let x = fit.cx + fit.r * t.cos();
let y = fit.cy + fit.r * t.sin();
Point::new(
lerp(x, xv.lo, xv.hi, xl, xr),
lerp(y, yv.hi, yv.lo, yt, yb),
)
}).collect();
draw_polyline(&mut frame, &arc_pts, COL_FIT, 1.5);
let n_arc = 120;
let arc_pts: Vec<Point> = (0..=n_arc).map(|i| {
let t = theta_r + (theta_l - theta_r) * i as f32 / n_arc as f32;
let x = fit.cx + fit.r * t.cos();
let y = fit.cy + fit.r * t.sin();
Point::new(
lerp(x, xv.lo, xv.hi, xl, xr),
lerp(y, yv.hi, yv.lo, yt, yb),
)
}).collect();
draw_polyline(&mut frame, &arc_pts, COL_FIT, 1.5);
let y0_scr = lerp(0.0, yv.hi, yv.lo, yt, yb);
let rs_scr = Point::new(lerp(fit.rs, xv.lo, xv.hi, xl, xr), y0_scr);
let rp_scr = Point::new(lerp(fit.rs + fit.rp, xv.lo, xv.hi, xl, xr), y0_scr);
frame.fill(&Path::circle(rs_scr, 5.0), COL_FIT_PT);
frame.fill(&Path::circle(rp_scr, 5.0), COL_FIT_PT);
let y0_scr = lerp(0.0, yv.hi, yv.lo, yt, yb);
let rs_scr = Point::new(lerp(fit.rs, xv.lo, xv.hi, xl, xr), y0_scr);
let rp_scr = Point::new(lerp(fit.rs + fit.rp, xv.lo, xv.hi, xl, xr), y0_scr);
frame.fill(&Path::circle(rs_scr, 5.0), COL_FIT_PT);
frame.fill(&Path::circle(rp_scr, 5.0), COL_FIT_PT);
dt(&mut frame, Point::new(rs_scr.x, rs_scr.y + 6.0),
&format!("Rs={:.0}", fit.rs), COL_FIT_PT, 10.0);
dt(&mut frame, Point::new(rp_scr.x - 30.0, rp_scr.y + 6.0),
&format!("Rp={:.0}", fit.rp), COL_FIT_PT, 10.0);
dt(&mut frame, Point::new(rs_scr.x, rs_scr.y + 6.0),
&format!("Rs={:.0}", fit.rs), COL_FIT_PT, 10.0);
dt(&mut frame, Point::new(rp_scr.x - 30.0, rp_scr.y + 6.0),
&format!("Rp={:.0}", fit.rp), COL_FIT_PT, 10.0);
}
Some(NyquistFit::Linear(fit)) => {
let x_min = self.points.iter()
.filter(|p| p.z_real.is_finite())
.map(|p| p.z_real)
.fold(f32::INFINITY, f32::min);
let x_max = self.points.iter()
.filter(|p| p.z_real.is_finite())
.map(|p| p.z_real)
.fold(f32::NEG_INFINITY, f32::max);
let pad = (x_max - x_min) * 0.1;
let x0 = x_min - pad;
let x1 = x_max + pad;
let y0 = fit.slope * x0 + fit.y_intercept;
let y1 = fit.slope * x1 + fit.y_intercept;
let p0 = Point::new(
lerp(x0, xv.lo, xv.hi, xl, xr),
lerp(y0, yv.hi, yv.lo, yt, yb),
);
let p1 = Point::new(
lerp(x1, xv.lo, xv.hi, xl, xr),
lerp(y1, yv.hi, yv.lo, yt, yb),
);
dl(&mut frame, p0, p1, COL_FIT, 1.5);
let y0_scr = lerp(0.0, yv.hi, yv.lo, yt, yb);
let rs_scr = Point::new(lerp(fit.rs, xv.lo, xv.hi, xl, xr), y0_scr);
frame.fill(&Path::circle(rs_scr, 5.0), COL_FIT_PT);
dt(&mut frame, Point::new(rs_scr.x, rs_scr.y + 6.0),
&format!("Rs={:.0}", fit.rs), COL_FIT_PT, 10.0);
}
None => {}
}
if let Some(pos) = cursor.position_in(bounds) {
@ -735,6 +824,7 @@ pub struct VoltammogramState {
pub struct VoltammogramPlot<'a> {
pub points: &'a [LsvPoint],
pub reference: Option<&'a [LsvPoint]>,
pub peaks: &'a [LsvPeak],
}
impl VoltammogramPlot<'_> {
@ -895,6 +985,24 @@ impl<'a> canvas::Program<Message> for VoltammogramPlot<'a> {
draw_polyline(&mut frame, &pts, COL_LSV, 2.0);
draw_dots(&mut frame, &pts, COL_LSV, 2.5);
for peak in self.peaks {
let px = lerp(peak.v_mv, xv.lo, xv.hi, xl, xr);
let py = lerp(peak.i_ua, yv.hi, yv.lo, yt, yb);
if !px.is_finite() || !py.is_finite() { continue; }
let col = match peak.kind {
PeakKind::FreeCl => COL_CL_FREE,
PeakKind::TotalCl => COL_CL_TOTAL,
PeakKind::Crossover => COL_FIT_PT,
};
frame.fill(&Path::circle(Point::new(px, py), 5.0), col);
let label = match peak.kind {
PeakKind::FreeCl => format!("Free {:.0}mV {:.2}uA", peak.v_mv, peak.i_ua),
PeakKind::TotalCl => format!("Total {:.0}mV {:.2}uA", peak.v_mv, peak.i_ua),
PeakKind::Crossover => format!("X-over {:.0}mV", peak.v_mv),
};
dt(&mut frame, Point::new(px - 20.0, py + 8.0), &label, col, 10.0);
}
if let Some(pos) = cursor.position_in(bounds) {
if pos.x >= xl && pos.x <= xr && pos.y >= yt && pos.y <= yb {
let v = lerp(pos.x, xl, xr, xv.lo, xv.hi);

View File

@ -26,6 +26,9 @@ pub const RSP_REF_LP_RANGE: u8 = 0x21;
pub const RSP_REFS_DONE: u8 = 0x22;
pub const RSP_CELL_K: u8 = 0x11;
pub const RSP_REF_STATUS: u8 = 0x23;
pub const RSP_CL_FACTOR: u8 = 0x24;
pub const RSP_PH_CAL: u8 = 0x25;
pub const RSP_KEEPALIVE: u8 = 0x50;
/* Cue → ESP32 */
pub const CMD_SET_SWEEP: u8 = 0x10;
@ -44,6 +47,10 @@ pub const CMD_START_PH: u8 = 0x24;
pub const CMD_START_CLEAN: u8 = 0x25;
pub const CMD_SET_CELL_K: u8 = 0x28;
pub const CMD_GET_CELL_K: u8 = 0x29;
pub const CMD_SET_CL_FACTOR: u8 = 0x33;
pub const CMD_GET_CL_FACTOR: u8 = 0x34;
pub const CMD_SET_PH_CAL: u8 = 0x35;
pub const CMD_GET_PH_CAL: u8 = 0x36;
pub const CMD_START_REFS: u8 = 0x30;
pub const CMD_GET_REFS: u8 = 0x31;
pub const CMD_CLEAR_REFS: u8 = 0x32;
@ -237,27 +244,32 @@ 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 },
RefsDone,
RefStatus { has_refs: bool },
CellK(f32),
ClFactor(f32),
PhCal { slope: f32, offset: f32 },
Keepalive,
}
fn decode_u16(data: &[u8]) -> u16 {
@ -275,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();
[
@ -293,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 => {
@ -332,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 => {
@ -351,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..];
@ -366,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..];
@ -393,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] })
@ -413,6 +454,18 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
let p = &data[2..];
Some(EisMessage::CellK(decode_float(&p[0..5])))
}
RSP_CL_FACTOR if data.len() >= 7 => {
let p = &data[2..];
Some(EisMessage::ClFactor(decode_float(&p[0..5])))
}
RSP_PH_CAL if data.len() >= 12 => {
let p = &data[2..];
Some(EisMessage::PhCal {
slope: decode_float(&p[0..5]),
offset: decode_float(&p[5..10]),
})
}
RSP_KEEPALIVE => Some(EisMessage::Keepalive),
_ => None,
}
}
@ -446,12 +499,13 @@ pub fn build_sysex_get_config() -> Vec<u8> {
vec![0xF0, SYSEX_MFR, CMD_GET_CONFIG, 0xF7]
}
pub fn build_sysex_start_lsv(v_start: f32, v_stop: f32, scan_rate: f32, lp_rtia: LpRtia) -> Vec<u8> {
pub fn build_sysex_start_lsv(v_start: f32, v_stop: f32, scan_rate: f32, lp_rtia: LpRtia, num_points: u16) -> Vec<u8> {
let mut sx = vec![0xF0, SYSEX_MFR, CMD_START_LSV];
sx.extend_from_slice(&encode_float(v_start));
sx.extend_from_slice(&encode_float(v_stop));
sx.extend_from_slice(&encode_float(scan_rate));
sx.push(lp_rtia.as_byte());
sx.extend_from_slice(&encode_u16(num_points));
sx.push(0xF7);
sx
}
@ -527,3 +581,26 @@ pub fn build_sysex_set_cell_k(k: f32) -> Vec<u8> {
pub fn build_sysex_get_cell_k() -> Vec<u8> {
vec![0xF0, SYSEX_MFR, CMD_GET_CELL_K, 0xF7]
}
pub fn build_sysex_set_cl_factor(f: f32) -> Vec<u8> {
let mut sx = vec![0xF0, SYSEX_MFR, CMD_SET_CL_FACTOR];
sx.extend_from_slice(&encode_float(f));
sx.push(0xF7);
sx
}
pub fn build_sysex_get_cl_factor() -> Vec<u8> {
vec![0xF0, SYSEX_MFR, CMD_GET_CL_FACTOR, 0xF7]
}
pub fn build_sysex_set_ph_cal(slope: f32, offset: f32) -> Vec<u8> {
let mut sx = vec![0xF0, SYSEX_MFR, CMD_SET_PH_CAL];
sx.extend_from_slice(&encode_float(slope));
sx.extend_from_slice(&encode_float(offset));
sx.push(0xF7);
sx
}
pub fn build_sysex_get_ph_cal() -> Vec<u8> {
vec![0xF0, SYSEX_MFR, CMD_GET_PH_CAL, 0xF7]
}

View File

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

View File

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

View File

@ -327,7 +327,14 @@ int echem_clean(float v_mv, float duration_s)
AD5940_LPDAC0WriteS(code, VZERO_CODE);
printf("Clean: %.0f mV for %.0f s\n", v_mv, duration_s);
vTaskDelay(pdMS_TO_TICKS((uint32_t)(duration_s * 1000.0f)));
uint32_t remain_ms = (uint32_t)(duration_s * 1000.0f);
while (remain_ms > 0) {
uint32_t chunk = remain_ms > 3000 ? 3000 : remain_ms;
vTaskDelay(pdMS_TO_TICKS(chunk));
remain_ms -= chunk;
if (remain_ms > 0) send_keepalive();
}
echem_shutdown_lp();
AD5940_AFECtrlS(AFECTRL_ALL, bFALSE);
@ -497,7 +504,15 @@ static uint32_t sample_phase(float v_mv, float t_dep_ms, float t_meas_ms,
AD5940_LPDAC0WriteS(mv_to_vbias_code(v_mv), VZERO_CODE);
/* settling — no samples recorded */
vTaskDelay(pdMS_TO_TICKS((uint32_t)t_dep_ms));
{
uint32_t remain_ms = (uint32_t)t_dep_ms;
while (remain_ms > 0) {
uint32_t chunk = remain_ms > 3000 ? 3000 : remain_ms;
vTaskDelay(pdMS_TO_TICKS(chunk));
remain_ms -= chunk;
if (remain_ms > 0) send_keepalive();
}
}
/* measurement — sample at ~50ms intervals */
uint32_t n_samples = (uint32_t)(t_meas_ms / 50.0f + 0.5f);
@ -541,7 +556,15 @@ int echem_chlorine(const ClConfig *cfg, ClPoint *out, uint32_t max_points,
printf("Cl: conditioning at %.0f mV for %.0f ms\n", cfg->v_cond, cfg->t_cond_ms);
AD5940_LPDAC0WriteS(mv_to_vbias_code(cfg->v_cond), VZERO_CODE);
vTaskDelay(pdMS_TO_TICKS((uint32_t)cfg->t_cond_ms));
{
uint32_t remain_ms = (uint32_t)cfg->t_cond_ms;
while (remain_ms > 0) {
uint32_t chunk = remain_ms > 3000 ? 3000 : remain_ms;
vTaskDelay(pdMS_TO_TICKS(chunk));
remain_ms -= chunk;
if (remain_ms > 0) send_keepalive();
}
}
printf("Cl: free chlorine at %.0f mV\n", cfg->v_free);
idx = sample_phase(cfg->v_free, cfg->t_dep_ms, cfg->t_meas_ms,
@ -579,7 +602,14 @@ int echem_ph_ocp(const PhConfig *cfg, PhResult *result)
AD5940_ADCBaseCfgS(&adc);
printf("pH: stabilizing %0.f s\n", cfg->stabilize_s);
vTaskDelay(pdMS_TO_TICKS((uint32_t)(cfg->stabilize_s * 1000.0f)));
uint32_t remain_ms = (uint32_t)(cfg->stabilize_s * 1000.0f);
while (remain_ms > 0) {
uint32_t chunk = remain_ms > 3000 ? 3000 : remain_ms;
vTaskDelay(pdMS_TO_TICKS(chunk));
remain_ms -= chunk;
if (remain_ms > 0) send_keepalive();
}
/* average N readings of V(SE0) and V(RE0) */
#define PH_AVG_N 10

View File

@ -25,6 +25,9 @@ static struct {
/* cell constant K (cm⁻¹), cached from NVS */
static float cell_k_cached;
static float cl_factor_cached;
static float ph_slope_cached;
static float ph_offset_cached;
/* open-circuit calibration data */
static struct {
@ -593,7 +596,10 @@ int eis_has_open_cal(void)
return ocal.valid;
}
#define NVS_CELLK_KEY "cell_k"
#define NVS_CELLK_KEY "cell_k"
#define NVS_CLFACTOR_KEY "cl_factor"
#define NVS_PH_SLOPE_KEY "ph_slope"
#define NVS_PH_OFFSET_KEY "ph_offset"
void eis_set_cell_k(float k)
{
@ -619,3 +625,56 @@ void eis_load_cell_k(void)
cell_k_cached = 0.0f;
nvs_close(h);
}
void eis_set_cl_factor(float f)
{
cl_factor_cached = f;
nvs_handle_t h;
if (nvs_open(NVS_OCAL_NS, NVS_READWRITE, &h) != ESP_OK) return;
nvs_set_blob(h, NVS_CLFACTOR_KEY, &f, sizeof(f));
nvs_commit(h);
nvs_close(h);
}
float eis_get_cl_factor(void)
{
return cl_factor_cached;
}
void eis_load_cl_factor(void)
{
nvs_handle_t h;
if (nvs_open(NVS_OCAL_NS, NVS_READONLY, &h) != ESP_OK) return;
size_t len = sizeof(cl_factor_cached);
if (nvs_get_blob(h, NVS_CLFACTOR_KEY, &cl_factor_cached, &len) != ESP_OK || len != sizeof(cl_factor_cached))
cl_factor_cached = 0.0f;
nvs_close(h);
}
void eis_set_ph_cal(float slope, float offset)
{
ph_slope_cached = slope;
ph_offset_cached = offset;
nvs_handle_t h;
if (nvs_open(NVS_OCAL_NS, NVS_READWRITE, &h) != ESP_OK) return;
nvs_set_blob(h, NVS_PH_SLOPE_KEY, &slope, sizeof(slope));
nvs_set_blob(h, NVS_PH_OFFSET_KEY, &offset, sizeof(offset));
nvs_commit(h);
nvs_close(h);
}
float eis_get_ph_slope(void) { return ph_slope_cached; }
float eis_get_ph_offset(void) { return ph_offset_cached; }
void eis_load_ph_cal(void)
{
nvs_handle_t h;
if (nvs_open(NVS_OCAL_NS, NVS_READONLY, &h) != ESP_OK) return;
size_t len = sizeof(ph_slope_cached);
if (nvs_get_blob(h, NVS_PH_SLOPE_KEY, &ph_slope_cached, &len) != ESP_OK || len != sizeof(ph_slope_cached))
ph_slope_cached = 0.0f;
len = sizeof(ph_offset_cached);
if (nvs_get_blob(h, NVS_PH_OFFSET_KEY, &ph_offset_cached, &len) != ESP_OK || len != sizeof(ph_offset_cached))
ph_offset_cached = 0.0f;
nvs_close(h);
}

View File

@ -67,4 +67,13 @@ void eis_set_cell_k(float k);
float eis_get_cell_k(void);
void eis_load_cell_k(void);
void eis_set_cl_factor(float f);
float eis_get_cl_factor(void);
void eis_load_cl_factor(void);
void eis_set_ph_cal(float slope, float offset);
float eis_get_ph_slope(void);
float eis_get_ph_offset(void);
void eis_load_ph_cal(void);
#endif

View File

@ -12,9 +12,11 @@
#include "nvs_flash.h"
#include "esp_netif.h"
#include "esp_event.h"
#include "esp_timer.h"
#define AD5941_EXPECTED_ADIID 0x4144
static EISConfig cfg;
static uint16_t measurement_counter = 0;
static EISPoint results[EIS_MAX_POINTS];
static LSVPoint lsv_results[ECHEM_MAX_POINTS];
static AmpPoint amp_results[ECHEM_MAX_POINTS];
@ -25,8 +27,10 @@ static void do_sweep(void)
{
eis_init(&cfg);
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
measurement_counter++;
uint32_t n = eis_calc_num_points(&cfg);
send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz);
send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz, ts_ms, measurement_counter);
int got = eis_sweep(results, n, send_eis_point);
printf("Sweep complete: %d points\n", got);
send_sweep_end();
@ -56,6 +60,8 @@ void app_main(void)
eis_default_config(&cfg);
eis_load_open_cal();
eis_load_cell_k();
eis_load_cl_factor();
eis_load_ph_cal();
temp_init();
esp_netif_init();
@ -123,12 +129,18 @@ void app_main(void)
lsv_cfg.v_stop = cmd.lsv.v_stop;
lsv_cfg.scan_rate = cmd.lsv.scan_rate;
lsv_cfg.lp_rtia = cmd.lsv.lp_rtia;
printf("LSV: %.0f-%.0f mV, %.0f mV/s, rtia=%u\n",
lsv_cfg.v_start, lsv_cfg.v_stop, lsv_cfg.scan_rate, lsv_cfg.lp_rtia);
uint32_t max_pts = ECHEM_MAX_POINTS;
if (cmd.lsv.num_points > 0 && cmd.lsv.num_points < ECHEM_MAX_POINTS)
max_pts = cmd.lsv.num_points;
printf("LSV: %.0f-%.0f mV, %.0f mV/s, rtia=%u, max_pts=%lu\n",
lsv_cfg.v_start, lsv_cfg.v_stop, lsv_cfg.scan_rate, lsv_cfg.lp_rtia,
(unsigned long)max_pts);
uint32_t n = echem_lsv_calc_steps(&lsv_cfg, ECHEM_MAX_POINTS);
send_lsv_start(n, lsv_cfg.v_start, lsv_cfg.v_stop);
int got = echem_lsv(&lsv_cfg, lsv_results, ECHEM_MAX_POINTS, send_lsv_point);
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, 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();
break;
@ -143,7 +155,11 @@ void app_main(void)
printf("Amp: %.0f mV, %.0f ms interval, %.0f s\n",
amp_cfg.v_hold, amp_cfg.interval_ms, amp_cfg.duration_s);
send_amp_start(amp_cfg.v_hold);
{
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
measurement_counter++;
send_amp_start(amp_cfg.v_hold, ts_ms, measurement_counter);
}
int got = echem_amp(&amp_cfg, amp_results, ECHEM_MAX_POINTS, send_amp_point);
printf("Amp complete: %d points\n", got);
send_amp_end();
@ -165,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;
}
@ -191,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();
@ -214,6 +237,26 @@ void app_main(void)
send_cell_k(eis_get_cell_k());
break;
case CMD_SET_CL_FACTOR:
eis_set_cl_factor(cmd.cl_factor);
send_cl_factor(cmd.cl_factor);
printf("Cl factor set: %.6f\n", cmd.cl_factor);
break;
case CMD_GET_CL_FACTOR:
send_cl_factor(eis_get_cl_factor());
break;
case CMD_SET_PH_CAL:
eis_set_ph_cal(cmd.ph_cal.slope, cmd.ph_cal.offset);
send_ph_cal(cmd.ph_cal.slope, cmd.ph_cal.offset);
printf("pH cal set: slope=%.4f offset=%.4f\n", cmd.ph_cal.slope, cmd.ph_cal.offset);
break;
case CMD_GET_PH_CAL:
send_ph_cal(eis_get_ph_slope(), eis_get_ph_offset());
break;
case CMD_START_CL: {
ClConfig cl_cfg;
cl_cfg.v_cond = cmd.cl.v_cond;
@ -226,7 +269,11 @@ void app_main(void)
uint32_t n_per = (uint32_t)(cl_cfg.t_meas_ms / 50.0f + 0.5f);
if (n_per < 2) n_per = 2;
send_cl_start(2 * n_per);
{
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
measurement_counter++;
send_cl_start(2 * n_per, ts_ms, measurement_counter);
}
ClResult cl_result;
int got = echem_chlorine(&cl_cfg, cl_results, ECHEM_MAX_POINTS,
&cl_result, send_cl_point);

View File

@ -35,6 +35,29 @@ static void encode_u16(uint16_t val, uint8_t *out)
out[2] = p[1] & 0x7F;
}
void encode_u32(uint32_t val, uint8_t *out)
{
uint8_t *p = (uint8_t *)&val;
out[0] = ((p[0] >> 7) & 1) | ((p[1] >> 6) & 2) |
((p[2] >> 5) & 4) | ((p[3] >> 4) & 8);
out[1] = p[0] & 0x7F;
out[2] = p[1] & 0x7F;
out[3] = p[2] & 0x7F;
out[4] = p[3] & 0x7F;
}
uint32_t decode_u32(const uint8_t *d)
{
uint8_t b[4];
b[0] = d[1] | ((d[0] & 1) << 7);
b[1] = d[2] | ((d[0] & 2) << 6);
b[2] = d[3] | ((d[0] & 4) << 5);
b[3] = d[4] | ((d[0] & 8) << 4);
uint32_t v;
memcpy(&v, b, 4);
return v;
}
float decode_float(const uint8_t *d)
{
uint8_t b[4];
@ -144,16 +167,27 @@ static int send_sysex(const uint8_t *sysex, uint16_t len)
return wifi_send_sysex(sysex, len);
}
/* ---- outbound: keepalive ---- */
int send_keepalive(void)
{
uint8_t sx[] = { 0xF0, 0x7D, RSP_KEEPALIVE, 0xF7 };
return send_sysex(sx, sizeof(sx));
}
/* ---- 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);
}
@ -201,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);
}
@ -233,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);
}
@ -263,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);
}
@ -303,16 +344,32 @@ int send_cl_end(void)
return send_sysex(sx, sizeof(sx));
}
/* ---- outbound: pH calibration ---- */
int send_ph_cal(float slope, float offset)
{
uint8_t sx[16];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_PH_CAL;
encode_float(slope, &sx[p]); p += 5;
encode_float(offset, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
/* ---- 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);
}
@ -341,6 +398,18 @@ int send_cell_k(float k)
return send_sysex(sx, p);
}
/* ---- outbound: chlorine factor ---- */
int send_cl_factor(float f)
{
uint8_t sx[12];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_CL_FACTOR;
encode_float(f, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
/* ---- outbound: reference collection ---- */
int send_ref_frame(uint8_t mode, uint8_t rtia_idx)

View File

@ -26,6 +26,10 @@
#define CMD_START_REFS 0x30
#define CMD_GET_REFS 0x31
#define CMD_CLEAR_REFS 0x32
#define CMD_SET_CL_FACTOR 0x33
#define CMD_GET_CL_FACTOR 0x34
#define CMD_SET_PH_CAL 0x35
#define CMD_GET_PH_CAL 0x36
/* Session sync commands (0x4x) */
#define CMD_SESSION_CREATE 0x40
@ -56,6 +60,9 @@
#define RSP_REF_LP_RANGE 0x21
#define RSP_REFS_DONE 0x22
#define RSP_REF_STATUS 0x23
#define RSP_CL_FACTOR 0x24
#define RSP_PH_CAL 0x25
#define RSP_KEEPALIVE 0x50
/* Session sync responses (0x4x) */
#define RSP_SESSION_CREATED 0x40
@ -75,12 +82,14 @@ typedef struct {
uint8_t rtia;
uint8_t rcal;
uint8_t electrode;
struct { float v_start, v_stop, scan_rate; uint8_t lp_rtia; } lsv;
struct { float v_start, v_stop, scan_rate; uint8_t lp_rtia; uint16_t num_points; } lsv;
struct { float v_hold, interval_ms, duration_s; uint8_t lp_rtia; } amp;
struct { float v_cond, t_cond_ms, v_free, v_total, t_dep_ms, t_meas_ms; uint8_t lp_rtia; } cl;
struct { float stabilize_s; } ph;
struct { float v_mv; float duration_s; } clean;
float cell_k;
float cl_factor;
struct { float slope; float offset; } ph_cal;
struct { uint8_t name_len; char name[MAX_SESSION_NAME]; } session_create;
struct { uint8_t id; } session_switch;
struct { uint8_t id; uint8_t name_len; char name[MAX_SESSION_NAME]; } session_rename;
@ -97,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);
@ -132,12 +146,21 @@ int send_temp(float temp_c);
/* outbound: cell constant */
int send_cell_k(float k);
/* outbound: chlorine factor */
int send_cl_factor(float f);
/* outbound: pH calibration */
int send_ph_cal(float slope, float offset);
/* outbound: reference collection */
int send_ref_frame(uint8_t mode, uint8_t rtia_idx);
int send_ref_lp_range(uint8_t mode, uint8_t low_idx, uint8_t high_idx);
int send_refs_done(void);
int send_ref_status(uint8_t has_refs);
/* keepalive (sent during long blocking ops) */
int send_keepalive(void);
/* session management */
const Session *session_get_all(uint8_t *count);
uint8_t session_get_current(void);

View File

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

View File

@ -5,7 +5,9 @@
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "freertos/semphr.h"
#include "esp_wifi.h"
#include "esp_wifi_ap_get_sta_list.h"
#include "esp_netif.h"
#include "esp_event.h"
#include "lwip/sockets.h"
@ -13,57 +15,86 @@
#define WIFI_SSID "EIS4"
#define WIFI_PASS "eis4data"
#define WIFI_CHANNEL 1
#define WIFI_MAX_CONN 4
#define WIFI_MAX_CONN 10
#define UDP_PORT 5941
#define UDP_BUF_SIZE 128
#define MAX_UDP_CLIENTS 4
#define CLIENT_TIMEOUT_MS 30000
#define UDP_CLIENTS_MAX 16
#define REAP_THRESHOLD 10
#define REAP_WINDOW_MS 200
#define REAP_INTERVAL_MS 5000
static int udp_sock = -1;
static esp_netif_t *ap_netif;
static struct {
struct sockaddr_in addr;
TickType_t last_seen;
uint8_t mac[6];
uint32_t last_touch_ms;
bool active;
} clients[MAX_UDP_CLIENTS];
} clients[UDP_CLIENTS_MAX];
static int client_count;
static SemaphoreHandle_t client_mutex;
static void client_touch(const struct sockaddr_in *addr)
{
TickType_t now = xTaskGetTickCount();
xSemaphoreTake(client_mutex, portMAX_DELAY);
for (int i = 0; i < client_count; i++) {
if (clients[i].addr.sin_addr.s_addr == addr->sin_addr.s_addr &&
clients[i].addr.sin_port == addr->sin_port) {
clients[i].last_seen = now;
clients[i].last_touch_ms = xTaskGetTickCount() * portTICK_PERIOD_MS;
xSemaphoreGive(client_mutex);
return;
}
}
if (client_count < MAX_UDP_CLIENTS) {
if (client_count < UDP_CLIENTS_MAX) {
clients[client_count].addr = *addr;
clients[client_count].last_seen = now;
clients[client_count].active = true;
memset(clients[client_count].mac, 0, 6);
wifi_sta_list_t sta_list;
wifi_sta_mac_ip_list_t ip_list;
if (esp_wifi_ap_get_sta_list(&sta_list) == ESP_OK &&
esp_wifi_ap_get_sta_list_with_ip(&sta_list, &ip_list) == ESP_OK) {
for (int j = 0; j < ip_list.num; j++) {
if (ip_list.sta[j].ip.addr == addr->sin_addr.s_addr) {
memcpy(clients[client_count].mac, ip_list.sta[j].mac, 6);
break;
}
}
}
clients[client_count].last_touch_ms = xTaskGetTickCount() * portTICK_PERIOD_MS;
client_count++;
printf("UDP: client added (%d/%d)\n", client_count, MAX_UDP_CLIENTS);
printf("UDP: client added (%d)\n", client_count);
}
xSemaphoreGive(client_mutex);
}
static void clients_expire(void)
static void client_remove_by_mac(const uint8_t *mac)
{
TickType_t now = xTaskGetTickCount();
TickType_t timeout = pdMS_TO_TICKS(CLIENT_TIMEOUT_MS);
xSemaphoreTake(client_mutex, portMAX_DELAY);
for (int i = 0; i < client_count; ) {
if ((now - clients[i].last_seen) > timeout) {
if (memcmp(clients[i].mac, mac, 6) == 0) {
clients[i] = clients[--client_count];
printf("UDP: client expired (%d/%d)\n", client_count, MAX_UDP_CLIENTS);
printf("UDP: client removed by MAC (%d)\n", client_count);
} else {
i++;
}
}
xSemaphoreGive(client_mutex);
}
/* caller must hold client_mutex */
static void client_remove_by_index(int idx)
{
if (idx < 0 || idx >= client_count) return;
printf("REAP: removing client %d\n", idx);
clients[idx] = clients[--client_count];
}
static void parse_udp_sysex(const uint8_t *data, uint16_t len)
@ -106,6 +137,8 @@ static void parse_udp_sysex(const uint8_t *data, uint16_t len)
cmd.lsv.v_stop = decode_float(&data[8]);
cmd.lsv.scan_rate = decode_float(&data[13]);
cmd.lsv.lp_rtia = data[18];
if (len >= 22)
cmd.lsv.num_points = decode_u16(&data[19]);
break;
case CMD_START_AMP:
if (len < 19) return;
@ -137,6 +170,15 @@ static void parse_udp_sysex(const uint8_t *data, uint16_t len)
if (len < 8) return;
cmd.cell_k = decode_float(&data[3]);
break;
case CMD_SET_CL_FACTOR:
if (len < 8) return;
cmd.cl_factor = decode_float(&data[3]);
break;
case CMD_SET_PH_CAL:
if (len < 13) return;
cmd.ph_cal.slope = decode_float(&data[3]);
cmd.ph_cal.offset = decode_float(&data[8]);
break;
case CMD_SESSION_CREATE:
if (len < 5) return;
cmd.session_create.name_len = data[3] & 0x7F;
@ -163,6 +205,8 @@ static void parse_udp_sysex(const uint8_t *data, uint16_t len)
case CMD_STOP_AMP:
case CMD_GET_TEMP:
case CMD_GET_CELL_K:
case CMD_GET_CL_FACTOR:
case CMD_GET_PH_CAL:
case CMD_START_REFS:
case CMD_GET_REFS:
case CMD_CLEAR_REFS:
@ -191,16 +235,49 @@ static void udp_rx_task(void *param)
if (n <= 0) continue;
client_touch(&src);
clients_expire();
parse_udp_sysex(buf, (uint16_t)n);
}
}
static void udp_reaper_task(void *arg)
{
(void)arg;
for (;;) {
vTaskDelay(pdMS_TO_TICKS(REAP_INTERVAL_MS));
xSemaphoreTake(client_mutex, portMAX_DELAY);
if (client_count < REAP_THRESHOLD) {
xSemaphoreGive(client_mutex);
continue;
}
uint32_t cutoff = xTaskGetTickCount() * portTICK_PERIOD_MS;
printf("REAP: cycle start, %d clients\n", client_count);
xSemaphoreGive(client_mutex);
send_keepalive();
vTaskDelay(pdMS_TO_TICKS(REAP_WINDOW_MS));
xSemaphoreTake(client_mutex, portMAX_DELAY);
int reaped = 0;
for (int i = client_count - 1; i >= 0; i--) {
if (clients[i].last_touch_ms < cutoff) {
client_remove_by_index(i);
reaped++;
}
}
xSemaphoreGive(client_mutex);
if (reaped) printf("REAP: removed %d zombie(s), %d remain\n", reaped, client_count);
}
}
int wifi_send_sysex(const uint8_t *sysex, uint16_t len)
{
if (udp_sock < 0 || client_count == 0)
return -1;
xSemaphoreTake(client_mutex, portMAX_DELAY);
int sent = 0;
for (int i = 0; i < client_count; i++) {
int r = sendto(udp_sock, sysex, len, 0,
@ -208,6 +285,7 @@ int wifi_send_sysex(const uint8_t *sysex, uint16_t len)
sizeof(clients[i].addr));
if (r > 0) sent++;
}
xSemaphoreGive(client_mutex);
return sent > 0 ? 0 : -1;
}
@ -219,12 +297,15 @@ int wifi_get_client_count(void)
static void wifi_event_handler(void *arg, esp_event_base_t base,
int32_t id, void *data)
{
(void)arg; (void)data;
(void)arg;
if (base == WIFI_EVENT) {
if (id == WIFI_EVENT_AP_STACONNECTED)
if (id == WIFI_EVENT_AP_STACONNECTED) {
printf("WiFi: station connected\n");
else if (id == WIFI_EVENT_AP_STADISCONNECTED)
} else if (id == WIFI_EVENT_AP_STADISCONNECTED) {
wifi_event_ap_stadisconnected_t *evt = data;
printf("WiFi: station disconnected\n");
client_remove_by_mac(evt->mac);
}
}
}
@ -250,7 +331,7 @@ static void sta_event_handler(void *arg, esp_event_base_t base,
static int wifi_ap_init(void)
{
esp_netif_create_default_wifi_ap();
ap_netif = esp_netif_create_default_wifi_ap();
esp_netif_create_default_wifi_sta();
wifi_init_config_t wifi_cfg = WIFI_INIT_CONFIG_DEFAULT();
@ -290,6 +371,9 @@ static int wifi_ap_init(void)
err = esp_wifi_start();
if (err) return err;
esp_wifi_set_ps(WIFI_PS_NONE);
esp_wifi_set_inactive_time(WIFI_IF_AP, 120);
if (strlen(STA_SSID) > 0) {
esp_wifi_connect();
printf("WiFi: STA connecting to \"%s\"\n", STA_SSID);
@ -322,6 +406,8 @@ static int udp_init(void)
int wifi_transport_init(void)
{
client_mutex = xSemaphoreCreateMutex();
int rc = wifi_ap_init();
if (rc) {
printf("WiFi: AP init failed: %d\n", rc);
@ -335,5 +421,6 @@ int wifi_transport_init(void)
}
xTaskCreate(udp_rx_task, "udp_rx", 4096, NULL, 5, NULL);
xTaskCreate(udp_reaper_task, "reaper", 2048, NULL, 4, NULL);
return 0;
}

View File

@ -1 +1,3 @@
CONFIG_IDF_TARGET="esp32s3"
CONFIG_LWIP_DHCPS_MAX_STATION_NUM=12
CONFIG_ESP_WIFI_SLP_IRAM_OPT=n