cue-ios: scaffold with protocol port, BLE manager, GRDB storage

This commit is contained in:
jess 2026-03-31 17:41:03 -07:00
parent 595e25466c
commit 42665a3c84
8 changed files with 1065 additions and 0 deletions

View File

@ -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)
}
}

View File

@ -0,0 +1,12 @@
import SwiftUI
@main
struct CueIOSApp: App {
@State private var ble = BLEManager()
var body: some Scene {
WindowGroup {
ContentView(ble: ble)
}
}
}

25
cue-ios/CueIOS/Info.plist Normal file
View File

@ -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>

View File

@ -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
}

View File

@ -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]
}

View File

@ -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)
}
}

View File

@ -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
}
}
}

21
cue-ios/Package.swift Normal file
View File

@ -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"
),
]
)