From 4c914a510188f5e23df70b56f4762dcc827b123a Mon Sep 17 00:00:00 2001 From: jess Date: Thu, 2 Apr 2026 18:19:04 -0700 Subject: [PATCH 1/5] add cl_factor NVS storage and protocol commands to firmware --- main/eis.c | 29 ++++++++++++++++++++++++++++- main/eis.h | 4 ++++ main/eis4.c | 11 +++++++++++ main/protocol.c | 12 ++++++++++++ main/protocol.h | 7 +++++++ main/wifi_transport.c | 5 +++++ 6 files changed, 67 insertions(+), 1 deletion(-) diff --git a/main/eis.c b/main/eis.c index 4ba43cd..833e67a 100644 --- a/main/eis.c +++ b/main/eis.c @@ -25,6 +25,7 @@ static struct { /* cell constant K (cm⁻¹), cached from NVS */ static float cell_k_cached; +static float cl_factor_cached; /* open-circuit calibration data */ static struct { @@ -593,7 +594,8 @@ int eis_has_open_cal(void) return ocal.valid; } -#define NVS_CELLK_KEY "cell_k" +#define NVS_CELLK_KEY "cell_k" +#define NVS_CLFACTOR_KEY "cl_factor" void eis_set_cell_k(float k) { @@ -619,3 +621,28 @@ void eis_load_cell_k(void) cell_k_cached = 0.0f; nvs_close(h); } + +void eis_set_cl_factor(float f) +{ + cl_factor_cached = f; + nvs_handle_t h; + if (nvs_open(NVS_OCAL_NS, NVS_READWRITE, &h) != ESP_OK) return; + nvs_set_blob(h, NVS_CLFACTOR_KEY, &f, sizeof(f)); + nvs_commit(h); + nvs_close(h); +} + +float eis_get_cl_factor(void) +{ + return cl_factor_cached; +} + +void eis_load_cl_factor(void) +{ + nvs_handle_t h; + if (nvs_open(NVS_OCAL_NS, NVS_READONLY, &h) != ESP_OK) return; + size_t len = sizeof(cl_factor_cached); + if (nvs_get_blob(h, NVS_CLFACTOR_KEY, &cl_factor_cached, &len) != ESP_OK || len != sizeof(cl_factor_cached)) + cl_factor_cached = 0.0f; + nvs_close(h); +} diff --git a/main/eis.h b/main/eis.h index c4cd28c..8fbb4d3 100644 --- a/main/eis.h +++ b/main/eis.h @@ -67,4 +67,8 @@ void eis_set_cell_k(float k); float eis_get_cell_k(void); void eis_load_cell_k(void); +void eis_set_cl_factor(float f); +float eis_get_cl_factor(void); +void eis_load_cl_factor(void); + #endif diff --git a/main/eis4.c b/main/eis4.c index 5385e6d..dace30e 100644 --- a/main/eis4.c +++ b/main/eis4.c @@ -56,6 +56,7 @@ void app_main(void) eis_default_config(&cfg); eis_load_open_cal(); eis_load_cell_k(); + eis_load_cl_factor(); temp_init(); esp_netif_init(); @@ -214,6 +215,16 @@ void app_main(void) send_cell_k(eis_get_cell_k()); break; + case CMD_SET_CL_FACTOR: + eis_set_cl_factor(cmd.cl_factor); + send_cl_factor(cmd.cl_factor); + printf("Cl factor set: %.6f\n", cmd.cl_factor); + break; + + case CMD_GET_CL_FACTOR: + send_cl_factor(eis_get_cl_factor()); + break; + case CMD_START_CL: { ClConfig cl_cfg; cl_cfg.v_cond = cmd.cl.v_cond; diff --git a/main/protocol.c b/main/protocol.c index 08a9c47..504a6ed 100644 --- a/main/protocol.c +++ b/main/protocol.c @@ -341,6 +341,18 @@ int send_cell_k(float k) return send_sysex(sx, p); } +/* ---- outbound: chlorine factor ---- */ + +int send_cl_factor(float f) +{ + uint8_t sx[12]; + uint16_t p = 0; + sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_CL_FACTOR; + encode_float(f, &sx[p]); p += 5; + sx[p++] = 0xF7; + return send_sysex(sx, p); +} + /* ---- outbound: reference collection ---- */ int send_ref_frame(uint8_t mode, uint8_t rtia_idx) diff --git a/main/protocol.h b/main/protocol.h index 54b756b..8ab5709 100644 --- a/main/protocol.h +++ b/main/protocol.h @@ -26,6 +26,8 @@ #define CMD_START_REFS 0x30 #define CMD_GET_REFS 0x31 #define CMD_CLEAR_REFS 0x32 +#define CMD_SET_CL_FACTOR 0x33 +#define CMD_GET_CL_FACTOR 0x34 /* Session sync commands (0x4x) */ #define CMD_SESSION_CREATE 0x40 @@ -56,6 +58,7 @@ #define RSP_REF_LP_RANGE 0x21 #define RSP_REFS_DONE 0x22 #define RSP_REF_STATUS 0x23 +#define RSP_CL_FACTOR 0x24 /* Session sync responses (0x4x) */ #define RSP_SESSION_CREATED 0x40 @@ -81,6 +84,7 @@ typedef struct { struct { float stabilize_s; } ph; struct { float v_mv; float duration_s; } clean; float cell_k; + float cl_factor; struct { uint8_t name_len; char name[MAX_SESSION_NAME]; } session_create; struct { uint8_t id; } session_switch; struct { uint8_t id; uint8_t name_len; char name[MAX_SESSION_NAME]; } session_rename; @@ -132,6 +136,9 @@ int send_temp(float temp_c); /* outbound: cell constant */ int send_cell_k(float k); +/* outbound: chlorine factor */ +int send_cl_factor(float f); + /* outbound: reference collection */ int send_ref_frame(uint8_t mode, uint8_t rtia_idx); int send_ref_lp_range(uint8_t mode, uint8_t low_idx, uint8_t high_idx); diff --git a/main/wifi_transport.c b/main/wifi_transport.c index df02497..1d05ba4 100644 --- a/main/wifi_transport.c +++ b/main/wifi_transport.c @@ -166,6 +166,10 @@ static void parse_udp_sysex(const uint8_t *data, uint16_t len) if (len < 8) return; cmd.cell_k = decode_float(&data[3]); break; + case CMD_SET_CL_FACTOR: + if (len < 8) return; + cmd.cl_factor = decode_float(&data[3]); + break; case CMD_SESSION_CREATE: if (len < 5) return; cmd.session_create.name_len = data[3] & 0x7F; @@ -192,6 +196,7 @@ static void parse_udp_sysex(const uint8_t *data, uint16_t len) case CMD_STOP_AMP: case CMD_GET_TEMP: case CMD_GET_CELL_K: + case CMD_GET_CL_FACTOR: case CMD_START_REFS: case CMD_GET_REFS: case CMD_CLEAR_REFS: From 2e1a2f98f22ab71292183d0f858d65db6f59083d Mon Sep 17 00:00:00 2001 From: jess Date: Thu, 2 Apr 2026 18:19:51 -0700 Subject: [PATCH 2/5] add cl_factor protocol support to desktop cue --- cue/src/protocol.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/cue/src/protocol.rs b/cue/src/protocol.rs index 9ab45d6..58d2a0b 100644 --- a/cue/src/protocol.rs +++ b/cue/src/protocol.rs @@ -26,6 +26,7 @@ pub const RSP_REF_LP_RANGE: u8 = 0x21; pub const RSP_REFS_DONE: u8 = 0x22; pub const RSP_CELL_K: u8 = 0x11; pub const RSP_REF_STATUS: u8 = 0x23; +pub const RSP_CL_FACTOR: u8 = 0x24; /* Cue → ESP32 */ pub const CMD_SET_SWEEP: u8 = 0x10; @@ -44,6 +45,8 @@ pub const CMD_START_PH: u8 = 0x24; pub const CMD_START_CLEAN: u8 = 0x25; pub const CMD_SET_CELL_K: u8 = 0x28; pub const CMD_GET_CELL_K: u8 = 0x29; +pub const CMD_SET_CL_FACTOR: u8 = 0x33; +pub const CMD_GET_CL_FACTOR: u8 = 0x34; pub const CMD_START_REFS: u8 = 0x30; pub const CMD_GET_REFS: u8 = 0x31; pub const CMD_CLEAR_REFS: u8 = 0x32; @@ -258,6 +261,7 @@ pub enum EisMessage { RefsDone, RefStatus { has_refs: bool }, CellK(f32), + ClFactor(f32), } fn decode_u16(data: &[u8]) -> u16 { @@ -413,6 +417,10 @@ pub fn parse_sysex(data: &[u8]) -> Option { let p = &data[2..]; Some(EisMessage::CellK(decode_float(&p[0..5]))) } + RSP_CL_FACTOR if data.len() >= 7 => { + let p = &data[2..]; + Some(EisMessage::ClFactor(decode_float(&p[0..5]))) + } _ => None, } } @@ -527,3 +535,14 @@ pub fn build_sysex_set_cell_k(k: f32) -> Vec { pub fn build_sysex_get_cell_k() -> Vec { vec![0xF0, SYSEX_MFR, CMD_GET_CELL_K, 0xF7] } + +pub fn build_sysex_set_cl_factor(f: f32) -> Vec { + let mut sx = vec![0xF0, SYSEX_MFR, CMD_SET_CL_FACTOR]; + sx.extend_from_slice(&encode_float(f)); + sx.push(0xF7); + sx +} + +pub fn build_sysex_get_cl_factor() -> Vec { + vec![0xF0, SYSEX_MFR, CMD_GET_CL_FACTOR, 0xF7] +} From d84ed33c145f81bd6752f31f6e093c52d89254b0 Mon Sep 17 00:00:00 2001 From: jess Date: Thu, 2 Apr 2026 18:28:57 -0700 Subject: [PATCH 3/5] add cl_factor UI and state to desktop cue --- cue/src/app.rs | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/cue/src/app.rs b/cue/src/app.rs index 4a5ffba..643372b 100644 --- a/cue/src/app.rs +++ b/cue/src/app.rs @@ -98,6 +98,8 @@ pub enum Message { CalBleachChanged(String), CalTempChanged(String), CalComputeK, + ClCalKnownPpmChanged(String), + ClSetFactor, /* Global */ PollTemp, NativeMenuTick, @@ -227,6 +229,8 @@ pub struct App { cal_bleach_pct: String, cal_temp_c: String, cal_cell_constant: Option, + cl_factor: Option, + cl_cal_known_ppm: String, /* Global */ temp_c: f32, @@ -457,6 +461,8 @@ impl App { cal_bleach_pct: "7.825".into(), cal_temp_c: "40".into(), cal_cell_constant: None, + cl_factor: None, + cl_cal_known_ppm: String::from("5"), temp_c: 25.0, conn_gen: 0, @@ -561,6 +567,7 @@ impl App { self.connected = true; self.send_cmd(&protocol::build_sysex_get_config()); self.send_cmd(&protocol::build_sysex_get_cell_k()); + self.send_cmd(&protocol::build_sysex_get_cl_factor()); } Message::DeviceStatus(s) => { if s.contains("Reconnecting") || s.contains("Connecting") { @@ -734,6 +741,10 @@ impl App { self.cal_cell_constant = Some(k); self.status = format!("Device cell constant: {:.4} cm-1", k); } + EisMessage::ClFactor(f) => { + self.cl_factor = Some(f); + self.status = format!("Device Cl factor: {:.6}", f); + } }, Message::TabSelected(t) => { if t == Tab::Browse { @@ -951,6 +962,19 @@ impl App { self.status = "No valid EIS data for Rs extraction".into(); } } + Message::ClCalKnownPpmChanged(s) => { self.cl_cal_known_ppm = s; } + Message::ClSetFactor => { + let known_ppm = self.cl_cal_known_ppm.parse::().unwrap_or(0.0); + if let Some(r) = &self.cl_result { + let peak = r.i_free_ua.abs(); + if peak > 0.0 { + let factor = known_ppm / peak; + self.cl_factor = Some(factor); + self.send_cmd(&protocol::build_sysex_set_cl_factor(factor)); + self.status = format!("Cl factor: {:.6} ppm/uA", factor); + } + } + } /* Clean */ Message::CleanVChanged(s) => self.clean_v = s, Message::CleanDurChanged(s) => self.clean_dur = s, @@ -1538,6 +1562,10 @@ impl App { "Free: {:.3} uA | Total: {:.3} uA | Combined: {:.3} uA", r.i_free_ua, r.i_total_ua, r.i_total_ua - r.i_free_ua )); + if let (Some(f), Some(r)) = (self.cl_factor, &self.cl_result) { + let ppm = f * r.i_free_ua.abs(); + result_parts.push(format!("Free Cl: {:.2} ppm", ppm)); + } if let Some((_, ref_r)) = &self.cl_ref { let df = r.i_free_ua - ref_r.i_free_ua; let dt = r.i_total_ua - ref_r.i_total_ua; @@ -1645,6 +1673,28 @@ impl App { .on_press(Message::CalComputeK); results = results.push(compute_btn); + results = results.push(iced::widget::horizontal_rule(1)); + results = results.push(text("Chlorine Calibration").size(16)); + if let Some(f) = self.cl_factor { + results = results.push(text(format!("Cl factor: {:.6} ppm/uA", f)).size(14)); + } + if let Some(r) = &self.cl_result { + results = results.push(text(format!("Last free Cl peak: {:.3} uA", r.i_free_ua)).size(14)); + } + results = results.push( + row![ + column![ + text("Known Cl ppm").size(12), + text_input("5", &self.cl_cal_known_ppm) + .on_input(Message::ClCalKnownPpmChanged).width(80), + ].spacing(2), + button(text("Set Cl Factor").size(13)) + .style(style_action()) + .padding([6, 16]) + .on_press(Message::ClSetFactor), + ].spacing(10).align_y(iced::Alignment::End) + ); + row![ container(inputs).width(Length::FillPortion(2)), iced::widget::vertical_rule(1), From e8ce7eb98c7f4a2e749dbbf4c680f49d49b7775b Mon Sep 17 00:00:00 2001 From: jess Date: Thu, 2 Apr 2026 18:29:29 -0700 Subject: [PATCH 4/5] add cl_factor protocol support to iOS cue --- cue-ios/CueIOS/Models/Protocol.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/cue-ios/CueIOS/Models/Protocol.swift b/cue-ios/CueIOS/Models/Protocol.swift index a0702f3..61f9958 100644 --- a/cue-ios/CueIOS/Models/Protocol.swift +++ b/cue-ios/CueIOS/Models/Protocol.swift @@ -29,6 +29,7 @@ let RSP_REF_FRAME: UInt8 = 0x20 let RSP_REF_LP_RANGE: UInt8 = 0x21 let RSP_REFS_DONE: UInt8 = 0x22 let RSP_REF_STATUS: UInt8 = 0x23 +let RSP_CL_FACTOR: UInt8 = 0x24 // Cue -> ESP32 let CMD_SET_SWEEP: UInt8 = 0x10 @@ -46,6 +47,8 @@ let CMD_START_PH: UInt8 = 0x24 let CMD_START_CLEAN: UInt8 = 0x25 let CMD_SET_CELL_K: UInt8 = 0x28 let CMD_GET_CELL_K: UInt8 = 0x29 +let CMD_SET_CL_FACTOR: UInt8 = 0x33 +let CMD_GET_CL_FACTOR: UInt8 = 0x34 let CMD_START_REFS: UInt8 = 0x30 let CMD_GET_REFS: UInt8 = 0x31 let CMD_CLEAR_REFS: UInt8 = 0x32 @@ -123,6 +126,7 @@ enum EisMessage { case refsDone case refStatus(hasRefs: Bool) case cellK(Float) + case clFactor(Float) } // MARK: - Response parser @@ -254,6 +258,9 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? { case RSP_CELL_K where p.count >= 5: return .cellK(decodeFloat(p, at: 0)) + case RSP_CL_FACTOR where p.count >= 5: + return .clFactor(decodeFloat(p, at: 0)) + default: return nil } @@ -371,3 +378,14 @@ func buildSysexSetCellK(_ k: Float) -> [UInt8] { func buildSysexGetCellK() -> [UInt8] { [0xF0, sysexMfr, CMD_GET_CELL_K, 0xF7] } + +func buildSysexSetClFactor(_ f: Float) -> [UInt8] { + var sx: [UInt8] = [0xF0, sysexMfr, CMD_SET_CL_FACTOR] + sx.append(contentsOf: encodeFloat(f)) + sx.append(0xF7) + return sx +} + +func buildSysexGetClFactor() -> [UInt8] { + [0xF0, sysexMfr, CMD_GET_CL_FACTOR, 0xF7] +} From 0cfeb287e63f51e325d15787c8683aee0cc367ec Mon Sep 17 00:00:00 2001 From: jess Date: Thu, 2 Apr 2026 18:30:31 -0700 Subject: [PATCH 5/5] add cl_factor UI and state to iOS cue --- cue-ios/CueIOS/AppState.swift | 6 ++++ cue-ios/CueIOS/Transport/UDPManager.swift | 1 + cue-ios/CueIOS/Views/CalibrateView.swift | 43 +++++++++++++++++++++++ cue-ios/CueIOS/Views/ChlorineView.swift | 6 ++++ 4 files changed, 56 insertions(+) diff --git a/cue-ios/CueIOS/AppState.swift b/cue-ios/CueIOS/AppState.swift index e3243e2..b623e4c 100644 --- a/cue-ios/CueIOS/AppState.swift +++ b/cue-ios/CueIOS/AppState.swift @@ -93,6 +93,8 @@ final class AppState { var calTempC: String = "40" var calCellConstant: Double? = nil var calRs: Double? = nil + var clFactor: Double? = nil + var clCalKnownPpm: String = "5" // Clean var cleanV: String = "1200" @@ -251,6 +253,10 @@ final class AppState { case .cellK(let k): calCellConstant = Double(k) status = String(format: "Device cell constant: %.4f cm\u{207B}\u{00B9}", k) + + case .clFactor(let f): + clFactor = Double(f) + status = String(format: "Device Cl factor: %.6f", f) } } diff --git a/cue-ios/CueIOS/Transport/UDPManager.swift b/cue-ios/CueIOS/Transport/UDPManager.swift index 6bafac0..726792f 100644 --- a/cue-ios/CueIOS/Transport/UDPManager.swift +++ b/cue-ios/CueIOS/Transport/UDPManager.swift @@ -95,6 +95,7 @@ final class UDPManager: @unchecked Sendable { send(buildSysexGetTemp()) send(buildSysexGetConfig()) send(buildSysexGetCellK()) + send(buildSysexGetClFactor()) startTimers() receiveLoop() diff --git a/cue-ios/CueIOS/Views/CalibrateView.swift b/cue-ios/CueIOS/Views/CalibrateView.swift index 95d41e7..23486a3 100644 --- a/cue-ios/CueIOS/Views/CalibrateView.swift +++ b/cue-ios/CueIOS/Views/CalibrateView.swift @@ -14,6 +14,7 @@ struct CalibrateView: View { inputSection resultsSection cellConstantSection + chlorineCalSection } .navigationTitle("Calibrate") } @@ -115,6 +116,48 @@ struct CalibrateView: View { } } + // MARK: - Chlorine calibration + + private var chlorineCalSection: some View { + Section("Chlorine Calibration") { + if let f = state.clFactor { + Text(String(format: "Cl factor: %.6f ppm/\u{00B5}A", f)) + } + if let r = state.clResult { + Text(String(format: "Last free Cl peak: %.3f \u{00B5}A", r.iFreeUa)) + } + + HStack { + Text("Known Cl ppm") + Spacer() + TextField("ppm", text: $state.clCalKnownPpm) + .multilineTextAlignment(.trailing) + .frame(width: 80) + #if os(iOS) + .keyboardType(.decimalPad) + #endif + } + + Button("Set Cl Factor") { + guard let r = state.clResult else { + state.status = "No chlorine measurement" + return + } + let knownPpm = Double(state.clCalKnownPpm) ?? 0 + let peak = abs(Double(r.iFreeUa)) + guard peak > 0 else { + state.status = "Peak current is zero" + return + } + let factor = knownPpm / peak + state.clFactor = factor + state.send(buildSysexSetClFactor(Float(factor))) + state.status = String(format: "Cl factor: %.6f ppm/\u{00B5}A", factor) + } + .disabled(state.clResult == nil) + } + } + // MARK: - Calculations private func saltGrams(volumeGal: Double, ppm: Double) -> Double { diff --git a/cue-ios/CueIOS/Views/ChlorineView.swift b/cue-ios/CueIOS/Views/ChlorineView.swift index 30f6951..9e2ba0b 100644 --- a/cue-ios/CueIOS/Views/ChlorineView.swift +++ b/cue-ios/CueIOS/Views/ChlorineView.swift @@ -68,6 +68,12 @@ struct ChlorineView: View { Text(String(format: "Combined: %.3f uA", r.iTotalUa - r.iFreeUa)) .foregroundStyle(.secondary) + if let f = state.clFactor { + let ppm = f * Double(abs(r.iFreeUa)) + Text(String(format: "Free Cl: %.2f ppm", ppm)) + .foregroundStyle(.cyan) + } + if let (_, refR) = state.clRef { Divider().frame(height: 16) Text(String(format: "vs Ref: dFree=%.3f dTotal=%.3f",