merge integration
This commit is contained in:
commit
23f6c3bbcc
|
|
@ -0,0 +1,188 @@
|
|||
/// CoreBluetooth BLE MIDI connection manager.
|
||||
/// Scans for "EIS4", connects, subscribes to MIDI notifications,
|
||||
/// parses incoming BLE MIDI packets into EisMessage values.
|
||||
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class BLEManager: NSObject {
|
||||
|
||||
static let midiServiceUUID = CBUUID(string: "03B80E5A-EDE8-4B33-A751-6CE34EC4C700")
|
||||
static let midiCharUUID = CBUUID(string: "7772E5DB-3868-4112-A1A9-F2669D106BF3")
|
||||
|
||||
enum ConnectionState: String {
|
||||
case disconnected = "Disconnected"
|
||||
case scanning = "Scanning..."
|
||||
case connecting = "Connecting..."
|
||||
case connected = "Connected"
|
||||
}
|
||||
|
||||
var state: ConnectionState = .disconnected
|
||||
var lastMessage: EisMessage?
|
||||
|
||||
private var centralManager: CBCentralManager!
|
||||
private var peripheral: CBPeripheral?
|
||||
private var midiCharacteristic: CBCharacteristic?
|
||||
private var onMessage: ((EisMessage) -> Void)?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
centralManager = CBCentralManager(delegate: self, queue: nil)
|
||||
}
|
||||
|
||||
func setMessageHandler(_ handler: @escaping (EisMessage) -> Void) {
|
||||
onMessage = handler
|
||||
}
|
||||
|
||||
func startScanning() {
|
||||
guard centralManager.state == .poweredOn else { return }
|
||||
state = .scanning
|
||||
centralManager.scanForPeripherals(
|
||||
withServices: [Self.midiServiceUUID],
|
||||
options: nil
|
||||
)
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
if let p = peripheral {
|
||||
centralManager.cancelPeripheralConnection(p)
|
||||
}
|
||||
peripheral = nil
|
||||
midiCharacteristic = nil
|
||||
state = .disconnected
|
||||
}
|
||||
|
||||
func sendCommand(_ sysex: [UInt8]) {
|
||||
guard let char = midiCharacteristic, let p = peripheral else { return }
|
||||
let packet = Self.wrapBLEMIDI(sysex)
|
||||
p.writeValue(packet, for: char, type: .withoutResponse)
|
||||
}
|
||||
|
||||
// MARK: - BLE MIDI packet handling
|
||||
|
||||
/// Wrap raw SysEx into a BLE MIDI packet.
|
||||
/// Input: [F0, 7D, CMD, ...payload, F7]
|
||||
/// Output: [0x80, 0x80, F0, 7D, CMD, ...payload, 0x80, F7]
|
||||
static func wrapBLEMIDI(_ sysex: [UInt8]) -> Data {
|
||||
var pkt: [UInt8] = [0x80, 0x80]
|
||||
pkt.append(contentsOf: sysex.dropLast())
|
||||
pkt.append(0x80)
|
||||
pkt.append(0xF7)
|
||||
return Data(pkt)
|
||||
}
|
||||
|
||||
/// Extract SysEx payload from BLE MIDI notification.
|
||||
/// Returns bytes between F0 and F7 exclusive, stripping timestamps.
|
||||
/// Result: [7D, RSP_ID, ...data]
|
||||
static func extractSysEx(from data: Data) -> [UInt8]? {
|
||||
let bytes = Array(data)
|
||||
guard bytes.count >= 5 else { return nil }
|
||||
|
||||
var i = 1 // skip header
|
||||
while i < bytes.count {
|
||||
let b = bytes[i]
|
||||
if b == 0xF0 { break }
|
||||
if b & 0x80 != 0 && b != 0xF7 { i += 1; continue }
|
||||
i += 1
|
||||
}
|
||||
guard i < bytes.count, bytes[i] == 0xF0 else { return nil }
|
||||
i += 1
|
||||
|
||||
var payload: [UInt8] = []
|
||||
while i < bytes.count && bytes[i] != 0xF7 {
|
||||
if bytes[i] & 0x80 != 0 { i += 1; continue }
|
||||
payload.append(bytes[i])
|
||||
i += 1
|
||||
}
|
||||
guard !payload.isEmpty else { return nil }
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBCentralManagerDelegate
|
||||
|
||||
extension BLEManager: CBCentralManagerDelegate {
|
||||
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
if central.state == .poweredOn {
|
||||
startScanning()
|
||||
}
|
||||
}
|
||||
|
||||
func centralManager(
|
||||
_ central: CBCentralManager,
|
||||
didDiscover peripheral: CBPeripheral,
|
||||
advertisementData: [String: Any],
|
||||
rssi RSSI: NSNumber
|
||||
) {
|
||||
guard peripheral.name == "EIS4" else { return }
|
||||
central.stopScan()
|
||||
self.peripheral = peripheral
|
||||
state = .connecting
|
||||
central.connect(peripheral, options: nil)
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
peripheral.delegate = self
|
||||
peripheral.discoverServices([Self.midiServiceUUID])
|
||||
}
|
||||
|
||||
func centralManager(
|
||||
_ central: CBCentralManager,
|
||||
didDisconnectPeripheral peripheral: CBPeripheral,
|
||||
error: Error?
|
||||
) {
|
||||
self.peripheral = nil
|
||||
self.midiCharacteristic = nil
|
||||
state = .disconnected
|
||||
startScanning()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBPeripheralDelegate
|
||||
|
||||
extension BLEManager: CBPeripheralDelegate {
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||
guard let service = peripheral.services?.first(where: {
|
||||
$0.uuid == Self.midiServiceUUID
|
||||
}) else { return }
|
||||
peripheral.discoverCharacteristics([Self.midiCharUUID], for: service)
|
||||
}
|
||||
|
||||
func peripheral(
|
||||
_ peripheral: CBPeripheral,
|
||||
didDiscoverCharacteristicsFor service: CBService,
|
||||
error: Error?
|
||||
) {
|
||||
guard let char = service.characteristics?.first(where: {
|
||||
$0.uuid == Self.midiCharUUID
|
||||
}) else { return }
|
||||
midiCharacteristic = char
|
||||
peripheral.setNotifyValue(true, for: char)
|
||||
}
|
||||
|
||||
func peripheral(
|
||||
_ peripheral: CBPeripheral,
|
||||
didUpdateNotificationStateFor characteristic: CBCharacteristic,
|
||||
error: Error?
|
||||
) {
|
||||
if characteristic.isNotifying {
|
||||
state = .connected
|
||||
sendCommand(buildSysexGetConfig())
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(
|
||||
_ peripheral: CBPeripheral,
|
||||
didUpdateValueFor characteristic: CBCharacteristic,
|
||||
error: Error?
|
||||
) {
|
||||
guard let data = characteristic.value,
|
||||
let sysex = Self.extractSysEx(from: data),
|
||||
let msg = parseSysex(sysex) else { return }
|
||||
lastMessage = msg
|
||||
onMessage?(msg)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct CueIOSApp: App {
|
||||
@State private var ble = BLEManager()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView(ble: ble)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>EIS4 uses Bluetooth to communicate with the impedance analyzer</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
/// EIS4 measurement data types and hardware enums.
|
||||
/// All types are Codable for GRDB/JSON storage.
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Measurement points
|
||||
|
||||
struct EisPoint: Codable {
|
||||
var freqHz: Float
|
||||
var magOhms: Float
|
||||
var phaseDeg: Float
|
||||
var zReal: Float
|
||||
var zImag: Float
|
||||
var rtiaMagBefore: Float
|
||||
var rtiaMagAfter: Float
|
||||
var revMag: Float
|
||||
var revPhase: Float
|
||||
var pctErr: Float
|
||||
}
|
||||
|
||||
struct LsvPoint: Codable {
|
||||
var vMv: Float
|
||||
var iUa: Float
|
||||
}
|
||||
|
||||
struct AmpPoint: Codable {
|
||||
var tMs: Float
|
||||
var iUa: Float
|
||||
}
|
||||
|
||||
struct ClPoint: Codable {
|
||||
var tMs: Float
|
||||
var iUa: Float
|
||||
var phase: UInt8
|
||||
}
|
||||
|
||||
struct ClResult: Codable {
|
||||
var iFreeUa: Float
|
||||
var iTotalUa: Float
|
||||
}
|
||||
|
||||
struct PhResult: Codable {
|
||||
var vOcpMv: Float
|
||||
var ph: Float
|
||||
var tempC: Float
|
||||
}
|
||||
|
||||
// MARK: - Config
|
||||
|
||||
struct EisConfig: Codable {
|
||||
var freqStart: Float
|
||||
var freqStop: Float
|
||||
var ppd: UInt16
|
||||
var rtia: Rtia
|
||||
var rcal: Rcal
|
||||
var electrode: Electrode
|
||||
}
|
||||
|
||||
// MARK: - Hardware enums
|
||||
|
||||
enum Rtia: UInt8, Codable, CaseIterable {
|
||||
case r200 = 0
|
||||
case r1K = 1
|
||||
case r5K = 2
|
||||
case r10K = 3
|
||||
case r20K = 4
|
||||
case r40K = 5
|
||||
case r80K = 6
|
||||
case r160K = 7
|
||||
case extDe0 = 8
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .r200: "200\u{2126}"
|
||||
case .r1K: "1k\u{2126}"
|
||||
case .r5K: "5k\u{2126}"
|
||||
case .r10K: "10k\u{2126}"
|
||||
case .r20K: "20k\u{2126}"
|
||||
case .r40K: "40k\u{2126}"
|
||||
case .r80K: "80k\u{2126}"
|
||||
case .r160K: "160k\u{2126}"
|
||||
case .extDe0: "Ext 3k\u{2126} (DE0)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Rcal: UInt8, Codable, CaseIterable {
|
||||
case r200 = 0
|
||||
case r3K = 1
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .r200: "200\u{2126} (RCAL0\u{2194}RCAL1)"
|
||||
case .r3K: "3k\u{2126} (RCAL0\u{2194}AIN0)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Electrode: UInt8, Codable, CaseIterable {
|
||||
case fourWire = 0
|
||||
case threeWire = 1
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .fourWire: "4-wire (AIN)"
|
||||
case .threeWire: "3-wire (CE0/RE0/SE0)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LpRtia: UInt8, Codable, CaseIterable {
|
||||
case r200 = 0
|
||||
case r1K = 1
|
||||
case r2K = 2
|
||||
case r3K = 3
|
||||
case r4K = 4
|
||||
case r6K = 5
|
||||
case r8K = 6
|
||||
case r10K = 7
|
||||
case r12K = 8
|
||||
case r16K = 9
|
||||
case r20K = 10
|
||||
case r24K = 11
|
||||
case r30K = 12
|
||||
case r32K = 13
|
||||
case r40K = 14
|
||||
case r48K = 15
|
||||
case r64K = 16
|
||||
case r85K = 17
|
||||
case r96K = 18
|
||||
case r100K = 19
|
||||
case r120K = 20
|
||||
case r128K = 21
|
||||
case r160K = 22
|
||||
case r196K = 23
|
||||
case r256K = 24
|
||||
case r512K = 25
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .r200: "200\u{2126}"
|
||||
case .r1K: "1k\u{2126}"
|
||||
case .r2K: "2k\u{2126}"
|
||||
case .r3K: "3k\u{2126}"
|
||||
case .r4K: "4k\u{2126}"
|
||||
case .r6K: "6k\u{2126}"
|
||||
case .r8K: "8k\u{2126}"
|
||||
case .r10K: "10k\u{2126}"
|
||||
case .r12K: "12k\u{2126}"
|
||||
case .r16K: "16k\u{2126}"
|
||||
case .r20K: "20k\u{2126}"
|
||||
case .r24K: "24k\u{2126}"
|
||||
case .r30K: "30k\u{2126}"
|
||||
case .r32K: "32k\u{2126}"
|
||||
case .r40K: "40k\u{2126}"
|
||||
case .r48K: "48k\u{2126}"
|
||||
case .r64K: "64k\u{2126}"
|
||||
case .r85K: "85k\u{2126}"
|
||||
case .r96K: "96k\u{2126}"
|
||||
case .r100K: "100k\u{2126}"
|
||||
case .r120K: "120k\u{2126}"
|
||||
case .r128K: "128k\u{2126}"
|
||||
case .r160K: "160k\u{2126}"
|
||||
case .r196K: "196k\u{2126}"
|
||||
case .r256K: "256k\u{2126}"
|
||||
case .r512K: "512k\u{2126}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Measurement type tag
|
||||
|
||||
enum MeasurementType: String, Codable {
|
||||
case eis
|
||||
case lsv
|
||||
case amp
|
||||
case chlorine
|
||||
case ph
|
||||
}
|
||||
|
|
@ -0,0 +1,355 @@
|
|||
/// EIS4 SysEx Protocol — manufacturer ID 0x7D (non-commercial)
|
||||
/// Port of cue/src/protocol.rs
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
let sysexMfr: UInt8 = 0x7D
|
||||
|
||||
// ESP32 -> Cue
|
||||
let RSP_SWEEP_START: UInt8 = 0x01
|
||||
let RSP_DATA_POINT: UInt8 = 0x02
|
||||
let RSP_SWEEP_END: UInt8 = 0x03
|
||||
let RSP_CONFIG: UInt8 = 0x04
|
||||
let RSP_LSV_START: UInt8 = 0x05
|
||||
let RSP_LSV_POINT: UInt8 = 0x06
|
||||
let RSP_LSV_END: UInt8 = 0x07
|
||||
let RSP_AMP_START: UInt8 = 0x08
|
||||
let RSP_AMP_POINT: UInt8 = 0x09
|
||||
let RSP_AMP_END: UInt8 = 0x0A
|
||||
let RSP_CL_START: UInt8 = 0x0B
|
||||
let RSP_CL_POINT: UInt8 = 0x0C
|
||||
let RSP_CL_RESULT: UInt8 = 0x0D
|
||||
let RSP_CL_END: UInt8 = 0x0E
|
||||
let RSP_PH_RESULT: UInt8 = 0x0F
|
||||
let RSP_TEMP: UInt8 = 0x10
|
||||
let RSP_REF_FRAME: UInt8 = 0x20
|
||||
let RSP_REF_LP_RANGE: UInt8 = 0x21
|
||||
let RSP_REFS_DONE: UInt8 = 0x22
|
||||
let RSP_REF_STATUS: UInt8 = 0x23
|
||||
|
||||
// Cue -> ESP32
|
||||
let CMD_SET_SWEEP: UInt8 = 0x10
|
||||
let CMD_SET_RTIA: UInt8 = 0x11
|
||||
let CMD_SET_RCAL: UInt8 = 0x12
|
||||
let CMD_START_SWEEP: UInt8 = 0x13
|
||||
let CMD_GET_CONFIG: UInt8 = 0x14
|
||||
let CMD_SET_ELECTRODE: UInt8 = 0x15
|
||||
let CMD_GET_TEMP: UInt8 = 0x17
|
||||
let CMD_START_LSV: UInt8 = 0x20
|
||||
let CMD_START_AMP: UInt8 = 0x21
|
||||
let CMD_STOP_AMP: UInt8 = 0x22
|
||||
let CMD_START_CL: UInt8 = 0x23
|
||||
let CMD_START_PH: UInt8 = 0x24
|
||||
let CMD_START_CLEAN: UInt8 = 0x25
|
||||
let CMD_START_REFS: UInt8 = 0x30
|
||||
let CMD_GET_REFS: UInt8 = 0x31
|
||||
let CMD_CLEAR_REFS: UInt8 = 0x32
|
||||
|
||||
// MARK: - 7-bit MIDI encoding
|
||||
|
||||
/// Encode an IEEE 754 float into 5 MIDI-safe bytes.
|
||||
/// Byte 0: mask of high bits from each original byte.
|
||||
/// Bytes 1-4: original bytes with bit 7 stripped.
|
||||
func encodeFloat(_ val: Float) -> [UInt8] {
|
||||
var v = val
|
||||
let p = withUnsafeBytes(of: &v) { Array($0) }
|
||||
let mask: UInt8 = ((p[0] >> 7) & 1)
|
||||
| ((p[1] >> 6) & 2)
|
||||
| ((p[2] >> 5) & 4)
|
||||
| ((p[3] >> 4) & 8)
|
||||
return [mask, p[0] & 0x7F, p[1] & 0x7F, p[2] & 0x7F, p[3] & 0x7F]
|
||||
}
|
||||
|
||||
/// Decode 5 MIDI-safe bytes back into an IEEE 754 float.
|
||||
func decodeFloat(_ d: [UInt8], at offset: Int = 0) -> Float {
|
||||
let m = d[offset]
|
||||
let b0 = d[offset + 1] | ((m & 1) << 7)
|
||||
let b1 = d[offset + 2] | ((m & 2) << 6)
|
||||
let b2 = d[offset + 3] | ((m & 4) << 5)
|
||||
let b3 = d[offset + 4] | ((m & 8) << 4)
|
||||
var val: Float = 0
|
||||
withUnsafeMutableBytes(of: &val) { buf in
|
||||
buf[0] = b0; buf[1] = b1; buf[2] = b2; buf[3] = b3
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
/// Encode a UInt16 into 3 MIDI-safe bytes.
|
||||
func encodeU16(_ val: UInt16) -> [UInt8] {
|
||||
var v = val
|
||||
let p = withUnsafeBytes(of: &v) { Array($0) }
|
||||
let mask: UInt8 = ((p[0] >> 7) & 1) | ((p[1] >> 6) & 2)
|
||||
return [mask, p[0] & 0x7F, p[1] & 0x7F]
|
||||
}
|
||||
|
||||
/// Decode 3 MIDI-safe bytes back into a UInt16.
|
||||
func decodeU16(_ d: [UInt8], at offset: Int = 0) -> UInt16 {
|
||||
let m = d[offset]
|
||||
let b0 = d[offset + 1] | ((m & 1) << 7)
|
||||
let b1 = d[offset + 2] | ((m & 2) << 6)
|
||||
var val: UInt16 = 0
|
||||
withUnsafeMutableBytes(of: &val) { buf in
|
||||
buf[0] = b0; buf[1] = b1
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// MARK: - Message enum
|
||||
|
||||
enum EisMessage {
|
||||
case sweepStart(numPoints: UInt16, freqStart: Float, freqStop: Float)
|
||||
case dataPoint(index: UInt16, point: EisPoint)
|
||||
case sweepEnd
|
||||
case config(EisConfig)
|
||||
case lsvStart(numPoints: UInt16, vStart: Float, vStop: Float)
|
||||
case lsvPoint(index: UInt16, point: LsvPoint)
|
||||
case lsvEnd
|
||||
case ampStart(vHold: Float)
|
||||
case ampPoint(index: UInt16, point: AmpPoint)
|
||||
case ampEnd
|
||||
case clStart(numPoints: UInt16)
|
||||
case clPoint(index: UInt16, point: ClPoint)
|
||||
case clResult(ClResult)
|
||||
case clEnd
|
||||
case phResult(PhResult)
|
||||
case temperature(Float)
|
||||
case refFrame(mode: UInt8, rtiaIdx: UInt8)
|
||||
case refLpRange(mode: UInt8, lowIdx: UInt8, highIdx: UInt8)
|
||||
case refsDone
|
||||
case refStatus(hasRefs: Bool)
|
||||
}
|
||||
|
||||
// MARK: - Response parser
|
||||
|
||||
/// Parse a SysEx payload (after BLE MIDI unwrapping).
|
||||
/// Input: [0x7D, RSP_ID, ...payload_bytes...]
|
||||
func parseSysex(_ data: [UInt8]) -> EisMessage? {
|
||||
guard data.count >= 2, data[0] == sysexMfr else { return nil }
|
||||
let p = Array(data.dropFirst(2))
|
||||
|
||||
switch data[1] {
|
||||
|
||||
case RSP_SWEEP_START where p.count >= 13:
|
||||
return .sweepStart(
|
||||
numPoints: decodeU16(p, at: 0),
|
||||
freqStart: decodeFloat(p, at: 3),
|
||||
freqStop: decodeFloat(p, at: 8)
|
||||
)
|
||||
|
||||
case RSP_DATA_POINT where p.count >= 28:
|
||||
let ext = p.count >= 53
|
||||
return .dataPoint(
|
||||
index: decodeU16(p, at: 0),
|
||||
point: EisPoint(
|
||||
freqHz: decodeFloat(p, at: 3),
|
||||
magOhms: decodeFloat(p, at: 8),
|
||||
phaseDeg: decodeFloat(p, at: 13),
|
||||
zReal: decodeFloat(p, at: 18),
|
||||
zImag: decodeFloat(p, at: 23),
|
||||
rtiaMagBefore: ext ? decodeFloat(p, at: 28) : 0,
|
||||
rtiaMagAfter: ext ? decodeFloat(p, at: 33) : 0,
|
||||
revMag: ext ? decodeFloat(p, at: 38) : 0,
|
||||
revPhase: ext ? decodeFloat(p, at: 43) : 0,
|
||||
pctErr: ext ? decodeFloat(p, at: 48) : 0
|
||||
)
|
||||
)
|
||||
|
||||
case RSP_SWEEP_END:
|
||||
return .sweepEnd
|
||||
|
||||
case RSP_CONFIG where p.count >= 16:
|
||||
return .config(EisConfig(
|
||||
freqStart: decodeFloat(p, at: 0),
|
||||
freqStop: decodeFloat(p, at: 5),
|
||||
ppd: decodeU16(p, at: 10),
|
||||
rtia: Rtia(rawValue: p[13]) ?? .r5K,
|
||||
rcal: Rcal(rawValue: p[14]) ?? .r3K,
|
||||
electrode: Electrode(rawValue: p[15]) ?? .fourWire
|
||||
))
|
||||
|
||||
case RSP_LSV_START where p.count >= 13:
|
||||
return .lsvStart(
|
||||
numPoints: decodeU16(p, at: 0),
|
||||
vStart: decodeFloat(p, at: 3),
|
||||
vStop: decodeFloat(p, at: 8)
|
||||
)
|
||||
|
||||
case RSP_LSV_POINT where p.count >= 13:
|
||||
return .lsvPoint(
|
||||
index: decodeU16(p, at: 0),
|
||||
point: LsvPoint(
|
||||
vMv: decodeFloat(p, at: 3),
|
||||
iUa: decodeFloat(p, at: 8)
|
||||
)
|
||||
)
|
||||
|
||||
case RSP_LSV_END:
|
||||
return .lsvEnd
|
||||
|
||||
case RSP_AMP_START where p.count >= 5:
|
||||
return .ampStart(vHold: decodeFloat(p, at: 0))
|
||||
|
||||
case RSP_AMP_POINT where p.count >= 13:
|
||||
return .ampPoint(
|
||||
index: decodeU16(p, at: 0),
|
||||
point: AmpPoint(
|
||||
tMs: decodeFloat(p, at: 3),
|
||||
iUa: decodeFloat(p, at: 8)
|
||||
)
|
||||
)
|
||||
|
||||
case RSP_AMP_END:
|
||||
return .ampEnd
|
||||
|
||||
case RSP_CL_START where p.count >= 3:
|
||||
return .clStart(numPoints: decodeU16(p, at: 0))
|
||||
|
||||
case RSP_CL_POINT where p.count >= 14:
|
||||
return .clPoint(
|
||||
index: decodeU16(p, at: 0),
|
||||
point: ClPoint(
|
||||
tMs: decodeFloat(p, at: 3),
|
||||
iUa: decodeFloat(p, at: 8),
|
||||
phase: p[13]
|
||||
)
|
||||
)
|
||||
|
||||
case RSP_CL_RESULT where p.count >= 10:
|
||||
return .clResult(ClResult(
|
||||
iFreeUa: decodeFloat(p, at: 0),
|
||||
iTotalUa: decodeFloat(p, at: 5)
|
||||
))
|
||||
|
||||
case RSP_CL_END:
|
||||
return .clEnd
|
||||
|
||||
case RSP_PH_RESULT where p.count >= 15:
|
||||
return .phResult(PhResult(
|
||||
vOcpMv: decodeFloat(p, at: 0),
|
||||
ph: decodeFloat(p, at: 5),
|
||||
tempC: decodeFloat(p, at: 10)
|
||||
))
|
||||
|
||||
case RSP_TEMP where p.count >= 5:
|
||||
return .temperature(decodeFloat(p, at: 0))
|
||||
|
||||
case RSP_REF_FRAME where p.count >= 2:
|
||||
return .refFrame(mode: p[0], rtiaIdx: p[1])
|
||||
|
||||
case RSP_REF_LP_RANGE where p.count >= 3:
|
||||
return .refLpRange(mode: p[0], lowIdx: p[1], highIdx: p[2])
|
||||
|
||||
case RSP_REFS_DONE:
|
||||
return .refsDone
|
||||
|
||||
case RSP_REF_STATUS where p.count >= 1:
|
||||
return .refStatus(hasRefs: p[0] != 0)
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Command builders
|
||||
|
||||
func buildSysexSetSweep(freqStart: Float, freqStop: Float, ppd: UInt16) -> [UInt8] {
|
||||
var sx: [UInt8] = [0xF0, sysexMfr, CMD_SET_SWEEP]
|
||||
sx.append(contentsOf: encodeFloat(freqStart))
|
||||
sx.append(contentsOf: encodeFloat(freqStop))
|
||||
sx.append(contentsOf: encodeU16(ppd))
|
||||
sx.append(0xF7)
|
||||
return sx
|
||||
}
|
||||
|
||||
func buildSysexSetRtia(_ rtia: Rtia) -> [UInt8] {
|
||||
[0xF0, sysexMfr, CMD_SET_RTIA, rtia.rawValue, 0xF7]
|
||||
}
|
||||
|
||||
func buildSysexSetRcal(_ rcal: Rcal) -> [UInt8] {
|
||||
[0xF0, sysexMfr, CMD_SET_RCAL, rcal.rawValue, 0xF7]
|
||||
}
|
||||
|
||||
func buildSysexSetElectrode(_ e: Electrode) -> [UInt8] {
|
||||
[0xF0, sysexMfr, CMD_SET_ELECTRODE, e.rawValue, 0xF7]
|
||||
}
|
||||
|
||||
func buildSysexStartSweep() -> [UInt8] {
|
||||
[0xF0, sysexMfr, CMD_START_SWEEP, 0xF7]
|
||||
}
|
||||
|
||||
func buildSysexGetConfig() -> [UInt8] {
|
||||
[0xF0, sysexMfr, CMD_GET_CONFIG, 0xF7]
|
||||
}
|
||||
|
||||
func buildSysexStartLsv(vStart: Float, vStop: Float, scanRate: Float, lpRtia: LpRtia) -> [UInt8] {
|
||||
var sx: [UInt8] = [0xF0, sysexMfr, CMD_START_LSV]
|
||||
sx.append(contentsOf: encodeFloat(vStart))
|
||||
sx.append(contentsOf: encodeFloat(vStop))
|
||||
sx.append(contentsOf: encodeFloat(scanRate))
|
||||
sx.append(lpRtia.rawValue)
|
||||
sx.append(0xF7)
|
||||
return sx
|
||||
}
|
||||
|
||||
func buildSysexStartAmp(vHold: Float, intervalMs: Float, durationS: Float, lpRtia: LpRtia) -> [UInt8] {
|
||||
var sx: [UInt8] = [0xF0, sysexMfr, CMD_START_AMP]
|
||||
sx.append(contentsOf: encodeFloat(vHold))
|
||||
sx.append(contentsOf: encodeFloat(intervalMs))
|
||||
sx.append(contentsOf: encodeFloat(durationS))
|
||||
sx.append(lpRtia.rawValue)
|
||||
sx.append(0xF7)
|
||||
return sx
|
||||
}
|
||||
|
||||
func buildSysexStopAmp() -> [UInt8] {
|
||||
[0xF0, sysexMfr, CMD_STOP_AMP, 0xF7]
|
||||
}
|
||||
|
||||
func buildSysexStartCl(
|
||||
vCond: Float, tCondMs: Float, vFree: Float, vTotal: Float,
|
||||
tDepMs: Float, tMeasMs: Float, lpRtia: LpRtia
|
||||
) -> [UInt8] {
|
||||
var sx: [UInt8] = [0xF0, sysexMfr, CMD_START_CL]
|
||||
sx.append(contentsOf: encodeFloat(vCond))
|
||||
sx.append(contentsOf: encodeFloat(tCondMs))
|
||||
sx.append(contentsOf: encodeFloat(vFree))
|
||||
sx.append(contentsOf: encodeFloat(vTotal))
|
||||
sx.append(contentsOf: encodeFloat(tDepMs))
|
||||
sx.append(contentsOf: encodeFloat(tMeasMs))
|
||||
sx.append(lpRtia.rawValue)
|
||||
sx.append(0xF7)
|
||||
return sx
|
||||
}
|
||||
|
||||
func buildSysexGetTemp() -> [UInt8] {
|
||||
[0xF0, sysexMfr, CMD_GET_TEMP, 0xF7]
|
||||
}
|
||||
|
||||
func buildSysexStartPh(stabilizeS: Float) -> [UInt8] {
|
||||
var sx: [UInt8] = [0xF0, sysexMfr, CMD_START_PH]
|
||||
sx.append(contentsOf: encodeFloat(stabilizeS))
|
||||
sx.append(0xF7)
|
||||
return sx
|
||||
}
|
||||
|
||||
func buildSysexStartClean(vMv: Float, durationS: Float) -> [UInt8] {
|
||||
var sx: [UInt8] = [0xF0, sysexMfr, CMD_START_CLEAN]
|
||||
sx.append(contentsOf: encodeFloat(vMv))
|
||||
sx.append(contentsOf: encodeFloat(durationS))
|
||||
sx.append(0xF7)
|
||||
return sx
|
||||
}
|
||||
|
||||
func buildSysexStartRefs() -> [UInt8] {
|
||||
[0xF0, sysexMfr, CMD_START_REFS, 0xF7]
|
||||
}
|
||||
|
||||
func buildSysexGetRefs() -> [UInt8] {
|
||||
[0xF0, sysexMfr, CMD_GET_REFS, 0xF7]
|
||||
}
|
||||
|
||||
func buildSysexClearRefs() -> [UInt8] {
|
||||
[0xF0, sysexMfr, CMD_CLEAR_REFS, 0xF7]
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
/// SQLite persistence via GRDB.
|
||||
/// Schema: Session -> Measurement -> DataPoint
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
|
||||
// MARK: - Records
|
||||
|
||||
struct Session: Codable, FetchableRecord, MutablePersistableRecord {
|
||||
var id: Int64?
|
||||
var startedAt: Date
|
||||
var label: String?
|
||||
var notes: String?
|
||||
|
||||
mutating func didInsert(_ inserted: InsertionSuccess) {
|
||||
id = inserted.rowID
|
||||
}
|
||||
}
|
||||
|
||||
struct Measurement: Codable, FetchableRecord, MutablePersistableRecord {
|
||||
var id: Int64?
|
||||
var sessionId: Int64
|
||||
var type: String
|
||||
var startedAt: Date
|
||||
var config: Data?
|
||||
var resultSummary: Data?
|
||||
|
||||
mutating func didInsert(_ inserted: InsertionSuccess) {
|
||||
id = inserted.rowID
|
||||
}
|
||||
}
|
||||
|
||||
struct DataPoint: Codable, FetchableRecord, MutablePersistableRecord {
|
||||
var id: Int64?
|
||||
var measurementId: Int64
|
||||
var index: Int
|
||||
var payload: Data
|
||||
|
||||
mutating func didInsert(_ inserted: InsertionSuccess) {
|
||||
id = inserted.rowID
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Database manager
|
||||
|
||||
final class Storage {
|
||||
static let shared = Storage()
|
||||
|
||||
private let dbQueue: DatabaseQueue
|
||||
|
||||
private init() {
|
||||
let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let path = dir.appendingPathComponent("eis4.sqlite").path
|
||||
do {
|
||||
dbQueue = try DatabaseQueue(path: path)
|
||||
try migrate()
|
||||
} catch {
|
||||
fatalError("Database init failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func migrate() throws {
|
||||
var migrator = DatabaseMigrator()
|
||||
|
||||
migrator.registerMigration("v1") { db in
|
||||
try db.create(table: "session") { t in
|
||||
t.autoIncrementedPrimaryKey("id")
|
||||
t.column("startedAt", .datetime).notNull()
|
||||
t.column("label", .text)
|
||||
t.column("notes", .text)
|
||||
}
|
||||
|
||||
try db.create(table: "measurement") { t in
|
||||
t.autoIncrementedPrimaryKey("id")
|
||||
t.column("sessionId", .integer).notNull()
|
||||
.references("session", onDelete: .cascade)
|
||||
t.column("type", .text).notNull()
|
||||
t.column("startedAt", .datetime).notNull()
|
||||
t.column("config", .blob)
|
||||
t.column("resultSummary", .blob)
|
||||
}
|
||||
|
||||
try db.create(table: "dataPoint") { t in
|
||||
t.autoIncrementedPrimaryKey("id")
|
||||
t.column("measurementId", .integer).notNull()
|
||||
.references("measurement", onDelete: .cascade)
|
||||
t.column("index", .integer).notNull()
|
||||
t.column("payload", .blob).notNull()
|
||||
}
|
||||
}
|
||||
|
||||
try migrator.migrate(dbQueue)
|
||||
}
|
||||
|
||||
// MARK: - Sessions
|
||||
|
||||
func createSession(label: String? = nil) throws -> Session {
|
||||
try dbQueue.write { db in
|
||||
var s = Session(startedAt: Date(), label: label)
|
||||
try s.insert(db)
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
func fetchSessions() throws -> [Session] {
|
||||
try dbQueue.read { db in
|
||||
try Session.order(Column("startedAt").desc).fetchAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteSession(_ id: Int64) throws {
|
||||
try dbQueue.write { db in
|
||||
_ = try Session.deleteOne(db, id: id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Measurements
|
||||
|
||||
func addMeasurement(
|
||||
sessionId: Int64,
|
||||
type: MeasurementType,
|
||||
config: (any Encodable)? = nil
|
||||
) throws -> Measurement {
|
||||
let configData: Data? = if let config {
|
||||
try JSONEncoder().encode(config)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
return try dbQueue.write { db in
|
||||
var m = Measurement(
|
||||
sessionId: sessionId,
|
||||
type: type.rawValue,
|
||||
startedAt: Date(),
|
||||
config: configData
|
||||
)
|
||||
try m.insert(db)
|
||||
return m
|
||||
}
|
||||
}
|
||||
|
||||
func setMeasurementResult(_ measurementId: Int64, result: any Encodable) throws {
|
||||
try dbQueue.write { db in
|
||||
let data = try JSONEncoder().encode(result)
|
||||
try db.execute(
|
||||
sql: "UPDATE measurement SET resultSummary = ? WHERE id = ?",
|
||||
arguments: [data, measurementId]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchMeasurements(sessionId: Int64) throws -> [Measurement] {
|
||||
try dbQueue.read { db in
|
||||
try Measurement
|
||||
.filter(Column("sessionId") == sessionId)
|
||||
.order(Column("startedAt").asc)
|
||||
.fetchAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data points
|
||||
|
||||
func addDataPoint<T: Encodable>(measurementId: Int64, index: Int, point: T) throws {
|
||||
let payload = try JSONEncoder().encode(point)
|
||||
try dbQueue.write { db in
|
||||
var dp = DataPoint(
|
||||
measurementId: measurementId,
|
||||
index: index,
|
||||
payload: payload
|
||||
)
|
||||
try dp.insert(db)
|
||||
}
|
||||
}
|
||||
|
||||
func addDataPoints<T: Encodable>(measurementId: Int64, points: [(index: Int, value: T)]) throws {
|
||||
let encoder = JSONEncoder()
|
||||
try dbQueue.write { db in
|
||||
for (idx, val) in points {
|
||||
let payload = try encoder.encode(val)
|
||||
var dp = DataPoint(measurementId: measurementId, index: idx, payload: payload)
|
||||
try dp.insert(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchDataPoints(measurementId: Int64) throws -> [DataPoint] {
|
||||
try dbQueue.read { db in
|
||||
try DataPoint
|
||||
.filter(Column("measurementId") == measurementId)
|
||||
.order(Column("index").asc)
|
||||
.fetchAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode stored data points into typed values.
|
||||
func fetchTypedPoints<T: Decodable>(measurementId: Int64, as type: T.Type) throws -> [T] {
|
||||
let rows = try fetchDataPoints(measurementId: measurementId)
|
||||
let decoder = JSONDecoder()
|
||||
return try rows.map { try decoder.decode(T.self, from: $0.payload) }
|
||||
}
|
||||
|
||||
// MARK: - Observation (for SwiftUI live updates)
|
||||
|
||||
func observeDataPoints(
|
||||
measurementId: Int64,
|
||||
onChange: @escaping ([DataPoint]) -> Void
|
||||
) -> DatabaseCancellable {
|
||||
let observation = ValueObservation.tracking { db in
|
||||
try DataPoint
|
||||
.filter(Column("measurementId") == measurementId)
|
||||
.order(Column("index").asc)
|
||||
.fetchAll(db)
|
||||
}
|
||||
return observation.start(in: dbQueue, onError: { _ in }, onChange: onChange)
|
||||
}
|
||||
|
||||
func observeSessions(onChange: @escaping ([Session]) -> Void) -> DatabaseCancellable {
|
||||
let observation = ValueObservation.tracking { db in
|
||||
try Session.order(Column("startedAt").desc).fetchAll(db)
|
||||
}
|
||||
return observation.start(in: dbQueue, onError: { _ in }, onChange: onChange)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
var ble: BLEManager
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
Tab("EIS", systemImage: "waveform.path") {
|
||||
PlaceholderTab(title: "Impedance Spectroscopy", ble: ble)
|
||||
}
|
||||
Tab("LSV", systemImage: "arrow.right") {
|
||||
PlaceholderTab(title: "Linear Sweep Voltammetry", ble: ble)
|
||||
}
|
||||
Tab("Amp", systemImage: "bolt") {
|
||||
PlaceholderTab(title: "Amperometry", ble: ble)
|
||||
}
|
||||
Tab("Cl\u{2082}", systemImage: "drop") {
|
||||
PlaceholderTab(title: "Chlorine", ble: ble)
|
||||
}
|
||||
Tab("pH", systemImage: "scalemass") {
|
||||
PlaceholderTab(title: "pH", ble: ble)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PlaceholderTab: View {
|
||||
let title: String
|
||||
var ble: BLEManager
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(statusColor)
|
||||
Text(ble.state.rawValue)
|
||||
.font(.headline)
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(ble.state == .connected ? "Disconnect" : "Connect") {
|
||||
if ble.state == .connected {
|
||||
ble.disconnect()
|
||||
} else {
|
||||
ble.startScanning()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch ble.state {
|
||||
case .connected: .green
|
||||
case .scanning: .orange
|
||||
case .connecting: .yellow
|
||||
case .disconnected: .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// swift-tools-version: 5.9
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "CueIOS",
|
||||
platforms: [.iOS(.v17)],
|
||||
products: [
|
||||
.library(name: "CueIOS", targets: ["CueIOS"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/groue/GRDB.swift.git", from: "7.0.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "CueIOS",
|
||||
dependencies: [.product(name: "GRDB", package: "GRDB.swift")],
|
||||
path: "CueIOS"
|
||||
),
|
||||
]
|
||||
)
|
||||
Loading…
Reference in New Issue