diff --git a/.vscode/settings.json b/.vscode/settings.json index d7e496a..7f09cfc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,6 @@ "activityBar.background": "#4C0C61", "titleBar.activeBackground": "#6B1188", "titleBar.activeForeground": "#FDFAFE" - } + }, + "rust-analyzer.showUnlinkedFileNotification": false } \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index aa82ce1..914ddb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,4 +14,5 @@ serde_json = "1.0" futures = "0.3.28" tokio = { version = "1.32.0", features=["macros", "rt-multi-thread"] } chrono = "0.4.31" -actix-web = "4.4.0" \ No newline at end of file +actix-web = "4.4.0" +actix-files = "0.6.2" \ No newline at end of file diff --git a/build.sh b/build.sh index 5ae879c..bf3bc68 100644 --- a/build.sh +++ b/build.sh @@ -9,4 +9,5 @@ cargo build --release mkdir -p server cp devices.conf server/ -cp target/release/home_server server/ \ No newline at end of file +cp target/release/home_server server/ +cp -r resources server/ \ No newline at end of file diff --git a/resources/css/index.css b/resources/css/index.css new file mode 100644 index 0000000..e69de29 diff --git a/resources/js/main.js b/resources/js/main.js new file mode 100644 index 0000000..e69de29 diff --git a/resources/static/index.html b/resources/static/index.html new file mode 100644 index 0000000..96c5206 --- /dev/null +++ b/resources/static/index.html @@ -0,0 +1,14 @@ + + + + + Smart Homeserver + + + + +
+ + + + \ No newline at end of file diff --git a/src/db.rs b/src/db.rs index f547a00..d085010 100644 --- a/src/db.rs +++ b/src/db.rs @@ -3,28 +3,52 @@ use std::path::Path; use anyhow::Result; use rusqlite::{Connection, ToSql}; +use crate::devices::{Devices, DevicesWithName}; + pub struct DataBase { sql: Connection, } impl DataBase { + const VERSION_0_1_0: &'static str = "0.1.0"; + pub async fn new(path: impl AsRef) -> Result { let me = Self { sql: Connection::open(path)?, }; me.generate_tables()?; + me.init()?; Ok(me) } fn generate_tables(&self) -> Result<()> { self.sql.execute( - "CREATE TABLE IF NOT EXISTS data ( + "CREATE TABLE IF NOT EXISTS meta ( id INTEGER PRIMARY KEY, - device TEXT NOT NULL, - time BIGINT NOT NULL, - watts REAL NOT NULL + version INTEGER NOT NULL + )", + [], + )?; + + self.sql.execute( + "CREATE TABLE IF NOT EXISTS devices( + id INTEGER PRIMARY KEY, + device VARCHAR(60) NOT NULL, + type VARCHAR(30) NOT NULL, + name VARCHAR(80) + )", + [], + )?; + + self.sql.execute( + "CREATE TABLE IF NOT EXISTS data ( + id INTEGER PRIMARY KEY, + time BIGINT NOT NULL, + watts REAL NOT NULL, + device_id INTEGER NOT NULL, + FOREIGN KEY(device_id) REFERENCES devices(id) )", [], )?; @@ -32,18 +56,97 @@ impl DataBase { Ok(()) } + pub fn version(&self) -> Result { + Ok(self + .sql + .query_row("SELECT version FROM meta WHERE id=1", [], |row| row.get(0))?) + } + + fn init(&self) -> Result<()> { + if self.version().is_err() { + self.sql.execute( + "INSERT INTO meta (version) + VALUES (?1)", + &[Self::VERSION_0_1_0], + )?; + } + + Ok(()) + } + + pub fn register_devices(&self, devices: &Devices) -> Result<()> { + for device in devices.plugs.iter() { + self.sql.execute( + "INSERT INTO devices (device, type) + SELECT ?1, \"plug\" + WHERE + NOT EXISTS ( + SELECT device + FROM devices + WHERE device=\"?1\" + ) + ", + &[device], + )?; + } + + Ok(()) + } + pub fn write(&self, device_name: &str, time: u64, watts: f32) -> Result<()> { - let params: &[&dyn ToSql] = &[&device_name, &time, &watts]; + let params: &[&dyn ToSql] = &[&time, &watts, &device_name]; self.sql.execute( - "INSERT INTO data (device, time, watts) - VALUES (?1, ?2, ?3)", + &format!( + "INSERT INTO data (time, watts, device_id) + VALUES (?1, ?2, (SELECT id FROM devices WHERE device=?3) )" + ), params, )?; Ok(()) } + pub fn devices(&self) -> Result { + let mut devices = DevicesWithName::default(); + + for row in self + .sql + .prepare(&format!( + " + SELECT device, type, name + FROM devices + " + ))? + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))? + { + let (device, dev_type, name): (String, String, String) = row?; + + match dev_type.as_str() { + "plug" => devices.plugs.push((device, name)), + + _ => panic!(), + } + } + + Ok(devices) + } + + pub fn change_device_name(&self, device: &str, description: &str) -> Result<()> { + self.sql.execute( + &format!( + " + UPDATE devices + SET name=?1 + WHERE device=?2 + " + ), + &[&description, device], + )?; + + Ok(()) + } + pub fn read(&self, device: &str) -> Result> { self.sql .prepare(&format!( @@ -69,22 +172,45 @@ mod test { use anyhow::Result; + use crate::devices::Devices; + use super::DataBase; #[tokio::test] async fn test_connection() -> Result<()> { - DataBase::new("connection_test.db").await?; + let db = DataBase::new("connection_test.db").await?; + assert_eq!(DataBase::VERSION_0_1_0, db.version()?); fs::remove_file("connection_test.db")?; Ok(()) } + #[tokio::test] + async fn test_startup() -> Result<()> { + let db = DataBase::new("startup_test.db").await?; + + db.register_devices(&Devices { + plugs: vec!["test".to_string()], + })?; + + fs::remove_file("startup_test.db")?; + + Ok(()) + } + #[tokio::test] async fn test_write() -> Result<()> { let db = DataBase::new("write_test.db").await?; - db.write("test", 0, 5.5)?; + let device_name = "test"; + + db.register_devices(&Devices { + plugs: vec![device_name.to_string()], + })?; + + db.write(device_name, 0, 5.5)?; + db.change_device_name(device_name, "udo")?; fs::remove_file("write_test.db")?; diff --git a/src/devices.rs b/src/devices.rs index 148d8cb..6132c8f 100644 --- a/src/devices.rs +++ b/src/devices.rs @@ -26,6 +26,17 @@ impl Devices { } } +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct DevicesWithName { + pub plugs: Vec<(String, String)>, +} + +impl DevicesWithName { + pub fn to_json(&self) -> Result { + Ok(to_string(self)?) + } +} + #[cfg(test)] mod test { use super::Devices; diff --git a/src/main.rs b/src/main.rs index b71a46e..b5c13d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,16 +12,13 @@ mod devices; mod tasmota; mod web_server; -use actix_web::{ - get, middleware, rt, - web::{self, Data}, - App, HttpRequest, HttpServer, -}; +use actix_files::Files; +use actix_web::{web::Data, App, HttpServer}; use anyhow::Result; use devices::Devices; use futures::{future::try_join_all, try_join, Future}; use tasmota::Tasmota; -use web_server::{device_query, index, plug_state}; +use web_server::{change_plug_state, device_query, index, plug_state}; fn since_epoch() -> Result { Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()) @@ -38,8 +35,6 @@ fn read_power_usage( db.lock() .unwrap() .write(plug.name(), since_epoch()?, usage)?; - - println!("read {}", plug.name(),); } Ok::<(), anyhow::Error>(()) @@ -61,6 +56,9 @@ async fn run_web_server( .app_data(Data::new(devices.clone())) .app_data(Data::new(db.clone())) .app_data(Data::new(plugs.clone())) + .service(Files::new("/images", "resources/images/").show_files_listing()) + .service(Files::new("/css", "resources/css").show_files_listing()) + .service(Files::new("/js", "resources/js").show_files_listing()) .service(index) .service(device_query) .service(plug_state) @@ -81,6 +79,7 @@ async fn main() -> Result<()> { let (db, devices) = try_join!(db_future, devices_future)?; + db.register_devices(&devices)?; let shared_db = Arc::new(Mutex::new(db)); let tasmota_plugs: Vec = devices @@ -89,7 +88,7 @@ async fn main() -> Result<()> { .map(|plug| Tasmota::new(plug)) .collect(); - let res = try_join!( + try_join!( read_power_usage(tasmota_plugs.clone(), shared_db.clone()), run_web_server(devices, tasmota_plugs, shared_db) )?; diff --git a/src/web_server.rs b/src/web_server.rs index 8d342ed..8023a2e 100644 --- a/src/web_server.rs +++ b/src/web_server.rs @@ -1,14 +1,18 @@ +use actix_files::NamedFile; use actix_web::{ get, post, web::{Data, Json, Path}, - HttpRequest, Responder, ResponseError, + Responder, ResponseError, }; use serde::Serialize; use serde_json::to_string; -use crate::{devices::Devices, tasmota::Tasmota}; +use crate::{db::DataBase, tasmota::Tasmota}; -use std::fmt::{Display, Formatter, Result as FmtResult}; +use std::{ + fmt::{Display, Formatter, Result as FmtResult}, + sync::{Arc, Mutex}, +}; #[derive(Serialize)] struct DeviceState { @@ -40,19 +44,36 @@ impl From for MyError { } #[get("/")] -async fn index(req: HttpRequest) -> &'static str { - println!("REQ: {:?}", req); - "Hello world!\r\n" +async fn index() -> Result { + NamedFile::open("resources/static/index.html") } #[get("/devices")] -async fn device_query(devices: Data) -> Result { - devices +async fn device_query( + db: Data>>, +) -> Result { + db.lock() + .unwrap() + .devices()? .to_json() .map(|json| Json(json)) .map_err(|err| MyError::from(err)) } +#[post("/device_name/{device}/{name}")] +async fn change_device_name( + device: Path, + name: Path, + db: Data>>, +) -> Result { + db.lock() + .unwrap() + .change_device_name(&device.into_inner(), &name.into_inner()) + .map_err(|err| MyError::from(err))?; + + return Ok("Ok"); +} + async fn tasmota_info(tasmota: &Tasmota) -> anyhow::Result { let led = tasmota.led_state().await?; let power = tasmota.power_state().await?; @@ -122,3 +143,24 @@ async fn change_plug_state( msg: format!("plug ({plug_name}) not found"), }) } + +#[cfg(test)] +mod test { + use actix_web::{http::header::ContentType, test, App}; + + use super::*; + + #[actix_web::test] + async fn test_index_get() { + let app = test::init_service(App::new().service(index)).await; + let req = test::TestRequest::default() + .insert_header(ContentType::plaintext()) + .to_request(); + let resp = test::call_service(&app, req).await; + + let status = resp.status(); + let body = resp.into_body(); + + assert!(status.is_success(), "{:?}", body); + } +}