Compare commits
3 Commits
0ff998c82c
...
7570510491
| Author | SHA1 | Date |
|---|---|---|
|
|
7570510491 | |
|
|
596f641f7f | |
|
|
e12207b6e7 |
|
|
@ -1,5 +1,6 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
|
import Combine
|
||||||
|
|
||||||
enum Tab: String, CaseIterable, Identifiable {
|
enum Tab: String, CaseIterable, Identifiable {
|
||||||
case eis = "EIS"
|
case eis = "EIS"
|
||||||
|
|
@ -8,6 +9,7 @@ enum Tab: String, CaseIterable, Identifiable {
|
||||||
case chlorine = "Chlorine"
|
case chlorine = "Chlorine"
|
||||||
case ph = "pH"
|
case ph = "pH"
|
||||||
case sessions = "Sessions"
|
case sessions = "Sessions"
|
||||||
|
case connection = "Connection"
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
}
|
}
|
||||||
|
|
@ -16,6 +18,7 @@ enum Tab: String, CaseIterable, Identifiable {
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class AppState {
|
final class AppState {
|
||||||
|
let ble: BLEManager
|
||||||
var tab: Tab = .eis
|
var tab: Tab = .eis
|
||||||
var status: String = "Disconnected"
|
var status: String = "Disconnected"
|
||||||
var bleConnected: Bool = false
|
var bleConnected: Bool = false
|
||||||
|
|
@ -74,52 +77,255 @@ final class AppState {
|
||||||
// Device reference collection
|
// Device reference collection
|
||||||
var collectingRefs: Bool = false
|
var collectingRefs: Bool = false
|
||||||
var hasDeviceRefs: Bool = false
|
var hasDeviceRefs: Bool = false
|
||||||
|
private var eisRefs: [Int: [EisPoint]] = [:]
|
||||||
|
private var refMode: UInt8?
|
||||||
|
private var refRtia: UInt8?
|
||||||
|
|
||||||
|
// Session
|
||||||
|
var currentSessionId: Int64? = nil
|
||||||
|
|
||||||
// Clean
|
// Clean
|
||||||
var cleanV: String = "1200"
|
var cleanV: String = "1200"
|
||||||
var cleanDur: String = "30"
|
var cleanDur: String = "30"
|
||||||
|
|
||||||
|
// Temperature polling
|
||||||
|
private var tempTimer: Timer?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
ble = BLEManager()
|
||||||
|
ble.setMessageHandler { [weak self] msg in
|
||||||
|
self?.handleMessage(msg)
|
||||||
|
}
|
||||||
|
startTempPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BLE connection tracking
|
||||||
|
|
||||||
|
func updateConnectionState() {
|
||||||
|
let connected = ble.state == .connected
|
||||||
|
if connected != bleConnected {
|
||||||
|
bleConnected = connected
|
||||||
|
status = ble.state.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Temperature polling
|
||||||
|
|
||||||
|
private func startTempPolling() {
|
||||||
|
tempTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||||
|
guard let self, self.bleConnected else { return }
|
||||||
|
self.send(buildSysexGetTemp())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Send helper
|
||||||
|
|
||||||
|
func send(_ sysex: [UInt8]) {
|
||||||
|
ble.sendCommand(sysex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Message handler
|
||||||
|
|
||||||
|
private func handleMessage(_ msg: EisMessage) {
|
||||||
|
switch msg {
|
||||||
|
|
||||||
|
case .sweepStart(let numPoints, let freqStart, let freqStop):
|
||||||
|
if collectingRefs {
|
||||||
|
eisPoints.removeAll()
|
||||||
|
sweepTotal = numPoints
|
||||||
|
} else {
|
||||||
|
eisPoints.removeAll()
|
||||||
|
sweepTotal = numPoints
|
||||||
|
status = String(format: "Sweep: %d pts, %.0f--%.0f Hz", numPoints, freqStart, freqStop)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .dataPoint(_, let point):
|
||||||
|
eisPoints.append(point)
|
||||||
|
if !collectingRefs {
|
||||||
|
status = "Receiving: \(eisPoints.count)/\(sweepTotal)"
|
||||||
|
}
|
||||||
|
|
||||||
|
case .sweepEnd:
|
||||||
|
if collectingRefs {
|
||||||
|
if let r = refRtia, r < 8 {
|
||||||
|
eisRefs[Int(r)] = eisPoints
|
||||||
|
}
|
||||||
|
eisPoints.removeAll()
|
||||||
|
} else {
|
||||||
|
saveEis()
|
||||||
|
status = "Sweep complete: \(eisPoints.count) points"
|
||||||
|
}
|
||||||
|
|
||||||
|
case .config(let cfg):
|
||||||
|
freqStart = String(format: "%.0f", cfg.freqStart)
|
||||||
|
freqStop = String(format: "%.0f", cfg.freqStop)
|
||||||
|
ppd = "\(cfg.ppd)"
|
||||||
|
rtia = cfg.rtia
|
||||||
|
rcal = cfg.rcal
|
||||||
|
electrode = cfg.electrode
|
||||||
|
status = "Config received"
|
||||||
|
|
||||||
|
case .lsvStart(let numPoints, let vStart, let vStop):
|
||||||
|
lsvPoints.removeAll()
|
||||||
|
lsvTotal = numPoints
|
||||||
|
status = String(format: "LSV: %d pts, %.0f--%.0f mV", numPoints, vStart, vStop)
|
||||||
|
|
||||||
|
case .lsvPoint(_, let point):
|
||||||
|
lsvPoints.append(point)
|
||||||
|
status = "LSV: \(lsvPoints.count)/\(lsvTotal)"
|
||||||
|
|
||||||
|
case .lsvEnd:
|
||||||
|
saveLsv()
|
||||||
|
status = "LSV complete: \(lsvPoints.count) points"
|
||||||
|
|
||||||
|
case .ampStart(let vHold):
|
||||||
|
ampPoints.removeAll()
|
||||||
|
ampRunning = true
|
||||||
|
status = String(format: "Amp: %.0f mV", vHold)
|
||||||
|
|
||||||
|
case .ampPoint(let index, let point):
|
||||||
|
ampPoints.append(point)
|
||||||
|
ampTotal = index + 1
|
||||||
|
status = "Amp: \(ampPoints.count) pts"
|
||||||
|
|
||||||
|
case .ampEnd:
|
||||||
|
ampRunning = false
|
||||||
|
saveAmp()
|
||||||
|
status = "Amp complete: \(ampPoints.count) points"
|
||||||
|
|
||||||
|
case .clStart(let numPoints):
|
||||||
|
clPoints.removeAll()
|
||||||
|
clResult = nil
|
||||||
|
clTotal = numPoints
|
||||||
|
status = "Chlorine: \(numPoints) pts"
|
||||||
|
|
||||||
|
case .clPoint(_, let point):
|
||||||
|
clPoints.append(point)
|
||||||
|
status = "Chlorine: \(clPoints.count)/\(clTotal)"
|
||||||
|
|
||||||
|
case .clResult(let r):
|
||||||
|
clResult = r
|
||||||
|
status = String(format: "Chlorine: free=%.3f uA, total=%.3f uA", r.iFreeUa, r.iTotalUa)
|
||||||
|
|
||||||
|
case .clEnd:
|
||||||
|
saveCl()
|
||||||
|
status = "Chlorine complete: \(clPoints.count) points"
|
||||||
|
|
||||||
|
case .phResult(let r):
|
||||||
|
if collectingRefs {
|
||||||
|
phRef = r
|
||||||
|
} else {
|
||||||
|
savePh(r)
|
||||||
|
status = String(format: "pH: %.2f (OCP=%.1f mV, T=%.1fC)", r.ph, r.vOcpMv, r.tempC)
|
||||||
|
phResult = r
|
||||||
|
}
|
||||||
|
|
||||||
|
case .temperature(let t):
|
||||||
|
tempC = t
|
||||||
|
|
||||||
|
case .refFrame(let mode, let rtiaIdx):
|
||||||
|
refMode = mode
|
||||||
|
refRtia = rtiaIdx
|
||||||
|
let modeName: String
|
||||||
|
switch mode {
|
||||||
|
case 0: modeName = "EIS"
|
||||||
|
case 1: modeName = "LSV"
|
||||||
|
case 2: modeName = "Amp"
|
||||||
|
case 3: modeName = "Cl"
|
||||||
|
case 4: modeName = "pH"
|
||||||
|
default: modeName = "?"
|
||||||
|
}
|
||||||
|
if mode == 0 {
|
||||||
|
status = "Ref: \(modeName) RTIA \(rtiaIdx + 1)/8"
|
||||||
|
} else {
|
||||||
|
status = "Ref: \(modeName) range search"
|
||||||
|
}
|
||||||
|
|
||||||
|
case .refLpRange:
|
||||||
|
break
|
||||||
|
|
||||||
|
case .refsDone:
|
||||||
|
collectingRefs = false
|
||||||
|
hasDeviceRefs = true
|
||||||
|
refMode = nil
|
||||||
|
refRtia = nil
|
||||||
|
let idx = Int(rtia.rawValue)
|
||||||
|
if idx < 8, let pts = eisRefs[idx] {
|
||||||
|
eisRef = pts
|
||||||
|
}
|
||||||
|
status = "Reference collection complete"
|
||||||
|
|
||||||
|
case .refStatus(let hasRefs):
|
||||||
|
hasDeviceRefs = hasRefs
|
||||||
|
if !hasRefs {
|
||||||
|
status = "No device refs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
func applyEISSettings() {
|
func applyEISSettings() {
|
||||||
let fs = Float(freqStart) ?? 1000
|
let fs = Float(freqStart) ?? 1000
|
||||||
let fe = Float(freqStop) ?? 200000
|
let fe = Float(freqStop) ?? 200000
|
||||||
let p = UInt16(ppd) ?? 10
|
let p = UInt16(ppd) ?? 10
|
||||||
status = "Applying: \(fs)-\(fe) Hz, \(p) PPD"
|
send(buildSysexSetSweep(freqStart: fs, freqStop: fe, ppd: p))
|
||||||
|
send(buildSysexSetRtia(rtia))
|
||||||
|
send(buildSysexSetRcal(rcal))
|
||||||
|
send(buildSysexSetElectrode(electrode))
|
||||||
|
send(buildSysexGetConfig())
|
||||||
}
|
}
|
||||||
|
|
||||||
func startSweep() {
|
func startSweep() {
|
||||||
eisPoints.removeAll()
|
eisPoints.removeAll()
|
||||||
status = "Starting sweep..."
|
send(buildSysexGetTemp())
|
||||||
|
send(buildSysexStartSweep())
|
||||||
}
|
}
|
||||||
|
|
||||||
func startLSV() {
|
func startLSV() {
|
||||||
lsvPoints.removeAll()
|
lsvPoints.removeAll()
|
||||||
let vs = Float(lsvStartV) ?? 0
|
let vs = Float(lsvStartV) ?? 0
|
||||||
let ve = Float(lsvStopV) ?? 500
|
let ve = Float(lsvStopV) ?? 500
|
||||||
status = "Starting LSV: \(vs)-\(ve) mV"
|
let sr = Float(lsvScanRate) ?? 50
|
||||||
|
send(buildSysexGetTemp())
|
||||||
|
send(buildSysexStartLsv(vStart: vs, vStop: ve, scanRate: sr, lpRtia: lsvRtia))
|
||||||
}
|
}
|
||||||
|
|
||||||
func startAmp() {
|
func startAmp() {
|
||||||
ampPoints.removeAll()
|
ampPoints.removeAll()
|
||||||
ampRunning = true
|
ampRunning = true
|
||||||
status = "Starting amperometry..."
|
let vh = Float(ampVHold) ?? 200
|
||||||
|
let iv = Float(ampInterval) ?? 100
|
||||||
|
let dur = Float(ampDuration) ?? 60
|
||||||
|
send(buildSysexGetTemp())
|
||||||
|
send(buildSysexStartAmp(vHold: vh, intervalMs: iv, durationS: dur, lpRtia: ampRtia))
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopAmp() {
|
func stopAmp() {
|
||||||
ampRunning = false
|
send(buildSysexStopAmp())
|
||||||
status = "Stopping amperometry..."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func startChlorine() {
|
func startChlorine() {
|
||||||
clPoints.removeAll()
|
clPoints.removeAll()
|
||||||
clResult = nil
|
clResult = nil
|
||||||
status = "Starting chlorine measurement..."
|
let vCond = Float(clCondV) ?? 800
|
||||||
|
let tCond = Float(clCondT) ?? 2000
|
||||||
|
let vFree = Float(clFreeV) ?? 100
|
||||||
|
let vTotal = Float(clTotalV) ?? -200
|
||||||
|
let tDep = Float(clDepT) ?? 5000
|
||||||
|
let tMeas = Float(clMeasT) ?? 5000
|
||||||
|
send(buildSysexGetTemp())
|
||||||
|
send(buildSysexStartCl(
|
||||||
|
vCond: vCond, tCondMs: tCond, vFree: vFree, vTotal: vTotal,
|
||||||
|
tDepMs: tDep, tMeasMs: tMeas, lpRtia: clRtia
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func startPh() {
|
func startPh() {
|
||||||
phResult = nil
|
phResult = nil
|
||||||
status = "Starting pH measurement..."
|
let stab = Float(phStabilize) ?? 30
|
||||||
|
send(buildSysexGetTemp())
|
||||||
|
send(buildSysexStartPh(stabilizeS: stab))
|
||||||
}
|
}
|
||||||
|
|
||||||
func setReference() {
|
func setReference() {
|
||||||
|
|
@ -155,29 +361,37 @@ final class AppState {
|
||||||
case .amp: ampRef = nil; status = "Amp reference cleared"
|
case .amp: ampRef = nil; status = "Amp reference cleared"
|
||||||
case .chlorine: clRef = nil; status = "Chlorine reference cleared"
|
case .chlorine: clRef = nil; status = "Chlorine reference cleared"
|
||||||
case .ph: phRef = nil; status = "pH reference cleared"
|
case .ph: phRef = nil; status = "pH reference cleared"
|
||||||
case .sessions: break
|
case .sessions, .connection: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectRefs() {
|
func collectRefs() {
|
||||||
collectingRefs = true
|
collectingRefs = true
|
||||||
|
eisRefs.removeAll()
|
||||||
status = "Starting reference collection..."
|
status = "Starting reference collection..."
|
||||||
|
send(buildSysexStartRefs())
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRefs() {
|
||||||
|
collectingRefs = true
|
||||||
|
eisRefs.removeAll()
|
||||||
|
send(buildSysexGetRefs())
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearRefs() {
|
func clearRefs() {
|
||||||
collectingRefs = false
|
collectingRefs = false
|
||||||
hasDeviceRefs = false
|
hasDeviceRefs = false
|
||||||
|
eisRefs.removeAll()
|
||||||
eisRef = nil
|
eisRef = nil
|
||||||
lsvRef = nil
|
|
||||||
ampRef = nil
|
|
||||||
clRef = nil
|
|
||||||
phRef = nil
|
phRef = nil
|
||||||
|
send(buildSysexClearRefs())
|
||||||
status = "Refs cleared"
|
status = "Refs cleared"
|
||||||
}
|
}
|
||||||
|
|
||||||
func startClean() {
|
func startClean() {
|
||||||
let v = Float(cleanV) ?? 1200
|
let v = Float(cleanV) ?? 1200
|
||||||
let d = Float(cleanDur) ?? 30
|
let d = Float(cleanDur) ?? 30
|
||||||
|
send(buildSysexStartClean(vMv: v, durationS: d))
|
||||||
status = String(format: "Cleaning: %.0f mV for %.0fs", v, d)
|
status = String(format: "Cleaning: %.0f mV for %.0fs", v, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,7 +402,7 @@ final class AppState {
|
||||||
case .amp: ampRef != nil
|
case .amp: ampRef != nil
|
||||||
case .chlorine: clRef != nil
|
case .chlorine: clRef != nil
|
||||||
case .ph: phRef != nil
|
case .ph: phRef != nil
|
||||||
case .sessions: false
|
case .sessions, .connection: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,10 +413,97 @@ final class AppState {
|
||||||
case .amp: !ampPoints.isEmpty
|
case .amp: !ampPoints.isEmpty
|
||||||
case .chlorine: clResult != nil
|
case .chlorine: clResult != nil
|
||||||
case .ph: phResult != nil
|
case .ph: phResult != nil
|
||||||
case .sessions: false
|
case .sessions, .connection: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Auto-save
|
||||||
|
|
||||||
|
private func saveEis() {
|
||||||
|
guard let sid = currentSessionId else { return }
|
||||||
|
let params: [String: String] = [
|
||||||
|
"freq_start": freqStart,
|
||||||
|
"freq_stop": freqStop,
|
||||||
|
"ppd": ppd,
|
||||||
|
"rtia": rtia.label,
|
||||||
|
"rcal": rcal.label,
|
||||||
|
"electrode": electrode.label,
|
||||||
|
]
|
||||||
|
guard let configData = try? JSONEncoder().encode(params) else { return }
|
||||||
|
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .eis) else { return }
|
||||||
|
meas.config = configData
|
||||||
|
guard let mid = meas.id else { return }
|
||||||
|
let indexed = eisPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
||||||
|
try? Storage.shared.addDataPoints(measurementId: mid, points: indexed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveLsv() {
|
||||||
|
guard let sid = currentSessionId else { return }
|
||||||
|
let params: [String: String] = [
|
||||||
|
"v_start": lsvStartV,
|
||||||
|
"v_stop": lsvStopV,
|
||||||
|
"scan_rate": lsvScanRate,
|
||||||
|
"rtia": lsvRtia.label,
|
||||||
|
]
|
||||||
|
guard let configData = try? JSONEncoder().encode(params) else { return }
|
||||||
|
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .lsv) else { return }
|
||||||
|
meas.config = configData
|
||||||
|
guard let mid = meas.id else { return }
|
||||||
|
let indexed = lsvPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
||||||
|
try? Storage.shared.addDataPoints(measurementId: mid, points: indexed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveAmp() {
|
||||||
|
guard let sid = currentSessionId else { return }
|
||||||
|
let params: [String: String] = [
|
||||||
|
"v_hold": ampVHold,
|
||||||
|
"interval_ms": ampInterval,
|
||||||
|
"duration_s": ampDuration,
|
||||||
|
"rtia": ampRtia.label,
|
||||||
|
]
|
||||||
|
guard let configData = try? JSONEncoder().encode(params) else { return }
|
||||||
|
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .amp) else { return }
|
||||||
|
meas.config = configData
|
||||||
|
guard let mid = meas.id else { return }
|
||||||
|
let indexed = ampPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
||||||
|
try? Storage.shared.addDataPoints(measurementId: mid, points: indexed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveCl() {
|
||||||
|
guard let sid = currentSessionId else { return }
|
||||||
|
let params: [String: String] = [
|
||||||
|
"cond_v": clCondV,
|
||||||
|
"cond_t": clCondT,
|
||||||
|
"free_v": clFreeV,
|
||||||
|
"total_v": clTotalV,
|
||||||
|
"dep_t": clDepT,
|
||||||
|
"meas_t": clMeasT,
|
||||||
|
"rtia": clRtia.label,
|
||||||
|
]
|
||||||
|
guard let configData = try? JSONEncoder().encode(params) else { return }
|
||||||
|
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .chlorine) else { return }
|
||||||
|
meas.config = configData
|
||||||
|
guard let mid = meas.id else { return }
|
||||||
|
let indexed = clPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
||||||
|
try? Storage.shared.addDataPoints(measurementId: mid, points: indexed)
|
||||||
|
if let r = clResult {
|
||||||
|
try? Storage.shared.setMeasurementResult(mid, result: r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func savePh(_ result: PhResult) {
|
||||||
|
guard let sid = currentSessionId else { return }
|
||||||
|
let params: [String: String] = [
|
||||||
|
"stabilize_s": phStabilize,
|
||||||
|
]
|
||||||
|
guard let configData = try? JSONEncoder().encode(params) else { return }
|
||||||
|
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .ph) else { return }
|
||||||
|
meas.config = configData
|
||||||
|
guard let mid = meas.id else { return }
|
||||||
|
try? Storage.shared.addDataPoint(measurementId: mid, index: 0, point: result)
|
||||||
|
try? Storage.shared.setMeasurementResult(mid, result: result)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Measurement loading
|
// MARK: - Measurement loading
|
||||||
|
|
||||||
func loadMeasurement(_ measurement: Measurement) {
|
func loadMeasurement(_ measurement: Measurement) {
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 334 KiB |
|
|
@ -8,8 +8,8 @@ import Foundation
|
||||||
@Observable
|
@Observable
|
||||||
final class BLEManager: NSObject {
|
final class BLEManager: NSObject {
|
||||||
|
|
||||||
static let midiServiceUUID = CBUUID(string: "03B80E5A-EDE8-4B33-A751-6CE34EC4C700")
|
nonisolated(unsafe) static let midiServiceUUID = CBUUID(string: "03B80E5A-EDE8-4B33-A751-6CE34EC4C700")
|
||||||
static let midiCharUUID = CBUUID(string: "7772E5DB-3868-4112-A1A9-F2669D106BF3")
|
nonisolated(unsafe) static let midiCharUUID = CBUUID(string: "7772E5DB-3868-4112-A1A9-F2669D106BF3")
|
||||||
|
|
||||||
enum ConnectionState: String {
|
enum ConnectionState: String {
|
||||||
case disconnected = "Disconnected"
|
case disconnected = "Disconnected"
|
||||||
|
|
@ -18,11 +18,20 @@ final class BLEManager: NSObject {
|
||||||
case connected = "Connected"
|
case connected = "Connected"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DiscoveredDevice: Identifiable {
|
||||||
|
let id: UUID
|
||||||
|
let peripheral: CBPeripheral
|
||||||
|
let name: String
|
||||||
|
let rssi: Int
|
||||||
|
var serviceUUIDs: [CBUUID]
|
||||||
|
}
|
||||||
|
|
||||||
var state: ConnectionState = .disconnected
|
var state: ConnectionState = .disconnected
|
||||||
var lastMessage: EisMessage?
|
var lastMessage: EisMessage?
|
||||||
|
var discoveredDevices: [DiscoveredDevice] = []
|
||||||
|
|
||||||
private var centralManager: CBCentralManager!
|
private var centralManager: CBCentralManager!
|
||||||
private var peripheral: CBPeripheral?
|
private(set) var peripheral: CBPeripheral?
|
||||||
private var midiCharacteristic: CBCharacteristic?
|
private var midiCharacteristic: CBCharacteristic?
|
||||||
private var onMessage: ((EisMessage) -> Void)?
|
private var onMessage: ((EisMessage) -> Void)?
|
||||||
|
|
||||||
|
|
@ -36,14 +45,32 @@ final class BLEManager: NSObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func startScanning() {
|
func startScanning() {
|
||||||
guard centralManager.state == .poweredOn else { return }
|
guard centralManager.state == .poweredOn else {
|
||||||
|
print("[BLE] can't scan, state: \(centralManager.state.rawValue)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
print("[BLE] starting scan (no filter)")
|
||||||
state = .scanning
|
state = .scanning
|
||||||
|
discoveredDevices.removeAll()
|
||||||
centralManager.scanForPeripherals(
|
centralManager.scanForPeripherals(
|
||||||
withServices: [Self.midiServiceUUID],
|
withServices: nil,
|
||||||
options: nil
|
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stopScanning() {
|
||||||
|
centralManager.stopScan()
|
||||||
|
if state == .scanning { state = .disconnected }
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectTo(_ device: DiscoveredDevice) {
|
||||||
|
centralManager.stopScan()
|
||||||
|
peripheral = device.peripheral
|
||||||
|
state = .connecting
|
||||||
|
print("[BLE] connecting to \(device.name)")
|
||||||
|
centralManager.connect(device.peripheral, options: nil)
|
||||||
|
}
|
||||||
|
|
||||||
func disconnect() {
|
func disconnect() {
|
||||||
if let p = peripheral {
|
if let p = peripheral {
|
||||||
centralManager.cancelPeripheralConnection(p)
|
centralManager.cancelPeripheralConnection(p)
|
||||||
|
|
@ -105,6 +132,7 @@ final class BLEManager: NSObject {
|
||||||
extension BLEManager: CBCentralManagerDelegate {
|
extension BLEManager: CBCentralManagerDelegate {
|
||||||
|
|
||||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||||
|
print("[BLE] centralManager state: \(central.state.rawValue)")
|
||||||
if central.state == .poweredOn {
|
if central.state == .poweredOn {
|
||||||
startScanning()
|
startScanning()
|
||||||
}
|
}
|
||||||
|
|
@ -116,11 +144,21 @@ extension BLEManager: CBCentralManagerDelegate {
|
||||||
advertisementData: [String: Any],
|
advertisementData: [String: Any],
|
||||||
rssi RSSI: NSNumber
|
rssi RSSI: NSNumber
|
||||||
) {
|
) {
|
||||||
guard peripheral.name == "EIS4" else { return }
|
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "Unknown"
|
||||||
central.stopScan()
|
let svcUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
|
||||||
self.peripheral = peripheral
|
|
||||||
state = .connecting
|
print("[BLE] found: \(name) rssi:\(RSSI) services:\(svcUUIDs)")
|
||||||
central.connect(peripheral, options: nil)
|
|
||||||
|
if discoveredDevices.contains(where: { $0.id == peripheral.identifier }) { return }
|
||||||
|
|
||||||
|
let device = DiscoveredDevice(
|
||||||
|
id: peripheral.identifier,
|
||||||
|
peripheral: peripheral,
|
||||||
|
name: name,
|
||||||
|
rssi: RSSI.intValue,
|
||||||
|
serviceUUIDs: svcUUIDs
|
||||||
|
)
|
||||||
|
discoveredDevices.append(device)
|
||||||
}
|
}
|
||||||
|
|
||||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,14 @@ import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct CueIOSApp: App {
|
struct CueIOSApp: App {
|
||||||
@State private var ble = BLEManager()
|
@State private var state = AppState()
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView(state: state)
|
||||||
|
.onReceive(Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()) { _ in
|
||||||
|
state.updateConnectionState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,18 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
<string>EIS4 uses Bluetooth to communicate with the impedance analyzer</string>
|
<string>EIS4 uses Bluetooth to communicate with the impedance analyzer</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ struct DataPoint: Codable, FetchableRecord, MutablePersistableRecord {
|
||||||
|
|
||||||
// MARK: - Database manager
|
// MARK: - Database manager
|
||||||
|
|
||||||
final class Storage {
|
final class Storage: @unchecked Sendable {
|
||||||
static let shared = Storage()
|
static let shared = Storage()
|
||||||
|
|
||||||
private let dbQueue: DatabaseQueue
|
private let dbQueue: DatabaseQueue
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ConnectionView: View {
|
||||||
|
var state: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
Divider()
|
||||||
|
deviceList
|
||||||
|
}
|
||||||
|
.navigationTitle("Connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack {
|
||||||
|
Circle()
|
||||||
|
.fill(statusColor)
|
||||||
|
.frame(width: 10, height: 10)
|
||||||
|
Text(state.ble.state.rawValue)
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
if state.ble.state == .connected {
|
||||||
|
Button("Disconnect") { state.ble.disconnect() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.red)
|
||||||
|
} else if state.ble.state == .scanning {
|
||||||
|
Button("Stop") { state.ble.stopScanning() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
} else {
|
||||||
|
Button("Scan") { state.ble.startScanning() }
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusColor: Color {
|
||||||
|
switch state.ble.state {
|
||||||
|
case .connected: .green
|
||||||
|
case .scanning: .orange
|
||||||
|
case .connecting: .yellow
|
||||||
|
case .disconnected: .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deviceList: some View {
|
||||||
|
List {
|
||||||
|
if state.ble.discoveredDevices.isEmpty && state.ble.state == .scanning {
|
||||||
|
HStack {
|
||||||
|
ProgressView()
|
||||||
|
Text("Scanning for devices...")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(state.ble.discoveredDevices) { device in
|
||||||
|
Button {
|
||||||
|
state.ble.connectTo(device)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(device.name)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
if !device.serviceUUIDs.isEmpty {
|
||||||
|
Text(device.serviceUUIDs.map(\.uuidString).joined(separator: ", "))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text("\(device.rssi) dBm")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if device.name == "EIS4" {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(state.ble.state == .connecting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@State private var state = AppState()
|
@Bindable var state: AppState
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
@ -40,6 +40,7 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
Section("Data") {
|
Section("Data") {
|
||||||
sidebarButton(.sessions, "Sessions", "folder")
|
sidebarButton(.sessions, "Sessions", "folder")
|
||||||
|
sidebarButton(.connection, "Connection", "antenna.radiowaves.left.and.right")
|
||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
cleanControls
|
cleanControls
|
||||||
|
|
@ -126,6 +127,10 @@ struct ContentView: View {
|
||||||
SessionView(state: state)
|
SessionView(state: state)
|
||||||
.tabItem { Label("Sessions", systemImage: "folder") }
|
.tabItem { Label("Sessions", systemImage: "folder") }
|
||||||
.tag(Tab.sessions)
|
.tag(Tab.sessions)
|
||||||
|
|
||||||
|
ConnectionView(state: state)
|
||||||
|
.tabItem { Label("Connection", systemImage: "antenna.radiowaves.left.and.right") }
|
||||||
|
.tag(Tab.connection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,6 +145,7 @@ struct ContentView: View {
|
||||||
case .chlorine: ChlorineView(state: state)
|
case .chlorine: ChlorineView(state: state)
|
||||||
case .ph: PhView(state: state)
|
case .ph: PhView(state: state)
|
||||||
case .sessions: SessionView(state: state)
|
case .sessions: SessionView(state: state)
|
||||||
|
case .connection: ConnectionView(state: state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
36
main/ble.c
36
main/ble.c
|
|
@ -30,7 +30,7 @@ static const ble_uuid128_t midi_chr_uuid = BLE_UUID128_INIT(
|
||||||
0xf3, 0x6b, 0x10, 0x9d, 0x66, 0xf2, 0xa9, 0xa1,
|
0xf3, 0x6b, 0x10, 0x9d, 0x66, 0xf2, 0xa9, 0xa1,
|
||||||
0x12, 0x41, 0x68, 0x38, 0xdb, 0xe5, 0x72, 0x77);
|
0x12, 0x41, 0x68, 0x38, 0xdb, 0xe5, 0x72, 0x77);
|
||||||
|
|
||||||
#define MAX_CONNECTIONS 2
|
#define MAX_CONNECTIONS 4
|
||||||
|
|
||||||
static EventGroupHandle_t ble_events;
|
static EventGroupHandle_t ble_events;
|
||||||
static QueueHandle_t cmd_queue;
|
static QueueHandle_t cmd_queue;
|
||||||
|
|
@ -523,8 +523,13 @@ static int gap_event_cb(struct ble_gap_event *event, void *arg)
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void start_adv(void)
|
static void adv_task(void *param)
|
||||||
{
|
{
|
||||||
|
(void)param;
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(200));
|
||||||
|
|
||||||
|
ble_gap_adv_stop();
|
||||||
|
|
||||||
struct ble_hs_adv_fields fields = {0};
|
struct ble_hs_adv_fields fields = {0};
|
||||||
fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
|
fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
|
||||||
fields.name = (uint8_t *)DEVICE_NAME;
|
fields.name = (uint8_t *)DEVICE_NAME;
|
||||||
|
|
@ -539,19 +544,38 @@ static void start_adv(void)
|
||||||
fields.uuids16 = adv_uuids;
|
fields.uuids16 = adv_uuids;
|
||||||
fields.num_uuids16 = 2;
|
fields.num_uuids16 = 2;
|
||||||
fields.uuids16_is_complete = 0;
|
fields.uuids16_is_complete = 0;
|
||||||
ble_gap_adv_set_fields(&fields);
|
|
||||||
|
int rc = ble_gap_adv_set_fields(&fields);
|
||||||
|
if (rc) { printf("BLE: set_fields failed: %d\n", rc); goto done; }
|
||||||
|
|
||||||
struct ble_hs_adv_fields rsp = {0};
|
struct ble_hs_adv_fields rsp = {0};
|
||||||
rsp.uuids128 = (ble_uuid128_t *)&midi_svc_uuid;
|
rsp.uuids128 = (ble_uuid128_t *)&midi_svc_uuid;
|
||||||
rsp.num_uuids128 = 1;
|
rsp.num_uuids128 = 1;
|
||||||
rsp.uuids128_is_complete = 1;
|
rsp.uuids128_is_complete = 1;
|
||||||
ble_gap_adv_rsp_set_fields(&rsp);
|
|
||||||
|
rc = ble_gap_adv_rsp_set_fields(&rsp);
|
||||||
|
if (rc) { printf("BLE: set_rsp failed: %d\n", rc); goto done; }
|
||||||
|
|
||||||
struct ble_gap_adv_params params = {0};
|
struct ble_gap_adv_params params = {0};
|
||||||
params.conn_mode = BLE_GAP_CONN_MODE_UND;
|
params.conn_mode = BLE_GAP_CONN_MODE_UND;
|
||||||
params.disc_mode = BLE_GAP_DISC_MODE_GEN;
|
params.disc_mode = BLE_GAP_DISC_MODE_GEN;
|
||||||
ble_gap_adv_start(BLE_OWN_ADDR_PUBLIC, NULL, BLE_HS_FOREVER,
|
params.itvl_min = BLE_GAP_ADV_FAST_INTERVAL1_MIN;
|
||||||
¶ms, gap_event_cb, NULL);
|
params.itvl_max = BLE_GAP_ADV_FAST_INTERVAL1_MAX;
|
||||||
|
|
||||||
|
rc = ble_gap_adv_start(BLE_OWN_ADDR_PUBLIC, NULL, BLE_HS_FOREVER,
|
||||||
|
¶ms, gap_event_cb, NULL);
|
||||||
|
if (rc)
|
||||||
|
printf("BLE: adv_start failed: %d\n", rc);
|
||||||
|
else
|
||||||
|
printf("BLE: advertising (%d connected)\n", conn_count);
|
||||||
|
|
||||||
|
done:
|
||||||
|
vTaskDelete(NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void start_adv(void)
|
||||||
|
{
|
||||||
|
xTaskCreate(adv_task, "adv", 2048, NULL, 5, NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void on_sync(void)
|
static void on_sync(void)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
CONFIG_IDF_TARGET="esp32s3"
|
CONFIG_IDF_TARGET="esp32s3"
|
||||||
CONFIG_BT_ENABLED=y
|
CONFIG_BT_ENABLED=y
|
||||||
CONFIG_BT_NIMBLE_ENABLED=y
|
CONFIG_BT_NIMBLE_ENABLED=y
|
||||||
CONFIG_BT_NIMBLE_MAX_CONNECTIONS=2
|
CONFIG_BT_NIMBLE_MAX_CONNECTIONS=4
|
||||||
CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=y
|
CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=y
|
||||||
CONFIG_BT_NIMBLE_ROLE_CENTRAL=y
|
CONFIG_BT_NIMBLE_ROLE_CENTRAL=y
|
||||||
CONFIG_BT_NIMBLE_ROLE_OBSERVER=n
|
CONFIG_BT_NIMBLE_ROLE_OBSERVER=n
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue