488 lines
17 KiB
Rust
488 lines
17 KiB
Rust
use rusqlite::{Connection, params};
|
|
use toml::value::Table;
|
|
|
|
#[derive(Debug, Clone)]
|
|
#[allow(dead_code)]
|
|
pub struct Session {
|
|
pub id: i64,
|
|
pub name: String,
|
|
pub notes: String,
|
|
pub created_at: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
#[allow(dead_code)]
|
|
pub struct Measurement {
|
|
pub id: i64,
|
|
pub session_id: i64,
|
|
pub mtype: String,
|
|
pub params_json: String,
|
|
pub created_at: String,
|
|
pub esp_timestamp: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
#[allow(dead_code)]
|
|
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)?;
|
|
Self::migrate_v2(&conn)?;
|
|
Ok(Self { conn })
|
|
}
|
|
|
|
fn migrate_v2(conn: &Connection) -> Result<(), rusqlite::Error> {
|
|
let has_col: bool = conn.prepare("SELECT esp_timestamp FROM measurements LIMIT 0")
|
|
.is_ok();
|
|
if !has_col {
|
|
conn.execute_batch(
|
|
"ALTER TABLE measurements ADD COLUMN esp_timestamp INTEGER;"
|
|
)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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,
|
|
esp_timestamp: Option<u32>,
|
|
) -> Result<i64, rusqlite::Error> {
|
|
if let Some(ts) = esp_timestamp {
|
|
let exists: bool = self.conn.query_row(
|
|
"SELECT EXISTS(SELECT 1 FROM measurements WHERE session_id = ?1 AND esp_timestamp = ?2)",
|
|
params![session_id, ts as i64],
|
|
|row| row.get(0),
|
|
)?;
|
|
if exists {
|
|
return Err(rusqlite::Error::StatementChangedRows(0));
|
|
}
|
|
}
|
|
self.conn.execute(
|
|
"INSERT INTO measurements (session_id, type, params_json, esp_timestamp) VALUES (?1, ?2, ?3, ?4)",
|
|
params![session_id, mtype, params_json, esp_timestamp.map(|t| t as i64)],
|
|
)?;
|
|
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, esp_timestamp \
|
|
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)?,
|
|
esp_timestamp: row.get(5)?,
|
|
})
|
|
})?;
|
|
rows.collect()
|
|
}
|
|
|
|
pub fn measurement_count(&self, session_id: i64) -> Result<i64, rusqlite::Error> {
|
|
self.conn.query_row(
|
|
"SELECT COUNT(*) FROM measurements WHERE session_id = ?1",
|
|
params![session_id],
|
|
|row| row.get(0),
|
|
)
|
|
}
|
|
|
|
pub fn data_point_count(&self, measurement_id: i64) -> Result<i64, rusqlite::Error> {
|
|
self.conn.query_row(
|
|
"SELECT COUNT(*) FROM data_points WHERE measurement_id = ?1",
|
|
params![measurement_id],
|
|
|row| row.get(0),
|
|
)
|
|
}
|
|
|
|
pub fn delete_measurement(&self, id: i64) -> Result<(), rusqlite::Error> {
|
|
self.conn.execute("DELETE FROM measurements WHERE id = ?1", params![id])?;
|
|
Ok(())
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
pub fn export_session(&self, session_id: i64) -> Result<String, Box<dyn std::error::Error>> {
|
|
let sess = self.conn.query_row(
|
|
"SELECT id, name, notes, created_at FROM sessions WHERE id = ?1",
|
|
params![session_id],
|
|
|row| Ok(Session {
|
|
id: row.get(0)?, name: row.get(1)?,
|
|
notes: row.get(2)?, created_at: row.get(3)?,
|
|
}),
|
|
)?;
|
|
|
|
let mut root = Table::new();
|
|
let mut session_table = Table::new();
|
|
session_table.insert("name".into(), toml::Value::String(sess.name));
|
|
session_table.insert("notes".into(), toml::Value::String(sess.notes));
|
|
session_table.insert("created_at".into(), toml::Value::String(sess.created_at));
|
|
root.insert("session".into(), toml::Value::Table(session_table));
|
|
|
|
let measurements = self.get_measurements(session_id)?;
|
|
let mut meas_array = Vec::new();
|
|
|
|
for m in &measurements {
|
|
let mut mt = Table::new();
|
|
mt.insert("type".into(), toml::Value::String(m.mtype.clone()));
|
|
mt.insert("created_at".into(), toml::Value::String(m.created_at.clone()));
|
|
|
|
let params: serde_json::Value = serde_json::from_str(&m.params_json)
|
|
.unwrap_or(serde_json::Value::Object(Default::default()));
|
|
mt.insert("params".into(), toml::Value::Table(json_to_toml_table(¶ms)));
|
|
|
|
let points = self.get_data_points(m.id)?;
|
|
let mut data_array = Vec::new();
|
|
let mut cl_result: Option<serde_json::Value> = None;
|
|
|
|
for p in &points {
|
|
let jv: serde_json::Value = serde_json::from_str(&p.data_json)
|
|
.unwrap_or(serde_json::Value::Null);
|
|
if let Some(obj) = jv.as_object() {
|
|
if obj.contains_key("result") {
|
|
cl_result = obj.get("result").cloned();
|
|
continue;
|
|
}
|
|
}
|
|
if let Some(row) = data_point_to_toml(&m.mtype, &jv) {
|
|
data_array.push(toml::Value::Table(row));
|
|
}
|
|
}
|
|
|
|
if !data_array.is_empty() {
|
|
mt.insert("data".into(), toml::Value::Array(data_array));
|
|
}
|
|
|
|
if let Some(r) = cl_result {
|
|
mt.insert("result".into(), toml::Value::Table(cl_result_to_toml(&r)));
|
|
}
|
|
|
|
meas_array.push(toml::Value::Table(mt));
|
|
}
|
|
|
|
root.insert("measurement".into(), toml::Value::Array(meas_array));
|
|
Ok(toml::to_string_pretty(&toml::Value::Table(root))?)
|
|
}
|
|
|
|
pub fn import_session(&self, toml_str: &str) -> Result<i64, Box<dyn std::error::Error>> {
|
|
let doc: toml::Value = toml::from_str(toml_str)?;
|
|
let root = doc.as_table().ok_or("invalid TOML root")?;
|
|
|
|
let sess = root.get("session")
|
|
.and_then(|v| v.as_table())
|
|
.ok_or("missing [session]")?;
|
|
|
|
let name = sess.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
let notes = sess.get("notes").and_then(|v| v.as_str()).unwrap_or("");
|
|
let session_id = self.create_session(name, notes)?;
|
|
|
|
if let Some(toml::Value::Array(measurements)) = root.get("measurement") {
|
|
for mt in measurements {
|
|
let mt = mt.as_table().ok_or("invalid measurement")?;
|
|
let mtype = mt.get("type").and_then(|v| v.as_str()).unwrap_or("eis");
|
|
let params_table = mt.get("params").and_then(|v| v.as_table());
|
|
let params_json = match params_table {
|
|
Some(t) => serde_json::to_string(&toml_table_to_json(t))?,
|
|
None => "{}".to_string(),
|
|
};
|
|
let mid = self.create_measurement(session_id, mtype, ¶ms_json, None)?;
|
|
|
|
if let Some(toml::Value::Array(data)) = mt.get("data") {
|
|
let pts: Vec<(i32, String)> = data.iter().enumerate()
|
|
.filter_map(|(i, row)| {
|
|
let row = row.as_table()?;
|
|
let jv = toml_data_row_to_json(mtype, row);
|
|
serde_json::to_string(&jv).ok().map(|s| (i as i32, s))
|
|
})
|
|
.collect();
|
|
self.add_data_points_batch(mid, &pts)?;
|
|
}
|
|
|
|
if mtype == "chlorine" {
|
|
if let Some(result_table) = mt.get("result").and_then(|v| v.as_table()) {
|
|
let rj = toml_cl_result_to_json(result_table);
|
|
let wrapper = format!("{{\"result\":{}}}", serde_json::to_string(&rj)?);
|
|
let idx = self.conn.query_row(
|
|
"SELECT COALESCE(MAX(idx), -1) + 1 FROM data_points WHERE measurement_id = ?1",
|
|
params![mid], |row| row.get::<_, i32>(0),
|
|
)?;
|
|
self.add_data_point(mid, idx, &wrapper)?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(session_id)
|
|
}
|
|
}
|
|
|
|
// MARK: - TOML ↔ JSON conversion helpers
|
|
|
|
fn json_to_toml_table(jv: &serde_json::Value) -> Table {
|
|
let mut t = Table::new();
|
|
if let Some(obj) = jv.as_object() {
|
|
for (k, v) in obj {
|
|
if let Some(tv) = json_val_to_toml(v) {
|
|
t.insert(k.clone(), tv);
|
|
}
|
|
}
|
|
}
|
|
t
|
|
}
|
|
|
|
fn json_val_to_toml(jv: &serde_json::Value) -> Option<toml::Value> {
|
|
match jv {
|
|
serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
|
|
serde_json::Value::Number(n) => {
|
|
if let Some(i) = n.as_i64() { Some(toml::Value::Integer(i)) }
|
|
else { n.as_f64().map(toml::Value::Float) }
|
|
}
|
|
serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn data_point_to_toml(mtype: &str, jv: &serde_json::Value) -> Option<Table> {
|
|
let obj = jv.as_object()?;
|
|
let mut t = Table::new();
|
|
match mtype {
|
|
"eis" => {
|
|
t.insert("Frequency (Hz)".into(), toml_f(obj, "freq_hz")?);
|
|
t.insert("|Z| (Ohm)".into(), toml_f(obj, "mag_ohms")?);
|
|
t.insert("Phase (deg)".into(), toml_f(obj, "phase_deg")?);
|
|
t.insert("Re (Ohm)".into(), toml_f(obj, "z_real")?);
|
|
t.insert("Im (Ohm)".into(), toml_f(obj, "z_imag")?);
|
|
}
|
|
"lsv" => {
|
|
t.insert("Voltage (mV)".into(), toml_f(obj, "v_mv")?);
|
|
t.insert("Current (uA)".into(), toml_f(obj, "i_ua")?);
|
|
}
|
|
"amp" => {
|
|
t.insert("Time (ms)".into(), toml_f(obj, "t_ms")?);
|
|
t.insert("Current (uA)".into(), toml_f(obj, "i_ua")?);
|
|
}
|
|
"chlorine" => {
|
|
t.insert("Time (ms)".into(), toml_f(obj, "t_ms")?);
|
|
t.insert("Current (uA)".into(), toml_f(obj, "i_ua")?);
|
|
if let Some(p) = obj.get("phase").and_then(|v| v.as_u64()) {
|
|
t.insert("Phase".into(), toml::Value::Integer(p as i64));
|
|
}
|
|
}
|
|
"ph" => {
|
|
t.insert("OCP (mV)".into(), toml_f(obj, "v_ocp_mv")?);
|
|
t.insert("pH".into(), toml_f(obj, "ph")?);
|
|
t.insert("Temperature (C)".into(), toml_f(obj, "temp_c")?);
|
|
}
|
|
_ => return None,
|
|
}
|
|
Some(t)
|
|
}
|
|
|
|
fn toml_f(obj: &serde_json::Map<String, serde_json::Value>, key: &str) -> Option<toml::Value> {
|
|
obj.get(key)?.as_f64().map(toml::Value::Float)
|
|
}
|
|
|
|
fn cl_result_to_toml(jv: &serde_json::Value) -> Table {
|
|
let mut t = Table::new();
|
|
if let Some(obj) = jv.as_object() {
|
|
if let Some(v) = obj.get("i_free_ua").and_then(|v| v.as_f64()) {
|
|
t.insert("Free Cl (uA)".into(), toml::Value::Float(v));
|
|
}
|
|
if let Some(v) = obj.get("i_total_ua").and_then(|v| v.as_f64()) {
|
|
t.insert("Total Cl (uA)".into(), toml::Value::Float(v));
|
|
}
|
|
}
|
|
t
|
|
}
|
|
|
|
fn toml_table_to_json(t: &Table) -> serde_json::Value {
|
|
let mut obj = serde_json::Map::new();
|
|
for (k, v) in t {
|
|
obj.insert(k.clone(), toml_val_to_json(v));
|
|
}
|
|
serde_json::Value::Object(obj)
|
|
}
|
|
|
|
fn toml_val_to_json(v: &toml::Value) -> serde_json::Value {
|
|
match v {
|
|
toml::Value::String(s) => serde_json::Value::String(s.clone()),
|
|
toml::Value::Integer(i) => serde_json::json!(*i),
|
|
toml::Value::Float(f) => serde_json::json!(*f),
|
|
toml::Value::Boolean(b) => serde_json::Value::Bool(*b),
|
|
toml::Value::Table(t) => toml_table_to_json(t),
|
|
_ => serde_json::Value::Null,
|
|
}
|
|
}
|
|
|
|
fn toml_data_row_to_json(mtype: &str, row: &Table) -> serde_json::Value {
|
|
let mut obj = serde_json::Map::new();
|
|
match mtype {
|
|
"eis" => {
|
|
set_f(&mut obj, "freq_hz", row, "Frequency (Hz)");
|
|
set_f(&mut obj, "mag_ohms", row, "|Z| (Ohm)");
|
|
set_f(&mut obj, "phase_deg", row, "Phase (deg)");
|
|
set_f(&mut obj, "z_real", row, "Re (Ohm)");
|
|
set_f(&mut obj, "z_imag", row, "Im (Ohm)");
|
|
}
|
|
"lsv" => {
|
|
set_f(&mut obj, "v_mv", row, "Voltage (mV)");
|
|
set_f(&mut obj, "i_ua", row, "Current (uA)");
|
|
}
|
|
"amp" => {
|
|
set_f(&mut obj, "t_ms", row, "Time (ms)");
|
|
set_f(&mut obj, "i_ua", row, "Current (uA)");
|
|
}
|
|
"chlorine" => {
|
|
set_f(&mut obj, "t_ms", row, "Time (ms)");
|
|
set_f(&mut obj, "i_ua", row, "Current (uA)");
|
|
if let Some(v) = row.get("Phase").and_then(|v| v.as_integer()) {
|
|
obj.insert("phase".into(), serde_json::json!(v));
|
|
}
|
|
}
|
|
"ph" => {
|
|
set_f(&mut obj, "v_ocp_mv", row, "OCP (mV)");
|
|
set_f(&mut obj, "ph", row, "pH");
|
|
set_f(&mut obj, "temp_c", row, "Temperature (C)");
|
|
}
|
|
_ => {}
|
|
}
|
|
serde_json::Value::Object(obj)
|
|
}
|
|
|
|
fn set_f(
|
|
obj: &mut serde_json::Map<String, serde_json::Value>,
|
|
json_key: &str, row: &Table, toml_key: &str,
|
|
) {
|
|
if let Some(v) = row.get(toml_key).and_then(|v| v.as_float()) {
|
|
obj.insert(json_key.into(), serde_json::json!(v));
|
|
}
|
|
}
|
|
|
|
fn toml_cl_result_to_json(t: &Table) -> serde_json::Value {
|
|
let mut obj = serde_json::Map::new();
|
|
if let Some(v) = t.get("Free Cl (uA)").and_then(|v| v.as_float()) {
|
|
obj.insert("i_free_ua".into(), serde_json::json!(v));
|
|
}
|
|
if let Some(v) = t.get("Total Cl (uA)").and_then(|v| v.as_float()) {
|
|
obj.insert("i_total_ua".into(), serde_json::json!(v));
|
|
}
|
|
serde_json::Value::Object(obj)
|
|
}
|
|
|
|
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
|
|
);
|
|
";
|