Start frontend

This commit is contained in:
hodasemi 2023-09-21 10:08:23 +02:00
parent 4c2addb2de
commit 07b9b6c0be
10 changed files with 224 additions and 29 deletions

View file

@ -3,5 +3,6 @@
"activityBar.background": "#4C0C61", "activityBar.background": "#4C0C61",
"titleBar.activeBackground": "#6B1188", "titleBar.activeBackground": "#6B1188",
"titleBar.activeForeground": "#FDFAFE" "titleBar.activeForeground": "#FDFAFE"
} },
"rust-analyzer.showUnlinkedFileNotification": false
} }

View file

@ -14,4 +14,5 @@ serde_json = "1.0"
futures = "0.3.28" futures = "0.3.28"
tokio = { version = "1.32.0", features=["macros", "rt-multi-thread"] } tokio = { version = "1.32.0", features=["macros", "rt-multi-thread"] }
chrono = "0.4.31" chrono = "0.4.31"
actix-web = "4.4.0" actix-web = "4.4.0"
actix-files = "0.6.2"

View file

@ -9,4 +9,5 @@ cargo build --release
mkdir -p server mkdir -p server
cp devices.conf server/ cp devices.conf server/
cp target/release/home_server server/ cp target/release/home_server server/
cp -r resources server/

0
resources/css/index.css Normal file
View file

0
resources/js/main.js Normal file
View file

View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title>Smart Homeserver</title>
<link href="/css/index.css" rel="stylesheet">
</head>
<body>
<div id="main"></div>
<script type="text/javascript" src="/js/main.js"></script>
</body>
</html>

144
src/db.rs
View file

@ -3,28 +3,52 @@ use std::path::Path;
use anyhow::Result; use anyhow::Result;
use rusqlite::{Connection, ToSql}; use rusqlite::{Connection, ToSql};
use crate::devices::{Devices, DevicesWithName};
pub struct DataBase { pub struct DataBase {
sql: Connection, sql: Connection,
} }
impl DataBase { impl DataBase {
const VERSION_0_1_0: &'static str = "0.1.0";
pub async fn new(path: impl AsRef<Path>) -> Result<Self> { pub async fn new(path: impl AsRef<Path>) -> Result<Self> {
let me = Self { let me = Self {
sql: Connection::open(path)?, sql: Connection::open(path)?,
}; };
me.generate_tables()?; me.generate_tables()?;
me.init()?;
Ok(me) Ok(me)
} }
fn generate_tables(&self) -> Result<()> { fn generate_tables(&self) -> Result<()> {
self.sql.execute( self.sql.execute(
"CREATE TABLE IF NOT EXISTS data ( "CREATE TABLE IF NOT EXISTS meta (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
device TEXT NOT NULL, version INTEGER NOT NULL
time BIGINT NOT NULL, )",
watts REAL 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(()) Ok(())
} }
pub fn version(&self) -> Result<String> {
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<()> { 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( self.sql.execute(
"INSERT INTO data (device, time, watts) &format!(
VALUES (?1, ?2, ?3)", "INSERT INTO data (time, watts, device_id)
VALUES (?1, ?2, (SELECT id FROM devices WHERE device=?3) )"
),
params, params,
)?; )?;
Ok(()) Ok(())
} }
pub fn devices(&self) -> Result<DevicesWithName> {
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<Vec<(u64, f32)>> { pub fn read(&self, device: &str) -> Result<Vec<(u64, f32)>> {
self.sql self.sql
.prepare(&format!( .prepare(&format!(
@ -69,22 +172,45 @@ mod test {
use anyhow::Result; use anyhow::Result;
use crate::devices::Devices;
use super::DataBase; use super::DataBase;
#[tokio::test] #[tokio::test]
async fn test_connection() -> Result<()> { 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")?; fs::remove_file("connection_test.db")?;
Ok(()) 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] #[tokio::test]
async fn test_write() -> Result<()> { async fn test_write() -> Result<()> {
let db = DataBase::new("write_test.db").await?; 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")?; fs::remove_file("write_test.db")?;

View file

@ -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<String> {
Ok(to_string(self)?)
}
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::Devices; use super::Devices;

View file

@ -12,16 +12,13 @@ mod devices;
mod tasmota; mod tasmota;
mod web_server; mod web_server;
use actix_web::{ use actix_files::Files;
get, middleware, rt, use actix_web::{web::Data, App, HttpServer};
web::{self, Data},
App, HttpRequest, HttpServer,
};
use anyhow::Result; use anyhow::Result;
use devices::Devices; use devices::Devices;
use futures::{future::try_join_all, try_join, Future}; use futures::{future::try_join_all, try_join, Future};
use tasmota::Tasmota; 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<u64> { fn since_epoch() -> Result<u64> {
Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()) Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
@ -38,8 +35,6 @@ fn read_power_usage(
db.lock() db.lock()
.unwrap() .unwrap()
.write(plug.name(), since_epoch()?, usage)?; .write(plug.name(), since_epoch()?, usage)?;
println!("read {}", plug.name(),);
} }
Ok::<(), anyhow::Error>(()) Ok::<(), anyhow::Error>(())
@ -61,6 +56,9 @@ async fn run_web_server(
.app_data(Data::new(devices.clone())) .app_data(Data::new(devices.clone()))
.app_data(Data::new(db.clone())) .app_data(Data::new(db.clone()))
.app_data(Data::new(plugs.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(index)
.service(device_query) .service(device_query)
.service(plug_state) .service(plug_state)
@ -81,6 +79,7 @@ async fn main() -> Result<()> {
let (db, devices) = try_join!(db_future, devices_future)?; let (db, devices) = try_join!(db_future, devices_future)?;
db.register_devices(&devices)?;
let shared_db = Arc::new(Mutex::new(db)); let shared_db = Arc::new(Mutex::new(db));
let tasmota_plugs: Vec<Tasmota> = devices let tasmota_plugs: Vec<Tasmota> = devices
@ -89,7 +88,7 @@ async fn main() -> Result<()> {
.map(|plug| Tasmota::new(plug)) .map(|plug| Tasmota::new(plug))
.collect(); .collect();
let res = try_join!( try_join!(
read_power_usage(tasmota_plugs.clone(), shared_db.clone()), read_power_usage(tasmota_plugs.clone(), shared_db.clone()),
run_web_server(devices, tasmota_plugs, shared_db) run_web_server(devices, tasmota_plugs, shared_db)
)?; )?;

View file

@ -1,14 +1,18 @@
use actix_files::NamedFile;
use actix_web::{ use actix_web::{
get, post, get, post,
web::{Data, Json, Path}, web::{Data, Json, Path},
HttpRequest, Responder, ResponseError, Responder, ResponseError,
}; };
use serde::Serialize; use serde::Serialize;
use serde_json::to_string; 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)] #[derive(Serialize)]
struct DeviceState { struct DeviceState {
@ -40,19 +44,36 @@ impl From<anyhow::Error> for MyError {
} }
#[get("/")] #[get("/")]
async fn index(req: HttpRequest) -> &'static str { async fn index() -> Result<NamedFile, impl ResponseError> {
println!("REQ: {:?}", req); NamedFile::open("resources/static/index.html")
"Hello world!\r\n"
} }
#[get("/devices")] #[get("/devices")]
async fn device_query(devices: Data<Devices>) -> Result<impl Responder, impl ResponseError> { async fn device_query(
devices db: Data<Arc<Mutex<DataBase>>>,
) -> Result<impl Responder, impl ResponseError> {
db.lock()
.unwrap()
.devices()?
.to_json() .to_json()
.map(|json| Json(json)) .map(|json| Json(json))
.map_err(|err| MyError::from(err)) .map_err(|err| MyError::from(err))
} }
#[post("/device_name/{device}/{name}")]
async fn change_device_name(
device: Path<String>,
name: Path<String>,
db: Data<Arc<Mutex<DataBase>>>,
) -> Result<impl Responder, MyError> {
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<String> { async fn tasmota_info(tasmota: &Tasmota) -> anyhow::Result<String> {
let led = tasmota.led_state().await?; let led = tasmota.led_state().await?;
let power = tasmota.power_state().await?; let power = tasmota.power_state().await?;
@ -122,3 +143,24 @@ async fn change_plug_state(
msg: format!("plug ({plug_name}) not found"), 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);
}
}