Compare commits
65 Commits
a04163cade
...
8403ff349e
| Author | SHA1 | Date |
|---|---|---|
|
|
8403ff349e | |
|
|
1dca7b035e | |
|
|
e4db734098 | |
|
|
35be164188 | |
|
|
b6ff02bdb4 | |
|
|
95ca2f7cdc | |
|
|
6eab85af57 | |
|
|
f5394d01ca | |
|
|
d061a17e54 | |
|
|
dcde79cf08 | |
|
|
1ba6772738 | |
|
|
8297773827 | |
|
|
80dc8ef561 | |
|
|
bb894b42be | |
|
|
ea3356ac20 | |
|
|
3239eaf9c8 | |
|
|
9825ddb287 | |
|
|
a9f6f9f6ac | |
|
|
3e0cbfd131 | |
|
|
5f550f031a | |
|
|
618e9ed4c8 | |
|
|
d409f3569e | |
|
|
c1721dfd1f | |
|
|
292a1a2e87 | |
|
|
a2a48f47a3 | |
|
|
9bc5347c66 | |
|
|
804ea21d71 | |
|
|
399ee4229b | |
|
|
91a361732d | |
|
|
03d10ab678 | |
|
|
c6bbaa5bc4 | |
|
|
01edb88e0b | |
|
|
cabf04551c | |
|
|
34f8bda191 | |
|
|
73899beaa5 | |
|
|
8de67ca66e | |
|
|
090fcfa2f5 | |
|
|
a21b014d89 | |
|
|
8e1153585b | |
|
|
311fb8ecc7 | |
|
|
c0a0904a44 | |
|
|
1441c5ec42 | |
|
|
818c4ff7a2 | |
|
|
d5e1a7dd0f | |
|
|
bdb72a9917 | |
|
|
5b051cfa20 | |
|
|
3c33c7806d | |
|
|
0cfeb287e6 | |
|
|
e8ce7eb98c | |
|
|
d84ed33c14 | |
|
|
2e1a2f98f2 | |
|
|
b17d12195d | |
|
|
4c914a5101 | |
|
|
95997e4fd5 | |
|
|
491befa8c6 | |
|
|
324c8a7f5a | |
|
|
df6268d2ac | |
|
|
30cd80d03b | |
|
|
e5fe1c9229 | |
|
|
1abf46f0c3 | |
|
|
6a09782d30 | |
|
|
9e9410a78e | |
|
|
e1209a89cc | |
|
|
a611a72c48 | |
|
|
1ea5c760d7 |
|
|
@ -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
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
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 {
|
enum Tab: String, CaseIterable, Identifiable {
|
||||||
case eis = "EIS"
|
case eis = "EIS"
|
||||||
case lsv = "LSV"
|
case lsv = "LSV"
|
||||||
|
|
@ -37,11 +47,14 @@ final class AppState {
|
||||||
|
|
||||||
// LSV
|
// LSV
|
||||||
var lsvPoints: [LsvPoint] = []
|
var lsvPoints: [LsvPoint] = []
|
||||||
|
var lsvPeaks: [LsvPeak] = []
|
||||||
var lsvTotal: UInt16 = 0
|
var lsvTotal: UInt16 = 0
|
||||||
var lsvStartV: String = "0"
|
var lsvStartV: String = "0"
|
||||||
var lsvStopV: String = "500"
|
var lsvStopV: String = "500"
|
||||||
var lsvScanRate: String = "50"
|
var lsvScanRate: String = "50"
|
||||||
var lsvRtia: LpRtia = .r10K
|
var lsvRtia: LpRtia = .r10K
|
||||||
|
var lsvDensityMode: LsvDensityMode = .ptsPerMv
|
||||||
|
var lsvDensity: String = "1"
|
||||||
|
|
||||||
// Amperometry
|
// Amperometry
|
||||||
var ampPoints: [AmpPoint] = []
|
var ampPoints: [AmpPoint] = []
|
||||||
|
|
@ -63,6 +76,9 @@ final class AppState {
|
||||||
var clDepT: String = "5000"
|
var clDepT: String = "5000"
|
||||||
var clMeasT: String = "5000"
|
var clMeasT: String = "5000"
|
||||||
var clRtia: LpRtia = .r10K
|
var clRtia: LpRtia = .r10K
|
||||||
|
var clManualPeaks: Bool = false
|
||||||
|
var clAutoState: ClAutoState = .idle
|
||||||
|
var clAutoPotentials: ClPotentials? = nil
|
||||||
|
|
||||||
// pH
|
// pH
|
||||||
var phResult: PhResult? = nil
|
var phResult: PhResult? = nil
|
||||||
|
|
@ -84,6 +100,9 @@ final class AppState {
|
||||||
|
|
||||||
// Session
|
// Session
|
||||||
var currentSessionId: Int64? = nil
|
var currentSessionId: Int64? = nil
|
||||||
|
var firmwareSessionMap: [UInt8: Int64] = [:]
|
||||||
|
var sessionListReceived: Bool = false
|
||||||
|
private var pendingEspTimestamp: Int64? = nil
|
||||||
|
|
||||||
// Calibration
|
// Calibration
|
||||||
var calVolumeGal: Double = 25
|
var calVolumeGal: Double = 25
|
||||||
|
|
@ -93,6 +112,12 @@ final class AppState {
|
||||||
var calTempC: String = "40"
|
var calTempC: String = "40"
|
||||||
var calCellConstant: Double? = nil
|
var calCellConstant: Double? = nil
|
||||||
var calRs: 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
|
// Clean
|
||||||
var cleanV: String = "1200"
|
var cleanV: String = "1200"
|
||||||
|
|
@ -103,6 +128,10 @@ final class AppState {
|
||||||
transport.setMessageHandler { [weak self] msg in
|
transport.setMessageHandler { [weak self] msg in
|
||||||
self?.handleMessage(msg)
|
self?.handleMessage(msg)
|
||||||
}
|
}
|
||||||
|
transport.setDisconnectHandler { [weak self] in
|
||||||
|
self?.sessionListReceived = false
|
||||||
|
self?.firmwareSessionMap.removeAll()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Send helper
|
// MARK: - Send helper
|
||||||
|
|
@ -116,7 +145,8 @@ final class AppState {
|
||||||
private func handleMessage(_ msg: EisMessage) {
|
private func handleMessage(_ msg: EisMessage) {
|
||||||
switch msg {
|
switch msg {
|
||||||
|
|
||||||
case .sweepStart(let numPoints, let freqStart, let freqStop):
|
case .sweepStart(let numPoints, let freqStart, let freqStop, let espTs, _):
|
||||||
|
pendingEspTimestamp = espTs.map { Int64($0) }
|
||||||
if collectingRefs {
|
if collectingRefs {
|
||||||
eisPoints.removeAll()
|
eisPoints.removeAll()
|
||||||
sweepTotal = numPoints
|
sweepTotal = numPoints
|
||||||
|
|
@ -150,9 +180,14 @@ final class AppState {
|
||||||
rtia = cfg.rtia
|
rtia = cfg.rtia
|
||||||
rcal = cfg.rcal
|
rcal = cfg.rcal
|
||||||
electrode = cfg.electrode
|
electrode = cfg.electrode
|
||||||
|
if !sessionListReceived {
|
||||||
|
sessionListReceived = true
|
||||||
|
send(buildSysexSessionList())
|
||||||
|
}
|
||||||
status = "Config received"
|
status = "Config received"
|
||||||
|
|
||||||
case .lsvStart(let numPoints, let vStart, let vStop):
|
case .lsvStart(let numPoints, let vStart, let vStop, let espTs, _):
|
||||||
|
pendingEspTimestamp = espTs.map { Int64($0) }
|
||||||
lsvPoints.removeAll()
|
lsvPoints.removeAll()
|
||||||
lsvTotal = numPoints
|
lsvTotal = numPoints
|
||||||
status = String(format: "LSV: %d pts, %.0f--%.0f mV", numPoints, vStart, vStop)
|
status = String(format: "LSV: %d pts, %.0f--%.0f mV", numPoints, vStart, vStop)
|
||||||
|
|
@ -163,9 +198,37 @@ final class AppState {
|
||||||
|
|
||||||
case .lsvEnd:
|
case .lsvEnd:
|
||||||
saveLsv()
|
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()
|
ampPoints.removeAll()
|
||||||
ampRunning = true
|
ampRunning = true
|
||||||
status = String(format: "Amp: %.0f mV", vHold)
|
status = String(format: "Amp: %.0f mV", vHold)
|
||||||
|
|
@ -180,7 +243,8 @@ final class AppState {
|
||||||
saveAmp()
|
saveAmp()
|
||||||
status = "Amp complete: \(ampPoints.count) points"
|
status = "Amp complete: \(ampPoints.count) points"
|
||||||
|
|
||||||
case .clStart(let numPoints):
|
case .clStart(let numPoints, let espTs, _):
|
||||||
|
pendingEspTimestamp = espTs.map { Int64($0) }
|
||||||
clPoints.removeAll()
|
clPoints.removeAll()
|
||||||
clResult = nil
|
clResult = nil
|
||||||
clTotal = numPoints
|
clTotal = numPoints
|
||||||
|
|
@ -196,9 +260,22 @@ final class AppState {
|
||||||
|
|
||||||
case .clEnd:
|
case .clEnd:
|
||||||
saveCl()
|
saveCl()
|
||||||
|
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"
|
status = "Chlorine complete: \(clPoints.count) points"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status = "Chlorine complete: \(clPoints.count) points"
|
||||||
|
}
|
||||||
|
|
||||||
case .phResult(let r):
|
case .phResult(let r):
|
||||||
|
transport.measuring = false
|
||||||
if collectingRefs {
|
if collectingRefs {
|
||||||
phRef = r
|
phRef = r
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -232,6 +309,7 @@ final class AppState {
|
||||||
break
|
break
|
||||||
|
|
||||||
case .refsDone:
|
case .refsDone:
|
||||||
|
transport.measuring = false
|
||||||
collectingRefs = false
|
collectingRefs = false
|
||||||
hasDeviceRefs = true
|
hasDeviceRefs = true
|
||||||
refMode = nil
|
refMode = nil
|
||||||
|
|
@ -251,9 +329,80 @@ final class AppState {
|
||||||
case .cellK(let k):
|
case .cellK(let k):
|
||||||
calCellConstant = Double(k)
|
calCellConstant = Double(k)
|
||||||
status = String(format: "Device cell constant: %.4f cm\u{207B}\u{00B9}", 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
|
// MARK: - Actions
|
||||||
|
|
||||||
func applyEISSettings() {
|
func applyEISSettings() {
|
||||||
|
|
@ -273,13 +422,30 @@ final class AppState {
|
||||||
send(buildSysexStartSweep())
|
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() {
|
func startLSV() {
|
||||||
lsvPoints.removeAll()
|
lsvPoints.removeAll()
|
||||||
let vs = Float(lsvStartV) ?? 0
|
let vs = Float(lsvStartV) ?? 0
|
||||||
let ve = Float(lsvStopV) ?? 500
|
let ve = Float(lsvStopV) ?? 500
|
||||||
let sr = Float(lsvScanRate) ?? 50
|
let sr = Float(lsvScanRate) ?? 50
|
||||||
|
let n = lsvCalcPoints()
|
||||||
send(buildSysexGetTemp())
|
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() {
|
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() {
|
func startPh() {
|
||||||
phResult = nil
|
phResult = nil
|
||||||
|
transport.measuring = true
|
||||||
let stab = Float(phStabilize) ?? 30
|
let stab = Float(phStabilize) ?? 30
|
||||||
send(buildSysexGetTemp())
|
send(buildSysexGetTemp())
|
||||||
send(buildSysexStartPh(stabilizeS: stab))
|
send(buildSysexStartPh(stabilizeS: stab))
|
||||||
|
|
@ -358,6 +547,7 @@ final class AppState {
|
||||||
|
|
||||||
func collectRefs() {
|
func collectRefs() {
|
||||||
collectingRefs = true
|
collectingRefs = true
|
||||||
|
transport.measuring = true
|
||||||
eisRefs.removeAll()
|
eisRefs.removeAll()
|
||||||
status = "Starting reference collection..."
|
status = "Starting reference collection..."
|
||||||
send(buildSysexStartRefs())
|
send(buildSysexStartRefs())
|
||||||
|
|
@ -365,6 +555,7 @@ final class AppState {
|
||||||
|
|
||||||
func getRefs() {
|
func getRefs() {
|
||||||
collectingRefs = true
|
collectingRefs = true
|
||||||
|
transport.measuring = true
|
||||||
eisRefs.removeAll()
|
eisRefs.removeAll()
|
||||||
send(buildSysexGetRefs())
|
send(buildSysexGetRefs())
|
||||||
}
|
}
|
||||||
|
|
@ -382,8 +573,13 @@ final class AppState {
|
||||||
func startClean() {
|
func startClean() {
|
||||||
let v = Float(cleanV) ?? 1200
|
let v = Float(cleanV) ?? 1200
|
||||||
let d = Float(cleanDur) ?? 30
|
let d = Float(cleanDur) ?? 30
|
||||||
|
transport.measuring = true
|
||||||
send(buildSysexStartClean(vMv: v, durationS: d))
|
send(buildSysexStartClean(vMv: v, durationS: d))
|
||||||
status = String(format: "Cleaning: %.0f mV for %.0fs", v, d)
|
status = String(format: "Cleaning: %.0f mV for %.0fs", v, d)
|
||||||
|
let t = transport
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + Double(d) + 2) {
|
||||||
|
t.measuring = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasCurrentRef: Bool {
|
var hasCurrentRef: Bool {
|
||||||
|
|
@ -412,6 +608,8 @@ final class AppState {
|
||||||
|
|
||||||
private func saveEis() {
|
private func saveEis() {
|
||||||
guard let sid = currentSessionId else { return }
|
guard let sid = currentSessionId else { return }
|
||||||
|
let ts = pendingEspTimestamp
|
||||||
|
pendingEspTimestamp = nil
|
||||||
let params: [String: String] = [
|
let params: [String: String] = [
|
||||||
"freq_start": freqStart,
|
"freq_start": freqStart,
|
||||||
"freq_stop": freqStop,
|
"freq_stop": freqStop,
|
||||||
|
|
@ -421,7 +619,7 @@ final class AppState {
|
||||||
"electrode": electrode.label,
|
"electrode": electrode.label,
|
||||||
]
|
]
|
||||||
guard let configData = try? JSONEncoder().encode(params) else { return }
|
guard let configData = try? JSONEncoder().encode(params) else { return }
|
||||||
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .eis) else { return }
|
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .eis, espTimestamp: ts) else { return }
|
||||||
meas.config = configData
|
meas.config = configData
|
||||||
guard let mid = meas.id else { return }
|
guard let mid = meas.id else { return }
|
||||||
let indexed = eisPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
let indexed = eisPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
||||||
|
|
@ -430,6 +628,8 @@ final class AppState {
|
||||||
|
|
||||||
private func saveLsv() {
|
private func saveLsv() {
|
||||||
guard let sid = currentSessionId else { return }
|
guard let sid = currentSessionId else { return }
|
||||||
|
let ts = pendingEspTimestamp
|
||||||
|
pendingEspTimestamp = nil
|
||||||
let params: [String: String] = [
|
let params: [String: String] = [
|
||||||
"v_start": lsvStartV,
|
"v_start": lsvStartV,
|
||||||
"v_stop": lsvStopV,
|
"v_stop": lsvStopV,
|
||||||
|
|
@ -437,7 +637,7 @@ final class AppState {
|
||||||
"rtia": lsvRtia.label,
|
"rtia": lsvRtia.label,
|
||||||
]
|
]
|
||||||
guard let configData = try? JSONEncoder().encode(params) else { return }
|
guard let configData = try? JSONEncoder().encode(params) else { return }
|
||||||
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .lsv) else { return }
|
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .lsv, espTimestamp: ts) else { return }
|
||||||
meas.config = configData
|
meas.config = configData
|
||||||
guard let mid = meas.id else { return }
|
guard let mid = meas.id else { return }
|
||||||
let indexed = lsvPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
let indexed = lsvPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
||||||
|
|
@ -446,6 +646,8 @@ final class AppState {
|
||||||
|
|
||||||
private func saveAmp() {
|
private func saveAmp() {
|
||||||
guard let sid = currentSessionId else { return }
|
guard let sid = currentSessionId else { return }
|
||||||
|
let ts = pendingEspTimestamp
|
||||||
|
pendingEspTimestamp = nil
|
||||||
let params: [String: String] = [
|
let params: [String: String] = [
|
||||||
"v_hold": ampVHold,
|
"v_hold": ampVHold,
|
||||||
"interval_ms": ampInterval,
|
"interval_ms": ampInterval,
|
||||||
|
|
@ -453,7 +655,7 @@ final class AppState {
|
||||||
"rtia": ampRtia.label,
|
"rtia": ampRtia.label,
|
||||||
]
|
]
|
||||||
guard let configData = try? JSONEncoder().encode(params) else { return }
|
guard let configData = try? JSONEncoder().encode(params) else { return }
|
||||||
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .amp) else { return }
|
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .amp, espTimestamp: ts) else { return }
|
||||||
meas.config = configData
|
meas.config = configData
|
||||||
guard let mid = meas.id else { return }
|
guard let mid = meas.id else { return }
|
||||||
let indexed = ampPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
let indexed = ampPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
||||||
|
|
@ -462,6 +664,8 @@ final class AppState {
|
||||||
|
|
||||||
private func saveCl() {
|
private func saveCl() {
|
||||||
guard let sid = currentSessionId else { return }
|
guard let sid = currentSessionId else { return }
|
||||||
|
let ts = pendingEspTimestamp
|
||||||
|
pendingEspTimestamp = nil
|
||||||
let params: [String: String] = [
|
let params: [String: String] = [
|
||||||
"cond_v": clCondV,
|
"cond_v": clCondV,
|
||||||
"cond_t": clCondT,
|
"cond_t": clCondT,
|
||||||
|
|
@ -472,7 +676,7 @@ final class AppState {
|
||||||
"rtia": clRtia.label,
|
"rtia": clRtia.label,
|
||||||
]
|
]
|
||||||
guard let configData = try? JSONEncoder().encode(params) else { return }
|
guard let configData = try? JSONEncoder().encode(params) else { return }
|
||||||
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .chlorine) else { return }
|
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .chlorine, espTimestamp: ts) else { return }
|
||||||
meas.config = configData
|
meas.config = configData
|
||||||
guard let mid = meas.id else { return }
|
guard let mid = meas.id else { return }
|
||||||
let indexed = clPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
let indexed = clPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
||||||
|
|
@ -484,11 +688,13 @@ final class AppState {
|
||||||
|
|
||||||
private func savePh(_ result: PhResult) {
|
private func savePh(_ result: PhResult) {
|
||||||
guard let sid = currentSessionId else { return }
|
guard let sid = currentSessionId else { return }
|
||||||
|
let ts = pendingEspTimestamp
|
||||||
|
pendingEspTimestamp = nil
|
||||||
let params: [String: String] = [
|
let params: [String: String] = [
|
||||||
"stabilize_s": phStabilize,
|
"stabilize_s": phStabilize,
|
||||||
]
|
]
|
||||||
guard let configData = try? JSONEncoder().encode(params) else { return }
|
guard let configData = try? JSONEncoder().encode(params) else { return }
|
||||||
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .ph) else { return }
|
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .ph, espTimestamp: ts) else { return }
|
||||||
meas.config = configData
|
meas.config = configData
|
||||||
guard let mid = meas.id else { return }
|
guard let mid = meas.id else { return }
|
||||||
try? Storage.shared.addDataPoint(measurementId: mid, index: 0, point: result)
|
try? Storage.shared.addDataPoint(measurementId: mid, index: 0, point: result)
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 179 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,13 @@ let RSP_REF_FRAME: UInt8 = 0x20
|
||||||
let RSP_REF_LP_RANGE: UInt8 = 0x21
|
let RSP_REF_LP_RANGE: UInt8 = 0x21
|
||||||
let RSP_REFS_DONE: UInt8 = 0x22
|
let RSP_REFS_DONE: UInt8 = 0x22
|
||||||
let RSP_REF_STATUS: UInt8 = 0x23
|
let RSP_REF_STATUS: UInt8 = 0x23
|
||||||
|
let RSP_CL_FACTOR: UInt8 = 0x24
|
||||||
|
let RSP_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
|
// Cue -> ESP32
|
||||||
let CMD_SET_SWEEP: UInt8 = 0x10
|
let CMD_SET_SWEEP: UInt8 = 0x10
|
||||||
|
|
@ -46,9 +53,17 @@ let CMD_START_PH: UInt8 = 0x24
|
||||||
let CMD_START_CLEAN: UInt8 = 0x25
|
let CMD_START_CLEAN: UInt8 = 0x25
|
||||||
let CMD_SET_CELL_K: UInt8 = 0x28
|
let CMD_SET_CELL_K: UInt8 = 0x28
|
||||||
let CMD_GET_CELL_K: UInt8 = 0x29
|
let CMD_GET_CELL_K: UInt8 = 0x29
|
||||||
|
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_START_REFS: UInt8 = 0x30
|
||||||
let CMD_GET_REFS: UInt8 = 0x31
|
let CMD_GET_REFS: UInt8 = 0x31
|
||||||
let CMD_CLEAR_REFS: UInt8 = 0x32
|
let CMD_CLEAR_REFS: UInt8 = 0x32
|
||||||
|
let CMD_SESSION_CREATE: UInt8 = 0x40
|
||||||
|
let CMD_SESSION_SWITCH: UInt8 = 0x41
|
||||||
|
let CMD_SESSION_LIST: UInt8 = 0x42
|
||||||
|
let CMD_SESSION_RENAME: UInt8 = 0x43
|
||||||
|
|
||||||
// MARK: - 7-bit MIDI encoding
|
// MARK: - 7-bit MIDI encoding
|
||||||
|
|
||||||
|
|
@ -87,6 +102,31 @@ func encodeU16(_ val: UInt16) -> [UInt8] {
|
||||||
return [mask, p[0] & 0x7F, p[1] & 0x7F]
|
return [mask, p[0] & 0x7F, p[1] & 0x7F]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encode a UInt32 into 5 MIDI-safe bytes.
|
||||||
|
func encodeU32(_ val: UInt32) -> [UInt8] {
|
||||||
|
var v = val
|
||||||
|
let p = withUnsafeBytes(of: &v) { Array($0) }
|
||||||
|
let mask: UInt8 = ((p[0] >> 7) & 1)
|
||||||
|
| ((p[1] >> 6) & 2)
|
||||||
|
| ((p[2] >> 5) & 4)
|
||||||
|
| ((p[3] >> 4) & 8)
|
||||||
|
return [mask, p[0] & 0x7F, p[1] & 0x7F, p[2] & 0x7F, p[3] & 0x7F]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode 5 MIDI-safe bytes back into a UInt32.
|
||||||
|
func decodeU32(_ d: [UInt8], at offset: Int = 0) -> UInt32 {
|
||||||
|
let m = d[offset]
|
||||||
|
let b0 = d[offset + 1] | ((m & 1) << 7)
|
||||||
|
let b1 = d[offset + 2] | ((m & 2) << 6)
|
||||||
|
let b2 = d[offset + 3] | ((m & 4) << 5)
|
||||||
|
let b3 = d[offset + 4] | ((m & 8) << 4)
|
||||||
|
var val: UInt32 = 0
|
||||||
|
withUnsafeMutableBytes(of: &val) { buf in
|
||||||
|
buf[0] = b0; buf[1] = b1; buf[2] = b2; buf[3] = b3
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
/// Decode 3 MIDI-safe bytes back into a UInt16.
|
/// Decode 3 MIDI-safe bytes back into a UInt16.
|
||||||
func decodeU16(_ d: [UInt8], at offset: Int = 0) -> UInt16 {
|
func decodeU16(_ d: [UInt8], at offset: Int = 0) -> UInt16 {
|
||||||
let m = d[offset]
|
let m = d[offset]
|
||||||
|
|
@ -102,17 +142,17 @@ func decodeU16(_ d: [UInt8], at offset: Int = 0) -> UInt16 {
|
||||||
// MARK: - Message enum
|
// MARK: - Message enum
|
||||||
|
|
||||||
enum EisMessage {
|
enum EisMessage {
|
||||||
case sweepStart(numPoints: UInt16, freqStart: Float, freqStop: Float)
|
case sweepStart(numPoints: UInt16, freqStart: Float, freqStop: Float, espTimestamp: UInt32?, espMeasId: UInt16?)
|
||||||
case dataPoint(index: UInt16, point: EisPoint)
|
case dataPoint(index: UInt16, point: EisPoint)
|
||||||
case sweepEnd
|
case sweepEnd
|
||||||
case config(EisConfig)
|
case config(EisConfig)
|
||||||
case lsvStart(numPoints: UInt16, vStart: Float, vStop: Float)
|
case lsvStart(numPoints: UInt16, vStart: Float, vStop: Float, espTimestamp: UInt32?, espMeasId: UInt16?)
|
||||||
case lsvPoint(index: UInt16, point: LsvPoint)
|
case lsvPoint(index: UInt16, point: LsvPoint)
|
||||||
case lsvEnd
|
case lsvEnd
|
||||||
case ampStart(vHold: Float)
|
case ampStart(vHold: Float, espTimestamp: UInt32?, espMeasId: UInt16?)
|
||||||
case ampPoint(index: UInt16, point: AmpPoint)
|
case ampPoint(index: UInt16, point: AmpPoint)
|
||||||
case ampEnd
|
case ampEnd
|
||||||
case clStart(numPoints: UInt16)
|
case clStart(numPoints: UInt16, espTimestamp: UInt32?, espMeasId: UInt16?)
|
||||||
case clPoint(index: UInt16, point: ClPoint)
|
case clPoint(index: UInt16, point: ClPoint)
|
||||||
case clResult(ClResult)
|
case clResult(ClResult)
|
||||||
case clEnd
|
case clEnd
|
||||||
|
|
@ -123,6 +163,13 @@ enum EisMessage {
|
||||||
case refsDone
|
case refsDone
|
||||||
case refStatus(hasRefs: Bool)
|
case refStatus(hasRefs: Bool)
|
||||||
case cellK(Float)
|
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
|
// MARK: - Response parser
|
||||||
|
|
@ -136,10 +183,13 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? {
|
||||||
switch data[1] {
|
switch data[1] {
|
||||||
|
|
||||||
case RSP_SWEEP_START where p.count >= 13:
|
case RSP_SWEEP_START where p.count >= 13:
|
||||||
|
let hasExt = p.count >= 21
|
||||||
return .sweepStart(
|
return .sweepStart(
|
||||||
numPoints: decodeU16(p, at: 0),
|
numPoints: decodeU16(p, at: 0),
|
||||||
freqStart: decodeFloat(p, at: 3),
|
freqStart: decodeFloat(p, at: 3),
|
||||||
freqStop: decodeFloat(p, at: 8)
|
freqStop: decodeFloat(p, at: 8),
|
||||||
|
espTimestamp: hasExt ? decodeU32(p, at: 13) : nil,
|
||||||
|
espMeasId: hasExt ? decodeU16(p, at: 18) : nil
|
||||||
)
|
)
|
||||||
|
|
||||||
case RSP_DATA_POINT where p.count >= 28:
|
case RSP_DATA_POINT where p.count >= 28:
|
||||||
|
|
@ -174,10 +224,13 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? {
|
||||||
))
|
))
|
||||||
|
|
||||||
case RSP_LSV_START where p.count >= 13:
|
case RSP_LSV_START where p.count >= 13:
|
||||||
|
let hasExt = p.count >= 21
|
||||||
return .lsvStart(
|
return .lsvStart(
|
||||||
numPoints: decodeU16(p, at: 0),
|
numPoints: decodeU16(p, at: 0),
|
||||||
vStart: decodeFloat(p, at: 3),
|
vStart: decodeFloat(p, at: 3),
|
||||||
vStop: decodeFloat(p, at: 8)
|
vStop: decodeFloat(p, at: 8),
|
||||||
|
espTimestamp: hasExt ? decodeU32(p, at: 13) : nil,
|
||||||
|
espMeasId: hasExt ? decodeU16(p, at: 18) : nil
|
||||||
)
|
)
|
||||||
|
|
||||||
case RSP_LSV_POINT where p.count >= 13:
|
case RSP_LSV_POINT where p.count >= 13:
|
||||||
|
|
@ -193,7 +246,12 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? {
|
||||||
return .lsvEnd
|
return .lsvEnd
|
||||||
|
|
||||||
case RSP_AMP_START where p.count >= 5:
|
case RSP_AMP_START where p.count >= 5:
|
||||||
return .ampStart(vHold: decodeFloat(p, at: 0))
|
let hasExt = p.count >= 13
|
||||||
|
return .ampStart(
|
||||||
|
vHold: decodeFloat(p, at: 0),
|
||||||
|
espTimestamp: hasExt ? decodeU32(p, at: 5) : nil,
|
||||||
|
espMeasId: hasExt ? decodeU16(p, at: 10) : nil
|
||||||
|
)
|
||||||
|
|
||||||
case RSP_AMP_POINT where p.count >= 13:
|
case RSP_AMP_POINT where p.count >= 13:
|
||||||
return .ampPoint(
|
return .ampPoint(
|
||||||
|
|
@ -208,7 +266,12 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? {
|
||||||
return .ampEnd
|
return .ampEnd
|
||||||
|
|
||||||
case RSP_CL_START where p.count >= 3:
|
case RSP_CL_START where p.count >= 3:
|
||||||
return .clStart(numPoints: decodeU16(p, at: 0))
|
let hasExt = p.count >= 11
|
||||||
|
return .clStart(
|
||||||
|
numPoints: decodeU16(p, at: 0),
|
||||||
|
espTimestamp: hasExt ? decodeU32(p, at: 3) : nil,
|
||||||
|
espMeasId: hasExt ? decodeU16(p, at: 8) : nil
|
||||||
|
)
|
||||||
|
|
||||||
case RSP_CL_POINT where p.count >= 14:
|
case RSP_CL_POINT where p.count >= 14:
|
||||||
return .clPoint(
|
return .clPoint(
|
||||||
|
|
@ -254,6 +317,58 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? {
|
||||||
case RSP_CELL_K where p.count >= 5:
|
case RSP_CELL_K where p.count >= 5:
|
||||||
return .cellK(decodeFloat(p, at: 0))
|
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:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -290,12 +405,13 @@ func buildSysexGetConfig() -> [UInt8] {
|
||||||
[0xF0, sysexMfr, CMD_GET_CONFIG, 0xF7]
|
[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]
|
var sx: [UInt8] = [0xF0, sysexMfr, CMD_START_LSV]
|
||||||
sx.append(contentsOf: encodeFloat(vStart))
|
sx.append(contentsOf: encodeFloat(vStart))
|
||||||
sx.append(contentsOf: encodeFloat(vStop))
|
sx.append(contentsOf: encodeFloat(vStop))
|
||||||
sx.append(contentsOf: encodeFloat(scanRate))
|
sx.append(contentsOf: encodeFloat(scanRate))
|
||||||
sx.append(lpRtia.rawValue)
|
sx.append(lpRtia.rawValue)
|
||||||
|
sx.append(contentsOf: encodeU16(numPoints))
|
||||||
sx.append(0xF7)
|
sx.append(0xF7)
|
||||||
return sx
|
return sx
|
||||||
}
|
}
|
||||||
|
|
@ -371,3 +487,52 @@ func buildSysexSetCellK(_ k: Float) -> [UInt8] {
|
||||||
func buildSysexGetCellK() -> [UInt8] {
|
func buildSysexGetCellK() -> [UInt8] {
|
||||||
[0xF0, sysexMfr, CMD_GET_CELL_K, 0xF7]
|
[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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ struct Session: Codable, FetchableRecord, MutablePersistableRecord {
|
||||||
var startedAt: Date
|
var startedAt: Date
|
||||||
var label: String?
|
var label: String?
|
||||||
var notes: String?
|
var notes: String?
|
||||||
|
var firmwareSessionId: Int64?
|
||||||
|
|
||||||
mutating func didInsert(_ inserted: InsertionSuccess) {
|
mutating func didInsert(_ inserted: InsertionSuccess) {
|
||||||
id = inserted.rowID
|
id = inserted.rowID
|
||||||
|
|
@ -24,6 +25,7 @@ struct Measurement: Codable, FetchableRecord, MutablePersistableRecord {
|
||||||
var startedAt: Date
|
var startedAt: Date
|
||||||
var config: Data?
|
var config: Data?
|
||||||
var resultSummary: Data?
|
var resultSummary: Data?
|
||||||
|
var espTimestamp: Int64?
|
||||||
|
|
||||||
mutating func didInsert(_ inserted: InsertionSuccess) {
|
mutating func didInsert(_ inserted: InsertionSuccess) {
|
||||||
id = inserted.rowID
|
id = inserted.rowID
|
||||||
|
|
@ -89,19 +91,34 @@ final class Storage: @unchecked Sendable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
migrator.registerMigration("v2") { db in
|
||||||
|
try db.alter(table: "measurement") { t in
|
||||||
|
t.add(column: "espTimestamp", .integer)
|
||||||
|
}
|
||||||
|
try db.alter(table: "session") { t in
|
||||||
|
t.add(column: "firmwareSessionId", .integer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try migrator.migrate(dbQueue)
|
try migrator.migrate(dbQueue)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Sessions
|
// MARK: - Sessions
|
||||||
|
|
||||||
func createSession(label: String? = nil) throws -> Session {
|
func createSession(label: String? = nil, firmwareSessionId: Int64? = nil) throws -> Session {
|
||||||
try dbQueue.write { db in
|
try dbQueue.write { db in
|
||||||
var s = Session(startedAt: Date(), label: label)
|
var s = Session(startedAt: Date(), label: label, firmwareSessionId: firmwareSessionId)
|
||||||
try s.insert(db)
|
try s.insert(db)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fetchSession(_ id: Int64) -> Session? {
|
||||||
|
try? dbQueue.read { db in
|
||||||
|
try Session.fetchOne(db, key: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func fetchSessions() throws -> [Session] {
|
func fetchSessions() throws -> [Session] {
|
||||||
try dbQueue.read { db in
|
try dbQueue.read { db in
|
||||||
try Session.order(Column("startedAt").desc).fetchAll(db)
|
try Session.order(Column("startedAt").desc).fetchAll(db)
|
||||||
|
|
@ -123,13 +140,43 @@ final class Storage: @unchecked Sendable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateSessionLabel(_ id: Int64, label: String) throws {
|
||||||
|
try dbQueue.write { db in
|
||||||
|
try db.execute(
|
||||||
|
sql: "UPDATE session SET label = ? WHERE id = ?",
|
||||||
|
arguments: [label, id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionByFirmwareId(_ fwId: Int64) -> Session? {
|
||||||
|
try? dbQueue.read { db in
|
||||||
|
try Session
|
||||||
|
.filter(Column("firmwareSessionId") == fwId)
|
||||||
|
.fetchOne(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Measurements
|
// MARK: - Measurements
|
||||||
|
|
||||||
|
func measurementExists(sessionId: Int64, espTimestamp: Int64) -> Bool {
|
||||||
|
(try? dbQueue.read { db in
|
||||||
|
try Measurement
|
||||||
|
.filter(Column("sessionId") == sessionId)
|
||||||
|
.filter(Column("espTimestamp") == espTimestamp)
|
||||||
|
.fetchCount(db) > 0
|
||||||
|
}) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
func addMeasurement(
|
func addMeasurement(
|
||||||
sessionId: Int64,
|
sessionId: Int64,
|
||||||
type: MeasurementType,
|
type: MeasurementType,
|
||||||
config: (any Encodable)? = nil
|
config: (any Encodable)? = nil,
|
||||||
|
espTimestamp: Int64? = nil
|
||||||
) throws -> Measurement {
|
) throws -> Measurement {
|
||||||
|
if let ts = espTimestamp, measurementExists(sessionId: sessionId, espTimestamp: ts) {
|
||||||
|
throw StorageError.duplicate
|
||||||
|
}
|
||||||
let configData: Data? = if let config {
|
let configData: Data? = if let config {
|
||||||
try JSONEncoder().encode(config)
|
try JSONEncoder().encode(config)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -140,7 +187,8 @@ final class Storage: @unchecked Sendable {
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
type: type.rawValue,
|
type: type.rawValue,
|
||||||
startedAt: Date(),
|
startedAt: Date(),
|
||||||
config: configData
|
config: configData,
|
||||||
|
espTimestamp: espTimestamp
|
||||||
)
|
)
|
||||||
try m.insert(db)
|
try m.insert(db)
|
||||||
return m
|
return m
|
||||||
|
|
@ -231,9 +279,10 @@ final class Storage: @unchecked Sendable {
|
||||||
|
|
||||||
// MARK: - Observation (for SwiftUI live updates)
|
// MARK: - Observation (for SwiftUI live updates)
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func observeDataPoints(
|
func observeDataPoints(
|
||||||
measurementId: Int64,
|
measurementId: Int64,
|
||||||
onChange: @escaping ([DataPoint]) -> Void
|
onChange: @escaping @Sendable ([DataPoint]) -> Void
|
||||||
) -> DatabaseCancellable {
|
) -> DatabaseCancellable {
|
||||||
let observation = ValueObservation.tracking { db in
|
let observation = ValueObservation.tracking { db in
|
||||||
try DataPoint
|
try DataPoint
|
||||||
|
|
@ -244,7 +293,8 @@ final class Storage: @unchecked Sendable {
|
||||||
return observation.start(in: dbQueue, onError: { _ in }, onChange: onChange)
|
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
|
let observation = ValueObservation.tracking { db in
|
||||||
try Session.order(Column("startedAt").desc).fetchAll(db)
|
try Session.order(Column("startedAt").desc).fetchAll(db)
|
||||||
}
|
}
|
||||||
|
|
@ -458,6 +508,7 @@ final class Storage: @unchecked Sendable {
|
||||||
|
|
||||||
enum StorageError: Error {
|
enum StorageError: Error {
|
||||||
case notFound
|
case notFound
|
||||||
|
case duplicate
|
||||||
case parseError(String)
|
case parseError(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,13 @@ final class UDPManager: @unchecked Sendable {
|
||||||
var address: String
|
var address: String
|
||||||
var port: UInt16
|
var port: UInt16
|
||||||
|
|
||||||
|
/// Suppress keepalive timeout during blocking firmware operations (pH, clean, refs)
|
||||||
|
var measuring: Bool = false
|
||||||
|
|
||||||
private var connection: NWConnection?
|
private var connection: NWConnection?
|
||||||
private let queue = DispatchQueue(label: "udp.eis4", qos: .userInitiated)
|
private let queue = DispatchQueue(label: "udp.eis4", qos: .userInitiated)
|
||||||
private var onMessage: ((EisMessage) -> Void)?
|
private var onMessage: ((EisMessage) -> Void)?
|
||||||
|
private var onDisconnect: (() -> Void)?
|
||||||
private var keepaliveTimer: Timer?
|
private var keepaliveTimer: Timer?
|
||||||
private var timeoutTimer: Timer?
|
private var timeoutTimer: Timer?
|
||||||
private var lastReceived: Date = .distantPast
|
private var lastReceived: Date = .distantPast
|
||||||
|
|
@ -46,6 +50,10 @@ final class UDPManager: @unchecked Sendable {
|
||||||
onMessage = handler
|
onMessage = handler
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setDisconnectHandler(_ handler: @escaping () -> Void) {
|
||||||
|
onDisconnect = handler
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Connection
|
// MARK: - Connection
|
||||||
|
|
||||||
func connect() {
|
func connect() {
|
||||||
|
|
@ -64,8 +72,9 @@ final class UDPManager: @unchecked Sendable {
|
||||||
connection = conn
|
connection = conn
|
||||||
|
|
||||||
conn.stateUpdateHandler = { [weak self] newState in
|
conn.stateUpdateHandler = { [weak self] newState in
|
||||||
|
guard let self else { return }
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self?.handleStateChange(newState)
|
self.handleStateChange(newState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,7 +85,9 @@ final class UDPManager: @unchecked Sendable {
|
||||||
stopTimers()
|
stopTimers()
|
||||||
connection?.cancel()
|
connection?.cancel()
|
||||||
connection = nil
|
connection = nil
|
||||||
|
measuring = false
|
||||||
state = .disconnected
|
state = .disconnected
|
||||||
|
onDisconnect?()
|
||||||
}
|
}
|
||||||
|
|
||||||
func send(_ sysex: [UInt8]) {
|
func send(_ sysex: [UInt8]) {
|
||||||
|
|
@ -95,6 +106,8 @@ final class UDPManager: @unchecked Sendable {
|
||||||
send(buildSysexGetTemp())
|
send(buildSysexGetTemp())
|
||||||
send(buildSysexGetConfig())
|
send(buildSysexGetConfig())
|
||||||
send(buildSysexGetCellK())
|
send(buildSysexGetCellK())
|
||||||
|
send(buildSysexGetClFactor())
|
||||||
|
send(buildSysexGetPhCal())
|
||||||
startTimers()
|
startTimers()
|
||||||
receiveLoop()
|
receiveLoop()
|
||||||
|
|
||||||
|
|
@ -172,16 +185,18 @@ final class UDPManager: @unchecked Sendable {
|
||||||
|
|
||||||
private func startTimers() {
|
private func startTimers() {
|
||||||
keepaliveTimer = Timer.scheduledTimer(withTimeInterval: Self.keepaliveInterval, repeats: true) { [weak self] _ in
|
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
|
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 {
|
if Date().timeIntervalSince(self.lastReceived) > Self.timeout {
|
||||||
self.state = .disconnected
|
self.state = .disconnected
|
||||||
self.stopTimers()
|
self.stopTimers()
|
||||||
self.connection?.cancel()
|
self.connection?.cancel()
|
||||||
self.connection = nil
|
self.connection = nil
|
||||||
|
self.onDisconnect?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ struct CalibrateView: View {
|
||||||
inputSection
|
inputSection
|
||||||
resultsSection
|
resultsSection
|
||||||
cellConstantSection
|
cellConstantSection
|
||||||
|
chlorineCalSection
|
||||||
|
phCalibrationSection
|
||||||
}
|
}
|
||||||
.navigationTitle("Calibrate")
|
.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
|
// MARK: - Calculations
|
||||||
|
|
||||||
private func saltGrams(volumeGal: Double, ppm: Double) -> Double {
|
private func saltGrams(volumeGal: Double, ppm: Double) -> Double {
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,13 @@ struct ChlorineView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
controlsRow
|
controlsRow
|
||||||
|
clPeakLabels
|
||||||
Divider()
|
Divider()
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
if geo.size.width > 700 {
|
if geo.size.width > 700 {
|
||||||
HSplitLayout(ratio: 0.55) {
|
HSplitLayout(ratio: 0.55) {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
|
voltammogramPlot
|
||||||
resultBanner
|
resultBanner
|
||||||
chlorinePlot
|
chlorinePlot
|
||||||
}
|
}
|
||||||
|
|
@ -21,8 +23,9 @@ struct ChlorineView: View {
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
|
voltammogramPlot.frame(height: 250)
|
||||||
resultBanner
|
resultBanner
|
||||||
chlorinePlot.frame(height: 350)
|
chlorinePlot.frame(height: 250)
|
||||||
clTable.frame(height: 300)
|
clTable.frame(height: 300)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|
@ -37,6 +40,18 @@ struct ChlorineView: View {
|
||||||
private var controlsRow: some View {
|
private var controlsRow: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
|
if state.clManualPeaks {
|
||||||
|
Button("Start LSV") { state.startLSV() }
|
||||||
|
.buttonStyle(ActionButtonStyle(color: .green))
|
||||||
|
|
||||||
|
Button("Manual") {
|
||||||
|
state.clManualPeaks = false
|
||||||
|
state.lsvPeaks = detectLsvPeaks(state.lsvPoints)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
|
||||||
|
Divider().frame(height: 24)
|
||||||
|
|
||||||
LabeledField("Cond mV", text: $state.clCondV, width: 70)
|
LabeledField("Cond mV", text: $state.clCondV, width: 70)
|
||||||
LabeledField("Cond ms", text: $state.clCondT, width: 70)
|
LabeledField("Cond ms", text: $state.clCondT, width: 70)
|
||||||
LabeledField("Free mV", text: $state.clFreeV, width: 70)
|
LabeledField("Free mV", text: $state.clFreeV, width: 70)
|
||||||
|
|
@ -49,6 +64,30 @@ struct ChlorineView: View {
|
||||||
|
|
||||||
Button("Measure") { state.startChlorine() }
|
Button("Measure") { state.startChlorine() }
|
||||||
.buttonStyle(ActionButtonStyle(color: .green))
|
.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(.horizontal)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|
@ -68,6 +107,12 @@ struct ChlorineView: View {
|
||||||
Text(String(format: "Combined: %.3f uA", r.iTotalUa - r.iFreeUa))
|
Text(String(format: "Combined: %.3f uA", r.iTotalUa - r.iFreeUa))
|
||||||
.foregroundStyle(.secondary)
|
.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 {
|
if let (_, refR) = state.clRef {
|
||||||
Divider().frame(height: 16)
|
Divider().frame(height: 16)
|
||||||
Text(String(format: "vs Ref: dFree=%.3f dTotal=%.3f",
|
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 {
|
private var chlorinePlot: some View {
|
||||||
Group {
|
Group {
|
||||||
|
|
|
||||||
|
|
@ -285,8 +285,13 @@ struct EISView: View {
|
||||||
with: .color(nyqColor))
|
with: .color(nyqColor))
|
||||||
}
|
}
|
||||||
|
|
||||||
// circle fit
|
// fit overlay
|
||||||
if let fit = kasaCircleFit(points: points.map { (Double($0.zReal), Double(-$0.zImag)) }) {
|
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 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
|
let disc = fit.r * fit.r - fit.cy * fit.cy
|
||||||
if disc > 0 {
|
if disc > 0 {
|
||||||
let sd = sqrt(disc)
|
let sd = sqrt(disc)
|
||||||
|
|
@ -307,11 +312,8 @@ struct EISView: View {
|
||||||
let pt = CGPoint(x: lx(CGFloat(ax)), y: ly(CGFloat(ay)))
|
let pt = CGPoint(x: lx(CGFloat(ax)), y: ly(CGFloat(ay)))
|
||||||
if i == 0 { arcPath.move(to: pt) } else { arcPath.addLine(to: pt) }
|
if i == 0 { arcPath.move(to: pt) } else { arcPath.addLine(to: pt) }
|
||||||
}
|
}
|
||||||
context.stroke(arcPath, with: .color(Color(red: 0.7, green: 0.3, blue: 0.9).opacity(0.5)),
|
context.stroke(arcPath, with: .color(fitColor), lineWidth: 1.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 rsScr = CGPoint(x: lx(CGFloat(rs)), y: ly(0))
|
||||||
let rpScr = CGPoint(x: lx(CGFloat(rs + rp)), 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)),
|
context.fill(Path(ellipseIn: CGRect(x: rsScr.x - 5, y: rsScr.y - 5, width: 10, height: 10)),
|
||||||
|
|
@ -326,6 +328,26 @@ struct EISView: View {
|
||||||
at: CGPoint(x: rpScr.x, y: rpScr.y + 14))
|
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 {
|
struct CircleFitResult {
|
||||||
let cx: Double
|
let cx: Double
|
||||||
|
|
@ -378,6 +400,17 @@ struct CircleFitResult {
|
||||||
let r: Double
|
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? {
|
func kasaCircleFit(points: [(Double, Double)]) -> CircleFitResult? {
|
||||||
let all = points.filter { $0.0.isFinite && $0.1.isFinite }
|
let all = points.filter { $0.0.isFinite && $0.1.isFinite }
|
||||||
guard all.count >= 4 else { return nil }
|
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))
|
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
|
// MARK: - Canvas drawing helpers
|
||||||
|
|
||||||
private func drawPolyline(context: GraphicsContext, points: [CGPoint], color: Color, width: CGFloat) {
|
private func drawPolyline(context: GraphicsContext, points: [CGPoint], color: Color, width: CGFloat) {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ struct LSVView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
controlsRow
|
controlsRow
|
||||||
|
peakLabels
|
||||||
Divider()
|
Divider()
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
if geo.size.width > 700 {
|
if geo.size.width > 700 {
|
||||||
|
|
@ -40,6 +41,15 @@ struct LSVView: View {
|
||||||
LabeledPicker("RTIA", selection: $state.lsvRtia, items: LpRtia.allCases) { $0.label }
|
LabeledPicker("RTIA", selection: $state.lsvRtia, items: LpRtia.allCases) { $0.label }
|
||||||
.frame(width: 120)
|
.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() }
|
Button("Start LSV") { state.startLSV() }
|
||||||
.buttonStyle(ActionButtonStyle(color: .green))
|
.buttonStyle(ActionButtonStyle(color: .green))
|
||||||
}
|
}
|
||||||
|
|
@ -86,6 +96,18 @@ struct LSVView: View {
|
||||||
.foregroundStyle(Color.yellow)
|
.foregroundStyle(Color.yellow)
|
||||||
.symbolSize(16)
|
.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)")
|
.chartXAxisLabel("V (mV)")
|
||||||
.chartYAxisLabel("I (uA)", position: .leading)
|
.chartYAxisLabel("I (uA)", position: .leading)
|
||||||
|
|
@ -115,6 +137,36 @@ struct LSVView: View {
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.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
|
// MARK: - Table
|
||||||
|
|
||||||
private var lsvTable: some View {
|
private var lsvTable: some View {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import GRDB
|
import GRDB
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct SessionView: View {
|
struct SessionView: View {
|
||||||
@Bindable var state: AppState
|
@Bindable var state: AppState
|
||||||
|
|
@ -27,9 +28,11 @@ struct SessionView: View {
|
||||||
|
|
||||||
private func startObserving() {
|
private func startObserving() {
|
||||||
sessionCancellable = Storage.shared.observeSessions { sessions in
|
sessionCancellable = Storage.shared.observeSessions { sessions in
|
||||||
|
Task { @MainActor in
|
||||||
self.sessions = sessions
|
self.sessions = sessions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Wide layout (iPad)
|
// MARK: - Wide layout (iPad)
|
||||||
|
|
||||||
|
|
@ -191,16 +194,31 @@ struct SessionDetailView: View {
|
||||||
@State private var editing = false
|
@State private var editing = false
|
||||||
@State private var editLabel = ""
|
@State private var editLabel = ""
|
||||||
@State private var editNotes = ""
|
@State private var editNotes = ""
|
||||||
|
@State private var showingFileImporter = false
|
||||||
|
@State private var showingShareSheet = false
|
||||||
|
@State private var exportFileURL: URL?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
header
|
header
|
||||||
Divider()
|
Divider()
|
||||||
measurementsList
|
measurementsList
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.onAppear { loadMeasurements() }
|
.onAppear { loadMeasurements() }
|
||||||
.onChange(of: session.id) { loadMeasurements() }
|
.onChange(of: session.id) { loadMeasurements() }
|
||||||
.sheet(isPresented: $editing) { editSheet }
|
.sheet(isPresented: $editing) { editSheet }
|
||||||
|
.sheet(isPresented: $showingShareSheet) {
|
||||||
|
if let url = exportFileURL {
|
||||||
|
ShareSheet(items: [url])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fileImporter(
|
||||||
|
isPresented: $showingFileImporter,
|
||||||
|
allowedContentTypes: [.plainText],
|
||||||
|
onCompletion: handleImportedFile
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadMeasurements() {
|
private func loadMeasurements() {
|
||||||
|
|
@ -208,6 +226,40 @@ struct SessionDetailView: View {
|
||||||
measurements = (try? Storage.shared.fetchMeasurements(sessionId: sid)) ?? []
|
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 {
|
private var header: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack {
|
HStack {
|
||||||
|
|
@ -222,6 +274,14 @@ struct SessionDetailView: View {
|
||||||
Image(systemName: "pencil.circle")
|
Image(systemName: "pencil.circle")
|
||||||
.imageScale(.large)
|
.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 {
|
HStack {
|
||||||
Text(session.startedAt, style: .date)
|
Text(session.startedAt, style: .date)
|
||||||
|
|
@ -252,8 +312,12 @@ struct SessionDetailView: View {
|
||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
ForEach(measurements, id: \.id) { meas in
|
ForEach(measurements, id: \.id) { meas in
|
||||||
|
NavigationLink {
|
||||||
|
MeasurementDataView(measurement: meas)
|
||||||
|
} label: {
|
||||||
MeasurementRow(measurement: meas, state: state)
|
MeasurementRow(measurement: meas, state: state)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.onDelete { indices in
|
.onDelete { indices in
|
||||||
for idx in indices {
|
for idx in indices {
|
||||||
guard let mid = measurements[idx].id else { continue }
|
guard let mid = measurements[idx].id else { continue }
|
||||||
|
|
@ -368,3 +432,11 @@ struct MeasurementRow: View {
|
||||||
return (try? Storage.shared.dataPointCount(measurementId: mid)) ?? 0
|
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) {}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,28 @@ dependencies = [
|
||||||
"libloading 0.7.4",
|
"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]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
|
|
@ -210,6 +232,17 @@ dependencies = [
|
||||||
"pin-project-lite",
|
"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]]
|
[[package]]
|
||||||
name = "async-process"
|
name = "async-process"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
|
|
@ -340,6 +373,15 @@ dependencies = [
|
||||||
"objc2 0.5.2",
|
"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]]
|
[[package]]
|
||||||
name = "blocking"
|
name = "blocking"
|
||||||
version = "1.6.2"
|
version = "1.6.2"
|
||||||
|
|
@ -722,6 +764,7 @@ dependencies = [
|
||||||
"futures",
|
"futures",
|
||||||
"iced",
|
"iced",
|
||||||
"muda",
|
"muda",
|
||||||
|
"rfd",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
@ -760,7 +803,7 @@ dependencies = [
|
||||||
"rust-ini",
|
"rust-ini",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"winreg",
|
"winreg",
|
||||||
"zbus",
|
"zbus 4.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -839,9 +882,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
|
"block2 0.6.2",
|
||||||
|
"libc",
|
||||||
"objc2 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "dlib"
|
name = "dlib"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
|
|
@ -1125,6 +1181,15 @@ version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
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]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
|
|
@ -1656,12 +1721,115 @@ dependencies = [
|
||||||
"winit",
|
"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]]
|
[[package]]
|
||||||
name = "id-arena"
|
name = "id-arena"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
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]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.13.0"
|
version = "2.13.0"
|
||||||
|
|
@ -1848,6 +2016,12 @@ version = "0.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litemap"
|
||||||
|
version = "0.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
|
|
@ -2155,7 +2329,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
|
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-core-data",
|
"objc2-core-data",
|
||||||
|
|
@ -2171,6 +2345,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
|
"block2 0.6.2",
|
||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation 0.3.2",
|
"objc2-foundation 0.3.2",
|
||||||
|
|
@ -2183,7 +2358,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
|
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-core-location",
|
"objc2-core-location",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
|
|
@ -2195,7 +2370,7 @@ version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
|
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
@ -2207,7 +2382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
|
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
@ -2242,7 +2417,7 @@ version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
|
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-metal",
|
"objc2-metal",
|
||||||
|
|
@ -2254,7 +2429,7 @@ version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
|
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-contacts",
|
"objc2-contacts",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
|
|
@ -2273,7 +2448,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
|
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
|
|
@ -2307,7 +2482,7 @@ version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
|
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-app-kit 0.2.2",
|
"objc2-app-kit 0.2.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
|
|
@ -2320,7 +2495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
|
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
@ -2332,7 +2507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
|
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-metal",
|
"objc2-metal",
|
||||||
|
|
@ -2367,7 +2542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
|
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-cloud-kit",
|
"objc2-cloud-kit",
|
||||||
"objc2-core-data",
|
"objc2-core-data",
|
||||||
|
|
@ -2387,7 +2562,7 @@ version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
|
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
@ -2399,7 +2574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
|
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-core-location",
|
"objc2-core-location",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
|
|
@ -2566,7 +2741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_shared",
|
"phf_shared",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2673,6 +2848,21 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
|
|
@ -2759,8 +2949,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha",
|
"rand_chacha 0.3.1",
|
||||||
"rand_core",
|
"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]]
|
[[package]]
|
||||||
|
|
@ -2770,7 +2970,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"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]]
|
[[package]]
|
||||||
|
|
@ -2782,6 +2992,15 @@ dependencies = [
|
||||||
"getrandom 0.2.17",
|
"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]]
|
[[package]]
|
||||||
name = "range-alloc"
|
name = "range-alloc"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
|
|
@ -2883,6 +3102,30 @@ version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
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]]
|
[[package]]
|
||||||
name = "roxmltree"
|
name = "roxmltree"
|
||||||
version = "0.20.0"
|
version = "0.20.0"
|
||||||
|
|
@ -3276,6 +3519,12 @@ dependencies = [
|
||||||
"bitflags 2.11.0",
|
"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]]
|
[[package]]
|
||||||
name = "static_assertions"
|
name = "static_assertions"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|
@ -3327,6 +3576,17 @@ dependencies = [
|
||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "sys-locale"
|
name = "sys-locale"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
|
|
@ -3437,6 +3697,16 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinystr"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinyvec"
|
name = "tinyvec"
|
||||||
version = "1.10.0"
|
version = "1.10.0"
|
||||||
|
|
@ -3688,6 +3958,42 @@ version = "0.2.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
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]]
|
[[package]]
|
||||||
name = "vcpkg"
|
name = "vcpkg"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
|
|
@ -4421,7 +4727,7 @@ dependencies = [
|
||||||
"android-activity",
|
"android-activity",
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"calloop 0.13.0",
|
"calloop 0.13.0",
|
||||||
"cfg_aliases 0.2.1",
|
"cfg_aliases 0.2.1",
|
||||||
|
|
@ -4578,6 +4884,12 @@ dependencies = [
|
||||||
"wasmparser",
|
"wasmparser",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "writeable"
|
||||||
|
version = "0.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "x11-dl"
|
name = "x11-dl"
|
||||||
version = "2.21.0"
|
version = "2.21.0"
|
||||||
|
|
@ -4657,6 +4969,29 @@ version = "0.1.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1"
|
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]]
|
[[package]]
|
||||||
name = "zbus"
|
name = "zbus"
|
||||||
version = "4.4.0"
|
version = "4.4.0"
|
||||||
|
|
@ -4681,7 +5016,7 @@ dependencies = [
|
||||||
"hex",
|
"hex",
|
||||||
"nix",
|
"nix",
|
||||||
"ordered-stream",
|
"ordered-stream",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
"sha1",
|
"sha1",
|
||||||
|
|
@ -4690,9 +5025,44 @@ dependencies = [
|
||||||
"uds_windows",
|
"uds_windows",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
"xdg-home",
|
"xdg-home",
|
||||||
"zbus_macros",
|
"zbus_macros 4.4.0",
|
||||||
"zbus_names",
|
"zbus_names 3.0.0",
|
||||||
"zvariant",
|
"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]]
|
[[package]]
|
||||||
|
|
@ -4705,7 +5075,22 @@ dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
|
|
@ -4716,7 +5101,18 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"static_assertions",
|
"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]]
|
[[package]]
|
||||||
|
|
@ -4745,6 +5141,60 @@ dependencies = [
|
||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|
@ -4761,7 +5211,22 @@ dependencies = [
|
||||||
"enumflags2",
|
"enumflags2",
|
||||||
"serde",
|
"serde",
|
||||||
"static_assertions",
|
"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]]
|
[[package]]
|
||||||
|
|
@ -4774,7 +5239,20 @@ dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
|
|
@ -4787,3 +5265,16 @@ dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.117",
|
"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",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
toml = { version = "0.8", features = ["preserve_order"] }
|
toml = { version = "0.8", features = ["preserve_order"] }
|
||||||
dirs-next = "2"
|
dirs-next = "2"
|
||||||
|
rfd = "0.15"
|
||||||
|
|
||||||
[target.'cfg(windows)'.build-dependencies]
|
[target.'cfg(windows)'.build-dependencies]
|
||||||
winres = "0.1"
|
winres = "0.1"
|
||||||
|
|
|
||||||
Binary file not shown.
508
cue/src/app.rs
508
cue/src/app.rs
|
|
@ -1,7 +1,7 @@
|
||||||
use futures::SinkExt;
|
use futures::SinkExt;
|
||||||
use iced::widget::{
|
use iced::widget::{
|
||||||
button, canvas, column, container, pane_grid, pick_list, row, scrollable, text, text_editor,
|
button, canvas, column, container, pane_grid, pick_list, row, rule, scrollable, text,
|
||||||
text_input,
|
text_editor, text_input,
|
||||||
};
|
};
|
||||||
use iced::widget::button::Style as ButtonStyle;
|
use iced::widget::button::Style as ButtonStyle;
|
||||||
use iced::{Border, Color, Element, Length, Subscription, Task, Theme};
|
use iced::{Border, Color, Element, Length, Subscription, Task, Theme};
|
||||||
|
|
@ -17,6 +17,32 @@ use crate::protocol::{
|
||||||
use crate::storage::{self, Session, Storage};
|
use crate::storage::{self, Session, Storage};
|
||||||
use crate::udp::UdpEvent;
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Tab {
|
pub enum Tab {
|
||||||
Eis,
|
Eis,
|
||||||
|
|
@ -71,6 +97,8 @@ pub enum Message {
|
||||||
LsvStopVChanged(String),
|
LsvStopVChanged(String),
|
||||||
LsvScanRateChanged(String),
|
LsvScanRateChanged(String),
|
||||||
LsvRtiaSelected(LpRtia),
|
LsvRtiaSelected(LpRtia),
|
||||||
|
LsvDensityModeSelected(LsvDensityMode),
|
||||||
|
LsvDensityChanged(String),
|
||||||
StartLsv,
|
StartLsv,
|
||||||
/* Amperometry */
|
/* Amperometry */
|
||||||
AmpVholdChanged(String),
|
AmpVholdChanged(String),
|
||||||
|
|
@ -88,6 +116,8 @@ pub enum Message {
|
||||||
ClMeasTChanged(String),
|
ClMeasTChanged(String),
|
||||||
ClRtiaSelected(LpRtia),
|
ClRtiaSelected(LpRtia),
|
||||||
StartCl,
|
StartCl,
|
||||||
|
StartClAuto,
|
||||||
|
ClToggleManual,
|
||||||
/* pH */
|
/* pH */
|
||||||
PhStabilizeChanged(String),
|
PhStabilizeChanged(String),
|
||||||
StartPh,
|
StartPh,
|
||||||
|
|
@ -98,6 +128,13 @@ pub enum Message {
|
||||||
CalBleachChanged(String),
|
CalBleachChanged(String),
|
||||||
CalTempChanged(String),
|
CalTempChanged(String),
|
||||||
CalComputeK,
|
CalComputeK,
|
||||||
|
ClCalKnownPpmChanged(String),
|
||||||
|
ClSetFactor,
|
||||||
|
/* pH calibration */
|
||||||
|
PhCalKnownChanged(String),
|
||||||
|
PhAddCalPoint,
|
||||||
|
PhClearCalPoints,
|
||||||
|
PhComputeAndSetCal,
|
||||||
/* Global */
|
/* Global */
|
||||||
PollTemp,
|
PollTemp,
|
||||||
NativeMenuTick,
|
NativeMenuTick,
|
||||||
|
|
@ -124,11 +161,24 @@ pub enum Message {
|
||||||
BrowseLoadAsReference(i64),
|
BrowseLoadAsReference(i64),
|
||||||
BrowseDeleteMeasurement(i64),
|
BrowseDeleteMeasurement(i64),
|
||||||
BrowseBack,
|
BrowseBack,
|
||||||
|
ExportSession(i64),
|
||||||
|
ImportSession,
|
||||||
/* Misc */
|
/* Misc */
|
||||||
Reconnect,
|
Reconnect,
|
||||||
UdpAddrChanged(String),
|
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 {
|
pub struct App {
|
||||||
tab: Tab,
|
tab: Tab,
|
||||||
status: String,
|
status: String,
|
||||||
|
|
@ -170,6 +220,9 @@ pub struct App {
|
||||||
lsv_stop_v: String,
|
lsv_stop_v: String,
|
||||||
lsv_scan_rate: String,
|
lsv_scan_rate: String,
|
||||||
lsv_rtia: LpRtia,
|
lsv_rtia: LpRtia,
|
||||||
|
lsv_density_mode: LsvDensityMode,
|
||||||
|
lsv_density: String,
|
||||||
|
lsv_peaks: Vec<crate::lsv_analysis::LsvPeak>,
|
||||||
lsv_data: text_editor::Content,
|
lsv_data: text_editor::Content,
|
||||||
|
|
||||||
/* Amp */
|
/* Amp */
|
||||||
|
|
@ -194,11 +247,17 @@ pub struct App {
|
||||||
cl_meas_t: String,
|
cl_meas_t: String,
|
||||||
cl_rtia: LpRtia,
|
cl_rtia: LpRtia,
|
||||||
cl_data: text_editor::Content,
|
cl_data: text_editor::Content,
|
||||||
|
cl_manual_peaks: bool,
|
||||||
|
cl_auto_state: ClAutoState,
|
||||||
|
cl_auto_potentials: Option<crate::lsv_analysis::ClPotentials>,
|
||||||
|
|
||||||
/* pH */
|
/* pH */
|
||||||
ph_result: Option<PhResult>,
|
ph_result: Option<PhResult>,
|
||||||
ph_stabilize: String,
|
ph_stabilize: String,
|
||||||
|
|
||||||
|
/* measurement dedup */
|
||||||
|
current_esp_ts: Option<u32>,
|
||||||
|
|
||||||
/* Reference baselines */
|
/* Reference baselines */
|
||||||
eis_ref: Option<Vec<EisPoint>>,
|
eis_ref: Option<Vec<EisPoint>>,
|
||||||
lsv_ref: Option<Vec<LsvPoint>>,
|
lsv_ref: Option<Vec<LsvPoint>>,
|
||||||
|
|
@ -227,6 +286,12 @@ pub struct App {
|
||||||
cal_bleach_pct: String,
|
cal_bleach_pct: String,
|
||||||
cal_temp_c: String,
|
cal_temp_c: String,
|
||||||
cal_cell_constant: Option<f32>,
|
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 */
|
/* Global */
|
||||||
temp_c: f32,
|
temp_c: f32,
|
||||||
|
|
@ -407,6 +472,9 @@ impl App {
|
||||||
lsv_stop_v: "500".into(),
|
lsv_stop_v: "500".into(),
|
||||||
lsv_scan_rate: "50".into(),
|
lsv_scan_rate: "50".into(),
|
||||||
lsv_rtia: LpRtia::R10K,
|
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(&[])),
|
lsv_data: text_editor::Content::with_text(&fmt_lsv(&[])),
|
||||||
|
|
||||||
amp_points: Vec::new(),
|
amp_points: Vec::new(),
|
||||||
|
|
@ -429,10 +497,15 @@ impl App {
|
||||||
cl_meas_t: "5000".into(),
|
cl_meas_t: "5000".into(),
|
||||||
cl_rtia: LpRtia::R10K,
|
cl_rtia: LpRtia::R10K,
|
||||||
cl_data: text_editor::Content::with_text(&fmt_cl(&[])),
|
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_result: None,
|
||||||
ph_stabilize: "30".into(),
|
ph_stabilize: "30".into(),
|
||||||
|
|
||||||
|
current_esp_ts: None,
|
||||||
|
|
||||||
eis_ref: None,
|
eis_ref: None,
|
||||||
lsv_ref: None,
|
lsv_ref: None,
|
||||||
amp_ref: None,
|
amp_ref: None,
|
||||||
|
|
@ -457,6 +530,12 @@ impl App {
|
||||||
cal_bleach_pct: "7.825".into(),
|
cal_bleach_pct: "7.825".into(),
|
||||||
cal_temp_c: "40".into(),
|
cal_temp_c: "40".into(),
|
||||||
cal_cell_constant: None,
|
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,
|
temp_c: 25.0,
|
||||||
conn_gen: 0,
|
conn_gen: 0,
|
||||||
|
|
@ -482,7 +561,7 @@ impl App {
|
||||||
"rcal": format!("{}", self.rcal),
|
"rcal": format!("{}", self.rcal),
|
||||||
"electrode": format!("{}", self.electrode),
|
"electrode": format!("{}", self.electrode),
|
||||||
});
|
});
|
||||||
if let Ok(mid) = self.storage.create_measurement(session_id, "eis", ¶ms.to_string()) {
|
if let Ok(mid) = self.storage.create_measurement(session_id, "eis", ¶ms.to_string(), self.current_esp_ts) {
|
||||||
let pts: Vec<(i32, String)> = self.eis_points.iter().enumerate()
|
let pts: Vec<(i32, String)> = self.eis_points.iter().enumerate()
|
||||||
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -497,7 +576,7 @@ impl App {
|
||||||
"scan_rate": self.lsv_scan_rate,
|
"scan_rate": self.lsv_scan_rate,
|
||||||
"rtia": format!("{}", self.lsv_rtia),
|
"rtia": format!("{}", self.lsv_rtia),
|
||||||
});
|
});
|
||||||
if let Ok(mid) = self.storage.create_measurement(session_id, "lsv", ¶ms.to_string()) {
|
if let Ok(mid) = self.storage.create_measurement(session_id, "lsv", ¶ms.to_string(), self.current_esp_ts) {
|
||||||
let pts: Vec<(i32, String)> = self.lsv_points.iter().enumerate()
|
let pts: Vec<(i32, String)> = self.lsv_points.iter().enumerate()
|
||||||
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -512,7 +591,7 @@ impl App {
|
||||||
"duration_s": self.amp_duration,
|
"duration_s": self.amp_duration,
|
||||||
"rtia": format!("{}", self.amp_rtia),
|
"rtia": format!("{}", self.amp_rtia),
|
||||||
});
|
});
|
||||||
if let Ok(mid) = self.storage.create_measurement(session_id, "amp", ¶ms.to_string()) {
|
if let Ok(mid) = self.storage.create_measurement(session_id, "amp", ¶ms.to_string(), self.current_esp_ts) {
|
||||||
let pts: Vec<(i32, String)> = self.amp_points.iter().enumerate()
|
let pts: Vec<(i32, String)> = self.amp_points.iter().enumerate()
|
||||||
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -530,7 +609,7 @@ impl App {
|
||||||
"meas_t": self.cl_meas_t,
|
"meas_t": self.cl_meas_t,
|
||||||
"rtia": format!("{}", self.cl_rtia),
|
"rtia": format!("{}", self.cl_rtia),
|
||||||
});
|
});
|
||||||
if let Ok(mid) = self.storage.create_measurement(session_id, "chlorine", ¶ms.to_string()) {
|
if let Ok(mid) = self.storage.create_measurement(session_id, "chlorine", ¶ms.to_string(), self.current_esp_ts) {
|
||||||
let mut pts: Vec<(i32, String)> = self.cl_points.iter().enumerate()
|
let mut pts: Vec<(i32, String)> = self.cl_points.iter().enumerate()
|
||||||
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -547,7 +626,7 @@ impl App {
|
||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
"stabilize_s": self.ph_stabilize,
|
"stabilize_s": self.ph_stabilize,
|
||||||
});
|
});
|
||||||
if let Ok(mid) = self.storage.create_measurement(session_id, "ph", ¶ms.to_string()) {
|
if let Ok(mid) = self.storage.create_measurement(session_id, "ph", ¶ms.to_string(), self.current_esp_ts) {
|
||||||
if let Ok(j) = serde_json::to_string(result) {
|
if let Ok(j) = serde_json::to_string(result) {
|
||||||
let _ = self.storage.add_data_point(mid, 0, &j);
|
let _ = self.storage.add_data_point(mid, 0, &j);
|
||||||
}
|
}
|
||||||
|
|
@ -561,6 +640,8 @@ impl App {
|
||||||
self.connected = true;
|
self.connected = true;
|
||||||
self.send_cmd(&protocol::build_sysex_get_config());
|
self.send_cmd(&protocol::build_sysex_get_config());
|
||||||
self.send_cmd(&protocol::build_sysex_get_cell_k());
|
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) => {
|
Message::DeviceStatus(s) => {
|
||||||
if s.contains("Reconnecting") || s.contains("Connecting") {
|
if s.contains("Reconnecting") || s.contains("Connecting") {
|
||||||
|
|
@ -570,9 +651,9 @@ impl App {
|
||||||
self.status = s;
|
self.status = s;
|
||||||
}
|
}
|
||||||
Message::DeviceData(msg) => match msg {
|
Message::DeviceData(msg) => match msg {
|
||||||
EisMessage::SweepStart { num_points, freq_start, freq_stop } => {
|
EisMessage::SweepStart { num_points, freq_start, freq_stop, esp_timestamp, .. } => {
|
||||||
|
self.current_esp_ts = esp_timestamp;
|
||||||
if self.collecting_refs {
|
if self.collecting_refs {
|
||||||
/* ref collection: clear temp buffer */
|
|
||||||
self.eis_points.clear();
|
self.eis_points.clear();
|
||||||
self.sweep_total = num_points;
|
self.sweep_total = num_points;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -615,7 +696,8 @@ impl App {
|
||||||
self.electrode = cfg.electrode;
|
self.electrode = cfg.electrode;
|
||||||
self.status = "Config received".into();
|
self.status = "Config received".into();
|
||||||
}
|
}
|
||||||
EisMessage::LsvStart { num_points, v_start, v_stop } => {
|
EisMessage::LsvStart { num_points, v_start, v_stop, esp_timestamp, .. } => {
|
||||||
|
self.current_esp_ts = esp_timestamp;
|
||||||
self.lsv_points.clear();
|
self.lsv_points.clear();
|
||||||
self.lsv_total = num_points;
|
self.lsv_total = num_points;
|
||||||
self.lsv_data = text_editor::Content::with_text(&fmt_lsv(&self.lsv_points));
|
self.lsv_data = text_editor::Content::with_text(&fmt_lsv(&self.lsv_points));
|
||||||
|
|
@ -630,9 +712,41 @@ impl App {
|
||||||
if let Some(sid) = self.current_session {
|
if let Some(sid) = self.current_session {
|
||||||
self.save_lsv(sid);
|
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();
|
||||||
}
|
}
|
||||||
EisMessage::AmpStart { v_hold } => {
|
}
|
||||||
|
}
|
||||||
|
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, esp_timestamp, .. } => {
|
||||||
|
self.current_esp_ts = esp_timestamp;
|
||||||
self.amp_points.clear();
|
self.amp_points.clear();
|
||||||
self.amp_running = true;
|
self.amp_running = true;
|
||||||
self.amp_data = text_editor::Content::with_text(&fmt_amp(&self.amp_points));
|
self.amp_data = text_editor::Content::with_text(&fmt_amp(&self.amp_points));
|
||||||
|
|
@ -651,7 +765,8 @@ impl App {
|
||||||
}
|
}
|
||||||
self.status = format!("Amp complete: {} points", self.amp_points.len());
|
self.status = format!("Amp complete: {} points", self.amp_points.len());
|
||||||
}
|
}
|
||||||
EisMessage::ClStart { num_points } => {
|
EisMessage::ClStart { num_points, esp_timestamp, .. } => {
|
||||||
|
self.current_esp_ts = esp_timestamp;
|
||||||
self.cl_points.clear();
|
self.cl_points.clear();
|
||||||
self.cl_result = None;
|
self.cl_result = None;
|
||||||
self.cl_total = num_points;
|
self.cl_total = num_points;
|
||||||
|
|
@ -673,13 +788,30 @@ impl App {
|
||||||
if let Some(sid) = self.current_session {
|
if let Some(sid) = self.current_session {
|
||||||
self.save_cl(sid);
|
self.save_cl(sid);
|
||||||
}
|
}
|
||||||
|
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());
|
self.status = format!("Chlorine complete: {} points", self.cl_points.len());
|
||||||
}
|
}
|
||||||
EisMessage::PhResult(r) => {
|
} else {
|
||||||
|
self.status = format!("Chlorine complete: {} points", self.cl_points.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EisMessage::PhResult(r, esp_ts, _) => {
|
||||||
if self.collecting_refs {
|
if self.collecting_refs {
|
||||||
self.ph_ref = Some(r);
|
self.ph_ref = Some(r);
|
||||||
} else {
|
} else {
|
||||||
if let Some(sid) = self.current_session {
|
if let Some(sid) = self.current_session {
|
||||||
|
self.current_esp_ts = esp_ts;
|
||||||
self.save_ph(sid, &r);
|
self.save_ph(sid, &r);
|
||||||
}
|
}
|
||||||
self.status = format!("pH: {:.2} (OCP={:.1} mV, T={:.1}C)",
|
self.status = format!("pH: {:.2} (OCP={:.1} mV, T={:.1}C)",
|
||||||
|
|
@ -734,6 +866,16 @@ impl App {
|
||||||
self.cal_cell_constant = Some(k);
|
self.cal_cell_constant = Some(k);
|
||||||
self.status = format!("Device cell constant: {:.4} cm-1", 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) => {
|
Message::TabSelected(t) => {
|
||||||
if t == Tab::Browse {
|
if t == Tab::Browse {
|
||||||
|
|
@ -800,12 +942,22 @@ impl App {
|
||||||
Message::LsvStopVChanged(s) => self.lsv_stop_v = s,
|
Message::LsvStopVChanged(s) => self.lsv_stop_v = s,
|
||||||
Message::LsvScanRateChanged(s) => self.lsv_scan_rate = s,
|
Message::LsvScanRateChanged(s) => self.lsv_scan_rate = s,
|
||||||
Message::LsvRtiaSelected(r) => self.lsv_rtia = r,
|
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 => {
|
Message::StartLsv => {
|
||||||
let vs = self.lsv_start_v.parse::<f32>().unwrap_or(0.0);
|
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 ve = self.lsv_stop_v.parse::<f32>().unwrap_or(500.0);
|
||||||
let sr = self.lsv_scan_rate.parse::<f32>().unwrap_or(50.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_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 */
|
/* Amp */
|
||||||
Message::AmpVholdChanged(s) => self.amp_v_hold = s,
|
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,
|
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 */
|
/* pH */
|
||||||
Message::PhStabilizeChanged(s) => self.ph_stabilize = s,
|
Message::PhStabilizeChanged(s) => self.ph_stabilize = s,
|
||||||
Message::StartPh => {
|
Message::StartPh => {
|
||||||
|
|
@ -951,6 +1114,58 @@ impl App {
|
||||||
self.status = "No valid EIS data for Rs extraction".into();
|
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 */
|
/* Clean */
|
||||||
Message::CleanVChanged(s) => self.clean_v = s,
|
Message::CleanVChanged(s) => self.clean_v = s,
|
||||||
Message::CleanDurChanged(s) => self.clean_dur = s,
|
Message::CleanDurChanged(s) => self.clean_dur = s,
|
||||||
|
|
@ -1068,6 +1283,53 @@ impl App {
|
||||||
self.browse_measurements.clear();
|
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 => {
|
Message::Reconnect => {
|
||||||
self.conn_gen += 1;
|
self.conn_gen += 1;
|
||||||
self.cmd_tx = None;
|
self.cmd_tx = None;
|
||||||
|
|
@ -1383,7 +1645,13 @@ impl App {
|
||||||
.align_y(iced::Alignment::End)
|
.align_y(iced::Alignment::End)
|
||||||
.into(),
|
.into(),
|
||||||
|
|
||||||
Tab::Lsv => row![
|
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![
|
column![
|
||||||
text("Start mV").size(12),
|
text("Start mV").size(12),
|
||||||
text_input("0", &self.lsv_start_v).on_input(Message::LsvStartVChanged).width(80),
|
text_input("0", &self.lsv_start_v).on_input(Message::LsvStartVChanged).width(80),
|
||||||
|
|
@ -1400,11 +1668,24 @@ impl App {
|
||||||
text("RTIA").size(12),
|
text("RTIA").size(12),
|
||||||
pick_list(LpRtia::ALL, Some(self.lsv_rtia), Message::LsvRtiaSelected).width(Length::Shrink),
|
pick_list(LpRtia::ALL, Some(self.lsv_rtia), Message::LsvRtiaSelected).width(Length::Shrink),
|
||||||
].spacing(2),
|
].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))
|
button(text("Start LSV").size(13))
|
||||||
.style(style_action())
|
.style(style_action())
|
||||||
.padding([6, 16])
|
.padding([6, 16])
|
||||||
.on_press(Message::StartLsv),
|
.on_press(Message::StartLsv),
|
||||||
]
|
]
|
||||||
|
}
|
||||||
.spacing(10)
|
.spacing(10)
|
||||||
.align_y(iced::Alignment::End)
|
.align_y(iced::Alignment::End)
|
||||||
.into(),
|
.into(),
|
||||||
|
|
@ -1442,7 +1723,16 @@ impl App {
|
||||||
.align_y(iced::Alignment::End)
|
.align_y(iced::Alignment::End)
|
||||||
.into(),
|
.into(),
|
||||||
|
|
||||||
Tab::Chlorine => row![
|
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![
|
column![
|
||||||
text("Cond mV").size(12),
|
text("Cond mV").size(12),
|
||||||
text_input("800", &self.cl_cond_v).on_input(Message::ClCondVChanged).width(70),
|
text_input("800", &self.cl_cond_v).on_input(Message::ClCondVChanged).width(70),
|
||||||
|
|
@ -1478,7 +1768,44 @@ impl App {
|
||||||
]
|
]
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
.align_y(iced::Alignment::End)
|
.align_y(iced::Alignment::End)
|
||||||
.into(),
|
.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![
|
Tab::Ph => row![
|
||||||
column![
|
column![
|
||||||
|
|
@ -1515,29 +1842,65 @@ impl App {
|
||||||
.height(Length::Fill);
|
.height(Length::Fill);
|
||||||
row![bode, nyquist].spacing(10).height(Length::Fill).into()
|
row![bode, nyquist].spacing(10).height(Length::Fill).into()
|
||||||
}
|
}
|
||||||
Tab::Lsv => canvas(crate::plot::VoltammogramPlot {
|
Tab::Lsv => {
|
||||||
|
let plot = canvas(crate::plot::VoltammogramPlot {
|
||||||
points: &self.lsv_points,
|
points: &self.lsv_points,
|
||||||
reference: self.lsv_ref.as_deref(),
|
reference: self.lsv_ref.as_deref(),
|
||||||
|
peaks: &self.lsv_peaks,
|
||||||
})
|
})
|
||||||
.width(Length::Fill).height(Length::Fill).into(),
|
.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 {
|
Tab::Amp => canvas(crate::plot::AmperogramPlot {
|
||||||
points: &self.amp_points,
|
points: &self.amp_points,
|
||||||
reference: self.amp_ref.as_deref(),
|
reference: self.amp_ref.as_deref(),
|
||||||
})
|
})
|
||||||
.width(Length::Fill).height(Length::Fill).into(),
|
.width(Length::Fill).height(Length::Fill).into(),
|
||||||
Tab::Chlorine => {
|
Tab::Chlorine => {
|
||||||
let ref_pts = self.cl_ref.as_ref().map(|(pts, _)| pts.as_slice());
|
let mut col = column![].spacing(4).height(Length::Fill);
|
||||||
let plot = canvas(crate::plot::ChlorinePlot {
|
|
||||||
points: &self.cl_points,
|
if !self.lsv_peaks.is_empty() {
|
||||||
reference: ref_pts,
|
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();
|
let mut result_parts: Vec<String> = Vec::new();
|
||||||
if let Some(r) = &self.cl_result {
|
if let Some(r) = &self.cl_result {
|
||||||
result_parts.push(format!(
|
result_parts.push(format!(
|
||||||
"Free: {:.3} uA | Total: {:.3} uA | Combined: {:.3} uA",
|
"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
|
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 {
|
if let Some((_, ref_r)) = &self.cl_ref {
|
||||||
let df = r.i_free_ua - ref_r.i_free_ua;
|
let df = r.i_free_ua - ref_r.i_free_ua;
|
||||||
let dt = r.i_total_ua - ref_r.i_total_ua;
|
let dt = r.i_total_ua - ref_r.i_total_ua;
|
||||||
|
|
@ -1547,12 +1910,19 @@ impl App {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if result_parts.is_empty() {
|
if !result_parts.is_empty() {
|
||||||
plot.into()
|
col = col.push(text(result_parts.join(" ")).size(14));
|
||||||
} else {
|
|
||||||
let result_text = text(result_parts.join(" ")).size(14);
|
|
||||||
column![result_text, plot].spacing(4).height(Length::Fill).into()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(),
|
Tab::Ph | Tab::Calibrate | Tab::Browse => text("").into(),
|
||||||
}
|
}
|
||||||
|
|
@ -1645,6 +2015,69 @@ impl App {
|
||||||
.on_press(Message::CalComputeK);
|
.on_press(Message::CalComputeK);
|
||||||
results = results.push(compute_btn);
|
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![
|
row![
|
||||||
container(inputs).width(Length::FillPortion(2)),
|
container(inputs).width(Length::FillPortion(2)),
|
||||||
iced::widget::vertical_rule(1),
|
iced::widget::vertical_rule(1),
|
||||||
|
|
@ -1716,7 +2149,14 @@ impl App {
|
||||||
|
|
||||||
fn view_browse_sessions(&self) -> Element<'_, Message> {
|
fn view_browse_sessions(&self) -> Element<'_, Message> {
|
||||||
let mut items = column![
|
let mut items = column![
|
||||||
|
row![
|
||||||
text("Sessions").size(16),
|
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),
|
iced::widget::horizontal_rule(1),
|
||||||
].spacing(4);
|
].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);
|
let mut mlist = column![].spacing(2);
|
||||||
|
|
||||||
if self.browse_measurements.is_empty() {
|
if self.browse_measurements.is_empty() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
mod app;
|
mod app;
|
||||||
|
mod lsv_analysis;
|
||||||
mod native_menu;
|
mod native_menu;
|
||||||
mod plot;
|
mod plot;
|
||||||
mod protocol;
|
mod protocol;
|
||||||
|
|
|
||||||
118
cue/src/plot.rs
118
cue/src/plot.rs
|
|
@ -3,6 +3,7 @@ use iced::{Color, Point, Rectangle, Renderer, Theme};
|
||||||
use iced::mouse;
|
use iced::mouse;
|
||||||
|
|
||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
|
use crate::lsv_analysis::{LsvPeak, PeakKind};
|
||||||
use crate::protocol::{AmpPoint, ClPoint, EisPoint, LsvPoint};
|
use crate::protocol::{AmpPoint, ClPoint, EisPoint, LsvPoint};
|
||||||
|
|
||||||
const MARGIN_L: f32 = 55.0;
|
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()))
|
Some((cx, cy, r_sq.sqrt()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fit the dominant Nyquist semicircle, trimming first-arc points from the
|
fn cumulative_turning(pts: &[(f64, f64)]) -> f64 {
|
||||||
/// low-frequency end before falling back to outlier removal within each subset.
|
if pts.len() < 3 { return 0.0; }
|
||||||
fn fit_nyquist_circle(points: &[EisPoint]) -> Option<CircleFit> {
|
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()
|
let all: Vec<(f64, f64)> = points.iter()
|
||||||
.filter(|p| p.z_real.is_finite() && p.z_imag.is_finite())
|
.filter(|p| p.z_real.is_finite() && p.z_imag.is_finite())
|
||||||
.map(|p| (p.z_real as f64, -p.z_imag as f64))
|
.map(|p| (p.z_real as f64, -p.z_imag as f64))
|
||||||
.collect();
|
.collect();
|
||||||
if all.len() < 4 { return None; }
|
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 min_pts = 4.max(all.len() / 3);
|
||||||
let mut best: Option<CircleFit> = None;
|
let mut best: Option<CircleFit> = None;
|
||||||
let mut best_score = f64::MAX;
|
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 ---- */
|
/* ---- Bode ---- */
|
||||||
|
|
@ -676,7 +732,8 @@ impl<'a> canvas::Program<Message> for NyquistPlot<'a> {
|
||||||
draw_polyline(&mut frame, &pts, COL_NYQ, 2.0);
|
draw_polyline(&mut frame, &pts, COL_NYQ, 2.0);
|
||||||
draw_dots(&mut frame, &pts, COL_NYQ, 3.0);
|
draw_dots(&mut frame, &pts, COL_NYQ, 3.0);
|
||||||
|
|
||||||
if let Some(fit) = fit_nyquist_circle(self.points) {
|
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 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());
|
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; }
|
if theta_l < theta_r { theta_l += std::f32::consts::TAU; }
|
||||||
|
|
@ -704,6 +761,38 @@ impl<'a> canvas::Program<Message> for NyquistPlot<'a> {
|
||||||
dt(&mut frame, Point::new(rp_scr.x - 30.0, rp_scr.y + 6.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);
|
&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) {
|
if let Some(pos) = cursor.position_in(bounds) {
|
||||||
if pos.x >= xl && pos.x <= xr && pos.y >= yt && pos.y <= yb {
|
if pos.x >= xl && pos.x <= xr && pos.y >= yt && pos.y <= yb {
|
||||||
|
|
@ -735,6 +824,7 @@ pub struct VoltammogramState {
|
||||||
pub struct VoltammogramPlot<'a> {
|
pub struct VoltammogramPlot<'a> {
|
||||||
pub points: &'a [LsvPoint],
|
pub points: &'a [LsvPoint],
|
||||||
pub reference: Option<&'a [LsvPoint]>,
|
pub reference: Option<&'a [LsvPoint]>,
|
||||||
|
pub peaks: &'a [LsvPeak],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VoltammogramPlot<'_> {
|
impl VoltammogramPlot<'_> {
|
||||||
|
|
@ -895,6 +985,24 @@ impl<'a> canvas::Program<Message> for VoltammogramPlot<'a> {
|
||||||
draw_polyline(&mut frame, &pts, COL_LSV, 2.0);
|
draw_polyline(&mut frame, &pts, COL_LSV, 2.0);
|
||||||
draw_dots(&mut frame, &pts, COL_LSV, 2.5);
|
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 let Some(pos) = cursor.position_in(bounds) {
|
||||||
if pos.x >= xl && pos.x <= xr && pos.y >= yt && pos.y <= yb {
|
if pos.x >= xl && pos.x <= xr && pos.y >= yt && pos.y <= yb {
|
||||||
let v = lerp(pos.x, xl, xr, xv.lo, xv.hi);
|
let v = lerp(pos.x, xl, xr, xv.lo, xv.hi);
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@ pub const RSP_REF_LP_RANGE: u8 = 0x21;
|
||||||
pub const RSP_REFS_DONE: u8 = 0x22;
|
pub const RSP_REFS_DONE: u8 = 0x22;
|
||||||
pub const RSP_CELL_K: u8 = 0x11;
|
pub const RSP_CELL_K: u8 = 0x11;
|
||||||
pub const RSP_REF_STATUS: u8 = 0x23;
|
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 */
|
/* Cue → ESP32 */
|
||||||
pub const CMD_SET_SWEEP: u8 = 0x10;
|
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_START_CLEAN: u8 = 0x25;
|
||||||
pub const CMD_SET_CELL_K: u8 = 0x28;
|
pub const CMD_SET_CELL_K: u8 = 0x28;
|
||||||
pub const CMD_GET_CELL_K: u8 = 0x29;
|
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_START_REFS: u8 = 0x30;
|
||||||
pub const CMD_GET_REFS: u8 = 0x31;
|
pub const CMD_GET_REFS: u8 = 0x31;
|
||||||
pub const CMD_CLEAR_REFS: u8 = 0x32;
|
pub const CMD_CLEAR_REFS: u8 = 0x32;
|
||||||
|
|
@ -237,27 +244,32 @@ pub struct EisConfig {
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum EisMessage {
|
pub enum EisMessage {
|
||||||
SweepStart { num_points: u16, freq_start: f32, freq_stop: f32 },
|
SweepStart { num_points: u16, freq_start: f32, freq_stop: f32,
|
||||||
|
esp_timestamp: Option<u32>, meas_id: Option<u16> },
|
||||||
DataPoint { _index: u16, point: EisPoint },
|
DataPoint { _index: u16, point: EisPoint },
|
||||||
SweepEnd,
|
SweepEnd,
|
||||||
Config(EisConfig),
|
Config(EisConfig),
|
||||||
LsvStart { num_points: u16, v_start: f32, v_stop: f32 },
|
LsvStart { num_points: u16, v_start: f32, v_stop: f32,
|
||||||
|
esp_timestamp: Option<u32>, meas_id: Option<u16> },
|
||||||
LsvPoint { _index: u16, point: LsvPoint },
|
LsvPoint { _index: u16, point: LsvPoint },
|
||||||
LsvEnd,
|
LsvEnd,
|
||||||
AmpStart { v_hold: f32 },
|
AmpStart { v_hold: f32, esp_timestamp: Option<u32>, meas_id: Option<u16> },
|
||||||
AmpPoint { _index: u16, point: AmpPoint },
|
AmpPoint { _index: u16, point: AmpPoint },
|
||||||
AmpEnd,
|
AmpEnd,
|
||||||
ClStart { num_points: u16 },
|
ClStart { num_points: u16, esp_timestamp: Option<u32>, meas_id: Option<u16> },
|
||||||
ClPoint { _index: u16, point: ClPoint },
|
ClPoint { _index: u16, point: ClPoint },
|
||||||
ClResult(ClResult),
|
ClResult(ClResult),
|
||||||
ClEnd,
|
ClEnd,
|
||||||
PhResult(PhResult),
|
PhResult(PhResult, Option<u32>, Option<u16>),
|
||||||
Temperature(f32),
|
Temperature(f32),
|
||||||
RefFrame { mode: u8, rtia_idx: u8 },
|
RefFrame { mode: u8, rtia_idx: u8 },
|
||||||
RefLpRange { mode: u8, low_idx: u8, high_idx: u8 },
|
RefLpRange { mode: u8, low_idx: u8, high_idx: u8 },
|
||||||
RefsDone,
|
RefsDone,
|
||||||
RefStatus { has_refs: bool },
|
RefStatus { has_refs: bool },
|
||||||
CellK(f32),
|
CellK(f32),
|
||||||
|
ClFactor(f32),
|
||||||
|
PhCal { slope: f32, offset: f32 },
|
||||||
|
Keepalive,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decode_u16(data: &[u8]) -> u16 {
|
fn decode_u16(data: &[u8]) -> u16 {
|
||||||
|
|
@ -275,6 +287,16 @@ fn decode_float(data: &[u8]) -> f32 {
|
||||||
f32::from_le_bytes([b0, b1, b2, b3])
|
f32::from_le_bytes([b0, b1, b2, b3])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn decode_u32(data: &[u8]) -> u32 {
|
||||||
|
let b = [
|
||||||
|
data[1] | ((data[0] & 1) << 7),
|
||||||
|
data[2] | ((data[0] & 2) << 6),
|
||||||
|
data[3] | ((data[0] & 4) << 5),
|
||||||
|
data[4] | ((data[0] & 8) << 4),
|
||||||
|
];
|
||||||
|
u32::from_le_bytes(b)
|
||||||
|
}
|
||||||
|
|
||||||
fn encode_float(val: f32) -> [u8; 5] {
|
fn encode_float(val: f32) -> [u8; 5] {
|
||||||
let p = val.to_le_bytes();
|
let p = val.to_le_bytes();
|
||||||
[
|
[
|
||||||
|
|
@ -293,10 +315,14 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
|
||||||
match data[1] {
|
match data[1] {
|
||||||
RSP_SWEEP_START if data.len() >= 15 => {
|
RSP_SWEEP_START if data.len() >= 15 => {
|
||||||
let p = &data[2..];
|
let p = &data[2..];
|
||||||
|
let (ts, mid) = if p.len() >= 21 {
|
||||||
|
(Some(decode_u32(&p[13..18])), Some(decode_u16(&p[18..21])))
|
||||||
|
} else { (None, None) };
|
||||||
Some(EisMessage::SweepStart {
|
Some(EisMessage::SweepStart {
|
||||||
num_points: decode_u16(&p[0..3]),
|
num_points: decode_u16(&p[0..3]),
|
||||||
freq_start: decode_float(&p[3..8]),
|
freq_start: decode_float(&p[3..8]),
|
||||||
freq_stop: decode_float(&p[8..13]),
|
freq_stop: decode_float(&p[8..13]),
|
||||||
|
esp_timestamp: ts, meas_id: mid,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
RSP_DATA_POINT if data.len() >= 30 => {
|
RSP_DATA_POINT if data.len() >= 30 => {
|
||||||
|
|
@ -332,10 +358,14 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
|
||||||
}
|
}
|
||||||
RSP_LSV_START if data.len() >= 15 => {
|
RSP_LSV_START if data.len() >= 15 => {
|
||||||
let p = &data[2..];
|
let p = &data[2..];
|
||||||
|
let (ts, mid) = if p.len() >= 21 {
|
||||||
|
(Some(decode_u32(&p[13..18])), Some(decode_u16(&p[18..21])))
|
||||||
|
} else { (None, None) };
|
||||||
Some(EisMessage::LsvStart {
|
Some(EisMessage::LsvStart {
|
||||||
num_points: decode_u16(&p[0..3]),
|
num_points: decode_u16(&p[0..3]),
|
||||||
v_start: decode_float(&p[3..8]),
|
v_start: decode_float(&p[3..8]),
|
||||||
v_stop: decode_float(&p[8..13]),
|
v_stop: decode_float(&p[8..13]),
|
||||||
|
esp_timestamp: ts, meas_id: mid,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
RSP_LSV_POINT if data.len() >= 15 => {
|
RSP_LSV_POINT if data.len() >= 15 => {
|
||||||
|
|
@ -351,7 +381,11 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
|
||||||
RSP_LSV_END => Some(EisMessage::LsvEnd),
|
RSP_LSV_END => Some(EisMessage::LsvEnd),
|
||||||
RSP_AMP_START if data.len() >= 7 => {
|
RSP_AMP_START if data.len() >= 7 => {
|
||||||
let p = &data[2..];
|
let p = &data[2..];
|
||||||
Some(EisMessage::AmpStart { v_hold: decode_float(&p[0..5]) })
|
let (ts, mid) = if p.len() >= 13 {
|
||||||
|
(Some(decode_u32(&p[5..10])), Some(decode_u16(&p[10..13])))
|
||||||
|
} else { (None, None) };
|
||||||
|
Some(EisMessage::AmpStart { v_hold: decode_float(&p[0..5]),
|
||||||
|
esp_timestamp: ts, meas_id: mid })
|
||||||
}
|
}
|
||||||
RSP_AMP_POINT if data.len() >= 15 => {
|
RSP_AMP_POINT if data.len() >= 15 => {
|
||||||
let p = &data[2..];
|
let p = &data[2..];
|
||||||
|
|
@ -366,7 +400,11 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
|
||||||
RSP_AMP_END => Some(EisMessage::AmpEnd),
|
RSP_AMP_END => Some(EisMessage::AmpEnd),
|
||||||
RSP_CL_START if data.len() >= 5 => {
|
RSP_CL_START if data.len() >= 5 => {
|
||||||
let p = &data[2..];
|
let p = &data[2..];
|
||||||
Some(EisMessage::ClStart { num_points: decode_u16(&p[0..3]) })
|
let (ts, mid) = if p.len() >= 11 {
|
||||||
|
(Some(decode_u32(&p[3..8])), Some(decode_u16(&p[8..11])))
|
||||||
|
} else { (None, None) };
|
||||||
|
Some(EisMessage::ClStart { num_points: decode_u16(&p[0..3]),
|
||||||
|
esp_timestamp: ts, meas_id: mid })
|
||||||
}
|
}
|
||||||
RSP_CL_POINT if data.len() >= 16 => {
|
RSP_CL_POINT if data.len() >= 16 => {
|
||||||
let p = &data[2..];
|
let p = &data[2..];
|
||||||
|
|
@ -393,11 +431,14 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
|
||||||
}
|
}
|
||||||
RSP_PH_RESULT if data.len() >= 17 => {
|
RSP_PH_RESULT if data.len() >= 17 => {
|
||||||
let p = &data[2..];
|
let p = &data[2..];
|
||||||
|
let (ts, mid) = if p.len() >= 23 {
|
||||||
|
(Some(decode_u32(&p[15..20])), Some(decode_u16(&p[20..23])))
|
||||||
|
} else { (None, None) };
|
||||||
Some(EisMessage::PhResult(PhResult {
|
Some(EisMessage::PhResult(PhResult {
|
||||||
v_ocp_mv: decode_float(&p[0..5]),
|
v_ocp_mv: decode_float(&p[0..5]),
|
||||||
ph: decode_float(&p[5..10]),
|
ph: decode_float(&p[5..10]),
|
||||||
temp_c: decode_float(&p[10..15]),
|
temp_c: decode_float(&p[10..15]),
|
||||||
}))
|
}, ts, mid))
|
||||||
}
|
}
|
||||||
RSP_REF_FRAME if data.len() >= 4 => {
|
RSP_REF_FRAME if data.len() >= 4 => {
|
||||||
Some(EisMessage::RefFrame { mode: data[2], rtia_idx: data[3] })
|
Some(EisMessage::RefFrame { mode: data[2], rtia_idx: data[3] })
|
||||||
|
|
@ -413,6 +454,18 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
|
||||||
let p = &data[2..];
|
let p = &data[2..];
|
||||||
Some(EisMessage::CellK(decode_float(&p[0..5])))
|
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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -446,12 +499,13 @@ pub fn build_sysex_get_config() -> Vec<u8> {
|
||||||
vec![0xF0, SYSEX_MFR, CMD_GET_CONFIG, 0xF7]
|
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];
|
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_start));
|
||||||
sx.extend_from_slice(&encode_float(v_stop));
|
sx.extend_from_slice(&encode_float(v_stop));
|
||||||
sx.extend_from_slice(&encode_float(scan_rate));
|
sx.extend_from_slice(&encode_float(scan_rate));
|
||||||
sx.push(lp_rtia.as_byte());
|
sx.push(lp_rtia.as_byte());
|
||||||
|
sx.extend_from_slice(&encode_u16(num_points));
|
||||||
sx.push(0xF7);
|
sx.push(0xF7);
|
||||||
sx
|
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> {
|
pub fn build_sysex_get_cell_k() -> Vec<u8> {
|
||||||
vec![0xF0, SYSEX_MFR, CMD_GET_CELL_K, 0xF7]
|
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]
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ pub struct Measurement {
|
||||||
pub mtype: String,
|
pub mtype: String,
|
||||||
pub params_json: String,
|
pub params_json: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
pub esp_timestamp: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -41,9 +42,21 @@ impl Storage {
|
||||||
let conn = Connection::open(path)?;
|
let conn = Connection::open(path)?;
|
||||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
|
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
|
||||||
conn.execute_batch(SCHEMA)?;
|
conn.execute_batch(SCHEMA)?;
|
||||||
|
Self::migrate_v2(&conn)?;
|
||||||
Ok(Self { conn })
|
Ok(Self { conn })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn migrate_v2(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||||
|
let has_col: bool = conn.prepare("SELECT esp_timestamp FROM measurements LIMIT 0")
|
||||||
|
.is_ok();
|
||||||
|
if !has_col {
|
||||||
|
conn.execute_batch(
|
||||||
|
"ALTER TABLE measurements ADD COLUMN esp_timestamp INTEGER;"
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_session(&self, name: &str, notes: &str) -> Result<i64, rusqlite::Error> {
|
pub fn create_session(&self, name: &str, notes: &str) -> Result<i64, rusqlite::Error> {
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"INSERT INTO sessions (name, notes) VALUES (?1, ?2)",
|
"INSERT INTO sessions (name, notes) VALUES (?1, ?2)",
|
||||||
|
|
@ -74,10 +87,21 @@ impl Storage {
|
||||||
|
|
||||||
pub fn create_measurement(
|
pub fn create_measurement(
|
||||||
&self, session_id: i64, mtype: &str, params_json: &str,
|
&self, session_id: i64, mtype: &str, params_json: &str,
|
||||||
|
esp_timestamp: Option<u32>,
|
||||||
) -> Result<i64, rusqlite::Error> {
|
) -> Result<i64, rusqlite::Error> {
|
||||||
|
if let Some(ts) = esp_timestamp {
|
||||||
|
let exists: bool = self.conn.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM measurements WHERE session_id = ?1 AND esp_timestamp = ?2)",
|
||||||
|
params![session_id, ts as i64],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
if exists {
|
||||||
|
return Err(rusqlite::Error::StatementChangedRows(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"INSERT INTO measurements (session_id, type, params_json) VALUES (?1, ?2, ?3)",
|
"INSERT INTO measurements (session_id, type, params_json, esp_timestamp) VALUES (?1, ?2, ?3, ?4)",
|
||||||
params![session_id, mtype, params_json],
|
params![session_id, mtype, params_json, esp_timestamp.map(|t| t as i64)],
|
||||||
)?;
|
)?;
|
||||||
Ok(self.conn.last_insert_rowid())
|
Ok(self.conn.last_insert_rowid())
|
||||||
}
|
}
|
||||||
|
|
@ -109,7 +133,7 @@ impl Storage {
|
||||||
|
|
||||||
pub fn get_measurements(&self, session_id: i64) -> Result<Vec<Measurement>, rusqlite::Error> {
|
pub fn get_measurements(&self, session_id: i64) -> Result<Vec<Measurement>, rusqlite::Error> {
|
||||||
let mut stmt = self.conn.prepare(
|
let mut stmt = self.conn.prepare(
|
||||||
"SELECT id, session_id, type, params_json, created_at \
|
"SELECT id, session_id, type, params_json, created_at, esp_timestamp \
|
||||||
FROM measurements WHERE session_id = ?1 ORDER BY created_at DESC",
|
FROM measurements WHERE session_id = ?1 ORDER BY created_at DESC",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map(params![session_id], |row| {
|
let rows = stmt.query_map(params![session_id], |row| {
|
||||||
|
|
@ -119,6 +143,7 @@ impl Storage {
|
||||||
mtype: row.get(2)?,
|
mtype: row.get(2)?,
|
||||||
params_json: row.get(3)?,
|
params_json: row.get(3)?,
|
||||||
created_at: row.get(4)?,
|
created_at: row.get(4)?,
|
||||||
|
esp_timestamp: row.get(5)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
rows.collect()
|
rows.collect()
|
||||||
|
|
@ -244,7 +269,7 @@ impl Storage {
|
||||||
Some(t) => serde_json::to_string(&toml_table_to_json(t))?,
|
Some(t) => serde_json::to_string(&toml_table_to_json(t))?,
|
||||||
None => "{}".to_string(),
|
None => "{}".to_string(),
|
||||||
};
|
};
|
||||||
let mid = self.create_measurement(session_id, mtype, ¶ms_json)?;
|
let mid = self.create_measurement(session_id, mtype, ¶ms_json, None)?;
|
||||||
|
|
||||||
if let Some(toml::Value::Array(data)) = mt.get("data") {
|
if let Some(toml::Value::Array(data)) = mt.get("data") {
|
||||||
let pts: Vec<(i32, String)> = data.iter().enumerate()
|
let pts: Vec<(i32, String)> = data.iter().enumerate()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
idf_component_register(SRCS "eis4.c" "eis.c" "echem.c" "protocol.c" "wifi_transport.c" "temp.c" "refs.c"
|
idf_component_register(SRCS "eis4.c" "eis.c" "echem.c" "protocol.c" "wifi_transport.c" "temp.c" "refs.c"
|
||||||
INCLUDE_DIRS "."
|
INCLUDE_DIRS "."
|
||||||
REQUIRES ad5941 ad5941_port nvs_flash esp_wifi esp_netif esp_event)
|
REQUIRES ad5941 ad5941_port nvs_flash esp_wifi esp_netif esp_event esp_timer)
|
||||||
|
|
||||||
if(DEFINED ENV{WIFI_SSID})
|
if(DEFINED ENV{WIFI_SSID})
|
||||||
target_compile_definitions(${COMPONENT_LIB} PRIVATE
|
target_compile_definitions(${COMPONENT_LIB} PRIVATE
|
||||||
|
|
|
||||||
38
main/echem.c
38
main/echem.c
|
|
@ -327,7 +327,14 @@ int echem_clean(float v_mv, float duration_s)
|
||||||
AD5940_LPDAC0WriteS(code, VZERO_CODE);
|
AD5940_LPDAC0WriteS(code, VZERO_CODE);
|
||||||
|
|
||||||
printf("Clean: %.0f mV for %.0f s\n", v_mv, duration_s);
|
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();
|
echem_shutdown_lp();
|
||||||
AD5940_AFECtrlS(AFECTRL_ALL, bFALSE);
|
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);
|
AD5940_LPDAC0WriteS(mv_to_vbias_code(v_mv), VZERO_CODE);
|
||||||
|
|
||||||
/* settling — no samples recorded */
|
/* 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 */
|
/* measurement — sample at ~50ms intervals */
|
||||||
uint32_t n_samples = (uint32_t)(t_meas_ms / 50.0f + 0.5f);
|
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);
|
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);
|
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);
|
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,
|
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);
|
AD5940_ADCBaseCfgS(&adc);
|
||||||
|
|
||||||
printf("pH: stabilizing %0.f s\n", cfg->stabilize_s);
|
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) */
|
/* average N readings of V(SE0) and V(RE0) */
|
||||||
#define PH_AVG_N 10
|
#define PH_AVG_N 10
|
||||||
|
|
|
||||||
59
main/eis.c
59
main/eis.c
|
|
@ -25,6 +25,9 @@ static struct {
|
||||||
|
|
||||||
/* cell constant K (cm⁻¹), cached from NVS */
|
/* cell constant K (cm⁻¹), cached from NVS */
|
||||||
static float cell_k_cached;
|
static float cell_k_cached;
|
||||||
|
static float cl_factor_cached;
|
||||||
|
static float ph_slope_cached;
|
||||||
|
static float ph_offset_cached;
|
||||||
|
|
||||||
/* open-circuit calibration data */
|
/* open-circuit calibration data */
|
||||||
static struct {
|
static struct {
|
||||||
|
|
@ -594,6 +597,9 @@ int eis_has_open_cal(void)
|
||||||
}
|
}
|
||||||
|
|
||||||
#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)
|
void eis_set_cell_k(float k)
|
||||||
{
|
{
|
||||||
|
|
@ -619,3 +625,56 @@ void eis_load_cell_k(void)
|
||||||
cell_k_cached = 0.0f;
|
cell_k_cached = 0.0f;
|
||||||
nvs_close(h);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,4 +67,13 @@ void eis_set_cell_k(float k);
|
||||||
float eis_get_cell_k(void);
|
float eis_get_cell_k(void);
|
||||||
void eis_load_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
|
#endif
|
||||||
|
|
|
||||||
67
main/eis4.c
67
main/eis4.c
|
|
@ -12,9 +12,11 @@
|
||||||
#include "nvs_flash.h"
|
#include "nvs_flash.h"
|
||||||
#include "esp_netif.h"
|
#include "esp_netif.h"
|
||||||
#include "esp_event.h"
|
#include "esp_event.h"
|
||||||
|
#include "esp_timer.h"
|
||||||
|
|
||||||
#define AD5941_EXPECTED_ADIID 0x4144
|
#define AD5941_EXPECTED_ADIID 0x4144
|
||||||
static EISConfig cfg;
|
static EISConfig cfg;
|
||||||
|
static uint16_t measurement_counter = 0;
|
||||||
static EISPoint results[EIS_MAX_POINTS];
|
static EISPoint results[EIS_MAX_POINTS];
|
||||||
static LSVPoint lsv_results[ECHEM_MAX_POINTS];
|
static LSVPoint lsv_results[ECHEM_MAX_POINTS];
|
||||||
static AmpPoint amp_results[ECHEM_MAX_POINTS];
|
static AmpPoint amp_results[ECHEM_MAX_POINTS];
|
||||||
|
|
@ -25,8 +27,10 @@ static void do_sweep(void)
|
||||||
{
|
{
|
||||||
eis_init(&cfg);
|
eis_init(&cfg);
|
||||||
|
|
||||||
|
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||||
|
measurement_counter++;
|
||||||
uint32_t n = eis_calc_num_points(&cfg);
|
uint32_t n = eis_calc_num_points(&cfg);
|
||||||
send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz);
|
send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz, ts_ms, measurement_counter);
|
||||||
int got = eis_sweep(results, n, send_eis_point);
|
int got = eis_sweep(results, n, send_eis_point);
|
||||||
printf("Sweep complete: %d points\n", got);
|
printf("Sweep complete: %d points\n", got);
|
||||||
send_sweep_end();
|
send_sweep_end();
|
||||||
|
|
@ -56,6 +60,8 @@ void app_main(void)
|
||||||
eis_default_config(&cfg);
|
eis_default_config(&cfg);
|
||||||
eis_load_open_cal();
|
eis_load_open_cal();
|
||||||
eis_load_cell_k();
|
eis_load_cell_k();
|
||||||
|
eis_load_cl_factor();
|
||||||
|
eis_load_ph_cal();
|
||||||
temp_init();
|
temp_init();
|
||||||
|
|
||||||
esp_netif_init();
|
esp_netif_init();
|
||||||
|
|
@ -123,12 +129,18 @@ void app_main(void)
|
||||||
lsv_cfg.v_stop = cmd.lsv.v_stop;
|
lsv_cfg.v_stop = cmd.lsv.v_stop;
|
||||||
lsv_cfg.scan_rate = cmd.lsv.scan_rate;
|
lsv_cfg.scan_rate = cmd.lsv.scan_rate;
|
||||||
lsv_cfg.lp_rtia = cmd.lsv.lp_rtia;
|
lsv_cfg.lp_rtia = cmd.lsv.lp_rtia;
|
||||||
printf("LSV: %.0f-%.0f mV, %.0f mV/s, rtia=%u\n",
|
uint32_t max_pts = ECHEM_MAX_POINTS;
|
||||||
lsv_cfg.v_start, lsv_cfg.v_stop, lsv_cfg.scan_rate, lsv_cfg.lp_rtia);
|
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);
|
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||||
send_lsv_start(n, lsv_cfg.v_start, lsv_cfg.v_stop);
|
measurement_counter++;
|
||||||
int got = echem_lsv(&lsv_cfg, lsv_results, ECHEM_MAX_POINTS, send_lsv_point);
|
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);
|
printf("LSV complete: %d points\n", got);
|
||||||
send_lsv_end();
|
send_lsv_end();
|
||||||
break;
|
break;
|
||||||
|
|
@ -143,7 +155,11 @@ void app_main(void)
|
||||||
printf("Amp: %.0f mV, %.0f ms interval, %.0f s\n",
|
printf("Amp: %.0f mV, %.0f ms interval, %.0f s\n",
|
||||||
amp_cfg.v_hold, amp_cfg.interval_ms, amp_cfg.duration_s);
|
amp_cfg.v_hold, amp_cfg.interval_ms, amp_cfg.duration_s);
|
||||||
|
|
||||||
send_amp_start(amp_cfg.v_hold);
|
{
|
||||||
|
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||||
|
measurement_counter++;
|
||||||
|
send_amp_start(amp_cfg.v_hold, ts_ms, measurement_counter);
|
||||||
|
}
|
||||||
int got = echem_amp(&_cfg, amp_results, ECHEM_MAX_POINTS, send_amp_point);
|
int got = echem_amp(&_cfg, amp_results, ECHEM_MAX_POINTS, send_amp_point);
|
||||||
printf("Amp complete: %d points\n", got);
|
printf("Amp complete: %d points\n", got);
|
||||||
send_amp_end();
|
send_amp_end();
|
||||||
|
|
@ -165,7 +181,12 @@ void app_main(void)
|
||||||
echem_ph_ocp(&ph_cfg, &ph_result);
|
echem_ph_ocp(&ph_cfg, &ph_result);
|
||||||
printf("pH: OCP=%.1f mV, pH=%.2f\n",
|
printf("pH: OCP=%.1f mV, pH=%.2f\n",
|
||||||
ph_result.v_ocp_mv, ph_result.ph);
|
ph_result.v_ocp_mv, ph_result.ph);
|
||||||
send_ph_result(ph_result.v_ocp_mv, ph_result.ph, ph_result.temp_c);
|
{
|
||||||
|
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||||
|
measurement_counter++;
|
||||||
|
send_ph_result(ph_result.v_ocp_mv, ph_result.ph, ph_result.temp_c,
|
||||||
|
ts_ms, measurement_counter);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,8 +212,10 @@ void app_main(void)
|
||||||
case CMD_OPEN_CAL: {
|
case CMD_OPEN_CAL: {
|
||||||
printf("Open-circuit cal starting\n");
|
printf("Open-circuit cal starting\n");
|
||||||
eis_init(&cfg);
|
eis_init(&cfg);
|
||||||
|
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||||
|
measurement_counter++;
|
||||||
uint32_t n = eis_calc_num_points(&cfg);
|
uint32_t n = eis_calc_num_points(&cfg);
|
||||||
send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz);
|
send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz, ts_ms, measurement_counter);
|
||||||
int got = eis_open_cal(results, n, send_eis_point);
|
int got = eis_open_cal(results, n, send_eis_point);
|
||||||
printf("Open-circuit cal: %d points\n", got);
|
printf("Open-circuit cal: %d points\n", got);
|
||||||
send_sweep_end();
|
send_sweep_end();
|
||||||
|
|
@ -214,6 +237,26 @@ void app_main(void)
|
||||||
send_cell_k(eis_get_cell_k());
|
send_cell_k(eis_get_cell_k());
|
||||||
break;
|
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: {
|
case CMD_START_CL: {
|
||||||
ClConfig cl_cfg;
|
ClConfig cl_cfg;
|
||||||
cl_cfg.v_cond = cmd.cl.v_cond;
|
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);
|
uint32_t n_per = (uint32_t)(cl_cfg.t_meas_ms / 50.0f + 0.5f);
|
||||||
if (n_per < 2) n_per = 2;
|
if (n_per < 2) n_per = 2;
|
||||||
send_cl_start(2 * n_per);
|
{
|
||||||
|
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||||
|
measurement_counter++;
|
||||||
|
send_cl_start(2 * n_per, ts_ms, measurement_counter);
|
||||||
|
}
|
||||||
ClResult cl_result;
|
ClResult cl_result;
|
||||||
int got = echem_chlorine(&cl_cfg, cl_results, ECHEM_MAX_POINTS,
|
int got = echem_chlorine(&cl_cfg, cl_results, ECHEM_MAX_POINTS,
|
||||||
&cl_result, send_cl_point);
|
&cl_result, send_cl_point);
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,29 @@ static void encode_u16(uint16_t val, uint8_t *out)
|
||||||
out[2] = p[1] & 0x7F;
|
out[2] = p[1] & 0x7F;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void encode_u32(uint32_t val, uint8_t *out)
|
||||||
|
{
|
||||||
|
uint8_t *p = (uint8_t *)&val;
|
||||||
|
out[0] = ((p[0] >> 7) & 1) | ((p[1] >> 6) & 2) |
|
||||||
|
((p[2] >> 5) & 4) | ((p[3] >> 4) & 8);
|
||||||
|
out[1] = p[0] & 0x7F;
|
||||||
|
out[2] = p[1] & 0x7F;
|
||||||
|
out[3] = p[2] & 0x7F;
|
||||||
|
out[4] = p[3] & 0x7F;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t decode_u32(const uint8_t *d)
|
||||||
|
{
|
||||||
|
uint8_t b[4];
|
||||||
|
b[0] = d[1] | ((d[0] & 1) << 7);
|
||||||
|
b[1] = d[2] | ((d[0] & 2) << 6);
|
||||||
|
b[2] = d[3] | ((d[0] & 4) << 5);
|
||||||
|
b[3] = d[4] | ((d[0] & 8) << 4);
|
||||||
|
uint32_t v;
|
||||||
|
memcpy(&v, b, 4);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
float decode_float(const uint8_t *d)
|
float decode_float(const uint8_t *d)
|
||||||
{
|
{
|
||||||
uint8_t b[4];
|
uint8_t b[4];
|
||||||
|
|
@ -144,16 +167,27 @@ static int send_sysex(const uint8_t *sysex, uint16_t len)
|
||||||
return wifi_send_sysex(sysex, 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 ---- */
|
/* ---- outbound: EIS ---- */
|
||||||
|
|
||||||
int send_sweep_start(uint32_t num_points, float freq_start, float freq_stop)
|
int send_sweep_start(uint32_t num_points, float freq_start, float freq_stop,
|
||||||
|
uint32_t ts_ms, uint16_t meas_id)
|
||||||
{
|
{
|
||||||
uint8_t sx[20];
|
uint8_t sx[28];
|
||||||
uint16_t p = 0;
|
uint16_t p = 0;
|
||||||
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_SWEEP_START;
|
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_SWEEP_START;
|
||||||
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
|
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
|
||||||
encode_float(freq_start, &sx[p]); p += 5;
|
encode_float(freq_start, &sx[p]); p += 5;
|
||||||
encode_float(freq_stop, &sx[p]); p += 5;
|
encode_float(freq_stop, &sx[p]); p += 5;
|
||||||
|
encode_u32(ts_ms, &sx[p]); p += 5;
|
||||||
|
encode_u16(meas_id, &sx[p]); p += 3;
|
||||||
sx[p++] = 0xF7;
|
sx[p++] = 0xF7;
|
||||||
return send_sysex(sx, p);
|
return send_sysex(sx, p);
|
||||||
}
|
}
|
||||||
|
|
@ -201,14 +235,17 @@ int send_config(const EISConfig *cfg)
|
||||||
|
|
||||||
/* ---- outbound: LSV ---- */
|
/* ---- outbound: LSV ---- */
|
||||||
|
|
||||||
int send_lsv_start(uint32_t num_points, float v_start, float v_stop)
|
int send_lsv_start(uint32_t num_points, float v_start, float v_stop,
|
||||||
|
uint32_t ts_ms, uint16_t meas_id)
|
||||||
{
|
{
|
||||||
uint8_t sx[20];
|
uint8_t sx[28];
|
||||||
uint16_t p = 0;
|
uint16_t p = 0;
|
||||||
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_LSV_START;
|
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_LSV_START;
|
||||||
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
|
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
|
||||||
encode_float(v_start, &sx[p]); p += 5;
|
encode_float(v_start, &sx[p]); p += 5;
|
||||||
encode_float(v_stop, &sx[p]); p += 5;
|
encode_float(v_stop, &sx[p]); p += 5;
|
||||||
|
encode_u32(ts_ms, &sx[p]); p += 5;
|
||||||
|
encode_u16(meas_id, &sx[p]); p += 3;
|
||||||
sx[p++] = 0xF7;
|
sx[p++] = 0xF7;
|
||||||
return send_sysex(sx, p);
|
return send_sysex(sx, p);
|
||||||
}
|
}
|
||||||
|
|
@ -233,12 +270,14 @@ int send_lsv_end(void)
|
||||||
|
|
||||||
/* ---- outbound: Amperometry ---- */
|
/* ---- outbound: Amperometry ---- */
|
||||||
|
|
||||||
int send_amp_start(float v_hold)
|
int send_amp_start(float v_hold, uint32_t ts_ms, uint16_t meas_id)
|
||||||
{
|
{
|
||||||
uint8_t sx[12];
|
uint8_t sx[20];
|
||||||
uint16_t p = 0;
|
uint16_t p = 0;
|
||||||
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_AMP_START;
|
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_AMP_START;
|
||||||
encode_float(v_hold, &sx[p]); p += 5;
|
encode_float(v_hold, &sx[p]); p += 5;
|
||||||
|
encode_u32(ts_ms, &sx[p]); p += 5;
|
||||||
|
encode_u16(meas_id, &sx[p]); p += 3;
|
||||||
sx[p++] = 0xF7;
|
sx[p++] = 0xF7;
|
||||||
return send_sysex(sx, p);
|
return send_sysex(sx, p);
|
||||||
}
|
}
|
||||||
|
|
@ -263,12 +302,14 @@ int send_amp_end(void)
|
||||||
|
|
||||||
/* ---- outbound: Chlorine ---- */
|
/* ---- outbound: Chlorine ---- */
|
||||||
|
|
||||||
int send_cl_start(uint32_t num_points)
|
int send_cl_start(uint32_t num_points, uint32_t ts_ms, uint16_t meas_id)
|
||||||
{
|
{
|
||||||
uint8_t sx[10];
|
uint8_t sx[18];
|
||||||
uint16_t p = 0;
|
uint16_t p = 0;
|
||||||
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_CL_START;
|
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_CL_START;
|
||||||
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
|
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
|
||||||
|
encode_u32(ts_ms, &sx[p]); p += 5;
|
||||||
|
encode_u16(meas_id, &sx[p]); p += 3;
|
||||||
sx[p++] = 0xF7;
|
sx[p++] = 0xF7;
|
||||||
return send_sysex(sx, p);
|
return send_sysex(sx, p);
|
||||||
}
|
}
|
||||||
|
|
@ -303,16 +344,32 @@ int send_cl_end(void)
|
||||||
return send_sysex(sx, sizeof(sx));
|
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 ---- */
|
/* ---- outbound: pH ---- */
|
||||||
|
|
||||||
int send_ph_result(float v_ocp_mv, float ph, float temp_c)
|
int send_ph_result(float v_ocp_mv, float ph, float temp_c,
|
||||||
|
uint32_t ts_ms, uint16_t meas_id)
|
||||||
{
|
{
|
||||||
uint8_t sx[20];
|
uint8_t sx[28];
|
||||||
uint16_t p = 0;
|
uint16_t p = 0;
|
||||||
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_PH_RESULT;
|
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_PH_RESULT;
|
||||||
encode_float(v_ocp_mv, &sx[p]); p += 5;
|
encode_float(v_ocp_mv, &sx[p]); p += 5;
|
||||||
encode_float(ph, &sx[p]); p += 5;
|
encode_float(ph, &sx[p]); p += 5;
|
||||||
encode_float(temp_c, &sx[p]); p += 5;
|
encode_float(temp_c, &sx[p]); p += 5;
|
||||||
|
encode_u32(ts_ms, &sx[p]); p += 5;
|
||||||
|
encode_u16(meas_id, &sx[p]); p += 3;
|
||||||
sx[p++] = 0xF7;
|
sx[p++] = 0xF7;
|
||||||
return send_sysex(sx, p);
|
return send_sysex(sx, p);
|
||||||
}
|
}
|
||||||
|
|
@ -341,6 +398,18 @@ int send_cell_k(float k)
|
||||||
return send_sysex(sx, p);
|
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 ---- */
|
/* ---- outbound: reference collection ---- */
|
||||||
|
|
||||||
int send_ref_frame(uint8_t mode, uint8_t rtia_idx)
|
int send_ref_frame(uint8_t mode, uint8_t rtia_idx)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@
|
||||||
#define CMD_START_REFS 0x30
|
#define CMD_START_REFS 0x30
|
||||||
#define CMD_GET_REFS 0x31
|
#define CMD_GET_REFS 0x31
|
||||||
#define CMD_CLEAR_REFS 0x32
|
#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) */
|
/* Session sync commands (0x4x) */
|
||||||
#define CMD_SESSION_CREATE 0x40
|
#define CMD_SESSION_CREATE 0x40
|
||||||
|
|
@ -56,6 +60,9 @@
|
||||||
#define RSP_REF_LP_RANGE 0x21
|
#define RSP_REF_LP_RANGE 0x21
|
||||||
#define RSP_REFS_DONE 0x22
|
#define RSP_REFS_DONE 0x22
|
||||||
#define RSP_REF_STATUS 0x23
|
#define RSP_REF_STATUS 0x23
|
||||||
|
#define RSP_CL_FACTOR 0x24
|
||||||
|
#define RSP_PH_CAL 0x25
|
||||||
|
#define RSP_KEEPALIVE 0x50
|
||||||
|
|
||||||
/* Session sync responses (0x4x) */
|
/* Session sync responses (0x4x) */
|
||||||
#define RSP_SESSION_CREATED 0x40
|
#define RSP_SESSION_CREATED 0x40
|
||||||
|
|
@ -75,12 +82,14 @@ typedef struct {
|
||||||
uint8_t rtia;
|
uint8_t rtia;
|
||||||
uint8_t rcal;
|
uint8_t rcal;
|
||||||
uint8_t electrode;
|
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_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 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 stabilize_s; } ph;
|
||||||
struct { float v_mv; float duration_s; } clean;
|
struct { float v_mv; float duration_s; } clean;
|
||||||
float cell_k;
|
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 name_len; char name[MAX_SESSION_NAME]; } session_create;
|
||||||
struct { uint8_t id; } session_switch;
|
struct { uint8_t id; } session_switch;
|
||||||
struct { uint8_t id; uint8_t name_len; char name[MAX_SESSION_NAME]; } session_rename;
|
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);
|
int protocol_recv_command(Command *cmd, uint32_t timeout_ms);
|
||||||
void protocol_push_command(const Command *cmd);
|
void protocol_push_command(const Command *cmd);
|
||||||
|
|
||||||
/* 7-bit decode helpers */
|
/* 7-bit encode/decode helpers */
|
||||||
|
void encode_u32(uint32_t val, uint8_t *out);
|
||||||
|
uint32_t decode_u32(const uint8_t *d);
|
||||||
float decode_float(const uint8_t *d);
|
float decode_float(const uint8_t *d);
|
||||||
uint16_t decode_u16(const uint8_t *d);
|
uint16_t decode_u16(const uint8_t *d);
|
||||||
|
|
||||||
/* outbound: EIS */
|
/* outbound: EIS */
|
||||||
int send_sweep_start(uint32_t num_points, float freq_start, float freq_stop);
|
int send_sweep_start(uint32_t num_points, float freq_start, float freq_stop,
|
||||||
|
uint32_t ts_ms, uint16_t meas_id);
|
||||||
int send_eis_point(uint16_t index, const EISPoint *pt);
|
int send_eis_point(uint16_t index, const EISPoint *pt);
|
||||||
int send_sweep_end(void);
|
int send_sweep_end(void);
|
||||||
int send_config(const EISConfig *cfg);
|
int send_config(const EISConfig *cfg);
|
||||||
|
|
||||||
/* outbound: LSV */
|
/* outbound: LSV */
|
||||||
int send_lsv_start(uint32_t num_points, float v_start, float v_stop);
|
int send_lsv_start(uint32_t num_points, float v_start, float v_stop,
|
||||||
|
uint32_t ts_ms, uint16_t meas_id);
|
||||||
int send_lsv_point(uint16_t index, float v_mv, float i_ua);
|
int send_lsv_point(uint16_t index, float v_mv, float i_ua);
|
||||||
int send_lsv_end(void);
|
int send_lsv_end(void);
|
||||||
|
|
||||||
/* outbound: Amperometry */
|
/* outbound: Amperometry */
|
||||||
int send_amp_start(float v_hold);
|
int send_amp_start(float v_hold, uint32_t ts_ms, uint16_t meas_id);
|
||||||
int send_amp_point(uint16_t index, float t_ms, float i_ua);
|
int send_amp_point(uint16_t index, float t_ms, float i_ua);
|
||||||
int send_amp_end(void);
|
int send_amp_end(void);
|
||||||
|
|
||||||
/* outbound: Chlorine */
|
/* outbound: Chlorine */
|
||||||
int send_cl_start(uint32_t num_points);
|
int send_cl_start(uint32_t num_points, uint32_t ts_ms, uint16_t meas_id);
|
||||||
int send_cl_point(uint16_t index, float t_ms, float i_ua, uint8_t phase);
|
int send_cl_point(uint16_t index, float t_ms, float i_ua, uint8_t phase);
|
||||||
int send_cl_result(float i_free_ua, float i_total_ua);
|
int send_cl_result(float i_free_ua, float i_total_ua);
|
||||||
int send_cl_end(void);
|
int send_cl_end(void);
|
||||||
|
|
||||||
/* outbound: pH */
|
/* outbound: pH */
|
||||||
int send_ph_result(float v_ocp_mv, float ph, float temp_c);
|
int send_ph_result(float v_ocp_mv, float ph, float temp_c,
|
||||||
|
uint32_t ts_ms, uint16_t meas_id);
|
||||||
|
|
||||||
/* outbound: temperature */
|
/* outbound: temperature */
|
||||||
int send_temp(float temp_c);
|
int send_temp(float temp_c);
|
||||||
|
|
@ -132,12 +146,21 @@ int send_temp(float temp_c);
|
||||||
/* outbound: cell constant */
|
/* outbound: cell constant */
|
||||||
int send_cell_k(float k);
|
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 */
|
/* outbound: reference collection */
|
||||||
int send_ref_frame(uint8_t mode, uint8_t rtia_idx);
|
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_ref_lp_range(uint8_t mode, uint8_t low_idx, uint8_t high_idx);
|
||||||
int send_refs_done(void);
|
int send_refs_done(void);
|
||||||
int send_ref_status(uint8_t has_refs);
|
int send_ref_status(uint8_t has_refs);
|
||||||
|
|
||||||
|
/* keepalive (sent during long blocking ops) */
|
||||||
|
int send_keepalive(void);
|
||||||
|
|
||||||
/* session management */
|
/* session management */
|
||||||
const Session *session_get_all(uint8_t *count);
|
const Session *session_get_all(uint8_t *count);
|
||||||
uint8_t session_get_current(void);
|
uint8_t session_get_current(void);
|
||||||
|
|
|
||||||
15
main/refs.c
15
main/refs.c
|
|
@ -7,6 +7,7 @@
|
||||||
#include <math.h>
|
#include <math.h>
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
|
#include "esp_timer.h"
|
||||||
|
|
||||||
extern const uint32_t lp_rtia_map[];
|
extern const uint32_t lp_rtia_map[];
|
||||||
extern const float lp_rtia_ohms[];
|
extern const float lp_rtia_ohms[];
|
||||||
|
|
@ -232,7 +233,8 @@ void refs_collect(RefStore *store, const EISConfig *cfg)
|
||||||
send_ref_frame(REF_MODE_EIS, (uint8_t)r);
|
send_ref_frame(REF_MODE_EIS, (uint8_t)r);
|
||||||
|
|
||||||
uint32_t n = eis_calc_num_points(&ref_cfg);
|
uint32_t n = eis_calc_num_points(&ref_cfg);
|
||||||
send_sweep_start(n, ref_cfg.freq_start_hz, ref_cfg.freq_stop_hz);
|
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||||
|
send_sweep_start(n, ref_cfg.freq_start_hz, ref_cfg.freq_stop_hz, ts_ms, 0);
|
||||||
|
|
||||||
int got = eis_sweep(store->eis[r].pts, n, send_eis_point);
|
int got = eis_sweep(store->eis[r].pts, n, send_eis_point);
|
||||||
store->eis[r].n_points = (uint32_t)got;
|
store->eis[r].n_points = (uint32_t)got;
|
||||||
|
|
@ -270,7 +272,11 @@ void refs_collect(RefStore *store, const EISConfig *cfg)
|
||||||
ph_cfg.temp_c = temp_get();
|
ph_cfg.temp_c = temp_get();
|
||||||
echem_ph_ocp(&ph_cfg, &store->ph_ref);
|
echem_ph_ocp(&ph_cfg, &store->ph_ref);
|
||||||
store->ph_valid = 1;
|
store->ph_valid = 1;
|
||||||
send_ph_result(store->ph_ref.v_ocp_mv, store->ph_ref.ph, store->ph_ref.temp_c);
|
{
|
||||||
|
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||||
|
send_ph_result(store->ph_ref.v_ocp_mv, store->ph_ref.ph, store->ph_ref.temp_c,
|
||||||
|
ts_ms, 0);
|
||||||
|
}
|
||||||
|
|
||||||
store->has_refs = 1;
|
store->has_refs = 1;
|
||||||
send_refs_done();
|
send_refs_done();
|
||||||
|
|
@ -291,7 +297,7 @@ void refs_send(const RefStore *store)
|
||||||
send_ref_frame(REF_MODE_EIS, (uint8_t)r);
|
send_ref_frame(REF_MODE_EIS, (uint8_t)r);
|
||||||
uint32_t n = store->eis[r].n_points;
|
uint32_t n = store->eis[r].n_points;
|
||||||
send_sweep_start(n, store->eis[r].pts[0].freq_hz,
|
send_sweep_start(n, store->eis[r].pts[0].freq_hz,
|
||||||
store->eis[r].pts[n - 1].freq_hz);
|
store->eis[r].pts[n - 1].freq_hz, 0, 0);
|
||||||
for (uint32_t i = 0; i < n; i++)
|
for (uint32_t i = 0; i < n; i++)
|
||||||
send_eis_point((uint16_t)i, &store->eis[r].pts[i]);
|
send_eis_point((uint16_t)i, &store->eis[r].pts[i]);
|
||||||
send_sweep_end();
|
send_sweep_end();
|
||||||
|
|
@ -306,7 +312,8 @@ void refs_send(const RefStore *store)
|
||||||
|
|
||||||
if (store->ph_valid) {
|
if (store->ph_valid) {
|
||||||
send_ref_frame(REF_MODE_PH, 0);
|
send_ref_frame(REF_MODE_PH, 0);
|
||||||
send_ph_result(store->ph_ref.v_ocp_mv, store->ph_ref.ph, store->ph_ref.temp_c);
|
send_ph_result(store->ph_ref.v_ocp_mv, store->ph_ref.ph, store->ph_ref.temp_c,
|
||||||
|
0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
send_refs_done();
|
send_refs_done();
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
#include "freertos/queue.h"
|
#include "freertos/queue.h"
|
||||||
|
#include "freertos/semphr.h"
|
||||||
#include "esp_wifi.h"
|
#include "esp_wifi.h"
|
||||||
|
#include "esp_wifi_ap_get_sta_list.h"
|
||||||
#include "esp_netif.h"
|
#include "esp_netif.h"
|
||||||
#include "esp_event.h"
|
#include "esp_event.h"
|
||||||
#include "lwip/sockets.h"
|
#include "lwip/sockets.h"
|
||||||
|
|
@ -13,57 +15,86 @@
|
||||||
#define WIFI_SSID "EIS4"
|
#define WIFI_SSID "EIS4"
|
||||||
#define WIFI_PASS "eis4data"
|
#define WIFI_PASS "eis4data"
|
||||||
#define WIFI_CHANNEL 1
|
#define WIFI_CHANNEL 1
|
||||||
#define WIFI_MAX_CONN 4
|
#define WIFI_MAX_CONN 10
|
||||||
|
|
||||||
#define UDP_PORT 5941
|
#define UDP_PORT 5941
|
||||||
#define UDP_BUF_SIZE 128
|
#define UDP_BUF_SIZE 128
|
||||||
#define MAX_UDP_CLIENTS 4
|
#define UDP_CLIENTS_MAX 16
|
||||||
#define CLIENT_TIMEOUT_MS 30000
|
#define REAP_THRESHOLD 10
|
||||||
|
#define REAP_WINDOW_MS 200
|
||||||
|
#define REAP_INTERVAL_MS 5000
|
||||||
|
|
||||||
static int udp_sock = -1;
|
static int udp_sock = -1;
|
||||||
|
static esp_netif_t *ap_netif;
|
||||||
|
|
||||||
static struct {
|
static struct {
|
||||||
struct sockaddr_in addr;
|
struct sockaddr_in addr;
|
||||||
TickType_t last_seen;
|
uint8_t mac[6];
|
||||||
|
uint32_t last_touch_ms;
|
||||||
bool active;
|
bool active;
|
||||||
} clients[MAX_UDP_CLIENTS];
|
} clients[UDP_CLIENTS_MAX];
|
||||||
|
|
||||||
static int client_count;
|
static int client_count;
|
||||||
|
static SemaphoreHandle_t client_mutex;
|
||||||
|
|
||||||
static void client_touch(const struct sockaddr_in *addr)
|
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++) {
|
for (int i = 0; i < client_count; i++) {
|
||||||
if (clients[i].addr.sin_addr.s_addr == addr->sin_addr.s_addr &&
|
if (clients[i].addr.sin_addr.s_addr == addr->sin_addr.s_addr &&
|
||||||
clients[i].addr.sin_port == addr->sin_port) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client_count < MAX_UDP_CLIENTS) {
|
if (client_count < UDP_CLIENTS_MAX) {
|
||||||
clients[client_count].addr = *addr;
|
clients[client_count].addr = *addr;
|
||||||
clients[client_count].last_seen = now;
|
|
||||||
clients[client_count].active = true;
|
clients[client_count].active = true;
|
||||||
client_count++;
|
memset(clients[client_count].mac, 0, 6);
|
||||||
printf("UDP: client added (%d/%d)\n", client_count, MAX_UDP_CLIENTS);
|
|
||||||
|
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)\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();
|
xSemaphoreTake(client_mutex, portMAX_DELAY);
|
||||||
TickType_t timeout = pdMS_TO_TICKS(CLIENT_TIMEOUT_MS);
|
|
||||||
|
|
||||||
for (int i = 0; i < client_count; ) {
|
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];
|
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 {
|
} else {
|
||||||
i++;
|
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)
|
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.v_stop = decode_float(&data[8]);
|
||||||
cmd.lsv.scan_rate = decode_float(&data[13]);
|
cmd.lsv.scan_rate = decode_float(&data[13]);
|
||||||
cmd.lsv.lp_rtia = data[18];
|
cmd.lsv.lp_rtia = data[18];
|
||||||
|
if (len >= 22)
|
||||||
|
cmd.lsv.num_points = decode_u16(&data[19]);
|
||||||
break;
|
break;
|
||||||
case CMD_START_AMP:
|
case CMD_START_AMP:
|
||||||
if (len < 19) return;
|
if (len < 19) return;
|
||||||
|
|
@ -137,6 +170,15 @@ static void parse_udp_sysex(const uint8_t *data, uint16_t len)
|
||||||
if (len < 8) return;
|
if (len < 8) return;
|
||||||
cmd.cell_k = decode_float(&data[3]);
|
cmd.cell_k = decode_float(&data[3]);
|
||||||
break;
|
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:
|
case CMD_SESSION_CREATE:
|
||||||
if (len < 5) return;
|
if (len < 5) return;
|
||||||
cmd.session_create.name_len = data[3] & 0x7F;
|
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_STOP_AMP:
|
||||||
case CMD_GET_TEMP:
|
case CMD_GET_TEMP:
|
||||||
case CMD_GET_CELL_K:
|
case CMD_GET_CELL_K:
|
||||||
|
case CMD_GET_CL_FACTOR:
|
||||||
|
case CMD_GET_PH_CAL:
|
||||||
case CMD_START_REFS:
|
case CMD_START_REFS:
|
||||||
case CMD_GET_REFS:
|
case CMD_GET_REFS:
|
||||||
case CMD_CLEAR_REFS:
|
case CMD_CLEAR_REFS:
|
||||||
|
|
@ -191,16 +235,49 @@ static void udp_rx_task(void *param)
|
||||||
if (n <= 0) continue;
|
if (n <= 0) continue;
|
||||||
|
|
||||||
client_touch(&src);
|
client_touch(&src);
|
||||||
clients_expire();
|
|
||||||
parse_udp_sysex(buf, (uint16_t)n);
|
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)
|
int wifi_send_sysex(const uint8_t *sysex, uint16_t len)
|
||||||
{
|
{
|
||||||
if (udp_sock < 0 || client_count == 0)
|
if (udp_sock < 0 || client_count == 0)
|
||||||
return -1;
|
return -1;
|
||||||
|
|
||||||
|
xSemaphoreTake(client_mutex, portMAX_DELAY);
|
||||||
int sent = 0;
|
int sent = 0;
|
||||||
for (int i = 0; i < client_count; i++) {
|
for (int i = 0; i < client_count; i++) {
|
||||||
int r = sendto(udp_sock, sysex, len, 0,
|
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));
|
sizeof(clients[i].addr));
|
||||||
if (r > 0) sent++;
|
if (r > 0) sent++;
|
||||||
}
|
}
|
||||||
|
xSemaphoreGive(client_mutex);
|
||||||
return sent > 0 ? 0 : -1;
|
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,
|
static void wifi_event_handler(void *arg, esp_event_base_t base,
|
||||||
int32_t id, void *data)
|
int32_t id, void *data)
|
||||||
{
|
{
|
||||||
(void)arg; (void)data;
|
(void)arg;
|
||||||
if (base == WIFI_EVENT) {
|
if (base == WIFI_EVENT) {
|
||||||
if (id == WIFI_EVENT_AP_STACONNECTED)
|
if (id == WIFI_EVENT_AP_STACONNECTED) {
|
||||||
printf("WiFi: station connected\n");
|
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");
|
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)
|
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();
|
esp_netif_create_default_wifi_sta();
|
||||||
|
|
||||||
wifi_init_config_t wifi_cfg = WIFI_INIT_CONFIG_DEFAULT();
|
wifi_init_config_t wifi_cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||||
|
|
@ -290,6 +371,9 @@ static int wifi_ap_init(void)
|
||||||
err = esp_wifi_start();
|
err = esp_wifi_start();
|
||||||
if (err) return err;
|
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) {
|
if (strlen(STA_SSID) > 0) {
|
||||||
esp_wifi_connect();
|
esp_wifi_connect();
|
||||||
printf("WiFi: STA connecting to \"%s\"\n", STA_SSID);
|
printf("WiFi: STA connecting to \"%s\"\n", STA_SSID);
|
||||||
|
|
@ -322,6 +406,8 @@ static int udp_init(void)
|
||||||
|
|
||||||
int wifi_transport_init(void)
|
int wifi_transport_init(void)
|
||||||
{
|
{
|
||||||
|
client_mutex = xSemaphoreCreateMutex();
|
||||||
|
|
||||||
int rc = wifi_ap_init();
|
int rc = wifi_ap_init();
|
||||||
if (rc) {
|
if (rc) {
|
||||||
printf("WiFi: AP init failed: %d\n", 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_rx_task, "udp_rx", 4096, NULL, 5, NULL);
|
||||||
|
xTaskCreate(udp_reaper_task, "reaper", 2048, NULL, 4, NULL);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,3 @@
|
||||||
CONFIG_IDF_TARGET="esp32s3"
|
CONFIG_IDF_TARGET="esp32s3"
|
||||||
|
CONFIG_LWIP_DHCPS_MAX_STATION_NUM=12
|
||||||
|
CONFIG_ESP_WIFI_SLP_IRAM_OPT=n
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue