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