Compare commits
14 Commits
7570510491
...
a04163cade
| Author | SHA1 | Date |
|---|---|---|
|
|
a04163cade | |
|
|
cc0685a333 | |
|
|
3f91159596 | |
|
|
d13909c400 | |
|
|
fc0ff900f1 | |
|
|
06f4fa8e71 | |
|
|
34b298dfe2 | |
|
|
4e0dfecce0 | |
|
|
f5dd536ca4 | |
|
|
0ff291549e | |
|
|
6351a8baa0 | |
|
|
4beb9f4408 | |
|
|
f36989e3f9 | |
|
|
201d9881ce |
6
Makefile
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
Before Width: | Height: | Size: 255 KiB After Width: | Height: | Size: 255 KiB |
|
|
@ -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 |
|
After Width: | Height: | Size: 15 KiB |
293
cue/src/app.rs
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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())
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(¶ms)));
|
||||||
|
|
||||||
|
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, ¶ms_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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
873
main/ble.c
|
|
@ -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,
|
|
||||||
¶ms, 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));
|
|
||||||
}
|
|
||||||
105
main/ble.h
|
|
@ -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
|
|
||||||
12
main/echem.c
|
|
@ -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);
|
||||||
|
|
|
||||||
34
main/eis.c
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
112
main/eis4.c
|
|
@ -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(&_cfg, amp_results, ECHEM_MAX_POINTS, ble_send_amp_point);
|
int got = echem_amp(&_cfg, amp_results, ECHEM_MAX_POINTS, send_amp_point);
|
||||||
printf("Amp complete: %d points\n", got);
|
printf("Amp complete: %d points\n", got);
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
72
main/refs.c
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||