diff --git a/Cargo.toml b/Cargo.toml index 9a0d36061cd968a5a691b676b8e944ec9ce74d7c..e46aeff60880fe22944adbaef1c353c6cbe0e36e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ fern = "0.7.1" log = "0.4.25" meshtastic = "0.1.6" tokio = "1.42.0" -rusqlite = { version = "0.32.1", features = ["bundled"] } +rusqlite = { version = "0.32.1", features = ["bundled", "backup"] } rusqlite_migration = "1.3.1" lazy_static = "1.5.0" figment = {version = "0.10.19", features = ["toml", "env"]} diff --git a/src/config.rs b/src/config.rs index 7deb82fc96bf3a525d48f5d0ee5e275c7abf989c..4ff19cb8d17b1cafc1fd47cee7c1d7fb74a4bf44 100644 --- a/src/config.rs +++ b/src/config.rs @@ -53,6 +53,21 @@ pub struct GuiConfig { pub units: String } +impl GuiConfig { + pub fn get_unit_shorthand(&self) -> Option<&'static str> { + match self.units.to_lowercase().as_str() { + "centimeters" => Some("cm"), + "meters" => Some("m"), + "kilometers" => Some("km"), + "inches" => Some("inch"), + "feet" => Some("ft"), + "yards" => Some("yd"), + "miles" => Some("mi"), + _ => None, + } + } +} + // Defaults impl Default for ServerConfig { diff --git a/src/db.rs b/src/db.rs index 35fab6ac9f21dcfa9ff55884993c6be87be0ee8e..fdf80b4982c196da514b116f8582e5e46b71f464 100644 --- a/src/db.rs +++ b/src/db.rs @@ -12,6 +12,10 @@ use meshtastic::protobufs::{Channel, DeviceMetrics, MeshPacket, MyNodeInfo, Node use crate::CONFIG; +// General TODO: +// Spin this off into a thread? Have some form of caching? reading from the DB every single frame seems a little... retarded. +// Do we have separate dbs for different types of MeshPackets? (table for each type? each node? ) + pub(crate) struct Database { conn: Connection, } @@ -58,85 +62,99 @@ impl Database { // Open it up, and run migrations! let mut conn = Connection::open(&db_path)?; // Apply migrations - MIGRATIONS.to_latest(&mut conn).expect("Migration failed"); + if let Err(e) = MIGRATIONS.to_latest(&mut conn) { + error!("DB Migration failed: {}", e); + } Ok(Database { conn }) } /// Updates the database with a new MyNodeInfo packet pub fn update_mynodeinfo(my_node_info: &MyNodeInfo) -> Result<bool> { // Open DB - let db = Database::open().expect("Failure to open database"); - - db.conn.execute( - "INSERT INTO MyNodeInfo (my_node_num, reboot_count, min_app_version) VALUES (?1, ?2, ?3)", - rusqlite::params![my_node_info.my_node_num, my_node_info.reboot_count, my_node_info.min_app_version], - )?; - debug!("Saved MyNodeInfo to database"); - Ok(true) + match Database::open() { + Ok(db) => { + db.conn.execute( + "INSERT INTO MyNodeInfo (my_node_num, reboot_count, min_app_version) VALUES (?1, ?2, ?3)", + rusqlite::params![my_node_info.my_node_num, my_node_info.reboot_count, my_node_info.min_app_version], + )?; + debug!("Saved MyNodeInfo to database"); + Ok(true) + }, + Err(e) => { + error!("Failed to open database: {}", e); + Ok(false) + }, + } } /// Updates the database with a new NodeInfo packet pub fn update_nodeinfo(node_info: &NodeInfo) -> Result<bool> { // Open DB - let db = Database::open().expect("Failure to open database"); - - db.conn.execute( - "INSERT INTO Node ( - num, user_id, user_lname, user_sname, hw_model, is_licensed, role, - latitude_i, longitude_i, altitude, location_source, gps_timestamp, - altitude_source, pdop, hdop, vdop, gps_accuracy, ground_speed, - ground_track, fix_quality, fix_type, sats_in_view, sensor_id, - next_update, seq_number, precision_bits, snr, last_heard, - battery_level, voltage, channel_utilization, air_util_tx, - channel, via_mqtt, hops_away - ) VALUES ( - ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, - ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, - ?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35 - )", - rusqlite::params![ - node_info.num, - // This is gibberish to me, so lets explain. - // We're mapping the value user.id as the value, but chucking a NULL in if it's a None type. - node_info.user.as_ref().map(|user| user.id.clone()), - node_info.user.as_ref().map(|user| user.long_name.clone()), - node_info.user.as_ref().map(|user| user.short_name.clone()), - node_info.user.as_ref().map(|user| user.hw_model), - node_info.user.as_ref().map(|user| user.is_licensed), - node_info.user.as_ref().map(|user| user.role), - node_info.position.as_ref().map(|position| position.latitude_i), - node_info.position.as_ref().map(|position| position.longitude_i), - node_info.position.as_ref().map(|position| position.altitude), - node_info.position.as_ref().map(|position| position.location_source), - node_info.position.as_ref().map(|position| position.timestamp), - node_info.position.as_ref().map(|position| position.altitude_source), - node_info.position.as_ref().map(|position| position.pdop), - node_info.position.as_ref().map(|position| position.hdop), - node_info.position.as_ref().map(|position| position.vdop), - node_info.position.as_ref().map(|position| position.gps_accuracy), - node_info.position.as_ref().map(|position| position.ground_speed), - node_info.position.as_ref().map(|position| position.ground_track), - node_info.position.as_ref().map(|position| position.fix_quality), - node_info.position.as_ref().map(|position| position.fix_type), - node_info.position.as_ref().map(|position| position.sats_in_view), - node_info.position.as_ref().map(|position| position.sensor_id), - node_info.position.as_ref().map(|position| position.next_update), - node_info.position.as_ref().map(|position| position.seq_number), - node_info.position.as_ref().map(|position| position.precision_bits), - node_info.snr, - node_info.last_heard, - node_info.device_metrics.as_ref().map(|device_metrics| device_metrics.battery_level), - node_info.device_metrics.as_ref().map(|device_metrics| device_metrics.voltage), - node_info.device_metrics.as_ref().map(|device_metrics| device_metrics.channel_utilization), - node_info.device_metrics.as_ref().map(|device_metrics| device_metrics.air_util_tx), - node_info.channel, - node_info.via_mqtt, - node_info.hops_away - ] - )?; - - debug!("Saved NodeInfo to database"); - Ok(true) + match Database::open() { + Ok(db) => { + db.conn.execute( + "INSERT INTO Node ( + num, user_id, user_lname, user_sname, hw_model, is_licensed, role, + latitude_i, longitude_i, altitude, location_source, gps_timestamp, + altitude_source, pdop, hdop, vdop, gps_accuracy, ground_speed, + ground_track, fix_quality, fix_type, sats_in_view, sensor_id, + next_update, seq_number, precision_bits, snr, last_heard, + battery_level, voltage, channel_utilization, air_util_tx, + channel, via_mqtt, hops_away + ) VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, + ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, + ?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35 + )", + rusqlite::params![ + node_info.num, + // This is gibberish to me, so lets explain. + // We're mapping the value user.id as the value, but chucking a NULL in if it's a None type. + node_info.user.as_ref().map(|user| user.id.clone()), + node_info.user.as_ref().map(|user| user.long_name.clone()), + node_info.user.as_ref().map(|user| user.short_name.clone()), + node_info.user.as_ref().map(|user| user.hw_model), + node_info.user.as_ref().map(|user| user.is_licensed), + node_info.user.as_ref().map(|user| user.role), + node_info.position.as_ref().map(|position| position.latitude_i), + node_info.position.as_ref().map(|position| position.longitude_i), + node_info.position.as_ref().map(|position| position.altitude), + node_info.position.as_ref().map(|position| position.location_source), + node_info.position.as_ref().map(|position| position.timestamp), + node_info.position.as_ref().map(|position| position.altitude_source), + node_info.position.as_ref().map(|position| position.pdop), + node_info.position.as_ref().map(|position| position.hdop), + node_info.position.as_ref().map(|position| position.vdop), + node_info.position.as_ref().map(|position| position.gps_accuracy), + node_info.position.as_ref().map(|position| position.ground_speed), + node_info.position.as_ref().map(|position| position.ground_track), + node_info.position.as_ref().map(|position| position.fix_quality), + node_info.position.as_ref().map(|position| position.fix_type), + node_info.position.as_ref().map(|position| position.sats_in_view), + node_info.position.as_ref().map(|position| position.sensor_id), + node_info.position.as_ref().map(|position| position.next_update), + node_info.position.as_ref().map(|position| position.seq_number), + node_info.position.as_ref().map(|position| position.precision_bits), + node_info.snr, + node_info.last_heard, + node_info.device_metrics.as_ref().map(|device_metrics| device_metrics.battery_level), + node_info.device_metrics.as_ref().map(|device_metrics| device_metrics.voltage), + node_info.device_metrics.as_ref().map(|device_metrics| device_metrics.channel_utilization), + node_info.device_metrics.as_ref().map(|device_metrics| device_metrics.air_util_tx), + node_info.channel, + node_info.via_mqtt, + node_info.hops_away + ] + )?; + + debug!("Saved NodeInfo to database"); + Ok(true) + }, + Err(e) => { + error!("Failed to open database: {}", e); + Ok(false) + }, + } } /// Updates the database with a new Channel packet diff --git a/src/gui/widgets/mod.rs b/src/gui/widgets/mod.rs index f048c5894a291642f4bdbc0d15ca323caa1215eb..6f593f5c2c39faee394ffbda19d2fc177a7894fb 100644 --- a/src/gui/widgets/mod.rs +++ b/src/gui/widgets/mod.rs @@ -40,7 +40,9 @@ pub fn render_widget(widget_type: &WidgetType, ui: &mut egui::Ui) { WidgetType::NodeList => { node_list::nodelist_ui(ui); }, - WidgetType::NodeInfo => todo!(), + WidgetType::NodeInfo => { + node_info::nodeinfo_ui(ui); + }, WidgetType::Log => { logging::log_ui(ui); }, diff --git a/src/gui/widgets/node_info.rs b/src/gui/widgets/node_info.rs index b8d10d3e2248f47313a3481d6bf33e83e5b91d20..f9da0f3d8d4bca115e8b7e7e948bb0ccc39288ca 100644 --- a/src/gui/widgets/node_info.rs +++ b/src/gui/widgets/node_info.rs @@ -1,10 +1,13 @@ use chrono::DateTime; use chrono_humanize::HumanTime; +use egui::Label; use egui_extras::{Column, TableBuilder}; +use log::info; use longitude::{DistanceUnit, Location}; -use meshtastic::protobufs::{channel::Role, HardwareModel, Position}; +use meshtastic::{protobufs::{channel::Role, HardwareModel, Position}, types::NodeId}; +use meshtastic::protobufs::{Channel, DeviceMetrics, MeshPacket, MyNodeInfo, NodeInfo, User}; -use crate::{db::Database, CONFIG}; +use crate::{db::Database, gui::widgets::node_list::calc_distance_to_node, nodes, CONFIG}; use super::node_list; @@ -22,122 +25,84 @@ use super::node_list; /// UI Elements for the Node List pub fn nodeinfo_ui(ui: &mut egui::Ui) { // Get nodes from DB - let mut nodes = Database::read_node_list().expect("Failed to read nodes"); - - // Sort nodes by `last_heard` in descending order - nodes.sort_by(|a, b| b.last_heard.cmp(&a.last_heard)); - - TableBuilder::new(ui) - // TODO for the table: - // - Clickable Table Sorting - // - Add more info - // - Auto hide/show scrollbar on smaller devices - // - Add to a panel that we can move around / shitty windowing. - .column(Column::remainder()) // node id - .column(Column::remainder().clip(true)) // node name - .column(Column::remainder()) // snr - .column(Column::remainder()) // dist - .column(Column::remainder()) // hops - .column(Column::remainder().clip(true)) // hw model - .column(Column::remainder()) // role - .column(Column::remainder()) // battery - .column(Column::remainder().clip(true)) // last heard - .striped(true) - .header(15.0, |mut header| { - header.col(|ui| { - ui.heading("Node ID"); - }); - header.col(|ui| { - ui.heading("Name"); - }); - header.col(|ui| { - ui.heading("SNR"); - }); - header.col(|ui| { - - // Get distance units - let unit = match CONFIG.gui.units.as_ref() { - "Centimeters" => "cm", - "Meters" => "m", - "Kilometers" => "km", - "Inches" => "in", - "Feet" => "ft", - "Yards" => "yd", - "Miles" => "mi", - _ => "km", // Default to km if no match found. - }; + let nodes: Vec<NodeInfo> = Database::read_node_list().expect("Failed to read nodes"); + + info!("Nodes: {:?}", nodes); + + // Set the first node in the db to be selected + // This needs to be set elsewhere, but for testing... meh. + let mut selected_node = nodes.first().unwrap().num; + + // Add a dropdown selector for changing the node + egui::ComboBox::from_label("Selected Node") + .selected_text(format!("{:?}", selected_node)) + .show_ui(ui, |ui| { + for node in &nodes { + ui.selectable_value(&mut selected_node, node.num, format!("{:?}", node.user.clone().unwrap().id)); + } + } + ); + + // Iterate through the list until we find the selected node. + let mut found_selected_node = false; + for node in &nodes { + if node.num == selected_node { + // Render Info when found + ui.label(format!("Node: {:?}", node)); + ui.label(format!("User ID: {}", node.user.clone().unwrap().id)); + + // Compute First Heard + // How will we do this? the database doesn't store first heard. TODO! + + // Compute Last Heard + let user = node.user.clone().unwrap(); + let device = node.device_metrics.clone().unwrap(); + // Convert the last heard to a time, then make it nice to read. + let now = chrono::Local::now().to_utc(); + let humanised = if node.last_heard == 0 { + String::from("Unknown") + } else { + let last_heard = DateTime::from_timestamp(node.last_heard.into(), 0).unwrap(); + let duration = last_heard - now; + HumanTime::from(duration).to_string() + }; + ui.label(format!("Last Heard: {}", humanised)); + + // Find Messages to or from said node + // TODO: Requires parsing all MeshPackets in the DB - ui.heading(format!("Distance ({})", unit)); - }); - header.col(|ui| { - ui.heading("Hops Away"); - }); - header.col(|ui| { - ui.heading("HW Model"); - }); - header.col(|ui| { - ui.heading("Role"); - }); - header.col(|ui| { - ui.heading("Battery"); - }); - header.col(|ui| { - ui.heading("Last Heard"); - }); - }) - .body(|mut body| { - for node in nodes { - let user = node.user.unwrap(); - let device = node.device_metrics.unwrap(); - // Convert the last heard to a time, then make it nice to read. - let now = chrono::Local::now().to_utc(); - let humanised = if node.last_heard == 0 { - String::from("Unknown") + // Find all Battery stats from said node + // TODO: Requires parsing all MeshPackets in the DB + + // Find all temperature stats from said node + // TODO: Requires parsing all MeshPackets in the DB + + // Find Location history from said node + // TODO: Requires parsing all MeshPackets in the DB + // Perhaps with the 3 above, we parse all packets to or from the node, and then filter depending on the above 4 + + // Find distance to node. + if let Some(distance) = calc_distance_to_node(node.position.clone().unwrap()) { + if distance.is_nan() { + ui.label("-"); } else { - let last_heard = DateTime::from_timestamp(node.last_heard.into(), 0).unwrap(); - let duration = last_heard - now; - HumanTime::from(duration).to_string() - }; - - body.row(15.0, |mut row| { - row.col(|ui| { - ui.label(user.id); - }); - row.col(|ui| { - ui.label(format!("{} ({})", user.long_name, user.short_name)); - }); - row.col(|ui| { - ui.label(node.snr.to_string()); - }); - row.col(|ui| { - if let Some(distance) = node_list::calc_distance_to_node(node.position.unwrap()) { - if distance.is_nan() { - ui.label("-"); - } else { - ui.label(format!("{:.2}", distance)); - } - } else { - ui.label("-"); - } - }); - row.col(|ui| { - ui.label(node.hops_away.to_string()); - }); - row.col(|ui| { - // Convert the i32 to a HardwareModel object, then convert to string - ui.label(HardwareModel::from_i32(user.hw_model).unwrap_or_default().as_str_name()); - }); - row.col(|ui| { - // Convert the i32 to a Role object, convert to string. - ui.label(Role::from_i32(user.role).unwrap_or_default().as_str_name()); - }); - row.col(|ui| { - ui.label(device.battery_level.to_string()); - }); - row.col(|ui| { - ui.label(humanised); - }); - }); + // Get the unit shorthand from the GuiConfig struct + let unit_shorthand = CONFIG.gui.get_unit_shorthand().unwrap_or(""); + ui.label(format!("Distance to Node: {:.2}{}", distance, unit_shorthand)); + } + } else { + ui.label("-"); } - }); + + // Find Direct (radio) Neighbours of said node + + found_selected_node = true; + break; // Exit loop once the selected node is found + } + } + + if !found_selected_node { + ui.label("Selected node not found."); + } + } \ No newline at end of file