Compare commits
11 Commits
595e25466c
...
0ff998c82c
| Author | SHA1 | Date |
|---|---|---|
|
|
0ff998c82c | |
|
|
019723d245 | |
|
|
b2493ffb54 | |
|
|
ae35b1248f | |
|
|
bbfb008d1a | |
|
|
aee5a2e033 | |
|
|
6d9a2b7cc1 | |
|
|
23f6c3bbcc | |
|
|
42665a3c84 | |
|
|
339c4c9a99 | |
|
|
0ae5219c19 |
|
|
@ -0,0 +1,6 @@
|
||||||
|
*.xcodeproj
|
||||||
|
.swiftpm/
|
||||||
|
.build/
|
||||||
|
build/
|
||||||
|
DerivedData/
|
||||||
|
*.xcuserdata
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
enum Tab: String, CaseIterable, Identifiable {
|
||||||
|
case eis = "EIS"
|
||||||
|
case lsv = "LSV"
|
||||||
|
case amp = "Amperometry"
|
||||||
|
case chlorine = "Chlorine"
|
||||||
|
case ph = "pH"
|
||||||
|
case sessions = "Sessions"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - App State
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class AppState {
|
||||||
|
var tab: Tab = .eis
|
||||||
|
var status: String = "Disconnected"
|
||||||
|
var bleConnected: Bool = false
|
||||||
|
var tempC: Float = 25.0
|
||||||
|
|
||||||
|
// EIS
|
||||||
|
var eisPoints: [EisPoint] = []
|
||||||
|
var sweepTotal: UInt16 = 0
|
||||||
|
var freqStart: String = "1000"
|
||||||
|
var freqStop: String = "200000"
|
||||||
|
var ppd: String = "10"
|
||||||
|
var rtia: Rtia = .r5K
|
||||||
|
var rcal: Rcal = .r3K
|
||||||
|
var electrode: Electrode = .fourWire
|
||||||
|
|
||||||
|
// LSV
|
||||||
|
var lsvPoints: [LsvPoint] = []
|
||||||
|
var lsvTotal: UInt16 = 0
|
||||||
|
var lsvStartV: String = "0"
|
||||||
|
var lsvStopV: String = "500"
|
||||||
|
var lsvScanRate: String = "50"
|
||||||
|
var lsvRtia: LpRtia = .r10K
|
||||||
|
|
||||||
|
// Amperometry
|
||||||
|
var ampPoints: [AmpPoint] = []
|
||||||
|
var ampTotal: UInt16 = 0
|
||||||
|
var ampRunning: Bool = false
|
||||||
|
var ampVHold: String = "200"
|
||||||
|
var ampInterval: String = "100"
|
||||||
|
var ampDuration: String = "60"
|
||||||
|
var ampRtia: LpRtia = .r10K
|
||||||
|
|
||||||
|
// Chlorine
|
||||||
|
var clPoints: [ClPoint] = []
|
||||||
|
var clResult: ClResult? = nil
|
||||||
|
var clTotal: UInt16 = 0
|
||||||
|
var clCondV: String = "800"
|
||||||
|
var clCondT: String = "2000"
|
||||||
|
var clFreeV: String = "100"
|
||||||
|
var clTotalV: String = "-200"
|
||||||
|
var clDepT: String = "5000"
|
||||||
|
var clMeasT: String = "5000"
|
||||||
|
var clRtia: LpRtia = .r10K
|
||||||
|
|
||||||
|
// pH
|
||||||
|
var phResult: PhResult? = nil
|
||||||
|
var phStabilize: String = "30"
|
||||||
|
|
||||||
|
// Reference baselines
|
||||||
|
var eisRef: [EisPoint]? = nil
|
||||||
|
var lsvRef: [LsvPoint]? = nil
|
||||||
|
var ampRef: [AmpPoint]? = nil
|
||||||
|
var clRef: (points: [ClPoint], result: ClResult)? = nil
|
||||||
|
var phRef: PhResult? = nil
|
||||||
|
|
||||||
|
// Device reference collection
|
||||||
|
var collectingRefs: Bool = false
|
||||||
|
var hasDeviceRefs: Bool = false
|
||||||
|
|
||||||
|
// Clean
|
||||||
|
var cleanV: String = "1200"
|
||||||
|
var cleanDur: String = "30"
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
func applyEISSettings() {
|
||||||
|
let fs = Float(freqStart) ?? 1000
|
||||||
|
let fe = Float(freqStop) ?? 200000
|
||||||
|
let p = UInt16(ppd) ?? 10
|
||||||
|
status = "Applying: \(fs)-\(fe) Hz, \(p) PPD"
|
||||||
|
}
|
||||||
|
|
||||||
|
func startSweep() {
|
||||||
|
eisPoints.removeAll()
|
||||||
|
status = "Starting sweep..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func startLSV() {
|
||||||
|
lsvPoints.removeAll()
|
||||||
|
let vs = Float(lsvStartV) ?? 0
|
||||||
|
let ve = Float(lsvStopV) ?? 500
|
||||||
|
status = "Starting LSV: \(vs)-\(ve) mV"
|
||||||
|
}
|
||||||
|
|
||||||
|
func startAmp() {
|
||||||
|
ampPoints.removeAll()
|
||||||
|
ampRunning = true
|
||||||
|
status = "Starting amperometry..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopAmp() {
|
||||||
|
ampRunning = false
|
||||||
|
status = "Stopping amperometry..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func startChlorine() {
|
||||||
|
clPoints.removeAll()
|
||||||
|
clResult = nil
|
||||||
|
status = "Starting chlorine measurement..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func startPh() {
|
||||||
|
phResult = nil
|
||||||
|
status = "Starting pH measurement..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func setReference() {
|
||||||
|
switch tab {
|
||||||
|
case .eis where !eisPoints.isEmpty:
|
||||||
|
eisRef = eisPoints
|
||||||
|
status = "EIS reference set (\(eisPoints.count) pts)"
|
||||||
|
case .lsv where !lsvPoints.isEmpty:
|
||||||
|
lsvRef = lsvPoints
|
||||||
|
status = "LSV reference set (\(lsvPoints.count) pts)"
|
||||||
|
case .amp where !ampPoints.isEmpty:
|
||||||
|
ampRef = ampPoints
|
||||||
|
status = "Amp reference set (\(ampPoints.count) pts)"
|
||||||
|
case .chlorine where !clPoints.isEmpty:
|
||||||
|
if let r = clResult {
|
||||||
|
clRef = (clPoints, r)
|
||||||
|
status = "Chlorine reference set"
|
||||||
|
}
|
||||||
|
case .ph:
|
||||||
|
if let r = phResult {
|
||||||
|
phRef = r
|
||||||
|
status = String(format: "pH reference set (%.2f)", r.ph)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearReference() {
|
||||||
|
switch tab {
|
||||||
|
case .eis: eisRef = nil; status = "EIS reference cleared"
|
||||||
|
case .lsv: lsvRef = nil; status = "LSV reference cleared"
|
||||||
|
case .amp: ampRef = nil; status = "Amp reference cleared"
|
||||||
|
case .chlorine: clRef = nil; status = "Chlorine reference cleared"
|
||||||
|
case .ph: phRef = nil; status = "pH reference cleared"
|
||||||
|
case .sessions: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectRefs() {
|
||||||
|
collectingRefs = true
|
||||||
|
status = "Starting reference collection..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearRefs() {
|
||||||
|
collectingRefs = false
|
||||||
|
hasDeviceRefs = false
|
||||||
|
eisRef = nil
|
||||||
|
lsvRef = nil
|
||||||
|
ampRef = nil
|
||||||
|
clRef = nil
|
||||||
|
phRef = nil
|
||||||
|
status = "Refs cleared"
|
||||||
|
}
|
||||||
|
|
||||||
|
func startClean() {
|
||||||
|
let v = Float(cleanV) ?? 1200
|
||||||
|
let d = Float(cleanDur) ?? 30
|
||||||
|
status = String(format: "Cleaning: %.0f mV for %.0fs", v, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasCurrentRef: Bool {
|
||||||
|
switch tab {
|
||||||
|
case .eis: eisRef != nil
|
||||||
|
case .lsv: lsvRef != nil
|
||||||
|
case .amp: ampRef != nil
|
||||||
|
case .chlorine: clRef != nil
|
||||||
|
case .ph: phRef != nil
|
||||||
|
case .sessions: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasCurrentData: Bool {
|
||||||
|
switch tab {
|
||||||
|
case .eis: !eisPoints.isEmpty
|
||||||
|
case .lsv: !lsvPoints.isEmpty
|
||||||
|
case .amp: !ampPoints.isEmpty
|
||||||
|
case .chlorine: clResult != nil
|
||||||
|
case .ph: phResult != nil
|
||||||
|
case .sessions: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Measurement loading
|
||||||
|
|
||||||
|
func loadMeasurement(_ measurement: Measurement) {
|
||||||
|
guard let id = measurement.id,
|
||||||
|
let type = MeasurementType(rawValue: measurement.type) else { return }
|
||||||
|
do {
|
||||||
|
switch type {
|
||||||
|
case .eis:
|
||||||
|
eisPoints = try Storage.shared.fetchTypedPoints(measurementId: id, as: EisPoint.self)
|
||||||
|
tab = .eis
|
||||||
|
status = "Loaded EIS (\(eisPoints.count) pts)"
|
||||||
|
case .lsv:
|
||||||
|
lsvPoints = try Storage.shared.fetchTypedPoints(measurementId: id, as: LsvPoint.self)
|
||||||
|
tab = .lsv
|
||||||
|
status = "Loaded LSV (\(lsvPoints.count) pts)"
|
||||||
|
case .amp:
|
||||||
|
ampPoints = try Storage.shared.fetchTypedPoints(measurementId: id, as: AmpPoint.self)
|
||||||
|
tab = .amp
|
||||||
|
status = "Loaded Amp (\(ampPoints.count) pts)"
|
||||||
|
case .chlorine:
|
||||||
|
clPoints = try Storage.shared.fetchTypedPoints(measurementId: id, as: ClPoint.self)
|
||||||
|
if let summary = measurement.resultSummary {
|
||||||
|
clResult = try JSONDecoder().decode(ClResult.self, from: summary)
|
||||||
|
}
|
||||||
|
tab = .chlorine
|
||||||
|
status = "Loaded Chlorine (\(clPoints.count) pts)"
|
||||||
|
case .ph:
|
||||||
|
if let summary = measurement.resultSummary {
|
||||||
|
phResult = try JSONDecoder().decode(PhResult.self, from: summary)
|
||||||
|
}
|
||||||
|
tab = .ph
|
||||||
|
status = "Loaded pH result"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
status = "Load failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAsReference(_ measurement: Measurement) {
|
||||||
|
guard let id = measurement.id,
|
||||||
|
let type = MeasurementType(rawValue: measurement.type) else { return }
|
||||||
|
do {
|
||||||
|
switch type {
|
||||||
|
case .eis:
|
||||||
|
eisRef = try Storage.shared.fetchTypedPoints(measurementId: id, as: EisPoint.self)
|
||||||
|
status = "EIS reference loaded (\(eisRef?.count ?? 0) pts)"
|
||||||
|
case .lsv:
|
||||||
|
lsvRef = try Storage.shared.fetchTypedPoints(measurementId: id, as: LsvPoint.self)
|
||||||
|
status = "LSV reference loaded (\(lsvRef?.count ?? 0) pts)"
|
||||||
|
case .amp:
|
||||||
|
ampRef = try Storage.shared.fetchTypedPoints(measurementId: id, as: AmpPoint.self)
|
||||||
|
status = "Amp reference loaded (\(ampRef?.count ?? 0) pts)"
|
||||||
|
case .chlorine:
|
||||||
|
let pts = try Storage.shared.fetchTypedPoints(measurementId: id, as: ClPoint.self)
|
||||||
|
if let summary = measurement.resultSummary {
|
||||||
|
let result = try JSONDecoder().decode(ClResult.self, from: summary)
|
||||||
|
clRef = (pts, result)
|
||||||
|
status = "Chlorine reference loaded"
|
||||||
|
}
|
||||||
|
case .ph:
|
||||||
|
if let summary = measurement.resultSummary {
|
||||||
|
phRef = try JSONDecoder().decode(PhResult.self, from: summary)
|
||||||
|
status = String(format: "pH reference loaded (%.2f)", phRef?.ph ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
status = "Reference load failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "appicon-1024.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
/// CoreBluetooth BLE MIDI connection manager.
|
||||||
|
/// Scans for "EIS4", connects, subscribes to MIDI notifications,
|
||||||
|
/// parses incoming BLE MIDI packets into EisMessage values.
|
||||||
|
|
||||||
|
import CoreBluetooth
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class BLEManager: NSObject {
|
||||||
|
|
||||||
|
static let midiServiceUUID = CBUUID(string: "03B80E5A-EDE8-4B33-A751-6CE34EC4C700")
|
||||||
|
static let midiCharUUID = CBUUID(string: "7772E5DB-3868-4112-A1A9-F2669D106BF3")
|
||||||
|
|
||||||
|
enum ConnectionState: String {
|
||||||
|
case disconnected = "Disconnected"
|
||||||
|
case scanning = "Scanning..."
|
||||||
|
case connecting = "Connecting..."
|
||||||
|
case connected = "Connected"
|
||||||
|
}
|
||||||
|
|
||||||
|
var state: ConnectionState = .disconnected
|
||||||
|
var lastMessage: EisMessage?
|
||||||
|
|
||||||
|
private var centralManager: CBCentralManager!
|
||||||
|
private var peripheral: CBPeripheral?
|
||||||
|
private var midiCharacteristic: CBCharacteristic?
|
||||||
|
private var onMessage: ((EisMessage) -> Void)?
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
centralManager = CBCentralManager(delegate: self, queue: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMessageHandler(_ handler: @escaping (EisMessage) -> Void) {
|
||||||
|
onMessage = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func startScanning() {
|
||||||
|
guard centralManager.state == .poweredOn else { return }
|
||||||
|
state = .scanning
|
||||||
|
centralManager.scanForPeripherals(
|
||||||
|
withServices: [Self.midiServiceUUID],
|
||||||
|
options: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect() {
|
||||||
|
if let p = peripheral {
|
||||||
|
centralManager.cancelPeripheralConnection(p)
|
||||||
|
}
|
||||||
|
peripheral = nil
|
||||||
|
midiCharacteristic = nil
|
||||||
|
state = .disconnected
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendCommand(_ sysex: [UInt8]) {
|
||||||
|
guard let char = midiCharacteristic, let p = peripheral else { return }
|
||||||
|
let packet = Self.wrapBLEMIDI(sysex)
|
||||||
|
p.writeValue(packet, for: char, type: .withoutResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BLE MIDI packet handling
|
||||||
|
|
||||||
|
/// Wrap raw SysEx into a BLE MIDI packet.
|
||||||
|
/// Input: [F0, 7D, CMD, ...payload, F7]
|
||||||
|
/// Output: [0x80, 0x80, F0, 7D, CMD, ...payload, 0x80, F7]
|
||||||
|
static func wrapBLEMIDI(_ sysex: [UInt8]) -> Data {
|
||||||
|
var pkt: [UInt8] = [0x80, 0x80]
|
||||||
|
pkt.append(contentsOf: sysex.dropLast())
|
||||||
|
pkt.append(0x80)
|
||||||
|
pkt.append(0xF7)
|
||||||
|
return Data(pkt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract SysEx payload from BLE MIDI notification.
|
||||||
|
/// Returns bytes between F0 and F7 exclusive, stripping timestamps.
|
||||||
|
/// Result: [7D, RSP_ID, ...data]
|
||||||
|
static func extractSysEx(from data: Data) -> [UInt8]? {
|
||||||
|
let bytes = Array(data)
|
||||||
|
guard bytes.count >= 5 else { return nil }
|
||||||
|
|
||||||
|
var i = 1 // skip header
|
||||||
|
while i < bytes.count {
|
||||||
|
let b = bytes[i]
|
||||||
|
if b == 0xF0 { break }
|
||||||
|
if b & 0x80 != 0 && b != 0xF7 { i += 1; continue }
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
guard i < bytes.count, bytes[i] == 0xF0 else { return nil }
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
var payload: [UInt8] = []
|
||||||
|
while i < bytes.count && bytes[i] != 0xF7 {
|
||||||
|
if bytes[i] & 0x80 != 0 { i += 1; continue }
|
||||||
|
payload.append(bytes[i])
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
guard !payload.isEmpty else { return nil }
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CBCentralManagerDelegate
|
||||||
|
|
||||||
|
extension BLEManager: CBCentralManagerDelegate {
|
||||||
|
|
||||||
|
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||||
|
if central.state == .poweredOn {
|
||||||
|
startScanning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func centralManager(
|
||||||
|
_ central: CBCentralManager,
|
||||||
|
didDiscover peripheral: CBPeripheral,
|
||||||
|
advertisementData: [String: Any],
|
||||||
|
rssi RSSI: NSNumber
|
||||||
|
) {
|
||||||
|
guard peripheral.name == "EIS4" else { return }
|
||||||
|
central.stopScan()
|
||||||
|
self.peripheral = peripheral
|
||||||
|
state = .connecting
|
||||||
|
central.connect(peripheral, options: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||||
|
peripheral.delegate = self
|
||||||
|
peripheral.discoverServices([Self.midiServiceUUID])
|
||||||
|
}
|
||||||
|
|
||||||
|
func centralManager(
|
||||||
|
_ central: CBCentralManager,
|
||||||
|
didDisconnectPeripheral peripheral: CBPeripheral,
|
||||||
|
error: Error?
|
||||||
|
) {
|
||||||
|
self.peripheral = nil
|
||||||
|
self.midiCharacteristic = nil
|
||||||
|
state = .disconnected
|
||||||
|
startScanning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CBPeripheralDelegate
|
||||||
|
|
||||||
|
extension BLEManager: CBPeripheralDelegate {
|
||||||
|
|
||||||
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||||
|
guard let service = peripheral.services?.first(where: {
|
||||||
|
$0.uuid == Self.midiServiceUUID
|
||||||
|
}) else { return }
|
||||||
|
peripheral.discoverCharacteristics([Self.midiCharUUID], for: service)
|
||||||
|
}
|
||||||
|
|
||||||
|
func peripheral(
|
||||||
|
_ peripheral: CBPeripheral,
|
||||||
|
didDiscoverCharacteristicsFor service: CBService,
|
||||||
|
error: Error?
|
||||||
|
) {
|
||||||
|
guard let char = service.characteristics?.first(where: {
|
||||||
|
$0.uuid == Self.midiCharUUID
|
||||||
|
}) else { return }
|
||||||
|
midiCharacteristic = char
|
||||||
|
peripheral.setNotifyValue(true, for: char)
|
||||||
|
}
|
||||||
|
|
||||||
|
func peripheral(
|
||||||
|
_ peripheral: CBPeripheral,
|
||||||
|
didUpdateNotificationStateFor characteristic: CBCharacteristic,
|
||||||
|
error: Error?
|
||||||
|
) {
|
||||||
|
if characteristic.isNotifying {
|
||||||
|
state = .connected
|
||||||
|
sendCommand(buildSysexGetConfig())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func peripheral(
|
||||||
|
_ peripheral: CBPeripheral,
|
||||||
|
didUpdateValueFor characteristic: CBCharacteristic,
|
||||||
|
error: Error?
|
||||||
|
) {
|
||||||
|
guard let data = characteristic.value,
|
||||||
|
let sysex = Self.extractSysEx(from: data),
|
||||||
|
let msg = parseSysex(sysex) else { return }
|
||||||
|
lastMessage = msg
|
||||||
|
onMessage?(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict/>
|
||||||
|
</plist>
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct CueIOSApp: App {
|
||||||
|
@State private var ble = BLEManager()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
|
<string>EIS4 uses Bluetooth to communicate with the impedance analyzer</string>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>bluetooth-central</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
/// EIS4 measurement data types and hardware enums.
|
||||||
|
/// All types are Codable for GRDB/JSON storage.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Measurement points
|
||||||
|
|
||||||
|
struct EisPoint: Codable, Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
var freqHz: Float
|
||||||
|
var magOhms: Float
|
||||||
|
var phaseDeg: Float
|
||||||
|
var zReal: Float
|
||||||
|
var zImag: Float
|
||||||
|
var rtiaMagBefore: Float
|
||||||
|
var rtiaMagAfter: Float
|
||||||
|
var revMag: Float
|
||||||
|
var revPhase: Float
|
||||||
|
var pctErr: Float
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case freqHz, magOhms, phaseDeg, zReal, zImag
|
||||||
|
case rtiaMagBefore, rtiaMagAfter, revMag, revPhase, pctErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LsvPoint: Codable, Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
var vMv: Float
|
||||||
|
var iUa: Float
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case vMv, iUa
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AmpPoint: Codable, Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
var tMs: Float
|
||||||
|
var iUa: Float
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case tMs, iUa
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClPoint: Codable, Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
var tMs: Float
|
||||||
|
var iUa: Float
|
||||||
|
var phase: UInt8
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case tMs, iUa, phase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClResult: Codable {
|
||||||
|
var iFreeUa: Float
|
||||||
|
var iTotalUa: Float
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PhResult: Codable {
|
||||||
|
var vOcpMv: Float
|
||||||
|
var ph: Float
|
||||||
|
var tempC: Float
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Config
|
||||||
|
|
||||||
|
struct EisConfig: Codable {
|
||||||
|
var freqStart: Float
|
||||||
|
var freqStop: Float
|
||||||
|
var ppd: UInt16
|
||||||
|
var rtia: Rtia
|
||||||
|
var rcal: Rcal
|
||||||
|
var electrode: Electrode
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hardware enums
|
||||||
|
|
||||||
|
enum Rtia: UInt8, Codable, CaseIterable, Identifiable {
|
||||||
|
var id: UInt8 { rawValue }
|
||||||
|
|
||||||
|
case r200 = 0
|
||||||
|
case r1K = 1
|
||||||
|
case r5K = 2
|
||||||
|
case r10K = 3
|
||||||
|
case r20K = 4
|
||||||
|
case r40K = 5
|
||||||
|
case r80K = 6
|
||||||
|
case r160K = 7
|
||||||
|
case extDe0 = 8
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .r200: "200\u{2126}"
|
||||||
|
case .r1K: "1k\u{2126}"
|
||||||
|
case .r5K: "5k\u{2126}"
|
||||||
|
case .r10K: "10k\u{2126}"
|
||||||
|
case .r20K: "20k\u{2126}"
|
||||||
|
case .r40K: "40k\u{2126}"
|
||||||
|
case .r80K: "80k\u{2126}"
|
||||||
|
case .r160K: "160k\u{2126}"
|
||||||
|
case .extDe0: "Ext 3k\u{2126} (DE0)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Rcal: UInt8, Codable, CaseIterable, Identifiable {
|
||||||
|
var id: UInt8 { rawValue }
|
||||||
|
|
||||||
|
case r200 = 0
|
||||||
|
case r3K = 1
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .r200: "200\u{2126} (RCAL0\u{2194}RCAL1)"
|
||||||
|
case .r3K: "3k\u{2126} (RCAL0\u{2194}AIN0)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Electrode: UInt8, Codable, CaseIterable, Identifiable {
|
||||||
|
var id: UInt8 { rawValue }
|
||||||
|
|
||||||
|
case fourWire = 0
|
||||||
|
case threeWire = 1
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .fourWire: "4-wire (AIN)"
|
||||||
|
case .threeWire: "3-wire (CE0/RE0/SE0)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LpRtia: UInt8, Codable, CaseIterable, Identifiable {
|
||||||
|
var id: UInt8 { rawValue }
|
||||||
|
|
||||||
|
case r200 = 0
|
||||||
|
case r1K = 1
|
||||||
|
case r2K = 2
|
||||||
|
case r3K = 3
|
||||||
|
case r4K = 4
|
||||||
|
case r6K = 5
|
||||||
|
case r8K = 6
|
||||||
|
case r10K = 7
|
||||||
|
case r12K = 8
|
||||||
|
case r16K = 9
|
||||||
|
case r20K = 10
|
||||||
|
case r24K = 11
|
||||||
|
case r30K = 12
|
||||||
|
case r32K = 13
|
||||||
|
case r40K = 14
|
||||||
|
case r48K = 15
|
||||||
|
case r64K = 16
|
||||||
|
case r85K = 17
|
||||||
|
case r96K = 18
|
||||||
|
case r100K = 19
|
||||||
|
case r120K = 20
|
||||||
|
case r128K = 21
|
||||||
|
case r160K = 22
|
||||||
|
case r196K = 23
|
||||||
|
case r256K = 24
|
||||||
|
case r512K = 25
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .r200: "200\u{2126}"
|
||||||
|
case .r1K: "1k\u{2126}"
|
||||||
|
case .r2K: "2k\u{2126}"
|
||||||
|
case .r3K: "3k\u{2126}"
|
||||||
|
case .r4K: "4k\u{2126}"
|
||||||
|
case .r6K: "6k\u{2126}"
|
||||||
|
case .r8K: "8k\u{2126}"
|
||||||
|
case .r10K: "10k\u{2126}"
|
||||||
|
case .r12K: "12k\u{2126}"
|
||||||
|
case .r16K: "16k\u{2126}"
|
||||||
|
case .r20K: "20k\u{2126}"
|
||||||
|
case .r24K: "24k\u{2126}"
|
||||||
|
case .r30K: "30k\u{2126}"
|
||||||
|
case .r32K: "32k\u{2126}"
|
||||||
|
case .r40K: "40k\u{2126}"
|
||||||
|
case .r48K: "48k\u{2126}"
|
||||||
|
case .r64K: "64k\u{2126}"
|
||||||
|
case .r85K: "85k\u{2126}"
|
||||||
|
case .r96K: "96k\u{2126}"
|
||||||
|
case .r100K: "100k\u{2126}"
|
||||||
|
case .r120K: "120k\u{2126}"
|
||||||
|
case .r128K: "128k\u{2126}"
|
||||||
|
case .r160K: "160k\u{2126}"
|
||||||
|
case .r196K: "196k\u{2126}"
|
||||||
|
case .r256K: "256k\u{2126}"
|
||||||
|
case .r512K: "512k\u{2126}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Measurement type tag
|
||||||
|
|
||||||
|
enum MeasurementType: String, Codable {
|
||||||
|
case eis
|
||||||
|
case lsv
|
||||||
|
case amp
|
||||||
|
case chlorine
|
||||||
|
case ph
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,355 @@
|
||||||
|
/// EIS4 SysEx Protocol — manufacturer ID 0x7D (non-commercial)
|
||||||
|
/// Port of cue/src/protocol.rs
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
|
||||||
|
let sysexMfr: UInt8 = 0x7D
|
||||||
|
|
||||||
|
// ESP32 -> Cue
|
||||||
|
let RSP_SWEEP_START: UInt8 = 0x01
|
||||||
|
let RSP_DATA_POINT: UInt8 = 0x02
|
||||||
|
let RSP_SWEEP_END: UInt8 = 0x03
|
||||||
|
let RSP_CONFIG: UInt8 = 0x04
|
||||||
|
let RSP_LSV_START: UInt8 = 0x05
|
||||||
|
let RSP_LSV_POINT: UInt8 = 0x06
|
||||||
|
let RSP_LSV_END: UInt8 = 0x07
|
||||||
|
let RSP_AMP_START: UInt8 = 0x08
|
||||||
|
let RSP_AMP_POINT: UInt8 = 0x09
|
||||||
|
let RSP_AMP_END: UInt8 = 0x0A
|
||||||
|
let RSP_CL_START: UInt8 = 0x0B
|
||||||
|
let RSP_CL_POINT: UInt8 = 0x0C
|
||||||
|
let RSP_CL_RESULT: UInt8 = 0x0D
|
||||||
|
let RSP_CL_END: UInt8 = 0x0E
|
||||||
|
let RSP_PH_RESULT: UInt8 = 0x0F
|
||||||
|
let RSP_TEMP: UInt8 = 0x10
|
||||||
|
let RSP_REF_FRAME: UInt8 = 0x20
|
||||||
|
let RSP_REF_LP_RANGE: UInt8 = 0x21
|
||||||
|
let RSP_REFS_DONE: UInt8 = 0x22
|
||||||
|
let RSP_REF_STATUS: UInt8 = 0x23
|
||||||
|
|
||||||
|
// Cue -> ESP32
|
||||||
|
let CMD_SET_SWEEP: UInt8 = 0x10
|
||||||
|
let CMD_SET_RTIA: UInt8 = 0x11
|
||||||
|
let CMD_SET_RCAL: UInt8 = 0x12
|
||||||
|
let CMD_START_SWEEP: UInt8 = 0x13
|
||||||
|
let CMD_GET_CONFIG: UInt8 = 0x14
|
||||||
|
let CMD_SET_ELECTRODE: UInt8 = 0x15
|
||||||
|
let CMD_GET_TEMP: UInt8 = 0x17
|
||||||
|
let CMD_START_LSV: UInt8 = 0x20
|
||||||
|
let CMD_START_AMP: UInt8 = 0x21
|
||||||
|
let CMD_STOP_AMP: UInt8 = 0x22
|
||||||
|
let CMD_START_CL: UInt8 = 0x23
|
||||||
|
let CMD_START_PH: UInt8 = 0x24
|
||||||
|
let CMD_START_CLEAN: UInt8 = 0x25
|
||||||
|
let CMD_START_REFS: UInt8 = 0x30
|
||||||
|
let CMD_GET_REFS: UInt8 = 0x31
|
||||||
|
let CMD_CLEAR_REFS: UInt8 = 0x32
|
||||||
|
|
||||||
|
// MARK: - 7-bit MIDI encoding
|
||||||
|
|
||||||
|
/// Encode an IEEE 754 float into 5 MIDI-safe bytes.
|
||||||
|
/// Byte 0: mask of high bits from each original byte.
|
||||||
|
/// Bytes 1-4: original bytes with bit 7 stripped.
|
||||||
|
func encodeFloat(_ val: Float) -> [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 an IEEE 754 float.
|
||||||
|
func decodeFloat(_ d: [UInt8], at offset: Int = 0) -> Float {
|
||||||
|
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: Float = 0
|
||||||
|
withUnsafeMutableBytes(of: &val) { buf in
|
||||||
|
buf[0] = b0; buf[1] = b1; buf[2] = b2; buf[3] = b3
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode a UInt16 into 3 MIDI-safe bytes.
|
||||||
|
func encodeU16(_ val: UInt16) -> [UInt8] {
|
||||||
|
var v = val
|
||||||
|
let p = withUnsafeBytes(of: &v) { Array($0) }
|
||||||
|
let mask: UInt8 = ((p[0] >> 7) & 1) | ((p[1] >> 6) & 2)
|
||||||
|
return [mask, p[0] & 0x7F, p[1] & 0x7F]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode 3 MIDI-safe bytes back into a UInt16.
|
||||||
|
func decodeU16(_ d: [UInt8], at offset: Int = 0) -> UInt16 {
|
||||||
|
let m = d[offset]
|
||||||
|
let b0 = d[offset + 1] | ((m & 1) << 7)
|
||||||
|
let b1 = d[offset + 2] | ((m & 2) << 6)
|
||||||
|
var val: UInt16 = 0
|
||||||
|
withUnsafeMutableBytes(of: &val) { buf in
|
||||||
|
buf[0] = b0; buf[1] = b1
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Message enum
|
||||||
|
|
||||||
|
enum EisMessage {
|
||||||
|
case sweepStart(numPoints: UInt16, freqStart: Float, freqStop: Float)
|
||||||
|
case dataPoint(index: UInt16, point: EisPoint)
|
||||||
|
case sweepEnd
|
||||||
|
case config(EisConfig)
|
||||||
|
case lsvStart(numPoints: UInt16, vStart: Float, vStop: Float)
|
||||||
|
case lsvPoint(index: UInt16, point: LsvPoint)
|
||||||
|
case lsvEnd
|
||||||
|
case ampStart(vHold: Float)
|
||||||
|
case ampPoint(index: UInt16, point: AmpPoint)
|
||||||
|
case ampEnd
|
||||||
|
case clStart(numPoints: UInt16)
|
||||||
|
case clPoint(index: UInt16, point: ClPoint)
|
||||||
|
case clResult(ClResult)
|
||||||
|
case clEnd
|
||||||
|
case phResult(PhResult)
|
||||||
|
case temperature(Float)
|
||||||
|
case refFrame(mode: UInt8, rtiaIdx: UInt8)
|
||||||
|
case refLpRange(mode: UInt8, lowIdx: UInt8, highIdx: UInt8)
|
||||||
|
case refsDone
|
||||||
|
case refStatus(hasRefs: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Response parser
|
||||||
|
|
||||||
|
/// Parse a SysEx payload (after BLE MIDI unwrapping).
|
||||||
|
/// Input: [0x7D, RSP_ID, ...payload_bytes...]
|
||||||
|
func parseSysex(_ data: [UInt8]) -> EisMessage? {
|
||||||
|
guard data.count >= 2, data[0] == sysexMfr else { return nil }
|
||||||
|
let p = Array(data.dropFirst(2))
|
||||||
|
|
||||||
|
switch data[1] {
|
||||||
|
|
||||||
|
case RSP_SWEEP_START where p.count >= 13:
|
||||||
|
return .sweepStart(
|
||||||
|
numPoints: decodeU16(p, at: 0),
|
||||||
|
freqStart: decodeFloat(p, at: 3),
|
||||||
|
freqStop: decodeFloat(p, at: 8)
|
||||||
|
)
|
||||||
|
|
||||||
|
case RSP_DATA_POINT where p.count >= 28:
|
||||||
|
let ext = p.count >= 53
|
||||||
|
return .dataPoint(
|
||||||
|
index: decodeU16(p, at: 0),
|
||||||
|
point: EisPoint(
|
||||||
|
freqHz: decodeFloat(p, at: 3),
|
||||||
|
magOhms: decodeFloat(p, at: 8),
|
||||||
|
phaseDeg: decodeFloat(p, at: 13),
|
||||||
|
zReal: decodeFloat(p, at: 18),
|
||||||
|
zImag: decodeFloat(p, at: 23),
|
||||||
|
rtiaMagBefore: ext ? decodeFloat(p, at: 28) : 0,
|
||||||
|
rtiaMagAfter: ext ? decodeFloat(p, at: 33) : 0,
|
||||||
|
revMag: ext ? decodeFloat(p, at: 38) : 0,
|
||||||
|
revPhase: ext ? decodeFloat(p, at: 43) : 0,
|
||||||
|
pctErr: ext ? decodeFloat(p, at: 48) : 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
case RSP_SWEEP_END:
|
||||||
|
return .sweepEnd
|
||||||
|
|
||||||
|
case RSP_CONFIG where p.count >= 16:
|
||||||
|
return .config(EisConfig(
|
||||||
|
freqStart: decodeFloat(p, at: 0),
|
||||||
|
freqStop: decodeFloat(p, at: 5),
|
||||||
|
ppd: decodeU16(p, at: 10),
|
||||||
|
rtia: Rtia(rawValue: p[13]) ?? .r5K,
|
||||||
|
rcal: Rcal(rawValue: p[14]) ?? .r3K,
|
||||||
|
electrode: Electrode(rawValue: p[15]) ?? .fourWire
|
||||||
|
))
|
||||||
|
|
||||||
|
case RSP_LSV_START where p.count >= 13:
|
||||||
|
return .lsvStart(
|
||||||
|
numPoints: decodeU16(p, at: 0),
|
||||||
|
vStart: decodeFloat(p, at: 3),
|
||||||
|
vStop: decodeFloat(p, at: 8)
|
||||||
|
)
|
||||||
|
|
||||||
|
case RSP_LSV_POINT where p.count >= 13:
|
||||||
|
return .lsvPoint(
|
||||||
|
index: decodeU16(p, at: 0),
|
||||||
|
point: LsvPoint(
|
||||||
|
vMv: decodeFloat(p, at: 3),
|
||||||
|
iUa: decodeFloat(p, at: 8)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
case RSP_LSV_END:
|
||||||
|
return .lsvEnd
|
||||||
|
|
||||||
|
case RSP_AMP_START where p.count >= 5:
|
||||||
|
return .ampStart(vHold: decodeFloat(p, at: 0))
|
||||||
|
|
||||||
|
case RSP_AMP_POINT where p.count >= 13:
|
||||||
|
return .ampPoint(
|
||||||
|
index: decodeU16(p, at: 0),
|
||||||
|
point: AmpPoint(
|
||||||
|
tMs: decodeFloat(p, at: 3),
|
||||||
|
iUa: decodeFloat(p, at: 8)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
case RSP_AMP_END:
|
||||||
|
return .ampEnd
|
||||||
|
|
||||||
|
case RSP_CL_START where p.count >= 3:
|
||||||
|
return .clStart(numPoints: decodeU16(p, at: 0))
|
||||||
|
|
||||||
|
case RSP_CL_POINT where p.count >= 14:
|
||||||
|
return .clPoint(
|
||||||
|
index: decodeU16(p, at: 0),
|
||||||
|
point: ClPoint(
|
||||||
|
tMs: decodeFloat(p, at: 3),
|
||||||
|
iUa: decodeFloat(p, at: 8),
|
||||||
|
phase: p[13]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
case RSP_CL_RESULT where p.count >= 10:
|
||||||
|
return .clResult(ClResult(
|
||||||
|
iFreeUa: decodeFloat(p, at: 0),
|
||||||
|
iTotalUa: decodeFloat(p, at: 5)
|
||||||
|
))
|
||||||
|
|
||||||
|
case RSP_CL_END:
|
||||||
|
return .clEnd
|
||||||
|
|
||||||
|
case RSP_PH_RESULT where p.count >= 15:
|
||||||
|
return .phResult(PhResult(
|
||||||
|
vOcpMv: decodeFloat(p, at: 0),
|
||||||
|
ph: decodeFloat(p, at: 5),
|
||||||
|
tempC: decodeFloat(p, at: 10)
|
||||||
|
))
|
||||||
|
|
||||||
|
case RSP_TEMP where p.count >= 5:
|
||||||
|
return .temperature(decodeFloat(p, at: 0))
|
||||||
|
|
||||||
|
case RSP_REF_FRAME where p.count >= 2:
|
||||||
|
return .refFrame(mode: p[0], rtiaIdx: p[1])
|
||||||
|
|
||||||
|
case RSP_REF_LP_RANGE where p.count >= 3:
|
||||||
|
return .refLpRange(mode: p[0], lowIdx: p[1], highIdx: p[2])
|
||||||
|
|
||||||
|
case RSP_REFS_DONE:
|
||||||
|
return .refsDone
|
||||||
|
|
||||||
|
case RSP_REF_STATUS where p.count >= 1:
|
||||||
|
return .refStatus(hasRefs: p[0] != 0)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Command builders
|
||||||
|
|
||||||
|
func buildSysexSetSweep(freqStart: Float, freqStop: Float, ppd: UInt16) -> [UInt8] {
|
||||||
|
var sx: [UInt8] = [0xF0, sysexMfr, CMD_SET_SWEEP]
|
||||||
|
sx.append(contentsOf: encodeFloat(freqStart))
|
||||||
|
sx.append(contentsOf: encodeFloat(freqStop))
|
||||||
|
sx.append(contentsOf: encodeU16(ppd))
|
||||||
|
sx.append(0xF7)
|
||||||
|
return sx
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSysexSetRtia(_ rtia: Rtia) -> [UInt8] {
|
||||||
|
[0xF0, sysexMfr, CMD_SET_RTIA, rtia.rawValue, 0xF7]
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSysexSetRcal(_ rcal: Rcal) -> [UInt8] {
|
||||||
|
[0xF0, sysexMfr, CMD_SET_RCAL, rcal.rawValue, 0xF7]
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSysexSetElectrode(_ e: Electrode) -> [UInt8] {
|
||||||
|
[0xF0, sysexMfr, CMD_SET_ELECTRODE, e.rawValue, 0xF7]
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSysexStartSweep() -> [UInt8] {
|
||||||
|
[0xF0, sysexMfr, CMD_START_SWEEP, 0xF7]
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSysexGetConfig() -> [UInt8] {
|
||||||
|
[0xF0, sysexMfr, CMD_GET_CONFIG, 0xF7]
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSysexStartLsv(vStart: Float, vStop: Float, scanRate: Float, lpRtia: LpRtia) -> [UInt8] {
|
||||||
|
var sx: [UInt8] = [0xF0, sysexMfr, CMD_START_LSV]
|
||||||
|
sx.append(contentsOf: encodeFloat(vStart))
|
||||||
|
sx.append(contentsOf: encodeFloat(vStop))
|
||||||
|
sx.append(contentsOf: encodeFloat(scanRate))
|
||||||
|
sx.append(lpRtia.rawValue)
|
||||||
|
sx.append(0xF7)
|
||||||
|
return sx
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSysexStartAmp(vHold: Float, intervalMs: Float, durationS: Float, lpRtia: LpRtia) -> [UInt8] {
|
||||||
|
var sx: [UInt8] = [0xF0, sysexMfr, CMD_START_AMP]
|
||||||
|
sx.append(contentsOf: encodeFloat(vHold))
|
||||||
|
sx.append(contentsOf: encodeFloat(intervalMs))
|
||||||
|
sx.append(contentsOf: encodeFloat(durationS))
|
||||||
|
sx.append(lpRtia.rawValue)
|
||||||
|
sx.append(0xF7)
|
||||||
|
return sx
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSysexStopAmp() -> [UInt8] {
|
||||||
|
[0xF0, sysexMfr, CMD_STOP_AMP, 0xF7]
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSysexStartCl(
|
||||||
|
vCond: Float, tCondMs: Float, vFree: Float, vTotal: Float,
|
||||||
|
tDepMs: Float, tMeasMs: Float, lpRtia: LpRtia
|
||||||
|
) -> [UInt8] {
|
||||||
|
var sx: [UInt8] = [0xF0, sysexMfr, CMD_START_CL]
|
||||||
|
sx.append(contentsOf: encodeFloat(vCond))
|
||||||
|
sx.append(contentsOf: encodeFloat(tCondMs))
|
||||||
|
sx.append(contentsOf: encodeFloat(vFree))
|
||||||
|
sx.append(contentsOf: encodeFloat(vTotal))
|
||||||
|
sx.append(contentsOf: encodeFloat(tDepMs))
|
||||||
|
sx.append(contentsOf: encodeFloat(tMeasMs))
|
||||||
|
sx.append(lpRtia.rawValue)
|
||||||
|
sx.append(0xF7)
|
||||||
|
return sx
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSysexGetTemp() -> [UInt8] {
|
||||||
|
[0xF0, sysexMfr, CMD_GET_TEMP, 0xF7]
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSysexStartPh(stabilizeS: Float) -> [UInt8] {
|
||||||
|
var sx: [UInt8] = [0xF0, sysexMfr, CMD_START_PH]
|
||||||
|
sx.append(contentsOf: encodeFloat(stabilizeS))
|
||||||
|
sx.append(0xF7)
|
||||||
|
return sx
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSysexStartClean(vMv: Float, durationS: Float) -> [UInt8] {
|
||||||
|
var sx: [UInt8] = [0xF0, sysexMfr, CMD_START_CLEAN]
|
||||||
|
sx.append(contentsOf: encodeFloat(vMv))
|
||||||
|
sx.append(contentsOf: encodeFloat(durationS))
|
||||||
|
sx.append(0xF7)
|
||||||
|
return sx
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSysexStartRefs() -> [UInt8] {
|
||||||
|
[0xF0, sysexMfr, CMD_START_REFS, 0xF7]
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSysexGetRefs() -> [UInt8] {
|
||||||
|
[0xF0, sysexMfr, CMD_GET_REFS, 0xF7]
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSysexClearRefs() -> [UInt8] {
|
||||||
|
[0xF0, sysexMfr, CMD_CLEAR_REFS, 0xF7]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,253 @@
|
||||||
|
/// SQLite persistence via GRDB.
|
||||||
|
/// Schema: Session -> Measurement -> DataPoint
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
|
||||||
|
// MARK: - Records
|
||||||
|
|
||||||
|
struct Session: Codable, FetchableRecord, MutablePersistableRecord {
|
||||||
|
var id: Int64?
|
||||||
|
var startedAt: Date
|
||||||
|
var label: String?
|
||||||
|
var notes: String?
|
||||||
|
|
||||||
|
mutating func didInsert(_ inserted: InsertionSuccess) {
|
||||||
|
id = inserted.rowID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Measurement: Codable, FetchableRecord, MutablePersistableRecord {
|
||||||
|
var id: Int64?
|
||||||
|
var sessionId: Int64
|
||||||
|
var type: String
|
||||||
|
var startedAt: Date
|
||||||
|
var config: Data?
|
||||||
|
var resultSummary: Data?
|
||||||
|
|
||||||
|
mutating func didInsert(_ inserted: InsertionSuccess) {
|
||||||
|
id = inserted.rowID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DataPoint: Codable, FetchableRecord, MutablePersistableRecord {
|
||||||
|
var id: Int64?
|
||||||
|
var measurementId: Int64
|
||||||
|
var index: Int
|
||||||
|
var payload: Data
|
||||||
|
|
||||||
|
mutating func didInsert(_ inserted: InsertionSuccess) {
|
||||||
|
id = inserted.rowID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Database manager
|
||||||
|
|
||||||
|
final class Storage {
|
||||||
|
static let shared = Storage()
|
||||||
|
|
||||||
|
private let dbQueue: DatabaseQueue
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
let path = dir.appendingPathComponent("eis4.sqlite").path
|
||||||
|
do {
|
||||||
|
dbQueue = try DatabaseQueue(path: path)
|
||||||
|
try migrate()
|
||||||
|
} catch {
|
||||||
|
fatalError("Database init failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func migrate() throws {
|
||||||
|
var migrator = DatabaseMigrator()
|
||||||
|
|
||||||
|
migrator.registerMigration("v1") { db in
|
||||||
|
try db.create(table: "session") { t in
|
||||||
|
t.autoIncrementedPrimaryKey("id")
|
||||||
|
t.column("startedAt", .datetime).notNull()
|
||||||
|
t.column("label", .text)
|
||||||
|
t.column("notes", .text)
|
||||||
|
}
|
||||||
|
|
||||||
|
try db.create(table: "measurement") { t in
|
||||||
|
t.autoIncrementedPrimaryKey("id")
|
||||||
|
t.column("sessionId", .integer).notNull()
|
||||||
|
.references("session", onDelete: .cascade)
|
||||||
|
t.column("type", .text).notNull()
|
||||||
|
t.column("startedAt", .datetime).notNull()
|
||||||
|
t.column("config", .blob)
|
||||||
|
t.column("resultSummary", .blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
try db.create(table: "dataPoint") { t in
|
||||||
|
t.autoIncrementedPrimaryKey("id")
|
||||||
|
t.column("measurementId", .integer).notNull()
|
||||||
|
.references("measurement", onDelete: .cascade)
|
||||||
|
t.column("index", .integer).notNull()
|
||||||
|
t.column("payload", .blob).notNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try migrator.migrate(dbQueue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sessions
|
||||||
|
|
||||||
|
func createSession(label: String? = nil) throws -> Session {
|
||||||
|
try dbQueue.write { db in
|
||||||
|
var s = Session(startedAt: Date(), label: label)
|
||||||
|
try s.insert(db)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchSessions() throws -> [Session] {
|
||||||
|
try dbQueue.read { db in
|
||||||
|
try Session.order(Column("startedAt").desc).fetchAll(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteSession(_ id: Int64) throws {
|
||||||
|
try dbQueue.write { db in
|
||||||
|
try db.execute(sql: "DELETE FROM session WHERE id = ?", arguments: [id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSession(_ id: Int64, label: String?, notes: String?) throws {
|
||||||
|
try dbQueue.write { db in
|
||||||
|
try db.execute(
|
||||||
|
sql: "UPDATE session SET label = ?, notes = ? WHERE id = ?",
|
||||||
|
arguments: [label, notes, id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Measurements
|
||||||
|
|
||||||
|
func addMeasurement(
|
||||||
|
sessionId: Int64,
|
||||||
|
type: MeasurementType,
|
||||||
|
config: (any Encodable)? = nil
|
||||||
|
) throws -> Measurement {
|
||||||
|
let configData: Data? = if let config {
|
||||||
|
try JSONEncoder().encode(config)
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
return try dbQueue.write { db in
|
||||||
|
var m = Measurement(
|
||||||
|
sessionId: sessionId,
|
||||||
|
type: type.rawValue,
|
||||||
|
startedAt: Date(),
|
||||||
|
config: configData
|
||||||
|
)
|
||||||
|
try m.insert(db)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMeasurementResult(_ measurementId: Int64, result: any Encodable) throws {
|
||||||
|
try dbQueue.write { db in
|
||||||
|
let data = try JSONEncoder().encode(result)
|
||||||
|
try db.execute(
|
||||||
|
sql: "UPDATE measurement SET resultSummary = ? WHERE id = ?",
|
||||||
|
arguments: [data, measurementId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchMeasurements(sessionId: Int64) throws -> [Measurement] {
|
||||||
|
try dbQueue.read { db in
|
||||||
|
try Measurement
|
||||||
|
.filter(Column("sessionId") == sessionId)
|
||||||
|
.order(Column("startedAt").asc)
|
||||||
|
.fetchAll(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteMeasurement(_ id: Int64) throws {
|
||||||
|
try dbQueue.write { db in
|
||||||
|
try db.execute(sql: "DELETE FROM measurement WHERE id = ?", arguments: [id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dataPointCount(measurementId: Int64) throws -> Int {
|
||||||
|
try dbQueue.read { db in
|
||||||
|
try DataPoint
|
||||||
|
.filter(Column("measurementId") == measurementId)
|
||||||
|
.fetchCount(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func measurementCount(sessionId: Int64) throws -> Int {
|
||||||
|
try dbQueue.read { db in
|
||||||
|
try Measurement
|
||||||
|
.filter(Column("sessionId") == sessionId)
|
||||||
|
.fetchCount(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data points
|
||||||
|
|
||||||
|
func addDataPoint<T: Encodable>(measurementId: Int64, index: Int, point: T) throws {
|
||||||
|
let payload = try JSONEncoder().encode(point)
|
||||||
|
try dbQueue.write { db in
|
||||||
|
var dp = DataPoint(
|
||||||
|
measurementId: measurementId,
|
||||||
|
index: index,
|
||||||
|
payload: payload
|
||||||
|
)
|
||||||
|
try dp.insert(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addDataPoints<T: Encodable>(measurementId: Int64, points: [(index: Int, value: T)]) throws {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
try dbQueue.write { db in
|
||||||
|
for (idx, val) in points {
|
||||||
|
let payload = try encoder.encode(val)
|
||||||
|
var dp = DataPoint(measurementId: measurementId, index: idx, payload: payload)
|
||||||
|
try dp.insert(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchDataPoints(measurementId: Int64) throws -> [DataPoint] {
|
||||||
|
try dbQueue.read { db in
|
||||||
|
try DataPoint
|
||||||
|
.filter(Column("measurementId") == measurementId)
|
||||||
|
.order(Column("index").asc)
|
||||||
|
.fetchAll(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode stored data points into typed values.
|
||||||
|
func fetchTypedPoints<T: Decodable>(measurementId: Int64, as type: T.Type) throws -> [T] {
|
||||||
|
let rows = try fetchDataPoints(measurementId: measurementId)
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
return try rows.map { try decoder.decode(T.self, from: $0.payload) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Observation (for SwiftUI live updates)
|
||||||
|
|
||||||
|
func observeDataPoints(
|
||||||
|
measurementId: Int64,
|
||||||
|
onChange: @escaping ([DataPoint]) -> Void
|
||||||
|
) -> DatabaseCancellable {
|
||||||
|
let observation = ValueObservation.tracking { db in
|
||||||
|
try DataPoint
|
||||||
|
.filter(Column("measurementId") == measurementId)
|
||||||
|
.order(Column("index").asc)
|
||||||
|
.fetchAll(db)
|
||||||
|
}
|
||||||
|
return observation.start(in: dbQueue, onError: { _ in }, onChange: onChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
func observeSessions(onChange: @escaping ([Session]) -> Void) -> DatabaseCancellable {
|
||||||
|
let observation = ValueObservation.tracking { db in
|
||||||
|
try Session.order(Column("startedAt").desc).fetchAll(db)
|
||||||
|
}
|
||||||
|
return observation.start(in: dbQueue, onError: { _ in }, onChange: onChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
struct AmpView: View {
|
||||||
|
@Bindable var state: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
controlsRow
|
||||||
|
Divider()
|
||||||
|
GeometryReader { geo in
|
||||||
|
if geo.size.width > 700 {
|
||||||
|
HSplitLayout(ratio: 0.55) {
|
||||||
|
amperogramPlot
|
||||||
|
} trailing: {
|
||||||
|
ampTable
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
amperogramPlot.frame(height: 350)
|
||||||
|
ampTable.frame(height: 300)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Controls
|
||||||
|
|
||||||
|
private var controlsRow: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
LabeledField("V hold mV", text: $state.ampVHold, width: 80)
|
||||||
|
LabeledField("Interval ms", text: $state.ampInterval, width: 80)
|
||||||
|
LabeledField("Duration s", text: $state.ampDuration, width: 80)
|
||||||
|
|
||||||
|
LabeledPicker("RTIA", selection: $state.ampRtia, items: LpRtia.allCases) { $0.label }
|
||||||
|
.frame(width: 120)
|
||||||
|
|
||||||
|
if state.ampRunning {
|
||||||
|
Button("Stop") { state.stopAmp() }
|
||||||
|
.buttonStyle(ActionButtonStyle(color: .red))
|
||||||
|
} else {
|
||||||
|
Button("Start Amp") { state.startAmp() }
|
||||||
|
.buttonStyle(ActionButtonStyle(color: .green))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Plot
|
||||||
|
|
||||||
|
private var amperogramPlot: some View {
|
||||||
|
Group {
|
||||||
|
if state.ampPoints.isEmpty {
|
||||||
|
Text("No data")
|
||||||
|
.foregroundStyle(Color(white: 0.4))
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.black.opacity(0.3))
|
||||||
|
} else {
|
||||||
|
let ampColor = Color(red: 0.6, green: 0.6, blue: 1.0)
|
||||||
|
PlotContainer(title: "") {
|
||||||
|
Chart {
|
||||||
|
if let ref = state.ampRef {
|
||||||
|
ForEach(Array(ref.enumerated()), id: \.offset) { _, pt in
|
||||||
|
LineMark(
|
||||||
|
x: .value("t", Double(pt.tMs)),
|
||||||
|
y: .value("I", Double(pt.iUa))
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.gray.opacity(0.5))
|
||||||
|
.lineStyle(StrokeStyle(lineWidth: 1.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(Array(state.ampPoints.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(state.ampPoints.enumerated()), id: \.offset) { _, pt in
|
||||||
|
PointMark(
|
||||||
|
x: .value("t", Double(pt.tMs)),
|
||||||
|
y: .value("I", Double(pt.iUa))
|
||||||
|
)
|
||||||
|
.foregroundStyle(ampColor)
|
||||||
|
.symbolSize(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartXAxisLabel("t (ms)")
|
||||||
|
.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(ampColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.black.opacity(0.3))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Table
|
||||||
|
|
||||||
|
private var ampTable: some View {
|
||||||
|
MeasurementTable(
|
||||||
|
columns: [
|
||||||
|
MeasurementColumn(header: "t (ms)", width: 80, alignment: .trailing),
|
||||||
|
MeasurementColumn(header: "I (uA)", width: 90, alignment: .trailing),
|
||||||
|
],
|
||||||
|
rows: state.ampPoints,
|
||||||
|
cellText: { pt, col in
|
||||||
|
switch col {
|
||||||
|
case 0: String(format: "%.1f", pt.tMs)
|
||||||
|
case 1: String(format: "%.3f", pt.iUa)
|
||||||
|
default: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
struct ChlorineView: View {
|
||||||
|
@Bindable var state: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
controlsRow
|
||||||
|
Divider()
|
||||||
|
GeometryReader { geo in
|
||||||
|
if geo.size.width > 700 {
|
||||||
|
HSplitLayout(ratio: 0.55) {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
resultBanner
|
||||||
|
chlorinePlot
|
||||||
|
}
|
||||||
|
} trailing: {
|
||||||
|
clTable
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
resultBanner
|
||||||
|
chlorinePlot.frame(height: 350)
|
||||||
|
clTable.frame(height: 300)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Controls
|
||||||
|
|
||||||
|
private var controlsRow: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
LabeledField("Cond mV", text: $state.clCondV, width: 70)
|
||||||
|
LabeledField("Cond ms", text: $state.clCondT, width: 70)
|
||||||
|
LabeledField("Free mV", text: $state.clFreeV, width: 70)
|
||||||
|
LabeledField("Total mV", text: $state.clTotalV, width: 70)
|
||||||
|
LabeledField("Settle ms", text: $state.clDepT, width: 70)
|
||||||
|
LabeledField("Meas ms", text: $state.clMeasT, width: 70)
|
||||||
|
|
||||||
|
LabeledPicker("RTIA", selection: $state.clRtia, items: LpRtia.allCases) { $0.label }
|
||||||
|
.frame(width: 120)
|
||||||
|
|
||||||
|
Button("Measure") { state.startChlorine() }
|
||||||
|
.buttonStyle(ActionButtonStyle(color: .green))
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Result banner
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var resultBanner: some View {
|
||||||
|
if let r = state.clResult {
|
||||||
|
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)
|
||||||
|
|
||||||
|
if let (_, refR) = state.clRef {
|
||||||
|
Divider().frame(height: 16)
|
||||||
|
Text(String(format: "vs Ref: dFree=%.3f dTotal=%.3f",
|
||||||
|
r.iFreeUa - refR.iFreeUa,
|
||||||
|
r.iTotalUa - refR.iTotalUa))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.subheadline.monospacedDigit())
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Plot
|
||||||
|
|
||||||
|
private var chlorinePlot: some View {
|
||||||
|
Group {
|
||||||
|
if state.clPoints.isEmpty {
|
||||||
|
Text("No data")
|
||||||
|
.foregroundStyle(Color(white: 0.4))
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.black.opacity(0.3))
|
||||||
|
} else {
|
||||||
|
PlotContainer(title: "") {
|
||||||
|
chlorineCanvas
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.black.opacity(0.3))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var chlorineCanvas: some View {
|
||||||
|
Canvas { context, size in
|
||||||
|
let ml: CGFloat = 50, mr: CGFloat = 12, mt: CGFloat = 12, mb: CGFloat = 22
|
||||||
|
let xl = ml, xr = size.width - mr
|
||||||
|
let yt = mt, yb = size.height - mb
|
||||||
|
|
||||||
|
let valid = state.clPoints.filter { $0.tMs.isFinite && $0.iUa.isFinite }
|
||||||
|
guard !valid.isEmpty else { return }
|
||||||
|
|
||||||
|
let ts = valid.map { CGFloat($0.tMs) }
|
||||||
|
let is_ = valid.map { CGFloat($0.iUa) }
|
||||||
|
let (tMin, tMax) = (ts.min()!, ts.max()!)
|
||||||
|
let (iMin, iMax) = (is_.min()!, is_.max()!)
|
||||||
|
let tPad = max(tMax - tMin, 100) * 0.05
|
||||||
|
let iPad = max(iMax - iMin, 0.001) * 0.12
|
||||||
|
let xvLo = tMin - tPad, xvHi = tMax + tPad
|
||||||
|
let yvLo = iMin - iPad, yvHi = iMax + iPad
|
||||||
|
|
||||||
|
func lx(_ v: CGFloat) -> CGFloat { xl + (v - xvLo) / (xvHi - xvLo) * (xr - xl) }
|
||||||
|
func ly(_ v: CGFloat) -> CGFloat { yt + (yvHi - v) / (yvHi - yvLo) * (yb - yt) }
|
||||||
|
|
||||||
|
// grid
|
||||||
|
let gridColor = Color(white: 0.25).opacity(0.6)
|
||||||
|
drawClGrid(context: context, xl: xl, xr: xr, yt: yt, yb: yb,
|
||||||
|
xvLo: xvLo, xvHi: xvHi, yvLo: yvLo, yvHi: yvHi, gridColor: gridColor)
|
||||||
|
|
||||||
|
// axis labels
|
||||||
|
context.draw(
|
||||||
|
Text("I (uA)").font(.caption2).foregroundStyle(.secondary),
|
||||||
|
at: CGPoint(x: 20, y: yt - 2))
|
||||||
|
context.draw(
|
||||||
|
Text("t (ms)").font(.caption2).foregroundStyle(.secondary),
|
||||||
|
at: CGPoint(x: (xl + xr) / 2, y: yb + 12))
|
||||||
|
|
||||||
|
// reference
|
||||||
|
if let refPts = state.clRef?.points {
|
||||||
|
let refFree = refPts.filter { $0.phase == 1 }
|
||||||
|
.map { CGPoint(x: lx(CGFloat($0.tMs)), y: ly(CGFloat($0.iUa))) }
|
||||||
|
let refTotal = refPts.filter { $0.phase == 2 }
|
||||||
|
.map { CGPoint(x: lx(CGFloat($0.tMs)), y: ly(CGFloat($0.iUa))) }
|
||||||
|
drawClPolyline(context: context, points: refFree, color: Color.gray.opacity(0.5), width: 1.5)
|
||||||
|
drawClPolyline(context: context, points: refTotal, color: Color.gray.opacity(0.5), width: 1.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
let clFreeColor = Color(red: 0.2, green: 1, blue: 0.5)
|
||||||
|
let clTotalColor = Color(red: 1, green: 0.6, blue: 0.2)
|
||||||
|
let condColor = Color.gray
|
||||||
|
|
||||||
|
// conditioning
|
||||||
|
let condPts = valid.filter { $0.phase == 0 }
|
||||||
|
.map { CGPoint(x: lx(CGFloat($0.tMs)), y: ly(CGFloat($0.iUa))) }
|
||||||
|
drawClPolyline(context: context, points: condPts, color: condColor, width: 1.5)
|
||||||
|
|
||||||
|
// free
|
||||||
|
let freePts = valid.filter { $0.phase == 1 }
|
||||||
|
.map { CGPoint(x: lx(CGFloat($0.tMs)), y: ly(CGFloat($0.iUa))) }
|
||||||
|
drawClPolyline(context: context, points: freePts, color: clFreeColor, width: 2)
|
||||||
|
for pt in freePts {
|
||||||
|
context.fill(Path(ellipseIn: CGRect(x: pt.x - 3, y: pt.y - 3, width: 6, height: 6)),
|
||||||
|
with: .color(clFreeColor))
|
||||||
|
}
|
||||||
|
|
||||||
|
// total
|
||||||
|
let totalPts = valid.filter { $0.phase == 2 }
|
||||||
|
.map { CGPoint(x: lx(CGFloat($0.tMs)), y: ly(CGFloat($0.iUa))) }
|
||||||
|
drawClPolyline(context: context, points: totalPts, color: clTotalColor, width: 2)
|
||||||
|
for pt in totalPts {
|
||||||
|
context.fill(Path(ellipseIn: CGRect(x: pt.x - 3, y: pt.y - 3, width: 6, height: 6)),
|
||||||
|
with: .color(clTotalColor))
|
||||||
|
}
|
||||||
|
|
||||||
|
// phase boundary
|
||||||
|
if let lastFree = valid.last(where: { $0.phase == 1 }),
|
||||||
|
let firstTotal = valid.first(where: { $0.phase == 2 }) {
|
||||||
|
let bx = lx(CGFloat((lastFree.tMs + firstTotal.tMs) / 2))
|
||||||
|
if bx > xl && bx < xr {
|
||||||
|
var bp = Path()
|
||||||
|
bp.move(to: CGPoint(x: bx, y: yt))
|
||||||
|
bp.addLine(to: CGPoint(x: bx, y: yb))
|
||||||
|
context.stroke(bp, with: .color(Color(white: 0.4)), lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// legend
|
||||||
|
context.draw(
|
||||||
|
Text("Free").font(.caption2).foregroundStyle(clFreeColor),
|
||||||
|
at: CGPoint(x: xl + 25, y: yt + 10))
|
||||||
|
context.draw(
|
||||||
|
Text("Total").font(.caption2).foregroundStyle(clTotalColor),
|
||||||
|
at: CGPoint(x: xl + 70, y: yt + 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Table
|
||||||
|
|
||||||
|
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: 60, alignment: .trailing),
|
||||||
|
],
|
||||||
|
rows: state.clPoints,
|
||||||
|
cellText: { pt, col in
|
||||||
|
switch col {
|
||||||
|
case 0: String(format: "%.1f", pt.tMs)
|
||||||
|
case 1: String(format: "%.3f", pt.iUa)
|
||||||
|
case 2:
|
||||||
|
switch pt.phase {
|
||||||
|
case 1: "Free"
|
||||||
|
case 2: "Total"
|
||||||
|
default: "Cond"
|
||||||
|
}
|
||||||
|
default: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Drawing helpers (chlorine-local)
|
||||||
|
|
||||||
|
private func drawClPolyline(context: GraphicsContext, points: [CGPoint], color: Color, width: CGFloat) {
|
||||||
|
guard points.count >= 2 else { return }
|
||||||
|
var path = Path()
|
||||||
|
path.move(to: points[0])
|
||||||
|
for pt in points.dropFirst() {
|
||||||
|
guard pt.x.isFinite && pt.y.isFinite else { continue }
|
||||||
|
path.addLine(to: pt)
|
||||||
|
}
|
||||||
|
context.stroke(path, with: .color(color), lineWidth: width)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawClGrid(context: GraphicsContext, xl: CGFloat, xr: CGFloat, yt: CGFloat, yb: CGFloat,
|
||||||
|
xvLo: CGFloat, xvHi: CGFloat, yvLo: CGFloat, yvHi: CGFloat, gridColor: Color) {
|
||||||
|
func niceStep(_ range: CGFloat, _ ticks: Int) -> CGFloat {
|
||||||
|
guard abs(range) > 1e-10 else { return 1 }
|
||||||
|
let rough = range / CGFloat(ticks)
|
||||||
|
let mag = pow(10, floor(log10(abs(rough))))
|
||||||
|
let norm = abs(rough) / mag
|
||||||
|
let s: CGFloat
|
||||||
|
if norm < 1.5 { s = 1 } else if norm < 3.5 { s = 2 } else if norm < 7.5 { s = 5 } else { s = 10 }
|
||||||
|
return s * mag
|
||||||
|
}
|
||||||
|
|
||||||
|
let xs = niceStep(xvHi - xvLo, 5)
|
||||||
|
if xs > 0 {
|
||||||
|
var g = (xvLo / xs).rounded(.up) * xs
|
||||||
|
while g <= xvHi {
|
||||||
|
let x = xl + (g - xvLo) / (xvHi - xvLo) * (xr - xl)
|
||||||
|
var p = Path(); p.move(to: CGPoint(x: x, y: yt)); p.addLine(to: CGPoint(x: x, y: yb))
|
||||||
|
context.stroke(p, with: .color(gridColor), lineWidth: 0.5)
|
||||||
|
context.draw(Text(String(format: "%.0f", g)).font(.system(size: 9)).foregroundStyle(.secondary),
|
||||||
|
at: CGPoint(x: x, y: yb + 10))
|
||||||
|
g += xs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ys = niceStep(yvHi - yvLo, 4)
|
||||||
|
if ys > 0 {
|
||||||
|
var g = (yvLo / ys).rounded(.up) * ys
|
||||||
|
while g <= yvHi {
|
||||||
|
let y = yt + (yvHi - g) / (yvHi - yvLo) * (yb - yt)
|
||||||
|
var p = Path(); p.move(to: CGPoint(x: xl, y: y)); p.addLine(to: CGPoint(x: xr, y: y))
|
||||||
|
context.stroke(p, with: .color(gridColor), lineWidth: 0.5)
|
||||||
|
context.draw(Text(String(format: "%.1f", g)).font(.system(size: 9)).foregroundStyle(.secondary),
|
||||||
|
at: CGPoint(x: xl - 20, y: y))
|
||||||
|
g += ys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MeasurementColumn: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let header: String
|
||||||
|
let width: CGFloat
|
||||||
|
let alignment: Alignment
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MeasurementTable<Row: Identifiable>: View {
|
||||||
|
let columns: [MeasurementColumn]
|
||||||
|
let rows: [Row]
|
||||||
|
let cellText: (Row, Int) -> String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
headerRow
|
||||||
|
Divider().background(Color.gray.opacity(0.4))
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
ForEach(Array(rows.enumerated()), id: \.element.id) { idx, row in
|
||||||
|
dataRow(row, even: idx % 2 == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
#if os(iOS)
|
||||||
|
.background(Color(.systemBackground).opacity(0.05))
|
||||||
|
#else
|
||||||
|
.background(Color(white: 0.1).opacity(0.05))
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var headerRow: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(Array(columns.enumerated()), id: \.element.id) { idx, col in
|
||||||
|
Text(col.header)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.frame(width: col.width, alignment: col.alignment)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if idx < columns.count - 1 {
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dataRow(_ row: Row, even: Bool) -> some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(Array(columns.enumerated()), id: \.element.id) { idx, col in
|
||||||
|
Text(cellText(row, idx))
|
||||||
|
.frame(width: col.width, alignment: col.alignment)
|
||||||
|
if idx < columns.count - 1 {
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.background(even ? Color.clear : Color.white.opacity(0.03))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PlotContainer<Content: View>: View {
|
||||||
|
let title: String
|
||||||
|
@ViewBuilder let content: () -> Content
|
||||||
|
|
||||||
|
@State private var scale: CGFloat = 1.0
|
||||||
|
@State private var offset: CGSize = .zero
|
||||||
|
@State private var lastOffset: CGSize = .zero
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
content()
|
||||||
|
.scaleEffect(scale)
|
||||||
|
.offset(offset)
|
||||||
|
.gesture(dragGesture)
|
||||||
|
.gesture(magnificationGesture)
|
||||||
|
.simultaneousGesture(doubleTapReset)
|
||||||
|
.clipped()
|
||||||
|
.overlay(alignment: .topLeading) {
|
||||||
|
if !title.isEmpty {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dragGesture: some Gesture {
|
||||||
|
DragGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
offset = CGSize(
|
||||||
|
width: lastOffset.width + value.translation.width,
|
||||||
|
height: lastOffset.height + value.translation.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
lastOffset = offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var magnificationGesture: some Gesture {
|
||||||
|
MagnifyGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
scale = max(0.5, min(10.0, value.magnification))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var doubleTapReset: some Gesture {
|
||||||
|
TapGesture(count: 2)
|
||||||
|
.onEnded {
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
|
scale = 1.0
|
||||||
|
offset = .zero
|
||||||
|
lastOffset = .zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct StatusBar: View {
|
||||||
|
let state: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
connectionIndicator
|
||||||
|
Text(state.status)
|
||||||
|
.font(.subheadline)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
refButtons
|
||||||
|
|
||||||
|
Text(String(format: "%.1f\u{00B0}C", state.tempC))
|
||||||
|
.font(.subheadline.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var connectionIndicator: some View {
|
||||||
|
Circle()
|
||||||
|
.fill(state.bleConnected ? Color.green : Color.red)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var refButtons: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if !state.collectingRefs {
|
||||||
|
Button("Collect Refs") { state.collectRefs() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.green)
|
||||||
|
.controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Button("Collecting...") {}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
.disabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.hasDeviceRefs {
|
||||||
|
Button("Clear Refs") { state.clearRefs() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.red)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.hasCurrentData {
|
||||||
|
Button("Set Ref") { state.setReference() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.hasCurrentRef {
|
||||||
|
Button("Clear Ref") { state.clearReference() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.red)
|
||||||
|
.controlSize(.small)
|
||||||
|
|
||||||
|
Text("REF")
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@State private var state = AppState()
|
||||||
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if sizeClass == .regular {
|
||||||
|
iPadLayout
|
||||||
|
} else {
|
||||||
|
iPhoneLayout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - iPad: NavigationSplitView
|
||||||
|
|
||||||
|
private var iPadLayout: some View {
|
||||||
|
NavigationSplitView {
|
||||||
|
sidebar
|
||||||
|
} detail: {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
StatusBar(state: state)
|
||||||
|
Divider()
|
||||||
|
tabContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sidebar: some View {
|
||||||
|
List {
|
||||||
|
Section("Measurements") {
|
||||||
|
sidebarButton(.eis, "EIS", "waveform.path.ecg")
|
||||||
|
sidebarButton(.lsv, "LSV", "chart.xyaxis.line")
|
||||||
|
sidebarButton(.amp, "Amperometry", "bolt.fill")
|
||||||
|
sidebarButton(.chlorine, "Chlorine", "drop.fill")
|
||||||
|
sidebarButton(.ph, "pH", "scalemass")
|
||||||
|
}
|
||||||
|
Section("Data") {
|
||||||
|
sidebarButton(.sessions, "Sessions", "folder")
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
cleanControls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Cue")
|
||||||
|
.listStyle(.sidebar)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sidebarButton(_ tab: Tab, _ title: String, _ icon: String) -> some View {
|
||||||
|
Button {
|
||||||
|
state.tab = tab
|
||||||
|
} label: {
|
||||||
|
Label(title, systemImage: icon)
|
||||||
|
}
|
||||||
|
.listRowBackground(state.tab == tab ? Color.accentColor.opacity(0.2) : nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cleanControls: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Electrode Clean")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
TextField("mV", text: $state.cleanV)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 60)
|
||||||
|
#if os(iOS)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
#endif
|
||||||
|
TextField("s", text: $state.cleanDur)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 45)
|
||||||
|
#if os(iOS)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
#endif
|
||||||
|
Button("Clean") { state.startClean() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(Color(red: 0.65, green: 0.55, blue: 0.15))
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - iPhone: TabView
|
||||||
|
|
||||||
|
private var iPhoneLayout: some View {
|
||||||
|
TabView(selection: $state.tab) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
StatusBar(state: state)
|
||||||
|
EISView(state: state)
|
||||||
|
}
|
||||||
|
.tabItem { Label("EIS", systemImage: "waveform.path.ecg") }
|
||||||
|
.tag(Tab.eis)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
StatusBar(state: state)
|
||||||
|
LSVView(state: state)
|
||||||
|
}
|
||||||
|
.tabItem { Label("LSV", systemImage: "chart.xyaxis.line") }
|
||||||
|
.tag(Tab.lsv)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
StatusBar(state: state)
|
||||||
|
AmpView(state: state)
|
||||||
|
}
|
||||||
|
.tabItem { Label("Amp", systemImage: "bolt.fill") }
|
||||||
|
.tag(Tab.amp)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
StatusBar(state: state)
|
||||||
|
ChlorineView(state: state)
|
||||||
|
}
|
||||||
|
.tabItem { Label("Chlorine", systemImage: "drop.fill") }
|
||||||
|
.tag(Tab.chlorine)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
StatusBar(state: state)
|
||||||
|
PhView(state: state)
|
||||||
|
}
|
||||||
|
.tabItem { Label("pH", systemImage: "scalemass") }
|
||||||
|
.tag(Tab.ph)
|
||||||
|
|
||||||
|
SessionView(state: state)
|
||||||
|
.tabItem { Label("Sessions", systemImage: "folder") }
|
||||||
|
.tag(Tab.sessions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tab content
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var tabContent: some View {
|
||||||
|
switch state.tab {
|
||||||
|
case .eis: EISView(state: state)
|
||||||
|
case .lsv: LSVView(state: state)
|
||||||
|
case .amp: AmpView(state: state)
|
||||||
|
case .chlorine: ChlorineView(state: state)
|
||||||
|
case .ph: PhView(state: state)
|
||||||
|
case .sessions: SessionView(state: state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,614 @@
|
||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
struct EISView: View {
|
||||||
|
@Bindable var state: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
controlsRow
|
||||||
|
Divider()
|
||||||
|
GeometryReader { geo in
|
||||||
|
if geo.size.width > 700 {
|
||||||
|
wideLayout(geo: geo)
|
||||||
|
} else {
|
||||||
|
compactLayout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Controls
|
||||||
|
|
||||||
|
private var controlsRow: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
LabeledField("Start Hz", text: $state.freqStart, width: 90)
|
||||||
|
LabeledField("Stop Hz", text: $state.freqStop, width: 90)
|
||||||
|
LabeledField("PPD", text: $state.ppd, width: 50)
|
||||||
|
|
||||||
|
LabeledPicker("RTIA", selection: $state.rtia, items: Rtia.allCases) { $0.label }
|
||||||
|
.frame(width: 120)
|
||||||
|
LabeledPicker("RCAL", selection: $state.rcal, items: Rcal.allCases) { $0.label }
|
||||||
|
.frame(width: 170)
|
||||||
|
LabeledPicker("Electrodes", selection: $state.electrode, items: Electrode.allCases) { $0.label }
|
||||||
|
.frame(width: 180)
|
||||||
|
|
||||||
|
Button("Apply") { state.applyEISSettings() }
|
||||||
|
.buttonStyle(ActionButtonStyle(color: .blue))
|
||||||
|
Button("Sweep") { state.startSweep() }
|
||||||
|
.buttonStyle(ActionButtonStyle(color: .green))
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wide layout (iPad)
|
||||||
|
|
||||||
|
private func wideLayout(geo: GeometryProxy) -> some View {
|
||||||
|
HSplitLayout(ratio: 0.55) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
bodePlot
|
||||||
|
nyquistPlot
|
||||||
|
}
|
||||||
|
.padding(4)
|
||||||
|
}
|
||||||
|
} trailing: {
|
||||||
|
eisTable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Compact layout (iPhone)
|
||||||
|
|
||||||
|
private var compactLayout: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
bodePlot.frame(height: 300)
|
||||||
|
nyquistPlot.frame(height: 300)
|
||||||
|
eisTable.frame(height: 300)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bode plot
|
||||||
|
|
||||||
|
private var bodePlot: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if state.eisPoints.isEmpty {
|
||||||
|
noDataPlaceholder
|
||||||
|
} else {
|
||||||
|
PlotContainer(title: "") {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
magnitudePlot
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
Divider()
|
||||||
|
phasePlot
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.black.opacity(0.3))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var magnitudePlot: some View {
|
||||||
|
Chart {
|
||||||
|
if let ref = state.eisRef {
|
||||||
|
ForEach(Array(ref.enumerated()), id: \.offset) { _, pt in
|
||||||
|
LineMark(
|
||||||
|
x: .value("Freq", log10(max(Double(pt.freqHz), 1))),
|
||||||
|
y: .value("|Z|", Double(pt.magOhms))
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.gray.opacity(0.5))
|
||||||
|
.lineStyle(StrokeStyle(lineWidth: 1.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(Array(state.eisPoints.enumerated()), id: \.offset) { _, pt in
|
||||||
|
LineMark(
|
||||||
|
x: .value("Freq", log10(max(Double(pt.freqHz), 1))),
|
||||||
|
y: .value("|Z|", Double(pt.magOhms))
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.cyan)
|
||||||
|
.lineStyle(StrokeStyle(lineWidth: 2))
|
||||||
|
}
|
||||||
|
ForEach(Array(state.eisPoints.enumerated()), id: \.offset) { _, pt in
|
||||||
|
PointMark(
|
||||||
|
x: .value("Freq", log10(max(Double(pt.freqHz), 1))),
|
||||||
|
y: .value("|Z|", Double(pt.magOhms))
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.cyan)
|
||||||
|
.symbolSize(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartXAxisLabel("|Z| (Ohm)", alignment: .leading)
|
||||||
|
.chartXAxis {
|
||||||
|
AxisMarks(values: .automatic) { value in
|
||||||
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||||
|
.foregroundStyle(Color.gray.opacity(0.3))
|
||||||
|
AxisValueLabel {
|
||||||
|
if let v = value.as(Double.self) {
|
||||||
|
Text(freqLabel(v))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartYAxisLabel("|Z|", position: .leading)
|
||||||
|
.chartYAxis {
|
||||||
|
AxisMarks(position: .leading) { _ in
|
||||||
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||||
|
.foregroundStyle(Color.gray.opacity(0.3))
|
||||||
|
AxisValueLabel()
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.cyan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var phasePlot: some View {
|
||||||
|
Chart {
|
||||||
|
if let ref = state.eisRef {
|
||||||
|
ForEach(Array(ref.enumerated()), id: \.offset) { _, pt in
|
||||||
|
LineMark(
|
||||||
|
x: .value("Freq", log10(max(Double(pt.freqHz), 1))),
|
||||||
|
y: .value("Phase", Double(pt.phaseDeg))
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.gray.opacity(0.5))
|
||||||
|
.lineStyle(StrokeStyle(lineWidth: 1.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(Array(state.eisPoints.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(state.eisPoints.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("Phase (deg)", alignment: .leading)
|
||||||
|
.chartXAxis {
|
||||||
|
AxisMarks(values: .automatic) { value in
|
||||||
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||||
|
.foregroundStyle(Color.gray.opacity(0.3))
|
||||||
|
AxisValueLabel {
|
||||||
|
if let v = value.as(Double.self) {
|
||||||
|
Text(freqLabel(v))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartYAxisLabel("Phase", position: .leading)
|
||||||
|
.chartYAxis {
|
||||||
|
AxisMarks(position: .leading) { _ in
|
||||||
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||||
|
.foregroundStyle(Color.gray.opacity(0.3))
|
||||||
|
AxisValueLabel()
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Nyquist plot
|
||||||
|
|
||||||
|
private var nyquistPlot: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if state.eisPoints.isEmpty {
|
||||||
|
noDataPlaceholder
|
||||||
|
} else {
|
||||||
|
PlotContainer(title: "") {
|
||||||
|
nyquistCanvas
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.black.opacity(0.3))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var nyquistCanvas: some View {
|
||||||
|
Canvas { context, size in
|
||||||
|
let ml: CGFloat = 50, mr: CGFloat = 12, mt: CGFloat = 12, mb: CGFloat = 22
|
||||||
|
let xl = ml, xr = size.width - mr
|
||||||
|
let yt = mt, yb = size.height - mb
|
||||||
|
|
||||||
|
let points = state.eisPoints.filter { $0.zReal.isFinite && $0.zImag.isFinite }
|
||||||
|
guard !points.isEmpty else { return }
|
||||||
|
|
||||||
|
let reVals = points.map { CGFloat($0.zReal) }
|
||||||
|
let niVals = points.map { CGFloat(-$0.zImag) }
|
||||||
|
let (reMin, reMax) = (reVals.min()!, reVals.max()!)
|
||||||
|
let (niMin, niMax) = (niVals.min()!, niVals.max()!)
|
||||||
|
let reSpan = max(reMax - reMin, 1)
|
||||||
|
let niSpan = max(niMax - niMin, 1)
|
||||||
|
let span = max(reSpan, niSpan) * 1.3
|
||||||
|
let reC = (reMin + reMax) / 2
|
||||||
|
let niC = (niMin + niMax) / 2
|
||||||
|
let xvLo = reC - span / 2, xvHi = reC + span / 2
|
||||||
|
let yvLo = niC - span / 2, yvHi = niC + span / 2
|
||||||
|
|
||||||
|
func lx(_ v: CGFloat) -> CGFloat { xl + (v - xvLo) / (xvHi - xvLo) * (xr - xl) }
|
||||||
|
func ly(_ v: CGFloat) -> CGFloat { yt + (yvHi - v) / (yvHi - yvLo) * (yb - yt) }
|
||||||
|
|
||||||
|
// grid
|
||||||
|
let gridColor = Color(white: 0.25).opacity(0.6)
|
||||||
|
drawGrid(context: context, xl: xl, xr: xr, yt: yt, yb: yb,
|
||||||
|
xvLo: xvLo, xvHi: xvHi, yvLo: yvLo, yvHi: yvHi,
|
||||||
|
gridColor: gridColor, size: size)
|
||||||
|
|
||||||
|
// zero line
|
||||||
|
let zy = ly(0)
|
||||||
|
if zy > yt && zy < yb {
|
||||||
|
var zeroPath = Path()
|
||||||
|
zeroPath.move(to: CGPoint(x: xl, y: zy))
|
||||||
|
zeroPath.addLine(to: CGPoint(x: xr, y: zy))
|
||||||
|
context.stroke(zeroPath, with: .color(.gray.opacity(0.6)), lineWidth: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// axis labels
|
||||||
|
context.draw(
|
||||||
|
Text("-Z''").font(.caption2).foregroundStyle(Color(red: 0.4, green: 1, blue: 0.4)),
|
||||||
|
at: CGPoint(x: 20, y: yt - 2))
|
||||||
|
context.draw(
|
||||||
|
Text("Z'").font(.caption2).foregroundStyle(Color(red: 0.4, green: 1, blue: 0.4)),
|
||||||
|
at: CGPoint(x: (xl + xr) / 2, y: yb + 12))
|
||||||
|
|
||||||
|
// reference
|
||||||
|
if let ref = state.eisRef {
|
||||||
|
let refPts = ref.map { CGPoint(x: lx(CGFloat($0.zReal)), y: ly(CGFloat(-$0.zImag))) }
|
||||||
|
drawPolyline(context: context, points: refPts, color: Color.gray.opacity(0.5), width: 1.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// data
|
||||||
|
let dataPts = points.map { CGPoint(x: lx(CGFloat($0.zReal)), y: ly(CGFloat(-$0.zImag))) }
|
||||||
|
let nyqColor = Color(red: 0.4, green: 1, blue: 0.4)
|
||||||
|
drawPolyline(context: context, points: dataPts, color: nyqColor, width: 2)
|
||||||
|
for pt in dataPts {
|
||||||
|
context.fill(Path(ellipseIn: CGRect(x: pt.x - 3, y: pt.y - 3, width: 6, height: 6)),
|
||||||
|
with: .color(nyqColor))
|
||||||
|
}
|
||||||
|
|
||||||
|
// circle fit
|
||||||
|
if let fit = kasaCircleFit(points: points.map { (Double($0.zReal), Double(-$0.zImag)) }) {
|
||||||
|
let disc = fit.r * fit.r - fit.cy * fit.cy
|
||||||
|
if disc > 0 {
|
||||||
|
let sd = sqrt(disc)
|
||||||
|
let rs = fit.cx - sd
|
||||||
|
let rp = 2 * sd
|
||||||
|
|
||||||
|
if rp > 0 {
|
||||||
|
let thetaR = atan2(-fit.cy, sd)
|
||||||
|
var thetaL = atan2(-fit.cy, -sd)
|
||||||
|
if thetaL < thetaR { thetaL += 2 * .pi }
|
||||||
|
|
||||||
|
let nArc = 120
|
||||||
|
var arcPath = Path()
|
||||||
|
for i in 0...nArc {
|
||||||
|
let t = thetaR + (thetaL - thetaR) * Double(i) / Double(nArc)
|
||||||
|
let ax = fit.cx + fit.r * cos(t)
|
||||||
|
let ay = fit.cy + fit.r * sin(t)
|
||||||
|
let pt = CGPoint(x: lx(CGFloat(ax)), y: ly(CGFloat(ay)))
|
||||||
|
if i == 0 { arcPath.move(to: pt) } else { arcPath.addLine(to: pt) }
|
||||||
|
}
|
||||||
|
context.stroke(arcPath, with: .color(Color(red: 0.7, green: 0.3, blue: 0.9).opacity(0.5)),
|
||||||
|
lineWidth: 1.5)
|
||||||
|
|
||||||
|
// Rs and Rp markers
|
||||||
|
let fitPtColor = Color(red: 0.9, green: 0.4, blue: 0.9)
|
||||||
|
let rsScr = CGPoint(x: lx(CGFloat(rs)), y: ly(0))
|
||||||
|
let rpScr = CGPoint(x: lx(CGFloat(rs + rp)), y: ly(0))
|
||||||
|
context.fill(Path(ellipseIn: CGRect(x: rsScr.x - 5, y: rsScr.y - 5, width: 10, height: 10)),
|
||||||
|
with: .color(fitPtColor))
|
||||||
|
context.fill(Path(ellipseIn: CGRect(x: rpScr.x - 5, y: rpScr.y - 5, width: 10, height: 10)),
|
||||||
|
with: .color(fitPtColor))
|
||||||
|
context.draw(
|
||||||
|
Text(String(format: "Rs=%.0f", rs)).font(.caption2).foregroundStyle(fitPtColor),
|
||||||
|
at: CGPoint(x: rsScr.x, y: rsScr.y + 14))
|
||||||
|
context.draw(
|
||||||
|
Text(String(format: "Rp=%.0f", rp)).font(.caption2).foregroundStyle(fitPtColor),
|
||||||
|
at: CGPoint(x: rpScr.x, y: rpScr.y + 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data table
|
||||||
|
|
||||||
|
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 (\u{00B0})", width: 70, alignment: .trailing),
|
||||||
|
MeasurementColumn(header: "Re (Ohm)", width: 90, alignment: .trailing),
|
||||||
|
MeasurementColumn(header: "Im (Ohm)", width: 90, alignment: .trailing),
|
||||||
|
],
|
||||||
|
rows: state.eisPoints,
|
||||||
|
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: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private var noDataPlaceholder: some View {
|
||||||
|
Text("No data")
|
||||||
|
.foregroundStyle(Color(white: 0.4))
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func freqLabel(_ logVal: Double) -> String {
|
||||||
|
let hz = pow(10, logVal)
|
||||||
|
if hz >= 1000 { return String(format: "%.0fk", hz / 1000) }
|
||||||
|
return String(format: "%.0f", hz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Kasa circle fit (ported from plot.rs)
|
||||||
|
|
||||||
|
struct CircleFitResult {
|
||||||
|
let cx: Double
|
||||||
|
let cy: Double
|
||||||
|
let r: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
func kasaCircleFit(points: [(Double, Double)]) -> CircleFitResult? {
|
||||||
|
let all = points.filter { $0.0.isFinite && $0.1.isFinite }
|
||||||
|
guard all.count >= 4 else { return nil }
|
||||||
|
|
||||||
|
let minPts = max(4, all.count / 3)
|
||||||
|
var best: CircleFitResult?
|
||||||
|
var bestScore = Double.greatestFiniteMagnitude
|
||||||
|
|
||||||
|
for start in 0..<all.count {
|
||||||
|
var pts = Array(all[start...])
|
||||||
|
while pts.count >= minPts {
|
||||||
|
guard let raw = kasaFitRaw(pts) else { break }
|
||||||
|
let (cx, cy, r) = raw
|
||||||
|
let disc = r * r - cy * cy
|
||||||
|
if disc > 0 {
|
||||||
|
let sd = sqrt(disc)
|
||||||
|
let rp = 2 * sd
|
||||||
|
if rp > 0 {
|
||||||
|
let avgErr = pts.map { p in
|
||||||
|
abs(sqrt((p.0 - cx) * (p.0 - cx) + (p.1 - cy) * (p.1 - cy)) - r)
|
||||||
|
}.reduce(0, +) / (Double(pts.count) * r)
|
||||||
|
let coverage = Double(pts.count) / Double(all.count)
|
||||||
|
let score = avgErr / coverage
|
||||||
|
if score < bestScore {
|
||||||
|
bestScore = score
|
||||||
|
best = CircleFitResult(cx: cx, cy: cy, r: r)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// remove worst outlier
|
||||||
|
guard let worstIdx = pts.enumerated().max(by: { a, b in
|
||||||
|
let dA = abs(sqrt((a.element.0 - cx) * (a.element.0 - cx) +
|
||||||
|
(a.element.1 - cy) * (a.element.1 - cy)) - r)
|
||||||
|
let dB = abs(sqrt((b.element.0 - cx) * (b.element.0 - cx) +
|
||||||
|
(b.element.1 - cy) * (b.element.1 - cy)) - r)
|
||||||
|
return dA < dB
|
||||||
|
})?.offset else { break }
|
||||||
|
pts.remove(at: worstIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
private func kasaFitRaw(_ pts: [(Double, Double)]) -> (Double, Double, Double)? {
|
||||||
|
guard pts.count >= 3 else { return nil }
|
||||||
|
let n = Double(pts.count)
|
||||||
|
var sx = 0.0, sy = 0.0
|
||||||
|
var sx2 = 0.0, sy2 = 0.0, sxy = 0.0
|
||||||
|
var sx3 = 0.0, sy3 = 0.0, sx2y = 0.0, sxy2 = 0.0
|
||||||
|
|
||||||
|
for (x, y) in pts {
|
||||||
|
sx += x; sy += y
|
||||||
|
let x2 = x * x, y2 = y * y, xy = x * y
|
||||||
|
sx2 += x2; sy2 += y2; sxy += xy
|
||||||
|
sx3 += x2 * x; sy3 += y2 * y; sx2y += x2 * y; sxy2 += x * y2
|
||||||
|
}
|
||||||
|
|
||||||
|
let (a00, a01, a02) = (sx2, sxy, sx)
|
||||||
|
let (a10, a11, a12) = (sxy, sy2, sy)
|
||||||
|
let (a20, a21, a22) = (sx, sy, n)
|
||||||
|
let (r0, r1, r2) = (sx3 + sxy2, sx2y + sy3, sx2 + sy2)
|
||||||
|
|
||||||
|
let det = a00 * (a11 * a22 - a12 * a21)
|
||||||
|
- a01 * (a10 * a22 - a12 * a20)
|
||||||
|
+ a02 * (a10 * a21 - a11 * a20)
|
||||||
|
guard abs(det) > 1e-20 else { return nil }
|
||||||
|
|
||||||
|
let a = (r0 * (a11 * a22 - a12 * a21)
|
||||||
|
- a01 * (r1 * a22 - a12 * r2)
|
||||||
|
+ a02 * (r1 * a21 - a11 * r2)) / det
|
||||||
|
let b = (a00 * (r1 * a22 - a12 * r2)
|
||||||
|
- r0 * (a10 * a22 - a12 * a20)
|
||||||
|
+ a02 * (a10 * r2 - r1 * a20)) / det
|
||||||
|
let c = (a00 * (a11 * r2 - r1 * a21)
|
||||||
|
- a01 * (a10 * r2 - r1 * a20)
|
||||||
|
+ r0 * (a10 * a21 - a11 * a20)) / det
|
||||||
|
|
||||||
|
let cx = a / 2
|
||||||
|
let cy = b / 2
|
||||||
|
let rSq = c + cx * cx + cy * cy
|
||||||
|
guard rSq > 0 else { return nil }
|
||||||
|
return (cx, cy, sqrt(rSq))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Canvas drawing helpers
|
||||||
|
|
||||||
|
private func drawPolyline(context: GraphicsContext, points: [CGPoint], color: Color, width: CGFloat) {
|
||||||
|
guard points.count >= 2 else { return }
|
||||||
|
var path = Path()
|
||||||
|
path.move(to: points[0])
|
||||||
|
for pt in points.dropFirst() {
|
||||||
|
guard pt.x.isFinite && pt.y.isFinite else { continue }
|
||||||
|
path.addLine(to: pt)
|
||||||
|
}
|
||||||
|
context.stroke(path, with: .color(color), lineWidth: width)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawGrid(context: GraphicsContext, xl: CGFloat, xr: CGFloat, yt: CGFloat, yb: CGFloat,
|
||||||
|
xvLo: CGFloat, xvHi: CGFloat, yvLo: CGFloat, yvHi: CGFloat,
|
||||||
|
gridColor: Color, size: CGSize) {
|
||||||
|
let xStep = niceStep(Double(xvHi - xvLo), targetTicks: 4)
|
||||||
|
if xStep > 0 {
|
||||||
|
var g = (Double(xvLo) / xStep).rounded(.up) * xStep
|
||||||
|
while g <= Double(xvHi) {
|
||||||
|
let x = xl + CGFloat((g - Double(xvLo)) / Double(xvHi - xvLo)) * (xr - xl)
|
||||||
|
var p = Path(); p.move(to: CGPoint(x: x, y: yt)); p.addLine(to: CGPoint(x: x, y: yb))
|
||||||
|
context.stroke(p, with: .color(gridColor), lineWidth: 0.5)
|
||||||
|
context.draw(
|
||||||
|
Text(String(format: "%.0f", g)).font(.system(size: 9)).foregroundStyle(.secondary),
|
||||||
|
at: CGPoint(x: x, y: yb + 10))
|
||||||
|
g += xStep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let yStep = niceStep(Double(yvHi - yvLo), targetTicks: 4)
|
||||||
|
if yStep > 0 {
|
||||||
|
var g = (Double(yvLo) / yStep).rounded(.up) * yStep
|
||||||
|
while g <= Double(yvHi) {
|
||||||
|
let y = yt + CGFloat((Double(yvHi) - g) / Double(yvHi - yvLo)) * (yb - yt)
|
||||||
|
var p = Path(); p.move(to: CGPoint(x: xl, y: y)); p.addLine(to: CGPoint(x: xr, y: y))
|
||||||
|
context.stroke(p, with: .color(gridColor), lineWidth: 0.5)
|
||||||
|
context.draw(
|
||||||
|
Text(String(format: "%.0f", g)).font(.system(size: 9)).foregroundStyle(.secondary),
|
||||||
|
at: CGPoint(x: xl - 20, y: y))
|
||||||
|
g += yStep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func niceStep(_ range: Double, targetTicks: Int) -> Double {
|
||||||
|
guard abs(range) > 1e-10 else { return 1 }
|
||||||
|
let rough = range / Double(targetTicks)
|
||||||
|
let mag = pow(10, floor(log10(abs(rough))))
|
||||||
|
let norm = abs(rough) / mag
|
||||||
|
let s: Double
|
||||||
|
if norm < 1.5 { s = 1 }
|
||||||
|
else if norm < 3.5 { s = 2 }
|
||||||
|
else if norm < 7.5 { s = 5 }
|
||||||
|
else { s = 10 }
|
||||||
|
return s * mag
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared UI components
|
||||||
|
|
||||||
|
struct LabeledField: View {
|
||||||
|
let label: String
|
||||||
|
@Binding var text: String
|
||||||
|
let width: CGFloat
|
||||||
|
|
||||||
|
init(_ label: String, text: Binding<String>, width: CGFloat) {
|
||||||
|
self.label = label
|
||||||
|
self._text = text
|
||||||
|
self.width = width
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField(label, text: $text)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: width)
|
||||||
|
#if os(iOS)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LabeledPicker<Item: Hashable & Identifiable>: View {
|
||||||
|
let label: String
|
||||||
|
@Binding var selection: Item
|
||||||
|
let items: [Item]
|
||||||
|
let itemLabel: (Item) -> String
|
||||||
|
|
||||||
|
init(_ label: String, selection: Binding<Item>, items: [Item], itemLabel: @escaping (Item) -> String) {
|
||||||
|
self.label = label
|
||||||
|
self._selection = selection
|
||||||
|
self.items = items
|
||||||
|
self.itemLabel = itemLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Picker(label, selection: $selection) {
|
||||||
|
ForEach(items) { item in
|
||||||
|
Text(itemLabel(item)).tag(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
#if os(iOS)
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ActionButtonStyle: ButtonStyle {
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(color.opacity(configuration.isPressed ? 0.7 : 1.0))
|
||||||
|
)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HSplitLayout<Leading: View, Trailing: View>: View {
|
||||||
|
let ratio: CGFloat
|
||||||
|
@ViewBuilder let leading: () -> Leading
|
||||||
|
@ViewBuilder let trailing: () -> Trailing
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
leading()
|
||||||
|
.frame(width: geo.size.width * ratio)
|
||||||
|
Divider()
|
||||||
|
trailing()
|
||||||
|
.frame(width: geo.size.width * (1 - ratio))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
struct LSVView: View {
|
||||||
|
@Bindable var state: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
controlsRow
|
||||||
|
Divider()
|
||||||
|
GeometryReader { geo in
|
||||||
|
if geo.size.width > 700 {
|
||||||
|
HSplitLayout(ratio: 0.55) {
|
||||||
|
voltammogramPlot
|
||||||
|
} trailing: {
|
||||||
|
lsvTable
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
voltammogramPlot.frame(height: 350)
|
||||||
|
lsvTable.frame(height: 300)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Controls
|
||||||
|
|
||||||
|
private var controlsRow: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
LabeledField("Start mV", text: $state.lsvStartV, width: 80)
|
||||||
|
LabeledField("Stop mV", text: $state.lsvStopV, width: 80)
|
||||||
|
LabeledField("Scan mV/s", text: $state.lsvScanRate, width: 80)
|
||||||
|
|
||||||
|
LabeledPicker("RTIA", selection: $state.lsvRtia, items: LpRtia.allCases) { $0.label }
|
||||||
|
.frame(width: 120)
|
||||||
|
|
||||||
|
Button("Start LSV") { state.startLSV() }
|
||||||
|
.buttonStyle(ActionButtonStyle(color: .green))
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Plot
|
||||||
|
|
||||||
|
private var voltammogramPlot: some View {
|
||||||
|
Group {
|
||||||
|
if state.lsvPoints.isEmpty {
|
||||||
|
Text("No 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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: - Table
|
||||||
|
|
||||||
|
private var lsvTable: some View {
|
||||||
|
MeasurementTable(
|
||||||
|
columns: [
|
||||||
|
MeasurementColumn(header: "V (mV)", width: 80, alignment: .trailing),
|
||||||
|
MeasurementColumn(header: "I (uA)", width: 90, alignment: .trailing),
|
||||||
|
],
|
||||||
|
rows: state.lsvPoints,
|
||||||
|
cellText: { pt, col in
|
||||||
|
switch col {
|
||||||
|
case 0: String(format: "%.1f", pt.vMv)
|
||||||
|
case 1: String(format: "%.3f", pt.iUa)
|
||||||
|
default: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PhView: View {
|
||||||
|
@Bindable var state: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
controlsRow
|
||||||
|
Divider()
|
||||||
|
phBody
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Controls
|
||||||
|
|
||||||
|
private var controlsRow: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
LabeledField("Stabilize s", text: $state.phStabilize, width: 80)
|
||||||
|
|
||||||
|
Button("Measure pH") { state.startPh() }
|
||||||
|
.buttonStyle(ActionButtonStyle(color: .green))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Result body
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var phBody: some View {
|
||||||
|
if let r = state.phResult {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(String(format: "pH: %.2f", r.ph))
|
||||||
|
.font(.system(size: 48, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
let nernstSlope = 0.1984 * (Double(r.tempC) + 273.15)
|
||||||
|
Text(String(format: "OCP: %.1f mV | Nernst slope: %.2f mV/pH | Temp: %.1f\u{00B0}C",
|
||||||
|
r.vOcpMv, nernstSlope, r.tempC))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if let refR = state.phRef {
|
||||||
|
let dPh = r.ph - refR.ph
|
||||||
|
let dV = r.vOcpMv - refR.vOcpMv
|
||||||
|
Text(String(format: "vs Ref: dpH=%+.3f dOCP=%+.1f mV (ref pH=%.2f)",
|
||||||
|
dPh, dV, refR.ph))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("No measurement yet")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("OCP method: V(SE0) - V(RE0) with Nernst correction")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color(white: 0.4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,370 @@
|
||||||
|
import SwiftUI
|
||||||
|
import GRDB
|
||||||
|
|
||||||
|
struct SessionView: View {
|
||||||
|
@Bindable var state: AppState
|
||||||
|
@State private var sessions: [Session] = []
|
||||||
|
@State private var selectedSessionId: Int64?
|
||||||
|
@State private var showingNewSession = false
|
||||||
|
@State private var newLabel = ""
|
||||||
|
@State private var newNotes = ""
|
||||||
|
@State private var sessionCancellable: DatabaseCancellable?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
if geo.size.width > 700 {
|
||||||
|
wideLayout
|
||||||
|
} else {
|
||||||
|
compactLayout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingNewSession) {
|
||||||
|
newSessionSheet
|
||||||
|
}
|
||||||
|
.onAppear { startObserving() }
|
||||||
|
.onDisappear { sessionCancellable?.cancel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startObserving() {
|
||||||
|
sessionCancellable = Storage.shared.observeSessions { sessions in
|
||||||
|
self.sessions = sessions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wide layout (iPad)
|
||||||
|
|
||||||
|
private var wideLayout: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
sessionList
|
||||||
|
.frame(width: 300)
|
||||||
|
Divider()
|
||||||
|
if let sid = selectedSessionId, let session = sessions.first(where: { $0.id == sid }) {
|
||||||
|
SessionDetailView(session: session, state: state)
|
||||||
|
} else {
|
||||||
|
Text("Select or create a session")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Compact layout (iPhone)
|
||||||
|
|
||||||
|
private var compactLayout: some View {
|
||||||
|
NavigationStack {
|
||||||
|
sessionList
|
||||||
|
.navigationTitle("Sessions")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button(action: { showingNewSession = true }) {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationDestination(for: Int64.self) { sid in
|
||||||
|
if let session = sessions.first(where: { $0.id == sid }) {
|
||||||
|
SessionDetailView(session: session, state: state)
|
||||||
|
.navigationTitle(session.label ?? "Session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session list
|
||||||
|
|
||||||
|
private var sessionList: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text("Sessions")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Button(action: { showingNewSession = true }) {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.imageScale(.large)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
if sessions.isEmpty {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("No sessions")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Create a session to organize measurements")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color(white: 0.4))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
List(selection: $selectedSessionId) {
|
||||||
|
ForEach(sessions, id: \.id) { session in
|
||||||
|
NavigationLink(value: session.id!) {
|
||||||
|
sessionRow(session)
|
||||||
|
}
|
||||||
|
.tag(session.id!)
|
||||||
|
}
|
||||||
|
.onDelete { indices in
|
||||||
|
for idx in indices {
|
||||||
|
guard let sid = sessions[idx].id else { continue }
|
||||||
|
try? Storage.shared.deleteSession(sid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sessionRow(_ session: Session) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(session.label ?? "Untitled")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
HStack {
|
||||||
|
Text(session.startedAt, style: .date)
|
||||||
|
Text(session.startedAt, style: .time)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if let notes = session.notes, !notes.isEmpty {
|
||||||
|
Text(notes)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
let count = (try? Storage.shared.measurementCount(sessionId: session.id!)) ?? 0
|
||||||
|
if count > 0 {
|
||||||
|
Text("\(count) measurement\(count == 1 ? "" : "s")")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - New session sheet
|
||||||
|
|
||||||
|
private var newSessionSheet: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Session Info") {
|
||||||
|
TextField("Name", text: $newLabel)
|
||||||
|
TextField("Notes (optional)", text: $newNotes, axis: .vertical)
|
||||||
|
.lineLimit(3...6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("New Session")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
showingNewSession = false
|
||||||
|
newLabel = ""
|
||||||
|
newNotes = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Create") {
|
||||||
|
let label = newLabel.trimmingCharacters(in: .whitespaces)
|
||||||
|
let notes = newNotes.trimmingCharacters(in: .whitespaces)
|
||||||
|
if let session = try? Storage.shared.createSession(label: label.isEmpty ? nil : label) {
|
||||||
|
if !notes.isEmpty {
|
||||||
|
try? Storage.shared.updateSession(session.id!, label: session.label, notes: notes)
|
||||||
|
}
|
||||||
|
selectedSessionId = session.id
|
||||||
|
}
|
||||||
|
showingNewSession = false
|
||||||
|
newLabel = ""
|
||||||
|
newNotes = ""
|
||||||
|
}
|
||||||
|
.disabled(newLabel.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session detail
|
||||||
|
|
||||||
|
struct SessionDetailView: View {
|
||||||
|
let session: Session
|
||||||
|
@Bindable var state: AppState
|
||||||
|
@State private var measurements: [Measurement] = []
|
||||||
|
@State private var editing = false
|
||||||
|
@State private var editLabel = ""
|
||||||
|
@State private var editNotes = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
header
|
||||||
|
Divider()
|
||||||
|
measurementsList
|
||||||
|
}
|
||||||
|
.onAppear { loadMeasurements() }
|
||||||
|
.onChange(of: session.id) { loadMeasurements() }
|
||||||
|
.sheet(isPresented: $editing) { editSheet }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadMeasurements() {
|
||||||
|
guard let sid = session.id else { return }
|
||||||
|
measurements = (try? Storage.shared.fetchMeasurements(sessionId: sid)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(session.label ?? "Untitled")
|
||||||
|
.font(.title2.bold())
|
||||||
|
Spacer()
|
||||||
|
Button(action: {
|
||||||
|
editLabel = session.label ?? ""
|
||||||
|
editNotes = session.notes ?? ""
|
||||||
|
editing = true
|
||||||
|
}) {
|
||||||
|
Image(systemName: "pencil.circle")
|
||||||
|
.imageScale(.large)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text(session.startedAt, style: .date)
|
||||||
|
Text(session.startedAt, style: .time)
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if let notes = session.notes, !notes.isEmpty {
|
||||||
|
Text(notes)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var measurementsList: some View {
|
||||||
|
if measurements.isEmpty {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("No measurements")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Run a measurement to add data here")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color(white: 0.4))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(measurements, id: \.id) { meas in
|
||||||
|
MeasurementRow(measurement: meas, state: state)
|
||||||
|
}
|
||||||
|
.onDelete { indices in
|
||||||
|
for idx in indices {
|
||||||
|
guard let mid = measurements[idx].id else { continue }
|
||||||
|
try? Storage.shared.deleteMeasurement(mid)
|
||||||
|
}
|
||||||
|
loadMeasurements()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var editSheet: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Session Info") {
|
||||||
|
TextField("Name", text: $editLabel)
|
||||||
|
TextField("Notes", text: $editNotes, axis: .vertical)
|
||||||
|
.lineLimit(3...6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Edit Session")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { editing = false }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Save") {
|
||||||
|
guard let sid = session.id else { return }
|
||||||
|
let label = editLabel.trimmingCharacters(in: .whitespaces)
|
||||||
|
let notes = editNotes.trimmingCharacters(in: .whitespaces)
|
||||||
|
try? Storage.shared.updateSession(
|
||||||
|
sid,
|
||||||
|
label: label.isEmpty ? nil : label,
|
||||||
|
notes: notes.isEmpty ? nil : notes
|
||||||
|
)
|
||||||
|
editing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Measurement row
|
||||||
|
|
||||||
|
struct MeasurementRow: View {
|
||||||
|
let measurement: Measurement
|
||||||
|
@Bindable var state: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Label(typeLabel, systemImage: typeIcon)
|
||||||
|
Spacer()
|
||||||
|
VStack(alignment: .trailing) {
|
||||||
|
Text("\(pointCount) pts")
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
Text(measurement.startedAt, style: .time)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { state.loadMeasurement(measurement) }
|
||||||
|
.contextMenu {
|
||||||
|
Button {
|
||||||
|
state.loadMeasurement(measurement)
|
||||||
|
} label: {
|
||||||
|
Label("Load", systemImage: "square.and.arrow.down")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
state.loadAsReference(measurement)
|
||||||
|
} label: {
|
||||||
|
Label("Load as Reference", systemImage: "line.horizontal.2.decrease.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .leading) {
|
||||||
|
Button {
|
||||||
|
state.loadAsReference(measurement)
|
||||||
|
} label: {
|
||||||
|
Label("Reference", systemImage: "line.horizontal.2.decrease.circle")
|
||||||
|
}
|
||||||
|
.tint(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var typeLabel: String {
|
||||||
|
switch measurement.type {
|
||||||
|
case "eis": "EIS"
|
||||||
|
case "lsv": "LSV"
|
||||||
|
case "amp": "Amp"
|
||||||
|
case "chlorine": "Chlorine"
|
||||||
|
case "ph": "pH"
|
||||||
|
default: measurement.type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var typeIcon: String {
|
||||||
|
switch measurement.type {
|
||||||
|
case "eis": "waveform.path.ecg"
|
||||||
|
case "lsv": "chart.xyaxis.line"
|
||||||
|
case "amp": "bolt.fill"
|
||||||
|
case "chlorine": "drop.fill"
|
||||||
|
case "ph": "scalemass"
|
||||||
|
default: "questionmark.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pointCount: Int {
|
||||||
|
guard let mid = measurement.id else { return 0 }
|
||||||
|
return (try? Storage.shared.dataPointCount(measurementId: mid)) ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "grdb.swift",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/groue/GRDB.swift.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "36e30a6f1ef10e4194f6af0cff90888526f0c115",
|
||||||
|
"version" : "7.10.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 2
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
// swift-tools-version: 5.9
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "CueIOS",
|
||||||
|
platforms: [.iOS(.v17), .macOS(.v14)],
|
||||||
|
products: [
|
||||||
|
.library(name: "CueIOS", targets: ["CueIOS"]),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/groue/GRDB.swift.git", from: "7.0.0"),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "CueIOS",
|
||||||
|
dependencies: [.product(name: "GRDB", package: "GRDB.swift")],
|
||||||
|
path: "CueIOS"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
SVG_SRC="../cue/assets/cue.svg"
|
||||||
|
ICON_DIR="CueIOS/Assets.xcassets/AppIcon.appiconset"
|
||||||
|
BUILD_DIR="build"
|
||||||
|
|
||||||
|
# Generate app icon from SVG
|
||||||
|
if [ -f "$SVG_SRC" ] && command -v rsvg-convert &>/dev/null; then
|
||||||
|
echo "Generating app icon from $SVG_SRC"
|
||||||
|
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 -filter Point -gravity center \
|
||||||
|
-background none -extent 1024x1024 \
|
||||||
|
"$ICON_DIR/appicon-1024.png"
|
||||||
|
else
|
||||||
|
cp /tmp/cue-icon-raw.png "$ICON_DIR/appicon-1024.png"
|
||||||
|
fi
|
||||||
|
rm -f /tmp/cue-icon-raw.png
|
||||||
|
echo "App icon generated"
|
||||||
|
elif [ ! -f "$ICON_DIR/appicon-1024.png" ]; then
|
||||||
|
echo "Warning: No app icon. Place appicon-1024.png in $ICON_DIR or install rsvg-convert."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Regenerate xcodeproj if xcodegen available
|
||||||
|
if command -v xcodegen &>/dev/null; then
|
||||||
|
xcodegen generate --quiet
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$BUILD_DIR"
|
||||||
|
|
||||||
|
# Build for iOS device (release)
|
||||||
|
xcodebuild \
|
||||||
|
-project CueIOS.xcodeproj \
|
||||||
|
-scheme CueIOS \
|
||||||
|
-configuration Release \
|
||||||
|
-sdk iphoneos \
|
||||||
|
-derivedDataPath "$BUILD_DIR/DerivedData" \
|
||||||
|
CODE_SIGN_IDENTITY="Apple Development" \
|
||||||
|
CODE_SIGN_STYLE=Automatic \
|
||||||
|
-quiet \
|
||||||
|
build
|
||||||
|
|
||||||
|
APP_PATH=$(find "$BUILD_DIR/DerivedData" -name "CueIOS.app" -type d | head -1)
|
||||||
|
if [ -n "$APP_PATH" ]; then
|
||||||
|
cp -R "$APP_PATH" "$BUILD_DIR/"
|
||||||
|
echo "Built: $BUILD_DIR/CueIOS.app"
|
||||||
|
else
|
||||||
|
echo "Build completed but .app not found in DerivedData"
|
||||||
|
fi
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
name: CueIOS
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: org.elseif
|
||||||
|
deploymentTarget:
|
||||||
|
iOS: "17.0"
|
||||||
|
xcodeVersion: "16.0"
|
||||||
|
generateEmptyDirectories: true
|
||||||
|
packages:
|
||||||
|
GRDB:
|
||||||
|
url: https://github.com/groue/GRDB.swift
|
||||||
|
from: "7.0.0"
|
||||||
|
targets:
|
||||||
|
CueIOS:
|
||||||
|
type: application
|
||||||
|
platform: iOS
|
||||||
|
sources:
|
||||||
|
- path: CueIOS
|
||||||
|
excludes:
|
||||||
|
- Info.plist
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: org.elseif.cue
|
||||||
|
INFOPLIST_FILE: CueIOS/Info.plist
|
||||||
|
DEVELOPMENT_TEAM: ""
|
||||||
|
SWIFT_VERSION: "6.0"
|
||||||
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING: false
|
||||||
|
configs:
|
||||||
|
Debug:
|
||||||
|
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL: -Onone
|
||||||
|
Release:
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL: -O
|
||||||
|
dependencies:
|
||||||
|
- package: GRDB
|
||||||
|
entitlements:
|
||||||
|
path: CueIOS/CueIOS.entitlements
|
||||||
|
|
@ -765,6 +765,9 @@ dependencies = [
|
||||||
"iced",
|
"iced",
|
||||||
"midir",
|
"midir",
|
||||||
"muda",
|
"muda",
|
||||||
|
"rusqlite",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"winres",
|
"winres",
|
||||||
]
|
]
|
||||||
|
|
@ -1023,6 +1026,18 @@ dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-iterator"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-streaming-iterator"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fast-srgb8"
|
name = "fast-srgb8"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
|
@ -1433,6 +1448,15 @@ version = "0.16.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashlink"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hassle-rs"
|
name = "hassle-rs"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
|
|
@ -1816,6 +1840,17 @@ dependencies = [
|
||||||
"redox_syscall 0.7.3",
|
"redox_syscall 0.7.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libsqlite3-sys"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.4.15"
|
version = "0.4.15"
|
||||||
|
|
@ -2892,6 +2927,20 @@ version = "0.20.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusqlite"
|
||||||
|
version = "0.31.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"fallible-iterator",
|
||||||
|
"fallible-streaming-iterator",
|
||||||
|
"hashlink",
|
||||||
|
"libsqlite3-sys",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust-ini"
|
name = "rust-ini"
|
||||||
version = "0.18.0"
|
version = "0.18.0"
|
||||||
|
|
@ -3626,6 +3675,12 @@ 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 = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ midir = "0.10"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
muda = { version = "0.17", default-features = false }
|
muda = { version = "0.17", default-features = false }
|
||||||
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
[target.'cfg(windows)'.build-dependencies]
|
[target.'cfg(windows)'.build-dependencies]
|
||||||
winres = "0.1"
|
winres = "0.1"
|
||||||
|
|
|
||||||
675
cue/src/app.rs
675
cue/src/app.rs
|
|
@ -1,6 +1,7 @@
|
||||||
use futures::SinkExt;
|
use futures::SinkExt;
|
||||||
use iced::widget::{
|
use iced::widget::{
|
||||||
button, canvas, column, container, pane_grid, pick_list, row, text, text_editor, text_input,
|
button, canvas, column, container, pane_grid, pick_list, row, scrollable, text, 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};
|
||||||
|
|
@ -14,6 +15,7 @@ use crate::protocol::{
|
||||||
self, AmpPoint, ClPoint, ClResult, Electrode, EisMessage, EisPoint, LpRtia, LsvPoint,
|
self, AmpPoint, ClPoint, ClResult, Electrode, EisMessage, EisPoint, LpRtia, LsvPoint,
|
||||||
PhResult, Rcal, Rtia,
|
PhResult, Rcal, Rtia,
|
||||||
};
|
};
|
||||||
|
use crate::storage::{self, Session, Storage};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Tab {
|
pub enum Tab {
|
||||||
|
|
@ -22,6 +24,7 @@ pub enum Tab {
|
||||||
Amp,
|
Amp,
|
||||||
Chlorine,
|
Chlorine,
|
||||||
Ph,
|
Ph,
|
||||||
|
Browse,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -30,6 +33,21 @@ enum PaneId {
|
||||||
Data,
|
Data,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
enum SessionItem {
|
||||||
|
None,
|
||||||
|
Some(i64, String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SessionItem {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
SessionItem::None => f.write_str("(none)"),
|
||||||
|
SessionItem::Some(_, name) => f.write_str(name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Message {
|
pub enum Message {
|
||||||
BleReady(mpsc::UnboundedSender<Vec<u8>>),
|
BleReady(mpsc::UnboundedSender<Vec<u8>>),
|
||||||
|
|
@ -86,6 +104,18 @@ pub enum Message {
|
||||||
CleanVChanged(String),
|
CleanVChanged(String),
|
||||||
CleanDurChanged(String),
|
CleanDurChanged(String),
|
||||||
StartClean,
|
StartClean,
|
||||||
|
/* Sessions */
|
||||||
|
CreateSession,
|
||||||
|
SelectSession(Option<i64>),
|
||||||
|
DeleteSession,
|
||||||
|
SessionNameInput(String),
|
||||||
|
/* Browse */
|
||||||
|
BrowseSelectSession(i64),
|
||||||
|
BrowseSelectMeasurement(i64),
|
||||||
|
BrowseLoadAsActive(i64),
|
||||||
|
BrowseLoadAsReference(i64),
|
||||||
|
BrowseDeleteMeasurement(i64),
|
||||||
|
BrowseBack,
|
||||||
/* Misc */
|
/* Misc */
|
||||||
OpenMidiSetup,
|
OpenMidiSetup,
|
||||||
RefreshMidi,
|
RefreshMidi,
|
||||||
|
|
@ -100,6 +130,20 @@ pub struct App {
|
||||||
native_menu: NativeMenu,
|
native_menu: NativeMenu,
|
||||||
show_sysinfo: bool,
|
show_sysinfo: bool,
|
||||||
|
|
||||||
|
/* Storage */
|
||||||
|
storage: Storage,
|
||||||
|
current_session: Option<i64>,
|
||||||
|
sessions: Vec<Session>,
|
||||||
|
session_name_input: String,
|
||||||
|
creating_session: bool,
|
||||||
|
|
||||||
|
/* Browse */
|
||||||
|
browse_sessions: Vec<(Session, i64)>,
|
||||||
|
browse_measurements: Vec<(storage::Measurement, i64)>,
|
||||||
|
browse_selected_session: Option<i64>,
|
||||||
|
browse_selected_measurement: Option<i64>,
|
||||||
|
browse_preview: String,
|
||||||
|
|
||||||
/* EIS */
|
/* EIS */
|
||||||
eis_points: Vec<EisPoint>,
|
eis_points: Vec<EisPoint>,
|
||||||
sweep_total: u16,
|
sweep_total: u16,
|
||||||
|
|
@ -274,6 +318,8 @@ fn style_neutral() -> impl Fn(&Theme, button::Status) -> ButtonStyle {
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new() -> (Self, Task<Message>) {
|
pub fn new() -> (Self, Task<Message>) {
|
||||||
|
let storage = Storage::open().expect("failed to open database");
|
||||||
|
let sessions = storage.list_sessions().unwrap_or_default();
|
||||||
(Self {
|
(Self {
|
||||||
tab: Tab::Eis,
|
tab: Tab::Eis,
|
||||||
status: "Starting...".into(),
|
status: "Starting...".into(),
|
||||||
|
|
@ -288,6 +334,18 @@ impl App {
|
||||||
native_menu: NativeMenu::init(),
|
native_menu: NativeMenu::init(),
|
||||||
show_sysinfo: false,
|
show_sysinfo: false,
|
||||||
|
|
||||||
|
storage,
|
||||||
|
current_session: None,
|
||||||
|
sessions,
|
||||||
|
session_name_input: String::new(),
|
||||||
|
creating_session: false,
|
||||||
|
|
||||||
|
browse_sessions: Vec::new(),
|
||||||
|
browse_measurements: Vec::new(),
|
||||||
|
browse_selected_session: None,
|
||||||
|
browse_selected_measurement: None,
|
||||||
|
browse_preview: String::new(),
|
||||||
|
|
||||||
eis_points: Vec::new(),
|
eis_points: Vec::new(),
|
||||||
sweep_total: 0,
|
sweep_total: 0,
|
||||||
freq_start: "1000".into(),
|
freq_start: "1000".into(),
|
||||||
|
|
@ -362,6 +420,87 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn save_eis(&self, session_id: i64) {
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"freq_start": self.freq_start,
|
||||||
|
"freq_stop": self.freq_stop,
|
||||||
|
"ppd": self.ppd,
|
||||||
|
"rtia": format!("{}", self.rtia),
|
||||||
|
"rcal": format!("{}", self.rcal),
|
||||||
|
"electrode": format!("{}", self.electrode),
|
||||||
|
});
|
||||||
|
if let Ok(mid) = self.storage.create_measurement(session_id, "eis", ¶ms.to_string()) {
|
||||||
|
let pts: Vec<(i32, String)> = self.eis_points.iter().enumerate()
|
||||||
|
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
||||||
|
.collect();
|
||||||
|
let _ = self.storage.add_data_points_batch(mid, &pts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_lsv(&self, session_id: i64) {
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"v_start": self.lsv_start_v,
|
||||||
|
"v_stop": self.lsv_stop_v,
|
||||||
|
"scan_rate": self.lsv_scan_rate,
|
||||||
|
"rtia": format!("{}", self.lsv_rtia),
|
||||||
|
});
|
||||||
|
if let Ok(mid) = self.storage.create_measurement(session_id, "lsv", ¶ms.to_string()) {
|
||||||
|
let pts: Vec<(i32, String)> = self.lsv_points.iter().enumerate()
|
||||||
|
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
||||||
|
.collect();
|
||||||
|
let _ = self.storage.add_data_points_batch(mid, &pts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_amp(&self, session_id: i64) {
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"v_hold": self.amp_v_hold,
|
||||||
|
"interval_ms": self.amp_interval,
|
||||||
|
"duration_s": self.amp_duration,
|
||||||
|
"rtia": format!("{}", self.amp_rtia),
|
||||||
|
});
|
||||||
|
if let Ok(mid) = self.storage.create_measurement(session_id, "amp", ¶ms.to_string()) {
|
||||||
|
let pts: Vec<(i32, String)> = self.amp_points.iter().enumerate()
|
||||||
|
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
||||||
|
.collect();
|
||||||
|
let _ = self.storage.add_data_points_batch(mid, &pts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_cl(&self, session_id: i64) {
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"cond_v": self.cl_cond_v,
|
||||||
|
"cond_t": self.cl_cond_t,
|
||||||
|
"free_v": self.cl_free_v,
|
||||||
|
"total_v": self.cl_total_v,
|
||||||
|
"dep_t": self.cl_dep_t,
|
||||||
|
"meas_t": self.cl_meas_t,
|
||||||
|
"rtia": format!("{}", self.cl_rtia),
|
||||||
|
});
|
||||||
|
if let Ok(mid) = self.storage.create_measurement(session_id, "chlorine", ¶ms.to_string()) {
|
||||||
|
let mut pts: Vec<(i32, String)> = self.cl_points.iter().enumerate()
|
||||||
|
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
||||||
|
.collect();
|
||||||
|
if let Some(r) = &self.cl_result {
|
||||||
|
if let Ok(j) = serde_json::to_string(r) {
|
||||||
|
pts.push((pts.len() as i32, format!("{{\"result\":{}}}", j)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = self.storage.add_data_points_batch(mid, &pts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_ph(&self, session_id: i64, result: &PhResult) {
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"stabilize_s": self.ph_stabilize,
|
||||||
|
});
|
||||||
|
if let Ok(mid) = self.storage.create_measurement(session_id, "ph", ¶ms.to_string()) {
|
||||||
|
if let Ok(j) = serde_json::to_string(result) {
|
||||||
|
let _ = self.storage.add_data_point(mid, 0, &j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||||
match message {
|
match message {
|
||||||
Message::BleReady(tx) => {
|
Message::BleReady(tx) => {
|
||||||
|
|
@ -407,6 +546,9 @@ impl App {
|
||||||
}
|
}
|
||||||
self.eis_points.clear();
|
self.eis_points.clear();
|
||||||
} else {
|
} else {
|
||||||
|
if let Some(sid) = self.current_session {
|
||||||
|
self.save_eis(sid);
|
||||||
|
}
|
||||||
self.status = format!("Sweep complete: {} points", self.eis_points.len());
|
self.status = format!("Sweep complete: {} points", self.eis_points.len());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -431,6 +573,9 @@ impl App {
|
||||||
self.status = format!("LSV: {}/{}", self.lsv_points.len(), self.lsv_total);
|
self.status = format!("LSV: {}/{}", self.lsv_points.len(), self.lsv_total);
|
||||||
}
|
}
|
||||||
EisMessage::LsvEnd => {
|
EisMessage::LsvEnd => {
|
||||||
|
if let Some(sid) = self.current_session {
|
||||||
|
self.save_lsv(sid);
|
||||||
|
}
|
||||||
self.status = format!("LSV complete: {} points", self.lsv_points.len());
|
self.status = format!("LSV complete: {} points", self.lsv_points.len());
|
||||||
}
|
}
|
||||||
EisMessage::AmpStart { v_hold } => {
|
EisMessage::AmpStart { v_hold } => {
|
||||||
|
|
@ -447,6 +592,9 @@ impl App {
|
||||||
}
|
}
|
||||||
EisMessage::AmpEnd => {
|
EisMessage::AmpEnd => {
|
||||||
self.amp_running = false;
|
self.amp_running = false;
|
||||||
|
if let Some(sid) = self.current_session {
|
||||||
|
self.save_amp(sid);
|
||||||
|
}
|
||||||
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 } => {
|
||||||
|
|
@ -468,12 +616,18 @@ impl App {
|
||||||
self.cl_result.as_ref().unwrap().i_total_ua);
|
self.cl_result.as_ref().unwrap().i_total_ua);
|
||||||
}
|
}
|
||||||
EisMessage::ClEnd => {
|
EisMessage::ClEnd => {
|
||||||
|
if let Some(sid) = self.current_session {
|
||||||
|
self.save_cl(sid);
|
||||||
|
}
|
||||||
self.status = format!("Chlorine complete: {} points", self.cl_points.len());
|
self.status = format!("Chlorine complete: {} points", self.cl_points.len());
|
||||||
}
|
}
|
||||||
EisMessage::PhResult(r) => {
|
EisMessage::PhResult(r) => {
|
||||||
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 {
|
||||||
|
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)",
|
||||||
r.ph, r.v_ocp_mv, r.temp_c);
|
r.ph, r.v_ocp_mv, r.temp_c);
|
||||||
self.ph_result = Some(r);
|
self.ph_result = Some(r);
|
||||||
|
|
@ -523,7 +677,23 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Message::TabSelected(t) => self.tab = t,
|
Message::TabSelected(t) => {
|
||||||
|
if t == Tab::Browse {
|
||||||
|
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.browse_selected_session = None;
|
||||||
|
self.browse_selected_measurement = None;
|
||||||
|
self.browse_measurements.clear();
|
||||||
|
self.browse_preview.clear();
|
||||||
|
}
|
||||||
|
self.tab = t;
|
||||||
|
}
|
||||||
Message::PaneResized(event) => {
|
Message::PaneResized(event) => {
|
||||||
self.panes.resize(event.split, event.ratio);
|
self.panes.resize(event.split, event.ratio);
|
||||||
}
|
}
|
||||||
|
|
@ -534,7 +704,7 @@ impl App {
|
||||||
Tab::Lsv => self.lsv_data.perform(action),
|
Tab::Lsv => self.lsv_data.perform(action),
|
||||||
Tab::Amp => self.amp_data.perform(action),
|
Tab::Amp => self.amp_data.perform(action),
|
||||||
Tab::Chlorine => self.cl_data.perform(action),
|
Tab::Chlorine => self.cl_data.perform(action),
|
||||||
Tab::Ph => {}
|
Tab::Ph | Tab::Browse => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -687,6 +857,7 @@ impl App {
|
||||||
Tab::Amp => { self.amp_ref = None; self.status = "Amp reference cleared".into(); }
|
Tab::Amp => { self.amp_ref = None; self.status = "Amp reference cleared".into(); }
|
||||||
Tab::Chlorine => { self.cl_ref = None; self.status = "Chlorine reference cleared".into(); }
|
Tab::Chlorine => { self.cl_ref = None; self.status = "Chlorine reference cleared".into(); }
|
||||||
Tab::Ph => { self.ph_ref = None; self.status = "pH reference cleared".into(); }
|
Tab::Ph => { self.ph_ref = None; self.status = "pH reference cleared".into(); }
|
||||||
|
Tab::Browse => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* Global */
|
/* Global */
|
||||||
|
|
@ -712,6 +883,114 @@ impl App {
|
||||||
self.send_cmd(&protocol::build_sysex_start_clean(v, d));
|
self.send_cmd(&protocol::build_sysex_start_clean(v, d));
|
||||||
self.status = format!("Cleaning: {:.0} mV for {:.0}s", v, d);
|
self.status = format!("Cleaning: {:.0} mV for {:.0}s", v, d);
|
||||||
}
|
}
|
||||||
|
/* Sessions */
|
||||||
|
Message::CreateSession => {
|
||||||
|
if self.creating_session {
|
||||||
|
let name = self.session_name_input.trim();
|
||||||
|
if !name.is_empty() {
|
||||||
|
if let Ok(id) = self.storage.create_session(name, "") {
|
||||||
|
self.current_session = Some(id);
|
||||||
|
self.sessions = self.storage.list_sessions().unwrap_or_default();
|
||||||
|
self.status = format!("Session: {}", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.session_name_input.clear();
|
||||||
|
self.creating_session = false;
|
||||||
|
} else {
|
||||||
|
self.creating_session = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::SelectSession(id) => {
|
||||||
|
self.current_session = id;
|
||||||
|
}
|
||||||
|
Message::DeleteSession => {
|
||||||
|
if let Some(id) = self.current_session {
|
||||||
|
let _ = self.storage.delete_session(id);
|
||||||
|
self.current_session = None;
|
||||||
|
self.sessions = self.storage.list_sessions().unwrap_or_default();
|
||||||
|
self.status = "Session deleted".into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::SessionNameInput(s) => {
|
||||||
|
self.session_name_input = s;
|
||||||
|
}
|
||||||
|
/* Browse */
|
||||||
|
Message::BrowseSelectSession(sid) => {
|
||||||
|
self.browse_selected_session = Some(sid);
|
||||||
|
self.browse_selected_measurement = None;
|
||||||
|
self.browse_preview.clear();
|
||||||
|
self.browse_measurements = self.storage.get_measurements(sid)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| {
|
||||||
|
let cnt = self.storage.data_point_count(m.id).unwrap_or(0);
|
||||||
|
(m, cnt)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
Message::BrowseSelectMeasurement(mid) => {
|
||||||
|
self.browse_selected_measurement = Some(mid);
|
||||||
|
let mtype = self.browse_measurements.iter()
|
||||||
|
.find(|(m, _)| m.id == mid)
|
||||||
|
.map(|(m, _)| m.mtype.clone());
|
||||||
|
if let Some(mt) = mtype {
|
||||||
|
let pts = self.storage.get_data_points(mid).unwrap_or_default();
|
||||||
|
self.browse_preview = Self::format_preview(&mt, &pts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::BrowseLoadAsActive(mid) => {
|
||||||
|
let mtype = self.browse_measurements.iter()
|
||||||
|
.find(|(m, _)| m.id == mid)
|
||||||
|
.map(|(m, _)| m.mtype.clone());
|
||||||
|
if let Some(mt) = mtype {
|
||||||
|
let pts = self.storage.get_data_points(mid).unwrap_or_default();
|
||||||
|
self.load_measurement_active(&mt, &pts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::BrowseLoadAsReference(mid) => {
|
||||||
|
let mtype = self.browse_measurements.iter()
|
||||||
|
.find(|(m, _)| m.id == mid)
|
||||||
|
.map(|(m, _)| m.mtype.clone());
|
||||||
|
if let Some(mt) = mtype {
|
||||||
|
let pts = self.storage.get_data_points(mid).unwrap_or_default();
|
||||||
|
self.load_measurement_reference(&mt, &pts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::BrowseDeleteMeasurement(mid) => {
|
||||||
|
let _ = self.storage.delete_measurement(mid);
|
||||||
|
if self.browse_selected_measurement == Some(mid) {
|
||||||
|
self.browse_selected_measurement = None;
|
||||||
|
self.browse_preview.clear();
|
||||||
|
}
|
||||||
|
if let Some(sid) = self.browse_selected_session {
|
||||||
|
self.browse_measurements = self.storage.get_measurements(sid)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| {
|
||||||
|
let cnt = self.storage.data_point_count(m.id).unwrap_or(0);
|
||||||
|
(m, cnt)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
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.status = "Measurement deleted".into();
|
||||||
|
}
|
||||||
|
Message::BrowseBack => {
|
||||||
|
if self.browse_selected_measurement.is_some() {
|
||||||
|
self.browse_selected_measurement = None;
|
||||||
|
self.browse_preview.clear();
|
||||||
|
} else {
|
||||||
|
self.browse_selected_session = None;
|
||||||
|
self.browse_measurements.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
Message::OpenMidiSetup => {
|
Message::OpenMidiSetup => {
|
||||||
let _ = std::process::Command::new("open")
|
let _ = std::process::Command::new("open")
|
||||||
.arg("-a")
|
.arg("-a")
|
||||||
|
|
@ -785,6 +1064,7 @@ impl App {
|
||||||
tab_btn("Amperometry", Tab::Amp, self.tab == Tab::Amp),
|
tab_btn("Amperometry", Tab::Amp, self.tab == Tab::Amp),
|
||||||
tab_btn("Chlorine", Tab::Chlorine, self.tab == Tab::Chlorine),
|
tab_btn("Chlorine", Tab::Chlorine, self.tab == Tab::Chlorine),
|
||||||
tab_btn("pH", Tab::Ph, self.tab == Tab::Ph),
|
tab_btn("pH", Tab::Ph, self.tab == Tab::Ph),
|
||||||
|
tab_btn("Browse", Tab::Browse, self.tab == Tab::Browse),
|
||||||
button(text("MIDI Setup").size(13))
|
button(text("MIDI Setup").size(13))
|
||||||
.style(style_neutral())
|
.style(style_neutral())
|
||||||
.padding([6, 14])
|
.padding([6, 14])
|
||||||
|
|
@ -807,6 +1087,7 @@ impl App {
|
||||||
Tab::Amp => self.amp_ref.is_some(),
|
Tab::Amp => self.amp_ref.is_some(),
|
||||||
Tab::Chlorine => self.cl_ref.is_some(),
|
Tab::Chlorine => self.cl_ref.is_some(),
|
||||||
Tab::Ph => self.ph_ref.is_some(),
|
Tab::Ph => self.ph_ref.is_some(),
|
||||||
|
Tab::Browse => false,
|
||||||
};
|
};
|
||||||
let has_data = match self.tab {
|
let has_data = match self.tab {
|
||||||
Tab::Eis => !self.eis_points.is_empty(),
|
Tab::Eis => !self.eis_points.is_empty(),
|
||||||
|
|
@ -814,6 +1095,7 @@ impl App {
|
||||||
Tab::Amp => !self.amp_points.is_empty(),
|
Tab::Amp => !self.amp_points.is_empty(),
|
||||||
Tab::Chlorine => self.cl_result.is_some(),
|
Tab::Chlorine => self.cl_result.is_some(),
|
||||||
Tab::Ph => self.ph_result.is_some(),
|
Tab::Ph => self.ph_result.is_some(),
|
||||||
|
Tab::Browse => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut ref_row = row![].spacing(4).align_y(iced::Alignment::Center);
|
let mut ref_row = row![].spacing(4).align_y(iced::Alignment::Center);
|
||||||
|
|
@ -882,10 +1164,14 @@ impl App {
|
||||||
.push(ref_row)
|
.push(ref_row)
|
||||||
.push(text(format!("{:.1} C", self.temp_c)).size(14));
|
.push(text(format!("{:.1} C", self.temp_c)).size(14));
|
||||||
|
|
||||||
|
let session_row = self.view_session_row();
|
||||||
|
|
||||||
let controls = self.view_controls();
|
let controls = self.view_controls();
|
||||||
|
|
||||||
let body: Element<'_, Message> = if self.show_sysinfo {
|
let body: Element<'_, Message> = if self.show_sysinfo {
|
||||||
self.view_sysinfo()
|
self.view_sysinfo()
|
||||||
|
} else if self.tab == Tab::Browse {
|
||||||
|
self.view_browse_body()
|
||||||
} else if self.tab == Tab::Ph {
|
} else if self.tab == Tab::Ph {
|
||||||
self.view_ph_body()
|
self.view_ph_body()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -903,7 +1189,7 @@ impl App {
|
||||||
};
|
};
|
||||||
|
|
||||||
container(
|
container(
|
||||||
column![tabs, status_row, controls, body]
|
column![tabs, session_row, status_row, controls, body]
|
||||||
.spacing(10)
|
.spacing(10)
|
||||||
.padding(20)
|
.padding(20)
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
|
|
@ -914,6 +1200,66 @@ impl App {
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn view_session_row(&self) -> Element<'_, Message> {
|
||||||
|
let mut r = row![].spacing(6).align_y(iced::Alignment::Center);
|
||||||
|
|
||||||
|
let selected = self.current_session.and_then(|id| {
|
||||||
|
self.sessions.iter().find(|s| s.id == id)
|
||||||
|
});
|
||||||
|
let options: Vec<SessionItem> = std::iter::once(SessionItem::None)
|
||||||
|
.chain(self.sessions.iter().map(|s| SessionItem::Some(s.id, s.name.clone())))
|
||||||
|
.collect();
|
||||||
|
let current = match selected {
|
||||||
|
Some(s) => SessionItem::Some(s.id, s.name.clone()),
|
||||||
|
None => SessionItem::None,
|
||||||
|
};
|
||||||
|
r = r.push(text("Session:").size(12));
|
||||||
|
r = r.push(
|
||||||
|
pick_list(options, Some(current), |item| match item {
|
||||||
|
SessionItem::None => Message::SelectSession(None),
|
||||||
|
SessionItem::Some(id, _) => Message::SelectSession(Some(id)),
|
||||||
|
}).width(180).text_size(12),
|
||||||
|
);
|
||||||
|
|
||||||
|
if self.creating_session {
|
||||||
|
r = r.push(
|
||||||
|
text_input("Session name", &self.session_name_input)
|
||||||
|
.on_input(Message::SessionNameInput)
|
||||||
|
.on_submit(Message::CreateSession)
|
||||||
|
.width(150)
|
||||||
|
.size(12),
|
||||||
|
);
|
||||||
|
r = r.push(
|
||||||
|
button(text("Save").size(11))
|
||||||
|
.style(style_action())
|
||||||
|
.padding([4, 10])
|
||||||
|
.on_press(Message::CreateSession),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
r = r.push(
|
||||||
|
button(text("New").size(11))
|
||||||
|
.style(style_apply())
|
||||||
|
.padding([4, 10])
|
||||||
|
.on_press(Message::CreateSession),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.current_session.is_some() {
|
||||||
|
r = r.push(
|
||||||
|
button(text("Delete").size(11))
|
||||||
|
.style(style_danger())
|
||||||
|
.padding([4, 10])
|
||||||
|
.on_press(Message::DeleteSession),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
r = r.push(
|
||||||
|
text("No session -- data will not be saved").size(11),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
r.into()
|
||||||
|
}
|
||||||
|
|
||||||
fn view_controls(&self) -> Element<'_, Message> {
|
fn view_controls(&self) -> Element<'_, Message> {
|
||||||
match self.tab {
|
match self.tab {
|
||||||
Tab::Eis => row![
|
Tab::Eis => row![
|
||||||
|
|
@ -1064,6 +1410,8 @@ impl App {
|
||||||
.spacing(10)
|
.spacing(10)
|
||||||
.align_y(iced::Alignment::End)
|
.align_y(iced::Alignment::End)
|
||||||
.into(),
|
.into(),
|
||||||
|
|
||||||
|
Tab::Browse => row![].into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1123,7 +1471,7 @@ impl App {
|
||||||
column![result_text, plot].spacing(4).height(Length::Fill).into()
|
column![result_text, plot].spacing(4).height(Length::Fill).into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Tab::Ph => text("").into(),
|
Tab::Ph | Tab::Browse => text("").into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1133,7 +1481,7 @@ impl App {
|
||||||
Tab::Lsv => &self.lsv_data,
|
Tab::Lsv => &self.lsv_data,
|
||||||
Tab::Amp => &self.amp_data,
|
Tab::Amp => &self.amp_data,
|
||||||
Tab::Chlorine => &self.cl_data,
|
Tab::Chlorine => &self.cl_data,
|
||||||
Tab::Ph => return text("").into(),
|
Tab::Ph | Tab::Browse => return text("").into(),
|
||||||
};
|
};
|
||||||
text_editor(content)
|
text_editor(content)
|
||||||
.on_action(Message::DataAction)
|
.on_action(Message::DataAction)
|
||||||
|
|
@ -1187,4 +1535,319 @@ impl App {
|
||||||
.center(Length::Fill)
|
.center(Length::Fill)
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Browse tab ---- */
|
||||||
|
|
||||||
|
fn view_browse_body(&self) -> Element<'_, Message> {
|
||||||
|
let left = self.view_browse_sessions();
|
||||||
|
let right = self.view_browse_detail();
|
||||||
|
row![
|
||||||
|
container(left).width(Length::FillPortion(2)).height(Length::Fill),
|
||||||
|
iced::widget::vertical_rule(1),
|
||||||
|
container(right).width(Length::FillPortion(3)).height(Length::Fill),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_browse_sessions(&self) -> Element<'_, Message> {
|
||||||
|
let mut items = column![
|
||||||
|
text("Sessions").size(16),
|
||||||
|
iced::widget::horizontal_rule(1),
|
||||||
|
].spacing(4);
|
||||||
|
|
||||||
|
if self.browse_sessions.is_empty() {
|
||||||
|
items = items.push(text("No saved sessions").size(13));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (session, mcount) in &self.browse_sessions {
|
||||||
|
let selected = self.browse_selected_session == Some(session.id);
|
||||||
|
let bg = if selected {
|
||||||
|
Color::from_rgb(0.30, 0.40, 0.55)
|
||||||
|
} else {
|
||||||
|
Color::from_rgb(0.18, 0.18, 0.20)
|
||||||
|
};
|
||||||
|
let label = format!(
|
||||||
|
"{} ({} meas) {}",
|
||||||
|
session.name, mcount, &session.created_at[..10.min(session.created_at.len())]
|
||||||
|
);
|
||||||
|
let sid = session.id;
|
||||||
|
items = items.push(
|
||||||
|
button(text(label).size(12))
|
||||||
|
.style(btn_style(bg, Color::WHITE))
|
||||||
|
.padding([6, 10])
|
||||||
|
.width(Length::Fill)
|
||||||
|
.on_press(Message::BrowseSelectSession(sid)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollable(items.width(Length::Fill))
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_browse_detail(&self) -> Element<'_, Message> {
|
||||||
|
let Some(sid) = self.browse_selected_session else {
|
||||||
|
return column![
|
||||||
|
text("Select a session").size(14),
|
||||||
|
].spacing(8).into();
|
||||||
|
};
|
||||||
|
|
||||||
|
let session_name = self.browse_sessions.iter()
|
||||||
|
.find(|(s, _)| s.id == sid)
|
||||||
|
.map(|(s, _)| s.name.as_str())
|
||||||
|
.unwrap_or("?");
|
||||||
|
|
||||||
|
let mut header = row![
|
||||||
|
button(text("Back").size(11))
|
||||||
|
.style(style_neutral())
|
||||||
|
.padding([4, 10])
|
||||||
|
.on_press(Message::BrowseBack),
|
||||||
|
text(format!("Measurements in: {}", session_name)).size(14),
|
||||||
|
].spacing(8).align_y(iced::Alignment::Center);
|
||||||
|
|
||||||
|
if let Some(mid) = self.browse_selected_measurement {
|
||||||
|
header = header.push(iced::widget::horizontal_space());
|
||||||
|
header = header.push(
|
||||||
|
button(text("Load").size(11))
|
||||||
|
.style(style_action())
|
||||||
|
.padding([4, 12])
|
||||||
|
.on_press(Message::BrowseLoadAsActive(mid)),
|
||||||
|
);
|
||||||
|
header = header.push(
|
||||||
|
button(text("Load as Ref").size(11))
|
||||||
|
.style(style_apply())
|
||||||
|
.padding([4, 12])
|
||||||
|
.on_press(Message::BrowseLoadAsReference(mid)),
|
||||||
|
);
|
||||||
|
header = header.push(
|
||||||
|
button(text("Delete").size(11))
|
||||||
|
.style(style_danger())
|
||||||
|
.padding([4, 10])
|
||||||
|
.on_press(Message::BrowseDeleteMeasurement(mid)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut mlist = column![].spacing(2);
|
||||||
|
|
||||||
|
if self.browse_measurements.is_empty() {
|
||||||
|
mlist = mlist.push(text("No measurements").size(13));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (m, pt_count) in &self.browse_measurements {
|
||||||
|
let selected = self.browse_selected_measurement == Some(m.id);
|
||||||
|
let bg = if selected {
|
||||||
|
Color::from_rgb(0.30, 0.40, 0.55)
|
||||||
|
} else {
|
||||||
|
Color::from_rgb(0.18, 0.18, 0.20)
|
||||||
|
};
|
||||||
|
let type_label = match m.mtype.as_str() {
|
||||||
|
"eis" => "EIS",
|
||||||
|
"lsv" => "LSV",
|
||||||
|
"amp" => "Amp",
|
||||||
|
"chlorine" => "Cl",
|
||||||
|
"ph" => "pH",
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
|
let ts = if m.created_at.len() > 10 { &m.created_at[11..] } else { &m.created_at };
|
||||||
|
let label = format!("[{}] {} {} pts", type_label, ts, pt_count);
|
||||||
|
let mid = m.id;
|
||||||
|
mlist = mlist.push(
|
||||||
|
button(text(label).size(12))
|
||||||
|
.style(btn_style(bg, Color::WHITE))
|
||||||
|
.padding([5, 10])
|
||||||
|
.width(Length::Fill)
|
||||||
|
.on_press(Message::BrowseSelectMeasurement(mid)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut body = column![
|
||||||
|
header,
|
||||||
|
iced::widget::horizontal_rule(1),
|
||||||
|
].spacing(6);
|
||||||
|
|
||||||
|
body = body.push(
|
||||||
|
scrollable(mlist.width(Length::Fill))
|
||||||
|
.height(if self.browse_preview.is_empty() { Length::Fill } else { Length::FillPortion(2) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
if !self.browse_preview.is_empty() {
|
||||||
|
body = body.push(iced::widget::horizontal_rule(1));
|
||||||
|
body = body.push(
|
||||||
|
scrollable(
|
||||||
|
text(&self.browse_preview)
|
||||||
|
.size(12)
|
||||||
|
.font(iced::Font::MONOSPACE)
|
||||||
|
)
|
||||||
|
.height(Length::FillPortion(3)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.height(Length::Fill).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_preview(mtype: &str, pts: &[storage::DataPoint]) -> String {
|
||||||
|
match mtype {
|
||||||
|
"eis" => {
|
||||||
|
let decoded: Vec<EisPoint> = pts.iter()
|
||||||
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
||||||
|
.collect();
|
||||||
|
fmt_eis(&decoded)
|
||||||
|
}
|
||||||
|
"lsv" => {
|
||||||
|
let decoded: Vec<LsvPoint> = pts.iter()
|
||||||
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
||||||
|
.collect();
|
||||||
|
fmt_lsv(&decoded)
|
||||||
|
}
|
||||||
|
"amp" => {
|
||||||
|
let decoded: Vec<AmpPoint> = pts.iter()
|
||||||
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
||||||
|
.collect();
|
||||||
|
fmt_amp(&decoded)
|
||||||
|
}
|
||||||
|
"chlorine" => {
|
||||||
|
let decoded: Vec<ClPoint> = pts.iter()
|
||||||
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
||||||
|
.collect();
|
||||||
|
let mut s = fmt_cl(&decoded);
|
||||||
|
if let Some(last) = pts.last()
|
||||||
|
&& let Ok(wrap) = serde_json::from_str::<serde_json::Value>(&last.data_json)
|
||||||
|
&& let Some(r) = wrap.get("result")
|
||||||
|
&& let Ok(cr) = serde_json::from_value::<ClResult>(r.clone())
|
||||||
|
{
|
||||||
|
let _ = writeln!(s, "\nFree: {:.3} uA Total: {:.3} uA",
|
||||||
|
cr.i_free_ua, cr.i_total_ua);
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
"ph" => {
|
||||||
|
if let Some(dp) = pts.first() {
|
||||||
|
if let Ok(r) = serde_json::from_str::<PhResult>(&dp.data_json) {
|
||||||
|
format!("pH: {:.2} OCP: {:.1} mV Temp: {:.1} C", r.ph, r.v_ocp_mv, r.temp_c)
|
||||||
|
} else {
|
||||||
|
dp.data_json.clone()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => format!("{} data points", pts.len()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_measurement_active(&mut self, mtype: &str, pts: &[storage::DataPoint]) {
|
||||||
|
match mtype {
|
||||||
|
"eis" => {
|
||||||
|
let decoded: Vec<EisPoint> = pts.iter()
|
||||||
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
||||||
|
.collect();
|
||||||
|
self.eis_data = text_editor::Content::with_text(&fmt_eis(&decoded));
|
||||||
|
self.eis_points = decoded;
|
||||||
|
self.tab = Tab::Eis;
|
||||||
|
self.status = format!("Loaded EIS: {} pts", self.eis_points.len());
|
||||||
|
}
|
||||||
|
"lsv" => {
|
||||||
|
let decoded: Vec<LsvPoint> = pts.iter()
|
||||||
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
||||||
|
.collect();
|
||||||
|
self.lsv_data = text_editor::Content::with_text(&fmt_lsv(&decoded));
|
||||||
|
self.lsv_points = decoded;
|
||||||
|
self.tab = Tab::Lsv;
|
||||||
|
self.status = format!("Loaded LSV: {} pts", self.lsv_points.len());
|
||||||
|
}
|
||||||
|
"amp" => {
|
||||||
|
let decoded: Vec<AmpPoint> = pts.iter()
|
||||||
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
||||||
|
.collect();
|
||||||
|
self.amp_data = text_editor::Content::with_text(&fmt_amp(&decoded));
|
||||||
|
self.amp_points = decoded;
|
||||||
|
self.tab = Tab::Amp;
|
||||||
|
self.status = format!("Loaded Amp: {} pts", self.amp_points.len());
|
||||||
|
}
|
||||||
|
"chlorine" => {
|
||||||
|
let cl_pts: Vec<ClPoint> = pts.iter()
|
||||||
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
||||||
|
.collect();
|
||||||
|
let mut result: Option<ClResult> = None;
|
||||||
|
if let Some(last) = pts.last()
|
||||||
|
&& let Ok(wrap) = serde_json::from_str::<serde_json::Value>(&last.data_json)
|
||||||
|
&& let Some(r) = wrap.get("result")
|
||||||
|
{
|
||||||
|
result = serde_json::from_value(r.clone()).ok();
|
||||||
|
}
|
||||||
|
self.cl_data = text_editor::Content::with_text(&fmt_cl(&cl_pts));
|
||||||
|
self.cl_points = cl_pts;
|
||||||
|
self.cl_result = result;
|
||||||
|
self.tab = Tab::Chlorine;
|
||||||
|
self.status = format!("Loaded Chlorine: {} pts", self.cl_points.len());
|
||||||
|
}
|
||||||
|
"ph" => {
|
||||||
|
if let Some(dp) = pts.first()
|
||||||
|
&& let Ok(r) = serde_json::from_str::<PhResult>(&dp.data_json)
|
||||||
|
{
|
||||||
|
self.status = format!("Loaded pH: {:.2}", r.ph);
|
||||||
|
self.ph_result = Some(r);
|
||||||
|
self.tab = Tab::Ph;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_measurement_reference(&mut self, mtype: &str, pts: &[storage::DataPoint]) {
|
||||||
|
match mtype {
|
||||||
|
"eis" => {
|
||||||
|
let decoded: Vec<EisPoint> = pts.iter()
|
||||||
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
||||||
|
.collect();
|
||||||
|
self.status = format!("EIS ref loaded: {} pts", decoded.len());
|
||||||
|
self.eis_ref = Some(decoded);
|
||||||
|
self.tab = Tab::Eis;
|
||||||
|
}
|
||||||
|
"lsv" => {
|
||||||
|
let decoded: Vec<LsvPoint> = pts.iter()
|
||||||
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
||||||
|
.collect();
|
||||||
|
self.status = format!("LSV ref loaded: {} pts", decoded.len());
|
||||||
|
self.lsv_ref = Some(decoded);
|
||||||
|
self.tab = Tab::Lsv;
|
||||||
|
}
|
||||||
|
"amp" => {
|
||||||
|
let decoded: Vec<AmpPoint> = pts.iter()
|
||||||
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
||||||
|
.collect();
|
||||||
|
self.status = format!("Amp ref loaded: {} pts", decoded.len());
|
||||||
|
self.amp_ref = Some(decoded);
|
||||||
|
self.tab = Tab::Amp;
|
||||||
|
}
|
||||||
|
"chlorine" => {
|
||||||
|
let cl_pts: Vec<ClPoint> = pts.iter()
|
||||||
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
||||||
|
.collect();
|
||||||
|
let mut result = ClResult { i_free_ua: 0.0, i_total_ua: 0.0 };
|
||||||
|
if let Some(last) = pts.last()
|
||||||
|
&& let Ok(wrap) = serde_json::from_str::<serde_json::Value>(&last.data_json)
|
||||||
|
&& let Some(r) = wrap.get("result")
|
||||||
|
&& let Ok(cr) = serde_json::from_value::<ClResult>(r.clone())
|
||||||
|
{
|
||||||
|
result = cr;
|
||||||
|
}
|
||||||
|
self.status = format!("Cl ref loaded: {} pts", cl_pts.len());
|
||||||
|
self.cl_ref = Some((cl_pts, result));
|
||||||
|
self.tab = Tab::Chlorine;
|
||||||
|
}
|
||||||
|
"ph" => {
|
||||||
|
if let Some(dp) = pts.first()
|
||||||
|
&& let Ok(r) = serde_json::from_str::<PhResult>(&dp.data_json)
|
||||||
|
{
|
||||||
|
self.status = format!("pH ref loaded: {:.2}", r.ph);
|
||||||
|
self.ph_ref = Some(r);
|
||||||
|
self.tab = Tab::Ph;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ mod ble;
|
||||||
mod native_menu;
|
mod native_menu;
|
||||||
mod plot;
|
mod plot;
|
||||||
mod protocol;
|
mod protocol;
|
||||||
|
mod storage;
|
||||||
|
|
||||||
fn main() -> iced::Result {
|
fn main() -> iced::Result {
|
||||||
iced::application(app::App::title, app::App::update, app::App::view)
|
iced::application(app::App::title, app::App::update, app::App::view)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
/// EIS4 SysEx Protocol — manufacturer ID 0x7D (non-commercial)
|
/// EIS4 SysEx Protocol — manufacturer ID 0x7D (non-commercial)
|
||||||
|
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
|
||||||
pub const SYSEX_MFR: u8 = 0x7D;
|
pub const SYSEX_MFR: u8 = 0x7D;
|
||||||
|
|
||||||
/* ESP32 → Cue */
|
/* ESP32 → Cue */
|
||||||
|
|
@ -174,7 +176,7 @@ impl std::fmt::Display for LpRtia {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct EisPoint {
|
pub struct EisPoint {
|
||||||
pub freq_hz: f32,
|
pub freq_hz: f32,
|
||||||
pub mag_ohms: f32,
|
pub mag_ohms: f32,
|
||||||
|
|
@ -188,32 +190,32 @@ pub struct EisPoint {
|
||||||
pub pct_err: f32,
|
pub pct_err: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct LsvPoint {
|
pub struct LsvPoint {
|
||||||
pub v_mv: f32,
|
pub v_mv: f32,
|
||||||
pub i_ua: f32,
|
pub i_ua: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AmpPoint {
|
pub struct AmpPoint {
|
||||||
pub t_ms: f32,
|
pub t_ms: f32,
|
||||||
pub i_ua: f32,
|
pub i_ua: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ClPoint {
|
pub struct ClPoint {
|
||||||
pub t_ms: f32,
|
pub t_ms: f32,
|
||||||
pub i_ua: f32,
|
pub i_ua: f32,
|
||||||
pub phase: u8,
|
pub phase: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ClResult {
|
pub struct ClResult {
|
||||||
pub i_free_ua: f32,
|
pub i_free_ua: f32,
|
||||||
pub i_total_ua: f32,
|
pub i_total_ua: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PhResult {
|
pub struct PhResult {
|
||||||
pub v_ocp_mv: f32,
|
pub v_ocp_mv: f32,
|
||||||
pub ph: f32,
|
pub ph: f32,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
use rusqlite::{Connection, params};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct Session {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub notes: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct Measurement {
|
||||||
|
pub id: i64,
|
||||||
|
pub session_id: i64,
|
||||||
|
pub mtype: String,
|
||||||
|
pub params_json: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct DataPoint {
|
||||||
|
pub id: i64,
|
||||||
|
pub measurement_id: i64,
|
||||||
|
pub idx: i32,
|
||||||
|
pub data_json: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Storage {
|
||||||
|
conn: Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Storage {
|
||||||
|
pub fn open() -> Result<Self, rusqlite::Error> {
|
||||||
|
let dir = dirs();
|
||||||
|
std::fs::create_dir_all(&dir).ok();
|
||||||
|
let path = dir.join("measurements.db");
|
||||||
|
let conn = Connection::open(path)?;
|
||||||
|
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
|
||||||
|
conn.execute_batch(SCHEMA)?;
|
||||||
|
Ok(Self { conn })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_session(&self, name: &str, notes: &str) -> Result<i64, rusqlite::Error> {
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO sessions (name, notes) VALUES (?1, ?2)",
|
||||||
|
params![name, notes],
|
||||||
|
)?;
|
||||||
|
Ok(self.conn.last_insert_rowid())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_sessions(&self) -> Result<Vec<Session>, rusqlite::Error> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT id, name, notes, created_at FROM sessions ORDER BY created_at DESC",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
Ok(Session {
|
||||||
|
id: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
notes: row.get(2)?,
|
||||||
|
created_at: row.get(3)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
rows.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_session(&self, id: i64) -> Result<(), rusqlite::Error> {
|
||||||
|
self.conn.execute("DELETE FROM sessions WHERE id = ?1", params![id])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_measurement(
|
||||||
|
&self, session_id: i64, mtype: &str, params_json: &str,
|
||||||
|
) -> Result<i64, rusqlite::Error> {
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO measurements (session_id, type, params_json) VALUES (?1, ?2, ?3)",
|
||||||
|
params![session_id, mtype, params_json],
|
||||||
|
)?;
|
||||||
|
Ok(self.conn.last_insert_rowid())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_data_point(
|
||||||
|
&self, measurement_id: i64, idx: i32, data_json: &str,
|
||||||
|
) -> Result<(), rusqlite::Error> {
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO data_points (measurement_id, idx, data_json) VALUES (?1, ?2, ?3)",
|
||||||
|
params![measurement_id, idx, data_json],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_data_points_batch(
|
||||||
|
&self, measurement_id: i64, points: &[(i32, String)],
|
||||||
|
) -> Result<(), rusqlite::Error> {
|
||||||
|
let tx = self.conn.unchecked_transaction()?;
|
||||||
|
{
|
||||||
|
let mut stmt = tx.prepare(
|
||||||
|
"INSERT INTO data_points (measurement_id, idx, data_json) VALUES (?1, ?2, ?3)",
|
||||||
|
)?;
|
||||||
|
for (idx, json) in points {
|
||||||
|
stmt.execute(params![measurement_id, idx, json])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_measurements(&self, session_id: i64) -> Result<Vec<Measurement>, rusqlite::Error> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT id, session_id, type, params_json, created_at \
|
||||||
|
FROM measurements WHERE session_id = ?1 ORDER BY created_at DESC",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(params![session_id], |row| {
|
||||||
|
Ok(Measurement {
|
||||||
|
id: row.get(0)?,
|
||||||
|
session_id: row.get(1)?,
|
||||||
|
mtype: row.get(2)?,
|
||||||
|
params_json: row.get(3)?,
|
||||||
|
created_at: row.get(4)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
rows.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn measurement_count(&self, session_id: i64) -> Result<i64, rusqlite::Error> {
|
||||||
|
self.conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM measurements WHERE session_id = ?1",
|
||||||
|
params![session_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn data_point_count(&self, measurement_id: i64) -> Result<i64, rusqlite::Error> {
|
||||||
|
self.conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM data_points WHERE measurement_id = ?1",
|
||||||
|
params![measurement_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_measurement(&self, id: i64) -> Result<(), rusqlite::Error> {
|
||||||
|
self.conn.execute("DELETE FROM measurements WHERE id = ?1", params![id])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_data_points(&self, measurement_id: i64) -> Result<Vec<DataPoint>, rusqlite::Error> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT id, measurement_id, idx, data_json \
|
||||||
|
FROM data_points WHERE measurement_id = ?1 ORDER BY idx",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(params![measurement_id], |row| {
|
||||||
|
Ok(DataPoint {
|
||||||
|
id: row.get(0)?,
|
||||||
|
measurement_id: row.get(1)?,
|
||||||
|
idx: row.get(2)?,
|
||||||
|
data_json: row.get(3)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
rows.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dirs() -> std::path::PathBuf {
|
||||||
|
dirs_home().join(".eis4")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dirs_home() -> std::path::PathBuf {
|
||||||
|
std::env::var("HOME")
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| std::path::PathBuf::from("."))
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCHEMA: &str = "
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS measurements (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
params_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS data_points (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
measurement_id INTEGER NOT NULL REFERENCES measurements(id) ON DELETE CASCADE,
|
||||||
|
idx INTEGER NOT NULL,
|
||||||
|
data_json TEXT NOT NULL
|
||||||
|
);
|
||||||
|
";
|
||||||
Loading…
Reference in New Issue