diff --git a/cue/Cargo.lock b/cue/Cargo.lock index b93d578..14d1cde 100644 --- a/cue/Cargo.lock +++ b/cue/Cargo.lock @@ -765,6 +765,9 @@ dependencies = [ "iced", "midir", "muda", + "rusqlite", + "serde", + "serde_json", "tokio", "winres", ] @@ -1023,6 +1026,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fast-srgb8" version = "1.0.0" @@ -1433,6 +1448,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "hassle-rs" version = "0.11.0" @@ -1816,6 +1840,17 @@ dependencies = [ "redox_syscall 0.7.3", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2892,6 +2927,20 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust-ini" version = "0.18.0" @@ -3626,6 +3675,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/cue/Cargo.toml b/cue/Cargo.toml index 197f943..70ec142 100644 --- a/cue/Cargo.toml +++ b/cue/Cargo.toml @@ -9,6 +9,9 @@ midir = "0.10" tokio = { version = "1", features = ["full"] } futures = "0.3" muda = { version = "0.17", default-features = false } +rusqlite = { version = "0.31", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" [target.'cfg(windows)'.build-dependencies] winres = "0.1" diff --git a/cue/src/app.rs b/cue/src/app.rs index 859497a..c4d19bd 100644 --- a/cue/src/app.rs +++ b/cue/src/app.rs @@ -14,6 +14,7 @@ use crate::protocol::{ self, AmpPoint, ClPoint, ClResult, Electrode, EisMessage, EisPoint, LpRtia, LsvPoint, PhResult, Rcal, Rtia, }; +use crate::storage::{Session, Storage}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tab { @@ -30,6 +31,21 @@ enum PaneId { 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 { BleReady(mpsc::UnboundedSender>), @@ -86,6 +102,11 @@ pub enum Message { CleanVChanged(String), CleanDurChanged(String), StartClean, + /* Sessions */ + CreateSession, + SelectSession(Option), + DeleteSession, + SessionNameInput(String), /* Misc */ OpenMidiSetup, RefreshMidi, @@ -100,6 +121,13 @@ pub struct App { native_menu: NativeMenu, show_sysinfo: bool, + /* Storage */ + storage: Storage, + current_session: Option, + sessions: Vec, + session_name_input: String, + creating_session: bool, + /* EIS */ eis_points: Vec, sweep_total: u16, @@ -274,6 +302,8 @@ fn style_neutral() -> impl Fn(&Theme, button::Status) -> ButtonStyle { 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(), @@ -288,6 +318,12 @@ impl App { native_menu: NativeMenu::init(), show_sysinfo: false, + storage, + current_session: None, + sessions, + session_name_input: String::new(), + creating_session: false, + eis_points: Vec::new(), sweep_total: 0, freq_start: "1000".into(), @@ -362,6 +398,87 @@ impl App { } } + 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::BleReady(tx) => { @@ -407,6 +524,9 @@ impl App { } 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()); } } @@ -431,6 +551,9 @@ impl App { self.status = format!("LSV: {}/{}", self.lsv_points.len(), self.lsv_total); } EisMessage::LsvEnd => { + if let Some(sid) = self.current_session { + self.save_lsv(sid); + } self.status = format!("LSV complete: {} points", self.lsv_points.len()); } EisMessage::AmpStart { v_hold } => { @@ -447,6 +570,9 @@ impl App { } 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 } => { @@ -468,12 +594,18 @@ impl App { 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); @@ -712,6 +844,37 @@ impl App { 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; + } Message::OpenMidiSetup => { let _ = std::process::Command::new("open") .arg("-a") @@ -882,6 +1045,8 @@ impl App { .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 { @@ -903,7 +1068,7 @@ impl App { }; container( - column![tabs, status_row, controls, body] + column![tabs, session_row, status_row, controls, body] .spacing(10) .padding(20) .width(Length::Fill) @@ -914,6 +1079,66 @@ impl App { .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![ diff --git a/cue/src/main.rs b/cue/src/main.rs index ce0f7c9..f85c648 100644 --- a/cue/src/main.rs +++ b/cue/src/main.rs @@ -3,6 +3,7 @@ mod ble; mod native_menu; mod plot; mod protocol; +mod storage; fn main() -> iced::Result { iced::application(app::App::title, app::App::update, app::App::view) diff --git a/cue/src/protocol.rs b/cue/src/protocol.rs index 79f9fd4..9eedf73 100644 --- a/cue/src/protocol.rs +++ b/cue/src/protocol.rs @@ -1,5 +1,7 @@ /// EIS4 SysEx Protocol — manufacturer ID 0x7D (non-commercial) +use serde::{Serialize, Deserialize}; + pub const SYSEX_MFR: u8 = 0x7D; /* ESP32 → Cue */ @@ -174,7 +176,7 @@ impl std::fmt::Display for LpRtia { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct EisPoint { pub freq_hz: f32, pub mag_ohms: f32, @@ -188,32 +190,32 @@ pub struct EisPoint { pub pct_err: f32, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct LsvPoint { pub v_mv: f32, pub i_ua: f32, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct AmpPoint { pub t_ms: f32, pub i_ua: f32, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClPoint { pub t_ms: f32, pub i_ua: f32, pub phase: u8, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClResult { pub i_free_ua: f32, pub i_total_ua: f32, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct PhResult { pub v_ocp_mv: f32, pub ph: f32, diff --git a/cue/src/storage.rs b/cue/src/storage.rs new file mode 100644 index 0000000..f203ea1 --- /dev/null +++ b/cue/src/storage.rs @@ -0,0 +1,172 @@ +use rusqlite::{Connection, params}; + +#[derive(Debug, Clone)] +pub struct Session { + pub id: i64, + pub name: String, + pub notes: String, + pub created_at: String, +} + +#[derive(Debug, Clone)] +pub struct Measurement { + pub id: i64, + pub session_id: i64, + pub mtype: String, + pub params_json: String, + pub created_at: String, +} + +#[derive(Debug, Clone)] +pub struct DataPoint { + pub id: i64, + pub measurement_id: i64, + pub idx: i32, + pub data_json: String, +} + +pub struct Storage { + conn: Connection, +} + +impl Storage { + pub fn open() -> Result { + let dir = dirs(); + std::fs::create_dir_all(&dir).ok(); + let path = dir.join("measurements.db"); + let conn = Connection::open(path)?; + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; + conn.execute_batch(SCHEMA)?; + Ok(Self { conn }) + } + + pub fn create_session(&self, name: &str, notes: &str) -> Result { + self.conn.execute( + "INSERT INTO sessions (name, notes) VALUES (?1, ?2)", + params![name, notes], + )?; + Ok(self.conn.last_insert_rowid()) + } + + pub fn list_sessions(&self) -> Result, rusqlite::Error> { + let mut stmt = self.conn.prepare( + "SELECT id, name, notes, created_at FROM sessions ORDER BY created_at DESC", + )?; + let rows = stmt.query_map([], |row| { + Ok(Session { + id: row.get(0)?, + name: row.get(1)?, + notes: row.get(2)?, + created_at: row.get(3)?, + }) + })?; + rows.collect() + } + + pub fn delete_session(&self, id: i64) -> Result<(), rusqlite::Error> { + self.conn.execute("DELETE FROM sessions WHERE id = ?1", params![id])?; + Ok(()) + } + + pub fn create_measurement( + &self, session_id: i64, mtype: &str, params_json: &str, + ) -> Result { + self.conn.execute( + "INSERT INTO measurements (session_id, type, params_json) VALUES (?1, ?2, ?3)", + params![session_id, mtype, params_json], + )?; + Ok(self.conn.last_insert_rowid()) + } + + pub fn add_data_point( + &self, measurement_id: i64, idx: i32, data_json: &str, + ) -> Result<(), rusqlite::Error> { + self.conn.execute( + "INSERT INTO data_points (measurement_id, idx, data_json) VALUES (?1, ?2, ?3)", + params![measurement_id, idx, data_json], + )?; + Ok(()) + } + + pub fn add_data_points_batch( + &self, measurement_id: i64, points: &[(i32, String)], + ) -> Result<(), rusqlite::Error> { + let tx = self.conn.unchecked_transaction()?; + { + let mut stmt = tx.prepare( + "INSERT INTO data_points (measurement_id, idx, data_json) VALUES (?1, ?2, ?3)", + )?; + for (idx, json) in points { + stmt.execute(params![measurement_id, idx, json])?; + } + } + tx.commit() + } + + 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 \ + FROM measurements WHERE session_id = ?1 ORDER BY created_at DESC", + )?; + let rows = stmt.query_map(params![session_id], |row| { + Ok(Measurement { + id: row.get(0)?, + session_id: row.get(1)?, + mtype: row.get(2)?, + params_json: row.get(3)?, + created_at: row.get(4)?, + }) + })?; + rows.collect() + } + + pub fn get_data_points(&self, measurement_id: i64) -> Result, rusqlite::Error> { + let mut stmt = self.conn.prepare( + "SELECT id, measurement_id, idx, data_json \ + FROM data_points WHERE measurement_id = ?1 ORDER BY idx", + )?; + let rows = stmt.query_map(params![measurement_id], |row| { + Ok(DataPoint { + id: row.get(0)?, + measurement_id: row.get(1)?, + idx: row.get(2)?, + data_json: row.get(3)?, + }) + })?; + rows.collect() + } +} + +fn dirs() -> std::path::PathBuf { + dirs_home().join(".eis4") +} + +fn dirs_home() -> std::path::PathBuf { + std::env::var("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::path::PathBuf::from(".")) +} + +const SCHEMA: &str = " +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + notes TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS measurements ( + id INTEGER PRIMARY KEY, + session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + type TEXT NOT NULL, + params_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS data_points ( + id INTEGER PRIMARY KEY, + measurement_id INTEGER NOT NULL REFERENCES measurements(id) ON DELETE CASCADE, + idx INTEGER NOT NULL, + data_json TEXT NOT NULL +); +";