diff --git a/cue-ios/CueIOS/BLE/BLEManager.swift b/cue-ios/CueIOS/BLE/BLEManager.swift new file mode 100644 index 0000000..adf9f16 --- /dev/null +++ b/cue-ios/CueIOS/BLE/BLEManager.swift @@ -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) + } +} diff --git a/cue-ios/CueIOS/CueIOSApp.swift b/cue-ios/CueIOS/CueIOSApp.swift new file mode 100644 index 0000000..1fe8459 --- /dev/null +++ b/cue-ios/CueIOS/CueIOSApp.swift @@ -0,0 +1,12 @@ +import SwiftUI + +@main +struct CueIOSApp: App { + @State private var ble = BLEManager() + + var body: some Scene { + WindowGroup { + ContentView(ble: ble) + } + } +} diff --git a/cue-ios/CueIOS/Info.plist b/cue-ios/CueIOS/Info.plist new file mode 100644 index 0000000..fe3304e --- /dev/null +++ b/cue-ios/CueIOS/Info.plist @@ -0,0 +1,25 @@ + + + + + NSBluetoothAlwaysUsageDescription + EIS4 uses Bluetooth to communicate with the impedance analyzer + UIBackgroundModes + + bluetooth-central + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/cue-ios/CueIOS/Models/DataTypes.swift b/cue-ios/CueIOS/Models/DataTypes.swift new file mode 100644 index 0000000..c56c047 --- /dev/null +++ b/cue-ios/CueIOS/Models/DataTypes.swift @@ -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 +} diff --git a/cue-ios/CueIOS/Models/Protocol.swift b/cue-ios/CueIOS/Models/Protocol.swift new file mode 100644 index 0000000..61770ce --- /dev/null +++ b/cue-ios/CueIOS/Models/Protocol.swift @@ -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] +} diff --git a/cue-ios/CueIOS/Models/Storage.swift b/cue-ios/CueIOS/Models/Storage.swift new file mode 100644 index 0000000..655c9ae --- /dev/null +++ b/cue-ios/CueIOS/Models/Storage.swift @@ -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(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(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(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) + } +} diff --git a/cue-ios/CueIOS/Views/ContentView.swift b/cue-ios/CueIOS/Views/ContentView.swift new file mode 100644 index 0000000..62ee1e0 --- /dev/null +++ b/cue-ios/CueIOS/Views/ContentView.swift @@ -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 + } + } +} diff --git a/cue-ios/Package.swift b/cue-ios/Package.swift new file mode 100644 index 0000000..b310cf9 --- /dev/null +++ b/cue-ios/Package.swift @@ -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" + ), + ] +)