From bb894b42beda09bbd4a988cc5f6400dbb600bc1b Mon Sep 17 00:00:00 2001 From: jess Date: Fri, 3 Apr 2026 06:59:54 -0700 Subject: [PATCH 01/11] add encode_u32/decode_u32 to firmware protocol --- main/protocol.c | 23 +++++++++++++++++++++++ main/protocol.h | 4 +++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/main/protocol.c b/main/protocol.c index 8e53997..81d431c 100644 --- a/main/protocol.c +++ b/main/protocol.c @@ -35,6 +35,29 @@ static void encode_u16(uint16_t val, uint8_t *out) out[2] = p[1] & 0x7F; } +void encode_u32(uint32_t val, uint8_t *out) +{ + uint8_t *p = (uint8_t *)&val; + out[0] = ((p[0] >> 7) & 1) | ((p[1] >> 6) & 2) | + ((p[2] >> 5) & 4) | ((p[3] >> 4) & 8); + out[1] = p[0] & 0x7F; + out[2] = p[1] & 0x7F; + out[3] = p[2] & 0x7F; + out[4] = p[3] & 0x7F; +} + +uint32_t decode_u32(const uint8_t *d) +{ + uint8_t b[4]; + b[0] = d[1] | ((d[0] & 1) << 7); + b[1] = d[2] | ((d[0] & 2) << 6); + b[2] = d[3] | ((d[0] & 4) << 5); + b[3] = d[4] | ((d[0] & 8) << 4); + uint32_t v; + memcpy(&v, b, 4); + return v; +} + float decode_float(const uint8_t *d) { uint8_t b[4]; diff --git a/main/protocol.h b/main/protocol.h index 77dece0..c040c3d 100644 --- a/main/protocol.h +++ b/main/protocol.h @@ -106,7 +106,9 @@ int protocol_init(void); int protocol_recv_command(Command *cmd, uint32_t timeout_ms); void protocol_push_command(const Command *cmd); -/* 7-bit decode helpers */ +/* 7-bit encode/decode helpers */ +void encode_u32(uint32_t val, uint8_t *out); +uint32_t decode_u32(const uint8_t *d); float decode_float(const uint8_t *d); uint16_t decode_u16(const uint8_t *d); From 80dc8ef56123dd80251698cc8f077a675d33c16f Mon Sep 17 00:00:00 2001 From: jess Date: Fri, 3 Apr 2026 07:01:30 -0700 Subject: [PATCH 02/11] append esp timestamp and measurement ID to all START messages --- main/eis4.c | 33 +++++++++++++++++++++++++++------ main/protocol.c | 33 +++++++++++++++++++++++---------- main/protocol.h | 13 ++++++++----- 3 files changed, 58 insertions(+), 21 deletions(-) diff --git a/main/eis4.c b/main/eis4.c index 22bd4fb..3c30ced 100644 --- a/main/eis4.c +++ b/main/eis4.c @@ -12,9 +12,11 @@ #include "nvs_flash.h" #include "esp_netif.h" #include "esp_event.h" +#include "esp_timer.h" #define AD5941_EXPECTED_ADIID 0x4144 static EISConfig cfg; +static uint16_t measurement_counter = 0; static EISPoint results[EIS_MAX_POINTS]; static LSVPoint lsv_results[ECHEM_MAX_POINTS]; static AmpPoint amp_results[ECHEM_MAX_POINTS]; @@ -25,8 +27,10 @@ static void do_sweep(void) { eis_init(&cfg); + uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000); + measurement_counter++; uint32_t n = eis_calc_num_points(&cfg); - send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz); + send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz, ts_ms, measurement_counter); int got = eis_sweep(results, n, send_eis_point); printf("Sweep complete: %d points\n", got); send_sweep_end(); @@ -132,8 +136,10 @@ void app_main(void) lsv_cfg.v_start, lsv_cfg.v_stop, lsv_cfg.scan_rate, lsv_cfg.lp_rtia, (unsigned long)max_pts); + uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000); + measurement_counter++; uint32_t n = echem_lsv_calc_steps(&lsv_cfg, max_pts); - send_lsv_start(n, lsv_cfg.v_start, lsv_cfg.v_stop); + send_lsv_start(n, lsv_cfg.v_start, lsv_cfg.v_stop, ts_ms, measurement_counter); int got = echem_lsv(&lsv_cfg, lsv_results, max_pts, send_lsv_point); printf("LSV complete: %d points\n", got); send_lsv_end(); @@ -149,7 +155,11 @@ void app_main(void) printf("Amp: %.0f mV, %.0f ms interval, %.0f s\n", amp_cfg.v_hold, amp_cfg.interval_ms, amp_cfg.duration_s); - send_amp_start(amp_cfg.v_hold); + { + uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000); + measurement_counter++; + send_amp_start(amp_cfg.v_hold, ts_ms, measurement_counter); + } int got = echem_amp(&_cfg, amp_results, ECHEM_MAX_POINTS, send_amp_point); printf("Amp complete: %d points\n", got); send_amp_end(); @@ -171,7 +181,12 @@ void app_main(void) echem_ph_ocp(&ph_cfg, &ph_result); printf("pH: OCP=%.1f mV, pH=%.2f\n", ph_result.v_ocp_mv, ph_result.ph); - send_ph_result(ph_result.v_ocp_mv, ph_result.ph, ph_result.temp_c); + { + uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000); + measurement_counter++; + send_ph_result(ph_result.v_ocp_mv, ph_result.ph, ph_result.temp_c, + ts_ms, measurement_counter); + } break; } @@ -197,8 +212,10 @@ void app_main(void) case CMD_OPEN_CAL: { printf("Open-circuit cal starting\n"); eis_init(&cfg); + uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000); + measurement_counter++; uint32_t n = eis_calc_num_points(&cfg); - send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz); + send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz, ts_ms, measurement_counter); int got = eis_open_cal(results, n, send_eis_point); printf("Open-circuit cal: %d points\n", got); send_sweep_end(); @@ -252,7 +269,11 @@ void app_main(void) uint32_t n_per = (uint32_t)(cl_cfg.t_meas_ms / 50.0f + 0.5f); if (n_per < 2) n_per = 2; - send_cl_start(2 * n_per); + { + uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000); + measurement_counter++; + send_cl_start(2 * n_per, ts_ms, measurement_counter); + } ClResult cl_result; int got = echem_chlorine(&cl_cfg, cl_results, ECHEM_MAX_POINTS, &cl_result, send_cl_point); diff --git a/main/protocol.c b/main/protocol.c index 81d431c..17069f7 100644 --- a/main/protocol.c +++ b/main/protocol.c @@ -177,14 +177,17 @@ int send_keepalive(void) /* ---- outbound: EIS ---- */ -int send_sweep_start(uint32_t num_points, float freq_start, float freq_stop) +int send_sweep_start(uint32_t num_points, float freq_start, float freq_stop, + uint32_t ts_ms, uint16_t meas_id) { - uint8_t sx[20]; + uint8_t sx[28]; uint16_t p = 0; sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_SWEEP_START; encode_u16((uint16_t)num_points, &sx[p]); p += 3; encode_float(freq_start, &sx[p]); p += 5; encode_float(freq_stop, &sx[p]); p += 5; + encode_u32(ts_ms, &sx[p]); p += 5; + encode_u16(meas_id, &sx[p]); p += 3; sx[p++] = 0xF7; return send_sysex(sx, p); } @@ -232,14 +235,17 @@ int send_config(const EISConfig *cfg) /* ---- outbound: LSV ---- */ -int send_lsv_start(uint32_t num_points, float v_start, float v_stop) +int send_lsv_start(uint32_t num_points, float v_start, float v_stop, + uint32_t ts_ms, uint16_t meas_id) { - uint8_t sx[20]; + uint8_t sx[28]; uint16_t p = 0; sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_LSV_START; encode_u16((uint16_t)num_points, &sx[p]); p += 3; encode_float(v_start, &sx[p]); p += 5; encode_float(v_stop, &sx[p]); p += 5; + encode_u32(ts_ms, &sx[p]); p += 5; + encode_u16(meas_id, &sx[p]); p += 3; sx[p++] = 0xF7; return send_sysex(sx, p); } @@ -264,12 +270,14 @@ int send_lsv_end(void) /* ---- outbound: Amperometry ---- */ -int send_amp_start(float v_hold) +int send_amp_start(float v_hold, uint32_t ts_ms, uint16_t meas_id) { - uint8_t sx[12]; + uint8_t sx[20]; uint16_t p = 0; sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_AMP_START; encode_float(v_hold, &sx[p]); p += 5; + encode_u32(ts_ms, &sx[p]); p += 5; + encode_u16(meas_id, &sx[p]); p += 3; sx[p++] = 0xF7; return send_sysex(sx, p); } @@ -294,12 +302,14 @@ int send_amp_end(void) /* ---- outbound: Chlorine ---- */ -int send_cl_start(uint32_t num_points) +int send_cl_start(uint32_t num_points, uint32_t ts_ms, uint16_t meas_id) { - uint8_t sx[10]; + uint8_t sx[18]; uint16_t p = 0; sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_CL_START; encode_u16((uint16_t)num_points, &sx[p]); p += 3; + encode_u32(ts_ms, &sx[p]); p += 5; + encode_u16(meas_id, &sx[p]); p += 3; sx[p++] = 0xF7; return send_sysex(sx, p); } @@ -349,14 +359,17 @@ int send_ph_cal(float slope, float offset) /* ---- outbound: pH ---- */ -int send_ph_result(float v_ocp_mv, float ph, float temp_c) +int send_ph_result(float v_ocp_mv, float ph, float temp_c, + uint32_t ts_ms, uint16_t meas_id) { - uint8_t sx[20]; + uint8_t sx[28]; uint16_t p = 0; sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_PH_RESULT; encode_float(v_ocp_mv, &sx[p]); p += 5; encode_float(ph, &sx[p]); p += 5; encode_float(temp_c, &sx[p]); p += 5; + encode_u32(ts_ms, &sx[p]); p += 5; + encode_u16(meas_id, &sx[p]); p += 3; sx[p++] = 0xF7; return send_sysex(sx, p); } diff --git a/main/protocol.h b/main/protocol.h index c040c3d..f13134c 100644 --- a/main/protocol.h +++ b/main/protocol.h @@ -113,29 +113,32 @@ float decode_float(const uint8_t *d); uint16_t decode_u16(const uint8_t *d); /* outbound: EIS */ -int send_sweep_start(uint32_t num_points, float freq_start, float freq_stop); +int send_sweep_start(uint32_t num_points, float freq_start, float freq_stop, + uint32_t ts_ms, uint16_t meas_id); int send_eis_point(uint16_t index, const EISPoint *pt); int send_sweep_end(void); int send_config(const EISConfig *cfg); /* outbound: LSV */ -int send_lsv_start(uint32_t num_points, float v_start, float v_stop); +int send_lsv_start(uint32_t num_points, float v_start, float v_stop, + uint32_t ts_ms, uint16_t meas_id); int send_lsv_point(uint16_t index, float v_mv, float i_ua); int send_lsv_end(void); /* outbound: Amperometry */ -int send_amp_start(float v_hold); +int send_amp_start(float v_hold, uint32_t ts_ms, uint16_t meas_id); int send_amp_point(uint16_t index, float t_ms, float i_ua); int send_amp_end(void); /* outbound: Chlorine */ -int send_cl_start(uint32_t num_points); +int send_cl_start(uint32_t num_points, uint32_t ts_ms, uint16_t meas_id); int send_cl_point(uint16_t index, float t_ms, float i_ua, uint8_t phase); int send_cl_result(float i_free_ua, float i_total_ua); int send_cl_end(void); /* outbound: pH */ -int send_ph_result(float v_ocp_mv, float ph, float temp_c); +int send_ph_result(float v_ocp_mv, float ph, float temp_c, + uint32_t ts_ms, uint16_t meas_id); /* outbound: temperature */ int send_temp(float temp_c); From 82977738270f0717754b10371963f49d4bac8b97 Mon Sep 17 00:00:00 2001 From: jess Date: Fri, 3 Apr 2026 07:01:37 -0700 Subject: [PATCH 03/11] add v2 migration: espTimestamp on measurement, firmwareSessionId on session --- cue-ios/CueIOS/Models/Storage.swift | 57 +++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/cue-ios/CueIOS/Models/Storage.swift b/cue-ios/CueIOS/Models/Storage.swift index b6e29fc..4b4f04e 100644 --- a/cue-ios/CueIOS/Models/Storage.swift +++ b/cue-ios/CueIOS/Models/Storage.swift @@ -11,6 +11,7 @@ struct Session: Codable, FetchableRecord, MutablePersistableRecord { var startedAt: Date var label: String? var notes: String? + var firmwareSessionId: Int64? mutating func didInsert(_ inserted: InsertionSuccess) { id = inserted.rowID @@ -24,6 +25,7 @@ struct Measurement: Codable, FetchableRecord, MutablePersistableRecord { var startedAt: Date var config: Data? var resultSummary: Data? + var espTimestamp: Int64? mutating func didInsert(_ inserted: InsertionSuccess) { id = inserted.rowID @@ -89,19 +91,34 @@ final class Storage: @unchecked Sendable { } } + migrator.registerMigration("v2") { db in + try db.alter(table: "measurement") { t in + t.add(column: "espTimestamp", .integer) + } + try db.alter(table: "session") { t in + t.add(column: "firmwareSessionId", .integer) + } + } + try migrator.migrate(dbQueue) } // MARK: - Sessions - func createSession(label: String? = nil) throws -> Session { + func createSession(label: String? = nil, firmwareSessionId: Int64? = nil) throws -> Session { try dbQueue.write { db in - var s = Session(startedAt: Date(), label: label) + var s = Session(startedAt: Date(), label: label, firmwareSessionId: firmwareSessionId) try s.insert(db) return s } } + func fetchSession(_ id: Int64) -> Session? { + try? dbQueue.read { db in + try Session.fetchOne(db, key: id) + } + } + func fetchSessions() throws -> [Session] { try dbQueue.read { db in try Session.order(Column("startedAt").desc).fetchAll(db) @@ -123,13 +140,43 @@ final class Storage: @unchecked Sendable { } } + func updateSessionLabel(_ id: Int64, label: String) throws { + try dbQueue.write { db in + try db.execute( + sql: "UPDATE session SET label = ? WHERE id = ?", + arguments: [label, id] + ) + } + } + + func sessionByFirmwareId(_ fwId: Int64) -> Session? { + try? dbQueue.read { db in + try Session + .filter(Column("firmwareSessionId") == fwId) + .fetchOne(db) + } + } + // MARK: - Measurements + func measurementExists(sessionId: Int64, espTimestamp: Int64) -> Bool { + (try? dbQueue.read { db in + try Measurement + .filter(Column("sessionId") == sessionId) + .filter(Column("espTimestamp") == espTimestamp) + .fetchCount(db) > 0 + }) ?? false + } + func addMeasurement( sessionId: Int64, type: MeasurementType, - config: (any Encodable)? = nil + config: (any Encodable)? = nil, + espTimestamp: Int64? = nil ) throws -> Measurement { + if let ts = espTimestamp, measurementExists(sessionId: sessionId, espTimestamp: ts) { + throw StorageError.duplicate + } let configData: Data? = if let config { try JSONEncoder().encode(config) } else { @@ -140,7 +187,8 @@ final class Storage: @unchecked Sendable { sessionId: sessionId, type: type.rawValue, startedAt: Date(), - config: configData + config: configData, + espTimestamp: espTimestamp ) try m.insert(db) return m @@ -460,6 +508,7 @@ final class Storage: @unchecked Sendable { enum StorageError: Error { case notFound + case duplicate case parseError(String) } From 1ba677273875e36031d19f7300eb6bd56b8aa9ad Mon Sep 17 00:00:00 2001 From: jess Date: Fri, 3 Apr 2026 07:02:50 -0700 Subject: [PATCH 04/11] decode esp_timestamp and meas_id in desktop Rust parser --- cue/src/app.rs | 10 +++++----- cue/src/protocol.rs | 47 +++++++++++++++++++++++++++++++++++++-------- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/cue/src/app.rs b/cue/src/app.rs index f1a9a0d..e71b6c3 100644 --- a/cue/src/app.rs +++ b/cue/src/app.rs @@ -646,7 +646,7 @@ impl App { self.status = s; } Message::DeviceData(msg) => match msg { - EisMessage::SweepStart { num_points, freq_start, freq_stop } => { + EisMessage::SweepStart { num_points, freq_start, freq_stop, .. } => { if self.collecting_refs { /* ref collection: clear temp buffer */ self.eis_points.clear(); @@ -691,7 +691,7 @@ impl App { self.electrode = cfg.electrode; self.status = "Config received".into(); } - EisMessage::LsvStart { num_points, v_start, v_stop } => { + EisMessage::LsvStart { num_points, v_start, v_stop, .. } => { self.lsv_points.clear(); self.lsv_total = num_points; self.lsv_data = text_editor::Content::with_text(&fmt_lsv(&self.lsv_points)); @@ -739,7 +739,7 @@ impl App { ); } } - EisMessage::AmpStart { v_hold } => { + EisMessage::AmpStart { v_hold, .. } => { self.amp_points.clear(); self.amp_running = true; self.amp_data = text_editor::Content::with_text(&fmt_amp(&self.amp_points)); @@ -758,7 +758,7 @@ impl App { } self.status = format!("Amp complete: {} points", self.amp_points.len()); } - EisMessage::ClStart { num_points } => { + EisMessage::ClStart { num_points, .. } => { self.cl_points.clear(); self.cl_result = None; self.cl_total = num_points; @@ -798,7 +798,7 @@ impl App { self.status = format!("Chlorine complete: {} points", self.cl_points.len()); } } - EisMessage::PhResult(r) => { + EisMessage::PhResult(r, _, _) => { if self.collecting_refs { self.ph_ref = Some(r); } else { diff --git a/cue/src/protocol.rs b/cue/src/protocol.rs index a0c2548..23649b2 100644 --- a/cue/src/protocol.rs +++ b/cue/src/protocol.rs @@ -244,21 +244,23 @@ pub struct EisConfig { #[derive(Debug, Clone)] pub enum EisMessage { - SweepStart { num_points: u16, freq_start: f32, freq_stop: f32 }, + SweepStart { num_points: u16, freq_start: f32, freq_stop: f32, + esp_timestamp: Option, meas_id: Option }, DataPoint { _index: u16, point: EisPoint }, SweepEnd, Config(EisConfig), - LsvStart { num_points: u16, v_start: f32, v_stop: f32 }, + LsvStart { num_points: u16, v_start: f32, v_stop: f32, + esp_timestamp: Option, meas_id: Option }, LsvPoint { _index: u16, point: LsvPoint }, LsvEnd, - AmpStart { v_hold: f32 }, + AmpStart { v_hold: f32, esp_timestamp: Option, meas_id: Option }, AmpPoint { _index: u16, point: AmpPoint }, AmpEnd, - ClStart { num_points: u16 }, + ClStart { num_points: u16, esp_timestamp: Option, meas_id: Option }, ClPoint { _index: u16, point: ClPoint }, ClResult(ClResult), ClEnd, - PhResult(PhResult), + PhResult(PhResult, Option, Option), Temperature(f32), RefFrame { mode: u8, rtia_idx: u8 }, RefLpRange { mode: u8, low_idx: u8, high_idx: u8 }, @@ -285,6 +287,16 @@ fn decode_float(data: &[u8]) -> f32 { f32::from_le_bytes([b0, b1, b2, b3]) } +fn decode_u32(data: &[u8]) -> u32 { + let b = [ + data[1] | ((data[0] & 1) << 7), + data[2] | ((data[0] & 2) << 6), + data[3] | ((data[0] & 4) << 5), + data[4] | ((data[0] & 8) << 4), + ]; + u32::from_le_bytes(b) +} + fn encode_float(val: f32) -> [u8; 5] { let p = val.to_le_bytes(); [ @@ -303,10 +315,14 @@ pub fn parse_sysex(data: &[u8]) -> Option { match data[1] { RSP_SWEEP_START if data.len() >= 15 => { let p = &data[2..]; + let (ts, mid) = if p.len() >= 21 { + (Some(decode_u32(&p[13..18])), Some(decode_u16(&p[18..21]))) + } else { (None, None) }; Some(EisMessage::SweepStart { num_points: decode_u16(&p[0..3]), freq_start: decode_float(&p[3..8]), freq_stop: decode_float(&p[8..13]), + esp_timestamp: ts, meas_id: mid, }) } RSP_DATA_POINT if data.len() >= 30 => { @@ -342,10 +358,14 @@ pub fn parse_sysex(data: &[u8]) -> Option { } RSP_LSV_START if data.len() >= 15 => { let p = &data[2..]; + let (ts, mid) = if p.len() >= 21 { + (Some(decode_u32(&p[13..18])), Some(decode_u16(&p[18..21]))) + } else { (None, None) }; Some(EisMessage::LsvStart { num_points: decode_u16(&p[0..3]), v_start: decode_float(&p[3..8]), v_stop: decode_float(&p[8..13]), + esp_timestamp: ts, meas_id: mid, }) } RSP_LSV_POINT if data.len() >= 15 => { @@ -361,7 +381,11 @@ pub fn parse_sysex(data: &[u8]) -> Option { RSP_LSV_END => Some(EisMessage::LsvEnd), RSP_AMP_START if data.len() >= 7 => { let p = &data[2..]; - Some(EisMessage::AmpStart { v_hold: decode_float(&p[0..5]) }) + let (ts, mid) = if p.len() >= 13 { + (Some(decode_u32(&p[5..10])), Some(decode_u16(&p[10..13]))) + } else { (None, None) }; + Some(EisMessage::AmpStart { v_hold: decode_float(&p[0..5]), + esp_timestamp: ts, meas_id: mid }) } RSP_AMP_POINT if data.len() >= 15 => { let p = &data[2..]; @@ -376,7 +400,11 @@ pub fn parse_sysex(data: &[u8]) -> Option { RSP_AMP_END => Some(EisMessage::AmpEnd), RSP_CL_START if data.len() >= 5 => { let p = &data[2..]; - Some(EisMessage::ClStart { num_points: decode_u16(&p[0..3]) }) + let (ts, mid) = if p.len() >= 11 { + (Some(decode_u32(&p[3..8])), Some(decode_u16(&p[8..11]))) + } else { (None, None) }; + Some(EisMessage::ClStart { num_points: decode_u16(&p[0..3]), + esp_timestamp: ts, meas_id: mid }) } RSP_CL_POINT if data.len() >= 16 => { let p = &data[2..]; @@ -403,11 +431,14 @@ pub fn parse_sysex(data: &[u8]) -> Option { } RSP_PH_RESULT if data.len() >= 17 => { let p = &data[2..]; + let (ts, mid) = if p.len() >= 23 { + (Some(decode_u32(&p[15..20])), Some(decode_u16(&p[20..23]))) + } else { (None, None) }; Some(EisMessage::PhResult(PhResult { v_ocp_mv: decode_float(&p[0..5]), ph: decode_float(&p[5..10]), temp_c: decode_float(&p[10..15]), - })) + }, ts, mid)) } RSP_REF_FRAME if data.len() >= 4 => { Some(EisMessage::RefFrame { mode: data[2], rtia_idx: data[3] }) From dcde79cf08837f4258a4e7e3a9409eedae305e8c Mon Sep 17 00:00:00 2001 From: jess Date: Fri, 3 Apr 2026 07:05:29 -0700 Subject: [PATCH 05/11] add esp_timestamp to DB schema with v2 migration and dedup --- cue/src/app.rs | 31 ++++++++++++++++++++----------- cue/src/storage.rs | 33 +++++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/cue/src/app.rs b/cue/src/app.rs index e71b6c3..aabedec 100644 --- a/cue/src/app.rs +++ b/cue/src/app.rs @@ -255,6 +255,9 @@ pub struct App { ph_result: Option, ph_stabilize: String, + /* measurement dedup */ + current_esp_ts: Option, + /* Reference baselines */ eis_ref: Option>, lsv_ref: Option>, @@ -501,6 +504,8 @@ impl App { ph_result: None, ph_stabilize: "30".into(), + current_esp_ts: None, + eis_ref: None, lsv_ref: None, amp_ref: None, @@ -556,7 +561,7 @@ impl App { "rcal": format!("{}", self.rcal), "electrode": format!("{}", self.electrode), }); - if let Ok(mid) = self.storage.create_measurement(session_id, "eis", ¶ms.to_string()) { + if let Ok(mid) = self.storage.create_measurement(session_id, "eis", ¶ms.to_string(), self.current_esp_ts) { let pts: Vec<(i32, String)> = self.eis_points.iter().enumerate() .filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j))) .collect(); @@ -571,7 +576,7 @@ impl App { "scan_rate": self.lsv_scan_rate, "rtia": format!("{}", self.lsv_rtia), }); - if let Ok(mid) = self.storage.create_measurement(session_id, "lsv", ¶ms.to_string()) { + if let Ok(mid) = self.storage.create_measurement(session_id, "lsv", ¶ms.to_string(), self.current_esp_ts) { let pts: Vec<(i32, String)> = self.lsv_points.iter().enumerate() .filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j))) .collect(); @@ -586,7 +591,7 @@ impl App { "duration_s": self.amp_duration, "rtia": format!("{}", self.amp_rtia), }); - if let Ok(mid) = self.storage.create_measurement(session_id, "amp", ¶ms.to_string()) { + if let Ok(mid) = self.storage.create_measurement(session_id, "amp", ¶ms.to_string(), self.current_esp_ts) { let pts: Vec<(i32, String)> = self.amp_points.iter().enumerate() .filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j))) .collect(); @@ -604,7 +609,7 @@ impl App { "meas_t": self.cl_meas_t, "rtia": format!("{}", self.cl_rtia), }); - if let Ok(mid) = self.storage.create_measurement(session_id, "chlorine", ¶ms.to_string()) { + if let Ok(mid) = self.storage.create_measurement(session_id, "chlorine", ¶ms.to_string(), self.current_esp_ts) { let mut pts: Vec<(i32, String)> = self.cl_points.iter().enumerate() .filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j))) .collect(); @@ -621,7 +626,7 @@ impl App { let params = serde_json::json!({ "stabilize_s": self.ph_stabilize, }); - if let Ok(mid) = self.storage.create_measurement(session_id, "ph", ¶ms.to_string()) { + if let Ok(mid) = self.storage.create_measurement(session_id, "ph", ¶ms.to_string(), self.current_esp_ts) { if let Ok(j) = serde_json::to_string(result) { let _ = self.storage.add_data_point(mid, 0, &j); } @@ -646,9 +651,9 @@ impl App { self.status = s; } Message::DeviceData(msg) => match msg { - EisMessage::SweepStart { num_points, freq_start, freq_stop, .. } => { + EisMessage::SweepStart { num_points, freq_start, freq_stop, esp_timestamp, .. } => { + self.current_esp_ts = esp_timestamp; if self.collecting_refs { - /* ref collection: clear temp buffer */ self.eis_points.clear(); self.sweep_total = num_points; } else { @@ -691,7 +696,8 @@ impl App { self.electrode = cfg.electrode; self.status = "Config received".into(); } - EisMessage::LsvStart { num_points, v_start, v_stop, .. } => { + EisMessage::LsvStart { num_points, v_start, v_stop, esp_timestamp, .. } => { + self.current_esp_ts = esp_timestamp; self.lsv_points.clear(); self.lsv_total = num_points; self.lsv_data = text_editor::Content::with_text(&fmt_lsv(&self.lsv_points)); @@ -739,7 +745,8 @@ impl App { ); } } - EisMessage::AmpStart { v_hold, .. } => { + EisMessage::AmpStart { v_hold, esp_timestamp, .. } => { + self.current_esp_ts = esp_timestamp; self.amp_points.clear(); self.amp_running = true; self.amp_data = text_editor::Content::with_text(&fmt_amp(&self.amp_points)); @@ -758,7 +765,8 @@ impl App { } self.status = format!("Amp complete: {} points", self.amp_points.len()); } - EisMessage::ClStart { num_points, .. } => { + EisMessage::ClStart { num_points, esp_timestamp, .. } => { + self.current_esp_ts = esp_timestamp; self.cl_points.clear(); self.cl_result = None; self.cl_total = num_points; @@ -798,11 +806,12 @@ impl App { self.status = format!("Chlorine complete: {} points", self.cl_points.len()); } } - EisMessage::PhResult(r, _, _) => { + EisMessage::PhResult(r, esp_ts, _) => { if self.collecting_refs { self.ph_ref = Some(r); } else { if let Some(sid) = self.current_session { + self.current_esp_ts = esp_ts; self.save_ph(sid, &r); } self.status = format!("pH: {:.2} (OCP={:.1} mV, T={:.1}C)", diff --git a/cue/src/storage.rs b/cue/src/storage.rs index 4ae206e..65b28d2 100644 --- a/cue/src/storage.rs +++ b/cue/src/storage.rs @@ -18,6 +18,7 @@ pub struct Measurement { pub mtype: String, pub params_json: String, pub created_at: String, + pub esp_timestamp: Option, } #[derive(Debug, Clone)] @@ -41,9 +42,21 @@ impl Storage { let conn = Connection::open(path)?; conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; conn.execute_batch(SCHEMA)?; + Self::migrate_v2(&conn)?; Ok(Self { conn }) } + fn migrate_v2(conn: &Connection) -> Result<(), rusqlite::Error> { + let has_col: bool = conn.prepare("SELECT esp_timestamp FROM measurements LIMIT 0") + .is_ok(); + if !has_col { + conn.execute_batch( + "ALTER TABLE measurements ADD COLUMN esp_timestamp INTEGER;" + )?; + } + Ok(()) + } + pub fn create_session(&self, name: &str, notes: &str) -> Result { self.conn.execute( "INSERT INTO sessions (name, notes) VALUES (?1, ?2)", @@ -74,10 +87,21 @@ impl Storage { pub fn create_measurement( &self, session_id: i64, mtype: &str, params_json: &str, + esp_timestamp: Option, ) -> Result { + if let Some(ts) = esp_timestamp { + let exists: bool = self.conn.query_row( + "SELECT EXISTS(SELECT 1 FROM measurements WHERE session_id = ?1 AND esp_timestamp = ?2)", + params![session_id, ts as i64], + |row| row.get(0), + )?; + if exists { + return Err(rusqlite::Error::StatementChangedRows(0)); + } + } self.conn.execute( - "INSERT INTO measurements (session_id, type, params_json) VALUES (?1, ?2, ?3)", - params![session_id, mtype, params_json], + "INSERT INTO measurements (session_id, type, params_json, esp_timestamp) VALUES (?1, ?2, ?3, ?4)", + params![session_id, mtype, params_json, esp_timestamp.map(|t| t as i64)], )?; Ok(self.conn.last_insert_rowid()) } @@ -109,7 +133,7 @@ impl Storage { pub fn get_measurements(&self, session_id: i64) -> Result, rusqlite::Error> { let mut stmt = self.conn.prepare( - "SELECT id, session_id, type, params_json, created_at \ + "SELECT id, session_id, type, params_json, created_at, esp_timestamp \ FROM measurements WHERE session_id = ?1 ORDER BY created_at DESC", )?; let rows = stmt.query_map(params![session_id], |row| { @@ -119,6 +143,7 @@ impl Storage { mtype: row.get(2)?, params_json: row.get(3)?, created_at: row.get(4)?, + esp_timestamp: row.get(5)?, }) })?; rows.collect() @@ -244,7 +269,7 @@ impl Storage { Some(t) => serde_json::to_string(&toml_table_to_json(t))?, None => "{}".to_string(), }; - let mid = self.create_measurement(session_id, mtype, ¶ms_json)?; + let mid = self.create_measurement(session_id, mtype, ¶ms_json, None)?; if let Some(toml::Value::Array(data)) = mt.get("data") { let pts: Vec<(i32, String)> = data.iter().enumerate() From d061a17e548b643e341a3da54c77d36cad4b79c5 Mon Sep 17 00:00:00 2001 From: jess Date: Fri, 3 Apr 2026 07:05:40 -0700 Subject: [PATCH 06/11] add session sync protocol, extended START fields, and session management handlers --- cue-ios/CueIOS/AppState.swift | 102 +++++++++++++-- cue-ios/CueIOS/Models/Protocol.swift | 143 ++++++++++++++++++++-- cue-ios/CueIOS/Transport/UDPManager.swift | 7 ++ 3 files changed, 231 insertions(+), 21 deletions(-) diff --git a/cue-ios/CueIOS/AppState.swift b/cue-ios/CueIOS/AppState.swift index fa6aec9..295e8be 100644 --- a/cue-ios/CueIOS/AppState.swift +++ b/cue-ios/CueIOS/AppState.swift @@ -100,6 +100,9 @@ final class AppState { // Session var currentSessionId: Int64? = nil + var firmwareSessionMap: [UInt8: Int64] = [:] + var sessionListReceived: Bool = false + private var pendingEspTimestamp: Int64? = nil // Calibration var calVolumeGal: Double = 25 @@ -125,6 +128,10 @@ final class AppState { transport.setMessageHandler { [weak self] msg in self?.handleMessage(msg) } + transport.setDisconnectHandler { [weak self] in + self?.sessionListReceived = false + self?.firmwareSessionMap.removeAll() + } } // MARK: - Send helper @@ -138,7 +145,8 @@ final class AppState { private func handleMessage(_ msg: EisMessage) { switch msg { - case .sweepStart(let numPoints, let freqStart, let freqStop): + case .sweepStart(let numPoints, let freqStart, let freqStop, let espTs, _): + pendingEspTimestamp = espTs.map { Int64($0) } if collectingRefs { eisPoints.removeAll() sweepTotal = numPoints @@ -172,9 +180,14 @@ final class AppState { rtia = cfg.rtia rcal = cfg.rcal electrode = cfg.electrode + if !sessionListReceived { + sessionListReceived = true + send(buildSysexSessionList()) + } status = "Config received" - case .lsvStart(let numPoints, let vStart, let vStop): + case .lsvStart(let numPoints, let vStart, let vStop, let espTs, _): + pendingEspTimestamp = espTs.map { Int64($0) } lsvPoints.removeAll() lsvTotal = numPoints status = String(format: "LSV: %d pts, %.0f--%.0f mV", numPoints, vStart, vStop) @@ -214,7 +227,8 @@ final class AppState { status = String(format: "Auto Cl: measuring (free=%.0f, total=%.0f)", pots.vFree, pots.vTotal) } - case .ampStart(let vHold): + case .ampStart(let vHold, let espTs, _): + pendingEspTimestamp = espTs.map { Int64($0) } ampPoints.removeAll() ampRunning = true status = String(format: "Amp: %.0f mV", vHold) @@ -229,7 +243,8 @@ final class AppState { saveAmp() status = "Amp complete: \(ampPoints.count) points" - case .clStart(let numPoints): + case .clStart(let numPoints, let espTs, _): + pendingEspTimestamp = espTs.map { Int64($0) } clPoints.removeAll() clResult = nil clTotal = numPoints @@ -324,11 +339,70 @@ final class AppState { phOffset = Double(offset) status = String(format: "pH cal: slope=%.4f offset=%.4f", slope, offset) + case .sessionCreated(let fwId, let name): + handleSessionCreated(fwId: fwId, name: name) + + case .sessionSwitched(let fwId): + handleSessionSwitched(fwId: fwId) + + case .sessionList(_, let currentId, let sessions): + handleSessionList(currentId: currentId, sessions: sessions) + + case .sessionRenamed(let fwId, let name): + handleSessionRenamed(fwId: fwId, name: name) + case .keepalive: break } } + // MARK: - Session sync + + private func handleSessionCreated(fwId: UInt8, name: String) { + let fwId64 = Int64(fwId) + if let existing = Storage.shared.sessionByFirmwareId(fwId64) { + firmwareSessionMap[fwId] = existing.id + } else if let session = try? Storage.shared.createSession( + label: name.isEmpty ? nil : name, + firmwareSessionId: fwId64 + ) { + firmwareSessionMap[fwId] = session.id + currentSessionId = session.id + } + } + + private func handleSessionSwitched(fwId: UInt8) { + if let localId = firmwareSessionMap[fwId] { + currentSessionId = localId + } else { + let fwId64 = Int64(fwId) + if let existing = Storage.shared.sessionByFirmwareId(fwId64) { + firmwareSessionMap[fwId] = existing.id + currentSessionId = existing.id + } + } + } + + private func handleSessionList(currentId: UInt8, sessions: [(id: UInt8, name: String)]) { + for entry in sessions { + let fwId64 = Int64(entry.id) + if let existing = Storage.shared.sessionByFirmwareId(fwId64) { + firmwareSessionMap[entry.id] = existing.id + } else if let session = try? Storage.shared.createSession( + label: entry.name.isEmpty ? nil : entry.name, + firmwareSessionId: fwId64 + ) { + firmwareSessionMap[entry.id] = session.id + } + } + handleSessionSwitched(fwId: currentId) + } + + private func handleSessionRenamed(fwId: UInt8, name: String) { + guard let localId = firmwareSessionMap[fwId] else { return } + try? Storage.shared.updateSessionLabel(localId, label: name) + } + // MARK: - Actions func applyEISSettings() { @@ -533,6 +607,8 @@ final class AppState { private func saveEis() { guard let sid = currentSessionId else { return } + let ts = pendingEspTimestamp + pendingEspTimestamp = nil let params: [String: String] = [ "freq_start": freqStart, "freq_stop": freqStop, @@ -542,7 +618,7 @@ final class AppState { "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 } + guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .eis, espTimestamp: ts) else { return } meas.config = configData guard let mid = meas.id else { return } let indexed = eisPoints.enumerated().map { (index: $0.offset, value: $0.element) } @@ -551,6 +627,8 @@ final class AppState { private func saveLsv() { guard let sid = currentSessionId else { return } + let ts = pendingEspTimestamp + pendingEspTimestamp = nil let params: [String: String] = [ "v_start": lsvStartV, "v_stop": lsvStopV, @@ -558,7 +636,7 @@ final class AppState { "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 } + guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .lsv, espTimestamp: ts) else { return } meas.config = configData guard let mid = meas.id else { return } let indexed = lsvPoints.enumerated().map { (index: $0.offset, value: $0.element) } @@ -567,6 +645,8 @@ final class AppState { private func saveAmp() { guard let sid = currentSessionId else { return } + let ts = pendingEspTimestamp + pendingEspTimestamp = nil let params: [String: String] = [ "v_hold": ampVHold, "interval_ms": ampInterval, @@ -574,7 +654,7 @@ final class AppState { "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 } + guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .amp, espTimestamp: ts) else { return } meas.config = configData guard let mid = meas.id else { return } let indexed = ampPoints.enumerated().map { (index: $0.offset, value: $0.element) } @@ -583,6 +663,8 @@ final class AppState { private func saveCl() { guard let sid = currentSessionId else { return } + let ts = pendingEspTimestamp + pendingEspTimestamp = nil let params: [String: String] = [ "cond_v": clCondV, "cond_t": clCondT, @@ -593,7 +675,7 @@ final class AppState { "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 } + guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .chlorine, espTimestamp: ts) else { return } meas.config = configData guard let mid = meas.id else { return } let indexed = clPoints.enumerated().map { (index: $0.offset, value: $0.element) } @@ -605,11 +687,13 @@ final class AppState { private func savePh(_ result: PhResult) { guard let sid = currentSessionId else { return } + let ts = pendingEspTimestamp + pendingEspTimestamp = nil 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 } + guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .ph, espTimestamp: ts) else { return } meas.config = configData guard let mid = meas.id else { return } try? Storage.shared.addDataPoint(measurementId: mid, index: 0, point: result) diff --git a/cue-ios/CueIOS/Models/Protocol.swift b/cue-ios/CueIOS/Models/Protocol.swift index 34c3664..6dd6718 100644 --- a/cue-ios/CueIOS/Models/Protocol.swift +++ b/cue-ios/CueIOS/Models/Protocol.swift @@ -31,7 +31,11 @@ let RSP_REFS_DONE: UInt8 = 0x22 let RSP_REF_STATUS: UInt8 = 0x23 let RSP_CL_FACTOR: UInt8 = 0x24 let RSP_PH_CAL: UInt8 = 0x25 -let RSP_KEEPALIVE: UInt8 = 0x50 +let RSP_SESSION_CREATED: UInt8 = 0x40 +let RSP_SESSION_SWITCHED: UInt8 = 0x41 +let RSP_SESSION_LIST: UInt8 = 0x42 +let RSP_SESSION_RENAMED: UInt8 = 0x43 +let RSP_KEEPALIVE: UInt8 = 0x50 // Cue -> ESP32 let CMD_SET_SWEEP: UInt8 = 0x10 @@ -53,9 +57,13 @@ let CMD_SET_CL_FACTOR: UInt8 = 0x33 let CMD_GET_CL_FACTOR: UInt8 = 0x34 let CMD_SET_PH_CAL: UInt8 = 0x35 let CMD_GET_PH_CAL: UInt8 = 0x36 -let CMD_START_REFS: UInt8 = 0x30 -let CMD_GET_REFS: UInt8 = 0x31 -let CMD_CLEAR_REFS: UInt8 = 0x32 +let CMD_START_REFS: UInt8 = 0x30 +let CMD_GET_REFS: UInt8 = 0x31 +let CMD_CLEAR_REFS: UInt8 = 0x32 +let CMD_SESSION_CREATE: UInt8 = 0x40 +let CMD_SESSION_SWITCH: UInt8 = 0x41 +let CMD_SESSION_LIST: UInt8 = 0x42 +let CMD_SESSION_RENAME: UInt8 = 0x43 // MARK: - 7-bit MIDI encoding @@ -94,6 +102,31 @@ func encodeU16(_ val: UInt16) -> [UInt8] { return [mask, p[0] & 0x7F, p[1] & 0x7F] } +/// Encode a UInt32 into 5 MIDI-safe bytes. +func encodeU32(_ val: UInt32) -> [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 a UInt32. +func decodeU32(_ d: [UInt8], at offset: Int = 0) -> UInt32 { + 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: UInt32 = 0 + withUnsafeMutableBytes(of: &val) { buf in + buf[0] = b0; buf[1] = b1; buf[2] = b2; buf[3] = b3 + } + return val +} + /// Decode 3 MIDI-safe bytes back into a UInt16. func decodeU16(_ d: [UInt8], at offset: Int = 0) -> UInt16 { let m = d[offset] @@ -109,17 +142,17 @@ func decodeU16(_ d: [UInt8], at offset: Int = 0) -> UInt16 { // MARK: - Message enum enum EisMessage { - case sweepStart(numPoints: UInt16, freqStart: Float, freqStop: Float) + case sweepStart(numPoints: UInt16, freqStart: Float, freqStop: Float, espTimestamp: UInt32?, espMeasId: UInt16?) case dataPoint(index: UInt16, point: EisPoint) case sweepEnd case config(EisConfig) - case lsvStart(numPoints: UInt16, vStart: Float, vStop: Float) + case lsvStart(numPoints: UInt16, vStart: Float, vStop: Float, espTimestamp: UInt32?, espMeasId: UInt16?) case lsvPoint(index: UInt16, point: LsvPoint) case lsvEnd - case ampStart(vHold: Float) + case ampStart(vHold: Float, espTimestamp: UInt32?, espMeasId: UInt16?) case ampPoint(index: UInt16, point: AmpPoint) case ampEnd - case clStart(numPoints: UInt16) + case clStart(numPoints: UInt16, espTimestamp: UInt32?, espMeasId: UInt16?) case clPoint(index: UInt16, point: ClPoint) case clResult(ClResult) case clEnd @@ -132,6 +165,10 @@ enum EisMessage { case cellK(Float) case clFactor(Float) case phCal(slope: Float, offset: Float) + case sessionCreated(id: UInt8, name: String) + case sessionSwitched(id: UInt8) + case sessionList(count: UInt8, currentId: UInt8, sessions: [(id: UInt8, name: String)]) + case sessionRenamed(id: UInt8, name: String) case keepalive } @@ -146,10 +183,13 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? { switch data[1] { case RSP_SWEEP_START where p.count >= 13: + let hasExt = p.count >= 21 return .sweepStart( numPoints: decodeU16(p, at: 0), freqStart: decodeFloat(p, at: 3), - freqStop: decodeFloat(p, at: 8) + freqStop: decodeFloat(p, at: 8), + espTimestamp: hasExt ? decodeU32(p, at: 13) : nil, + espMeasId: hasExt ? decodeU16(p, at: 18) : nil ) case RSP_DATA_POINT where p.count >= 28: @@ -184,10 +224,13 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? { )) case RSP_LSV_START where p.count >= 13: + let hasExt = p.count >= 21 return .lsvStart( numPoints: decodeU16(p, at: 0), vStart: decodeFloat(p, at: 3), - vStop: decodeFloat(p, at: 8) + vStop: decodeFloat(p, at: 8), + espTimestamp: hasExt ? decodeU32(p, at: 13) : nil, + espMeasId: hasExt ? decodeU16(p, at: 18) : nil ) case RSP_LSV_POINT where p.count >= 13: @@ -203,7 +246,12 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? { return .lsvEnd case RSP_AMP_START where p.count >= 5: - return .ampStart(vHold: decodeFloat(p, at: 0)) + let hasExt = p.count >= 13 + return .ampStart( + vHold: decodeFloat(p, at: 0), + espTimestamp: hasExt ? decodeU32(p, at: 5) : nil, + espMeasId: hasExt ? decodeU16(p, at: 10) : nil + ) case RSP_AMP_POINT where p.count >= 13: return .ampPoint( @@ -218,7 +266,12 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? { return .ampEnd case RSP_CL_START where p.count >= 3: - return .clStart(numPoints: decodeU16(p, at: 0)) + let hasExt = p.count >= 11 + return .clStart( + numPoints: decodeU16(p, at: 0), + espTimestamp: hasExt ? decodeU32(p, at: 3) : nil, + espMeasId: hasExt ? decodeU16(p, at: 8) : nil + ) case RSP_CL_POINT where p.count >= 14: return .clPoint( @@ -273,6 +326,46 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? { offset: decodeFloat(p, at: 5) ) + case RSP_SESSION_CREATED where p.count >= 2: + let sid = p[0] + let nameLen = Int(p[1]) + let name = nameLen > 0 && p.count >= 2 + nameLen + ? String(bytes: p[2..<(2 + nameLen)], encoding: .utf8) ?? "" + : "" + return .sessionCreated(id: sid, name: name) + + case RSP_SESSION_SWITCHED where p.count >= 1: + return .sessionSwitched(id: p[0]) + + case RSP_SESSION_LIST where p.count >= 2: + let count = p[0] + let currentId = p[1] + var sessions: [(id: UInt8, name: String)] = [] + var off = 2 + for _ in 0.. 0 && off + nameLen <= p.count { + name = String(bytes: p[off..<(off + nameLen)], encoding: .utf8) ?? "" + off += nameLen + } else { + name = "" + } + sessions.append((id: sid, name: name)) + } + return .sessionList(count: count, currentId: currentId, sessions: sessions) + + case RSP_SESSION_RENAMED where p.count >= 2: + let sid = p[0] + let nameLen = Int(p[1]) + let name = nameLen > 0 && p.count >= 2 + nameLen + ? String(bytes: p[2..<(2 + nameLen)], encoding: .utf8) ?? "" + : "" + return .sessionRenamed(id: sid, name: name) + case RSP_KEEPALIVE: return .keepalive @@ -417,3 +510,29 @@ func buildSysexSetPhCal(_ slope: Float, _ offset: Float) -> [UInt8] { func buildSysexGetPhCal() -> [UInt8] { [0xF0, sysexMfr, CMD_GET_PH_CAL, 0xF7] } + +// MARK: - Session commands + +func buildSysexSessionCreate(name: String) -> [UInt8] { + let nameBytes = Array(name.utf8.prefix(32)) + var sx: [UInt8] = [0xF0, sysexMfr, CMD_SESSION_CREATE, UInt8(nameBytes.count)] + sx.append(contentsOf: nameBytes) + sx.append(0xF7) + return sx +} + +func buildSysexSessionSwitch(id: UInt8) -> [UInt8] { + [0xF0, sysexMfr, CMD_SESSION_SWITCH, id, 0xF7] +} + +func buildSysexSessionList() -> [UInt8] { + [0xF0, sysexMfr, CMD_SESSION_LIST, 0xF7] +} + +func buildSysexSessionRename(id: UInt8, name: String) -> [UInt8] { + let nameBytes = Array(name.utf8.prefix(32)) + var sx: [UInt8] = [0xF0, sysexMfr, CMD_SESSION_RENAME, id, UInt8(nameBytes.count)] + sx.append(contentsOf: nameBytes) + sx.append(0xF7) + return sx +} diff --git a/cue-ios/CueIOS/Transport/UDPManager.swift b/cue-ios/CueIOS/Transport/UDPManager.swift index 351aa91..a958418 100644 --- a/cue-ios/CueIOS/Transport/UDPManager.swift +++ b/cue-ios/CueIOS/Transport/UDPManager.swift @@ -29,6 +29,7 @@ final class UDPManager: @unchecked Sendable { private var connection: NWConnection? private let queue = DispatchQueue(label: "udp.eis4", qos: .userInitiated) private var onMessage: ((EisMessage) -> Void)? + private var onDisconnect: (() -> Void)? private var keepaliveTimer: Timer? private var timeoutTimer: Timer? private var lastReceived: Date = .distantPast @@ -49,6 +50,10 @@ final class UDPManager: @unchecked Sendable { onMessage = handler } + func setDisconnectHandler(_ handler: @escaping () -> Void) { + onDisconnect = handler + } + // MARK: - Connection func connect() { @@ -81,6 +86,7 @@ final class UDPManager: @unchecked Sendable { connection = nil measuring = false state = .disconnected + onDisconnect?() } func send(_ sysex: [UInt8]) { @@ -188,6 +194,7 @@ final class UDPManager: @unchecked Sendable { self.stopTimers() self.connection?.cancel() self.connection = nil + self.onDisconnect?() } } } From f5394d01cacc0dc7602dedc354c8af2ade0be00d Mon Sep 17 00:00:00 2001 From: jess Date: Fri, 3 Apr 2026 07:06:47 -0700 Subject: [PATCH 07/11] add measurement data views with charts for all measurement types --- .../CueIOS/Views/MeasurementDataView.swift | 566 ++++++++++++++++++ 1 file changed, 566 insertions(+) create mode 100644 cue-ios/CueIOS/Views/MeasurementDataView.swift diff --git a/cue-ios/CueIOS/Views/MeasurementDataView.swift b/cue-ios/CueIOS/Views/MeasurementDataView.swift new file mode 100644 index 0000000..19dc315 --- /dev/null +++ b/cue-ios/CueIOS/Views/MeasurementDataView.swift @@ -0,0 +1,566 @@ +/// Measurement data viewer — switches on type to show appropriate charts. + +import SwiftUI +import Charts + +// MARK: - Router + +struct MeasurementDataView: View { + let measurement: Measurement + + @State private var points: [DataPoint] = [] + @State private var loaded = false + + var body: some View { + Group { + if !loaded { + ProgressView() + } else { + content + } + } + .navigationTitle(typeLabel) + .onAppear { loadPoints() } + } + + private func loadPoints() { + guard let mid = measurement.id else { return } + points = (try? Storage.shared.fetchDataPoints(measurementId: mid)) ?? [] + loaded = true + } + + @ViewBuilder + private var content: some View { + switch MeasurementType(rawValue: measurement.type) { + case .eis: + EisDataView(points: decodePoints(EisPoint.self)) + case .lsv: + LsvDataView(points: decodePoints(LsvPoint.self)) + case .amp: + AmpDataView(points: decodePoints(AmpPoint.self)) + case .chlorine: + ClDataView( + points: decodePoints(ClPoint.self), + result: decodeResult(ClResult.self) + ) + case .ph: + PhDataView(result: decodeResult(PhResult.self)) + case nil: + Text("Unknown type: \(measurement.type)") + .foregroundStyle(.secondary) + } + } + + private var typeLabel: String { + switch measurement.type { + case "eis": "EIS" + case "lsv": "LSV" + case "amp": "Amperometry" + case "chlorine": "Chlorine" + case "ph": "pH" + default: measurement.type + } + } + + private func decodePoints(_ type: T.Type) -> [T] { + let decoder = JSONDecoder() + return points.compactMap { try? decoder.decode(T.self, from: $0.payload) } + } + + private func decodeResult(_ type: T.Type) -> T? { + guard let data = measurement.resultSummary else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } +} + +// MARK: - EIS data view + +enum EisPlotMode: String, CaseIterable, Identifiable { + case nyquist = "Nyquist" + case bodeMag = "Bode |Z|" + case bodePhase = "Bode Phase" + + var id: String { rawValue } +} + +struct EisDataView: View { + let points: [EisPoint] + @State private var plotMode: EisPlotMode = .nyquist + @State private var showTable = false + + var body: some View { + VStack(spacing: 0) { + Picker("Plot", selection: $plotMode) { + ForEach(EisPlotMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.vertical, 8) + + Divider() + + if points.isEmpty { + noData + } else { + plotView + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + if showTable && !points.isEmpty { + Divider() + eisTable + .frame(maxHeight: 250) + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(showTable ? "Hide Table" : "Show Table") { + showTable.toggle() + } + } + } + } + + @ViewBuilder + private var plotView: some View { + switch plotMode { + case .nyquist: + nyquistChart + .padding() + case .bodeMag: + bodeMagChart + .padding() + case .bodePhase: + bodePhaseChart + .padding() + } + } + + private var nyquistChart: some View { + Chart { + ForEach(Array(points.enumerated()), id: \.offset) { _, pt in + PointMark( + x: .value("Z'", Double(pt.zReal)), + y: .value("-Z''", Double(-pt.zImag)) + ) + .foregroundStyle(Color(red: 0.4, green: 1, blue: 0.4)) + .symbolSize(20) + } + ForEach(Array(points.enumerated()), id: \.offset) { _, pt in + LineMark( + x: .value("Z'", Double(pt.zReal)), + y: .value("-Z''", Double(-pt.zImag)) + ) + .foregroundStyle(Color(red: 0.4, green: 1, blue: 0.4).opacity(0.6)) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + } + } + .chartXAxisLabel("Z' (Ohm)") + .chartYAxisLabel("-Z'' (Ohm)") + .chartXAxis { darkAxis } + .chartYAxis { darkAxisLeading } + } + + private var bodeMagChart: some View { + Chart { + ForEach(Array(points.enumerated()), id: \.offset) { _, pt in + LineMark( + x: .value("Freq", log10(max(Double(pt.freqHz), 1))), + y: .value("|Z|", log10(max(Double(pt.magOhms), 0.01))) + ) + .foregroundStyle(Color.cyan) + .lineStyle(StrokeStyle(lineWidth: 2)) + } + ForEach(Array(points.enumerated()), id: \.offset) { _, pt in + PointMark( + x: .value("Freq", log10(max(Double(pt.freqHz), 1))), + y: .value("|Z|", log10(max(Double(pt.magOhms), 0.01))) + ) + .foregroundStyle(Color.cyan) + .symbolSize(16) + } + } + .chartXAxisLabel("log10(Freq Hz)") + .chartYAxisLabel("log10(|Z| Ohm)") + .chartXAxis { darkAxis } + .chartYAxis { darkAxisLeading } + } + + private var bodePhaseChart: some View { + Chart { + ForEach(Array(points.enumerated()), id: \.offset) { _, pt in + LineMark( + x: .value("Freq", log10(max(Double(pt.freqHz), 1))), + y: .value("Phase", Double(pt.phaseDeg)) + ) + .foregroundStyle(Color.orange) + .lineStyle(StrokeStyle(lineWidth: 2)) + } + ForEach(Array(points.enumerated()), id: \.offset) { _, pt in + PointMark( + x: .value("Freq", log10(max(Double(pt.freqHz), 1))), + y: .value("Phase", Double(pt.phaseDeg)) + ) + .foregroundStyle(Color.orange) + .symbolSize(16) + } + } + .chartXAxisLabel("log10(Freq Hz)") + .chartYAxisLabel("Phase (deg)") + .chartXAxis { darkAxis } + .chartYAxis { darkAxisLeading } + } + + private var eisTable: some View { + MeasurementTable( + columns: [ + MeasurementColumn(header: "Freq (Hz)", width: 80, alignment: .trailing), + MeasurementColumn(header: "|Z| (Ohm)", width: 90, alignment: .trailing), + MeasurementColumn(header: "Phase", width: 70, alignment: .trailing), + MeasurementColumn(header: "Re", width: 80, alignment: .trailing), + MeasurementColumn(header: "Im", width: 80, alignment: .trailing), + ], + rows: points, + cellText: { pt, col in + switch col { + case 0: String(format: "%.1f", pt.freqHz) + case 1: String(format: "%.2f", pt.magOhms) + case 2: String(format: "%.2f", pt.phaseDeg) + case 3: String(format: "%.2f", pt.zReal) + case 4: String(format: "%.2f", pt.zImag) + default: "" + } + } + ) + } + + private var noData: some View { + Text("No data points") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - LSV data view + +struct LsvDataView: View { + let points: [LsvPoint] + @State private var showTable = false + + var body: some View { + VStack(spacing: 0) { + if points.isEmpty { + noData + } else { + ivChart + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + if showTable && !points.isEmpty { + Divider() + lsvTable + .frame(maxHeight: 250) + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(showTable ? "Hide Table" : "Show Table") { + showTable.toggle() + } + } + } + } + + private var ivChart: some View { + Chart { + ForEach(Array(points.enumerated()), id: \.offset) { _, pt in + LineMark( + x: .value("V", Double(pt.vMv)), + y: .value("I", Double(pt.iUa)) + ) + .foregroundStyle(Color.yellow) + .lineStyle(StrokeStyle(lineWidth: 2)) + } + ForEach(Array(points.enumerated()), id: \.offset) { _, pt in + PointMark( + x: .value("V", Double(pt.vMv)), + y: .value("I", Double(pt.iUa)) + ) + .foregroundStyle(Color.yellow) + .symbolSize(16) + } + } + .chartXAxisLabel("Voltage (mV)") + .chartYAxisLabel("Current (uA)") + .chartXAxis { darkAxis } + .chartYAxis { darkAxisLeading } + } + + private var lsvTable: some View { + MeasurementTable( + columns: [ + MeasurementColumn(header: "V (mV)", width: 80, alignment: .trailing), + MeasurementColumn(header: "I (uA)", width: 90, alignment: .trailing), + ], + rows: points, + cellText: { pt, col in + switch col { + case 0: String(format: "%.1f", pt.vMv) + case 1: String(format: "%.3f", pt.iUa) + default: "" + } + } + ) + } + + private var noData: some View { + Text("No data points") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Amperometry data view + +struct AmpDataView: View { + let points: [AmpPoint] + @State private var showTable = false + + var body: some View { + VStack(spacing: 0) { + if points.isEmpty { + noData + } else { + ampChart + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + if showTable && !points.isEmpty { + Divider() + ampTable + .frame(maxHeight: 250) + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(showTable ? "Hide Table" : "Show Table") { + showTable.toggle() + } + } + } + } + + private var ampChart: some View { + let ampColor = Color(red: 0.6, green: 0.6, blue: 1.0) + return Chart { + ForEach(Array(points.enumerated()), id: \.offset) { _, pt in + LineMark( + x: .value("t", Double(pt.tMs)), + y: .value("I", Double(pt.iUa)) + ) + .foregroundStyle(ampColor) + .lineStyle(StrokeStyle(lineWidth: 2)) + } + ForEach(Array(points.enumerated()), id: \.offset) { _, pt in + PointMark( + x: .value("t", Double(pt.tMs)), + y: .value("I", Double(pt.iUa)) + ) + .foregroundStyle(ampColor) + .symbolSize(16) + } + } + .chartXAxisLabel("Time (ms)") + .chartYAxisLabel("Current (uA)") + .chartXAxis { darkAxis } + .chartYAxis { darkAxisLeading } + } + + private var ampTable: some View { + MeasurementTable( + columns: [ + MeasurementColumn(header: "t (ms)", width: 80, alignment: .trailing), + MeasurementColumn(header: "I (uA)", width: 90, alignment: .trailing), + ], + rows: points, + cellText: { pt, col in + switch col { + case 0: String(format: "%.1f", pt.tMs) + case 1: String(format: "%.3f", pt.iUa) + default: "" + } + } + ) + } + + private var noData: some View { + Text("No data points") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Chlorine data view + +struct ClDataView: View { + let points: [ClPoint] + let result: ClResult? + @State private var showTable = false + + var body: some View { + VStack(spacing: 0) { + if let r = result { + resultBanner(r) + } + + if points.isEmpty { + noData + } else { + clChart + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + if showTable && !points.isEmpty { + Divider() + clTable + .frame(maxHeight: 250) + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(showTable ? "Hide Table" : "Show Table") { + showTable.toggle() + } + } + } + } + + private func resultBanner(_ r: ClResult) -> some View { + HStack(spacing: 16) { + Text(String(format: "Free: %.3f uA", r.iFreeUa)) + .foregroundStyle(Color(red: 0.2, green: 1, blue: 0.5)) + Text(String(format: "Total: %.3f uA", r.iTotalUa)) + .foregroundStyle(Color(red: 1, green: 0.6, blue: 0.2)) + Text(String(format: "Combined: %.3f uA", r.iTotalUa - r.iFreeUa)) + .foregroundStyle(.secondary) + } + .font(.subheadline.monospacedDigit()) + .padding(.horizontal) + .padding(.vertical, 6) + } + + private var clChart: some View { + Chart { + ForEach(Array(points.enumerated()), id: \.offset) { _, pt in + LineMark( + x: .value("t", Double(pt.tMs)), + y: .value("I", Double(pt.iUa)) + ) + .foregroundStyle(by: .value("Phase", phaseLabel(pt.phase))) + .lineStyle(StrokeStyle(lineWidth: 2)) + } + } + .chartForegroundStyleScale([ + "Conditioning": Color.gray, + "Free": Color(red: 0.2, green: 1, blue: 0.5), + "Total": Color(red: 1, green: 0.6, blue: 0.2), + ]) + .chartXAxisLabel("Time (ms)") + .chartYAxisLabel("Current (uA)") + .chartXAxis { darkAxis } + .chartYAxis { darkAxisLeading } + .chartLegend(position: .top) + } + + private func phaseLabel(_ phase: UInt8) -> String { + switch phase { + case 1: "Free" + case 2: "Total" + default: "Conditioning" + } + } + + private var clTable: some View { + MeasurementTable( + columns: [ + MeasurementColumn(header: "t (ms)", width: 80, alignment: .trailing), + MeasurementColumn(header: "I (uA)", width: 90, alignment: .trailing), + MeasurementColumn(header: "Phase", width: 70, alignment: .trailing), + ], + rows: points, + cellText: { pt, col in + switch col { + case 0: String(format: "%.1f", pt.tMs) + case 1: String(format: "%.3f", pt.iUa) + case 2: phaseLabel(pt.phase) + default: "" + } + } + ) + } + + private var noData: some View { + Text("No data points") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - pH data view + +struct PhDataView: View { + let result: PhResult? + + var body: some View { + VStack(spacing: 0) { + if let r = result { + VStack(alignment: .leading, spacing: 12) { + Text(String(format: "pH: %.2f", r.ph)) + .font(.system(size: 56, weight: .bold, design: .rounded)) + .foregroundStyle(.primary) + + Text(String(format: "OCP: %.1f mV", r.vOcpMv)) + .font(.title3) + .foregroundStyle(.secondary) + + Text(String(format: "Temperature: %.1f C", r.tempC)) + .font(.title3) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding() + } else { + Text("No pH result") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } +} + +// MARK: - Shared axis styles + +private var darkAxis: some AxisContent { + AxisMarks { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(.secondary) + } +} + +private var darkAxisLeading: some AxisContent { + AxisMarks(position: .leading) { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(.secondary) + } +} From 6eab85af5783e8f36a9fb09001f94f1051950f28 Mon Sep 17 00:00:00 2001 From: jess Date: Fri, 3 Apr 2026 07:07:23 -0700 Subject: [PATCH 08/11] wire measurement rows to data views via NavigationLink --- cue-ios/CueIOS/Views/SessionView.swift | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/cue-ios/CueIOS/Views/SessionView.swift b/cue-ios/CueIOS/Views/SessionView.swift index f108020..6c20ce7 100644 --- a/cue-ios/CueIOS/Views/SessionView.swift +++ b/cue-ios/CueIOS/Views/SessionView.swift @@ -197,10 +197,12 @@ struct SessionDetailView: View { @State private var exportFileURL: URL? var body: some View { - VStack(alignment: .leading, spacing: 0) { - header - Divider() - measurementsList + NavigationStack { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + measurementsList + } } .onAppear { loadMeasurements() } .onChange(of: session.id) { loadMeasurements() } @@ -308,7 +310,11 @@ struct SessionDetailView: View { } else { List { ForEach(measurements, id: \.id) { meas in - MeasurementRow(measurement: meas, state: state) + NavigationLink { + MeasurementDataView(measurement: meas) + } label: { + MeasurementRow(measurement: meas, state: state) + } } .onDelete { indices in for idx in indices { From 95ca2f7cdc47f5766abbf5d385dc66c465c6dfe0 Mon Sep 17 00:00:00 2001 From: jess Date: Fri, 3 Apr 2026 07:08:25 -0700 Subject: [PATCH 09/11] inline axis styling in measurement data views --- .../CueIOS/Views/MeasurementDataView.swift | 141 ++++++++++++++---- 1 file changed, 108 insertions(+), 33 deletions(-) diff --git a/cue-ios/CueIOS/Views/MeasurementDataView.swift b/cue-ios/CueIOS/Views/MeasurementDataView.swift index 19dc315..65f21ac 100644 --- a/cue-ios/CueIOS/Views/MeasurementDataView.swift +++ b/cue-ios/CueIOS/Views/MeasurementDataView.swift @@ -159,8 +159,24 @@ struct EisDataView: View { } .chartXAxisLabel("Z' (Ohm)") .chartYAxisLabel("-Z'' (Ohm)") - .chartXAxis { darkAxis } - .chartYAxis { darkAxisLeading } + .chartXAxis { + AxisMarks { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(.secondary) + } + } } private var bodeMagChart: some View { @@ -184,8 +200,24 @@ struct EisDataView: View { } .chartXAxisLabel("log10(Freq Hz)") .chartYAxisLabel("log10(|Z| Ohm)") - .chartXAxis { darkAxis } - .chartYAxis { darkAxisLeading } + .chartXAxis { + AxisMarks { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(.secondary) + } + } } private var bodePhaseChart: some View { @@ -209,8 +241,24 @@ struct EisDataView: View { } .chartXAxisLabel("log10(Freq Hz)") .chartYAxisLabel("Phase (deg)") - .chartXAxis { darkAxis } - .chartYAxis { darkAxisLeading } + .chartXAxis { + AxisMarks { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(.secondary) + } + } } private var eisTable: some View { @@ -295,8 +343,24 @@ struct LsvDataView: View { } .chartXAxisLabel("Voltage (mV)") .chartYAxisLabel("Current (uA)") - .chartXAxis { darkAxis } - .chartYAxis { darkAxisLeading } + .chartXAxis { + AxisMarks { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(.secondary) + } + } } private var lsvTable: some View { @@ -376,8 +440,24 @@ struct AmpDataView: View { } .chartXAxisLabel("Time (ms)") .chartYAxisLabel("Current (uA)") - .chartXAxis { darkAxis } - .chartYAxis { darkAxisLeading } + .chartXAxis { + AxisMarks { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(.secondary) + } + } } private var ampTable: some View { @@ -472,8 +552,24 @@ struct ClDataView: View { ]) .chartXAxisLabel("Time (ms)") .chartYAxisLabel("Current (uA)") - .chartXAxis { darkAxis } - .chartYAxis { darkAxisLeading } + .chartXAxis { + AxisMarks { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(.secondary) + } + } .chartLegend(position: .top) } @@ -543,24 +639,3 @@ struct PhDataView: View { } } -// MARK: - Shared axis styles - -private var darkAxis: some AxisContent { - AxisMarks { _ in - AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(Color.gray.opacity(0.3)) - AxisValueLabel() - .font(.caption2) - .foregroundStyle(.secondary) - } -} - -private var darkAxisLeading: some AxisContent { - AxisMarks(position: .leading) { _ in - AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(Color.gray.opacity(0.3)) - AxisValueLabel() - .font(.caption2) - .foregroundStyle(.secondary) - } -} From b6ff02bdb4ce781103ae98f8156a5eb98249ed9e Mon Sep 17 00:00:00 2001 From: jess Date: Fri, 3 Apr 2026 07:13:24 -0700 Subject: [PATCH 10/11] add esp_timer to CMakeLists REQUIRES for esp_timer_get_time --- main/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 0c4470b..48201f9 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,6 +1,6 @@ idf_component_register(SRCS "eis4.c" "eis.c" "echem.c" "protocol.c" "wifi_transport.c" "temp.c" "refs.c" INCLUDE_DIRS "." - REQUIRES ad5941 ad5941_port nvs_flash esp_wifi esp_netif esp_event) + REQUIRES ad5941 ad5941_port nvs_flash esp_wifi esp_netif esp_event esp_timer) if(DEFINED ENV{WIFI_SSID}) target_compile_definitions(${COMPONENT_LIB} PRIVATE From 35be164188ef2b83a6c77e1be8f64c59dece257d Mon Sep 17 00:00:00 2001 From: jess Date: Fri, 3 Apr 2026 07:14:02 -0700 Subject: [PATCH 11/11] update refs.c call sites for new send_sweep_start and send_ph_result signatures --- main/refs.c | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/main/refs.c b/main/refs.c index 94cc15e..02f3796 100644 --- a/main/refs.c +++ b/main/refs.c @@ -7,6 +7,7 @@ #include #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "esp_timer.h" extern const uint32_t lp_rtia_map[]; extern const float lp_rtia_ohms[]; @@ -232,7 +233,8 @@ void refs_collect(RefStore *store, const EISConfig *cfg) send_ref_frame(REF_MODE_EIS, (uint8_t)r); uint32_t n = eis_calc_num_points(&ref_cfg); - send_sweep_start(n, ref_cfg.freq_start_hz, ref_cfg.freq_stop_hz); + uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000); + send_sweep_start(n, ref_cfg.freq_start_hz, ref_cfg.freq_stop_hz, ts_ms, 0); int got = eis_sweep(store->eis[r].pts, n, send_eis_point); store->eis[r].n_points = (uint32_t)got; @@ -270,7 +272,11 @@ void refs_collect(RefStore *store, const EISConfig *cfg) ph_cfg.temp_c = temp_get(); echem_ph_ocp(&ph_cfg, &store->ph_ref); store->ph_valid = 1; - send_ph_result(store->ph_ref.v_ocp_mv, store->ph_ref.ph, store->ph_ref.temp_c); + { + uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000); + send_ph_result(store->ph_ref.v_ocp_mv, store->ph_ref.ph, store->ph_ref.temp_c, + ts_ms, 0); + } store->has_refs = 1; send_refs_done(); @@ -291,7 +297,7 @@ void refs_send(const RefStore *store) send_ref_frame(REF_MODE_EIS, (uint8_t)r); uint32_t n = store->eis[r].n_points; send_sweep_start(n, store->eis[r].pts[0].freq_hz, - store->eis[r].pts[n - 1].freq_hz); + store->eis[r].pts[n - 1].freq_hz, 0, 0); for (uint32_t i = 0; i < n; i++) send_eis_point((uint16_t)i, &store->eis[r].pts[i]); send_sweep_end(); @@ -306,7 +312,8 @@ void refs_send(const RefStore *store) if (store->ph_valid) { send_ref_frame(REF_MODE_PH, 0); - send_ph_result(store->ph_ref.v_ocp_mv, store->ph_ref.ph, store->ph_ref.temp_c); + send_ph_result(store->ph_ref.v_ocp_mv, store->ph_ref.ph, store->ph_ref.temp_c, + 0, 0); } send_refs_done();