cue: add SQLite persistence — sessions, measurements, auto-save

This commit is contained in:
jess 2026-03-31 18:00:30 -07:00
parent bbfb008d1a
commit ae35b1248f
6 changed files with 465 additions and 7 deletions

55
cue/Cargo.lock generated
View File

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

View File

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

View File

@ -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<Vec<u8>>),
@ -86,6 +102,11 @@ pub enum Message {
CleanVChanged(String),
CleanDurChanged(String),
StartClean,
/* Sessions */
CreateSession,
SelectSession(Option<i64>),
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<i64>,
sessions: Vec<Session>,
session_name_input: String,
creating_session: bool,
/* EIS */
eis_points: Vec<EisPoint>,
sweep_total: u16,
@ -274,6 +302,8 @@ fn style_neutral() -> impl Fn(&Theme, button::Status) -> ButtonStyle {
impl App {
pub fn new() -> (Self, Task<Message>) {
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", &params.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", &params.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", &params.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", &params.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", &params.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<Message> {
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<SessionItem> = 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![

View File

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

View File

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

172
cue/src/storage.rs Normal file
View File

@ -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<Self, rusqlite::Error> {
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<i64, rusqlite::Error> {
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<Vec<Session>, 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<i64, rusqlite::Error> {
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<Vec<Measurement>, 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<Vec<DataPoint>, 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
);
";