Merge branch 'chlorine-calibration-ui'

This commit is contained in:
jess 2026-04-02 18:37:38 -07:00
commit 3c33c7806d
13 changed files with 210 additions and 1 deletions

View File

@ -95,6 +95,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"
@ -256,6 +258,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)
}
}

View File

@ -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]
}

View File

@ -95,6 +95,7 @@ final class UDPManager: @unchecked Sendable {
send(buildSysexGetTemp())
send(buildSysexGetConfig())
send(buildSysexGetCellK())
send(buildSysexGetClFactor())
startTimers()
receiveLoop()

View File

@ -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 {

View File

@ -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",

View File

@ -98,6 +98,8 @@ pub enum Message {
CalBleachChanged(String),
CalTempChanged(String),
CalComputeK,
ClCalKnownPpmChanged(String),
ClSetFactor,
/* Global */
PollTemp,
NativeMenuTick,
@ -231,6 +233,8 @@ pub struct App {
cal_bleach_pct: String,
cal_temp_c: String,
cal_cell_constant: Option<f32>,
cl_factor: Option<f32>,
cl_cal_known_ppm: String,
/* Global */
temp_c: f32,
@ -463,6 +467,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,
@ -567,6 +573,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") {
@ -743,6 +750,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 {
@ -968,6 +979,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::<f32>().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,
@ -1574,6 +1598,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;
@ -1681,6 +1709,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),

View File

@ -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<EisMessage> {
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<u8> {
pub fn build_sysex_get_cell_k() -> Vec<u8> {
vec![0xF0, SYSEX_MFR, CMD_GET_CELL_K, 0xF7]
}
pub fn build_sysex_set_cl_factor(f: f32) -> Vec<u8> {
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<u8> {
vec![0xF0, SYSEX_MFR, CMD_GET_CL_FACTOR, 0xF7]
}

View File

@ -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);
}

View File

@ -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

View File

@ -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;

View File

@ -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)

View File

@ -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);

View File

@ -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: