Compare commits

..

14 Commits

Author SHA1 Message Date
jess a04163cade icons 2026-04-01 09:32:07 -07:00
jess cc0685a333 fix: AD5941 soft-reset between measurement modes to prevent pH->EIS lockup 2026-04-01 01:29:27 -07:00
jess 3f91159596 cue-ios: strip CoreBluetooth, replace with UDP/WiFi transport 2026-04-01 00:37:12 -07:00
jess d13909c400 firmware: strip BLE/NimBLE, WiFi-only transport with session sync protocol 2026-04-01 00:33:11 -07:00
jess fc0ff900f1 strip MIDI/BLE transport, make UDP the sole connection method
Remove ble.rs, midir dependency, TransportMode enum, and all MIDI-related
Message variants (OpenMidiSetup, RefreshMidi, ToggleTransport). Simplify
subscription to UDP-only path. Rename BleReady/BleStatus/BleData to
DeviceReady/DeviceStatus/DeviceData. Replace transport toggle UI with
always-visible UDP address field and Reconnect button.
2026-04-01 00:30:16 -07:00
jess 06f4fa8e71 export/import: TOML format with human-readable keys, cross-platform compatible 2026-03-31 21:45:15 -07:00
jess 34b298dfe2 apps: sync cell constant K with device, verify export format compatibility 2026-03-31 20:59:44 -07:00
jess 4e0dfecce0 firmware: NVS-backed cell constant K — set/get over BLE for app sync 2026-03-31 20:48:20 -07:00
jess f5dd536ca4 merge integration 2026-03-31 20:43:06 -07:00
jess 0ff291549e cue-ios: calibration calculator -- solution prep, conductivity, cell constant from EIS 2026-03-31 20:42:39 -07:00
jess 6351a8baa0 cue: calibration calculator — solution prep, conductivity, cell constant from EIS 2026-03-31 20:41:39 -07:00
jess 4beb9f4408 WiFi STA to home network, configurable UDP address in Cue, JSON session export/import 2026-03-31 20:23:55 -07:00
jess f36989e3f9 cue: add UDP transport — connect to ESP32 WiFi AP as alternative to BLE MIDI 2026-03-31 19:56:39 -07:00
jess 201d9881ce firmware: WiFi AP + UDP transport — parallel to BLE, same protocol 2026-03-31 19:55:24 -07:00
38 changed files with 2696 additions and 1662 deletions

View File

@ -5,7 +5,7 @@ export IDF_PYTHON_ENV_PATH := $(HOME)/.espressif/python_env/idf6.0_py3.12_env
IDF = . $(IDF_PATH)/export.sh > /dev/null 2>&1 && idf.py IDF = . $(IDF_PATH)/export.sh > /dev/null 2>&1 && idf.py
.PHONY: all flash monitor clean menuconfig size erase select .PHONY: all flash monitor clean menuconfig size erase select fcf
all: all:
$(IDF) build $(IDF) build
@ -28,6 +28,10 @@ size:
erase: erase:
$(IDF) -p $(PORT) erase-flash $(IDF) -p $(PORT) erase-flash
fcf:
rm -rf build sdkconfig
$(IDF) -p $(PORT) flash monitor
select: select:
@devs=($$(ls /dev/cu.usb* 2>/dev/null)); \ @devs=($$(ls /dev/cu.usb* 2>/dev/null)); \
if [ $${#devs[@]} -eq 0 ]; then \ if [ $${#devs[@]} -eq 0 ]; then \

View File

@ -1,6 +1,5 @@
import Foundation import Foundation
import Observation import Observation
import Combine
enum Tab: String, CaseIterable, Identifiable { enum Tab: String, CaseIterable, Identifiable {
case eis = "EIS" case eis = "EIS"
@ -8,6 +7,7 @@ enum Tab: String, CaseIterable, Identifiable {
case amp = "Amperometry" case amp = "Amperometry"
case chlorine = "Chlorine" case chlorine = "Chlorine"
case ph = "pH" case ph = "pH"
case calibrate = "Calibrate"
case sessions = "Sessions" case sessions = "Sessions"
case connection = "Connection" case connection = "Connection"
@ -18,12 +18,13 @@ enum Tab: String, CaseIterable, Identifiable {
@Observable @Observable
final class AppState { final class AppState {
let ble: BLEManager let transport: UDPManager
var tab: Tab = .eis var tab: Tab = .eis
var status: String = "Disconnected" var status: String = "Disconnected"
var bleConnected: Bool = false
var tempC: Float = 25.0 var tempC: Float = 25.0
var connected: Bool { transport.state == .connected }
// EIS // EIS
var eisPoints: [EisPoint] = [] var eisPoints: [EisPoint] = []
var sweepTotal: UInt16 = 0 var sweepTotal: UInt16 = 0
@ -84,44 +85,30 @@ final class AppState {
// Session // Session
var currentSessionId: Int64? = nil var currentSessionId: Int64? = nil
// Calibration
var calVolumeGal: Double = 25
var calNaclPpm: String = "2500"
var calClPpm: String = "5"
var calBleachPct: String = "7.825"
var calTempC: String = "40"
var calCellConstant: Double? = nil
var calRs: Double? = nil
// Clean // Clean
var cleanV: String = "1200" var cleanV: String = "1200"
var cleanDur: String = "30" var cleanDur: String = "30"
// Temperature polling
private var tempTimer: Timer?
init() { init() {
ble = BLEManager() transport = UDPManager()
ble.setMessageHandler { [weak self] msg in transport.setMessageHandler { [weak self] msg in
self?.handleMessage(msg) self?.handleMessage(msg)
} }
startTempPolling()
}
// MARK: - BLE connection tracking
func updateConnectionState() {
let connected = ble.state == .connected
if connected != bleConnected {
bleConnected = connected
status = ble.state.rawValue
}
}
// MARK: - Temperature polling
private func startTempPolling() {
tempTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
guard let self, self.bleConnected else { return }
self.send(buildSysexGetTemp())
}
} }
// MARK: - Send helper // MARK: - Send helper
func send(_ sysex: [UInt8]) { func send(_ sysex: [UInt8]) {
ble.sendCommand(sysex) transport.send(sysex)
} }
// MARK: - Message handler // MARK: - Message handler
@ -260,6 +247,10 @@ final class AppState {
if !hasRefs { if !hasRefs {
status = "No device refs" status = "No device refs"
} }
case .cellK(let k):
calCellConstant = Double(k)
status = String(format: "Device cell constant: %.4f cm\u{207B}\u{00B9}", k)
} }
} }
@ -361,7 +352,7 @@ final class AppState {
case .amp: ampRef = nil; status = "Amp reference cleared" case .amp: ampRef = nil; status = "Amp reference cleared"
case .chlorine: clRef = nil; status = "Chlorine reference cleared" case .chlorine: clRef = nil; status = "Chlorine reference cleared"
case .ph: phRef = nil; status = "pH reference cleared" case .ph: phRef = nil; status = "pH reference cleared"
case .sessions, .connection: break case .calibrate, .sessions, .connection: break
} }
} }
@ -402,7 +393,7 @@ final class AppState {
case .amp: ampRef != nil case .amp: ampRef != nil
case .chlorine: clRef != nil case .chlorine: clRef != nil
case .ph: phRef != nil case .ph: phRef != nil
case .sessions, .connection: false case .calibrate, .sessions, .connection: false
} }
} }
@ -413,7 +404,7 @@ final class AppState {
case .amp: !ampPoints.isEmpty case .amp: !ampPoints.isEmpty
case .chlorine: clResult != nil case .chlorine: clResult != nil
case .ph: phResult != nil case .ph: phResult != nil
case .sessions, .connection: false case .calibrate, .sessions, .connection: false
} }
} }

View File

@ -1,226 +0,0 @@
/// 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 {
nonisolated(unsafe) static let midiServiceUUID = CBUUID(string: "03B80E5A-EDE8-4B33-A751-6CE34EC4C700")
nonisolated(unsafe) 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"
}
struct DiscoveredDevice: Identifiable {
let id: UUID
let peripheral: CBPeripheral
let name: String
let rssi: Int
var serviceUUIDs: [CBUUID]
}
var state: ConnectionState = .disconnected
var lastMessage: EisMessage?
var discoveredDevices: [DiscoveredDevice] = []
private var centralManager: CBCentralManager!
private(set) 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 {
print("[BLE] can't scan, state: \(centralManager.state.rawValue)")
return
}
print("[BLE] starting scan (no filter)")
state = .scanning
discoveredDevices.removeAll()
centralManager.scanForPeripherals(
withServices: nil,
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
}
func stopScanning() {
centralManager.stopScan()
if state == .scanning { state = .disconnected }
}
func connectTo(_ device: DiscoveredDevice) {
centralManager.stopScan()
peripheral = device.peripheral
state = .connecting
print("[BLE] connecting to \(device.name)")
centralManager.connect(device.peripheral, 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) {
print("[BLE] centralManager state: \(central.state.rawValue)")
if central.state == .poweredOn {
startScanning()
}
}
func centralManager(
_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "Unknown"
let svcUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
print("[BLE] found: \(name) rssi:\(RSSI) services:\(svcUUIDs)")
if discoveredDevices.contains(where: { $0.id == peripheral.identifier }) { return }
let device = DiscoveredDevice(
id: peripheral.identifier,
peripheral: peripheral,
name: name,
rssi: RSSI.intValue,
serviceUUIDs: svcUUIDs
)
discoveredDevices.append(device)
}
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)
}
}

View File

@ -7,9 +7,6 @@ struct CueIOSApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView(state: state) ContentView(state: state)
.onReceive(Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()) { _ in
state.updateConnectionState()
}
} }
} }
} }

View File

@ -14,12 +14,8 @@
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>NSBluetoothAlwaysUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>EIS4 uses Bluetooth to communicate with the impedance analyzer</string> <string>Cue connects to the EIS4 impedance analyzer over WiFi</string>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
</array>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>

View File

@ -24,6 +24,7 @@ let RSP_CL_RESULT: UInt8 = 0x0D
let RSP_CL_END: UInt8 = 0x0E let RSP_CL_END: UInt8 = 0x0E
let RSP_PH_RESULT: UInt8 = 0x0F let RSP_PH_RESULT: UInt8 = 0x0F
let RSP_TEMP: UInt8 = 0x10 let RSP_TEMP: UInt8 = 0x10
let RSP_CELL_K: UInt8 = 0x11
let RSP_REF_FRAME: UInt8 = 0x20 let RSP_REF_FRAME: UInt8 = 0x20
let RSP_REF_LP_RANGE: UInt8 = 0x21 let RSP_REF_LP_RANGE: UInt8 = 0x21
let RSP_REFS_DONE: UInt8 = 0x22 let RSP_REFS_DONE: UInt8 = 0x22
@ -43,6 +44,8 @@ let CMD_STOP_AMP: UInt8 = 0x22
let CMD_START_CL: UInt8 = 0x23 let CMD_START_CL: UInt8 = 0x23
let CMD_START_PH: UInt8 = 0x24 let CMD_START_PH: UInt8 = 0x24
let CMD_START_CLEAN: UInt8 = 0x25 let CMD_START_CLEAN: UInt8 = 0x25
let CMD_SET_CELL_K: UInt8 = 0x28
let CMD_GET_CELL_K: UInt8 = 0x29
let CMD_START_REFS: UInt8 = 0x30 let CMD_START_REFS: UInt8 = 0x30
let CMD_GET_REFS: UInt8 = 0x31 let CMD_GET_REFS: UInt8 = 0x31
let CMD_CLEAR_REFS: UInt8 = 0x32 let CMD_CLEAR_REFS: UInt8 = 0x32
@ -119,6 +122,7 @@ enum EisMessage {
case refLpRange(mode: UInt8, lowIdx: UInt8, highIdx: UInt8) case refLpRange(mode: UInt8, lowIdx: UInt8, highIdx: UInt8)
case refsDone case refsDone
case refStatus(hasRefs: Bool) case refStatus(hasRefs: Bool)
case cellK(Float)
} }
// MARK: - Response parser // MARK: - Response parser
@ -247,6 +251,9 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? {
case RSP_REF_STATUS where p.count >= 1: case RSP_REF_STATUS where p.count >= 1:
return .refStatus(hasRefs: p[0] != 0) return .refStatus(hasRefs: p[0] != 0)
case RSP_CELL_K where p.count >= 5:
return .cellK(decodeFloat(p, at: 0))
default: default:
return nil return nil
} }
@ -353,3 +360,14 @@ func buildSysexGetRefs() -> [UInt8] {
func buildSysexClearRefs() -> [UInt8] { func buildSysexClearRefs() -> [UInt8] {
[0xF0, sysexMfr, CMD_CLEAR_REFS, 0xF7] [0xF0, sysexMfr, CMD_CLEAR_REFS, 0xF7]
} }
func buildSysexSetCellK(_ k: Float) -> [UInt8] {
var sx: [UInt8] = [0xF0, sysexMfr, CMD_SET_CELL_K]
sx.append(contentsOf: encodeFloat(k))
sx.append(0xF7)
return sx
}
func buildSysexGetCellK() -> [UInt8] {
[0xF0, sysexMfr, CMD_GET_CELL_K, 0xF7]
}

View File

@ -250,4 +250,419 @@ final class Storage: @unchecked Sendable {
} }
return observation.start(in: dbQueue, onError: { _ in }, onChange: onChange) return observation.start(in: dbQueue, onError: { _ in }, onChange: onChange)
} }
// MARK: - TOML export/import
private static let tomlDateFmt: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone.current
return f
}()
func exportSession(_ id: Int64) throws -> String {
let session = try dbQueue.read { db in
try Session.fetchOne(db, key: id)
}
guard let session else { throw StorageError.notFound }
var out = "[session]\n"
out += "name = \(tomlQuote(session.label ?? ""))\n"
out += "notes = \(tomlQuote(session.notes ?? ""))\n"
out += "created_at = \(tomlQuote(Self.tomlDateFmt.string(from: session.startedAt)))\n"
let measurements = try fetchMeasurements(sessionId: id)
for m in measurements {
out += "\n[[measurement]]\n"
out += "type = \(tomlQuote(m.type))\n"
out += "created_at = \(tomlQuote(Self.tomlDateFmt.string(from: m.startedAt)))\n"
if let configData = m.config,
let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any] {
out += "\n[measurement.params]\n"
for (k, v) in config.sorted(by: { $0.key < $1.key }) {
out += "\(k) = \(tomlValue(v))\n"
}
}
guard let mid = m.id else { continue }
let mtype = MeasurementType(rawValue: m.type)
let points = try fetchDataPoints(measurementId: mid)
let decoder = JSONDecoder()
switch mtype {
case .eis:
for dp in points {
if let p = try? decoder.decode(EisPoint.self, from: dp.payload) {
out += "\n[[measurement.data]]\n"
out += "\"Frequency (Hz)\" = \(tomlFloat(p.freqHz))\n"
out += "\"|Z| (Ohm)\" = \(tomlFloat(p.magOhms))\n"
out += "\"Phase (deg)\" = \(tomlFloat(p.phaseDeg))\n"
out += "\"Re (Ohm)\" = \(tomlFloat(p.zReal))\n"
out += "\"Im (Ohm)\" = \(tomlFloat(p.zImag))\n"
}
}
case .lsv:
for dp in points {
if let p = try? decoder.decode(LsvPoint.self, from: dp.payload) {
out += "\n[[measurement.data]]\n"
out += "\"Voltage (mV)\" = \(tomlFloat(p.vMv))\n"
out += "\"Current (uA)\" = \(tomlFloat(p.iUa))\n"
}
}
case .amp:
for dp in points {
if let p = try? decoder.decode(AmpPoint.self, from: dp.payload) {
out += "\n[[measurement.data]]\n"
out += "\"Time (ms)\" = \(tomlFloat(p.tMs))\n"
out += "\"Current (uA)\" = \(tomlFloat(p.iUa))\n"
}
}
case .chlorine:
for dp in points {
if let p = try? decoder.decode(ClPoint.self, from: dp.payload) {
out += "\n[[measurement.data]]\n"
out += "\"Time (ms)\" = \(tomlFloat(p.tMs))\n"
out += "\"Current (uA)\" = \(tomlFloat(p.iUa))\n"
out += "\"Phase\" = \(p.phase)\n"
}
}
if let resultData = m.resultSummary,
let r = try? decoder.decode(ClResult.self, from: resultData) {
out += "\n[measurement.result]\n"
out += "\"Free Cl (uA)\" = \(tomlFloat(r.iFreeUa))\n"
out += "\"Total Cl (uA)\" = \(tomlFloat(r.iTotalUa))\n"
}
case .ph:
for dp in points {
if let p = try? decoder.decode(PhResult.self, from: dp.payload) {
out += "\n[[measurement.data]]\n"
out += "\"OCP (mV)\" = \(tomlFloat(p.vOcpMv))\n"
out += "\"pH\" = \(tomlFloat(p.ph))\n"
out += "\"Temperature (C)\" = \(tomlFloat(p.tempC))\n"
}
}
case nil:
break
}
}
return out
}
func importSession(from toml: String) throws -> Int64 {
let parsed = TOMLParser.parse(toml)
guard let sessionDict = parsed["session"] as? [String: Any] else {
throw StorageError.parseError("missing [session]")
}
let name = sessionDict["name"] as? String ?? ""
let notes = sessionDict["notes"] as? String ?? ""
var session = Session(
startedAt: Self.tomlDateFmt.date(from: sessionDict["created_at"] as? String ?? "") ?? Date(),
label: name.isEmpty ? nil : name,
notes: notes.isEmpty ? nil : notes
)
try dbQueue.write { db in try session.insert(db) }
guard let sid = session.id else { throw StorageError.parseError("insert failed") }
guard let measurements = parsed["measurement"] as? [[String: Any]] else { return sid }
let encoder = JSONEncoder()
for mDict in measurements {
let typeStr = mDict["type"] as? String ?? "eis"
let createdStr = mDict["created_at"] as? String ?? ""
let created = Self.tomlDateFmt.date(from: createdStr) ?? Date()
var configData: Data? = nil
if let params = mDict["params"] as? [String: Any] {
configData = try? JSONSerialization.data(withJSONObject: params)
}
var meas = Measurement(
sessionId: sid,
type: typeStr,
startedAt: created,
config: configData
)
try dbQueue.write { db in try meas.insert(db) }
guard let mid = meas.id else { continue }
let mtype = MeasurementType(rawValue: typeStr)
guard let dataRows = mDict["data"] as? [[String: Any]] else { continue }
try dbQueue.write { db in
for (idx, row) in dataRows.enumerated() {
let payload: Data
switch mtype {
case .eis:
payload = try encoder.encode(EisPoint(
freqHz: floatVal(row, "Frequency (Hz)"),
magOhms: floatVal(row, "|Z| (Ohm)"),
phaseDeg: floatVal(row, "Phase (deg)"),
zReal: floatVal(row, "Re (Ohm)"),
zImag: floatVal(row, "Im (Ohm)"),
rtiaMagBefore: 0, rtiaMagAfter: 0,
revMag: 0, revPhase: 0, pctErr: 0
))
case .lsv:
payload = try encoder.encode(LsvPoint(
vMv: floatVal(row, "Voltage (mV)"),
iUa: floatVal(row, "Current (uA)")
))
case .amp:
payload = try encoder.encode(AmpPoint(
tMs: floatVal(row, "Time (ms)"),
iUa: floatVal(row, "Current (uA)")
))
case .chlorine:
payload = try encoder.encode(ClPoint(
tMs: floatVal(row, "Time (ms)"),
iUa: floatVal(row, "Current (uA)"),
phase: UInt8(intVal(row, "Phase"))
))
case .ph:
payload = try encoder.encode(PhResult(
vOcpMv: floatVal(row, "OCP (mV)"),
ph: floatVal(row, "pH"),
tempC: floatVal(row, "Temperature (C)")
))
case nil:
continue
}
var dp = DataPoint(measurementId: mid, index: idx, payload: payload)
try dp.insert(db)
}
}
if mtype == .chlorine, let resultDict = mDict["result"] as? [String: Any] {
let r = ClResult(
iFreeUa: floatVal(resultDict, "Free Cl (uA)"),
iTotalUa: floatVal(resultDict, "Total Cl (uA)")
)
try setMeasurementResult(mid, result: r)
}
if mtype == .ph, let dataRows = mDict["data"] as? [[String: Any]], let first = dataRows.first {
let r = PhResult(
vOcpMv: floatVal(first, "OCP (mV)"),
ph: floatVal(first, "pH"),
tempC: floatVal(first, "Temperature (C)")
)
try setMeasurementResult(mid, result: r)
}
}
return sid
}
}
enum StorageError: Error {
case notFound
case parseError(String)
}
// MARK: - TOML helpers
private func tomlQuote(_ s: String) -> String {
let escaped = s.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "\n", with: "\\n")
return "\"\(escaped)\""
}
private func tomlFloat(_ v: Float) -> String {
if v == v.rounded() && abs(v) < 1e15 {
return String(format: "%.1f", v)
}
return String(v)
}
private func tomlValue(_ v: Any) -> String {
switch v {
case let s as String: return tomlQuote(s)
case let n as NSNumber:
if CFGetTypeID(n) == CFBooleanGetTypeID() { return n.boolValue ? "true" : "false" }
let d = n.doubleValue
if d == d.rounded() && abs(d) < 1e15 && !"\(n)".contains(".") {
return "\(n)"
}
return String(format: "%g", d)
default: return tomlQuote("\(v)")
}
}
private func floatVal(_ dict: [String: Any], _ key: String) -> Float {
if let n = dict[key] as? NSNumber { return n.floatValue }
return 0
}
private func intVal(_ dict: [String: Any], _ key: String) -> Int {
if let n = dict[key] as? NSNumber { return n.intValue }
return 0
}
// MARK: - Minimal TOML parser
enum TOMLParser {
static func parse(_ input: String) -> [String: Any] {
var root: [String: Any] = [:]
var currentSection: [String] = []
var arrayCounters: [String: Int] = [:]
let lines = input.components(separatedBy: .newlines)
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
if let match = parseArrayTable(trimmed) {
let parts = match.components(separatedBy: ".")
let rootKey = parts[0]
let counterKey = match
if arrayCounters[counterKey] == nil {
setNested(&root, path: [rootKey], value: [[String: Any]]())
arrayCounters[counterKey] = 0
} else {
arrayCounters[counterKey]! += 1
}
if var arr = getNested(root, path: [rootKey]) as? [[String: Any]] {
if arr.count <= arrayCounters[counterKey]! {
arr.append([:])
setNested(&root, path: [rootKey], value: arr)
}
}
currentSection = parts
continue
}
if let match = parseTable(trimmed) {
currentSection = match.components(separatedBy: ".")
continue
}
if let (key, value) = parseKeyValue(trimmed) {
let path = resolvePath(currentSection, arrayCounters: arrayCounters)
var target = (getNested(root, path: path) as? [String: Any]) ?? [:]
target[key] = value
if path.isEmpty {
for (k, v) in target { root[k] = v }
} else {
setNested(&root, path: path, value: target)
}
}
}
return root
}
private static func parseArrayTable(_ line: String) -> String? {
guard line.hasPrefix("[[") && line.hasSuffix("]]") else { return nil }
let inner = line.dropFirst(2).dropLast(2).trimmingCharacters(in: .whitespaces)
return inner.isEmpty ? nil : inner
}
private static func parseTable(_ line: String) -> String? {
guard line.hasPrefix("[") && line.hasSuffix("]") && !line.hasPrefix("[[") else { return nil }
let inner = line.dropFirst(1).dropLast(1).trimmingCharacters(in: .whitespaces)
return inner.isEmpty ? nil : inner
}
private static func parseKeyValue(_ line: String) -> (String, Any)? {
guard let eqIdx = line.firstIndex(of: "=") else { return nil }
var key = String(line[line.startIndex..<eqIdx]).trimmingCharacters(in: .whitespaces)
let valStr = String(line[line.index(after: eqIdx)...]).trimmingCharacters(in: .whitespaces)
if key.hasPrefix("\"") && key.hasSuffix("\"") {
key = String(key.dropFirst(1).dropLast(1))
}
return (key, parseValue(valStr))
}
private static func parseValue(_ s: String) -> Any {
if s.hasPrefix("\"") && s.hasSuffix("\"") {
var inner = String(s.dropFirst(1).dropLast(1))
inner = inner.replacingOccurrences(of: "\\n", with: "\n")
inner = inner.replacingOccurrences(of: "\\\"", with: "\"")
inner = inner.replacingOccurrences(of: "\\\\", with: "\\")
return inner
}
if s == "true" { return true }
if s == "false" { return false }
if s.contains(".") || s.contains("e") || s.contains("E") {
if let d = Double(s) { return NSNumber(value: d) }
}
if let i = Int(s) { return NSNumber(value: i) }
return s
}
private static func resolvePath(
_ section: [String], arrayCounters: [String: Int]
) -> [String] {
guard !section.isEmpty else { return [] }
let rootKey = section[0]
let fullKey = section.joined(separator: ".")
var path: [String] = [rootKey]
if let idx = arrayCounters[rootKey] {
path.append("\(idx)")
}
if section.count > 1 {
let subKey = section[1...].joined(separator: ".")
if let idx = arrayCounters[fullKey] {
path.append(subKey)
path.append("\(idx)")
} else {
for part in section[1...] {
path.append(part)
}
}
}
return path
}
private static func getNested(_ dict: [String: Any], path: [String]) -> Any? {
var current: Any = dict
for component in path {
if let idx = Int(component), let arr = current as? [Any], idx < arr.count {
current = arr[idx]
} else if let d = current as? [String: Any], let val = d[component] {
current = val
} else {
return nil
}
}
return current
}
private static func setNested(_ dict: inout [String: Any], path: [String], value: Any) {
guard !path.isEmpty else { return }
if path.count == 1 {
dict[path[0]] = value
return
}
let key = path[0]
let rest = Array(path[1...])
if let idxStr = rest.first, let idx = Int(idxStr), var arr = dict[key] as? [Any] {
while arr.count <= idx { arr.append([String: Any]()) }
if rest.count == 1 {
arr[idx] = value
} else {
var sub = (arr[idx] as? [String: Any]) ?? [:]
setNested(&sub, path: Array(rest[1...]), value: value)
arr[idx] = sub
}
dict[key] = arr
} else {
var sub = (dict[key] as? [String: Any]) ?? [:]
setNested(&sub, path: rest, value: value)
dict[key] = sub
}
}
} }

View File

@ -0,0 +1,195 @@
/// UDP transport for EIS4 firmware communication.
/// Matches the desktop Cue protocol: raw SysEx frames over UDP port 5941.
import Foundation
import Network
@Observable
final class UDPManager: @unchecked Sendable {
enum ConnectionState: String {
case disconnected = "Disconnected"
case connecting = "Connecting..."
case connected = "Connected"
}
private static let defaultAddr = "192.168.4.1"
private static let defaultPort: UInt16 = 5941
private static let keepaliveInterval: TimeInterval = 5
private static let timeout: TimeInterval = 10
private static let addrKey = "eis4_udp_addr"
var state: ConnectionState = .disconnected
var address: String
var port: UInt16
private var connection: NWConnection?
private let queue = DispatchQueue(label: "udp.eis4", qos: .userInitiated)
private var onMessage: ((EisMessage) -> Void)?
private var keepaliveTimer: Timer?
private var timeoutTimer: Timer?
private var lastReceived: Date = .distantPast
init() {
let saved = UserDefaults.standard.string(forKey: Self.addrKey) ?? ""
if saved.isEmpty {
address = Self.defaultAddr
port = Self.defaultPort
} else {
let parts = saved.split(separator: ":")
address = String(parts[0])
port = parts.count > 1 ? UInt16(parts[1]) ?? Self.defaultPort : Self.defaultPort
}
}
func setMessageHandler(_ handler: @escaping (EisMessage) -> Void) {
onMessage = handler
}
// MARK: - Connection
func connect() {
disconnect()
state = .connecting
let addrStr = "\(address):\(port)"
UserDefaults.standard.set(addrStr, forKey: Self.addrKey)
let host = NWEndpoint.Host(address)
let nwPort = NWEndpoint.Port(rawValue: port) ?? NWEndpoint.Port(rawValue: Self.defaultPort)!
let params = NWParameters.udp
params.allowLocalEndpointReuse = true
let conn = NWConnection(host: host, port: nwPort, using: params)
connection = conn
conn.stateUpdateHandler = { [weak self] newState in
DispatchQueue.main.async {
self?.handleStateChange(newState)
}
}
conn.start(queue: queue)
}
func disconnect() {
stopTimers()
connection?.cancel()
connection = nil
state = .disconnected
}
func send(_ sysex: [UInt8]) {
guard let conn = connection else { return }
let data = Data(sysex)
conn.send(content: data, completion: .contentProcessed({ _ in }))
}
// MARK: - State handling
private func handleStateChange(_ newState: NWConnection.State) {
switch newState {
case .ready:
state = .connected
lastReceived = Date()
send(buildSysexGetTemp())
send(buildSysexGetConfig())
send(buildSysexGetCellK())
startTimers()
receiveLoop()
case .failed, .cancelled:
state = .disconnected
stopTimers()
case .waiting:
state = .connecting
default:
break
}
}
// MARK: - Receive
private func receiveLoop() {
guard let conn = connection else { return }
conn.receiveMessage { [weak self] content, _, _, error in
guard let self else { return }
if error != nil {
DispatchQueue.main.async {
if self.state == .connected {
self.state = .disconnected
}
}
return
}
guard let data = content, !data.isEmpty else {
self.receiveLoop()
return
}
DispatchQueue.main.async {
self.lastReceived = Date()
}
let frames = Self.extractSysExFrames(data)
for frame in frames {
if let msg = parseSysex(frame) {
DispatchQueue.main.async {
self.onMessage?(msg)
}
}
}
self.receiveLoop()
}
}
/// Extract SysEx payloads from a UDP datagram.
/// Scans for F0..F7 boundaries. Returns inner bytes (between F0 and F7, exclusive).
static func extractSysExFrames(_ data: Data) -> [[UInt8]] {
let bytes = Array(data)
var frames: [[UInt8]] = []
var i = 0
while i < bytes.count {
if bytes[i] == 0xF0 {
if let end = bytes[i...].firstIndex(of: 0xF7) {
let payload = Array(bytes[(i + 1)..<end])
if !payload.isEmpty {
frames.append(payload)
}
i = end + 1
continue
}
}
i += 1
}
return frames
}
// MARK: - Timers
private func startTimers() {
keepaliveTimer = Timer.scheduledTimer(withTimeInterval: Self.keepaliveInterval, repeats: true) { [weak self] _ in
self?.send(buildSysexGetTemp())
}
timeoutTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
guard let self, self.state == .connected else { return }
if Date().timeIntervalSince(self.lastReceived) > Self.timeout {
self.state = .disconnected
self.stopTimers()
self.connection?.cancel()
self.connection = nil
}
}
}
private func stopTimers() {
keepaliveTimer?.invalidate()
keepaliveTimer = nil
timeoutTimer?.invalidate()
timeoutTimer = nil
}
}

View File

@ -0,0 +1,144 @@
import SwiftUI
struct CalibrateView: View {
@Bindable var state: AppState
private var volumeGal: Double { state.calVolumeGal }
private var naclPpm: Double { Double(state.calNaclPpm) ?? 0 }
private var clPpm: Double { Double(state.calClPpm) ?? 0 }
private var bleachPct: Double { Double(state.calBleachPct) ?? 0 }
private var tempC: Double { Double(state.calTempC) ?? 25 }
var body: some View {
Form {
inputSection
resultsSection
cellConstantSection
}
.navigationTitle("Calibrate")
}
// MARK: - Inputs
private var inputSection: some View {
Section("Solution Parameters") {
Stepper("Volume: \(Int(state.calVolumeGal)) gal",
value: $state.calVolumeGal, in: 5...30, step: 5)
HStack {
Text("NaCl ppm")
Spacer()
TextField("ppm", text: $state.calNaclPpm)
.multilineTextAlignment(.trailing)
.frame(width: 80)
#if os(iOS)
.keyboardType(.decimalPad)
#endif
}
HStack {
Text("Free Cl ppm")
Spacer()
TextField("ppm", text: $state.calClPpm)
.multilineTextAlignment(.trailing)
.frame(width: 80)
#if os(iOS)
.keyboardType(.decimalPad)
#endif
}
HStack {
Text("Bleach %")
Spacer()
TextField("%", text: $state.calBleachPct)
.multilineTextAlignment(.trailing)
.frame(width: 80)
#if os(iOS)
.keyboardType(.decimalPad)
#endif
}
HStack {
Text("Temperature")
Spacer()
TextField("\u{00B0}C", text: $state.calTempC)
.multilineTextAlignment(.trailing)
.frame(width: 80)
#if os(iOS)
.keyboardType(.decimalPad)
#endif
}
}
}
// MARK: - Computed results
private var resultsSection: some View {
Section("Preparation") {
let salt = saltGrams(volumeGal: volumeGal, ppm: naclPpm)
let tbsp = salt / 17.0
Text(String(format: "Salt: %.1f g (%.1f tbsp)", salt, tbsp))
let bleach = bleachMl(volumeGal: volumeGal, clPpm: clPpm, bleachPct: bleachPct)
let tsp = bleach / 5.0
Text(String(format: "Bleach: %.1f mL (%.1f tsp)", bleach, tsp))
let kappa = theoreticalConductivity(naclPpm: naclPpm, tempC: tempC)
Text(String(format: "Theoretical \u{03BA}: %.2f mS/cm at %.0f\u{00B0}C", kappa, tempC))
}
}
// MARK: - Cell constant from EIS
private var cellConstantSection: some View {
Section("Cell Constant") {
Button("Calculate K from Last Sweep") {
guard let rs = extractRs(points: state.eisPoints) else {
state.status = "No valid EIS data for Rs"
return
}
let kappa = theoreticalConductivity(naclPpm: naclPpm, tempC: tempC)
let k = cellConstant(kappaMsCm: kappa, rsOhm: Double(rs))
state.calRs = Double(rs)
state.calCellConstant = k
state.send(buildSysexSetCellK(Float(k)))
state.status = String(format: "K = %.4f cm\u{207B}\u{00B9} (Rs = %.1f \u{2126})", k, rs)
}
.disabled(state.eisPoints.isEmpty)
if let rs = state.calRs {
Text(String(format: "Rs: %.1f \u{2126}", rs))
}
if let k = state.calCellConstant {
Text(String(format: "Cell constant K: %.4f cm\u{207B}\u{00B9}", k))
}
}
}
// MARK: - Calculations
private func saltGrams(volumeGal: Double, ppm: Double) -> Double {
let liters = volumeGal * 3.78541
return ppm * liters / 1000.0
}
private func bleachMl(volumeGal: Double, clPpm: Double, bleachPct: Double) -> Double {
let liters = volumeGal * 3.78541
let clNeededMg = clPpm * liters
let bleachMgPerMl = bleachPct * 10.0
return clNeededMg / bleachMgPerMl
}
private func theoreticalConductivity(naclPpm: Double, tempC: Double) -> Double {
let kappa25 = naclPpm * 2.0 / 1000.0
return kappa25 * (1.0 + 0.0212 * (tempC - 25.0))
}
private func extractRs(points: [EisPoint]) -> Float? {
points.map(\.zReal).filter { $0.isFinite && $0 > 0 }.min()
}
private func cellConstant(kappaMsCm: Double, rsOhm: Double) -> Double {
(kappaMsCm / 1000.0) * rsOhm
}
}

View File

@ -25,7 +25,7 @@ struct StatusBar: View {
private var connectionIndicator: some View { private var connectionIndicator: some View {
Circle() Circle()
.fill(state.bleConnected ? Color.green : Color.red) .fill(state.connected ? Color.green : Color.red)
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
} }

View File

@ -2,33 +2,38 @@ import SwiftUI
struct ConnectionView: View { struct ConnectionView: View {
var state: AppState var state: AppState
@State private var addressField: String = ""
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
header header
Divider() Divider()
deviceList connectionForm
Spacer()
} }
.navigationTitle("Connection") .navigationTitle("Connection")
.onAppear { addressField = state.transport.address }
} }
// MARK: - Header
private var header: some View { private var header: some View {
HStack { HStack {
Circle() Circle()
.fill(statusColor) .fill(statusColor)
.frame(width: 10, height: 10) .frame(width: 10, height: 10)
Text(state.ble.state.rawValue) Text(state.transport.state.rawValue)
.font(.headline) .font(.headline)
Spacer() Spacer()
if state.ble.state == .connected { if state.transport.state == .connected {
Button("Disconnect") { state.ble.disconnect() } Button("Disconnect") { state.transport.disconnect() }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(.red) .tint(.red)
} else if state.ble.state == .scanning { } else if state.transport.state == .connecting {
Button("Stop") { state.ble.stopScanning() } ProgressView()
.buttonStyle(.bordered) .controlSize(.small)
} else { } else {
Button("Scan") { state.ble.startScanning() } Button("Connect") { connectToDevice() }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
} }
} }
@ -36,52 +41,49 @@ struct ConnectionView: View {
} }
private var statusColor: Color { private var statusColor: Color {
switch state.ble.state { switch state.transport.state {
case .connected: .green case .connected: .green
case .scanning: .orange
case .connecting: .yellow case .connecting: .yellow
case .disconnected: .red case .disconnected: .red
} }
} }
private var deviceList: some View { // MARK: - Connection form
List {
if state.ble.discoveredDevices.isEmpty && state.ble.state == .scanning { private var connectionForm: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("Firmware Address")
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
HStack { HStack {
ProgressView() TextField("192.168.4.1", text: $addressField)
Text("Scanning for devices...") .textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.keyboardType(.numbersAndPunctuation)
Text(":\(state.transport.port)")
.font(.subheadline.monospacedDigit())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
ForEach(state.ble.discoveredDevices) { device in Button("Connect") { connectToDevice() }
Button { .buttonStyle(.borderedProminent)
state.ble.connectTo(device) .disabled(state.transport.state == .connecting)
} label: {
HStack { Text("Connect to the EIS4 WiFi network, then tap Connect.")
VStack(alignment: .leading, spacing: 2) {
Text(device.name)
.font(.body.weight(.medium))
if !device.serviceUUIDs.isEmpty {
Text(device.serviceUUIDs.map(\.uuidString).joined(separator: ", "))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
Text("\(device.rssi) dBm")
.font(.caption)
.foregroundStyle(.secondary)
if device.name == "EIS4" {
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
.font(.caption) .font(.caption)
} .foregroundStyle(Color(white: 0.4))
} }
} .padding()
.disabled(state.ble.state == .connecting) }
}
} private func connectToDevice() {
let addr = addressField.trimmingCharacters(in: .whitespaces)
if !addr.isEmpty {
state.transport.address = addr
}
state.transport.connect()
} }
} }

View File

@ -38,6 +38,9 @@ struct ContentView: View {
sidebarButton(.chlorine, "Chlorine", "drop.fill") sidebarButton(.chlorine, "Chlorine", "drop.fill")
sidebarButton(.ph, "pH", "scalemass") sidebarButton(.ph, "pH", "scalemass")
} }
Section("Tools") {
sidebarButton(.calibrate, "Calibrate", "lines.measurement.horizontal")
}
Section("Data") { Section("Data") {
sidebarButton(.sessions, "Sessions", "folder") sidebarButton(.sessions, "Sessions", "folder")
sidebarButton(.connection, "Connection", "antenna.radiowaves.left.and.right") sidebarButton(.connection, "Connection", "antenna.radiowaves.left.and.right")
@ -124,6 +127,10 @@ struct ContentView: View {
.tabItem { Label("pH", systemImage: "scalemass") } .tabItem { Label("pH", systemImage: "scalemass") }
.tag(Tab.ph) .tag(Tab.ph)
CalibrateView(state: state)
.tabItem { Label("Calibrate", systemImage: "lines.measurement.horizontal") }
.tag(Tab.calibrate)
SessionView(state: state) SessionView(state: state)
.tabItem { Label("Sessions", systemImage: "folder") } .tabItem { Label("Sessions", systemImage: "folder") }
.tag(Tab.sessions) .tag(Tab.sessions)
@ -144,6 +151,7 @@ struct ContentView: View {
case .amp: AmpView(state: state) case .amp: AmpView(state: state)
case .chlorine: ChlorineView(state: state) case .chlorine: ChlorineView(state: state)
case .ph: PhView(state: state) case .ph: PhView(state: state)
case .calibrate: CalibrateView(state: state)
case .sessions: SessionView(state: state) case .sessions: SessionView(state: state)
case .connection: ConnectionView(state: state) case .connection: ConnectionView(state: state)
} }

198
cue/Cargo.lock generated
View File

@ -54,28 +54,6 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "alsa"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
dependencies = [
"alsa-sys",
"bitflags 2.11.0",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
dependencies = [
"libc",
"pkg-config",
]
[[package]] [[package]]
name = "android-activity" name = "android-activity"
version = "0.6.0" version = "0.6.0"
@ -639,27 +617,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "coremidi"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "964eb3e10ea8b0d29c797086aab3ca730f75e06dced0cb980642fd274a5cca30"
dependencies = [
"block",
"core-foundation",
"core-foundation-sys",
"coremidi-sys",
]
[[package]]
name = "coremidi-sys"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc9504310988d938e49fff1b5f1e56e3dafe39bb1bae580c19660b58b83a191e"
dependencies = [
"core-foundation-sys",
]
[[package]] [[package]]
name = "cosmic-text" name = "cosmic-text"
version = "0.12.1" version = "0.12.1"
@ -761,14 +718,15 @@ checksum = "e162d0c2e2068eb736b71e5597eff0b9944e6b973cd9f37b6a288ab9bf20e300"
name = "cue" name = "cue"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"dirs-next",
"futures", "futures",
"iced", "iced",
"midir",
"muda", "muda",
"rusqlite", "rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"toml 0.8.23",
"winres", "winres",
] ]
@ -836,6 +794,16 @@ dependencies = [
"dirs-sys", "dirs-sys",
] ]
[[package]]
name = "dirs-next"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
dependencies = [
"cfg-if",
"dirs-sys-next",
]
[[package]] [[package]]
name = "dirs-sys" name = "dirs-sys"
version = "0.3.7" version = "0.3.7"
@ -847,6 +815,17 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "dirs-sys-next"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
dependencies = [
"libc",
"redox_users",
"winapi",
]
[[package]] [[package]]
name = "dispatch" name = "dispatch"
version = "0.2.0" version = "0.2.0"
@ -1370,7 +1349,7 @@ dependencies = [
"presser", "presser",
"thiserror 1.0.69", "thiserror 1.0.69",
"winapi", "winapi",
"windows 0.52.0", "windows",
] ]
[[package]] [[package]]
@ -1990,23 +1969,6 @@ dependencies = [
"paste", "paste",
] ]
[[package]]
name = "midir"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b73f8737248ad37b88291a2108d9df5f991dc8555103597d586b5a29d4d703c0"
dependencies = [
"alsa",
"bitflags 1.3.2",
"coremidi",
"js-sys",
"libc",
"parking_lot 0.12.5",
"wasm-bindgen",
"web-sys",
"windows 0.56.0",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@ -2742,7 +2704,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [ dependencies = [
"toml_edit", "toml_edit 0.25.4+spec-1.1.0",
] ]
[[package]] [[package]]
@ -3112,6 +3074,15 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.6" version = "0.10.6"
@ -3518,6 +3489,28 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.0.0+spec-1.1.0" version = "1.0.0+spec-1.1.0"
@ -3527,6 +3520,20 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime 0.6.11",
"toml_write",
"winnow",
]
[[package]] [[package]]
name = "toml_edit" name = "toml_edit"
version = "0.25.4+spec-1.1.0" version = "0.25.4+spec-1.1.0"
@ -3534,7 +3541,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"toml_datetime", "toml_datetime 1.0.0+spec-1.1.0",
"toml_parser", "toml_parser",
"winnow", "winnow",
] ]
@ -3548,6 +3555,12 @@ dependencies = [
"winnow", "winnow",
] ]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"
@ -4148,17 +4161,7 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
dependencies = [ dependencies = [
"windows-core 0.52.0", "windows-core",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132"
dependencies = [
"windows-core 0.56.0",
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
@ -4171,55 +4174,12 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-core"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6"
dependencies = [
"windows-implement",
"windows-interface",
"windows-result",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-implement"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.45.0" version = "0.45.0"
@ -4527,7 +4487,7 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c" checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c"
dependencies = [ dependencies = [
"toml", "toml 0.5.11",
] ]
[[package]] [[package]]

View File

@ -5,13 +5,14 @@ edition = "2024"
[dependencies] [dependencies]
iced = { version = "0.13", features = ["canvas", "tokio"] } iced = { version = "0.13", features = ["canvas", "tokio"] }
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"] } rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
toml = { version = "0.8", features = ["preserve_order"] }
dirs-next = "2"
[target.'cfg(windows)'.build-dependencies] [target.'cfg(windows)'.build-dependencies]
winres = "0.1" winres = "0.1"

Binary file not shown.

View File

@ -18,14 +18,14 @@
id="g8192" id="g8192"
transform="translate(-0.23570223,2.9462785)"> transform="translate(-0.23570223,2.9462785)">
<path <path
style="fill:#000000;fill-opacity:1;stroke:#ffffff;stroke-width:0.5;stroke-dasharray:none;stroke-opacity:0.54354352" style="fill:#000000;fill-opacity:1;stroke:#ffffff;stroke-width:0.426;stroke-dasharray:none;stroke-opacity:1"
id="rect2" id="rect2"
width="82.823738" width="82.823738"
height="82.823738" height="82.823738"
x="-9.3073196" x="-9.3073196"
y="-9.6424408" y="-9.6424408"
rx="72.195969" rx="72.195969"
d="m 70.344806,16.036231 c 0,0 -0.31417,8.868017 -0.857802,17.738075 -0.543507,8.868017 -1.316476,17.738075 -1.316476,17.738075 C 70.83186,46.630722 71.222274,36.763563 70.118794,27.195377 69.017028,17.64206 66.449216,8.5851833 63.80656,5.398434 c 0,0 -0.484572,14.316035 -1.567451,28.638624 -1.082385,14.316035 -2.763076,28.638624 -2.763076,28.638624 2.661025,-2.364957 3.458202,-17.684329 2.3724,-33.01343 C 60.763515,14.345629 57.8185,-0.71556276 55.174986,-2.5017739 c 0,0 -0.147256,17.7662809 -1.215678,35.5406039 -1.067939,17.766281 -3.057044,35.540604 -3.057044,35.540604 2.570674,-1.317054 3.733552,-19.712836 2.665965,-38.019957 -1.067588,-18.307122 -4.356765,-36.5255816 -6.911057,-37.4879895 0,0 0.07536,19.6795155 -0.97939,39.3678315 -1.054283,19.679515 -3.238686,39.367831 -3.238686,39.367831 2.538242,-0.655222 3.901333,-20.698753 2.847532,-40.673208 -1.053801,-19.974454 -4.516656,-39.8798321 -7.039245,-40.2587477 0,0 0.190075,20.5465117 -0.851679,41.1021007 -1.041294,20.546511 -3.314417,41.1021 -3.314417,41.1021 2.507409,-0.123034 3.964076,-20.83911 2.923237,-41.494032 -1.040839,-20.654921 -4.571819,-41.2486877 -7.064497,-41.1271031 0,0 0.202746,20.5496381 -0.826695,41.1082471 -1.028992,20.549638 -3.290171,41.108248 -3.290171,41.108248 2.478031,0.368421 3.927737,-20.19114 2.898979,-40.686475 -1.028758,-20.495336 -4.528118,-40.9264458 -6.99158,-40.301122 0,0 0.117488,19.721003 -0.899862,39.450514 -1.016912,19.721003 -3.16866,39.450513 -3.16866,39.450513 2.449057,0.900221 3.794473,-18.675719 2.777462,-38.175493 -1.017011,-19.499773 -4.387321,-38.9233798 -6.8222,-37.7184933 0,0 -0.0641,17.9615953 -1.069639,35.9308453 C 11.54255,48.586639 9.5959975,66.55589 9.5959975,66.55589 12.016963,68.114423 13.162517,50.459084 12.156476,32.913204 11.152725,15.407284 8.0533182,-2.1090602 5.5978,-0.07273272 c 0,0 -0.3401747,14.96739972 -1.3341924,29.94109672 C 3.270008,44.835763 1.6225652,59.809461 1.6225652,59.809461 4.0519448,62.442106 4.8673071,47.837754 3.8726345,33.591584 2.8769258,19.330574 0.06743744,5.2419455 -2.3290304,8.8318383 c 0,0 -0.7184848,9.4666617 -1.6987661,18.9372267 -0.9798772,9.466661 -2.221551,18.937226 -2.221551,18.937226 2.3182591,5.967327 2.0457185,0.01317 0.9656475,-7.843192 C -6.3458898,31.1368 -8.2286118,21.87951 -8.2286118,21.87951 c 0,0 -2.7503322,12.652538 1.9792643,24.826781 0,0 1.2416738,-9.470565 2.221551,-18.937226 C -3.0475152,18.2985 -2.3290304,8.8318383 -2.3290304,8.8318383 -4.67813,12.350774 -4.6103531,23.892872 -3.6327884,35.049715 c 0.9787174,11.17 2.89300648,22.199742 5.2553536,24.759746 0,0 1.6474428,-14.973698 2.6410424,-29.941097 C 5.2576253,14.894667 5.5978,-0.07273272 5.5978,-0.07273272 3.2498812,1.874364 3.6642633,17.850793 4.6550661,33.569953 5.6489744,49.338384 7.175032,64.997356 9.5959975,66.55589 c 0,0 1.9465525,-17.969251 2.9516635,-35.930846 1.00554,-17.96925 1.069639,-35.9308453 1.069639,-35.9308453 -2.434879,1.2048865 -1.684089,19.7282733 -0.67838,38.1754933 1.005708,18.44722 2.274061,36.818273 4.723118,37.718493 0,0 2.151748,-19.72951 3.16866,-39.450513 1.01735,-19.729511 0.899862,-39.450514 0.899862,-39.450514 -2.463462,0.6253237 -1.526333,20.688012 -0.508651,40.686475 1.017682,19.998463 2.123222,39.932701 4.601252,40.301122 0,0 2.261179,-20.55861 3.290171,-41.108248 1.029441,-20.558609 0.826695,-41.1082471 0.826695,-41.1082471 -2.492677,0.1215846 -1.465293,20.8383861 -0.435502,41.4940321 1.02979,20.655646 2.069352,41.250138 4.576762,41.127103 0,0 2.273123,-20.555589 3.314417,-41.1021 1.041754,-20.555589 0.851679,-41.1021007 0.851679,-41.1021007 -2.522589,-0.3789156 -1.502395,20.1816847 -0.460496,40.6732077 1.041899,20.491524 2.113966,40.91397 4.652209,40.258748 0,0 2.184403,-19.688316 3.238686,-39.367831 1.054755,-19.688316 0.97939,-39.3678315 0.97939,-39.3678315 -2.554292,-0.9624079 -1.642728,18.5731055 -0.588211,38.0199575 1.054517,19.446852 2.262629,38.805042 4.833303,37.487989 0,0 1.989105,-17.774323 3.057044,-35.540604 1.068422,-17.774323 1.215678,-35.5406039 1.215678,-35.5406039 -2.529056,-1.7088725 -1.893933,15.6639869 -0.82447,33.0134299 1.069419,17.348716 2.564734,34.439894 5.125517,32.164026 0,0 1.680691,-14.322589 2.763076,-28.638624 C 63.321988,19.714469 63.80656,5.398434 63.80656,5.398434 61.215371,2.2737466 61.552253,16.14972 62.630968,29.715438 c 1.075883,13.530087 2.900743,26.637303 5.53956,21.796943 0,0 0.772969,-8.870058 1.316476,-17.738075 0.543632,-8.870058 0.857802,-17.738075 0.857802,-17.738075 z" /> d="m 32.004549,-9.642441 c 22.942176,0 41.411869,18.4696935 41.411869,41.411869 0,22.942175 -18.469693,41.411869 -41.411869,41.411869 -22.9421751,0 -41.4118686,-18.469694 -41.4118686,-41.411869 0,-22.9421755 18.4696935,-41.411869 41.4118686,-41.411869 z" />
<g <g
id="g4"> id="g4">
<path <path

Before

Width:  |  Height:  |  Size: 433 KiB

After

Width:  |  Height:  |  Size: 428 KiB

View File

Before

Width:  |  Height:  |  Size: 255 KiB

After

Width:  |  Height:  |  Size: 255 KiB

View File

@ -18,14 +18,14 @@
id="g8192" id="g8192"
transform="translate(-0.23570223,2.9462785)"> transform="translate(-0.23570223,2.9462785)">
<path <path
style="fill:#000000;fill-opacity:1;stroke:#ffffff;stroke-width:0.426;stroke-dasharray:none;stroke-opacity:1" style="fill:#000000;fill-opacity:1;stroke:#ffffff;stroke-width:0.5;stroke-dasharray:none;stroke-opacity:0.54354352"
id="rect2" id="rect2"
width="82.823738" width="82.823738"
height="82.823738" height="82.823738"
x="-9.3073196" x="-9.3073196"
y="-9.6424408" y="-9.6424408"
rx="72.195969" rx="72.195969"
d="m 32.004549,-9.642441 c 22.942176,0 41.411869,18.4696935 41.411869,41.411869 0,22.942175 -18.469693,41.411869 -41.411869,41.411869 -22.9421751,0 -41.4118686,-18.469694 -41.4118686,-41.411869 0,-22.9421755 18.4696935,-41.411869 41.4118686,-41.411869 z" /> d="m 70.344806,16.036231 c 0,0 -0.31417,8.868017 -0.857802,17.738075 -0.543507,8.868017 -1.316476,17.738075 -1.316476,17.738075 C 70.83186,46.630722 71.222274,36.763563 70.118794,27.195377 69.017028,17.64206 66.449216,8.5851833 63.80656,5.398434 c 0,0 -0.484572,14.316035 -1.567451,28.638624 -1.082385,14.316035 -2.763076,28.638624 -2.763076,28.638624 2.661025,-2.364957 3.458202,-17.684329 2.3724,-33.01343 C 60.763515,14.345629 57.8185,-0.71556276 55.174986,-2.5017739 c 0,0 -0.147256,17.7662809 -1.215678,35.5406039 -1.067939,17.766281 -3.057044,35.540604 -3.057044,35.540604 2.570674,-1.317054 3.733552,-19.712836 2.665965,-38.019957 -1.067588,-18.307122 -4.356765,-36.5255816 -6.911057,-37.4879895 0,0 0.07536,19.6795155 -0.97939,39.3678315 -1.054283,19.679515 -3.238686,39.367831 -3.238686,39.367831 2.538242,-0.655222 3.901333,-20.698753 2.847532,-40.673208 -1.053801,-19.974454 -4.516656,-39.8798321 -7.039245,-40.2587477 0,0 0.190075,20.5465117 -0.851679,41.1021007 -1.041294,20.546511 -3.314417,41.1021 -3.314417,41.1021 2.507409,-0.123034 3.964076,-20.83911 2.923237,-41.494032 -1.040839,-20.654921 -4.571819,-41.2486877 -7.064497,-41.1271031 0,0 0.202746,20.5496381 -0.826695,41.1082471 -1.028992,20.549638 -3.290171,41.108248 -3.290171,41.108248 2.478031,0.368421 3.927737,-20.19114 2.898979,-40.686475 -1.028758,-20.495336 -4.528118,-40.9264458 -6.99158,-40.301122 0,0 0.117488,19.721003 -0.899862,39.450514 -1.016912,19.721003 -3.16866,39.450513 -3.16866,39.450513 2.449057,0.900221 3.794473,-18.675719 2.777462,-38.175493 -1.017011,-19.499773 -4.387321,-38.9233798 -6.8222,-37.7184933 0,0 -0.0641,17.9615953 -1.069639,35.9308453 C 11.54255,48.586639 9.5959975,66.55589 9.5959975,66.55589 12.016963,68.114423 13.162517,50.459084 12.156476,32.913204 11.152725,15.407284 8.0533182,-2.1090602 5.5978,-0.07273272 c 0,0 -0.3401747,14.96739972 -1.3341924,29.94109672 C 3.270008,44.835763 1.6225652,59.809461 1.6225652,59.809461 4.0519448,62.442106 4.8673071,47.837754 3.8726345,33.591584 2.8769258,19.330574 0.06743744,5.2419455 -2.3290304,8.8318383 c 0,0 -0.7184848,9.4666617 -1.6987661,18.9372267 -0.9798772,9.466661 -2.221551,18.937226 -2.221551,18.937226 2.3182591,5.967327 2.0457185,0.01317 0.9656475,-7.843192 C -6.3458898,31.1368 -8.2286118,21.87951 -8.2286118,21.87951 c 0,0 -2.7503322,12.652538 1.9792643,24.826781 0,0 1.2416738,-9.470565 2.221551,-18.937226 C -3.0475152,18.2985 -2.3290304,8.8318383 -2.3290304,8.8318383 -4.67813,12.350774 -4.6103531,23.892872 -3.6327884,35.049715 c 0.9787174,11.17 2.89300648,22.199742 5.2553536,24.759746 0,0 1.6474428,-14.973698 2.6410424,-29.941097 C 5.2576253,14.894667 5.5978,-0.07273272 5.5978,-0.07273272 3.2498812,1.874364 3.6642633,17.850793 4.6550661,33.569953 5.6489744,49.338384 7.175032,64.997356 9.5959975,66.55589 c 0,0 1.9465525,-17.969251 2.9516635,-35.930846 1.00554,-17.96925 1.069639,-35.9308453 1.069639,-35.9308453 -2.434879,1.2048865 -1.684089,19.7282733 -0.67838,38.1754933 1.005708,18.44722 2.274061,36.818273 4.723118,37.718493 0,0 2.151748,-19.72951 3.16866,-39.450513 1.01735,-19.729511 0.899862,-39.450514 0.899862,-39.450514 -2.463462,0.6253237 -1.526333,20.688012 -0.508651,40.686475 1.017682,19.998463 2.123222,39.932701 4.601252,40.301122 0,0 2.261179,-20.55861 3.290171,-41.108248 1.029441,-20.558609 0.826695,-41.1082471 0.826695,-41.1082471 -2.492677,0.1215846 -1.465293,20.8383861 -0.435502,41.4940321 1.02979,20.655646 2.069352,41.250138 4.576762,41.127103 0,0 2.273123,-20.555589 3.314417,-41.1021 1.041754,-20.555589 0.851679,-41.1021007 0.851679,-41.1021007 -2.522589,-0.3789156 -1.502395,20.1816847 -0.460496,40.6732077 1.041899,20.491524 2.113966,40.91397 4.652209,40.258748 0,0 2.184403,-19.688316 3.238686,-39.367831 1.054755,-19.688316 0.97939,-39.3678315 0.97939,-39.3678315 -2.554292,-0.9624079 -1.642728,18.5731055 -0.588211,38.0199575 1.054517,19.446852 2.262629,38.805042 4.833303,37.487989 0,0 1.989105,-17.774323 3.057044,-35.540604 1.068422,-17.774323 1.215678,-35.5406039 1.215678,-35.5406039 -2.529056,-1.7088725 -1.893933,15.6639869 -0.82447,33.0134299 1.069419,17.348716 2.564734,34.439894 5.125517,32.164026 0,0 1.680691,-14.322589 2.763076,-28.638624 C 63.321988,19.714469 63.80656,5.398434 63.80656,5.398434 61.215371,2.2737466 61.552253,16.14972 62.630968,29.715438 c 1.075883,13.530087 2.900743,26.637303 5.53956,21.796943 0,0 0.772969,-8.870058 1.316476,-17.738075 0.543632,-8.870058 0.857802,-17.738075 0.857802,-17.738075 z" />
<g <g
id="g4"> id="g4">
<path <path

Before

Width:  |  Height:  |  Size: 428 KiB

After

Width:  |  Height:  |  Size: 433 KiB

1
cue/assets/cue5.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -9,13 +9,13 @@ use std::fmt::Write;
use std::time::Duration; use std::time::Duration;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::ble::BleEvent;
use crate::native_menu::{MenuAction, NativeMenu}; use crate::native_menu::{MenuAction, NativeMenu};
use crate::protocol::{ 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}; use crate::storage::{self, Session, Storage};
use crate::udp::UdpEvent;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab { pub enum Tab {
@ -24,6 +24,7 @@ pub enum Tab {
Amp, Amp,
Chlorine, Chlorine,
Ph, Ph,
Calibrate,
Browse, Browse,
} }
@ -50,9 +51,9 @@ impl std::fmt::Display for SessionItem {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Message { pub enum Message {
BleReady(mpsc::UnboundedSender<Vec<u8>>), DeviceReady(mpsc::UnboundedSender<Vec<u8>>),
BleStatus(String), DeviceStatus(String),
BleData(EisMessage), DeviceData(EisMessage),
TabSelected(Tab), TabSelected(Tab),
PaneResized(pane_grid::ResizeEvent), PaneResized(pane_grid::ResizeEvent),
DataAction(text_editor::Action), DataAction(text_editor::Action),
@ -90,6 +91,13 @@ pub enum Message {
/* pH */ /* pH */
PhStabilizeChanged(String), PhStabilizeChanged(String),
StartPh, StartPh,
/* Calibration */
CalVolumeChanged(String),
CalNaclChanged(String),
CalClChanged(String),
CalBleachChanged(String),
CalTempChanged(String),
CalComputeK,
/* Global */ /* Global */
PollTemp, PollTemp,
NativeMenuTick, NativeMenuTick,
@ -117,15 +125,15 @@ pub enum Message {
BrowseDeleteMeasurement(i64), BrowseDeleteMeasurement(i64),
BrowseBack, BrowseBack,
/* Misc */ /* Misc */
OpenMidiSetup, Reconnect,
RefreshMidi, UdpAddrChanged(String),
} }
pub struct App { pub struct App {
tab: Tab, tab: Tab,
status: String, status: String,
cmd_tx: Option<mpsc::UnboundedSender<Vec<u8>>>, cmd_tx: Option<mpsc::UnboundedSender<Vec<u8>>>,
ble_connected: bool, connected: bool,
panes: pane_grid::State<PaneId>, panes: pane_grid::State<PaneId>,
native_menu: NativeMenu, native_menu: NativeMenu,
show_sysinfo: bool, show_sysinfo: bool,
@ -212,9 +220,18 @@ pub struct App {
clean_v: String, clean_v: String,
clean_dur: String, clean_dur: String,
/* Calibration */
cal_volume_gal: String,
cal_nacl_ppm: String,
cal_cl_ppm: String,
cal_bleach_pct: String,
cal_temp_c: String,
cal_cell_constant: Option<f32>,
/* Global */ /* Global */
temp_c: f32, temp_c: f32,
midi_gen: u64, conn_gen: u64,
udp_addr: String,
} }
/* ---- data table formatting ---- */ /* ---- data table formatting ---- */
@ -260,6 +277,34 @@ fn fmt_cl(pts: &[ClPoint]) -> String {
s s
} }
fn calc_salt_grams(volume_gal: f32, target_ppm: f32) -> f32 {
let liters = volume_gal * 3.78541;
target_ppm * liters / 1000.0
}
fn calc_bleach_ml(volume_gal: f32, target_cl_ppm: f32, bleach_pct: f32) -> f32 {
let liters = volume_gal * 3.78541;
let cl_needed_mg = target_cl_ppm * liters;
let bleach_mg_per_ml = bleach_pct * 10.0;
cl_needed_mg / bleach_mg_per_ml
}
fn theoretical_conductivity_ms_cm(nacl_ppm: f32, temp_c: f32) -> f32 {
let kappa_25 = nacl_ppm * 2.0 / 1000.0;
kappa_25 * (1.0 + 0.0212 * (temp_c - 25.0))
}
fn extract_rs(eis_points: &[EisPoint]) -> Option<f32> {
eis_points.iter()
.map(|p| p.z_real)
.filter(|r| r.is_finite() && *r > 0.0)
.min_by(|a, b| a.partial_cmp(b).unwrap())
}
fn cell_constant(kappa_ms_cm: f32, rs_ohm: f32) -> f32 {
(kappa_ms_cm / 1000.0) * rs_ohm
}
const SQUIRCLE: f32 = 8.0; const SQUIRCLE: f32 = 8.0;
fn btn_style(bg: Color, fg: Color) -> impl Fn(&Theme, button::Status) -> ButtonStyle { fn btn_style(bg: Color, fg: Color) -> impl Fn(&Theme, button::Status) -> ButtonStyle {
@ -324,7 +369,7 @@ impl App {
tab: Tab::Eis, tab: Tab::Eis,
status: "Starting...".into(), status: "Starting...".into(),
cmd_tx: None, cmd_tx: None,
ble_connected: false, connected: false,
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split { panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
axis: pane_grid::Axis::Horizontal, axis: pane_grid::Axis::Horizontal,
ratio: 0.55, ratio: 0.55,
@ -406,8 +451,16 @@ impl App {
clean_v: "1200".into(), clean_v: "1200".into(),
clean_dur: "30".into(), clean_dur: "30".into(),
cal_volume_gal: "25".into(),
cal_nacl_ppm: "2500".into(),
cal_cl_ppm: "5".into(),
cal_bleach_pct: "7.825".into(),
cal_temp_c: "40".into(),
cal_cell_constant: None,
temp_c: 25.0, temp_c: 25.0,
midi_gen: 0, conn_gen: 0,
udp_addr: crate::udp::load_addr(),
}, Task::none()) }, Task::none())
} }
@ -503,19 +556,20 @@ impl App {
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::DeviceReady(tx) => {
self.cmd_tx = Some(tx); self.cmd_tx = Some(tx);
self.ble_connected = true; self.connected = true;
self.send_cmd(&protocol::build_sysex_get_config()); self.send_cmd(&protocol::build_sysex_get_config());
self.send_cmd(&protocol::build_sysex_get_cell_k());
} }
Message::BleStatus(s) => { Message::DeviceStatus(s) => {
if s.contains("Reconnecting") || s.contains("Looking") { if s.contains("Reconnecting") || s.contains("Connecting") {
self.ble_connected = false; self.connected = false;
self.cmd_tx = None; self.cmd_tx = None;
} }
self.status = s; self.status = s;
} }
Message::BleData(msg) => match msg { Message::DeviceData(msg) => match msg {
EisMessage::SweepStart { num_points, freq_start, freq_stop } => { EisMessage::SweepStart { num_points, freq_start, freq_stop } => {
if self.collecting_refs { if self.collecting_refs {
/* ref collection: clear temp buffer */ /* ref collection: clear temp buffer */
@ -676,6 +730,10 @@ impl App {
self.status = "No device refs".into(); self.status = "No device refs".into();
} }
} }
EisMessage::CellK(k) => {
self.cal_cell_constant = Some(k);
self.status = format!("Device cell constant: {:.4} cm-1", k);
}
}, },
Message::TabSelected(t) => { Message::TabSelected(t) => {
if t == Tab::Browse { if t == Tab::Browse {
@ -704,7 +762,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::Browse => {} Tab::Ph | Tab::Calibrate | Tab::Browse => {}
} }
} }
} }
@ -857,7 +915,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 => {} Tab::Calibrate | Tab::Browse => {}
} }
} }
/* Global */ /* Global */
@ -874,6 +932,25 @@ impl App {
Message::CloseSysInfo => { Message::CloseSysInfo => {
self.show_sysinfo = false; self.show_sysinfo = false;
} }
/* Calibration */
Message::CalVolumeChanged(s) => self.cal_volume_gal = s,
Message::CalNaclChanged(s) => self.cal_nacl_ppm = s,
Message::CalClChanged(s) => self.cal_cl_ppm = s,
Message::CalBleachChanged(s) => self.cal_bleach_pct = s,
Message::CalTempChanged(s) => self.cal_temp_c = s,
Message::CalComputeK => {
let ppm = self.cal_nacl_ppm.parse::<f32>().unwrap_or(2500.0);
let temp = self.cal_temp_c.parse::<f32>().unwrap_or(40.0);
let kappa = theoretical_conductivity_ms_cm(ppm, temp);
if let Some(rs) = extract_rs(&self.eis_points) {
let k = cell_constant(kappa, rs);
self.cal_cell_constant = Some(k);
self.send_cmd(&protocol::build_sysex_set_cell_k(k));
self.status = format!("Cell constant: {:.4} cm-1 (Rs={:.1} ohm)", k, rs);
} else {
self.status = "No valid EIS data for Rs extraction".into();
}
}
/* Clean */ /* Clean */
Message::CleanVChanged(s) => self.clean_v = s, Message::CleanVChanged(s) => self.clean_v = s,
Message::CleanDurChanged(s) => self.clean_dur = s, Message::CleanDurChanged(s) => self.clean_dur = s,
@ -991,52 +1068,52 @@ impl App {
self.browse_measurements.clear(); self.browse_measurements.clear();
} }
} }
Message::OpenMidiSetup => { Message::Reconnect => {
let _ = std::process::Command::new("open") self.conn_gen += 1;
.arg("-a")
.arg("Audio MIDI Setup")
.spawn();
}
Message::RefreshMidi => {
self.midi_gen += 1;
self.cmd_tx = None; self.cmd_tx = None;
self.ble_connected = false; self.connected = false;
self.status = "Looking for MIDI device...".into(); self.status = format!("Connecting to {}...", self.udp_addr);
}
Message::UdpAddrChanged(s) => {
self.udp_addr = s;
} }
} }
Task::none() Task::none()
} }
pub fn subscription(&self) -> Subscription<Message> { pub fn subscription(&self) -> Subscription<Message> {
let ble = Subscription::run_with_id( let udp_addr = self.udp_addr.clone();
self.midi_gen, let transport = Subscription::run_with_id(
iced::stream::channel(100, |mut output| async move { self.conn_gen,
iced::stream::channel(100, move |mut output| async move {
let addr = udp_addr.clone();
loop { loop {
let (ble_tx, mut ble_rx) = mpsc::unbounded_channel::<BleEvent>(); let (udp_tx, mut udp_rx) = mpsc::unbounded_channel::<UdpEvent>();
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<Vec<u8>>(); let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let tx = ble_tx.clone(); let tx = udp_tx.clone();
let a = addr.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = crate::ble::connect_and_run(tx, cmd_rx).await { if let Err(e) = crate::udp::connect_and_run(tx, cmd_rx, a).await {
eprintln!("BLE: {e}"); eprintln!("UDP: {e}");
} }
}); });
let mut ready_sent = false; let mut ready_sent = false;
while let Some(ev) = ble_rx.recv().await { while let Some(ev) = udp_rx.recv().await {
let msg = match ev { let msg = match ev {
BleEvent::Status(ref s) if s == "Connected" && !ready_sent => { UdpEvent::Status(ref s) if s == "Connected" && !ready_sent => {
ready_sent = true; ready_sent = true;
let _ = output.send(Message::BleReady(cmd_tx.clone())).await; let _ = output.send(Message::DeviceReady(cmd_tx.clone())).await;
Message::BleStatus(s.clone()) Message::DeviceStatus(s.clone())
} }
BleEvent::Status(s) => Message::BleStatus(s), UdpEvent::Status(s) => Message::DeviceStatus(s),
BleEvent::Data(m) => Message::BleData(m), UdpEvent::Data(m) => Message::DeviceData(m),
}; };
let _ = output.send(msg).await; let _ = output.send(msg).await;
} }
let _ = output.send(Message::BleStatus("Reconnecting...".into())).await; let _ = output.send(Message::DeviceStatus("Reconnecting...".into())).await;
tokio::time::sleep(Duration::from_millis(500)).await; tokio::time::sleep(Duration::from_millis(500)).await;
} }
}), }),
@ -1047,7 +1124,7 @@ impl App {
let menu_tick = iced::time::every(Duration::from_millis(50)) let menu_tick = iced::time::every(Duration::from_millis(50))
.map(|_| Message::NativeMenuTick); .map(|_| Message::NativeMenuTick);
Subscription::batch([ble, temp_poll, menu_tick]) Subscription::batch([transport, temp_poll, menu_tick])
} }
pub fn view(&self) -> Element<'_, Message> { pub fn view(&self) -> Element<'_, Message> {
@ -1058,28 +1135,28 @@ impl App {
if active { b.into() } else { b.on_press(Message::TabSelected(t)).into() } if active { b.into() } else { b.on_press(Message::TabSelected(t)).into() }
}; };
let tabs = row![ let mut tabs = row![
tab_btn("EIS", Tab::Eis, self.tab == Tab::Eis), tab_btn("EIS", Tab::Eis, self.tab == Tab::Eis),
tab_btn("LSV", Tab::Lsv, self.tab == Tab::Lsv), tab_btn("LSV", Tab::Lsv, self.tab == Tab::Lsv),
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("Cal", Tab::Calibrate, self.tab == Tab::Calibrate),
tab_btn("Browse", Tab::Browse, self.tab == Tab::Browse), tab_btn("Browse", Tab::Browse, self.tab == Tab::Browse),
button(text("MIDI Setup").size(13)) ]
.style(style_neutral()) .spacing(4)
.padding([6, 14]) .align_y(iced::Alignment::Center);
.on_press(Message::OpenMidiSetup), tabs = tabs
iced::widget::horizontal_space(), .push(iced::widget::horizontal_space())
text("Clean").size(12), .push(text("Clean").size(12))
text_input("mV", &self.clean_v).on_input(Message::CleanVChanged).width(60), .push(text_input("mV", &self.clean_v).on_input(Message::CleanVChanged).width(60))
text_input("s", &self.clean_dur).on_input(Message::CleanDurChanged).width(45), .push(text_input("s", &self.clean_dur).on_input(Message::CleanDurChanged).width(45))
.push(
button(text("Clean").size(13)) button(text("Clean").size(13))
.style(btn_style(Color::from_rgb(0.65, 0.55, 0.15), Color::WHITE)) .style(btn_style(Color::from_rgb(0.65, 0.55, 0.15), Color::WHITE))
.padding([6, 14]) .padding([6, 14])
.on_press(Message::StartClean), .on_press(Message::StartClean),
] );
.spacing(4)
.align_y(iced::Alignment::Center);
let has_ref = match self.tab { let has_ref = match self.tab {
Tab::Eis => self.eis_ref.is_some(), Tab::Eis => self.eis_ref.is_some(),
@ -1087,7 +1164,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, Tab::Calibrate | 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(),
@ -1095,7 +1172,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, Tab::Calibrate | 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);
@ -1148,17 +1225,21 @@ impl App {
ref_row = ref_row.push(text("REF").size(11)); ref_row = ref_row.push(text("REF").size(11));
} }
let connected = self.ble_connected;
let mut status_row = row![text(&self.status).size(16)].spacing(6) let mut status_row = row![text(&self.status).size(16)].spacing(6)
.align_y(iced::Alignment::Center); .align_y(iced::Alignment::Center);
if !connected {
status_row = status_row.push( status_row = status_row.push(
button(text("Refresh MIDI").size(11)) text_input("IP:port", &self.udp_addr)
.size(12)
.width(160)
.on_input(Message::UdpAddrChanged)
.on_submit(Message::Reconnect),
);
status_row = status_row.push(
button(text("Reconnect").size(11))
.style(style_apply()) .style(style_apply())
.padding([4, 10]) .padding([4, 10])
.on_press(Message::RefreshMidi), .on_press(Message::Reconnect),
); );
}
status_row = status_row status_row = status_row
.push(iced::widget::horizontal_space()) .push(iced::widget::horizontal_space())
.push(ref_row) .push(ref_row)
@ -1174,6 +1255,8 @@ impl App {
self.view_browse_body() 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 if self.tab == Tab::Calibrate {
self.view_cal_body()
} else { } else {
pane_grid::PaneGrid::new(&self.panes, |_pane, &pane_id, _maximized| { pane_grid::PaneGrid::new(&self.panes, |_pane, &pane_id, _maximized| {
let el = match pane_id { let el = match pane_id {
@ -1411,7 +1494,7 @@ impl App {
.align_y(iced::Alignment::End) .align_y(iced::Alignment::End)
.into(), .into(),
Tab::Browse => row![].into(), Tab::Calibrate | Tab::Browse => row![].into(),
} }
} }
@ -1471,7 +1554,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 | Tab::Browse => text("").into(), Tab::Ph | Tab::Calibrate | Tab::Browse => text("").into(),
} }
} }
@ -1481,7 +1564,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 | Tab::Browse => return text("").into(), Tab::Ph | Tab::Calibrate | Tab::Browse => return text("").into(),
}; };
text_editor(content) text_editor(content)
.on_action(Message::DataAction) .on_action(Message::DataAction)
@ -1492,6 +1575,86 @@ impl App {
.into() .into()
} }
fn view_cal_body(&self) -> Element<'_, Message> {
let vol = self.cal_volume_gal.parse::<f32>().unwrap_or(0.0);
let ppm = self.cal_nacl_ppm.parse::<f32>().unwrap_or(0.0);
let cl = self.cal_cl_ppm.parse::<f32>().unwrap_or(0.0);
let bleach = self.cal_bleach_pct.parse::<f32>().unwrap_or(7.825);
let temp = self.cal_temp_c.parse::<f32>().unwrap_or(40.0);
let salt_g = calc_salt_grams(vol, ppm);
let salt_tbsp = salt_g / 17.0;
let bleach_ml = calc_bleach_ml(vol, cl, bleach);
let bleach_tsp = bleach_ml / 5.0;
let kappa = theoretical_conductivity_ms_cm(ppm, temp);
let inputs = column![
text("Calibration Solution").size(16),
iced::widget::horizontal_rule(1),
row![
column![
text("Volume (gal)").size(12),
text_input("25", &self.cal_volume_gal)
.on_input(Message::CalVolumeChanged).width(80),
].spacing(2),
column![
text("NaCl ppm").size(12),
text_input("2500", &self.cal_nacl_ppm)
.on_input(Message::CalNaclChanged).width(80),
].spacing(2),
column![
text("Cl ppm").size(12),
text_input("5", &self.cal_cl_ppm)
.on_input(Message::CalClChanged).width(80),
].spacing(2),
column![
text("Bleach %").size(12),
text_input("7.825", &self.cal_bleach_pct)
.on_input(Message::CalBleachChanged).width(80),
].spacing(2),
column![
text("Temp C").size(12),
text_input("40", &self.cal_temp_c)
.on_input(Message::CalTempChanged).width(80),
].spacing(2),
].spacing(10).align_y(iced::Alignment::End),
].spacing(6);
let mut results = column![
text("Results").size(16),
iced::widget::horizontal_rule(1),
text(format!("Salt: {:.1} g ({:.1} tbsp sea salt)", salt_g, salt_tbsp)).size(14),
text(format!("Bleach: {:.1} mL ({:.1} tsp)", bleach_ml, bleach_tsp)).size(14),
text(format!("Theoretical kappa at {:.0} C: {:.3} mS/cm", temp, kappa)).size(14),
].spacing(4);
let rs = extract_rs(&self.eis_points);
if let Some(rs_val) = rs {
results = results.push(text(format!("Rs from sweep: {:.1} ohm", rs_val)).size(14));
} else {
results = results.push(text("Rs from sweep: (no EIS data)").size(14));
}
if let Some(k) = self.cal_cell_constant {
results = results.push(text(format!("Cell constant K: {:.4} cm-1", k)).size(14));
}
let compute_btn = button(text("Calculate K from Sweep").size(13))
.style(style_action())
.padding([6, 16])
.on_press(Message::CalComputeK);
results = results.push(compute_btn);
row![
container(inputs).width(Length::FillPortion(2)),
iced::widget::vertical_rule(1),
container(results).width(Length::FillPortion(3)),
]
.spacing(12)
.height(Length::Fill)
.into()
}
fn view_ph_body(&self) -> Element<'_, Message> { fn view_ph_body(&self) -> Element<'_, Message> {
if let Some(r) = &self.ph_result { if let Some(r) = &self.ph_result {
let nernst_slope = 0.1984 * (r.temp_c as f64 + 273.15); let nernst_slope = 0.1984 * (r.temp_c as f64 + 273.15);

View File

@ -1,93 +0,0 @@
use midir::{MidiInput, MidiOutput, MidiInputConnection, MidiOutputConnection};
use std::sync::mpsc as std_mpsc;
use tokio::sync::mpsc;
use crate::protocol::{self, EisMessage};
const DEVICE_NAME: &str = "EIS4";
#[derive(Debug, Clone)]
pub enum BleEvent {
Status(String),
Data(EisMessage),
}
pub async fn connect_and_run(
tx: mpsc::UnboundedSender<BleEvent>,
mut cmd_rx: mpsc::UnboundedReceiver<Vec<u8>>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let _ = tx.send(BleEvent::Status("Looking for MIDI device...".into()));
let (midi_in, in_port, midi_out, out_port) = loop {
if let Some(found) = find_midi_ports() {
break found;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
};
let _ = tx.send(BleEvent::Status("Connecting MIDI...".into()));
let (sysex_tx, sysex_rx) = std_mpsc::channel::<Vec<u8>>();
let _in_conn: MidiInputConnection<()> = midi_in.connect(
&in_port, "cue-in",
move |_ts, data, _| {
if let Some(sysex) = extract_sysex(data) {
let _ = sysex_tx.send(sysex);
}
},
(),
).map_err(|e| format!("MIDI input connect: {e}"))?;
let mut out_conn: MidiOutputConnection = midi_out.connect(
&out_port, "cue-out",
).map_err(|e| format!("MIDI output connect: {e}"))?;
let _ = tx.send(BleEvent::Status("Connected".into()));
loop {
while let Ok(sysex) = sysex_rx.try_recv() {
if let Some(msg) = protocol::parse_sysex(&sysex) {
let _ = tx.send(BleEvent::Data(msg));
}
}
loop {
match cmd_rx.try_recv() {
Ok(pkt) => {
if let Err(e) = out_conn.send(&pkt) {
eprintln!("MIDI send error: {e}");
}
}
Err(mpsc::error::TryRecvError::Disconnected) => return Ok(()),
Err(mpsc::error::TryRecvError::Empty) => break,
}
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
}
fn find_midi_ports() -> Option<(
MidiInput, midir::MidiInputPort,
MidiOutput, midir::MidiOutputPort,
)> {
let midi_in = MidiInput::new("cue-in").ok()?;
let midi_out = MidiOutput::new("cue-out").ok()?;
let in_port = midi_in.ports().into_iter().find(|p| {
midi_in.port_name(p).map_or(false, |n| n.contains(DEVICE_NAME))
})?;
let out_port = midi_out.ports().into_iter().find(|p| {
midi_out.port_name(p).map_or(false, |n| n.contains(DEVICE_NAME))
})?;
Some((midi_in, in_port, midi_out, out_port))
}
fn extract_sysex(data: &[u8]) -> Option<Vec<u8>> {
if data.first() != Some(&0xF0) { return None; }
let end = data.iter().position(|&b| b == 0xF7)?;
Some(data[1..end].to_vec())
}

View File

@ -1,9 +1,9 @@
mod app; mod app;
mod ble;
mod native_menu; mod native_menu;
mod plot; mod plot;
mod protocol; mod protocol;
mod storage; mod storage;
mod udp;
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)

View File

@ -24,6 +24,7 @@ pub const RSP_TEMP: u8 = 0x10;
pub const RSP_REF_FRAME: u8 = 0x20; pub const RSP_REF_FRAME: u8 = 0x20;
pub const RSP_REF_LP_RANGE: u8 = 0x21; pub const RSP_REF_LP_RANGE: u8 = 0x21;
pub const RSP_REFS_DONE: u8 = 0x22; pub const RSP_REFS_DONE: u8 = 0x22;
pub const RSP_CELL_K: u8 = 0x11;
pub const RSP_REF_STATUS: u8 = 0x23; pub const RSP_REF_STATUS: u8 = 0x23;
/* Cue → ESP32 */ /* Cue → ESP32 */
@ -41,6 +42,8 @@ pub const CMD_GET_TEMP: u8 = 0x17;
pub const CMD_START_CL: u8 = 0x23; pub const CMD_START_CL: u8 = 0x23;
pub const CMD_START_PH: u8 = 0x24; pub const CMD_START_PH: u8 = 0x24;
pub const CMD_START_CLEAN: u8 = 0x25; pub const CMD_START_CLEAN: u8 = 0x25;
pub const CMD_SET_CELL_K: u8 = 0x28;
pub const CMD_GET_CELL_K: u8 = 0x29;
pub const CMD_START_REFS: u8 = 0x30; pub const CMD_START_REFS: u8 = 0x30;
pub const CMD_GET_REFS: u8 = 0x31; pub const CMD_GET_REFS: u8 = 0x31;
pub const CMD_CLEAR_REFS: u8 = 0x32; pub const CMD_CLEAR_REFS: u8 = 0x32;
@ -254,6 +257,7 @@ pub enum EisMessage {
RefLpRange { mode: u8, low_idx: u8, high_idx: u8 }, RefLpRange { mode: u8, low_idx: u8, high_idx: u8 },
RefsDone, RefsDone,
RefStatus { has_refs: bool }, RefStatus { has_refs: bool },
CellK(f32),
} }
fn decode_u16(data: &[u8]) -> u16 { fn decode_u16(data: &[u8]) -> u16 {
@ -405,6 +409,10 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
RSP_REF_STATUS if data.len() >= 3 => { RSP_REF_STATUS if data.len() >= 3 => {
Some(EisMessage::RefStatus { has_refs: data[2] != 0 }) Some(EisMessage::RefStatus { has_refs: data[2] != 0 })
} }
RSP_CELL_K if data.len() >= 7 => {
let p = &data[2..];
Some(EisMessage::CellK(decode_float(&p[0..5])))
}
_ => None, _ => None,
} }
} }
@ -508,3 +516,14 @@ pub fn build_sysex_get_refs() -> Vec<u8> {
pub fn build_sysex_clear_refs() -> Vec<u8> { pub fn build_sysex_clear_refs() -> Vec<u8> {
vec![0xF0, SYSEX_MFR, CMD_CLEAR_REFS, 0xF7] vec![0xF0, SYSEX_MFR, CMD_CLEAR_REFS, 0xF7]
} }
pub fn build_sysex_set_cell_k(k: f32) -> Vec<u8> {
let mut sx = vec![0xF0, SYSEX_MFR, CMD_SET_CELL_K];
sx.extend_from_slice(&encode_float(k));
sx.push(0xF7);
sx
}
pub fn build_sysex_get_cell_k() -> Vec<u8> {
vec![0xF0, SYSEX_MFR, CMD_GET_CELL_K, 0xF7]
}

View File

@ -1,4 +1,5 @@
use rusqlite::{Connection, params}; use rusqlite::{Connection, params};
use toml::value::Table;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)] #[allow(dead_code)]
@ -159,6 +160,271 @@ impl Storage {
})?; })?;
rows.collect() rows.collect()
} }
pub fn export_session(&self, session_id: i64) -> Result<String, Box<dyn std::error::Error>> {
let sess = self.conn.query_row(
"SELECT id, name, notes, created_at FROM sessions WHERE id = ?1",
params![session_id],
|row| Ok(Session {
id: row.get(0)?, name: row.get(1)?,
notes: row.get(2)?, created_at: row.get(3)?,
}),
)?;
let mut root = Table::new();
let mut session_table = Table::new();
session_table.insert("name".into(), toml::Value::String(sess.name));
session_table.insert("notes".into(), toml::Value::String(sess.notes));
session_table.insert("created_at".into(), toml::Value::String(sess.created_at));
root.insert("session".into(), toml::Value::Table(session_table));
let measurements = self.get_measurements(session_id)?;
let mut meas_array = Vec::new();
for m in &measurements {
let mut mt = Table::new();
mt.insert("type".into(), toml::Value::String(m.mtype.clone()));
mt.insert("created_at".into(), toml::Value::String(m.created_at.clone()));
let params: serde_json::Value = serde_json::from_str(&m.params_json)
.unwrap_or(serde_json::Value::Object(Default::default()));
mt.insert("params".into(), toml::Value::Table(json_to_toml_table(&params)));
let points = self.get_data_points(m.id)?;
let mut data_array = Vec::new();
let mut cl_result: Option<serde_json::Value> = None;
for p in &points {
let jv: serde_json::Value = serde_json::from_str(&p.data_json)
.unwrap_or(serde_json::Value::Null);
if let Some(obj) = jv.as_object() {
if obj.contains_key("result") {
cl_result = obj.get("result").cloned();
continue;
}
}
if let Some(row) = data_point_to_toml(&m.mtype, &jv) {
data_array.push(toml::Value::Table(row));
}
}
if !data_array.is_empty() {
mt.insert("data".into(), toml::Value::Array(data_array));
}
if let Some(r) = cl_result {
mt.insert("result".into(), toml::Value::Table(cl_result_to_toml(&r)));
}
meas_array.push(toml::Value::Table(mt));
}
root.insert("measurement".into(), toml::Value::Array(meas_array));
Ok(toml::to_string_pretty(&toml::Value::Table(root))?)
}
pub fn import_session(&self, toml_str: &str) -> Result<i64, Box<dyn std::error::Error>> {
let doc: toml::Value = toml::from_str(toml_str)?;
let root = doc.as_table().ok_or("invalid TOML root")?;
let sess = root.get("session")
.and_then(|v| v.as_table())
.ok_or("missing [session]")?;
let name = sess.get("name").and_then(|v| v.as_str()).unwrap_or("");
let notes = sess.get("notes").and_then(|v| v.as_str()).unwrap_or("");
let session_id = self.create_session(name, notes)?;
if let Some(toml::Value::Array(measurements)) = root.get("measurement") {
for mt in measurements {
let mt = mt.as_table().ok_or("invalid measurement")?;
let mtype = mt.get("type").and_then(|v| v.as_str()).unwrap_or("eis");
let params_table = mt.get("params").and_then(|v| v.as_table());
let params_json = match params_table {
Some(t) => serde_json::to_string(&toml_table_to_json(t))?,
None => "{}".to_string(),
};
let mid = self.create_measurement(session_id, mtype, &params_json)?;
if let Some(toml::Value::Array(data)) = mt.get("data") {
let pts: Vec<(i32, String)> = data.iter().enumerate()
.filter_map(|(i, row)| {
let row = row.as_table()?;
let jv = toml_data_row_to_json(mtype, row);
serde_json::to_string(&jv).ok().map(|s| (i as i32, s))
})
.collect();
self.add_data_points_batch(mid, &pts)?;
}
if mtype == "chlorine" {
if let Some(result_table) = mt.get("result").and_then(|v| v.as_table()) {
let rj = toml_cl_result_to_json(result_table);
let wrapper = format!("{{\"result\":{}}}", serde_json::to_string(&rj)?);
let idx = self.conn.query_row(
"SELECT COALESCE(MAX(idx), -1) + 1 FROM data_points WHERE measurement_id = ?1",
params![mid], |row| row.get::<_, i32>(0),
)?;
self.add_data_point(mid, idx, &wrapper)?;
}
}
}
}
Ok(session_id)
}
}
// MARK: - TOML ↔ JSON conversion helpers
fn json_to_toml_table(jv: &serde_json::Value) -> Table {
let mut t = Table::new();
if let Some(obj) = jv.as_object() {
for (k, v) in obj {
if let Some(tv) = json_val_to_toml(v) {
t.insert(k.clone(), tv);
}
}
}
t
}
fn json_val_to_toml(jv: &serde_json::Value) -> Option<toml::Value> {
match jv {
serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() { Some(toml::Value::Integer(i)) }
else { n.as_f64().map(toml::Value::Float) }
}
serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
_ => None,
}
}
fn data_point_to_toml(mtype: &str, jv: &serde_json::Value) -> Option<Table> {
let obj = jv.as_object()?;
let mut t = Table::new();
match mtype {
"eis" => {
t.insert("Frequency (Hz)".into(), toml_f(obj, "freq_hz")?);
t.insert("|Z| (Ohm)".into(), toml_f(obj, "mag_ohms")?);
t.insert("Phase (deg)".into(), toml_f(obj, "phase_deg")?);
t.insert("Re (Ohm)".into(), toml_f(obj, "z_real")?);
t.insert("Im (Ohm)".into(), toml_f(obj, "z_imag")?);
}
"lsv" => {
t.insert("Voltage (mV)".into(), toml_f(obj, "v_mv")?);
t.insert("Current (uA)".into(), toml_f(obj, "i_ua")?);
}
"amp" => {
t.insert("Time (ms)".into(), toml_f(obj, "t_ms")?);
t.insert("Current (uA)".into(), toml_f(obj, "i_ua")?);
}
"chlorine" => {
t.insert("Time (ms)".into(), toml_f(obj, "t_ms")?);
t.insert("Current (uA)".into(), toml_f(obj, "i_ua")?);
if let Some(p) = obj.get("phase").and_then(|v| v.as_u64()) {
t.insert("Phase".into(), toml::Value::Integer(p as i64));
}
}
"ph" => {
t.insert("OCP (mV)".into(), toml_f(obj, "v_ocp_mv")?);
t.insert("pH".into(), toml_f(obj, "ph")?);
t.insert("Temperature (C)".into(), toml_f(obj, "temp_c")?);
}
_ => return None,
}
Some(t)
}
fn toml_f(obj: &serde_json::Map<String, serde_json::Value>, key: &str) -> Option<toml::Value> {
obj.get(key)?.as_f64().map(toml::Value::Float)
}
fn cl_result_to_toml(jv: &serde_json::Value) -> Table {
let mut t = Table::new();
if let Some(obj) = jv.as_object() {
if let Some(v) = obj.get("i_free_ua").and_then(|v| v.as_f64()) {
t.insert("Free Cl (uA)".into(), toml::Value::Float(v));
}
if let Some(v) = obj.get("i_total_ua").and_then(|v| v.as_f64()) {
t.insert("Total Cl (uA)".into(), toml::Value::Float(v));
}
}
t
}
fn toml_table_to_json(t: &Table) -> serde_json::Value {
let mut obj = serde_json::Map::new();
for (k, v) in t {
obj.insert(k.clone(), toml_val_to_json(v));
}
serde_json::Value::Object(obj)
}
fn toml_val_to_json(v: &toml::Value) -> serde_json::Value {
match v {
toml::Value::String(s) => serde_json::Value::String(s.clone()),
toml::Value::Integer(i) => serde_json::json!(*i),
toml::Value::Float(f) => serde_json::json!(*f),
toml::Value::Boolean(b) => serde_json::Value::Bool(*b),
toml::Value::Table(t) => toml_table_to_json(t),
_ => serde_json::Value::Null,
}
}
fn toml_data_row_to_json(mtype: &str, row: &Table) -> serde_json::Value {
let mut obj = serde_json::Map::new();
match mtype {
"eis" => {
set_f(&mut obj, "freq_hz", row, "Frequency (Hz)");
set_f(&mut obj, "mag_ohms", row, "|Z| (Ohm)");
set_f(&mut obj, "phase_deg", row, "Phase (deg)");
set_f(&mut obj, "z_real", row, "Re (Ohm)");
set_f(&mut obj, "z_imag", row, "Im (Ohm)");
}
"lsv" => {
set_f(&mut obj, "v_mv", row, "Voltage (mV)");
set_f(&mut obj, "i_ua", row, "Current (uA)");
}
"amp" => {
set_f(&mut obj, "t_ms", row, "Time (ms)");
set_f(&mut obj, "i_ua", row, "Current (uA)");
}
"chlorine" => {
set_f(&mut obj, "t_ms", row, "Time (ms)");
set_f(&mut obj, "i_ua", row, "Current (uA)");
if let Some(v) = row.get("Phase").and_then(|v| v.as_integer()) {
obj.insert("phase".into(), serde_json::json!(v));
}
}
"ph" => {
set_f(&mut obj, "v_ocp_mv", row, "OCP (mV)");
set_f(&mut obj, "ph", row, "pH");
set_f(&mut obj, "temp_c", row, "Temperature (C)");
}
_ => {}
}
serde_json::Value::Object(obj)
}
fn set_f(
obj: &mut serde_json::Map<String, serde_json::Value>,
json_key: &str, row: &Table, toml_key: &str,
) {
if let Some(v) = row.get(toml_key).and_then(|v| v.as_float()) {
obj.insert(json_key.into(), serde_json::json!(v));
}
}
fn toml_cl_result_to_json(t: &Table) -> serde_json::Value {
let mut obj = serde_json::Map::new();
if let Some(v) = t.get("Free Cl (uA)").and_then(|v| v.as_float()) {
obj.insert("i_free_ua".into(), serde_json::json!(v));
}
if let Some(v) = t.get("Total Cl (uA)").and_then(|v| v.as_float()) {
obj.insert("i_total_ua".into(), serde_json::json!(v));
}
serde_json::Value::Object(obj)
} }
fn dirs() -> std::path::PathBuf { fn dirs() -> std::path::PathBuf {

138
cue/src/udp.rs Normal file
View File

@ -0,0 +1,138 @@
use tokio::net::UdpSocket;
use tokio::sync::mpsc;
use std::time::{Duration, Instant};
use crate::protocol::{self, EisMessage};
const DEFAULT_ADDR: &str = "192.168.4.1:5941";
const SETTINGS_FILE: &str = ".eis4_udp_addr";
pub fn load_addr() -> String {
let path = dirs_next::home_dir()
.map(|h| h.join(SETTINGS_FILE))
.unwrap_or_default();
std::fs::read_to_string(path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_ADDR.to_string())
}
pub fn save_addr(addr: &str) {
if let Some(path) = dirs_next::home_dir().map(|h| h.join(SETTINGS_FILE)) {
let _ = std::fs::write(path, addr);
}
}
const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
const TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone)]
pub enum UdpEvent {
Status(String),
Data(EisMessage),
}
fn extract_sysex_frames(buf: &[u8]) -> Vec<Vec<u8>> {
let mut frames = Vec::new();
let mut i = 0;
while i < buf.len() {
if buf[i] == 0xF0 {
if let Some(end) = buf[i..].iter().position(|&b| b == 0xF7) {
frames.push(buf[i + 1..i + end].to_vec());
i += end + 1;
continue;
}
}
i += 1;
}
frames
}
pub async fn connect_and_run(
tx: mpsc::UnboundedSender<UdpEvent>,
mut cmd_rx: mpsc::UnboundedReceiver<Vec<u8>>,
addr: String,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let esp_addr = if addr.contains(':') { addr.clone() } else { format!("{addr}:5941") };
save_addr(&esp_addr);
loop {
let _ = tx.send(UdpEvent::Status(format!("Connecting UDP to {esp_addr}...")));
let sock = match UdpSocket::bind("0.0.0.0:0").await {
Ok(s) => s,
Err(e) => {
let _ = tx.send(UdpEvent::Status(format!("Bind failed: {e}")));
tokio::time::sleep(Duration::from_secs(2)).await;
continue;
}
};
if let Err(e) = sock.connect(&esp_addr).await {
let _ = tx.send(UdpEvent::Status(format!("Connect failed: {e}")));
tokio::time::sleep(Duration::from_secs(2)).await;
continue;
}
/* Initial keepalive to register with ESP */
let keepalive = protocol::build_sysex_get_temp();
let _ = sock.send(&keepalive).await;
let mut last_rx = Instant::now();
let mut last_keepalive = Instant::now();
let mut connected = false;
let mut buf = [0u8; 4096];
loop {
let deadline = tokio::time::sleep(Duration::from_millis(50));
tokio::pin!(deadline);
tokio::select! {
result = sock.recv(&mut buf) => {
match result {
Ok(n) if n > 0 => {
last_rx = Instant::now();
if !connected {
connected = true;
let _ = tx.send(UdpEvent::Status("Connected".into()));
}
for frame in extract_sysex_frames(&buf[..n]) {
if let Some(msg) = protocol::parse_sysex(&frame) {
let _ = tx.send(UdpEvent::Data(msg));
}
}
}
Ok(_) => {}
Err(e) => {
let _ = tx.send(UdpEvent::Status(format!("Recv error: {e}")));
break;
}
}
}
cmd = cmd_rx.recv() => {
match cmd {
Some(pkt) => {
if let Err(e) = sock.send(&pkt).await {
eprintln!("UDP send: {e}");
}
}
None => return Ok(()),
}
}
_ = &mut deadline => {}
}
if last_keepalive.elapsed() >= KEEPALIVE_INTERVAL {
let _ = sock.send(&keepalive).await;
last_keepalive = Instant::now();
}
if connected && last_rx.elapsed() >= TIMEOUT {
let _ = tx.send(UdpEvent::Status("Timeout — reconnecting...".into()));
break;
}
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}

View File

@ -1,3 +1,9 @@
idf_component_register(SRCS "eis4.c" "eis.c" "echem.c" "ble.c" "temp.c" "refs.c" idf_component_register(SRCS "eis4.c" "eis.c" "echem.c" "protocol.c" "wifi_transport.c" "temp.c" "refs.c"
INCLUDE_DIRS "." INCLUDE_DIRS "."
REQUIRES ad5941 ad5941_port bt nvs_flash) REQUIRES ad5941 ad5941_port nvs_flash esp_wifi esp_netif esp_event)
if(DEFINED ENV{WIFI_SSID})
target_compile_definitions(${COMPONENT_LIB} PRIVATE
STA_SSID="$ENV{WIFI_SSID}"
STA_PASS="$ENV{WIFI_PASS}")
endif()

View File

@ -1,873 +0,0 @@
#include "ble.h"
#include <string.h>
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"
#include "freertos/queue.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "host/ble_hs.h"
#include "host/ble_gap.h"
#include "host/util/util.h"
#include "host/ble_store.h"
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"
void ble_store_config_init(void);
#define DEVICE_NAME "EIS4"
#define CONNECTED_BIT BIT0
#define CMD_QUEUE_LEN 8
/* BLE MIDI Service 03B80E5A-EDE8-4B33-A751-6CE34EC4C700 */
static const ble_uuid128_t midi_svc_uuid = BLE_UUID128_INIT(
0x00, 0xc7, 0xc4, 0x4e, 0xe3, 0x6c, 0x51, 0xa7,
0x33, 0x4b, 0xe8, 0xed, 0x5a, 0x0e, 0xb8, 0x03);
/* BLE MIDI Characteristic 7772E5DB-3868-4112-A1A9-F2669D106BF3 */
static const ble_uuid128_t midi_chr_uuid = BLE_UUID128_INIT(
0xf3, 0x6b, 0x10, 0x9d, 0x66, 0xf2, 0xa9, 0xa1,
0x12, 0x41, 0x68, 0x38, 0xdb, 0xe5, 0x72, 0x77);
#define MAX_CONNECTIONS 4
static EventGroupHandle_t ble_events;
static QueueHandle_t cmd_queue;
static uint16_t midi_val_hdl;
static uint16_t hid_input_val_hdl;
static struct {
uint16_t hdl;
bool midi_notify;
bool hid_notify;
} conns[MAX_CONNECTIONS];
static int conn_count;
/* ---- HID keyboard report map ---- */
static const uint8_t hid_report_map[] = {
0x05, 0x01, /* Usage Page (Generic Desktop) */
0x09, 0x06, /* Usage (Keyboard) */
0xA1, 0x01, /* Collection (Application) */
0x85, 0x01, /* Report ID (1) */
0x05, 0x07, /* Usage Page (Keyboard) */
0x19, 0xE0, 0x29, 0xE7, /* Usage Min/Max (modifiers) */
0x15, 0x00, 0x25, 0x01, /* Logical Min/Max */
0x75, 0x01, 0x95, 0x08, /* 8x1-bit modifier keys */
0x81, 0x02, /* Input (Variable) */
0x95, 0x01, 0x75, 0x08, /* 1x8-bit reserved */
0x81, 0x01, /* Input (Constant) */
0x05, 0x08, /* Usage Page (LEDs) */
0x19, 0x01, 0x29, 0x05, /* 5 LEDs */
0x95, 0x05, 0x75, 0x01,
0x91, 0x02, /* Output (Variable) */
0x95, 0x01, 0x75, 0x03,
0x91, 0x01, /* Output pad to byte */
0x05, 0x07, /* Usage Page (Keyboard) */
0x19, 0x00, 0x29, 0xFF, /* Key codes 0-255 */
0x15, 0x00, 0x26, 0xFF, 0x00, /* Logical Min/Max (0-255) */
0x95, 0x06, 0x75, 0x08, /* 6x8-bit key array */
0x81, 0x00, /* Input (Array) */
0xC0, /* End Collection */
};
/* bcdHID=1.11, bCountryCode=0, Flags=NormallyConnectable */
static const uint8_t hid_info[] = { 0x11, 0x01, 0x00, 0x02 };
/* PnP ID: src=USB-IF(2), VID=0x1209(pid.codes), PID=0x0001, ver=0x0100 */
static const uint8_t pnp_id[] = { 0x02, 0x09, 0x12, 0x01, 0x00, 0x00, 0x01 };
/* ---- 7-bit MIDI encoding ---- */
static void encode_float(float val, uint8_t *out)
{
uint8_t *p = (uint8_t *)&val;
out[0] = ((p[0] >> 7) & 1) | ((p[1] >> 6) & 2) |
((p[2] >> 5) & 4) | ((p[3] >> 4) & 8);
out[1] = p[0] & 0x7F;
out[2] = p[1] & 0x7F;
out[3] = p[2] & 0x7F;
out[4] = p[3] & 0x7F;
}
static void encode_u16(uint16_t val, uint8_t *out)
{
uint8_t *p = (uint8_t *)&val;
out[0] = ((p[0] >> 7) & 1) | ((p[1] >> 6) & 2);
out[1] = p[0] & 0x7F;
out[2] = p[1] & 0x7F;
}
static float decode_float(const uint8_t *d)
{
uint8_t b[4];
b[0] = d[1] | ((d[0] & 1) << 7);
b[1] = d[2] | ((d[0] & 2) << 6);
b[2] = d[3] | ((d[0] & 4) << 5);
b[3] = d[4] | ((d[0] & 8) << 4);
float v;
memcpy(&v, b, 4);
return v;
}
static uint16_t decode_u16(const uint8_t *d)
{
uint8_t b[2];
b[0] = d[1] | ((d[0] & 1) << 7);
b[1] = d[2] | ((d[0] & 2) << 6);
uint16_t v;
memcpy(&v, b, 2);
return v;
}
/* ---- command parsing from incoming SysEx ---- */
static void parse_one_sysex(const uint8_t *midi, uint16_t mlen)
{
if (mlen < 3 || midi[0] != 0xF0 || midi[1] != 0x7D) return;
BleCommand cmd;
memset(&cmd, 0, sizeof(cmd));
cmd.type = midi[2];
switch (cmd.type) {
case CMD_SET_SWEEP:
if (mlen < 16) return;
cmd.sweep.freq_start = decode_float(&midi[3]);
cmd.sweep.freq_stop = decode_float(&midi[8]);
cmd.sweep.ppd = decode_u16(&midi[13]);
break;
case CMD_SET_RTIA:
if (mlen < 4) return;
cmd.rtia = midi[3];
break;
case CMD_SET_RCAL:
if (mlen < 4) return;
cmd.rcal = midi[3];
break;
case CMD_SET_ELECTRODE:
if (mlen < 4) return;
cmd.electrode = midi[3];
break;
case CMD_START_LSV:
if (mlen < 19) return;
cmd.lsv.v_start = decode_float(&midi[3]);
cmd.lsv.v_stop = decode_float(&midi[8]);
cmd.lsv.scan_rate = decode_float(&midi[13]);
cmd.lsv.lp_rtia = midi[18];
break;
case CMD_START_AMP:
if (mlen < 19) return;
cmd.amp.v_hold = decode_float(&midi[3]);
cmd.amp.interval_ms = decode_float(&midi[8]);
cmd.amp.duration_s = decode_float(&midi[13]);
cmd.amp.lp_rtia = midi[18];
break;
case CMD_START_CL:
if (mlen < 34) return;
cmd.cl.v_cond = decode_float(&midi[3]);
cmd.cl.t_cond_ms = decode_float(&midi[8]);
cmd.cl.v_free = decode_float(&midi[13]);
cmd.cl.v_total = decode_float(&midi[18]);
cmd.cl.t_dep_ms = decode_float(&midi[23]);
cmd.cl.t_meas_ms = decode_float(&midi[28]);
cmd.cl.lp_rtia = midi[33];
break;
case CMD_START_PH:
if (mlen < 8) return;
cmd.ph.stabilize_s = decode_float(&midi[3]);
break;
case CMD_START_CLEAN:
if (mlen < 13) return;
cmd.clean.v_mv = decode_float(&midi[3]);
cmd.clean.duration_s = decode_float(&midi[8]);
break;
case CMD_START_SWEEP:
case CMD_GET_CONFIG:
case CMD_STOP_AMP:
case CMD_GET_TEMP:
case CMD_START_REFS:
case CMD_GET_REFS:
case CMD_CLEAR_REFS:
case CMD_OPEN_CAL:
case CMD_CLEAR_OPEN_CAL:
break;
default:
return;
}
xQueueSend(cmd_queue, &cmd, 0);
}
static void parse_command(const uint8_t *data, uint16_t len)
{
if (len < 5) return;
uint16_t i = 1; /* skip BLE MIDI header byte */
while (i < len) {
/* skip timestamp bytes (bit 7 set, not F0/F7) */
if ((data[i] & 0x80) && data[i] != 0xF0 && data[i] != 0xF7) {
i++;
continue;
}
if (data[i] == 0xF0) {
uint8_t clean[64];
uint16_t clen = 0;
clean[clen++] = 0xF0;
i++;
while (i < len && data[i] != 0xF7) {
if (data[i] & 0x80) { i++; continue; } /* strip timestamps */
if (clen < sizeof(clean))
clean[clen++] = data[i];
i++;
}
if (i < len) i++; /* skip F7 */
parse_one_sysex(clean, clen);
} else {
i++;
}
}
}
/* ---- GATT access callbacks ---- */
static int midi_access_cb(uint16_t ch, uint16_t ah,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)ch; (void)ah; (void)arg;
if (ctxt->op == BLE_GATT_ACCESS_OP_WRITE_CHR) {
uint16_t len = OS_MBUF_PKTLEN(ctxt->om);
uint8_t buf[64];
if (len > sizeof(buf)) len = sizeof(buf);
os_mbuf_copydata(ctxt->om, 0, len, buf);
parse_command(buf, len);
}
return 0;
}
static int hid_report_map_cb(uint16_t ch, uint16_t ah,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)ch; (void)ah; (void)arg;
if (ctxt->op == BLE_GATT_ACCESS_OP_READ_CHR)
os_mbuf_append(ctxt->om, hid_report_map, sizeof(hid_report_map));
return 0;
}
static int hid_info_cb(uint16_t ch, uint16_t ah,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)ch; (void)ah; (void)arg;
if (ctxt->op == BLE_GATT_ACCESS_OP_READ_CHR)
os_mbuf_append(ctxt->om, hid_info, sizeof(hid_info));
return 0;
}
static int hid_input_report_cb(uint16_t ch, uint16_t ah,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)ch; (void)ah; (void)arg;
if (ctxt->op == BLE_GATT_ACCESS_OP_READ_CHR) {
static const uint8_t empty[8] = {0};
os_mbuf_append(ctxt->om, empty, sizeof(empty));
}
return 0;
}
static int hid_output_report_cb(uint16_t ch, uint16_t ah,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)ch; (void)ah; (void)arg;
return 0;
}
static int hid_input_ref_cb(uint16_t ch, uint16_t ah,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)ch; (void)ah; (void)arg;
if (ctxt->op == BLE_GATT_ACCESS_OP_READ_DSC) {
static const uint8_t ref[] = { 0x01, 0x01 }; /* Report ID 1, Input */
os_mbuf_append(ctxt->om, ref, sizeof(ref));
}
return 0;
}
static int hid_output_ref_cb(uint16_t ch, uint16_t ah,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)ch; (void)ah; (void)arg;
if (ctxt->op == BLE_GATT_ACCESS_OP_READ_DSC) {
static const uint8_t ref[] = { 0x01, 0x02 }; /* Report ID 1, Output */
os_mbuf_append(ctxt->om, ref, sizeof(ref));
}
return 0;
}
static int hid_boot_input_cb(uint16_t ch, uint16_t ah,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)ch; (void)ah; (void)arg;
if (ctxt->op == BLE_GATT_ACCESS_OP_READ_CHR) {
static const uint8_t empty[8] = {0};
os_mbuf_append(ctxt->om, empty, sizeof(empty));
}
return 0;
}
static int hid_boot_output_cb(uint16_t ch, uint16_t ah,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)ch; (void)ah; (void)arg;
return 0;
}
static int hid_ctrl_cb(uint16_t ch, uint16_t ah,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)ch; (void)ah; (void)arg;
return 0;
}
static int hid_proto_cb(uint16_t ch, uint16_t ah,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)ch; (void)ah; (void)arg;
if (ctxt->op == BLE_GATT_ACCESS_OP_READ_CHR) {
uint8_t mode = 1; /* Report Protocol */
os_mbuf_append(ctxt->om, &mode, 1);
}
return 0;
}
static int bas_level_cb(uint16_t ch, uint16_t ah,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)ch; (void)ah; (void)arg;
if (ctxt->op == BLE_GATT_ACCESS_OP_READ_CHR) {
uint8_t level = 100;
os_mbuf_append(ctxt->om, &level, 1);
}
return 0;
}
static int dis_pnp_cb(uint16_t ch, uint16_t ah,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)ch; (void)ah; (void)arg;
if (ctxt->op == BLE_GATT_ACCESS_OP_READ_CHR)
os_mbuf_append(ctxt->om, pnp_id, sizeof(pnp_id));
return 0;
}
/* ---- GATT service table ---- */
static const struct ble_gatt_svc_def gatt_svcs[] = {
/* Device Information Service */
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = BLE_UUID16_DECLARE(0x180A),
.characteristics = (struct ble_gatt_chr_def[]) {
{ .uuid = BLE_UUID16_DECLARE(0x2A50), .access_cb = dis_pnp_cb,
.flags = BLE_GATT_CHR_F_READ },
{ 0 },
},
},
/* Battery Service */
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = BLE_UUID16_DECLARE(0x180F),
.characteristics = (struct ble_gatt_chr_def[]) {
{ .uuid = BLE_UUID16_DECLARE(0x2A19), .access_cb = bas_level_cb,
.flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_NOTIFY },
{ 0 },
},
},
/* HID Service */
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = BLE_UUID16_DECLARE(0x1812),
.characteristics = (struct ble_gatt_chr_def[]) {
{ .uuid = BLE_UUID16_DECLARE(0x2A4B), .access_cb = hid_report_map_cb,
.flags = BLE_GATT_CHR_F_READ },
{ .uuid = BLE_UUID16_DECLARE(0x2A4A), .access_cb = hid_info_cb,
.flags = BLE_GATT_CHR_F_READ },
/* Input Report */
{ .uuid = BLE_UUID16_DECLARE(0x2A4D), .access_cb = hid_input_report_cb,
.val_handle = &hid_input_val_hdl,
.descriptors = (struct ble_gatt_dsc_def[]) {
{ .uuid = BLE_UUID16_DECLARE(0x2908), .att_flags = BLE_ATT_F_READ,
.access_cb = hid_input_ref_cb },
{ 0 },
},
.flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_NOTIFY | BLE_GATT_CHR_F_READ_ENC },
/* Output Report (LEDs) */
{ .uuid = BLE_UUID16_DECLARE(0x2A4D), .access_cb = hid_output_report_cb,
.descriptors = (struct ble_gatt_dsc_def[]) {
{ .uuid = BLE_UUID16_DECLARE(0x2908), .att_flags = BLE_ATT_F_READ,
.access_cb = hid_output_ref_cb },
{ 0 },
},
.flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_WRITE |
BLE_GATT_CHR_F_WRITE_NO_RSP | BLE_GATT_CHR_F_READ_ENC |
BLE_GATT_CHR_F_WRITE_ENC },
/* Boot Keyboard Input Report */
{ .uuid = BLE_UUID16_DECLARE(0x2A22), .access_cb = hid_boot_input_cb,
.flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_NOTIFY | BLE_GATT_CHR_F_READ_ENC },
/* Boot Keyboard Output Report */
{ .uuid = BLE_UUID16_DECLARE(0x2A32), .access_cb = hid_boot_output_cb,
.flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_WRITE |
BLE_GATT_CHR_F_WRITE_NO_RSP | BLE_GATT_CHR_F_READ_ENC },
{ .uuid = BLE_UUID16_DECLARE(0x2A4C), .access_cb = hid_ctrl_cb,
.flags = BLE_GATT_CHR_F_WRITE_NO_RSP },
{ .uuid = BLE_UUID16_DECLARE(0x2A4E), .access_cb = hid_proto_cb,
.flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_WRITE_NO_RSP },
{ 0 },
},
},
/* MIDI Service */
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = &midi_svc_uuid.u,
.characteristics = (struct ble_gatt_chr_def[]) {
{ .uuid = &midi_chr_uuid.u, .access_cb = midi_access_cb,
.val_handle = &midi_val_hdl,
.flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_WRITE_NO_RSP | BLE_GATT_CHR_F_NOTIFY },
{ 0 },
},
},
{ 0 },
};
/* ---- send empty keyboard report ---- */
static int conn_find(uint16_t hdl)
{
for (int i = 0; i < conn_count; i++)
if (conns[i].hdl == hdl) return i;
return -1;
}
static void send_empty_hid_report(uint16_t hdl)
{
static const uint8_t empty[8] = {0};
struct os_mbuf *om = ble_hs_mbuf_from_flat(empty, sizeof(empty));
if (om)
ble_gatts_notify_custom(hdl, hid_input_val_hdl, om);
}
/* ---- GAP / advertising ---- */
static void start_adv(void);
static int gap_event_cb(struct ble_gap_event *event, void *arg)
{
(void)arg;
switch (event->type) {
case BLE_GAP_EVENT_CONNECT:
if (event->connect.status == 0) {
uint16_t hdl = event->connect.conn_handle;
if (conn_count < MAX_CONNECTIONS) {
conns[conn_count].hdl = hdl;
conns[conn_count].midi_notify = false;
conns[conn_count].hid_notify = false;
conn_count++;
}
xEventGroupSetBits(ble_events, CONNECTED_BIT);
ble_att_set_preferred_mtu(128);
ble_gattc_exchange_mtu(hdl, NULL, NULL);
ble_gap_security_initiate(hdl);
printf("BLE: connected (%d/%d)\n", conn_count, MAX_CONNECTIONS);
if (conn_count < MAX_CONNECTIONS)
start_adv();
} else {
start_adv();
}
break;
case BLE_GAP_EVENT_DISCONNECT: {
uint16_t hdl = event->disconnect.conn.conn_handle;
int idx = conn_find(hdl);
if (idx >= 0) {
conns[idx] = conns[--conn_count];
}
if (conn_count == 0)
xEventGroupClearBits(ble_events, CONNECTED_BIT);
printf("BLE: disconnected (%d/%d)\n", conn_count, MAX_CONNECTIONS);
start_adv();
break;
}
case BLE_GAP_EVENT_SUBSCRIBE: {
int idx = conn_find(event->subscribe.conn_handle);
if (idx >= 0) {
if (event->subscribe.attr_handle == midi_val_hdl)
conns[idx].midi_notify = event->subscribe.cur_notify;
if (event->subscribe.attr_handle == hid_input_val_hdl) {
conns[idx].hid_notify = event->subscribe.cur_notify;
if (conns[idx].hid_notify)
send_empty_hid_report(event->subscribe.conn_handle);
}
}
break;
}
case BLE_GAP_EVENT_REPEAT_PAIRING: {
struct ble_gap_conn_desc desc;
ble_gap_conn_find(event->repeat_pairing.conn_handle, &desc);
ble_store_util_delete_peer(&desc.peer_id_addr);
return BLE_GAP_REPEAT_PAIRING_RETRY;
}
default:
break;
}
return 0;
}
static void adv_task(void *param)
{
(void)param;
vTaskDelay(pdMS_TO_TICKS(200));
ble_gap_adv_stop();
struct ble_hs_adv_fields fields = {0};
fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
fields.name = (uint8_t *)DEVICE_NAME;
fields.name_len = strlen(DEVICE_NAME);
fields.name_is_complete = 1;
fields.appearance = 0x03C1; /* Keyboard */
fields.appearance_is_present = 1;
ble_uuid16_t adv_uuids[] = {
BLE_UUID16_INIT(0x1812), /* HID */
BLE_UUID16_INIT(0x180F), /* Battery */
};
fields.uuids16 = adv_uuids;
fields.num_uuids16 = 2;
fields.uuids16_is_complete = 0;
int rc = ble_gap_adv_set_fields(&fields);
if (rc) { printf("BLE: set_fields failed: %d\n", rc); goto done; }
struct ble_hs_adv_fields rsp = {0};
rsp.uuids128 = (ble_uuid128_t *)&midi_svc_uuid;
rsp.num_uuids128 = 1;
rsp.uuids128_is_complete = 1;
rc = ble_gap_adv_rsp_set_fields(&rsp);
if (rc) { printf("BLE: set_rsp failed: %d\n", rc); goto done; }
struct ble_gap_adv_params params = {0};
params.conn_mode = BLE_GAP_CONN_MODE_UND;
params.disc_mode = BLE_GAP_DISC_MODE_GEN;
params.itvl_min = BLE_GAP_ADV_FAST_INTERVAL1_MIN;
params.itvl_max = BLE_GAP_ADV_FAST_INTERVAL1_MAX;
rc = ble_gap_adv_start(BLE_OWN_ADDR_PUBLIC, NULL, BLE_HS_FOREVER,
&params, gap_event_cb, NULL);
if (rc)
printf("BLE: adv_start failed: %d\n", rc);
else
printf("BLE: advertising (%d connected)\n", conn_count);
done:
vTaskDelete(NULL);
}
static void start_adv(void)
{
xTaskCreate(adv_task, "adv", 2048, NULL, 5, NULL);
}
static void on_sync(void)
{
uint8_t addr_type;
ble_hs_id_infer_auto(0, &addr_type);
start_adv();
printf("BLE: advertising as \"%s\"\n", DEVICE_NAME);
}
static void on_reset(int reason) { (void)reason; }
static void host_task(void *param)
{
(void)param;
nimble_port_run();
nimble_port_freertos_deinit();
}
/* ---- SysEx send ---- */
static int send_sysex(const uint8_t *sysex, uint16_t len)
{
if (conn_count == 0)
return -1;
uint16_t pkt_len = len + 3;
uint8_t pkt[80];
if (pkt_len > sizeof(pkt))
return -1;
pkt[0] = 0x80;
pkt[1] = 0x80;
memcpy(&pkt[2], sysex, len - 1);
pkt[len + 1] = 0x80;
pkt[len + 2] = 0xF7;
int sent = 0;
for (int i = 0; i < conn_count; i++) {
if (!conns[i].midi_notify) continue;
struct os_mbuf *om = ble_hs_mbuf_from_flat(pkt, pkt_len);
if (!om) continue;
if (ble_gatts_notify_custom(conns[i].hdl, midi_val_hdl, om) == 0)
sent++;
}
return sent > 0 ? 0 : -1;
}
/* ---- public API ---- */
int ble_init(void)
{
ble_events = xEventGroupCreate();
cmd_queue = xQueueCreate(CMD_QUEUE_LEN, sizeof(BleCommand));
int rc = nimble_port_init();
if (rc != ESP_OK) return rc;
ble_hs_cfg.sync_cb = on_sync;
ble_hs_cfg.reset_cb = on_reset;
ble_hs_cfg.sm_bonding = 1;
ble_hs_cfg.sm_mitm = 0;
ble_hs_cfg.sm_sc = 1;
ble_hs_cfg.sm_io_cap = BLE_SM_IO_CAP_NO_IO;
ble_hs_cfg.sm_our_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID;
ble_hs_cfg.sm_their_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID;
ble_svc_gap_init();
ble_svc_gatt_init();
ble_svc_gap_device_name_set(DEVICE_NAME);
ble_svc_gap_device_appearance_set(0x03C1);
rc = ble_gatts_count_cfg(gatt_svcs);
if (rc) return rc;
rc = ble_gatts_add_svcs(gatt_svcs);
if (rc) return rc;
ble_store_config_init();
nimble_port_freertos_init(host_task);
return 0;
}
int ble_is_connected(void)
{
return conn_count > 0;
}
void ble_wait_for_connection(void)
{
xEventGroupWaitBits(ble_events, CONNECTED_BIT, pdFALSE, pdTRUE, portMAX_DELAY);
vTaskDelay(pdMS_TO_TICKS(1000));
}
int ble_recv_command(BleCommand *cmd, uint32_t timeout_ms)
{
TickType_t ticks = (timeout_ms == UINT32_MAX) ? portMAX_DELAY : pdMS_TO_TICKS(timeout_ms);
return xQueueReceive(cmd_queue, cmd, ticks) == pdTRUE ? 0 : -1;
}
int ble_send_sweep_start(uint32_t num_points, float freq_start, float freq_stop)
{
uint8_t sx[20];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_SWEEP_START;
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
encode_float(freq_start, &sx[p]); p += 5;
encode_float(freq_stop, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int ble_send_eis_point(uint16_t index, const EISPoint *pt)
{
uint8_t sx[64];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_DATA_POINT;
encode_u16(index, &sx[p]); p += 3;
encode_float(pt->freq_hz, &sx[p]); p += 5;
encode_float(pt->mag_ohms, &sx[p]); p += 5;
encode_float(pt->phase_deg, &sx[p]); p += 5;
encode_float(pt->z_real, &sx[p]); p += 5;
encode_float(pt->z_imag, &sx[p]); p += 5;
encode_float(pt->rtia_mag_before, &sx[p]); p += 5;
encode_float(pt->rtia_mag_after, &sx[p]); p += 5;
encode_float(pt->rev_mag, &sx[p]); p += 5;
encode_float(pt->rev_phase, &sx[p]); p += 5;
encode_float(pt->pct_err, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int ble_send_sweep_end(void)
{
uint8_t sx[] = { 0xF0, 0x7D, RSP_SWEEP_END, 0xF7 };
return send_sysex(sx, sizeof(sx));
}
int ble_send_config(const EISConfig *cfg)
{
uint8_t sx[32];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_CONFIG;
encode_float(cfg->freq_start_hz, &sx[p]); p += 5;
encode_float(cfg->freq_stop_hz, &sx[p]); p += 5;
encode_u16(cfg->points_per_decade, &sx[p]); p += 3;
sx[p++] = (uint8_t)cfg->rtia;
sx[p++] = (uint8_t)cfg->rcal;
sx[p++] = (uint8_t)cfg->electrode;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int ble_send_lsv_start(uint32_t num_points, float v_start, float v_stop)
{
uint8_t sx[20];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_LSV_START;
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
encode_float(v_start, &sx[p]); p += 5;
encode_float(v_stop, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int ble_send_lsv_point(uint16_t index, float v_mv, float i_ua)
{
uint8_t sx[20];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_LSV_POINT;
encode_u16(index, &sx[p]); p += 3;
encode_float(v_mv, &sx[p]); p += 5;
encode_float(i_ua, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int ble_send_lsv_end(void)
{
uint8_t sx[] = { 0xF0, 0x7D, RSP_LSV_END, 0xF7 };
return send_sysex(sx, sizeof(sx));
}
int ble_send_amp_start(float v_hold)
{
uint8_t sx[12];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_AMP_START;
encode_float(v_hold, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int ble_send_amp_point(uint16_t index, float t_ms, float i_ua)
{
uint8_t sx[20];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_AMP_POINT;
encode_u16(index, &sx[p]); p += 3;
encode_float(t_ms, &sx[p]); p += 5;
encode_float(i_ua, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int ble_send_amp_end(void)
{
uint8_t sx[] = { 0xF0, 0x7D, RSP_AMP_END, 0xF7 };
return send_sysex(sx, sizeof(sx));
}
int ble_send_cl_start(uint32_t num_points)
{
uint8_t sx[10];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_CL_START;
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int ble_send_cl_point(uint16_t index, float t_ms, float i_ua, uint8_t phase)
{
uint8_t sx[20];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_CL_POINT;
encode_u16(index, &sx[p]); p += 3;
encode_float(t_ms, &sx[p]); p += 5;
encode_float(i_ua, &sx[p]); p += 5;
sx[p++] = phase & 0x7F;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int ble_send_cl_result(float i_free_ua, float i_total_ua)
{
uint8_t sx[16];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_CL_RESULT;
encode_float(i_free_ua, &sx[p]); p += 5;
encode_float(i_total_ua, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int ble_send_cl_end(void)
{
uint8_t sx[] = { 0xF0, 0x7D, RSP_CL_END, 0xF7 };
return send_sysex(sx, sizeof(sx));
}
int ble_send_ph_result(float v_ocp_mv, float ph, float temp_c)
{
uint8_t sx[20];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_PH_RESULT;
encode_float(v_ocp_mv, &sx[p]); p += 5;
encode_float(ph, &sx[p]); p += 5;
encode_float(temp_c, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int ble_send_temp(float temp_c)
{
uint8_t sx[12];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_TEMP;
encode_float(temp_c, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int ble_send_ref_frame(uint8_t mode, uint8_t rtia_idx)
{
uint8_t sx[] = { 0xF0, 0x7D, RSP_REF_FRAME, mode & 0x7F, rtia_idx & 0x7F, 0xF7 };
return send_sysex(sx, sizeof(sx));
}
int ble_send_ref_lp_range(uint8_t mode, uint8_t low_idx, uint8_t high_idx)
{
uint8_t sx[] = { 0xF0, 0x7D, RSP_REF_LP_RANGE, mode & 0x7F, low_idx & 0x7F, high_idx & 0x7F, 0xF7 };
return send_sysex(sx, sizeof(sx));
}
int ble_send_refs_done(void)
{
uint8_t sx[] = { 0xF0, 0x7D, RSP_REFS_DONE, 0xF7 };
return send_sysex(sx, sizeof(sx));
}
int ble_send_ref_status(uint8_t has_refs)
{
uint8_t sx[] = { 0xF0, 0x7D, RSP_REF_STATUS, has_refs & 0x7F, 0xF7 };
return send_sysex(sx, sizeof(sx));
}

View File

@ -1,105 +0,0 @@
#ifndef BLE_H
#define BLE_H
#include "eis.h"
/* Commands: Cue → ESP32 (0x1x, 0x2x) */
#define CMD_SET_SWEEP 0x10
#define CMD_SET_RTIA 0x11
#define CMD_SET_RCAL 0x12
#define CMD_START_SWEEP 0x13
#define CMD_GET_CONFIG 0x14
#define CMD_SET_ELECTRODE 0x15
#define CMD_START_LSV 0x20
#define CMD_START_AMP 0x21
#define CMD_STOP_AMP 0x22
#define CMD_GET_TEMP 0x17
#define CMD_START_CL 0x23
#define CMD_START_PH 0x24
#define CMD_START_CLEAN 0x25
#define CMD_OPEN_CAL 0x26
#define CMD_CLEAR_OPEN_CAL 0x27
#define CMD_START_REFS 0x30
#define CMD_GET_REFS 0x31
#define CMD_CLEAR_REFS 0x32
/* Responses: ESP32 → Cue (0x0x) */
#define RSP_SWEEP_START 0x01
#define RSP_DATA_POINT 0x02
#define RSP_SWEEP_END 0x03
#define RSP_CONFIG 0x04
#define RSP_LSV_START 0x05
#define RSP_LSV_POINT 0x06
#define RSP_LSV_END 0x07
#define RSP_AMP_START 0x08
#define RSP_AMP_POINT 0x09
#define RSP_AMP_END 0x0A
#define RSP_CL_START 0x0B
#define RSP_CL_POINT 0x0C
#define RSP_CL_RESULT 0x0D
#define RSP_CL_END 0x0E
#define RSP_PH_RESULT 0x0F
#define RSP_TEMP 0x10
#define RSP_REF_FRAME 0x20
#define RSP_REF_LP_RANGE 0x21
#define RSP_REFS_DONE 0x22
#define RSP_REF_STATUS 0x23
typedef struct {
uint8_t type;
union {
struct { float freq_start, freq_stop; uint16_t ppd; } sweep;
uint8_t rtia;
uint8_t rcal;
uint8_t electrode;
struct { float v_start, v_stop, scan_rate; uint8_t lp_rtia; } lsv;
struct { float v_hold, interval_ms, duration_s; uint8_t lp_rtia; } amp;
struct { float v_cond, t_cond_ms, v_free, v_total, t_dep_ms, t_meas_ms; uint8_t lp_rtia; } cl;
struct { float stabilize_s; } ph;
struct { float v_mv; float duration_s; } clean;
};
} BleCommand;
int ble_init(void);
int ble_is_connected(void);
void ble_wait_for_connection(void);
/* blocking receive from command queue */
int ble_recv_command(BleCommand *cmd, uint32_t timeout_ms);
/* outbound: EIS */
int ble_send_sweep_start(uint32_t num_points, float freq_start, float freq_stop);
int ble_send_eis_point(uint16_t index, const EISPoint *pt);
int ble_send_sweep_end(void);
int ble_send_config(const EISConfig *cfg);
/* outbound: LSV */
int ble_send_lsv_start(uint32_t num_points, float v_start, float v_stop);
int ble_send_lsv_point(uint16_t index, float v_mv, float i_ua);
int ble_send_lsv_end(void);
/* outbound: Amperometry */
int ble_send_amp_start(float v_hold);
int ble_send_amp_point(uint16_t index, float t_ms, float i_ua);
int ble_send_amp_end(void);
/* outbound: Chlorine */
int ble_send_cl_start(uint32_t num_points);
int ble_send_cl_point(uint16_t index, float t_ms, float i_ua, uint8_t phase);
int ble_send_cl_result(float i_free_ua, float i_total_ua);
int ble_send_cl_end(void);
/* outbound: pH */
int ble_send_ph_result(float v_ocp_mv, float ph, float temp_c);
/* outbound: temperature */
int ble_send_temp(float temp_c);
/* outbound: reference collection */
int ble_send_ref_frame(uint8_t mode, uint8_t rtia_idx);
int ble_send_ref_lp_range(uint8_t mode, uint8_t low_idx, uint8_t high_idx);
int ble_send_refs_done(void);
int ble_send_ref_status(uint8_t has_refs);
#endif

View File

@ -1,6 +1,6 @@
#include "echem.h" #include "echem.h"
#include "ad5940.h" #include "ad5940.h"
#include "ble.h" #include "protocol.h"
#include <math.h> #include <math.h>
#include <string.h> #include <string.h>
#include <stdio.h> #include <stdio.h>
@ -93,6 +93,9 @@ static uint16_t mv_to_vbias_code(float v_cell_mv)
static void echem_init_lp(uint32_t rtia_reg) static void echem_init_lp(uint32_t rtia_reg)
{ {
AD5940_SoftRst();
AD5940_Initialize();
CLKCfg_Type clk; CLKCfg_Type clk;
memset(&clk, 0, sizeof(clk)); memset(&clk, 0, sizeof(clk));
clk.HFOSCEn = bTRUE; clk.HFOSCEn = bTRUE;
@ -207,6 +210,9 @@ static float read_current_ua(float rtia_ohms)
static void echem_init_adc(void) static void echem_init_adc(void)
{ {
AD5940_SoftRst();
AD5940_Initialize();
CLKCfg_Type clk; CLKCfg_Type clk;
memset(&clk, 0, sizeof(clk)); memset(&clk, 0, sizeof(clk));
clk.HFOSCEn = bTRUE; clk.HFOSCEn = bTRUE;
@ -449,8 +455,8 @@ int echem_amp(const AmpConfig *cfg, AmpPoint *out, uint32_t max_points, amp_poin
uint32_t count = 0; uint32_t count = 0;
for (uint32_t i = 0; i < max_samples; i++) { for (uint32_t i = 0; i < max_samples; i++) {
BleCommand cmd; Command cmd;
if (ble_recv_command(&cmd, 0) == 0 && cmd.type == CMD_STOP_AMP) if (protocol_recv_command(&cmd, 0) == 0 && cmd.type == CMD_STOP_AMP)
break; break;
float i_ua = read_current_ua(rtia); float i_ua = read_current_ua(rtia);

View File

@ -23,6 +23,9 @@ static struct {
uint32_t dertia_reg; uint32_t dertia_reg;
} ctx; } ctx;
/* cell constant K (cm⁻¹), cached from NVS */
static float cell_k_cached;
/* open-circuit calibration data */ /* open-circuit calibration data */
static struct { static struct {
fImpCar_Type y[EIS_MAX_POINTS]; /* admittance at each freq */ fImpCar_Type y[EIS_MAX_POINTS]; /* admittance at each freq */
@ -156,6 +159,10 @@ void eis_init(const EISConfig *cfg)
ctx.sys_clk = 16000000.0f; ctx.sys_clk = 16000000.0f;
resolve_config(); resolve_config();
/* reset to clear stale AFE state from prior measurement mode */
AD5940_SoftRst();
AD5940_Initialize();
CLKCfg_Type clk; CLKCfg_Type clk;
memset(&clk, 0, sizeof(clk)); memset(&clk, 0, sizeof(clk));
clk.HFOSCEn = bTRUE; clk.HFOSCEn = bTRUE;
@ -585,3 +592,30 @@ int eis_has_open_cal(void)
{ {
return ocal.valid; return ocal.valid;
} }
#define NVS_CELLK_KEY "cell_k"
void eis_set_cell_k(float k)
{
cell_k_cached = k;
nvs_handle_t h;
if (nvs_open(NVS_OCAL_NS, NVS_READWRITE, &h) != ESP_OK) return;
nvs_set_blob(h, NVS_CELLK_KEY, &k, sizeof(k));
nvs_commit(h);
nvs_close(h);
}
float eis_get_cell_k(void)
{
return cell_k_cached;
}
void eis_load_cell_k(void)
{
nvs_handle_t h;
if (nvs_open(NVS_OCAL_NS, NVS_READONLY, &h) != ESP_OK) return;
size_t len = sizeof(cell_k_cached);
if (nvs_get_blob(h, NVS_CELLK_KEY, &cell_k_cached, &len) != ESP_OK || len != sizeof(cell_k_cached))
cell_k_cached = 0.0f;
nvs_close(h);
}

View File

@ -63,4 +63,8 @@ void eis_clear_open_cal(void);
int eis_has_open_cal(void); int eis_has_open_cal(void);
void eis_load_open_cal(void); void eis_load_open_cal(void);
void eis_set_cell_k(float k);
float eis_get_cell_k(void);
void eis_load_cell_k(void);
#endif #endif

View File

@ -3,13 +3,15 @@
#include "ad5941_port.h" #include "ad5941_port.h"
#include "eis.h" #include "eis.h"
#include "echem.h" #include "echem.h"
#include "ble.h" #include "protocol.h"
#include "wifi_transport.h"
#include "temp.h" #include "temp.h"
#include "refs.h" #include "refs.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "nvs_flash.h" #include "nvs_flash.h"
#include "esp_log.h" #include "esp_netif.h"
#include "esp_event.h"
#define AD5941_EXPECTED_ADIID 0x4144 #define AD5941_EXPECTED_ADIID 0x4144
static EISConfig cfg; static EISConfig cfg;
@ -24,10 +26,10 @@ static void do_sweep(void)
eis_init(&cfg); eis_init(&cfg);
uint32_t n = eis_calc_num_points(&cfg); uint32_t n = eis_calc_num_points(&cfg);
ble_send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz); send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz);
int got = eis_sweep(results, n, ble_send_eis_point); int got = eis_sweep(results, n, send_eis_point);
printf("Sweep complete: %d points\n", got); printf("Sweep complete: %d points\n", got);
ble_send_sweep_end(); send_sweep_end();
} }
void app_main(void) void app_main(void)
@ -53,17 +55,19 @@ void app_main(void)
eis_default_config(&cfg); eis_default_config(&cfg);
eis_load_open_cal(); eis_load_open_cal();
eis_load_cell_k();
temp_init(); temp_init();
esp_log_level_set("NimBLE", ESP_LOG_WARN); esp_netif_init();
ble_init(); esp_event_loop_create_default();
printf("Waiting for BLE connection...\n");
ble_wait_for_connection();
ble_send_config(&cfg);
BleCommand cmd; protocol_init();
wifi_transport_init();
printf("EIS4: WiFi transport ready, waiting for clients\n");
Command cmd;
for (;;) { for (;;) {
if (ble_recv_command(&cmd, UINT32_MAX) != 0) if (protocol_recv_command(&cmd, UINT32_MAX) != 0)
continue; continue;
switch (cmd.type) { switch (cmd.type) {
@ -110,7 +114,7 @@ void app_main(void)
break; break;
case CMD_GET_CONFIG: case CMD_GET_CONFIG:
ble_send_config(&cfg); send_config(&cfg);
break; break;
case CMD_START_LSV: { case CMD_START_LSV: {
@ -123,10 +127,10 @@ void app_main(void)
lsv_cfg.v_start, lsv_cfg.v_stop, lsv_cfg.scan_rate, lsv_cfg.lp_rtia); lsv_cfg.v_start, lsv_cfg.v_stop, lsv_cfg.scan_rate, lsv_cfg.lp_rtia);
uint32_t n = echem_lsv_calc_steps(&lsv_cfg, ECHEM_MAX_POINTS); uint32_t n = echem_lsv_calc_steps(&lsv_cfg, ECHEM_MAX_POINTS);
ble_send_lsv_start(n, lsv_cfg.v_start, lsv_cfg.v_stop); send_lsv_start(n, lsv_cfg.v_start, lsv_cfg.v_stop);
int got = echem_lsv(&lsv_cfg, lsv_results, ECHEM_MAX_POINTS, ble_send_lsv_point); int got = echem_lsv(&lsv_cfg, lsv_results, ECHEM_MAX_POINTS, send_lsv_point);
printf("LSV complete: %d points\n", got); printf("LSV complete: %d points\n", got);
ble_send_lsv_end(); send_lsv_end();
break; break;
} }
@ -139,15 +143,15 @@ void app_main(void)
printf("Amp: %.0f mV, %.0f ms interval, %.0f s\n", printf("Amp: %.0f mV, %.0f ms interval, %.0f s\n",
amp_cfg.v_hold, amp_cfg.interval_ms, amp_cfg.duration_s); amp_cfg.v_hold, amp_cfg.interval_ms, amp_cfg.duration_s);
ble_send_amp_start(amp_cfg.v_hold); send_amp_start(amp_cfg.v_hold);
int got = echem_amp(&amp_cfg, amp_results, ECHEM_MAX_POINTS, ble_send_amp_point); int got = echem_amp(&amp_cfg, amp_results, ECHEM_MAX_POINTS, send_amp_point);
printf("Amp complete: %d points\n", got); printf("Amp complete: %d points\n", got);
ble_send_amp_end(); send_amp_end();
break; break;
} }
case CMD_GET_TEMP: case CMD_GET_TEMP:
ble_send_temp(temp_get()); send_temp(temp_get());
break; break;
case CMD_START_PH: { case CMD_START_PH: {
@ -161,7 +165,7 @@ void app_main(void)
echem_ph_ocp(&ph_cfg, &ph_result); echem_ph_ocp(&ph_cfg, &ph_result);
printf("pH: OCP=%.1f mV, pH=%.2f\n", printf("pH: OCP=%.1f mV, pH=%.2f\n",
ph_result.v_ocp_mv, ph_result.ph); ph_result.v_ocp_mv, ph_result.ph);
ble_send_ph_result(ph_result.v_ocp_mv, ph_result.ph, ph_result.temp_c); send_ph_result(ph_result.v_ocp_mv, ph_result.ph, ph_result.temp_c);
break; break;
} }
@ -188,10 +192,10 @@ void app_main(void)
printf("Open-circuit cal starting\n"); printf("Open-circuit cal starting\n");
eis_init(&cfg); eis_init(&cfg);
uint32_t n = eis_calc_num_points(&cfg); uint32_t n = eis_calc_num_points(&cfg);
ble_send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz); send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz);
int got = eis_open_cal(results, n, ble_send_eis_point); int got = eis_open_cal(results, n, send_eis_point);
printf("Open-circuit cal: %d points\n", got); printf("Open-circuit cal: %d points\n", got);
ble_send_sweep_end(); send_sweep_end();
break; break;
} }
@ -200,6 +204,16 @@ void app_main(void)
printf("Open-circuit cal cleared\n"); printf("Open-circuit cal cleared\n");
break; break;
case CMD_SET_CELL_K:
eis_set_cell_k(cmd.cell_k);
send_cell_k(cmd.cell_k);
printf("Cell K set: %.4f cm^-1\n", cmd.cell_k);
break;
case CMD_GET_CELL_K:
send_cell_k(eis_get_cell_k());
break;
case CMD_START_CL: { case CMD_START_CL: {
ClConfig cl_cfg; ClConfig cl_cfg;
cl_cfg.v_cond = cmd.cl.v_cond; cl_cfg.v_cond = cmd.cl.v_cond;
@ -212,16 +226,56 @@ void app_main(void)
uint32_t n_per = (uint32_t)(cl_cfg.t_meas_ms / 50.0f + 0.5f); uint32_t n_per = (uint32_t)(cl_cfg.t_meas_ms / 50.0f + 0.5f);
if (n_per < 2) n_per = 2; if (n_per < 2) n_per = 2;
ble_send_cl_start(2 * n_per); send_cl_start(2 * n_per);
ClResult cl_result; ClResult cl_result;
int got = echem_chlorine(&cl_cfg, cl_results, ECHEM_MAX_POINTS, int got = echem_chlorine(&cl_cfg, cl_results, ECHEM_MAX_POINTS,
&cl_result, ble_send_cl_point); &cl_result, send_cl_point);
printf("Cl complete: %d points, free=%.3f uA, total=%.3f uA\n", printf("Cl complete: %d points, free=%.3f uA, total=%.3f uA\n",
got, cl_result.i_free_ua, cl_result.i_total_ua); got, cl_result.i_free_ua, cl_result.i_total_ua);
ble_send_cl_result(cl_result.i_free_ua, cl_result.i_total_ua); send_cl_result(cl_result.i_free_ua, cl_result.i_total_ua);
ble_send_cl_end(); send_cl_end();
break;
}
case CMD_SESSION_CREATE: {
uint8_t id = session_create(cmd.session_create.name,
cmd.session_create.name_len);
if (id != 0xFF) {
send_session_created(id, cmd.session_create.name,
cmd.session_create.name_len);
printf("Session created: %u \"%.*s\"\n",
id, cmd.session_create.name_len, cmd.session_create.name);
}
break;
}
case CMD_SESSION_SWITCH:
if (session_switch(cmd.session_switch.id) == 0) {
send_session_switched(cmd.session_switch.id);
printf("Session switched: %u\n", cmd.session_switch.id);
}
break;
case CMD_SESSION_LIST:
send_session_list();
break;
case CMD_SESSION_RENAME:
if (session_rename(cmd.session_rename.id, cmd.session_rename.name,
cmd.session_rename.name_len) == 0) {
send_session_renamed(cmd.session_rename.id, cmd.session_rename.name,
cmd.session_rename.name_len);
printf("Session renamed: %u \"%.*s\"\n",
cmd.session_rename.id, cmd.session_rename.name_len,
cmd.session_rename.name);
}
break;
case CMD_HEARTBEAT:
send_client_list((uint8_t)wifi_get_client_count());
send_session_list();
send_config(&cfg);
break; break;
} }
} }
} }
}

426
main/protocol.c Normal file
View File

@ -0,0 +1,426 @@
#include "protocol.h"
#include "wifi_transport.h"
#include <string.h>
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#define CMD_QUEUE_LEN 8
static QueueHandle_t cmd_queue;
/* session state */
static Session sessions[MAX_SESSIONS];
static uint8_t session_count;
static uint8_t current_session_id;
/* ---- 7-bit MIDI encoding ---- */
static void encode_float(float val, uint8_t *out)
{
uint8_t *p = (uint8_t *)&val;
out[0] = ((p[0] >> 7) & 1) | ((p[1] >> 6) & 2) |
((p[2] >> 5) & 4) | ((p[3] >> 4) & 8);
out[1] = p[0] & 0x7F;
out[2] = p[1] & 0x7F;
out[3] = p[2] & 0x7F;
out[4] = p[3] & 0x7F;
}
static void encode_u16(uint16_t val, uint8_t *out)
{
uint8_t *p = (uint8_t *)&val;
out[0] = ((p[0] >> 7) & 1) | ((p[1] >> 6) & 2);
out[1] = p[0] & 0x7F;
out[2] = p[1] & 0x7F;
}
float decode_float(const uint8_t *d)
{
uint8_t b[4];
b[0] = d[1] | ((d[0] & 1) << 7);
b[1] = d[2] | ((d[0] & 2) << 6);
b[2] = d[3] | ((d[0] & 4) << 5);
b[3] = d[4] | ((d[0] & 8) << 4);
float v;
memcpy(&v, b, 4);
return v;
}
uint16_t decode_u16(const uint8_t *d)
{
uint8_t b[2];
b[0] = d[1] | ((d[0] & 1) << 7);
b[1] = d[2] | ((d[0] & 2) << 6);
uint16_t v;
memcpy(&v, b, 2);
return v;
}
/* ---- command queue ---- */
int protocol_init(void)
{
cmd_queue = xQueueCreate(CMD_QUEUE_LEN, sizeof(Command));
if (!cmd_queue) return -1;
session_count = 0;
current_session_id = 0;
memset(sessions, 0, sizeof(sessions));
return 0;
}
int protocol_recv_command(Command *cmd, uint32_t timeout_ms)
{
TickType_t ticks = (timeout_ms == UINT32_MAX) ? portMAX_DELAY : pdMS_TO_TICKS(timeout_ms);
return xQueueReceive(cmd_queue, cmd, ticks) == pdTRUE ? 0 : -1;
}
void protocol_push_command(const Command *cmd)
{
xQueueSend(cmd_queue, cmd, 0);
}
/* ---- session management ---- */
const Session *session_get_all(uint8_t *count)
{
*count = session_count;
return sessions;
}
uint8_t session_get_current(void)
{
return current_session_id;
}
uint8_t session_create(const char *name, uint8_t name_len)
{
if (session_count >= MAX_SESSIONS)
return 0xFF;
uint8_t id = session_count + 1;
Session *s = &sessions[session_count];
s->id = id;
if (name_len > MAX_SESSION_NAME)
name_len = MAX_SESSION_NAME;
memcpy(s->name, name, name_len);
s->name[name_len] = '\0';
s->active = 1;
session_count++;
current_session_id = id;
return id;
}
int session_switch(uint8_t id)
{
for (uint8_t i = 0; i < session_count; i++) {
if (sessions[i].id == id) {
current_session_id = id;
return 0;
}
}
return -1;
}
int session_rename(uint8_t id, const char *name, uint8_t name_len)
{
for (uint8_t i = 0; i < session_count; i++) {
if (sessions[i].id == id) {
if (name_len > MAX_SESSION_NAME)
name_len = MAX_SESSION_NAME;
memcpy(sessions[i].name, name, name_len);
sessions[i].name[name_len] = '\0';
return 0;
}
}
return -1;
}
/* ---- SysEx send ---- */
static int send_sysex(const uint8_t *sysex, uint16_t len)
{
return wifi_send_sysex(sysex, len);
}
/* ---- outbound: EIS ---- */
int send_sweep_start(uint32_t num_points, float freq_start, float freq_stop)
{
uint8_t sx[20];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_SWEEP_START;
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
encode_float(freq_start, &sx[p]); p += 5;
encode_float(freq_stop, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int send_eis_point(uint16_t index, const EISPoint *pt)
{
uint8_t sx[64];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_DATA_POINT;
encode_u16(index, &sx[p]); p += 3;
encode_float(pt->freq_hz, &sx[p]); p += 5;
encode_float(pt->mag_ohms, &sx[p]); p += 5;
encode_float(pt->phase_deg, &sx[p]); p += 5;
encode_float(pt->z_real, &sx[p]); p += 5;
encode_float(pt->z_imag, &sx[p]); p += 5;
encode_float(pt->rtia_mag_before, &sx[p]); p += 5;
encode_float(pt->rtia_mag_after, &sx[p]); p += 5;
encode_float(pt->rev_mag, &sx[p]); p += 5;
encode_float(pt->rev_phase, &sx[p]); p += 5;
encode_float(pt->pct_err, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int send_sweep_end(void)
{
uint8_t sx[] = { 0xF0, 0x7D, RSP_SWEEP_END, 0xF7 };
return send_sysex(sx, sizeof(sx));
}
int send_config(const EISConfig *cfg)
{
uint8_t sx[32];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_CONFIG;
encode_float(cfg->freq_start_hz, &sx[p]); p += 5;
encode_float(cfg->freq_stop_hz, &sx[p]); p += 5;
encode_u16(cfg->points_per_decade, &sx[p]); p += 3;
sx[p++] = (uint8_t)cfg->rtia;
sx[p++] = (uint8_t)cfg->rcal;
sx[p++] = (uint8_t)cfg->electrode;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
/* ---- outbound: LSV ---- */
int send_lsv_start(uint32_t num_points, float v_start, float v_stop)
{
uint8_t sx[20];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_LSV_START;
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
encode_float(v_start, &sx[p]); p += 5;
encode_float(v_stop, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int send_lsv_point(uint16_t index, float v_mv, float i_ua)
{
uint8_t sx[20];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_LSV_POINT;
encode_u16(index, &sx[p]); p += 3;
encode_float(v_mv, &sx[p]); p += 5;
encode_float(i_ua, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int send_lsv_end(void)
{
uint8_t sx[] = { 0xF0, 0x7D, RSP_LSV_END, 0xF7 };
return send_sysex(sx, sizeof(sx));
}
/* ---- outbound: Amperometry ---- */
int send_amp_start(float v_hold)
{
uint8_t sx[12];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_AMP_START;
encode_float(v_hold, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int send_amp_point(uint16_t index, float t_ms, float i_ua)
{
uint8_t sx[20];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_AMP_POINT;
encode_u16(index, &sx[p]); p += 3;
encode_float(t_ms, &sx[p]); p += 5;
encode_float(i_ua, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int send_amp_end(void)
{
uint8_t sx[] = { 0xF0, 0x7D, RSP_AMP_END, 0xF7 };
return send_sysex(sx, sizeof(sx));
}
/* ---- outbound: Chlorine ---- */
int send_cl_start(uint32_t num_points)
{
uint8_t sx[10];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_CL_START;
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int send_cl_point(uint16_t index, float t_ms, float i_ua, uint8_t phase)
{
uint8_t sx[20];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_CL_POINT;
encode_u16(index, &sx[p]); p += 3;
encode_float(t_ms, &sx[p]); p += 5;
encode_float(i_ua, &sx[p]); p += 5;
sx[p++] = phase & 0x7F;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int send_cl_result(float i_free_ua, float i_total_ua)
{
uint8_t sx[16];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_CL_RESULT;
encode_float(i_free_ua, &sx[p]); p += 5;
encode_float(i_total_ua, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int send_cl_end(void)
{
uint8_t sx[] = { 0xF0, 0x7D, RSP_CL_END, 0xF7 };
return send_sysex(sx, sizeof(sx));
}
/* ---- outbound: pH ---- */
int send_ph_result(float v_ocp_mv, float ph, float temp_c)
{
uint8_t sx[20];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_PH_RESULT;
encode_float(v_ocp_mv, &sx[p]); p += 5;
encode_float(ph, &sx[p]); p += 5;
encode_float(temp_c, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
/* ---- outbound: temperature ---- */
int send_temp(float temp_c)
{
uint8_t sx[12];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_TEMP;
encode_float(temp_c, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
/* ---- outbound: cell constant ---- */
int send_cell_k(float k)
{
uint8_t sx[12];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_CELL_K;
encode_float(k, &sx[p]); p += 5;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
/* ---- outbound: reference collection ---- */
int send_ref_frame(uint8_t mode, uint8_t rtia_idx)
{
uint8_t sx[] = { 0xF0, 0x7D, RSP_REF_FRAME, mode & 0x7F, rtia_idx & 0x7F, 0xF7 };
return send_sysex(sx, sizeof(sx));
}
int send_ref_lp_range(uint8_t mode, uint8_t low_idx, uint8_t high_idx)
{
uint8_t sx[] = { 0xF0, 0x7D, RSP_REF_LP_RANGE, mode & 0x7F, low_idx & 0x7F, high_idx & 0x7F, 0xF7 };
return send_sysex(sx, sizeof(sx));
}
int send_refs_done(void)
{
uint8_t sx[] = { 0xF0, 0x7D, RSP_REFS_DONE, 0xF7 };
return send_sysex(sx, sizeof(sx));
}
int send_ref_status(uint8_t has_refs)
{
uint8_t sx[] = { 0xF0, 0x7D, RSP_REF_STATUS, has_refs & 0x7F, 0xF7 };
return send_sysex(sx, sizeof(sx));
}
/* ---- outbound: session sync ---- */
int send_session_created(uint8_t id, const char *name, uint8_t name_len)
{
uint8_t sx[48];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_SESSION_CREATED;
sx[p++] = id & 0x7F;
sx[p++] = name_len & 0x7F;
for (uint8_t i = 0; i < name_len && p < sizeof(sx) - 1; i++)
sx[p++] = name[i] & 0x7F;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int send_session_switched(uint8_t id)
{
uint8_t sx[] = { 0xF0, 0x7D, RSP_SESSION_SWITCHED, id & 0x7F, 0xF7 };
return send_sysex(sx, sizeof(sx));
}
int send_session_list(void)
{
uint8_t sx[128];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_SESSION_LIST;
sx[p++] = session_count & 0x7F;
sx[p++] = current_session_id & 0x7F;
for (uint8_t i = 0; i < session_count && p < sizeof(sx) - 4; i++) {
sx[p++] = sessions[i].id & 0x7F;
uint8_t nlen = (uint8_t)strlen(sessions[i].name);
sx[p++] = nlen & 0x7F;
for (uint8_t j = 0; j < nlen && p < sizeof(sx) - 1; j++)
sx[p++] = sessions[i].name[j] & 0x7F;
}
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int send_session_renamed(uint8_t id, const char *name, uint8_t name_len)
{
uint8_t sx[48];
uint16_t p = 0;
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_SESSION_RENAMED;
sx[p++] = id & 0x7F;
sx[p++] = name_len & 0x7F;
for (uint8_t i = 0; i < name_len && p < sizeof(sx) - 1; i++)
sx[p++] = name[i] & 0x7F;
sx[p++] = 0xF7;
return send_sysex(sx, p);
}
int send_client_list(uint8_t count)
{
uint8_t sx[] = { 0xF0, 0x7D, RSP_CLIENT_LIST, count & 0x7F, 0xF7 };
return send_sysex(sx, sizeof(sx));
}

155
main/protocol.h Normal file
View File

@ -0,0 +1,155 @@
#ifndef PROTOCOL_H
#define PROTOCOL_H
#include <stdint.h>
#include "eis.h"
/* Commands: Client -> Firmware (0x1x, 0x2x, 0x3x) */
#define CMD_SET_SWEEP 0x10
#define CMD_SET_RTIA 0x11
#define CMD_SET_RCAL 0x12
#define CMD_START_SWEEP 0x13
#define CMD_GET_CONFIG 0x14
#define CMD_SET_ELECTRODE 0x15
#define CMD_START_LSV 0x20
#define CMD_START_AMP 0x21
#define CMD_STOP_AMP 0x22
#define CMD_GET_TEMP 0x17
#define CMD_START_CL 0x23
#define CMD_START_PH 0x24
#define CMD_START_CLEAN 0x25
#define CMD_OPEN_CAL 0x26
#define CMD_CLEAR_OPEN_CAL 0x27
#define CMD_SET_CELL_K 0x28
#define CMD_GET_CELL_K 0x29
#define CMD_START_REFS 0x30
#define CMD_GET_REFS 0x31
#define CMD_CLEAR_REFS 0x32
/* Session sync commands (0x4x) */
#define CMD_SESSION_CREATE 0x40
#define CMD_SESSION_SWITCH 0x41
#define CMD_SESSION_LIST 0x42
#define CMD_SESSION_RENAME 0x43
#define CMD_HEARTBEAT 0x44
/* Responses: Firmware -> Client (0x0x, 0x2x) */
#define RSP_SWEEP_START 0x01
#define RSP_DATA_POINT 0x02
#define RSP_SWEEP_END 0x03
#define RSP_CONFIG 0x04
#define RSP_LSV_START 0x05
#define RSP_LSV_POINT 0x06
#define RSP_LSV_END 0x07
#define RSP_AMP_START 0x08
#define RSP_AMP_POINT 0x09
#define RSP_AMP_END 0x0A
#define RSP_CL_START 0x0B
#define RSP_CL_POINT 0x0C
#define RSP_CL_RESULT 0x0D
#define RSP_CL_END 0x0E
#define RSP_PH_RESULT 0x0F
#define RSP_TEMP 0x10
#define RSP_CELL_K 0x11
#define RSP_REF_FRAME 0x20
#define RSP_REF_LP_RANGE 0x21
#define RSP_REFS_DONE 0x22
#define RSP_REF_STATUS 0x23
/* Session sync responses (0x4x) */
#define RSP_SESSION_CREATED 0x40
#define RSP_SESSION_SWITCHED 0x41
#define RSP_SESSION_LIST 0x42
#define RSP_SESSION_RENAMED 0x43
#define RSP_CLIENT_LIST 0x44
/* Session limits */
#define MAX_SESSIONS 8
#define MAX_SESSION_NAME 32
typedef struct {
uint8_t type;
union {
struct { float freq_start, freq_stop; uint16_t ppd; } sweep;
uint8_t rtia;
uint8_t rcal;
uint8_t electrode;
struct { float v_start, v_stop, scan_rate; uint8_t lp_rtia; } lsv;
struct { float v_hold, interval_ms, duration_s; uint8_t lp_rtia; } amp;
struct { float v_cond, t_cond_ms, v_free, v_total, t_dep_ms, t_meas_ms; uint8_t lp_rtia; } cl;
struct { float stabilize_s; } ph;
struct { float v_mv; float duration_s; } clean;
float cell_k;
struct { uint8_t name_len; char name[MAX_SESSION_NAME]; } session_create;
struct { uint8_t id; } session_switch;
struct { uint8_t id; uint8_t name_len; char name[MAX_SESSION_NAME]; } session_rename;
};
} Command;
typedef struct {
uint8_t id;
char name[MAX_SESSION_NAME + 1];
uint8_t active;
} Session;
int protocol_init(void);
int protocol_recv_command(Command *cmd, uint32_t timeout_ms);
void protocol_push_command(const Command *cmd);
/* 7-bit decode helpers */
float decode_float(const uint8_t *d);
uint16_t decode_u16(const uint8_t *d);
/* outbound: EIS */
int send_sweep_start(uint32_t num_points, float freq_start, float freq_stop);
int send_eis_point(uint16_t index, const EISPoint *pt);
int send_sweep_end(void);
int send_config(const EISConfig *cfg);
/* outbound: LSV */
int send_lsv_start(uint32_t num_points, float v_start, float v_stop);
int send_lsv_point(uint16_t index, float v_mv, float i_ua);
int send_lsv_end(void);
/* outbound: Amperometry */
int send_amp_start(float v_hold);
int send_amp_point(uint16_t index, float t_ms, float i_ua);
int send_amp_end(void);
/* outbound: Chlorine */
int send_cl_start(uint32_t num_points);
int send_cl_point(uint16_t index, float t_ms, float i_ua, uint8_t phase);
int send_cl_result(float i_free_ua, float i_total_ua);
int send_cl_end(void);
/* outbound: pH */
int send_ph_result(float v_ocp_mv, float ph, float temp_c);
/* outbound: temperature */
int send_temp(float temp_c);
/* outbound: cell constant */
int send_cell_k(float k);
/* outbound: reference collection */
int send_ref_frame(uint8_t mode, uint8_t rtia_idx);
int send_ref_lp_range(uint8_t mode, uint8_t low_idx, uint8_t high_idx);
int send_refs_done(void);
int send_ref_status(uint8_t has_refs);
/* session management */
const Session *session_get_all(uint8_t *count);
uint8_t session_get_current(void);
uint8_t session_create(const char *name, uint8_t name_len);
int session_switch(uint8_t id);
int session_rename(uint8_t id, const char *name, uint8_t name_len);
/* session sync responses */
int send_session_created(uint8_t id, const char *name, uint8_t name_len);
int send_session_switched(uint8_t id);
int send_session_list(void);
int send_session_renamed(uint8_t id, const char *name, uint8_t name_len);
int send_client_list(uint8_t count);
#endif

View File

@ -1,5 +1,5 @@
#include "refs.h" #include "refs.h"
#include "ble.h" #include "protocol.h"
#include "temp.h" #include "temp.h"
#include "ad5940.h" #include "ad5940.h"
#include <string.h> #include <string.h>
@ -8,12 +8,9 @@
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
/* LP RTIA register mapping (mirrors echem.c) */
extern const uint32_t lp_rtia_map[]; extern const uint32_t lp_rtia_map[];
extern const float lp_rtia_ohms[]; extern const float lp_rtia_ohms[];
/* ---- ADC helpers ---- */
static int32_t read_adc_code(void) static int32_t read_adc_code(void)
{ {
AD5940_INTCClrFlag(AFEINTSRC_SINC2RDY); AD5940_INTCClrFlag(AFEINTSRC_SINC2RDY);
@ -32,8 +29,6 @@ static int32_t read_adc_code(void)
return (raw & (1UL << 15)) ? (int32_t)(raw | 0xFFFF0000UL) : (int32_t)raw; return (raw & (1UL << 15)) ? (int32_t)(raw | 0xFFFF0000UL) : (int32_t)raw;
} }
/* ---- Clipping detection ---- */
#define ADC_OK 0 #define ADC_OK 0
#define ADC_CLIPPED 1 #define ADC_CLIPPED 1
#define ADC_OSCILLATING 2 #define ADC_OSCILLATING 2
@ -76,8 +71,8 @@ static void init_lp_for_probe(uint32_t rtia_reg)
lp.LpDacCfg.LpDacRef = LPDACREF_2P5; lp.LpDacCfg.LpDacRef = LPDACREF_2P5;
lp.LpDacCfg.DataRst = bFALSE; lp.LpDacCfg.DataRst = bFALSE;
lp.LpDacCfg.PowerEn = bTRUE; lp.LpDacCfg.PowerEn = bTRUE;
lp.LpDacCfg.DacData6Bit = 26; /* VZERO ~1094mV */ lp.LpDacCfg.DacData6Bit = 26;
lp.LpDacCfg.DacData12Bit = (uint16_t)((1093.75f - 200.0f) / 0.537f + 0.5f); /* 0mV cell */ lp.LpDacCfg.DacData12Bit = (uint16_t)((1093.75f - 200.0f) / 0.537f + 0.5f);
lp.LpAmpCfg.LpAmpSel = LPAMP0; lp.LpAmpCfg.LpAmpSel = LPAMP0;
lp.LpAmpCfg.LpAmpPwrMod = LPAMPPWR_BOOST3; lp.LpAmpCfg.LpAmpPwrMod = LPAMPPWR_BOOST3;
@ -195,7 +190,6 @@ static void lp_find_valid_range(LpRtiaRange *range)
uint8_t lo = 0, hi = LP_RTIA_512K; uint8_t lo = 0, hi = LP_RTIA_512K;
if (low_clip != ADC_OK) { if (low_clip != ADC_OK) {
/* binary search upward for first non-clipping */
uint8_t a = 0, b = LP_RTIA_512K; uint8_t a = 0, b = LP_RTIA_512K;
while (a < b) { while (a < b) {
uint8_t m = (a + b) / 2; uint8_t m = (a + b) / 2;
@ -208,7 +202,6 @@ static void lp_find_valid_range(LpRtiaRange *range)
} }
if (high_clip != ADC_OK) { if (high_clip != ADC_OK) {
/* binary search downward for last non-clipping */
uint8_t a = lo, b = LP_RTIA_512K; uint8_t a = lo, b = LP_RTIA_512K;
while (a < b) { while (a < b) {
uint8_t m = (a + b + 1) / 2; uint8_t m = (a + b + 1) / 2;
@ -225,13 +218,10 @@ static void lp_find_valid_range(LpRtiaRange *range)
range->valid = (lo <= hi) ? 1 : 0; range->valid = (lo <= hi) ? 1 : 0;
} }
/* ---- Collection ---- */
void refs_collect(RefStore *store, const EISConfig *cfg) void refs_collect(RefStore *store, const EISConfig *cfg)
{ {
memset(store, 0, sizeof(*store)); memset(store, 0, sizeof(*store));
/* EIS phase: sweep each RTIA 0-7 with RCAL=3K */
EISConfig ref_cfg = *cfg; EISConfig ref_cfg = *cfg;
ref_cfg.rcal = RCAL_3K; ref_cfg.rcal = RCAL_3K;
@ -239,95 +229,91 @@ void refs_collect(RefStore *store, const EISConfig *cfg)
ref_cfg.rtia = (EISRtia)r; ref_cfg.rtia = (EISRtia)r;
eis_init(&ref_cfg); eis_init(&ref_cfg);
ble_send_ref_frame(REF_MODE_EIS, (uint8_t)r); send_ref_frame(REF_MODE_EIS, (uint8_t)r);
uint32_t n = eis_calc_num_points(&ref_cfg); uint32_t n = eis_calc_num_points(&ref_cfg);
ble_send_sweep_start(n, ref_cfg.freq_start_hz, ref_cfg.freq_stop_hz); send_sweep_start(n, ref_cfg.freq_start_hz, ref_cfg.freq_stop_hz);
int got = eis_sweep(store->eis[r].pts, n, ble_send_eis_point); int got = eis_sweep(store->eis[r].pts, n, send_eis_point);
store->eis[r].n_points = (uint32_t)got; store->eis[r].n_points = (uint32_t)got;
store->eis[r].valid = (got > 0) ? 1 : 0; store->eis[r].valid = (got > 0) ? 1 : 0;
ble_send_sweep_end(); send_sweep_end();
printf("Ref EIS RTIA %d: %d pts\n", r, got); printf("Ref EIS RTIA %d: %d pts\n", r, got);
} }
/* LP phase: binary search valid RTIA range for each LP mode */
printf("Ref: LP range search (LSV)\n"); printf("Ref: LP range search (LSV)\n");
ble_send_ref_frame(REF_MODE_LSV, 0); send_ref_frame(REF_MODE_LSV, 0);
lp_find_valid_range(&store->lsv_range); lp_find_valid_range(&store->lsv_range);
if (store->lsv_range.valid) if (store->lsv_range.valid)
ble_send_ref_lp_range(REF_MODE_LSV, store->lsv_range.low_idx, store->lsv_range.high_idx); send_ref_lp_range(REF_MODE_LSV, store->lsv_range.low_idx, store->lsv_range.high_idx);
printf("Ref LSV range: %u-%u\n", store->lsv_range.low_idx, store->lsv_range.high_idx); printf("Ref LSV range: %u-%u\n", store->lsv_range.low_idx, store->lsv_range.high_idx);
printf("Ref: LP range search (Amp)\n"); printf("Ref: LP range search (Amp)\n");
ble_send_ref_frame(REF_MODE_AMP, 0); send_ref_frame(REF_MODE_AMP, 0);
lp_find_valid_range(&store->amp_range); lp_find_valid_range(&store->amp_range);
if (store->amp_range.valid) if (store->amp_range.valid)
ble_send_ref_lp_range(REF_MODE_AMP, store->amp_range.low_idx, store->amp_range.high_idx); send_ref_lp_range(REF_MODE_AMP, store->amp_range.low_idx, store->amp_range.high_idx);
printf("Ref Amp range: %u-%u\n", store->amp_range.low_idx, store->amp_range.high_idx); printf("Ref Amp range: %u-%u\n", store->amp_range.low_idx, store->amp_range.high_idx);
printf("Ref: LP range search (Cl)\n"); printf("Ref: LP range search (Cl)\n");
ble_send_ref_frame(REF_MODE_CL, 0); send_ref_frame(REF_MODE_CL, 0);
lp_find_valid_range(&store->cl_range); lp_find_valid_range(&store->cl_range);
if (store->cl_range.valid) if (store->cl_range.valid)
ble_send_ref_lp_range(REF_MODE_CL, store->cl_range.low_idx, store->cl_range.high_idx); send_ref_lp_range(REF_MODE_CL, store->cl_range.low_idx, store->cl_range.high_idx);
printf("Ref Cl range: %u-%u\n", store->cl_range.low_idx, store->cl_range.high_idx); printf("Ref Cl range: %u-%u\n", store->cl_range.low_idx, store->cl_range.high_idx);
/* pH phase: OCP measurement */
printf("Ref: pH OCP\n"); printf("Ref: pH OCP\n");
ble_send_ref_frame(REF_MODE_PH, 0); send_ref_frame(REF_MODE_PH, 0);
PhConfig ph_cfg; PhConfig ph_cfg;
ph_cfg.stabilize_s = 10.0f; ph_cfg.stabilize_s = 10.0f;
ph_cfg.temp_c = temp_get(); ph_cfg.temp_c = temp_get();
echem_ph_ocp(&ph_cfg, &store->ph_ref); echem_ph_ocp(&ph_cfg, &store->ph_ref);
store->ph_valid = 1; store->ph_valid = 1;
ble_send_ph_result(store->ph_ref.v_ocp_mv, store->ph_ref.ph, store->ph_ref.temp_c); send_ph_result(store->ph_ref.v_ocp_mv, store->ph_ref.ph, store->ph_ref.temp_c);
store->has_refs = 1; store->has_refs = 1;
ble_send_refs_done(); send_refs_done();
printf("Ref collection complete\n"); printf("Ref collection complete\n");
} }
/* ---- Send stored refs to client ---- */
void refs_send(const RefStore *store) void refs_send(const RefStore *store)
{ {
if (!store->has_refs) { if (!store->has_refs) {
ble_send_ref_status(0); send_ref_status(0);
return; return;
} }
ble_send_ref_status(1); send_ref_status(1);
for (int r = 0; r < REF_EIS_RTIA_COUNT; r++) { for (int r = 0; r < REF_EIS_RTIA_COUNT; r++) {
if (!store->eis[r].valid) continue; if (!store->eis[r].valid) continue;
ble_send_ref_frame(REF_MODE_EIS, (uint8_t)r); send_ref_frame(REF_MODE_EIS, (uint8_t)r);
uint32_t n = store->eis[r].n_points; uint32_t n = store->eis[r].n_points;
ble_send_sweep_start(n, store->eis[r].pts[0].freq_hz, send_sweep_start(n, store->eis[r].pts[0].freq_hz,
store->eis[r].pts[n - 1].freq_hz); store->eis[r].pts[n - 1].freq_hz);
for (uint32_t i = 0; i < n; i++) for (uint32_t i = 0; i < n; i++)
ble_send_eis_point((uint16_t)i, &store->eis[r].pts[i]); send_eis_point((uint16_t)i, &store->eis[r].pts[i]);
ble_send_sweep_end(); send_sweep_end();
} }
if (store->lsv_range.valid) if (store->lsv_range.valid)
ble_send_ref_lp_range(REF_MODE_LSV, store->lsv_range.low_idx, store->lsv_range.high_idx); send_ref_lp_range(REF_MODE_LSV, store->lsv_range.low_idx, store->lsv_range.high_idx);
if (store->amp_range.valid) if (store->amp_range.valid)
ble_send_ref_lp_range(REF_MODE_AMP, store->amp_range.low_idx, store->amp_range.high_idx); send_ref_lp_range(REF_MODE_AMP, store->amp_range.low_idx, store->amp_range.high_idx);
if (store->cl_range.valid) if (store->cl_range.valid)
ble_send_ref_lp_range(REF_MODE_CL, store->cl_range.low_idx, store->cl_range.high_idx); send_ref_lp_range(REF_MODE_CL, store->cl_range.low_idx, store->cl_range.high_idx);
if (store->ph_valid) { if (store->ph_valid) {
ble_send_ref_frame(REF_MODE_PH, 0); send_ref_frame(REF_MODE_PH, 0);
ble_send_ph_result(store->ph_ref.v_ocp_mv, store->ph_ref.ph, store->ph_ref.temp_c); send_ph_result(store->ph_ref.v_ocp_mv, store->ph_ref.ph, store->ph_ref.temp_c);
} }
ble_send_refs_done(); send_refs_done();
} }
void refs_clear(RefStore *store) void refs_clear(RefStore *store)
{ {
memset(store, 0, sizeof(*store)); memset(store, 0, sizeof(*store));
ble_send_ref_status(0); send_ref_status(0);
} }

339
main/wifi_transport.c Normal file
View File

@ -0,0 +1,339 @@
#include "wifi_transport.h"
#include "protocol.h"
#include <string.h>
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "esp_wifi.h"
#include "esp_netif.h"
#include "esp_event.h"
#include "lwip/sockets.h"
#define WIFI_SSID "EIS4"
#define WIFI_PASS "eis4data"
#define WIFI_CHANNEL 1
#define WIFI_MAX_CONN 4
#define UDP_PORT 5941
#define UDP_BUF_SIZE 128
#define MAX_UDP_CLIENTS 4
#define CLIENT_TIMEOUT_MS 30000
static int udp_sock = -1;
static struct {
struct sockaddr_in addr;
TickType_t last_seen;
bool active;
} clients[MAX_UDP_CLIENTS];
static int client_count;
static void client_touch(const struct sockaddr_in *addr)
{
TickType_t now = xTaskGetTickCount();
for (int i = 0; i < client_count; i++) {
if (clients[i].addr.sin_addr.s_addr == addr->sin_addr.s_addr &&
clients[i].addr.sin_port == addr->sin_port) {
clients[i].last_seen = now;
return;
}
}
if (client_count < MAX_UDP_CLIENTS) {
clients[client_count].addr = *addr;
clients[client_count].last_seen = now;
clients[client_count].active = true;
client_count++;
printf("UDP: client added (%d/%d)\n", client_count, MAX_UDP_CLIENTS);
}
}
static void clients_expire(void)
{
TickType_t now = xTaskGetTickCount();
TickType_t timeout = pdMS_TO_TICKS(CLIENT_TIMEOUT_MS);
for (int i = 0; i < client_count; ) {
if ((now - clients[i].last_seen) > timeout) {
clients[i] = clients[--client_count];
printf("UDP: client expired (%d/%d)\n", client_count, MAX_UDP_CLIENTS);
} else {
i++;
}
}
}
static void parse_udp_sysex(const uint8_t *data, uint16_t len)
{
if (len < 3 || data[0] != 0xF0 || data[1] != 0x7D)
return;
uint16_t end = 0;
for (uint16_t i = 2; i < len; i++) {
if (data[i] == 0xF7) { end = i; break; }
}
if (!end) return;
Command cmd;
memset(&cmd, 0, sizeof(cmd));
cmd.type = data[2];
switch (cmd.type) {
case CMD_SET_SWEEP:
if (len < 16) return;
cmd.sweep.freq_start = decode_float(&data[3]);
cmd.sweep.freq_stop = decode_float(&data[8]);
cmd.sweep.ppd = decode_u16(&data[13]);
break;
case CMD_SET_RTIA:
if (len < 4) return;
cmd.rtia = data[3];
break;
case CMD_SET_RCAL:
if (len < 4) return;
cmd.rcal = data[3];
break;
case CMD_SET_ELECTRODE:
if (len < 4) return;
cmd.electrode = data[3];
break;
case CMD_START_LSV:
if (len < 19) return;
cmd.lsv.v_start = decode_float(&data[3]);
cmd.lsv.v_stop = decode_float(&data[8]);
cmd.lsv.scan_rate = decode_float(&data[13]);
cmd.lsv.lp_rtia = data[18];
break;
case CMD_START_AMP:
if (len < 19) return;
cmd.amp.v_hold = decode_float(&data[3]);
cmd.amp.interval_ms = decode_float(&data[8]);
cmd.amp.duration_s = decode_float(&data[13]);
cmd.amp.lp_rtia = data[18];
break;
case CMD_START_CL:
if (len < 34) return;
cmd.cl.v_cond = decode_float(&data[3]);
cmd.cl.t_cond_ms = decode_float(&data[8]);
cmd.cl.v_free = decode_float(&data[13]);
cmd.cl.v_total = decode_float(&data[18]);
cmd.cl.t_dep_ms = decode_float(&data[23]);
cmd.cl.t_meas_ms = decode_float(&data[28]);
cmd.cl.lp_rtia = data[33];
break;
case CMD_START_PH:
if (len < 8) return;
cmd.ph.stabilize_s = decode_float(&data[3]);
break;
case CMD_START_CLEAN:
if (len < 13) return;
cmd.clean.v_mv = decode_float(&data[3]);
cmd.clean.duration_s = decode_float(&data[8]);
break;
case CMD_SET_CELL_K:
if (len < 8) return;
cmd.cell_k = decode_float(&data[3]);
break;
case CMD_SESSION_CREATE:
if (len < 5) return;
cmd.session_create.name_len = data[3] & 0x7F;
if (cmd.session_create.name_len > MAX_SESSION_NAME)
cmd.session_create.name_len = MAX_SESSION_NAME;
for (uint8_t i = 0; i < cmd.session_create.name_len && (4 + i) < end; i++)
cmd.session_create.name[i] = data[4 + i];
break;
case CMD_SESSION_SWITCH:
if (len < 4) return;
cmd.session_switch.id = data[3] & 0x7F;
break;
case CMD_SESSION_RENAME:
if (len < 6) return;
cmd.session_rename.id = data[3] & 0x7F;
cmd.session_rename.name_len = data[4] & 0x7F;
if (cmd.session_rename.name_len > MAX_SESSION_NAME)
cmd.session_rename.name_len = MAX_SESSION_NAME;
for (uint8_t i = 0; i < cmd.session_rename.name_len && (5 + i) < end; i++)
cmd.session_rename.name[i] = data[5 + i];
break;
case CMD_START_SWEEP:
case CMD_GET_CONFIG:
case CMD_STOP_AMP:
case CMD_GET_TEMP:
case CMD_GET_CELL_K:
case CMD_START_REFS:
case CMD_GET_REFS:
case CMD_CLEAR_REFS:
case CMD_OPEN_CAL:
case CMD_CLEAR_OPEN_CAL:
case CMD_SESSION_LIST:
case CMD_HEARTBEAT:
break;
default:
return;
}
protocol_push_command(&cmd);
}
static void udp_rx_task(void *param)
{
(void)param;
uint8_t buf[UDP_BUF_SIZE];
struct sockaddr_in src;
socklen_t slen = sizeof(src);
for (;;) {
int n = recvfrom(udp_sock, buf, sizeof(buf), 0,
(struct sockaddr *)&src, &slen);
if (n <= 0) continue;
client_touch(&src);
clients_expire();
parse_udp_sysex(buf, (uint16_t)n);
}
}
int wifi_send_sysex(const uint8_t *sysex, uint16_t len)
{
if (udp_sock < 0 || client_count == 0)
return -1;
int sent = 0;
for (int i = 0; i < client_count; i++) {
int r = sendto(udp_sock, sysex, len, 0,
(struct sockaddr *)&clients[i].addr,
sizeof(clients[i].addr));
if (r > 0) sent++;
}
return sent > 0 ? 0 : -1;
}
int wifi_get_client_count(void)
{
return client_count;
}
static void wifi_event_handler(void *arg, esp_event_base_t base,
int32_t id, void *data)
{
(void)arg; (void)data;
if (base == WIFI_EVENT) {
if (id == WIFI_EVENT_AP_STACONNECTED)
printf("WiFi: station connected\n");
else if (id == WIFI_EVENT_AP_STADISCONNECTED)
printf("WiFi: station disconnected\n");
}
}
#ifndef STA_SSID
#define STA_SSID ""
#endif
#ifndef STA_PASS
#define STA_PASS ""
#endif
static void sta_event_handler(void *arg, esp_event_base_t base,
int32_t id, void *data)
{
(void)arg;
if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
printf("WiFi: STA disconnected, reconnecting...\n");
esp_wifi_connect();
} else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *evt = (ip_event_got_ip_t *)data;
printf("WiFi: STA connected, IP " IPSTR "\n", IP2STR(&evt->ip_info.ip));
}
}
static int wifi_ap_init(void)
{
esp_netif_create_default_wifi_ap();
esp_netif_create_default_wifi_sta();
wifi_init_config_t wifi_cfg = WIFI_INIT_CONFIG_DEFAULT();
esp_err_t err = esp_wifi_init(&wifi_cfg);
if (err) return err;
esp_event_handler_instance_t inst;
esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID,
wifi_event_handler, NULL, &inst);
esp_event_handler_instance_register(WIFI_EVENT, WIFI_EVENT_STA_DISCONNECTED,
sta_event_handler, NULL, &inst);
esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP,
sta_event_handler, NULL, &inst);
wifi_config_t ap_cfg = {
.ap = {
.ssid = WIFI_SSID,
.ssid_len = sizeof(WIFI_SSID) - 1,
.channel = WIFI_CHANNEL,
.password = WIFI_PASS,
.max_connection = WIFI_MAX_CONN,
.authmode = WIFI_AUTH_WPA2_PSK,
},
};
esp_wifi_set_mode(WIFI_MODE_APSTA);
esp_wifi_set_config(WIFI_IF_AP, &ap_cfg);
if (strlen(STA_SSID) > 0) {
wifi_config_t sta_cfg = {0};
strncpy((char *)sta_cfg.sta.ssid, STA_SSID, sizeof(sta_cfg.sta.ssid) - 1);
strncpy((char *)sta_cfg.sta.password, STA_PASS, sizeof(sta_cfg.sta.password) - 1);
sta_cfg.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
esp_wifi_set_config(WIFI_IF_STA, &sta_cfg);
}
err = esp_wifi_start();
if (err) return err;
if (strlen(STA_SSID) > 0) {
esp_wifi_connect();
printf("WiFi: STA connecting to \"%s\"\n", STA_SSID);
}
printf("WiFi: AP \"%s\" on channel %d\n", WIFI_SSID, WIFI_CHANNEL);
return 0;
}
static int udp_init(void)
{
udp_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (udp_sock < 0) return -1;
struct sockaddr_in bind_addr = {
.sin_family = AF_INET,
.sin_port = htons(UDP_PORT),
.sin_addr.s_addr = htonl(INADDR_ANY),
};
if (bind(udp_sock, (struct sockaddr *)&bind_addr, sizeof(bind_addr)) < 0) {
close(udp_sock);
udp_sock = -1;
return -1;
}
printf("UDP: listening on port %d\n", UDP_PORT);
return 0;
}
int wifi_transport_init(void)
{
int rc = wifi_ap_init();
if (rc) {
printf("WiFi: AP init failed: %d\n", rc);
return rc;
}
rc = udp_init();
if (rc) {
printf("UDP: init failed\n");
return rc;
}
xTaskCreate(udp_rx_task, "udp_rx", 4096, NULL, 5, NULL);
return 0;
}

10
main/wifi_transport.h Normal file
View File

@ -0,0 +1,10 @@
#ifndef WIFI_TRANSPORT_H
#define WIFI_TRANSPORT_H
#include <stdint.h>
int wifi_transport_init(void);
int wifi_send_sysex(const uint8_t *sysex, uint16_t len);
int wifi_get_client_count(void);
#endif

View File

@ -1,8 +1 @@
CONFIG_IDF_TARGET="esp32s3" CONFIG_IDF_TARGET="esp32s3"
CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_NIMBLE_MAX_CONNECTIONS=4
CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=y
CONFIG_BT_NIMBLE_ROLE_CENTRAL=y
CONFIG_BT_NIMBLE_ROLE_OBSERVER=n
CONFIG_BT_NIMBLE_ROLE_BROADCASTER=y