use futures::SinkExt; use iced::widget::{ button, canvas, column, container, pane_grid, pick_list, row, rule, scrollable, text, text_editor, text_input, }; use iced::widget::button::Style as ButtonStyle; use iced::{Border, Color, Element, Length, Subscription, Task, Theme}; use std::fmt::Write; use std::time::Duration; use tokio::sync::mpsc; use crate::native_menu::{MenuAction, NativeMenu}; use crate::protocol::{ self, AmpPoint, ClPoint, ClResult, Electrode, EisMessage, EisPoint, LpRtia, LsvPoint, PhResult, Rcal, Rtia, }; use crate::storage::{self, Session, Storage}; use crate::udp::UdpEvent; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tab { Eis, Lsv, Amp, Chlorine, Ph, Calibrate, Browse, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PaneId { Plot, Data, } #[derive(Debug, Clone, PartialEq, Eq)] enum SessionItem { None, Some(i64, String), } impl std::fmt::Display for SessionItem { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { SessionItem::None => f.write_str("(none)"), SessionItem::Some(_, name) => f.write_str(name), } } } #[derive(Debug, Clone)] pub enum Message { DeviceReady(mpsc::UnboundedSender>), DeviceStatus(String), DeviceData(EisMessage), TabSelected(Tab), PaneResized(pane_grid::ResizeEvent), DataAction(text_editor::Action), /* EIS */ FreqStartChanged(String), FreqStopChanged(String), PpdChanged(String), RtiaSelected(Rtia), RcalSelected(Rcal), ElectrodeSelected(Electrode), ApplySettings, StartSweep, /* LSV */ LsvStartVChanged(String), LsvStopVChanged(String), LsvScanRateChanged(String), LsvRtiaSelected(LpRtia), StartLsv, /* Amperometry */ AmpVholdChanged(String), AmpIntervalChanged(String), AmpDurationChanged(String), AmpRtiaSelected(LpRtia), StartAmp, StopAmp, /* Chlorine */ ClCondVChanged(String), ClCondTChanged(String), ClFreeVChanged(String), ClTotalVChanged(String), ClDepTChanged(String), ClMeasTChanged(String), ClRtiaSelected(LpRtia), StartCl, /* pH */ PhStabilizeChanged(String), StartPh, /* Calibration */ CalVolumeChanged(String), CalNaclChanged(String), CalClChanged(String), CalBleachChanged(String), CalTempChanged(String), CalComputeK, ClCalKnownPpmChanged(String), ClSetFactor, /* pH calibration */ PhCalKnownChanged(String), PhAddCalPoint, PhClearCalPoints, PhComputeAndSetCal, /* Global */ PollTemp, NativeMenuTick, CloseSysInfo, /* Reference baseline */ SetReference, ClearReference, CollectRefs, GetRefs, ClearRefs, /* Clean */ CleanVChanged(String), CleanDurChanged(String), StartClean, /* Sessions */ CreateSession, SelectSession(Option), DeleteSession, SessionNameInput(String), /* Browse */ BrowseSelectSession(i64), BrowseSelectMeasurement(i64), BrowseLoadAsActive(i64), BrowseLoadAsReference(i64), BrowseDeleteMeasurement(i64), BrowseBack, ExportSession(i64), ImportSession, /* LSV analysis */ LsvToggleManual, /* Misc */ Reconnect, UdpAddrChanged(String), } pub struct App { tab: Tab, status: String, cmd_tx: Option>>, connected: bool, panes: pane_grid::State, native_menu: NativeMenu, show_sysinfo: bool, /* Storage */ storage: Storage, current_session: Option, sessions: Vec, session_name_input: String, creating_session: bool, /* Browse */ browse_sessions: Vec<(Session, i64)>, browse_measurements: Vec<(storage::Measurement, i64)>, browse_selected_session: Option, browse_selected_measurement: Option, browse_preview: String, /* EIS */ eis_points: Vec, sweep_total: u16, freq_start: String, freq_stop: String, ppd: String, rtia: Rtia, rcal: Rcal, electrode: Electrode, eis_data: text_editor::Content, /* LSV */ lsv_points: Vec, lsv_total: u16, lsv_start_v: String, lsv_stop_v: String, lsv_scan_rate: String, lsv_rtia: LpRtia, lsv_peaks: Vec, lsv_manual_peaks: bool, lsv_data: text_editor::Content, /* Amp */ amp_points: Vec, amp_total: u16, amp_running: bool, amp_v_hold: String, amp_interval: String, amp_duration: String, amp_rtia: LpRtia, amp_data: text_editor::Content, /* Chlorine */ cl_points: Vec, cl_result: Option, cl_total: u16, cl_cond_v: String, cl_cond_t: String, cl_free_v: String, cl_total_v: String, cl_dep_t: String, cl_meas_t: String, cl_rtia: LpRtia, cl_data: text_editor::Content, /* pH */ ph_result: Option, ph_stabilize: String, /* Reference baselines */ eis_ref: Option>, lsv_ref: Option>, amp_ref: Option>, cl_ref: Option<(Vec, ClResult)>, ph_ref: Option, /* Device reference collection */ collecting_refs: bool, ref_mode: Option, ref_rtia: Option, has_device_refs: bool, eis_refs: [Option>; 8], lsv_lp_range: Option<(u8, u8)>, amp_lp_range: Option<(u8, u8)>, cl_lp_range: Option<(u8, u8)>, /* Clean */ clean_v: String, clean_dur: String, /* Calibration */ cal_volume_gal: String, cal_nacl_ppm: String, cal_cl_ppm: String, cal_bleach_pct: String, cal_temp_c: String, cal_cell_constant: Option, cl_factor: Option, cl_cal_known_ppm: String, ph_slope: Option, ph_offset: Option, ph_cal_points: Vec<(f32, f32)>, ph_cal_known: String, /* Global */ temp_c: f32, conn_gen: u64, udp_addr: String, } /* ---- data table formatting ---- */ fn fmt_eis(pts: &[EisPoint]) -> String { let mut s = String::with_capacity(pts.len() * 130 + 130); writeln!(s, "{:>10} {:>12} {:>10} {:>12} {:>12} {:>10} {:>10} {:>12} {:>10} {:>7}", "Freq (Hz)", "|Z| (Ohm)", "Phase (°)", "Re (Ohm)", "Im (Ohm)", "RTIA bef", "RTIA aft", "|Z| rev", "Ph rev", "Err%").unwrap(); for pt in pts { writeln!(s, "{:>10.1} {:>12.2} {:>10.2} {:>12.2} {:>12.2} {:>10.1} {:>10.1} {:>12.2} {:>10.2} {:>6.2}%", pt.freq_hz, pt.mag_ohms, pt.phase_deg, pt.z_real, pt.z_imag, pt.rtia_mag_before, pt.rtia_mag_after, pt.rev_mag, pt.rev_phase, pt.pct_err).unwrap(); } s } fn fmt_lsv(pts: &[LsvPoint]) -> String { let mut s = String::with_capacity(pts.len() * 28 + 28); writeln!(s, "{:>10} {:>12}", "V (mV)", "I (uA)").unwrap(); for pt in pts { writeln!(s, "{:>10.1} {:>12.3}", pt.v_mv, pt.i_ua).unwrap(); } s } fn fmt_amp(pts: &[AmpPoint]) -> String { let mut s = String::with_capacity(pts.len() * 28 + 28); writeln!(s, "{:>10} {:>12}", "t (ms)", "I (uA)").unwrap(); for pt in pts { writeln!(s, "{:>10.1} {:>12.3}", pt.t_ms, pt.i_ua).unwrap(); } s } fn fmt_cl(pts: &[ClPoint]) -> String { let mut s = String::with_capacity(pts.len() * 36 + 36); writeln!(s, "{:>10} {:>12} {:>8}", "t (ms)", "I (uA)", "Phase").unwrap(); for pt in pts { let phase = match pt.phase { 1 => "Free", 2 => "Total", _ => "Cond" }; writeln!(s, "{:>10.1} {:>12.3} {:>8}", pt.t_ms, pt.i_ua, phase).unwrap(); } s } fn calc_salt_grams(volume_gal: f32, target_ppm: f32) -> f32 { let liters = volume_gal * 3.78541; target_ppm * liters / 1000.0 } fn calc_bleach_ml(volume_gal: f32, target_cl_ppm: f32, bleach_pct: f32) -> f32 { let liters = volume_gal * 3.78541; let cl_needed_mg = target_cl_ppm * liters; let bleach_mg_per_ml = bleach_pct * 10.0; cl_needed_mg / bleach_mg_per_ml } fn theoretical_conductivity_ms_cm(nacl_ppm: f32, temp_c: f32) -> f32 { let kappa_25 = nacl_ppm * 2.0 / 1000.0; kappa_25 * (1.0 + 0.0212 * (temp_c - 25.0)) } fn extract_rs(eis_points: &[EisPoint]) -> Option { eis_points.iter() .map(|p| p.z_real) .filter(|r| r.is_finite() && *r > 0.0) .min_by(|a, b| a.partial_cmp(b).unwrap()) } fn cell_constant(kappa_ms_cm: f32, rs_ohm: f32) -> f32 { (kappa_ms_cm / 1000.0) * rs_ohm } const SQUIRCLE: f32 = 8.0; fn btn_style(bg: Color, fg: Color) -> impl Fn(&Theme, button::Status) -> ButtonStyle { move |_theme, status| { let (bg, fg) = match status { button::Status::Hovered => ( Color { a: bg.a * 0.85, ..bg }, fg, ), button::Status::Pressed => ( Color { a: bg.a * 0.7, ..bg }, fg, ), button::Status::Disabled => ( Color { a: 0.3, ..bg }, Color { a: 0.5, ..fg }, ), _ => (bg, fg), }; ButtonStyle { background: Some(iced::Background::Color(bg)), text_color: fg, border: Border { radius: SQUIRCLE.into(), ..Border::default() }, ..ButtonStyle::default() } } } fn style_action() -> impl Fn(&Theme, button::Status) -> ButtonStyle { btn_style(Color::from_rgb(0.20, 0.65, 0.35), Color::WHITE) } fn style_danger() -> impl Fn(&Theme, button::Status) -> ButtonStyle { btn_style(Color::from_rgb(0.80, 0.25, 0.25), Color::WHITE) } fn style_apply() -> impl Fn(&Theme, button::Status) -> ButtonStyle { btn_style(Color::from_rgb(0.25, 0.50, 0.85), Color::WHITE) } fn style_tab(active: bool) -> impl Fn(&Theme, button::Status) -> ButtonStyle { let bg = if active { Color::from_rgb(0.35, 0.35, 0.40) } else { Color::from_rgb(0.22, 0.22, 0.25) }; btn_style(bg, Color::WHITE) } fn style_neutral() -> impl Fn(&Theme, button::Status) -> ButtonStyle { btn_style(Color::from_rgb(0.30, 0.30, 0.35), Color::WHITE) } impl App { pub fn new() -> (Self, Task) { let storage = Storage::open().expect("failed to open database"); let sessions = storage.list_sessions().unwrap_or_default(); (Self { tab: Tab::Eis, status: "Starting...".into(), cmd_tx: None, connected: false, panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split { axis: pane_grid::Axis::Horizontal, ratio: 0.55, a: Box::new(pane_grid::Configuration::Pane(PaneId::Plot)), b: Box::new(pane_grid::Configuration::Pane(PaneId::Data)), }), native_menu: NativeMenu::init(), show_sysinfo: false, storage, current_session: None, sessions, session_name_input: String::new(), creating_session: false, browse_sessions: Vec::new(), browse_measurements: Vec::new(), browse_selected_session: None, browse_selected_measurement: None, browse_preview: String::new(), eis_points: Vec::new(), sweep_total: 0, freq_start: "1000".into(), freq_stop: "200000".into(), ppd: "10".into(), rtia: Rtia::R5K, rcal: Rcal::R3K, electrode: Electrode::FourWire, eis_data: text_editor::Content::with_text(&fmt_eis(&[])), lsv_points: Vec::new(), lsv_total: 0, lsv_start_v: "0".into(), lsv_stop_v: "500".into(), lsv_scan_rate: "50".into(), lsv_rtia: LpRtia::R10K, lsv_peaks: Vec::new(), lsv_manual_peaks: false, lsv_data: text_editor::Content::with_text(&fmt_lsv(&[])), amp_points: Vec::new(), amp_total: 0, amp_running: false, amp_v_hold: "200".into(), amp_interval: "100".into(), amp_duration: "60".into(), amp_rtia: LpRtia::R10K, amp_data: text_editor::Content::with_text(&fmt_amp(&[])), cl_points: Vec::new(), cl_result: None, cl_total: 0, cl_cond_v: "800".into(), cl_cond_t: "2000".into(), cl_free_v: "100".into(), cl_total_v: "-200".into(), cl_dep_t: "5000".into(), cl_meas_t: "5000".into(), cl_rtia: LpRtia::R10K, cl_data: text_editor::Content::with_text(&fmt_cl(&[])), ph_result: None, ph_stabilize: "30".into(), eis_ref: None, lsv_ref: None, amp_ref: None, cl_ref: None, ph_ref: None, collecting_refs: false, ref_mode: None, ref_rtia: None, has_device_refs: false, eis_refs: Default::default(), lsv_lp_range: None, amp_lp_range: None, cl_lp_range: None, clean_v: "1200".into(), clean_dur: "30".into(), cal_volume_gal: "25".into(), cal_nacl_ppm: "2500".into(), cal_cl_ppm: "5".into(), 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"), ph_slope: None, ph_offset: None, ph_cal_points: vec![], ph_cal_known: String::from("7.00"), temp_c: 25.0, conn_gen: 0, udp_addr: crate::udp::load_addr(), }, Task::none()) } pub fn title(&self) -> String { "Cue".into() } pub fn theme(&self) -> Theme { Theme::Dark } fn send_cmd(&self, sysex: &[u8]) { if let Some(tx) = &self.cmd_tx { let _ = tx.send(sysex.to_vec()); } } fn save_eis(&self, session_id: i64) { let params = serde_json::json!({ "freq_start": self.freq_start, "freq_stop": self.freq_stop, "ppd": self.ppd, "rtia": format!("{}", self.rtia), "rcal": format!("{}", self.rcal), "electrode": format!("{}", self.electrode), }); if let Ok(mid) = self.storage.create_measurement(session_id, "eis", ¶ms.to_string()) { 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(); let _ = self.storage.add_data_points_batch(mid, &pts); } } fn save_lsv(&self, session_id: i64) { let params = serde_json::json!({ "v_start": self.lsv_start_v, "v_stop": self.lsv_stop_v, "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()) { 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(); let _ = self.storage.add_data_points_batch(mid, &pts); } } fn save_amp(&self, session_id: i64) { let params = serde_json::json!({ "v_hold": self.amp_v_hold, "interval_ms": self.amp_interval, "duration_s": self.amp_duration, "rtia": format!("{}", self.amp_rtia), }); if let Ok(mid) = self.storage.create_measurement(session_id, "amp", ¶ms.to_string()) { 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(); let _ = self.storage.add_data_points_batch(mid, &pts); } } fn save_cl(&self, session_id: i64) { let params = serde_json::json!({ "cond_v": self.cl_cond_v, "cond_t": self.cl_cond_t, "free_v": self.cl_free_v, "total_v": self.cl_total_v, "dep_t": self.cl_dep_t, "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()) { 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(); if let Some(r) = &self.cl_result { if let Ok(j) = serde_json::to_string(r) { pts.push((pts.len() as i32, format!("{{\"result\":{}}}", j))); } } let _ = self.storage.add_data_points_batch(mid, &pts); } } fn save_ph(&self, session_id: i64, result: &PhResult) { 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(j) = serde_json::to_string(result) { let _ = self.storage.add_data_point(mid, 0, &j); } } } pub fn update(&mut self, message: Message) -> Task { match message { Message::DeviceReady(tx) => { self.cmd_tx = Some(tx); 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()); self.send_cmd(&protocol::build_sysex_get_ph_cal()); } Message::DeviceStatus(s) => { if s.contains("Reconnecting") || s.contains("Connecting") { self.connected = false; self.cmd_tx = None; } self.status = s; } Message::DeviceData(msg) => match msg { EisMessage::SweepStart { num_points, freq_start, freq_stop } => { if self.collecting_refs { /* ref collection: clear temp buffer */ self.eis_points.clear(); self.sweep_total = num_points; } else { self.eis_points.clear(); self.sweep_total = num_points; self.eis_data = text_editor::Content::with_text(&fmt_eis(&self.eis_points)); self.status = format!("Sweep: {} pts, {:.0}--{:.0} Hz", num_points, freq_start, freq_stop); } } EisMessage::DataPoint { point, .. } => { if self.collecting_refs { self.eis_points.push(point); } else { self.eis_points.push(point); self.eis_data = text_editor::Content::with_text(&fmt_eis(&self.eis_points)); self.status = format!("Receiving: {}/{}", self.eis_points.len(), self.sweep_total); } } EisMessage::SweepEnd => { if self.collecting_refs { if let Some(r) = self.ref_rtia { if (r as usize) < 8 { self.eis_refs[r as usize] = Some(self.eis_points.clone()); } } self.eis_points.clear(); } else { if let Some(sid) = self.current_session { self.save_eis(sid); } self.status = format!("Sweep complete: {} points", self.eis_points.len()); } } EisMessage::Config(cfg) => { self.freq_start = format!("{:.0}", cfg.freq_start); self.freq_stop = format!("{:.0}", cfg.freq_stop); self.ppd = format!("{}", cfg.ppd); self.rtia = cfg.rtia; self.rcal = cfg.rcal; self.electrode = cfg.electrode; self.status = "Config received".into(); } 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)); self.status = format!("LSV: {} pts, {:.0}--{:.0} mV", num_points, v_start, v_stop); } EisMessage::LsvPoint { point, .. } => { self.lsv_points.push(point); self.lsv_data = text_editor::Content::with_text(&fmt_lsv(&self.lsv_points)); self.status = format!("LSV: {}/{}", self.lsv_points.len(), self.lsv_total); } EisMessage::LsvEnd => { if let Some(sid) = self.current_session { self.save_lsv(sid); } if !self.lsv_manual_peaks { self.lsv_peaks = crate::lsv_analysis::detect_peaks(&self.lsv_points); } let mut st = format!("LSV complete: {} points", self.lsv_points.len()); if let (Some(s), Some(o)) = (self.ph_slope, self.ph_offset) { if let Some(peak) = crate::lsv_analysis::detect_qhq_peak(&self.lsv_points) { if s.abs() > 1e-6 { let ph = (peak - o) / s; write!(st, " | pH={:.2}", ph).ok(); } } } self.status = st; } 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)); self.status = format!("Amp: {:.0} mV", v_hold); } EisMessage::AmpPoint { _index, point } => { self.amp_points.push(point); self.amp_total = _index + 1; self.amp_data = text_editor::Content::with_text(&fmt_amp(&self.amp_points)); self.status = format!("Amp: {} pts", self.amp_points.len()); } EisMessage::AmpEnd => { self.amp_running = false; if let Some(sid) = self.current_session { self.save_amp(sid); } self.status = format!("Amp complete: {} points", self.amp_points.len()); } EisMessage::ClStart { num_points } => { self.cl_points.clear(); self.cl_result = None; self.cl_total = num_points; self.cl_data = text_editor::Content::with_text(&fmt_cl(&self.cl_points)); self.status = format!("Chlorine: {} pts", num_points); } EisMessage::ClPoint { point, .. } => { self.cl_points.push(point); self.cl_data = text_editor::Content::with_text(&fmt_cl(&self.cl_points)); self.status = format!("Chlorine: {}/{}", self.cl_points.len(), self.cl_total); } EisMessage::ClResult(r) => { self.cl_result = Some(r); self.status = format!("Chlorine: free={:.3} uA, total={:.3} uA", self.cl_result.as_ref().unwrap().i_free_ua, self.cl_result.as_ref().unwrap().i_total_ua); } EisMessage::ClEnd => { if let Some(sid) = self.current_session { self.save_cl(sid); } self.status = format!("Chlorine complete: {} points", self.cl_points.len()); } EisMessage::PhResult(r) => { if self.collecting_refs { self.ph_ref = Some(r); } else { if let Some(sid) = self.current_session { self.save_ph(sid, &r); } self.status = format!("pH: {:.2} (OCP={:.1} mV, T={:.1}C)", r.ph, r.v_ocp_mv, r.temp_c); self.ph_result = Some(r); } } EisMessage::Temperature(t) => { self.temp_c = t; } EisMessage::RefFrame { mode, rtia_idx } => { self.ref_mode = Some(mode); self.ref_rtia = Some(rtia_idx); let mode_name = match mode { 0 => "EIS", 1 => "LSV", 2 => "Amp", 3 => "Cl", 4 => "pH", _ => "?" }; if mode == 0 { self.status = format!("Ref: {} RTIA {}/8", mode_name, rtia_idx + 1); } else { self.status = format!("Ref: {} range search", mode_name); } } EisMessage::RefLpRange { mode, low_idx, high_idx } => { match mode { 1 => self.lsv_lp_range = Some((low_idx, high_idx)), 2 => self.amp_lp_range = Some((low_idx, high_idx)), 3 => self.cl_lp_range = Some((low_idx, high_idx)), _ => {} } } EisMessage::RefsDone => { self.collecting_refs = false; self.has_device_refs = true; self.ref_mode = None; self.ref_rtia = None; /* populate eis_ref from current RTIA's ref if available */ let rtia_idx = self.rtia.as_byte() as usize; if rtia_idx < 8 { if let Some(pts) = &self.eis_refs[rtia_idx] { self.eis_ref = Some(pts.clone()); } } self.status = "Reference collection complete".into(); } EisMessage::RefStatus { has_refs } => { self.has_device_refs = has_refs; if !has_refs { self.status = "No device refs".into(); } } EisMessage::CellK(k) => { 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); } EisMessage::PhCal { slope, offset } => { self.ph_slope = Some(slope); self.ph_offset = Some(offset); self.status = format!("pH cal: slope={:.4} offset={:.4}", slope, offset); } }, Message::TabSelected(t) => { if t == Tab::Browse { self.browse_sessions = self.storage.list_sessions() .unwrap_or_default() .into_iter() .map(|s| { let cnt = self.storage.measurement_count(s.id).unwrap_or(0); (s, cnt) }) .collect(); self.browse_selected_session = None; self.browse_selected_measurement = None; self.browse_measurements.clear(); self.browse_preview.clear(); } self.tab = t; } Message::PaneResized(event) => { self.panes.resize(event.split, event.ratio); } Message::DataAction(action) => { if !matches!(action, text_editor::Action::Edit(_)) { match self.tab { Tab::Eis => self.eis_data.perform(action), Tab::Lsv => self.lsv_data.perform(action), Tab::Amp => self.amp_data.perform(action), Tab::Chlorine => self.cl_data.perform(action), Tab::Ph | Tab::Calibrate | Tab::Browse => {} } } } /* EIS */ Message::FreqStartChanged(s) => self.freq_start = s, Message::FreqStopChanged(s) => self.freq_stop = s, Message::PpdChanged(s) => self.ppd = s, Message::RtiaSelected(r) => { self.rtia = r; let idx = r.as_byte() as usize; if idx < 8 { if let Some(pts) = &self.eis_refs[idx] { self.eis_ref = Some(pts.clone()); } } } Message::RcalSelected(r) => self.rcal = r, Message::ElectrodeSelected(e) => self.electrode = e, Message::ApplySettings => { let fs = self.freq_start.parse::().unwrap_or(1000.0); let fe = self.freq_stop.parse::().unwrap_or(200000.0); let ppd = self.ppd.parse::().unwrap_or(10); self.send_cmd(&protocol::build_sysex_set_sweep(fs, fe, ppd)); self.send_cmd(&protocol::build_sysex_set_rtia(self.rtia)); self.send_cmd(&protocol::build_sysex_set_rcal(self.rcal)); self.send_cmd(&protocol::build_sysex_set_electrode(self.electrode)); self.send_cmd(&protocol::build_sysex_get_config()); } Message::StartSweep => { self.send_cmd(&protocol::build_sysex_get_temp()); self.send_cmd(&protocol::build_sysex_start_sweep()); } /* LSV */ Message::LsvStartVChanged(s) => self.lsv_start_v = s, Message::LsvStopVChanged(s) => self.lsv_stop_v = s, Message::LsvScanRateChanged(s) => self.lsv_scan_rate = s, Message::LsvRtiaSelected(r) => self.lsv_rtia = r, Message::StartLsv => { let vs = self.lsv_start_v.parse::().unwrap_or(0.0); let ve = self.lsv_stop_v.parse::().unwrap_or(500.0); let sr = self.lsv_scan_rate.parse::().unwrap_or(50.0); self.send_cmd(&protocol::build_sysex_get_temp()); self.send_cmd(&protocol::build_sysex_start_lsv(vs, ve, sr, self.lsv_rtia)); } Message::LsvToggleManual => { self.lsv_manual_peaks = !self.lsv_manual_peaks; if self.lsv_manual_peaks { self.lsv_peaks.clear(); } else { self.lsv_peaks = crate::lsv_analysis::detect_peaks(&self.lsv_points); } } /* Amp */ Message::AmpVholdChanged(s) => self.amp_v_hold = s, Message::AmpIntervalChanged(s) => self.amp_interval = s, Message::AmpDurationChanged(s) => self.amp_duration = s, Message::AmpRtiaSelected(r) => self.amp_rtia = r, Message::StartAmp => { let vh = self.amp_v_hold.parse::().unwrap_or(200.0); let iv = self.amp_interval.parse::().unwrap_or(100.0); let dur = self.amp_duration.parse::().unwrap_or(60.0); self.send_cmd(&protocol::build_sysex_get_temp()); self.send_cmd(&protocol::build_sysex_start_amp(vh, iv, dur, self.amp_rtia)); } Message::StopAmp => { self.send_cmd(&protocol::build_sysex_stop_amp()); } /* Chlorine */ Message::ClCondVChanged(s) => self.cl_cond_v = s, Message::ClCondTChanged(s) => self.cl_cond_t = s, Message::ClFreeVChanged(s) => self.cl_free_v = s, Message::ClTotalVChanged(s) => self.cl_total_v = s, Message::ClDepTChanged(s) => self.cl_dep_t = s, Message::ClMeasTChanged(s) => self.cl_meas_t = s, Message::ClRtiaSelected(r) => self.cl_rtia = r, Message::StartCl => { let v_cond = self.cl_cond_v.parse::().unwrap_or(800.0); let t_cond = self.cl_cond_t.parse::().unwrap_or(2000.0); let v_free = self.cl_free_v.parse::().unwrap_or(100.0); let v_total = self.cl_total_v.parse::().unwrap_or(-200.0); let t_dep = self.cl_dep_t.parse::().unwrap_or(5000.0); let t_meas = self.cl_meas_t.parse::().unwrap_or(5000.0); self.send_cmd(&protocol::build_sysex_get_temp()); self.send_cmd(&protocol::build_sysex_start_cl( v_cond, t_cond, v_free, v_total, t_dep, t_meas, self.cl_rtia, )); } /* pH */ Message::PhStabilizeChanged(s) => self.ph_stabilize = s, Message::StartPh => { let stab = self.ph_stabilize.parse::().unwrap_or(30.0); self.send_cmd(&protocol::build_sysex_get_temp()); self.send_cmd(&protocol::build_sysex_start_ph(stab)); } /* Reference baseline */ Message::SetReference => { match self.tab { Tab::Eis if !self.eis_points.is_empty() => { self.eis_ref = Some(self.eis_points.clone()); self.status = format!("EIS reference set ({} pts)", self.eis_points.len()); } Tab::Lsv if !self.lsv_points.is_empty() => { self.lsv_ref = Some(self.lsv_points.clone()); self.status = format!("LSV reference set ({} pts)", self.lsv_points.len()); } Tab::Amp if !self.amp_points.is_empty() => { self.amp_ref = Some(self.amp_points.clone()); self.status = format!("Amp reference set ({} pts)", self.amp_points.len()); } Tab::Chlorine if !self.cl_points.is_empty() => { if let Some(r) = &self.cl_result { self.cl_ref = Some((self.cl_points.clone(), r.clone())); self.status = "Chlorine reference set".into(); } } Tab::Ph => { if let Some(r) = &self.ph_result { self.ph_ref = Some(r.clone()); self.status = format!("pH reference set ({:.2})", r.ph); } } _ => {} } } Message::CollectRefs => { self.collecting_refs = true; self.eis_refs = Default::default(); self.lsv_lp_range = None; self.amp_lp_range = None; self.cl_lp_range = None; self.status = "Starting reference collection...".into(); self.send_cmd(&protocol::build_sysex_start_refs()); } Message::GetRefs => { self.collecting_refs = true; self.eis_refs = Default::default(); self.lsv_lp_range = None; self.amp_lp_range = None; self.cl_lp_range = None; self.send_cmd(&protocol::build_sysex_get_refs()); } Message::ClearRefs => { self.collecting_refs = false; self.has_device_refs = false; self.eis_refs = Default::default(); self.eis_ref = None; self.lsv_lp_range = None; self.amp_lp_range = None; self.cl_lp_range = None; self.ph_ref = None; self.send_cmd(&protocol::build_sysex_clear_refs()); self.status = "Refs cleared".into(); } Message::ClearReference => { match self.tab { Tab::Eis => { self.eis_ref = None; self.status = "EIS reference cleared".into(); } Tab::Lsv => { self.lsv_ref = None; self.status = "LSV reference cleared".into(); } Tab::Amp => { self.amp_ref = None; self.status = "Amp reference cleared".into(); } Tab::Chlorine => { self.cl_ref = None; self.status = "Chlorine reference cleared".into(); } Tab::Ph => { self.ph_ref = None; self.status = "pH reference cleared".into(); } Tab::Calibrate | Tab::Browse => {} } } /* Global */ Message::PollTemp => { self.send_cmd(&protocol::build_sysex_get_temp()); } Message::NativeMenuTick => { for action in self.native_menu.poll_events() { match action { MenuAction::SystemInfo => self.show_sysinfo = !self.show_sysinfo, } } } Message::CloseSysInfo => { self.show_sysinfo = false; } /* Calibration */ Message::CalVolumeChanged(s) => self.cal_volume_gal = s, Message::CalNaclChanged(s) => self.cal_nacl_ppm = s, Message::CalClChanged(s) => self.cal_cl_ppm = s, Message::CalBleachChanged(s) => self.cal_bleach_pct = s, Message::CalTempChanged(s) => self.cal_temp_c = s, Message::CalComputeK => { let ppm = self.cal_nacl_ppm.parse::().unwrap_or(2500.0); let temp = self.cal_temp_c.parse::().unwrap_or(40.0); let kappa = theoretical_conductivity_ms_cm(ppm, temp); if let Some(rs) = extract_rs(&self.eis_points) { let k = cell_constant(kappa, rs); self.cal_cell_constant = Some(k); self.send_cmd(&protocol::build_sysex_set_cell_k(k)); self.status = format!("Cell constant: {:.4} cm-1 (Rs={:.1} ohm)", k, rs); } else { self.status = "No valid EIS data for Rs extraction".into(); } } Message::PhCalKnownChanged(s) => { self.ph_cal_known = s; } Message::PhAddCalPoint => { if let Some(peak_mv) = crate::lsv_analysis::detect_qhq_peak(&self.lsv_points) { if let Ok(ph) = self.ph_cal_known.parse::() { self.ph_cal_points.push((ph, peak_mv)); self.status = format!("pH cal point: pH={:.2} peak={:.1} mV ({} pts)", ph, peak_mv, self.ph_cal_points.len()); } } else { self.status = "No Q/HQ peak found in LSV data".into(); } } Message::PhClearCalPoints => { self.ph_cal_points.clear(); self.status = "pH cal points cleared".into(); } Message::PhComputeAndSetCal => { if self.ph_cal_points.len() < 2 { self.status = "Need at least 2 calibration points".into(); } else { let n = self.ph_cal_points.len() as f32; let mean_ph: f32 = self.ph_cal_points.iter().map(|p| p.0).sum::() / n; let mean_v: f32 = self.ph_cal_points.iter().map(|p| p.1).sum::() / n; let num: f32 = self.ph_cal_points.iter() .map(|p| (p.0 - mean_ph) * (p.1 - mean_v)).sum(); let den: f32 = self.ph_cal_points.iter() .map(|p| (p.0 - mean_ph).powi(2)).sum(); if den.abs() < 1e-12 { self.status = "Degenerate calibration data".into(); } else { let slope = num / den; let offset = mean_v - slope * mean_ph; self.ph_slope = Some(slope); self.ph_offset = Some(offset); self.send_cmd(&protocol::build_sysex_set_ph_cal(slope, offset)); self.status = format!("pH cal set: slope={:.4} offset={:.4}", slope, offset); } } } 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, Message::StartClean => { let v = self.clean_v.parse::().unwrap_or(1200.0); let d = self.clean_dur.parse::().unwrap_or(30.0); self.send_cmd(&protocol::build_sysex_start_clean(v, d)); self.status = format!("Cleaning: {:.0} mV for {:.0}s", v, d); } /* Sessions */ Message::CreateSession => { if self.creating_session { let name = self.session_name_input.trim(); if !name.is_empty() { if let Ok(id) = self.storage.create_session(name, "") { self.current_session = Some(id); self.sessions = self.storage.list_sessions().unwrap_or_default(); self.status = format!("Session: {}", name); } } self.session_name_input.clear(); self.creating_session = false; } else { self.creating_session = true; } } Message::SelectSession(id) => { self.current_session = id; } Message::DeleteSession => { if let Some(id) = self.current_session { let _ = self.storage.delete_session(id); self.current_session = None; self.sessions = self.storage.list_sessions().unwrap_or_default(); self.status = "Session deleted".into(); } } Message::SessionNameInput(s) => { self.session_name_input = s; } /* Browse */ Message::BrowseSelectSession(sid) => { self.browse_selected_session = Some(sid); self.browse_selected_measurement = None; self.browse_preview.clear(); self.browse_measurements = self.storage.get_measurements(sid) .unwrap_or_default() .into_iter() .map(|m| { let cnt = self.storage.data_point_count(m.id).unwrap_or(0); (m, cnt) }) .collect(); } Message::BrowseSelectMeasurement(mid) => { self.browse_selected_measurement = Some(mid); let mtype = self.browse_measurements.iter() .find(|(m, _)| m.id == mid) .map(|(m, _)| m.mtype.clone()); if let Some(mt) = mtype { let pts = self.storage.get_data_points(mid).unwrap_or_default(); self.browse_preview = Self::format_preview(&mt, &pts); } } Message::BrowseLoadAsActive(mid) => { let mtype = self.browse_measurements.iter() .find(|(m, _)| m.id == mid) .map(|(m, _)| m.mtype.clone()); if let Some(mt) = mtype { let pts = self.storage.get_data_points(mid).unwrap_or_default(); self.load_measurement_active(&mt, &pts); } } Message::BrowseLoadAsReference(mid) => { let mtype = self.browse_measurements.iter() .find(|(m, _)| m.id == mid) .map(|(m, _)| m.mtype.clone()); if let Some(mt) = mtype { let pts = self.storage.get_data_points(mid).unwrap_or_default(); self.load_measurement_reference(&mt, &pts); } } Message::BrowseDeleteMeasurement(mid) => { let _ = self.storage.delete_measurement(mid); if self.browse_selected_measurement == Some(mid) { self.browse_selected_measurement = None; self.browse_preview.clear(); } if let Some(sid) = self.browse_selected_session { self.browse_measurements = self.storage.get_measurements(sid) .unwrap_or_default() .into_iter() .map(|m| { let cnt = self.storage.data_point_count(m.id).unwrap_or(0); (m, cnt) }) .collect(); } self.browse_sessions = self.storage.list_sessions() .unwrap_or_default() .into_iter() .map(|s| { let cnt = self.storage.measurement_count(s.id).unwrap_or(0); (s, cnt) }) .collect(); self.status = "Measurement deleted".into(); } Message::BrowseBack => { if self.browse_selected_measurement.is_some() { self.browse_selected_measurement = None; self.browse_preview.clear(); } else { self.browse_selected_session = None; self.browse_measurements.clear(); } } Message::ExportSession(sid) => { match self.storage.export_session(sid) { Ok(toml_str) => { let name = self.browse_sessions.iter() .find(|(s, _)| s.id == sid) .map(|(s, _)| s.name.clone()) .unwrap_or_else(|| format!("session_{}", sid)); let filename = format!("{}.toml", name.replace(' ', "_")); let dialog = rfd::FileDialog::new() .set_file_name(&filename) .add_filter("TOML", &["toml"]); if let Some(path) = dialog.save_file() { match std::fs::write(&path, &toml_str) { Ok(_) => self.status = format!("Exported to {}", path.display()), Err(e) => self.status = format!("Write failed: {}", e), } } } Err(e) => self.status = format!("Export failed: {}", e), } } Message::ImportSession => { let dialog = rfd::FileDialog::new() .add_filter("TOML", &["toml"]); if let Some(path) = dialog.pick_file() { match std::fs::read_to_string(&path) { Ok(toml_str) => { match self.storage.import_session(&toml_str) { Ok(_) => { self.browse_sessions = self.storage.list_sessions() .unwrap_or_default() .into_iter() .map(|s| { let cnt = self.storage.measurement_count(s.id).unwrap_or(0); (s, cnt) }) .collect(); self.sessions = self.storage.list_sessions().unwrap_or_default(); self.status = format!("Imported from {}", path.display()); } Err(e) => self.status = format!("Import failed: {}", e), } } Err(e) => self.status = format!("Read failed: {}", e), } } } Message::Reconnect => { self.conn_gen += 1; self.cmd_tx = None; self.connected = false; self.status = format!("Connecting to {}...", self.udp_addr); } Message::UdpAddrChanged(s) => { self.udp_addr = s; } } Task::none() } pub fn subscription(&self) -> Subscription { let udp_addr = self.udp_addr.clone(); let transport = Subscription::run_with_id( self.conn_gen, iced::stream::channel(100, move |mut output| async move { let addr = udp_addr.clone(); loop { let (udp_tx, mut udp_rx) = mpsc::unbounded_channel::(); let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::>(); let tx = udp_tx.clone(); let a = addr.clone(); tokio::spawn(async move { if let Err(e) = crate::udp::connect_and_run(tx, cmd_rx, a).await { eprintln!("UDP: {e}"); } }); let mut ready_sent = false; while let Some(ev) = udp_rx.recv().await { let msg = match ev { UdpEvent::Status(ref s) if s == "Connected" && !ready_sent => { ready_sent = true; let _ = output.send(Message::DeviceReady(cmd_tx.clone())).await; Message::DeviceStatus(s.clone()) } UdpEvent::Status(s) => Message::DeviceStatus(s), UdpEvent::Data(m) => Message::DeviceData(m), }; let _ = output.send(msg).await; } let _ = output.send(Message::DeviceStatus("Reconnecting...".into())).await; tokio::time::sleep(Duration::from_millis(500)).await; } }), ); let temp_poll = iced::time::every(Duration::from_millis(500)) .map(|_| Message::PollTemp); let menu_tick = iced::time::every(Duration::from_millis(50)) .map(|_| Message::NativeMenuTick); Subscription::batch([transport, temp_poll, menu_tick]) } pub fn view(&self) -> Element<'_, Message> { let tab_btn = |label: &'static str, t: Tab, active: bool| -> Element<'_, Message> { let b = button(text(label).size(13)) .style(style_tab(active)) .padding([6, 14]); if active { b.into() } else { b.on_press(Message::TabSelected(t)).into() } }; let mut tabs = row![ tab_btn("EIS", Tab::Eis, self.tab == Tab::Eis), tab_btn("LSV", Tab::Lsv, self.tab == Tab::Lsv), tab_btn("Amperometry", Tab::Amp, self.tab == Tab::Amp), tab_btn("Chlorine", Tab::Chlorine, self.tab == Tab::Chlorine), tab_btn("pH", Tab::Ph, self.tab == Tab::Ph), tab_btn("Cal", Tab::Calibrate, self.tab == Tab::Calibrate), tab_btn("Browse", Tab::Browse, self.tab == Tab::Browse), ] .spacing(4) .align_y(iced::Alignment::Center); tabs = tabs .push(iced::widget::horizontal_space()) .push(text("Clean").size(12)) .push(text_input("mV", &self.clean_v).on_input(Message::CleanVChanged).width(60)) .push(text_input("s", &self.clean_dur).on_input(Message::CleanDurChanged).width(45)) .push( button(text("Clean").size(13)) .style(btn_style(Color::from_rgb(0.65, 0.55, 0.15), Color::WHITE)) .padding([6, 14]) .on_press(Message::StartClean), ); let has_ref = match self.tab { Tab::Eis => self.eis_ref.is_some(), Tab::Lsv => self.lsv_ref.is_some(), Tab::Amp => self.amp_ref.is_some(), Tab::Chlorine => self.cl_ref.is_some(), Tab::Ph => self.ph_ref.is_some(), Tab::Calibrate | Tab::Browse => false, }; let has_data = match self.tab { Tab::Eis => !self.eis_points.is_empty(), Tab::Lsv => !self.lsv_points.is_empty(), Tab::Amp => !self.amp_points.is_empty(), Tab::Chlorine => self.cl_result.is_some(), Tab::Ph => self.ph_result.is_some(), Tab::Calibrate | Tab::Browse => false, }; let mut ref_row = row![].spacing(4).align_y(iced::Alignment::Center); if !self.collecting_refs { ref_row = ref_row.push( button(text("Collect Refs").size(11)) .style(style_action()) .padding([4, 10]) .on_press(Message::CollectRefs), ); } else { ref_row = ref_row.push( button(text("Collecting...").size(11)) .style(style_neutral()) .padding([4, 10]), ); } if self.has_device_refs { ref_row = ref_row.push( button(text("Get Refs").size(11)) .style(style_neutral()) .padding([4, 10]) .on_press(Message::GetRefs), ); } if self.has_device_refs || has_ref { ref_row = ref_row.push( button(text("Clear Refs").size(11)) .style(style_danger()) .padding([4, 10]) .on_press(Message::ClearRefs), ); } if has_data { ref_row = ref_row.push( button(text("Set Ref").size(11)) .style(style_neutral()) .padding([4, 10]) .on_press(Message::SetReference), ); } if has_ref { ref_row = ref_row.push( button(text("Clear Ref").size(11)) .style(style_danger()) .padding([4, 10]) .on_press(Message::ClearReference), ); ref_row = ref_row.push(text("REF").size(11)); } let mut status_row = row![text(&self.status).size(16)].spacing(6) .align_y(iced::Alignment::Center); status_row = status_row.push( text_input("IP:port", &self.udp_addr) .size(12) .width(160) .on_input(Message::UdpAddrChanged) .on_submit(Message::Reconnect), ); status_row = status_row.push( button(text("Reconnect").size(11)) .style(style_apply()) .padding([4, 10]) .on_press(Message::Reconnect), ); status_row = status_row .push(iced::widget::horizontal_space()) .push(ref_row) .push(text(format!("{:.1} C", self.temp_c)).size(14)); let session_row = self.view_session_row(); let controls = self.view_controls(); let body: Element<'_, Message> = if self.show_sysinfo { self.view_sysinfo() } else if self.tab == Tab::Browse { self.view_browse_body() } else if self.tab == Tab::Ph { self.view_ph_body() } else if self.tab == Tab::Calibrate { self.view_cal_body() } else { pane_grid::PaneGrid::new(&self.panes, |_pane, &pane_id, _maximized| { let el = match pane_id { PaneId::Plot => self.view_plot_pane(), PaneId::Data => self.view_data_pane(), }; pane_grid::Content::new(el) }) .on_resize(6, Message::PaneResized) .width(Length::Fill) .height(Length::Fill) .into() }; container( column![tabs, session_row, status_row, controls, body] .spacing(10) .padding(20) .width(Length::Fill) .height(Length::Fill), ) .width(Length::Fill) .height(Length::Fill) .into() } fn view_session_row(&self) -> Element<'_, Message> { let mut r = row![].spacing(6).align_y(iced::Alignment::Center); let selected = self.current_session.and_then(|id| { self.sessions.iter().find(|s| s.id == id) }); let options: Vec = std::iter::once(SessionItem::None) .chain(self.sessions.iter().map(|s| SessionItem::Some(s.id, s.name.clone()))) .collect(); let current = match selected { Some(s) => SessionItem::Some(s.id, s.name.clone()), None => SessionItem::None, }; r = r.push(text("Session:").size(12)); r = r.push( pick_list(options, Some(current), |item| match item { SessionItem::None => Message::SelectSession(None), SessionItem::Some(id, _) => Message::SelectSession(Some(id)), }).width(180).text_size(12), ); if self.creating_session { r = r.push( text_input("Session name", &self.session_name_input) .on_input(Message::SessionNameInput) .on_submit(Message::CreateSession) .width(150) .size(12), ); r = r.push( button(text("Save").size(11)) .style(style_action()) .padding([4, 10]) .on_press(Message::CreateSession), ); } else { r = r.push( button(text("New").size(11)) .style(style_apply()) .padding([4, 10]) .on_press(Message::CreateSession), ); } if self.current_session.is_some() { r = r.push( button(text("Delete").size(11)) .style(style_danger()) .padding([4, 10]) .on_press(Message::DeleteSession), ); } else { r = r.push( text("No session -- data will not be saved").size(11), ); } r.into() } fn view_controls(&self) -> Element<'_, Message> { match self.tab { Tab::Eis => row![ column![ text("Start Hz").size(12), text_input("1000", &self.freq_start).on_input(Message::FreqStartChanged).width(90), ].spacing(2), column![ text("Stop Hz").size(12), text_input("200000", &self.freq_stop).on_input(Message::FreqStopChanged).width(90), ].spacing(2), column![ text("PPD").size(12), text_input("10", &self.ppd).on_input(Message::PpdChanged).width(50), ].spacing(2), column![ text("RTIA").size(12), pick_list(Rtia::ALL, Some(self.rtia), Message::RtiaSelected).width(110), ].spacing(2), column![ text("RCAL").size(12), pick_list(Rcal::ALL, Some(self.rcal), Message::RcalSelected).width(160), ].spacing(2), column![ text("Electrodes").size(12), pick_list(Electrode::ALL, Some(self.electrode), Message::ElectrodeSelected).width(170), ].spacing(2), button(text("Apply").size(13)) .style(style_apply()) .padding([6, 16]) .on_press(Message::ApplySettings), button(text("Sweep").size(13)) .style(style_action()) .padding([6, 20]) .on_press(Message::StartSweep), ] .spacing(8) .align_y(iced::Alignment::End) .into(), Tab::Lsv => row![ column![ text("Start mV").size(12), text_input("0", &self.lsv_start_v).on_input(Message::LsvStartVChanged).width(80), ].spacing(2), column![ text("Stop mV").size(12), text_input("500", &self.lsv_stop_v).on_input(Message::LsvStopVChanged).width(80), ].spacing(2), column![ text("Scan mV/s").size(12), text_input("50", &self.lsv_scan_rate).on_input(Message::LsvScanRateChanged).width(80), ].spacing(2), column![ text("RTIA").size(12), pick_list(LpRtia::ALL, Some(self.lsv_rtia), Message::LsvRtiaSelected).width(Length::Shrink), ].spacing(2), button(text("Start LSV").size(13)) .style(style_action()) .padding([6, 16]) .on_press(Message::StartLsv), button(text(if self.lsv_manual_peaks { "Manual" } else { "Auto" }).size(13)) .padding([6, 12]) .on_press(Message::LsvToggleManual), ] .spacing(10) .align_y(iced::Alignment::End) .into(), Tab::Amp => row![ column![ text("V hold mV").size(12), text_input("200", &self.amp_v_hold).on_input(Message::AmpVholdChanged).width(80), ].spacing(2), column![ text("Interval ms").size(12), text_input("100", &self.amp_interval).on_input(Message::AmpIntervalChanged).width(80), ].spacing(2), column![ text("Duration s").size(12), text_input("60", &self.amp_duration).on_input(Message::AmpDurationChanged).width(80), ].spacing(2), column![ text("RTIA").size(12), pick_list(LpRtia::ALL, Some(self.amp_rtia), Message::AmpRtiaSelected).width(Length::Shrink), ].spacing(2), if self.amp_running { button(text("Stop").size(13)) .style(style_danger()) .padding([6, 16]) .on_press(Message::StopAmp) } else { button(text("Start Amp").size(13)) .style(style_action()) .padding([6, 16]) .on_press(Message::StartAmp) }, ] .spacing(10) .align_y(iced::Alignment::End) .into(), Tab::Chlorine => row![ button(text("Start LSV").size(13)) .style(style_action()) .padding([6, 16]) .on_press(Message::StartLsv), button(text(if self.lsv_manual_peaks { "Manual" } else { "Auto" }).size(13)) .padding([6, 12]) .on_press(Message::LsvToggleManual), rule::Rule::vertical(1), column![ text("Cond mV").size(12), text_input("800", &self.cl_cond_v).on_input(Message::ClCondVChanged).width(70), ].spacing(2), column![ text("Cond ms").size(12), text_input("2000", &self.cl_cond_t).on_input(Message::ClCondTChanged).width(70), ].spacing(2), column![ text("Free mV").size(12), text_input("100", &self.cl_free_v).on_input(Message::ClFreeVChanged).width(70), ].spacing(2), column![ text("Total mV").size(12), text_input("-200", &self.cl_total_v).on_input(Message::ClTotalVChanged).width(70), ].spacing(2), column![ text("Settle ms").size(12), text_input("5000", &self.cl_dep_t).on_input(Message::ClDepTChanged).width(70), ].spacing(2), column![ text("Meas ms").size(12), text_input("5000", &self.cl_meas_t).on_input(Message::ClMeasTChanged).width(70), ].spacing(2), column![ text("RTIA").size(12), pick_list(LpRtia::ALL, Some(self.cl_rtia), Message::ClRtiaSelected).width(Length::Shrink), ].spacing(2), button(text("Measure").size(13)) .style(style_action()) .padding([6, 16]) .on_press(Message::StartCl), ] .spacing(8) .align_y(iced::Alignment::End) .into(), Tab::Ph => row![ column![ text("Stabilize s").size(12), text_input("30", &self.ph_stabilize).on_input(Message::PhStabilizeChanged).width(80), ].spacing(2), button(text("Measure pH").size(13)) .style(style_action()) .padding([6, 16]) .on_press(Message::StartPh), ] .spacing(10) .align_y(iced::Alignment::End) .into(), Tab::Calibrate | Tab::Browse => row![].into(), } } fn view_plot_pane(&self) -> Element<'_, Message> { match self.tab { Tab::Eis => { let bode = canvas(crate::plot::BodePlot { points: &self.eis_points, reference: self.eis_ref.as_deref(), }) .width(Length::FillPortion(3)) .height(Length::Fill); let nyquist = canvas(crate::plot::NyquistPlot { points: &self.eis_points, reference: self.eis_ref.as_deref(), }) .width(Length::FillPortion(2)) .height(Length::Fill); row![bode, nyquist].spacing(10).height(Length::Fill).into() } Tab::Lsv => { let plot = canvas(crate::plot::VoltammogramPlot { points: &self.lsv_points, reference: self.lsv_ref.as_deref(), peaks: &self.lsv_peaks, }) .width(Length::Fill).height(Length::Fill); if self.lsv_peaks.is_empty() { plot.into() } else { let info: Vec = self.lsv_peaks.iter().map(|p| { use crate::lsv_analysis::PeakKind; match p.kind { PeakKind::FreeCl => format!("Free: {:.0}mV {:.2}uA", p.v_mv, p.i_ua), PeakKind::TotalCl => format!("Total: {:.0}mV {:.2}uA", p.v_mv, p.i_ua), PeakKind::Crossover => format!("X-over: {:.0}mV", p.v_mv), } }).collect(); column![text(info.join(" | ")).size(14), plot].spacing(4).height(Length::Fill).into() } } Tab::Amp => canvas(crate::plot::AmperogramPlot { points: &self.amp_points, reference: self.amp_ref.as_deref(), }) .width(Length::Fill).height(Length::Fill).into(), Tab::Chlorine => { let mut col = column![].spacing(4).height(Length::Fill); if !self.lsv_peaks.is_empty() { let info: Vec = self.lsv_peaks.iter().map(|p| { use crate::lsv_analysis::PeakKind; match p.kind { PeakKind::FreeCl => format!("Free: {:.0}mV {:.2}uA", p.v_mv, p.i_ua), PeakKind::TotalCl => format!("Total: {:.0}mV {:.2}uA", p.v_mv, p.i_ua), PeakKind::Crossover => format!("X-over: {:.0}mV", p.v_mv), } }).collect(); col = col.push(text(info.join(" | ")).size(14)); } let voltammogram = canvas(crate::plot::VoltammogramPlot { points: &self.lsv_points, reference: self.lsv_ref.as_deref(), peaks: &self.lsv_peaks, }) .width(Length::Fill).height(Length::FillPortion(1)); col = col.push(voltammogram); let mut result_parts: Vec = Vec::new(); if let Some(r) = &self.cl_result { result_parts.push(format!( "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; result_parts.push(format!( "vs Ref: dFree={:.3} uA, dTotal={:.3} uA", df, dt )); } } if !result_parts.is_empty() { col = col.push(text(result_parts.join(" ")).size(14)); } let ref_pts = self.cl_ref.as_ref().map(|(pts, _)| pts.as_slice()); let cl_plot = canvas(crate::plot::ChlorinePlot { points: &self.cl_points, reference: ref_pts, }) .width(Length::Fill).height(Length::FillPortion(1)); col = col.push(cl_plot); col.into() } Tab::Ph | Tab::Calibrate | Tab::Browse => text("").into(), } } fn view_data_pane(&self) -> Element<'_, Message> { let content = match self.tab { Tab::Eis => &self.eis_data, Tab::Lsv => &self.lsv_data, Tab::Amp => &self.amp_data, Tab::Chlorine => &self.cl_data, Tab::Ph | Tab::Calibrate | Tab::Browse => return text("").into(), }; text_editor(content) .on_action(Message::DataAction) .font(iced::Font::MONOSPACE) .size(13) .height(Length::Fill) .padding(4) .into() } fn view_cal_body(&self) -> Element<'_, Message> { let vol = self.cal_volume_gal.parse::().unwrap_or(0.0); let ppm = self.cal_nacl_ppm.parse::().unwrap_or(0.0); let cl = self.cal_cl_ppm.parse::().unwrap_or(0.0); let bleach = self.cal_bleach_pct.parse::().unwrap_or(7.825); let temp = self.cal_temp_c.parse::().unwrap_or(40.0); let salt_g = calc_salt_grams(vol, ppm); let salt_tbsp = salt_g / 17.0; let bleach_ml = calc_bleach_ml(vol, cl, bleach); let bleach_tsp = bleach_ml / 5.0; let kappa = theoretical_conductivity_ms_cm(ppm, temp); let inputs = column![ text("Calibration Solution").size(16), iced::widget::horizontal_rule(1), row![ column![ text("Volume (gal)").size(12), text_input("25", &self.cal_volume_gal) .on_input(Message::CalVolumeChanged).width(80), ].spacing(2), column![ text("NaCl ppm").size(12), text_input("2500", &self.cal_nacl_ppm) .on_input(Message::CalNaclChanged).width(80), ].spacing(2), column![ text("Cl ppm").size(12), text_input("5", &self.cal_cl_ppm) .on_input(Message::CalClChanged).width(80), ].spacing(2), column![ text("Bleach %").size(12), text_input("7.825", &self.cal_bleach_pct) .on_input(Message::CalBleachChanged).width(80), ].spacing(2), column![ text("Temp C").size(12), text_input("40", &self.cal_temp_c) .on_input(Message::CalTempChanged).width(80), ].spacing(2), ].spacing(10).align_y(iced::Alignment::End), ].spacing(6); let mut results = column![ text("Results").size(16), iced::widget::horizontal_rule(1), text(format!("Salt: {:.1} g ({:.1} tbsp sea salt)", salt_g, salt_tbsp)).size(14), text(format!("Bleach: {:.1} mL ({:.1} tsp)", bleach_ml, bleach_tsp)).size(14), text(format!("Theoretical kappa at {:.0} C: {:.3} mS/cm", temp, kappa)).size(14), ].spacing(4); let rs = extract_rs(&self.eis_points); if let Some(rs_val) = rs { results = results.push(text(format!("Rs from sweep: {:.1} ohm", rs_val)).size(14)); } else { results = results.push(text("Rs from sweep: (no EIS data)").size(14)); } if let Some(k) = self.cal_cell_constant { results = results.push(text(format!("Cell constant K: {:.4} cm-1", k)).size(14)); } let compute_btn = button(text("Calculate K from Sweep").size(13)) .style(style_action()) .padding([6, 16]) .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) ); /* pH calibration */ results = results.push(iced::widget::horizontal_rule(1)); results = results.push(text("pH Calibration (Q/HQ peak-shift)").size(16)); if let (Some(s), Some(o)) = (self.ph_slope, self.ph_offset) { results = results.push(text(format!("slope: {:.4} mV/pH offset: {:.4} mV", s, o)).size(14)); if let Some(peak) = crate::lsv_analysis::detect_qhq_peak(&self.lsv_points) { if s.abs() > 1e-6 { let ph = (peak - o) / s; results = results.push(text(format!("Computed pH: {:.2} (peak at {:.1} mV)", ph, peak)).size(14)); } } } results = results.push( row![ column![ text("Known pH").size(12), text_input("7.00", &self.ph_cal_known) .on_input(Message::PhCalKnownChanged).width(80), ].spacing(2), button(text("Add Point").size(13)) .style(style_action()) .padding([6, 16]) .on_press(Message::PhAddCalPoint), ].spacing(10).align_y(iced::Alignment::End) ); for (i, (ph, mv)) in self.ph_cal_points.iter().enumerate() { results = results.push(text(format!(" {}. pH={:.2} peak={:.1} mV", i + 1, ph, mv)).size(13)); } results = results.push( row![ button(text("Clear Points").size(13)) .style(style_action()) .padding([6, 16]) .on_press(Message::PhClearCalPoints), button(text("Compute & Set pH Cal").size(13)) .style(style_action()) .padding([6, 16]) .on_press(Message::PhComputeAndSetCal), ].spacing(10) ); row![ container(inputs).width(Length::FillPortion(2)), iced::widget::vertical_rule(1), container(results).width(Length::FillPortion(3)), ] .spacing(12) .height(Length::Fill) .into() } fn view_ph_body(&self) -> Element<'_, Message> { if let Some(r) = &self.ph_result { let nernst_slope = 0.1984 * (r.temp_c as f64 + 273.15); let mut col = column![ text(format!("pH: {:.2}", r.ph)).size(28), text(format!("OCP: {:.1} mV | Nernst slope: {:.2} mV/pH | Temp: {:.1} C", r.v_ocp_mv, nernst_slope, r.temp_c)).size(14), ].spacing(4); if let Some(ref_r) = &self.ph_ref { let d_ph = r.ph - ref_r.ph; let d_v = r.v_ocp_mv - ref_r.v_ocp_mv; col = col.push(text(format!( "vs Ref: dpH={:+.3} dOCP={:+.1} mV (ref pH={:.2})", d_ph, d_v, ref_r.ph )).size(14)); } col.into() } else { column![ text("No measurement yet").size(16), text("OCP method: V(SE0) - V(RE0) with Nernst correction").size(12), ].spacing(4).into() } } fn view_sysinfo(&self) -> Element<'_, Message> { container( column![ text("System Info").size(20), iced::widget::horizontal_rule(1), text(format!("Temperature: {:.1} C", self.temp_c)).size(14), text("Syslog: not yet implemented").size(14), text("0 / 4,194,304 bytes used").size(14), iced::widget::vertical_space().height(20), button(text("Close").size(14)).on_press(Message::CloseSysInfo), ] .spacing(8) .width(350) .padding(20), ) .center(Length::Fill) .into() } /* ---- Browse tab ---- */ fn view_browse_body(&self) -> Element<'_, Message> { let left = self.view_browse_sessions(); let right = self.view_browse_detail(); row![ container(left).width(Length::FillPortion(2)).height(Length::Fill), iced::widget::vertical_rule(1), container(right).width(Length::FillPortion(3)).height(Length::Fill), ] .spacing(8) .height(Length::Fill) .into() } fn view_browse_sessions(&self) -> Element<'_, Message> { let mut items = column![ row![ text("Sessions").size(16), iced::widget::horizontal_space(), button(text("Import").size(11)) .style(style_apply()) .padding([4, 8]) .on_press(Message::ImportSession), ].align_y(iced::Alignment::Center), iced::widget::horizontal_rule(1), ].spacing(4); if self.browse_sessions.is_empty() { items = items.push(text("No saved sessions").size(13)); } for (session, mcount) in &self.browse_sessions { let selected = self.browse_selected_session == Some(session.id); let bg = if selected { Color::from_rgb(0.30, 0.40, 0.55) } else { Color::from_rgb(0.18, 0.18, 0.20) }; let label = format!( "{} ({} meas) {}", session.name, mcount, &session.created_at[..10.min(session.created_at.len())] ); let sid = session.id; items = items.push( button(text(label).size(12)) .style(btn_style(bg, Color::WHITE)) .padding([6, 10]) .width(Length::Fill) .on_press(Message::BrowseSelectSession(sid)), ); } scrollable(items.width(Length::Fill)) .height(Length::Fill) .into() } fn view_browse_detail(&self) -> Element<'_, Message> { let Some(sid) = self.browse_selected_session else { return column![ text("Select a session").size(14), ].spacing(8).into(); }; let session_name = self.browse_sessions.iter() .find(|(s, _)| s.id == sid) .map(|(s, _)| s.name.as_str()) .unwrap_or("?"); let mut header = row![ button(text("Back").size(11)) .style(style_neutral()) .padding([4, 10]) .on_press(Message::BrowseBack), text(format!("Measurements in: {}", session_name)).size(14), ].spacing(8).align_y(iced::Alignment::Center); if let Some(mid) = self.browse_selected_measurement { header = header.push(iced::widget::horizontal_space()); header = header.push( button(text("Load").size(11)) .style(style_action()) .padding([4, 12]) .on_press(Message::BrowseLoadAsActive(mid)), ); header = header.push( button(text("Load as Ref").size(11)) .style(style_apply()) .padding([4, 12]) .on_press(Message::BrowseLoadAsReference(mid)), ); header = header.push( button(text("Delete").size(11)) .style(style_danger()) .padding([4, 10]) .on_press(Message::BrowseDeleteMeasurement(mid)), ); } header = header.push(iced::widget::horizontal_space()); header = header.push( button(text("Export").size(11)) .style(style_apply()) .padding([4, 12]) .on_press(Message::ExportSession(sid)), ); let mut mlist = column![].spacing(2); if self.browse_measurements.is_empty() { mlist = mlist.push(text("No measurements").size(13)); } for (m, pt_count) in &self.browse_measurements { let selected = self.browse_selected_measurement == Some(m.id); let bg = if selected { Color::from_rgb(0.30, 0.40, 0.55) } else { Color::from_rgb(0.18, 0.18, 0.20) }; let type_label = match m.mtype.as_str() { "eis" => "EIS", "lsv" => "LSV", "amp" => "Amp", "chlorine" => "Cl", "ph" => "pH", other => other, }; let ts = if m.created_at.len() > 10 { &m.created_at[11..] } else { &m.created_at }; let label = format!("[{}] {} {} pts", type_label, ts, pt_count); let mid = m.id; mlist = mlist.push( button(text(label).size(12)) .style(btn_style(bg, Color::WHITE)) .padding([5, 10]) .width(Length::Fill) .on_press(Message::BrowseSelectMeasurement(mid)), ); } let mut body = column![ header, iced::widget::horizontal_rule(1), ].spacing(6); body = body.push( scrollable(mlist.width(Length::Fill)) .height(if self.browse_preview.is_empty() { Length::Fill } else { Length::FillPortion(2) }), ); if !self.browse_preview.is_empty() { body = body.push(iced::widget::horizontal_rule(1)); body = body.push( scrollable( text(&self.browse_preview) .size(12) .font(iced::Font::MONOSPACE) ) .height(Length::FillPortion(3)), ); } body.height(Length::Fill).into() } fn format_preview(mtype: &str, pts: &[storage::DataPoint]) -> String { match mtype { "eis" => { let decoded: Vec = pts.iter() .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) .collect(); fmt_eis(&decoded) } "lsv" => { let decoded: Vec = pts.iter() .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) .collect(); fmt_lsv(&decoded) } "amp" => { let decoded: Vec = pts.iter() .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) .collect(); fmt_amp(&decoded) } "chlorine" => { let decoded: Vec = pts.iter() .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) .collect(); let mut s = fmt_cl(&decoded); if let Some(last) = pts.last() && let Ok(wrap) = serde_json::from_str::(&last.data_json) && let Some(r) = wrap.get("result") && let Ok(cr) = serde_json::from_value::(r.clone()) { let _ = writeln!(s, "\nFree: {:.3} uA Total: {:.3} uA", cr.i_free_ua, cr.i_total_ua); } s } "ph" => { if let Some(dp) = pts.first() { if let Ok(r) = serde_json::from_str::(&dp.data_json) { format!("pH: {:.2} OCP: {:.1} mV Temp: {:.1} C", r.ph, r.v_ocp_mv, r.temp_c) } else { dp.data_json.clone() } } else { String::new() } } _ => format!("{} data points", pts.len()), } } fn load_measurement_active(&mut self, mtype: &str, pts: &[storage::DataPoint]) { match mtype { "eis" => { let decoded: Vec = pts.iter() .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) .collect(); self.eis_data = text_editor::Content::with_text(&fmt_eis(&decoded)); self.eis_points = decoded; self.tab = Tab::Eis; self.status = format!("Loaded EIS: {} pts", self.eis_points.len()); } "lsv" => { let decoded: Vec = pts.iter() .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) .collect(); self.lsv_data = text_editor::Content::with_text(&fmt_lsv(&decoded)); self.lsv_points = decoded; self.tab = Tab::Lsv; self.status = format!("Loaded LSV: {} pts", self.lsv_points.len()); } "amp" => { let decoded: Vec = pts.iter() .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) .collect(); self.amp_data = text_editor::Content::with_text(&fmt_amp(&decoded)); self.amp_points = decoded; self.tab = Tab::Amp; self.status = format!("Loaded Amp: {} pts", self.amp_points.len()); } "chlorine" => { let cl_pts: Vec = pts.iter() .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) .collect(); let mut result: Option = None; if let Some(last) = pts.last() && let Ok(wrap) = serde_json::from_str::(&last.data_json) && let Some(r) = wrap.get("result") { result = serde_json::from_value(r.clone()).ok(); } self.cl_data = text_editor::Content::with_text(&fmt_cl(&cl_pts)); self.cl_points = cl_pts; self.cl_result = result; self.tab = Tab::Chlorine; self.status = format!("Loaded Chlorine: {} pts", self.cl_points.len()); } "ph" => { if let Some(dp) = pts.first() && let Ok(r) = serde_json::from_str::(&dp.data_json) { self.status = format!("Loaded pH: {:.2}", r.ph); self.ph_result = Some(r); self.tab = Tab::Ph; } } _ => {} } } fn load_measurement_reference(&mut self, mtype: &str, pts: &[storage::DataPoint]) { match mtype { "eis" => { let decoded: Vec = pts.iter() .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) .collect(); self.status = format!("EIS ref loaded: {} pts", decoded.len()); self.eis_ref = Some(decoded); self.tab = Tab::Eis; } "lsv" => { let decoded: Vec = pts.iter() .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) .collect(); self.status = format!("LSV ref loaded: {} pts", decoded.len()); self.lsv_ref = Some(decoded); self.tab = Tab::Lsv; } "amp" => { let decoded: Vec = pts.iter() .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) .collect(); self.status = format!("Amp ref loaded: {} pts", decoded.len()); self.amp_ref = Some(decoded); self.tab = Tab::Amp; } "chlorine" => { let cl_pts: Vec = pts.iter() .filter_map(|dp| serde_json::from_str(&dp.data_json).ok()) .collect(); let mut result = ClResult { i_free_ua: 0.0, i_total_ua: 0.0 }; if let Some(last) = pts.last() && let Ok(wrap) = serde_json::from_str::(&last.data_json) && let Some(r) = wrap.get("result") && let Ok(cr) = serde_json::from_value::(r.clone()) { result = cr; } self.status = format!("Cl ref loaded: {} pts", cl_pts.len()); self.cl_ref = Some((cl_pts, result)); self.tab = Tab::Chlorine; } "ph" => { if let Some(dp) = pts.first() && let Ok(r) = serde_json::from_str::(&dp.data_json) { self.status = format!("pH ref loaded: {:.2}", r.ph); self.ph_ref = Some(r); self.tab = Tab::Ph; } } _ => {} } } }