Compare commits
110 Commits
renovate/c
...
master
Author | SHA1 | Date | |
---|---|---|---|
719b0a0c5c | |||
fe0bc03c03 | |||
09c25a67c5 | |||
92878e6548 | |||
565db05fb0 | |||
368eb13089 | |||
20bc71851d | |||
79b2ad12d8 | |||
9c45e541dd | |||
2262b98952 | |||
f0081eb4bf | |||
1d11c3a968 | |||
c1c01058d8 | |||
c74ed0cfbb | |||
36ba4efd9f | |||
a97614ce44 | |||
7cac6aeb35 | |||
6bdebe6932 | |||
1b02a812b4 | |||
ee938a3aa6 | |||
1784a0a1f8 | |||
539703b904 | |||
583dd7c8f7 | |||
78663854cc | |||
bbe128e055 | |||
b0023a5167 | |||
f35aac04f6 | |||
871d5109bf | |||
2022e99274 | |||
de277cc306 | |||
8c2dcd3855 | |||
9e24587541 | |||
31f4203c43 | |||
6028be92ef | |||
87fb3360fb | |||
f46a7dbc94 | |||
50e61707cc | |||
d890b23670 | |||
3b7e2f9a0c | |||
dd0a957a63 | |||
336c838eb0 | |||
05e347e80c | |||
38197afd79 | |||
3b5d2abcc0 | |||
a6b283d023 | |||
3b6e79e5e4 | |||
3867a38ff9 | |||
59ba55793e | |||
f60f6f6ccc | |||
d5dc6dae46 | |||
0d90973842 | |||
6b9d5e9d85 | |||
9966904e4d | |||
0c11703cea | |||
752bf50ad3 | |||
e18162b32d | |||
48a2f728de | |||
5497c36c75 | |||
3004b03d92 | |||
596d22739d | |||
8a65687970 | |||
402edb44d5 | |||
0c6c0f4a7f | |||
900b436856 | |||
73163e6e69 | |||
4d5ba939d1 | |||
baf341d505 | |||
1ce9ca3321 | |||
7be81fe0e9 | |||
370084b3bb | |||
37406faa32 | |||
717ad5b5e0 | |||
0e32622720 | |||
751e33cb72 | |||
b59e807de1 | |||
6ad50657a5 | |||
9cba9c5f0a | |||
8674d25512 | |||
e97ef6fe45 | |||
2502ed6bcf | |||
716af6219a | |||
01ffe085d7 | |||
e64a444bd0 | |||
9ba4aa5194 | |||
378c296e71 | |||
8918547375 | |||
1f14cf8212 | |||
f468f192d8 | |||
c5c11970a1 | |||
426c25fce5 | |||
4c4d1e13cb | |||
dca8848ec9 | |||
1d32ca1559 | |||
e1739d9818 | |||
738c53c8b9 | |||
236871e241 | |||
d4a81f5fdf | |||
49a3e3a669 | |||
9d3e2beb81 | |||
b4647d70a0 | |||
11054385a6 | |||
09f526bfb7 | |||
f4fde9bc46 | |||
24f8f8f842 | |||
aa97d28657 | |||
32d5707055 | |||
8bac181552 | |||
716e524bf4 | |||
ffb8cbb6eb | |||
f4e2bb69b6 |
11
Makefile
Normal file
11
Makefile
Normal file
@ -0,0 +1,11 @@
|
||||
DOCKER_TEMP_DIR=temp
|
||||
|
||||
all: frontend backend
|
||||
|
||||
frontend:
|
||||
cd central_frontend && npm run build && cd ..
|
||||
rm -rf central_backend/static
|
||||
mv central_frontend/dist central_backend/static
|
||||
|
||||
backend: frontend
|
||||
cd central_backend && cargo clippy -- -D warnings && cargo build --release
|
1
central_backend/.gitignore
vendored
1
central_backend/.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
target
|
||||
.idea
|
||||
storage
|
||||
static
|
||||
|
2720
central_backend/Cargo.lock
generated
2720
central_backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -4,10 +4,36 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
log = "0.4.21"
|
||||
env_logger = "0.11.3"
|
||||
log = "0.4.22"
|
||||
env_logger = "0.11.5"
|
||||
lazy_static = "1.5.0"
|
||||
clap = { version = "4.5.7", features = ["derive", "env"] }
|
||||
clap = { version = "4.5.15", features = ["derive", "env"] }
|
||||
anyhow = "1.0.86"
|
||||
thiserror = "1.0.61"
|
||||
openssl = { version = "0.10.64" }
|
||||
thiserror = "1.0.63"
|
||||
openssl = { version = "0.10.66" }
|
||||
openssl-sys = "0.9.102"
|
||||
libc = "0.2.155"
|
||||
foreign-types-shared = "0.1.1"
|
||||
asn1 = "0.17"
|
||||
actix-web = { version = "4", features = ["openssl"] }
|
||||
futures = "0.3.30"
|
||||
serde = { version = "1.0.206", features = ["derive"] }
|
||||
reqwest = "0.12.5"
|
||||
serde_json = "1.0.123"
|
||||
rand = "0.8.5"
|
||||
actix = "0.13.5"
|
||||
actix-identity = "0.7.1"
|
||||
actix-session = { version = "0.9.0", features = ["cookie-session"] }
|
||||
actix-cors = "0.7.0"
|
||||
actix-remote-ip = "0.1.0"
|
||||
futures-util = "0.3.30"
|
||||
uuid = { version = "1.10.0", features = ["v4", "serde"] }
|
||||
semver = { version = "1.0.23", features = ["serde"] }
|
||||
lazy-regex = "3.2.0"
|
||||
tokio = { version = "1.39.2", features = ["full"] }
|
||||
tokio_schedule = "0.3.2"
|
||||
mime_guess = "2.0.5"
|
||||
rust-embed = "8.5.0"
|
||||
jsonwebtoken = { version = "9.3.0", features = ["use_pem"] }
|
||||
prettytable-rs = "0.10.0"
|
||||
chrono = "0.4.38"
|
@ -1,17 +1,92 @@
|
||||
use crate::devices::device::{DeviceId, DeviceRelayID};
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::{Path, PathBuf};
|
||||
use clap::Parser;
|
||||
|
||||
/// Electrical consumption fetcher backend
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum ConsumptionBackend {
|
||||
/// Constant consumption value
|
||||
Constant {
|
||||
/// The constant value to use
|
||||
#[clap(short, long, default_value_t = 500)]
|
||||
value: i32,
|
||||
},
|
||||
|
||||
/// Generate random consumption value
|
||||
Random {
|
||||
/// Minimum acceptable generated value
|
||||
#[clap(long, default_value_t = -5000)]
|
||||
min: i32,
|
||||
/// Maximum acceptable generated value
|
||||
#[clap(long, default_value_t = 20000)]
|
||||
max: i32,
|
||||
},
|
||||
|
||||
/// Read consumption value in a file, on the filesystem
|
||||
File {
|
||||
/// The path to the file that will be read to process consumption values
|
||||
#[clap(short, long, default_value = "/dev/shm/consumption.txt")]
|
||||
path: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Solar system central backend
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about, long_about = None)]
|
||||
pub struct AppConfig {
|
||||
/// Proxy IP, might end with a star "*"
|
||||
#[clap(short, long, env)]
|
||||
pub proxy_ip: Option<String>,
|
||||
|
||||
/// Secret key, used to sign some resources. Must be randomly generated
|
||||
#[clap(short = 'S', long, env, default_value = "")]
|
||||
secret: String,
|
||||
|
||||
/// Specify whether the cookie should be transmitted only over secure connections
|
||||
///
|
||||
/// This should be always true when running in production mode
|
||||
#[clap(long, env)]
|
||||
pub cookie_secure: bool,
|
||||
|
||||
/// Unsecure : for development, bypass authentication
|
||||
#[clap(long, env)]
|
||||
pub unsecure_disable_login: bool,
|
||||
|
||||
/// Admin username
|
||||
#[clap(long, env, default_value = "admin")]
|
||||
pub admin_username: String,
|
||||
|
||||
/// Admin password
|
||||
#[clap(long, env, default_value = "admin")]
|
||||
pub admin_password: String,
|
||||
|
||||
/// The port the server will listen to (using HTTPS)
|
||||
#[arg(short, long, env, default_value = "0.0.0.0:8443")]
|
||||
listen_address: String,
|
||||
pub listen_address: String,
|
||||
|
||||
/// The port the server will listen to (using HTTP, for unsecure connections)
|
||||
#[arg(short, long, env, default_value = "0.0.0.0:8080")]
|
||||
pub unsecure_listen_address: String,
|
||||
|
||||
/// Public server hostname (assuming that the ports used are the same for listen address)
|
||||
#[arg(short('H'), long, env, default_value = "localhost")]
|
||||
pub hostname: String,
|
||||
|
||||
/// Server storage path
|
||||
#[arg(short, long, env, default_value = "storage")]
|
||||
storage: String,
|
||||
|
||||
/// The minimal production that must be excluded when selecting relays to turn on
|
||||
#[arg(short('m'), long, env, default_value_t = -500)]
|
||||
pub production_margin: i32,
|
||||
|
||||
/// Energy refresh operations interval, in seconds
|
||||
#[arg(short('i'), long, env, default_value_t = 20)]
|
||||
pub refresh_interval: u64,
|
||||
|
||||
/// Consumption backend provider
|
||||
#[clap(subcommand)]
|
||||
pub consumption_backend: Option<ConsumptionBackend>,
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@ -26,6 +101,55 @@ impl AppConfig {
|
||||
&ARGS
|
||||
}
|
||||
|
||||
/// Get app secret
|
||||
pub fn secret(&self) -> &str {
|
||||
let mut secret = self.secret.as_str();
|
||||
|
||||
if cfg!(debug_assertions) && secret.is_empty() {
|
||||
secret = "DEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEY";
|
||||
}
|
||||
|
||||
if secret.is_empty() {
|
||||
panic!("SECRET is undefined or too short (min 64 chars)!")
|
||||
}
|
||||
|
||||
secret
|
||||
}
|
||||
|
||||
/// URL for unsecure connections
|
||||
pub fn unsecure_origin(&self) -> String {
|
||||
format!(
|
||||
"http://{}:{}",
|
||||
self.hostname,
|
||||
self.unsecure_listen_address.split_once(':').unwrap().1
|
||||
)
|
||||
}
|
||||
|
||||
/// URL for secure connections
|
||||
pub fn secure_origin(&self) -> String {
|
||||
format!(
|
||||
"https://{}:{}",
|
||||
self.hostname,
|
||||
self.listen_address.split_once(':').unwrap().1
|
||||
)
|
||||
}
|
||||
|
||||
/// Get auth cookie domain
|
||||
pub fn cookie_domain(&self) -> Option<String> {
|
||||
if cfg!(debug_assertions) {
|
||||
let domain = self.secure_origin().split_once("://")?.1.to_string();
|
||||
Some(
|
||||
domain
|
||||
.split_once(':')
|
||||
.map(|s| s.0)
|
||||
.unwrap_or(&domain)
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
// In release mode, the web app is hosted on the same origin as the API
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Get storage path
|
||||
pub fn storage_path(&self) -> PathBuf {
|
||||
@ -39,13 +163,94 @@ impl AppConfig {
|
||||
|
||||
/// Get PKI root CA cert path
|
||||
pub fn root_ca_cert_path(&self) -> PathBuf {
|
||||
self.pki_path().join("root_ca.pem")
|
||||
self.pki_path().join("root_ca.crt")
|
||||
}
|
||||
|
||||
/// Get PKI root CA CRL path
|
||||
pub fn root_ca_crl_path(&self) -> PathBuf {
|
||||
self.pki_path().join("root_ca.crl")
|
||||
}
|
||||
|
||||
/// Get PKI root CA private key path
|
||||
pub fn root_ca_priv_key_path(&self) -> PathBuf {
|
||||
self.pki_path().join("root_ca.key")
|
||||
}
|
||||
|
||||
/// Get PKI web CA cert path
|
||||
pub fn web_ca_cert_path(&self) -> PathBuf {
|
||||
self.pki_path().join("web_ca.crt")
|
||||
}
|
||||
|
||||
/// Get PKI web CA CRL path
|
||||
pub fn web_ca_crl_path(&self) -> PathBuf {
|
||||
self.pki_path().join("web_ca.crl")
|
||||
}
|
||||
|
||||
/// Get PKI web CA private key path
|
||||
pub fn web_ca_priv_key_path(&self) -> PathBuf {
|
||||
self.pki_path().join("web_ca.key")
|
||||
}
|
||||
|
||||
/// Get PKI devices CA cert path
|
||||
pub fn devices_ca_cert_path(&self) -> PathBuf {
|
||||
self.pki_path().join("devices_ca.crt")
|
||||
}
|
||||
|
||||
/// Get PKI devices CA CRL path
|
||||
pub fn devices_ca_crl_path(&self) -> PathBuf {
|
||||
self.pki_path().join("devices_ca.crl")
|
||||
}
|
||||
|
||||
/// Get PKI devices CA private key path
|
||||
pub fn devices_ca_priv_key_path(&self) -> PathBuf {
|
||||
self.pki_path().join("devices_ca.key")
|
||||
}
|
||||
|
||||
/// Get PKI server cert path
|
||||
pub fn server_cert_path(&self) -> PathBuf {
|
||||
self.pki_path().join("server.crt")
|
||||
}
|
||||
|
||||
/// Get PKI server private key path
|
||||
pub fn server_priv_key_path(&self) -> PathBuf {
|
||||
self.pki_path().join("server.key")
|
||||
}
|
||||
|
||||
/// Get devices configuration storage path
|
||||
pub fn devices_config_path(&self) -> PathBuf {
|
||||
self.storage_path().join("devices")
|
||||
}
|
||||
|
||||
/// Get device configuration path
|
||||
pub fn device_config_path(&self, id: &DeviceId) -> PathBuf {
|
||||
self.devices_config_path().join(format!("{}.conf", id.0))
|
||||
}
|
||||
|
||||
/// Get device certificate path
|
||||
pub fn device_cert_path(&self, id: &DeviceId) -> PathBuf {
|
||||
self.devices_config_path().join(format!("{}.crt", id.0))
|
||||
}
|
||||
|
||||
/// Get device CSR path
|
||||
pub fn device_csr_path(&self, id: &DeviceId) -> PathBuf {
|
||||
self.devices_config_path().join(format!("{}.csr", id.0))
|
||||
}
|
||||
|
||||
/// Get relays runtime storage path
|
||||
pub fn relays_runtime_stats_storage_path(&self) -> PathBuf {
|
||||
self.storage_path().join("relays_runtime")
|
||||
}
|
||||
|
||||
/// Get relay runtime stats path for a given relay
|
||||
pub fn relay_runtime_stats_dir(&self, relay_id: DeviceRelayID) -> PathBuf {
|
||||
self.relays_runtime_stats_storage_path()
|
||||
.join(relay_id.0.to_string())
|
||||
}
|
||||
|
||||
/// Get relay runtime stats path for a given relay for a given day
|
||||
pub fn relay_runtime_day_file_path(&self, relay_id: DeviceRelayID, day: u64) -> PathBuf {
|
||||
self.relay_runtime_stats_dir(relay_id).join(day.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
77
central_backend/src/constants.rs
Normal file
77
central_backend/src/constants.rs
Normal file
@ -0,0 +1,77 @@
|
||||
/// Name of the cookie that contains session information
|
||||
pub const SESSION_COOKIE_NAME: &str = "X-session-cookie";
|
||||
|
||||
/// Maximum time after a ping during which a device is considered "up"
|
||||
pub const DEVICE_MAX_PING_TIME: u64 = 30;
|
||||
|
||||
/// Fallback value to use if production cannot be fetched
|
||||
pub const FALLBACK_PRODUCTION_VALUE: i32 = 5000;
|
||||
|
||||
/// Maximum session duration after inactivity, in seconds
|
||||
pub const MAX_INACTIVITY_DURATION: u64 = 3600;
|
||||
|
||||
/// Maximum session duration (1 day)
|
||||
pub const MAX_SESSION_DURATION: u64 = 3600 * 24;
|
||||
|
||||
/// List of routes that do not require authentication
|
||||
pub const ROUTES_WITHOUT_AUTH: [&str; 2] =
|
||||
["/web_api/server/config", "/web_api/auth/password_auth"];
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct SizeConstraint {
|
||||
/// Minimal string length
|
||||
min: usize,
|
||||
/// Maximal string length
|
||||
max: usize,
|
||||
}
|
||||
|
||||
impl SizeConstraint {
|
||||
pub fn new(min: usize, max: usize) -> Self {
|
||||
Self { min, max }
|
||||
}
|
||||
|
||||
pub fn validate(&self, val: &str) -> bool {
|
||||
let len = val.trim().len();
|
||||
len >= self.min && len <= self.max
|
||||
}
|
||||
|
||||
pub fn validate_usize(&self, val: usize) -> bool {
|
||||
val >= self.min && val <= self.max
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend static constraints
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct StaticConstraints {
|
||||
/// Device name constraint
|
||||
pub dev_name_len: SizeConstraint,
|
||||
/// Device description constraint
|
||||
pub dev_description_len: SizeConstraint,
|
||||
/// Relay name constraint
|
||||
pub relay_name_len: SizeConstraint,
|
||||
/// Relay priority constraint
|
||||
pub relay_priority: SizeConstraint,
|
||||
/// Relay consumption constraint
|
||||
pub relay_consumption: SizeConstraint,
|
||||
/// Relay minimal uptime
|
||||
pub relay_minimal_uptime: SizeConstraint,
|
||||
/// Relay minimal downtime
|
||||
pub relay_minimal_downtime: SizeConstraint,
|
||||
/// Relay daily minimal uptime
|
||||
pub relay_daily_minimal_runtime: SizeConstraint,
|
||||
}
|
||||
|
||||
impl Default for StaticConstraints {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dev_name_len: SizeConstraint::new(1, 50),
|
||||
dev_description_len: SizeConstraint::new(0, 100),
|
||||
relay_name_len: SizeConstraint::new(1, 100),
|
||||
relay_priority: SizeConstraint::new(0, 999999),
|
||||
relay_consumption: SizeConstraint::new(0, 999999),
|
||||
relay_minimal_uptime: SizeConstraint::new(0, 9999999),
|
||||
relay_minimal_downtime: SizeConstraint::new(0, 9999999),
|
||||
relay_daily_minimal_runtime: SizeConstraint::new(0, 3600 * 24),
|
||||
}
|
||||
}
|
||||
}
|
43
central_backend/src/crypto/crl_extension.rs
Normal file
43
central_backend/src/crypto/crl_extension.rs
Normal file
@ -0,0 +1,43 @@
|
||||
use asn1::Tag;
|
||||
use openssl::asn1::{Asn1Object, Asn1OctetString};
|
||||
use openssl::x509::X509Extension;
|
||||
|
||||
pub struct CRLDistributionPointExt {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
impl CRLDistributionPointExt {
|
||||
pub fn as_extension(&self) -> anyhow::Result<X509Extension> {
|
||||
let crl_obj = Asn1Object::from_str("2.5.29.31")?;
|
||||
|
||||
let tag_a0 = Tag::from_bytes(&[0xa0]).unwrap().0;
|
||||
let tag_86 = Tag::from_bytes(&[0x86]).unwrap().0;
|
||||
|
||||
let crl_bytes = asn1::write(|w| {
|
||||
w.write_element(&asn1::SequenceWriter::new(&|w| {
|
||||
w.write_element(&asn1::SequenceWriter::new(&|w| {
|
||||
w.write_tlv(tag_a0, |w| {
|
||||
w.push_slice(&asn1::write(|w| {
|
||||
w.write_tlv(tag_a0, |w| {
|
||||
w.push_slice(&asn1::write(|w| {
|
||||
w.write_tlv(tag_86, |b| b.push_slice(self.url.as_bytes()))?;
|
||||
Ok(())
|
||||
})?)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
})?)
|
||||
})?;
|
||||
Ok(())
|
||||
}))?;
|
||||
Ok(())
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(X509Extension::new_from_der(
|
||||
crl_obj.as_ref(),
|
||||
false,
|
||||
Asn1OctetString::new_from_bytes(&crl_bytes)?.as_ref(),
|
||||
)?)
|
||||
}
|
||||
}
|
3
central_backend/src/crypto/mod.rs
Normal file
3
central_backend/src/crypto/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod crl_extension;
|
||||
pub mod openssl_utils;
|
||||
pub mod pki;
|
24
central_backend/src/crypto/openssl_utils.rs
Normal file
24
central_backend/src/crypto/openssl_utils.rs
Normal file
@ -0,0 +1,24 @@
|
||||
use openssl::asn1::{Asn1Time, Asn1TimeRef};
|
||||
|
||||
/// Clone Asn1 time
|
||||
pub fn clone_asn1_time(time: &Asn1TimeRef) -> anyhow::Result<Asn1Time> {
|
||||
let diff = time.diff(Asn1Time::from_unix(0)?.as_ref())?;
|
||||
let days = diff.days.abs();
|
||||
let secs = diff.secs.abs();
|
||||
|
||||
Ok(Asn1Time::from_unix((days * 3600 * 24 + secs) as i64)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::crypto::openssl_utils::clone_asn1_time;
|
||||
use openssl::asn1::Asn1Time;
|
||||
use std::cmp::Ordering;
|
||||
|
||||
#[test]
|
||||
fn test_clone_asn1_time() {
|
||||
let a = Asn1Time::from_unix(10).unwrap();
|
||||
let b = clone_asn1_time(a.as_ref()).unwrap();
|
||||
assert_eq!(a.compare(&b).unwrap(), Ordering::Equal);
|
||||
}
|
||||
}
|
509
central_backend/src/crypto/pki.rs
Normal file
509
central_backend/src/crypto/pki.rs
Normal file
@ -0,0 +1,509 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use foreign_types_shared::ForeignType;
|
||||
use foreign_types_shared::ForeignTypeRef;
|
||||
use libc::c_long;
|
||||
use openssl::asn1::Asn1Time;
|
||||
use openssl::bn::{BigNum, MsbOption};
|
||||
use openssl::ec::EcGroup;
|
||||
use openssl::hash::MessageDigest;
|
||||
use openssl::nid::Nid;
|
||||
use openssl::pkey::{PKey, Private};
|
||||
use openssl::x509::extension::{
|
||||
BasicConstraints, ExtendedKeyUsage, KeyUsage, SubjectAlternativeName, SubjectKeyIdentifier,
|
||||
};
|
||||
use openssl::x509::{CrlStatus, X509Crl, X509Name, X509NameBuilder, X509Req, X509};
|
||||
use openssl_sys::{
|
||||
X509_CRL_add0_revoked, X509_CRL_new, X509_CRL_set1_lastUpdate, X509_CRL_set1_nextUpdate,
|
||||
X509_CRL_set_issuer_name, X509_CRL_set_version, X509_CRL_sign, X509_REVOKED_dup,
|
||||
X509_REVOKED_new, X509_REVOKED_set_revocationDate, X509_REVOKED_set_serialNumber,
|
||||
};
|
||||
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::crypto::crl_extension::CRLDistributionPointExt;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum PKIError {
|
||||
#[error("Certification Authority does not have a CRL")]
|
||||
MissingCRL,
|
||||
#[error("Certification Authority does not have a CRL next update time")]
|
||||
MissingCRLNextUpdate,
|
||||
#[error("Failed to initialize CRL! {0}")]
|
||||
GenCRLError(&'static str),
|
||||
}
|
||||
|
||||
/// Certificate and private key
|
||||
pub struct CertData {
|
||||
pub cert: X509,
|
||||
pub key: PKey<Private>,
|
||||
pub crl: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl CertData {
|
||||
/// Load root CA
|
||||
fn load_root_ca() -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
cert: load_certificate_from_file(AppConfig::get().root_ca_cert_path())?,
|
||||
key: load_priv_key_from_file(AppConfig::get().root_ca_priv_key_path())?,
|
||||
crl: Some(AppConfig::get().root_ca_crl_path()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Load web CA
|
||||
pub fn load_web_ca() -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
cert: load_certificate_from_file(AppConfig::get().web_ca_cert_path())?,
|
||||
key: load_priv_key_from_file(AppConfig::get().web_ca_priv_key_path())?,
|
||||
crl: Some(AppConfig::get().web_ca_crl_path()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Load devices CA
|
||||
pub fn load_devices_ca() -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
cert: load_certificate_from_file(AppConfig::get().devices_ca_cert_path())?,
|
||||
key: load_priv_key_from_file(AppConfig::get().devices_ca_priv_key_path())?,
|
||||
crl: Some(AppConfig::get().devices_ca_crl_path()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Load server CA
|
||||
pub fn load_server() -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
cert: load_certificate_from_file(AppConfig::get().server_cert_path())?,
|
||||
key: load_priv_key_from_file(AppConfig::get().server_priv_key_path())?,
|
||||
crl: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if a certificate is revoked
|
||||
pub fn is_revoked(&self, cert: &X509) -> anyhow::Result<bool> {
|
||||
let crl = X509Crl::from_pem(&std::fs::read(
|
||||
self.crl.as_ref().ok_or(PKIError::MissingCRL)?,
|
||||
)?)?;
|
||||
|
||||
let res = crl.get_by_cert(cert);
|
||||
|
||||
Ok(matches!(res, CrlStatus::Revoked(_)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate private key
|
||||
fn gen_private_key() -> anyhow::Result<PKey<Private>> {
|
||||
let nid = Nid::X9_62_PRIME256V1; // NIST P-256 curve
|
||||
let group = EcGroup::from_curve_name(nid)?;
|
||||
let key = openssl::ec::EcKey::generate(&group)?;
|
||||
let key_pair = PKey::from_ec_key(key.clone())?;
|
||||
|
||||
Ok(key_pair)
|
||||
}
|
||||
|
||||
/// Load private key from PEM file
|
||||
fn load_priv_key_from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<PKey<Private>> {
|
||||
Ok(PKey::private_key_from_pem(&std::fs::read(path)?)?)
|
||||
}
|
||||
|
||||
/// Load certificate from PEM file
|
||||
fn load_certificate_from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<X509> {
|
||||
Ok(X509::from_pem(&std::fs::read(path)?)?)
|
||||
}
|
||||
|
||||
/// Load CRL from PEM file
|
||||
fn load_crl_from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<X509Crl> {
|
||||
Ok(X509Crl::from_pem(&std::fs::read(path)?)?)
|
||||
}
|
||||
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
enum GenCertificatSubjectReq<'a> {
|
||||
Subject { cn: &'a str },
|
||||
CSR { csr: &'a X509Req },
|
||||
}
|
||||
|
||||
impl<'a> Default for GenCertificatSubjectReq<'a> {
|
||||
fn default() -> Self {
|
||||
Self::Subject { cn: "" }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct GenCertificateReq<'a> {
|
||||
pub sub: GenCertificatSubjectReq<'a>,
|
||||
pub issuer: Option<&'a CertData>,
|
||||
pub ca: bool,
|
||||
pub web_server: bool,
|
||||
pub web_client: bool,
|
||||
pub subject_alternative_names: Vec<&'a str>,
|
||||
}
|
||||
|
||||
/// Generate certificate
|
||||
fn gen_certificate(req: GenCertificateReq) -> anyhow::Result<(Option<Vec<u8>>, Vec<u8>)> {
|
||||
let mut cert_builder = X509::builder()?;
|
||||
cert_builder.set_version(2)?;
|
||||
let serial_number = {
|
||||
let mut serial = BigNum::new()?;
|
||||
serial.rand(159, MsbOption::MAYBE_ZERO, false)?;
|
||||
serial.to_asn1_integer()?
|
||||
};
|
||||
cert_builder.set_serial_number(&serial_number)?;
|
||||
|
||||
// Process subject
|
||||
let x509_name = match req.sub {
|
||||
GenCertificatSubjectReq::Subject { cn } => {
|
||||
let mut x509_name = X509NameBuilder::new()?;
|
||||
x509_name.append_entry_by_text("C", "FR")?;
|
||||
x509_name.append_entry_by_text("CN", cn)?;
|
||||
x509_name.build()
|
||||
}
|
||||
GenCertificatSubjectReq::CSR { csr } => X509Name::from_der(&csr.subject_name().to_der()?)?,
|
||||
};
|
||||
|
||||
cert_builder.set_subject_name(&x509_name)?;
|
||||
|
||||
match req.issuer {
|
||||
// Self-signed certificate
|
||||
None => cert_builder.set_issuer_name(&x509_name)?,
|
||||
// Certificate signed by another CA
|
||||
Some(i) => cert_builder.set_issuer_name(i.cert.subject_name())?,
|
||||
}
|
||||
|
||||
let not_before = Asn1Time::days_from_now(0)?;
|
||||
cert_builder.set_not_before(¬_before)?;
|
||||
|
||||
let not_after = Asn1Time::days_from_now(365 * 30)?;
|
||||
cert_builder.set_not_after(¬_after)?;
|
||||
|
||||
// Specify CRL URL
|
||||
if let Some(issuer) = req.issuer {
|
||||
if let Some(crl) = &issuer.crl {
|
||||
let crl_url = format!(
|
||||
"{}/pki/{}",
|
||||
AppConfig::get().unsecure_origin(),
|
||||
crl.file_name().unwrap().to_string_lossy()
|
||||
);
|
||||
|
||||
cert_builder
|
||||
.append_extension(CRLDistributionPointExt { url: crl_url }.as_extension()?)?;
|
||||
}
|
||||
}
|
||||
|
||||
// If cert is a CA or not
|
||||
let mut basic = BasicConstraints::new();
|
||||
if req.ca {
|
||||
basic.ca();
|
||||
}
|
||||
cert_builder.append_extension(basic.critical().build()?)?;
|
||||
|
||||
// Key usage
|
||||
let mut key_usage = KeyUsage::new();
|
||||
let mut eku = None;
|
||||
if req.ca {
|
||||
key_usage.key_cert_sign().crl_sign();
|
||||
}
|
||||
if req.web_server {
|
||||
key_usage.digital_signature().key_encipherment();
|
||||
eku = Some(
|
||||
ExtendedKeyUsage::new()
|
||||
.server_auth()
|
||||
.client_auth()
|
||||
.build()?,
|
||||
);
|
||||
}
|
||||
|
||||
if req.web_client {
|
||||
key_usage.digital_signature().key_encipherment();
|
||||
eku = Some(ExtendedKeyUsage::new().client_auth().build()?);
|
||||
}
|
||||
cert_builder.append_extension(key_usage.critical().build()?)?;
|
||||
|
||||
if let Some(eku) = eku {
|
||||
cert_builder.append_extension(eku)?;
|
||||
}
|
||||
|
||||
// Subject alternative names
|
||||
if !req.subject_alternative_names.is_empty() {
|
||||
let mut ext = SubjectAlternativeName::new();
|
||||
for subj in req.subject_alternative_names {
|
||||
ext.dns(subj);
|
||||
}
|
||||
cert_builder.append_extension(ext.build(&cert_builder.x509v3_context(None, None))?)?;
|
||||
}
|
||||
|
||||
// Subject key identifier
|
||||
let subject_key_identifier =
|
||||
SubjectKeyIdentifier::new().build(&cert_builder.x509v3_context(None, None))?;
|
||||
cert_builder.append_extension(subject_key_identifier)?;
|
||||
|
||||
// Public key
|
||||
match req.sub {
|
||||
// Private key known
|
||||
GenCertificatSubjectReq::Subject { .. } => {
|
||||
let key_pair = gen_private_key()?;
|
||||
cert_builder.set_pubkey(&key_pair)?;
|
||||
|
||||
// Sign certificate
|
||||
cert_builder.sign(
|
||||
match req.issuer {
|
||||
None => &key_pair,
|
||||
Some(i) => &i.key,
|
||||
},
|
||||
MessageDigest::sha256(),
|
||||
)?;
|
||||
let cert = cert_builder.build();
|
||||
|
||||
Ok((Some(key_pair.private_key_to_pem_pkcs8()?), cert.to_pem()?))
|
||||
}
|
||||
|
||||
// Private key unknown
|
||||
GenCertificatSubjectReq::CSR { csr } => {
|
||||
let pub_key = csr.public_key()?;
|
||||
cert_builder.set_pubkey(pub_key.as_ref())?;
|
||||
|
||||
// Sign certificate
|
||||
cert_builder.sign(
|
||||
&req.issuer
|
||||
.expect("Cannot issue certificate for CSR if issuer is not specified!")
|
||||
.key,
|
||||
MessageDigest::sha256(),
|
||||
)?;
|
||||
let cert = cert_builder.build();
|
||||
Ok((None, cert.to_pem()?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize Root CA, if required
|
||||
pub fn initialize_root_ca() -> anyhow::Result<()> {
|
||||
if AppConfig::get().root_ca_cert_path().exists()
|
||||
&& AppConfig::get().root_ca_priv_key_path().exists()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::info!("Generating root ca...");
|
||||
|
||||
let (key, cert) = gen_certificate(GenCertificateReq {
|
||||
sub: GenCertificatSubjectReq::Subject {
|
||||
cn: "SolarEnergy Root CA",
|
||||
},
|
||||
issuer: None,
|
||||
ca: true,
|
||||
..Default::default()
|
||||
})?;
|
||||
|
||||
// Serialize generated web CA
|
||||
std::fs::write(AppConfig::get().root_ca_priv_key_path(), key.unwrap())?;
|
||||
std::fs::write(AppConfig::get().root_ca_cert_path(), cert)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initialize web CA, if required
|
||||
pub fn initialize_web_ca() -> anyhow::Result<()> {
|
||||
if AppConfig::get().web_ca_cert_path().exists()
|
||||
&& AppConfig::get().web_ca_priv_key_path().exists()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::info!("Generating web ca...");
|
||||
|
||||
let (key, cert) = gen_certificate(GenCertificateReq {
|
||||
sub: GenCertificatSubjectReq::Subject {
|
||||
cn: "SolarEnergy Web CA",
|
||||
},
|
||||
issuer: Some(&CertData::load_root_ca()?),
|
||||
ca: true,
|
||||
..Default::default()
|
||||
})?;
|
||||
|
||||
// Serialize generated web CA
|
||||
std::fs::write(AppConfig::get().web_ca_priv_key_path(), key.unwrap())?;
|
||||
std::fs::write(AppConfig::get().web_ca_cert_path(), cert)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initialize devices CA, if required
|
||||
pub fn initialize_devices_ca() -> anyhow::Result<()> {
|
||||
if AppConfig::get().devices_ca_cert_path().exists()
|
||||
&& AppConfig::get().devices_ca_priv_key_path().exists()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::info!("Generating devices ca...");
|
||||
|
||||
let (key, cert) = gen_certificate(GenCertificateReq {
|
||||
sub: GenCertificatSubjectReq::Subject {
|
||||
cn: "SolarEnergy Devices CA",
|
||||
},
|
||||
issuer: Some(&CertData::load_root_ca()?),
|
||||
ca: true,
|
||||
..Default::default()
|
||||
})?;
|
||||
|
||||
// Serialize generated devices CA
|
||||
std::fs::write(AppConfig::get().devices_ca_priv_key_path(), key.unwrap())?;
|
||||
std::fs::write(AppConfig::get().devices_ca_cert_path(), cert)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initialize server certificate, if required
|
||||
pub fn initialize_server_ca() -> anyhow::Result<()> {
|
||||
if AppConfig::get().server_cert_path().exists()
|
||||
&& AppConfig::get().server_priv_key_path().exists()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::info!("Generating server certificate...");
|
||||
|
||||
let (key, cert) = gen_certificate(GenCertificateReq {
|
||||
sub: GenCertificatSubjectReq::Subject {
|
||||
cn: AppConfig::get().hostname.as_str(),
|
||||
},
|
||||
issuer: Some(&CertData::load_web_ca()?),
|
||||
web_server: true,
|
||||
subject_alternative_names: vec![AppConfig::get().hostname.as_str()],
|
||||
..Default::default()
|
||||
})?;
|
||||
|
||||
std::fs::write(AppConfig::get().server_priv_key_path(), key.unwrap())?;
|
||||
std::fs::write(AppConfig::get().server_cert_path(), cert)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initialize or refresh a CRL
|
||||
fn refresh_crl(d: &CertData, new_cert: Option<&X509>) -> anyhow::Result<()> {
|
||||
let crl_path = d.crl.as_ref().ok_or(PKIError::MissingCRL)?;
|
||||
|
||||
let old_crl = if crl_path.exists() {
|
||||
let crl = load_crl_from_file(crl_path)?;
|
||||
|
||||
// Check if revocation is un-needed
|
||||
let next_update = crl.next_update().ok_or(PKIError::MissingCRLNextUpdate)?;
|
||||
if next_update.compare(Asn1Time::days_from_now(0)?.as_ref())? == Ordering::Greater
|
||||
&& new_cert.is_none()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Some(crl)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
log::info!("Generating a new CRL...");
|
||||
|
||||
// based on https://github.com/openssl/openssl/blob/master/crypto/x509/x509_vfy.c
|
||||
unsafe {
|
||||
let crl = X509_CRL_new();
|
||||
if crl.is_null() {
|
||||
return Err(PKIError::GenCRLError("Could not construct CRL!").into());
|
||||
}
|
||||
|
||||
const X509_CRL_VERSION_2: c_long = 1;
|
||||
if X509_CRL_set_version(crl, X509_CRL_VERSION_2) == 0 {
|
||||
return Err(PKIError::GenCRLError("X509_CRL_set_version").into());
|
||||
}
|
||||
if X509_CRL_set_issuer_name(crl, d.cert.subject_name().as_ptr()) == 0 {
|
||||
return Err(PKIError::GenCRLError("X509_CRL_set_issuer_name").into());
|
||||
}
|
||||
|
||||
let last_update = Asn1Time::days_from_now(0)?;
|
||||
if X509_CRL_set1_lastUpdate(crl, last_update.as_ptr()) == 0 {
|
||||
return Err(PKIError::GenCRLError("X509_CRL_set1_lastUpdate").into());
|
||||
}
|
||||
|
||||
let next_update = Asn1Time::days_from_now(10)?;
|
||||
if X509_CRL_set1_nextUpdate(crl, next_update.as_ptr()) == 0 {
|
||||
return Err(PKIError::GenCRLError("X509_CRL_set1_nextUpdate").into());
|
||||
}
|
||||
|
||||
// Add old entries
|
||||
if let Some(old_crl) = old_crl {
|
||||
if let Some(entries) = old_crl.get_revoked() {
|
||||
for entry in entries {
|
||||
if X509_CRL_add0_revoked(crl, X509_REVOKED_dup(entry.as_ptr())) == 0 {
|
||||
return Err(PKIError::GenCRLError("X509_CRL_add0_revoked").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If requested, add new entry
|
||||
if let Some(new_cert) = new_cert {
|
||||
let entry = X509_REVOKED_new();
|
||||
if entry.is_null() {
|
||||
return Err(PKIError::GenCRLError("X509_CRL_new for new entry").into());
|
||||
}
|
||||
|
||||
if X509_REVOKED_set_serialNumber(entry, new_cert.serial_number().as_ptr()) == 0 {
|
||||
return Err(
|
||||
PKIError::GenCRLError("X509_REVOKED_set_serialNumber for new entry").into(),
|
||||
);
|
||||
}
|
||||
|
||||
let revocation_date = Asn1Time::days_from_now(0)?;
|
||||
if X509_REVOKED_set_revocationDate(entry, revocation_date.as_ptr()) == 0 {
|
||||
return Err(
|
||||
PKIError::GenCRLError("X509_REVOKED_set_revocationDate for new entry").into(),
|
||||
);
|
||||
}
|
||||
|
||||
if X509_CRL_add0_revoked(crl, X509_REVOKED_dup(entry)) == 0 {
|
||||
return Err(PKIError::GenCRLError("X509_CRL_add0_revoked for new entry").into());
|
||||
}
|
||||
}
|
||||
|
||||
let md = MessageDigest::sha256();
|
||||
if X509_CRL_sign(crl, d.key.as_ptr(), md.as_ptr()) == 0 {
|
||||
return Err(PKIError::GenCRLError("X509_CRL_sign").into());
|
||||
}
|
||||
|
||||
let crl = X509Crl::from_ptr(crl);
|
||||
std::fs::write(crl_path, crl.to_pem()?)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refresh revocation lists
|
||||
pub fn refresh_crls() -> anyhow::Result<()> {
|
||||
refresh_crl(&CertData::load_root_ca()?, None)?;
|
||||
refresh_crl(&CertData::load_web_ca()?, None)?;
|
||||
refresh_crl(&CertData::load_devices_ca()?, None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a certificate for a device
|
||||
pub fn gen_certificate_for_device(csr: &X509Req) -> anyhow::Result<String> {
|
||||
let (_, cert) = gen_certificate(GenCertificateReq {
|
||||
sub: GenCertificatSubjectReq::CSR { csr },
|
||||
issuer: Some(&CertData::load_devices_ca()?),
|
||||
web_client: true,
|
||||
..Default::default()
|
||||
})?;
|
||||
|
||||
Ok(String::from_utf8(cert)?)
|
||||
}
|
||||
|
||||
/// Revoke a certificate
|
||||
pub fn revoke(cert: &X509, ca: &CertData) -> anyhow::Result<()> {
|
||||
// Check if certificate is already revoked
|
||||
if ca.is_revoked(cert)? {
|
||||
// No op
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
refresh_crl(ca, Some(cert))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revoke a device certificate
|
||||
pub fn revoke_device_cert(cert: &X509) -> anyhow::Result<()> {
|
||||
revoke(cert, &CertData::load_devices_ca()?)
|
||||
}
|
383
central_backend/src/devices/device.rs
Normal file
383
central_backend/src/devices/device.rs
Normal file
@ -0,0 +1,383 @@
|
||||
//! # Devices entities definition
|
||||
|
||||
use crate::constants::StaticConstraints;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// Device information provided directly by the device during syncrhonisation.
|
||||
///
|
||||
/// It should not be editable fro the Web UI
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct DeviceInfo {
|
||||
/// Device reference
|
||||
reference: String,
|
||||
/// Device firmware / software version
|
||||
version: semver::Version,
|
||||
/// Maximum number of relay that the device can support
|
||||
pub max_relays: usize,
|
||||
}
|
||||
|
||||
impl DeviceInfo {
|
||||
/// Identify errors in device information definition
|
||||
pub fn error(&self) -> Option<&str> {
|
||||
if self.reference.trim().is_empty() {
|
||||
return Some("Given device reference is empty or blank!");
|
||||
}
|
||||
|
||||
if self.max_relays == 0 {
|
||||
return Some("Given device cannot handle any relay!");
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Device identifier
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq, Hash)]
|
||||
pub struct DeviceId(pub String);
|
||||
|
||||
/// Single device information
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Device {
|
||||
/// The device ID
|
||||
pub id: DeviceId,
|
||||
/// Information about the device
|
||||
///
|
||||
/// These information shall not be editable from the webui. They are automatically updated during
|
||||
/// device synchronization
|
||||
pub info: DeviceInfo,
|
||||
/// Time at which device was initially enrolled
|
||||
pub time_create: u64,
|
||||
/// Time at which device was last updated
|
||||
pub time_update: u64,
|
||||
/// Name given to the device on the Web UI
|
||||
pub name: String,
|
||||
/// Description given to the device on the Web UI
|
||||
pub description: String,
|
||||
/// Specify whether the device has been validated or not. Validated devices are given a
|
||||
/// certificate
|
||||
pub validated: bool,
|
||||
/// Specify whether the device is enabled or not
|
||||
pub enabled: bool,
|
||||
/// Information about the relays handled by the device
|
||||
///
|
||||
/// There cannot be more than [info.max_relays] relays
|
||||
pub relays: Vec<DeviceRelay>,
|
||||
}
|
||||
|
||||
/// Structure that contains information about the minimal expected execution
|
||||
/// time of a device
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct DailyMinRuntime {
|
||||
/// Minimum time, in seconds, that this relay should run each day
|
||||
pub min_runtime: usize,
|
||||
/// The seconds in the days (from 00:00) where the counter is reset
|
||||
pub reset_time: usize,
|
||||
/// The hours during which the relay should be turned on to reach expected runtime
|
||||
pub catch_up_hours: Vec<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq, Hash)]
|
||||
pub struct DeviceRelayID(pub uuid::Uuid);
|
||||
|
||||
impl Default for DeviceRelayID {
|
||||
fn default() -> Self {
|
||||
Self(uuid::Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
/// Single device relay information
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Default)]
|
||||
pub struct DeviceRelay {
|
||||
/// Device relay id. Should be unique across the whole application
|
||||
#[serde(default)]
|
||||
pub id: DeviceRelayID,
|
||||
/// Human-readable name for the relay
|
||||
pub name: String,
|
||||
/// Whether this relay can be turned on or not
|
||||
pub enabled: bool,
|
||||
/// Relay priority when selecting relays to turn on. 0 = lowest priority
|
||||
pub priority: usize,
|
||||
/// Estimated consumption of the electrical equipment triggered by the relay
|
||||
pub consumption: usize,
|
||||
/// Minimal time this relay shall be left on before it can be turned off (in seconds)
|
||||
pub minimal_uptime: usize,
|
||||
/// Minimal time this relay shall be left off before it can be turned on again (in seconds)
|
||||
pub minimal_downtime: usize,
|
||||
/// Optional minimal runtime requirements for this relay
|
||||
pub daily_runtime: Option<DailyMinRuntime>,
|
||||
/// Specify relay that must be turned on before this relay can be started
|
||||
pub depends_on: Vec<DeviceRelayID>,
|
||||
/// Specify relays that must be turned off before this relay can be started
|
||||
pub conflicts_with: Vec<DeviceRelayID>,
|
||||
}
|
||||
|
||||
impl DeviceRelay {
|
||||
/// Check device relay for errors
|
||||
pub fn error(&self, list: &[DeviceRelay]) -> Option<&'static str> {
|
||||
let constraints = StaticConstraints::default();
|
||||
if !constraints.relay_name_len.validate(&self.name) {
|
||||
return Some("Invalid relay name length!");
|
||||
}
|
||||
|
||||
if !constraints.relay_priority.validate_usize(self.priority) {
|
||||
return Some("Invalid relay priority!");
|
||||
}
|
||||
|
||||
if !constraints
|
||||
.relay_consumption
|
||||
.validate_usize(self.consumption)
|
||||
{
|
||||
return Some("Invalid consumption!");
|
||||
}
|
||||
|
||||
if !constraints
|
||||
.relay_minimal_uptime
|
||||
.validate_usize(self.minimal_uptime)
|
||||
{
|
||||
return Some("Invalid minimal uptime!");
|
||||
}
|
||||
|
||||
if !constraints
|
||||
.relay_minimal_downtime
|
||||
.validate_usize(self.minimal_downtime)
|
||||
{
|
||||
return Some("Invalid minimal uptime!");
|
||||
}
|
||||
|
||||
if let Some(daily) = &self.daily_runtime {
|
||||
if !constraints
|
||||
.relay_daily_minimal_runtime
|
||||
.validate_usize(daily.min_runtime)
|
||||
{
|
||||
return Some("Invalid minimal daily runtime!");
|
||||
}
|
||||
|
||||
if daily.reset_time > 3600 * 24 {
|
||||
return Some("Invalid daily reset time!");
|
||||
}
|
||||
|
||||
if daily.catch_up_hours.is_empty() {
|
||||
return Some("No catchup hours defined!");
|
||||
}
|
||||
|
||||
if daily.catch_up_hours.iter().any(|h| h > &23) {
|
||||
return Some("At least one catch up hour is invalid!");
|
||||
}
|
||||
}
|
||||
|
||||
let mut relays_map = list.iter().map(|r| (r.id, r)).collect::<HashMap<_, _>>();
|
||||
relays_map.insert(self.id, self);
|
||||
|
||||
if self.depends_on.iter().any(|d| !relays_map.contains_key(d)) {
|
||||
return Some("A specified dependent relay does not exists!");
|
||||
}
|
||||
|
||||
if self
|
||||
.conflicts_with
|
||||
.iter()
|
||||
.any(|d| !relays_map.contains_key(d))
|
||||
{
|
||||
return Some("A specified conflicting relay does not exists!");
|
||||
}
|
||||
|
||||
// Check for loops in dependencies
|
||||
if self.check_for_loop_in_dependencies(&HashSet::new(), &relays_map) {
|
||||
return Some("A loop was detected in relay dependencies!");
|
||||
}
|
||||
|
||||
// Check if relay is in conflicts with one of its dependencies
|
||||
let mut all_dependencies = HashSet::new();
|
||||
let mut all_conflicts = HashSet::new();
|
||||
self.get_list_of_dependencies_and_conflicts(
|
||||
&mut all_dependencies,
|
||||
&mut all_conflicts,
|
||||
&relays_map,
|
||||
);
|
||||
for conf_id in all_conflicts {
|
||||
if all_dependencies.contains(&conf_id) {
|
||||
return Some(
|
||||
"The relay or one of its dependencies is in conflict with a dependency!",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn check_for_loop_in_dependencies(
|
||||
&self,
|
||||
visited: &HashSet<DeviceRelayID>,
|
||||
list: &HashMap<DeviceRelayID, &Self>,
|
||||
) -> bool {
|
||||
let mut clone = visited.clone();
|
||||
clone.insert(self.id);
|
||||
|
||||
for d in &self.depends_on {
|
||||
if visited.contains(d) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if list
|
||||
.get(d)
|
||||
.expect("Missing a relay!")
|
||||
.check_for_loop_in_dependencies(&clone, list)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn get_list_of_dependencies_and_conflicts(
|
||||
&self,
|
||||
deps_out: &mut HashSet<DeviceRelayID>,
|
||||
conflicts_out: &mut HashSet<DeviceRelayID>,
|
||||
list: &HashMap<DeviceRelayID, &Self>,
|
||||
) {
|
||||
for d in &self.depends_on {
|
||||
let dependency = list.get(d).expect("Missing a relay!");
|
||||
|
||||
deps_out.insert(dependency.id);
|
||||
|
||||
dependency.get_list_of_dependencies_and_conflicts(deps_out, conflicts_out, list);
|
||||
}
|
||||
|
||||
for d in &self.conflicts_with {
|
||||
conflicts_out.insert(*d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Device general information
|
||||
///
|
||||
/// This structure is used to update device information
|
||||
#[derive(serde::Deserialize, Debug, Clone)]
|
||||
pub struct DeviceGeneralInfo {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl DeviceGeneralInfo {
|
||||
/// Check for errors in the structure
|
||||
pub fn error(&self) -> Option<&'static str> {
|
||||
let constraints = StaticConstraints::default();
|
||||
if !constraints.dev_name_len.validate(&self.name) {
|
||||
return Some("Invalid device name length!");
|
||||
}
|
||||
|
||||
if !constraints.dev_description_len.validate(&self.description) {
|
||||
return Some("Invalid device description length!");
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::devices::device::{DeviceRelay, DeviceRelayID};
|
||||
|
||||
#[test]
|
||||
fn check_device_relay_error() {
|
||||
let unitary = DeviceRelay {
|
||||
name: "unitary".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let bad_name = DeviceRelay {
|
||||
name: "".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let dep_on_unitary = DeviceRelay {
|
||||
name: "dep_on_unitary".to_string(),
|
||||
depends_on: vec![unitary.id],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(unitary.error(&[]), None);
|
||||
assert_eq!(unitary.error(&[unitary.clone(), bad_name.clone()]), None);
|
||||
assert!(bad_name.error(&[]).is_some());
|
||||
assert_eq!(dep_on_unitary.error(&[unitary.clone()]), None);
|
||||
assert!(dep_on_unitary.error(&[]).is_some());
|
||||
|
||||
// Dependency loop
|
||||
let mut dep_cycle_1 = DeviceRelay {
|
||||
id: DeviceRelayID::default(),
|
||||
name: "dep_cycle_1".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let dep_cycle_2 = DeviceRelay {
|
||||
id: DeviceRelayID::default(),
|
||||
name: "dep_cycle_2".to_string(),
|
||||
depends_on: vec![dep_cycle_1.id],
|
||||
..Default::default()
|
||||
};
|
||||
let dep_cycle_3 = DeviceRelay {
|
||||
id: DeviceRelayID::default(),
|
||||
name: "dep_cycle_3".to_string(),
|
||||
depends_on: vec![dep_cycle_2.id],
|
||||
..Default::default()
|
||||
};
|
||||
dep_cycle_1.depends_on = vec![dep_cycle_3.id];
|
||||
assert!(dep_cycle_1
|
||||
.error(&[dep_cycle_2.clone(), dep_cycle_3.clone()])
|
||||
.is_some());
|
||||
|
||||
dep_cycle_1.depends_on = vec![];
|
||||
assert!(dep_cycle_1.error(&[dep_cycle_2, dep_cycle_3]).is_none());
|
||||
|
||||
// Impossible conflict
|
||||
let other_dep = DeviceRelay {
|
||||
id: DeviceRelayID::default(),
|
||||
name: "other_dep".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut second_dep = DeviceRelay {
|
||||
id: DeviceRelayID::default(),
|
||||
name: "second_dep".to_string(),
|
||||
conflicts_with: vec![other_dep.id],
|
||||
..Default::default()
|
||||
};
|
||||
let target_relay = DeviceRelay {
|
||||
id: DeviceRelayID::default(),
|
||||
name: "target_relay".to_string(),
|
||||
depends_on: vec![other_dep.id, second_dep.id],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(target_relay
|
||||
.error(&[other_dep.clone(), second_dep.clone()])
|
||||
.is_some());
|
||||
assert!(target_relay
|
||||
.error(&[other_dep.clone(), second_dep.clone(), target_relay.clone()])
|
||||
.is_some());
|
||||
|
||||
second_dep.conflicts_with = vec![];
|
||||
|
||||
assert!(target_relay
|
||||
.error(&[other_dep.clone(), second_dep.clone()])
|
||||
.is_none());
|
||||
assert!(target_relay
|
||||
.error(&[other_dep.clone(), second_dep.clone(), target_relay.clone()])
|
||||
.is_none());
|
||||
|
||||
// self loop
|
||||
let mut self_loop = DeviceRelay {
|
||||
id: DeviceRelayID::default(),
|
||||
name: "self_loop".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let self_loop_good = self_loop.clone();
|
||||
self_loop.depends_on = vec![self_loop.id];
|
||||
assert!(self_loop.error(&[]).is_some());
|
||||
assert!(self_loop.error(&[self_loop.clone()]).is_some());
|
||||
assert!(self_loop.error(&[self_loop_good.clone()]).is_some());
|
||||
self_loop.depends_on = vec![];
|
||||
assert!(self_loop_good.error(&[]).is_none());
|
||||
assert!(self_loop_good.error(&[self_loop_good.clone()]).is_none());
|
||||
assert!(self_loop_good.error(&[self_loop.clone()]).is_none());
|
||||
}
|
||||
}
|
338
central_backend/src/devices/devices_list.rs
Normal file
338
central_backend/src/devices/devices_list.rs
Normal file
@ -0,0 +1,338 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::crypto::pki;
|
||||
use crate::devices::device::{
|
||||
Device, DeviceGeneralInfo, DeviceId, DeviceInfo, DeviceRelay, DeviceRelayID,
|
||||
};
|
||||
use crate::utils::time_utils::time_secs;
|
||||
use openssl::x509::{X509Req, X509};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum DevicesListError {
|
||||
#[error("Enrollment failed: a device with the same ID was already registered!")]
|
||||
EnrollFailedDeviceAlreadyExists,
|
||||
#[error("Persist device config failed: the configuration of the device was not found!")]
|
||||
PersistFailedDeviceNotFound,
|
||||
#[error("Validated device failed: the device does not exists!")]
|
||||
ValidateDeviceFailedDeviceNotFound,
|
||||
#[error("Validated device failed: the device is already validated!")]
|
||||
ValidateDeviceFailedDeviceAlreadyValidated,
|
||||
#[error("Update device failed: the device does not exists!")]
|
||||
UpdateDeviceFailedDeviceNotFound,
|
||||
#[error("Requested device was not found")]
|
||||
DeviceNotFound,
|
||||
#[error("Requested device is not validated")]
|
||||
DeviceNotValidated,
|
||||
#[error("Failed to delete device: {0}")]
|
||||
DeleteDeviceFailed(&'static str),
|
||||
#[error("Failed to update relay configuration: {0}")]
|
||||
UpdateRelayFailed(&'static str),
|
||||
#[error("Failed to delete relay: {0}")]
|
||||
DeleteRelayFailed(&'static str),
|
||||
}
|
||||
|
||||
pub struct DevicesList(HashMap<DeviceId, Device>);
|
||||
|
||||
impl DevicesList {
|
||||
/// Load the list of devices. This method should be called only once during the whole execution
|
||||
/// of the program
|
||||
pub fn load() -> anyhow::Result<Self> {
|
||||
let mut list = Self(HashMap::new());
|
||||
|
||||
for f in std::fs::read_dir(AppConfig::get().devices_config_path())? {
|
||||
let f = f?.file_name();
|
||||
let f = f.to_string_lossy();
|
||||
|
||||
let dev_id = match f.strip_suffix(".conf") {
|
||||
Some(s) => DeviceId(s.to_string()),
|
||||
|
||||
// This is not a device configuration file
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let device_conf = std::fs::read(AppConfig::get().device_config_path(&dev_id))?;
|
||||
|
||||
list.0.insert(dev_id, serde_json::from_slice(&device_conf)?);
|
||||
}
|
||||
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Check if a device with a given id exists or not
|
||||
pub fn exists(&self, id: &DeviceId) -> bool {
|
||||
self.0.contains_key(id)
|
||||
}
|
||||
|
||||
/// Enroll a new device
|
||||
pub fn enroll(
|
||||
&mut self,
|
||||
id: &DeviceId,
|
||||
info: &DeviceInfo,
|
||||
csr: &X509Req,
|
||||
) -> anyhow::Result<()> {
|
||||
if self.exists(id) {
|
||||
return Err(DevicesListError::EnrollFailedDeviceAlreadyExists.into());
|
||||
}
|
||||
|
||||
let device = Device {
|
||||
id: id.clone(),
|
||||
info: info.clone(),
|
||||
time_create: time_secs(),
|
||||
time_update: time_secs(),
|
||||
name: id.0.to_string(),
|
||||
description: "".to_string(),
|
||||
validated: false,
|
||||
enabled: false,
|
||||
relays: vec![],
|
||||
};
|
||||
|
||||
// First, write CSR
|
||||
std::fs::write(AppConfig::get().device_csr_path(id), csr.to_pem()?)?;
|
||||
|
||||
self.0.insert(id.clone(), device);
|
||||
self.persist_dev_config(id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist a device configuration on the filesystem
|
||||
fn persist_dev_config(&self, id: &DeviceId) -> anyhow::Result<()> {
|
||||
let dev = self
|
||||
.0
|
||||
.get(id)
|
||||
.ok_or(DevicesListError::PersistFailedDeviceNotFound)?;
|
||||
|
||||
std::fs::write(
|
||||
AppConfig::get().device_config_path(id),
|
||||
serde_json::to_string_pretty(dev)?,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a copy of the full list of devices
|
||||
pub fn full_list(&self) -> Vec<Device> {
|
||||
self.0.clone().into_values().collect()
|
||||
}
|
||||
|
||||
/// Get the information about a single device
|
||||
pub fn get_single(&self, id: &DeviceId) -> Option<Device> {
|
||||
self.0.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Validate a device
|
||||
pub fn validate(&mut self, id: &DeviceId) -> anyhow::Result<()> {
|
||||
let dev = self
|
||||
.0
|
||||
.get_mut(id)
|
||||
.ok_or(DevicesListError::ValidateDeviceFailedDeviceNotFound)?;
|
||||
|
||||
if dev.validated {
|
||||
return Err(DevicesListError::ValidateDeviceFailedDeviceAlreadyValidated.into());
|
||||
}
|
||||
|
||||
// Issue certificate
|
||||
let csr = X509Req::from_pem(&std::fs::read(AppConfig::get().device_csr_path(id))?)?;
|
||||
let cert = pki::gen_certificate_for_device(&csr)?;
|
||||
std::fs::write(AppConfig::get().device_cert_path(id), cert)?;
|
||||
|
||||
// Mark device as validated
|
||||
dev.validated = true;
|
||||
self.persist_dev_config(id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update a device general information
|
||||
pub fn synchronise_dev_info(
|
||||
&mut self,
|
||||
id: &DeviceId,
|
||||
general_info: DeviceInfo,
|
||||
) -> anyhow::Result<()> {
|
||||
let dev = self
|
||||
.0
|
||||
.get_mut(id)
|
||||
.ok_or(DevicesListError::UpdateDeviceFailedDeviceNotFound)?;
|
||||
|
||||
dev.info = general_info;
|
||||
self.persist_dev_config(id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update a device general information
|
||||
pub fn update_general_info(
|
||||
&mut self,
|
||||
id: &DeviceId,
|
||||
general_info: DeviceGeneralInfo,
|
||||
) -> anyhow::Result<()> {
|
||||
let dev = self
|
||||
.0
|
||||
.get_mut(id)
|
||||
.ok_or(DevicesListError::UpdateDeviceFailedDeviceNotFound)?;
|
||||
|
||||
dev.name = general_info.name;
|
||||
dev.description = general_info.description;
|
||||
dev.enabled = general_info.enabled;
|
||||
dev.time_update = time_secs();
|
||||
|
||||
self.persist_dev_config(id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get single certificate information
|
||||
fn get_cert(&self, id: &DeviceId) -> anyhow::Result<X509> {
|
||||
let dev = self
|
||||
.get_single(id)
|
||||
.ok_or(DevicesListError::DeviceNotFound)?;
|
||||
if !dev.validated {
|
||||
return Err(DevicesListError::DeviceNotValidated.into());
|
||||
}
|
||||
|
||||
Ok(X509::from_pem(&std::fs::read(
|
||||
AppConfig::get().device_cert_path(id),
|
||||
)?)?)
|
||||
}
|
||||
|
||||
/// Delete a device
|
||||
pub fn delete(&mut self, id: &DeviceId) -> anyhow::Result<()> {
|
||||
// Check for conflicts
|
||||
let device = self
|
||||
.get_single(id)
|
||||
.ok_or(DevicesListError::DeleteDeviceFailed("Device not found!"))?;
|
||||
for r in &device.relays {
|
||||
if !self.relay_get_direct_dependencies(r.id).is_empty() {
|
||||
return Err(DevicesListError::DeleteDeviceFailed(
|
||||
"A relay of this device is required by another relay!",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
let crt_path = AppConfig::get().device_cert_path(id);
|
||||
if crt_path.is_file() {
|
||||
let cert = self.get_cert(id)?;
|
||||
pki::revoke_device_cert(&cert)?;
|
||||
}
|
||||
|
||||
let csr_path = AppConfig::get().device_csr_path(id);
|
||||
if csr_path.is_file() {
|
||||
std::fs::remove_file(&csr_path)?;
|
||||
}
|
||||
|
||||
let conf_path = AppConfig::get().device_config_path(id);
|
||||
if conf_path.is_file() {
|
||||
std::fs::remove_file(&conf_path)?;
|
||||
}
|
||||
|
||||
self.0.remove(id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the full list of relays
|
||||
pub fn relays_list(&self) -> Vec<DeviceRelay> {
|
||||
self.0
|
||||
.iter()
|
||||
.flat_map(|(_id, d)| d.relays.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Create a new relay
|
||||
pub fn relay_create(&mut self, dev_id: &DeviceId, relay: DeviceRelay) -> anyhow::Result<()> {
|
||||
let dev = self
|
||||
.0
|
||||
.get_mut(dev_id)
|
||||
.ok_or(DevicesListError::DeviceNotFound)?;
|
||||
|
||||
dev.relays.push(relay);
|
||||
|
||||
self.persist_dev_config(dev_id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a single relay
|
||||
pub fn relay_get_single(&self, relay_id: DeviceRelayID) -> Option<DeviceRelay> {
|
||||
self.relays_list().into_iter().find(|i| i.id == relay_id)
|
||||
}
|
||||
|
||||
/// Get a mutable reference on a single relay
|
||||
pub fn relay_get_single_mut(&mut self, relay_id: DeviceRelayID) -> Option<&mut DeviceRelay> {
|
||||
self.0
|
||||
.iter_mut()
|
||||
.find(|d| d.1.relays.iter().any(|r| r.id == relay_id))?
|
||||
.1
|
||||
.relays
|
||||
.iter_mut()
|
||||
.find(|r| r.id == relay_id)
|
||||
}
|
||||
|
||||
/// Get the device hosting a relay
|
||||
pub fn relay_get_device(&mut self, relay_id: DeviceRelayID) -> Option<&mut Device> {
|
||||
self.0
|
||||
.iter_mut()
|
||||
.find(|r| r.1.relays.iter().any(|r| r.id == relay_id))
|
||||
.map(|d| d.1)
|
||||
}
|
||||
|
||||
/// Get all the relays that depends directly on a relay
|
||||
pub fn relay_get_direct_dependencies(&self, relay_id: DeviceRelayID) -> Vec<DeviceRelay> {
|
||||
self.relays_list()
|
||||
.into_iter()
|
||||
.filter(|d| d.depends_on.contains(&relay_id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Update a relay configuration
|
||||
pub fn relay_update(&mut self, relay: DeviceRelay) -> anyhow::Result<()> {
|
||||
let device = self
|
||||
.relay_get_device(relay.id)
|
||||
.ok_or(DevicesListError::UpdateRelayFailed(
|
||||
"Relay does not exists!",
|
||||
))?;
|
||||
|
||||
let idx = device.relays.iter().position(|r| r.id == relay.id).ok_or(
|
||||
DevicesListError::UpdateRelayFailed("Relay index not found!"),
|
||||
)?;
|
||||
|
||||
// Update the relay configuration
|
||||
device.relays[idx] = relay;
|
||||
let device_id = device.id.clone();
|
||||
|
||||
self.persist_dev_config(&device_id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a relay
|
||||
pub fn relay_delete(&mut self, relay_id: DeviceRelayID) -> anyhow::Result<()> {
|
||||
if !self.relay_get_direct_dependencies(relay_id).is_empty() {
|
||||
return Err(DevicesListError::DeleteRelayFailed(
|
||||
"At least one other relay depend on this relay!",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
// Delete relay energy information
|
||||
let stats_dir = AppConfig::get().relay_runtime_stats_dir(relay_id);
|
||||
if stats_dir.is_dir() {
|
||||
std::fs::remove_dir_all(stats_dir)?;
|
||||
}
|
||||
|
||||
// Delete the relay
|
||||
let device = self
|
||||
.relay_get_device(relay_id)
|
||||
.ok_or(DevicesListError::DeleteRelayFailed(
|
||||
"Relay does not exists!",
|
||||
))?;
|
||||
|
||||
device.relays.retain(|r| r.id != relay_id);
|
||||
|
||||
let device_id = device.id.clone();
|
||||
self.persist_dev_config(&device_id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
2
central_backend/src/devices/mod.rs
Normal file
2
central_backend/src/devices/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod device;
|
||||
pub mod devices_list;
|
42
central_backend/src/energy/consumption.rs
Normal file
42
central_backend/src/energy/consumption.rs
Normal file
@ -0,0 +1,42 @@
|
||||
use crate::app_config::{AppConfig, ConsumptionBackend};
|
||||
use rand::{thread_rng, Rng};
|
||||
use std::num::ParseIntError;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ConsumptionError {
|
||||
#[error("The file that should contain the consumption does not exists!")]
|
||||
NonExistentFile,
|
||||
#[error("The file that should contain the consumption has an invalid content!")]
|
||||
FileInvalidContent(#[source] ParseIntError),
|
||||
}
|
||||
|
||||
pub type EnergyConsumption = i32;
|
||||
|
||||
/// Get current electrical energy consumption
|
||||
pub async fn get_curr_consumption() -> anyhow::Result<EnergyConsumption> {
|
||||
let backend = AppConfig::get()
|
||||
.consumption_backend
|
||||
.as_ref()
|
||||
.unwrap_or(&ConsumptionBackend::Constant { value: 300 });
|
||||
|
||||
match backend {
|
||||
ConsumptionBackend::Constant { value } => Ok(*value),
|
||||
|
||||
ConsumptionBackend::Random { min, max } => Ok(thread_rng().gen_range(*min..*max)),
|
||||
|
||||
ConsumptionBackend::File { path } => {
|
||||
let path = Path::new(path);
|
||||
if !path.is_file() {
|
||||
return Err(ConsumptionError::NonExistentFile.into());
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
|
||||
Ok(content
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(ConsumptionError::FileInvalidContent)?)
|
||||
}
|
||||
}
|
||||
}
|
325
central_backend/src/energy/energy_actor.rs
Normal file
325
central_backend/src/energy/energy_actor.rs
Normal file
@ -0,0 +1,325 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::constants;
|
||||
use crate::devices::device::{
|
||||
Device, DeviceGeneralInfo, DeviceId, DeviceInfo, DeviceRelay, DeviceRelayID,
|
||||
};
|
||||
use crate::devices::devices_list::DevicesList;
|
||||
use crate::energy::consumption;
|
||||
use crate::energy::consumption::EnergyConsumption;
|
||||
use crate::energy::engine::EnergyEngine;
|
||||
use crate::utils::time_utils::time_secs;
|
||||
use actix::prelude::*;
|
||||
use openssl::x509::X509Req;
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct EnergyActor {
|
||||
curr_consumption: EnergyConsumption,
|
||||
devices: DevicesList,
|
||||
engine: EnergyEngine,
|
||||
}
|
||||
|
||||
impl EnergyActor {
|
||||
pub async fn new() -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
curr_consumption: consumption::get_curr_consumption().await?,
|
||||
devices: DevicesList::load()?,
|
||||
engine: EnergyEngine::default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn refresh(&mut self) -> anyhow::Result<()> {
|
||||
// Refresh energy
|
||||
self.curr_consumption = consumption::get_curr_consumption()
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
log::error!(
|
||||
"Failed to fetch latest consumption value, will use fallback value! {e}"
|
||||
);
|
||||
constants::FALLBACK_PRODUCTION_VALUE
|
||||
});
|
||||
|
||||
let devices_list = self.devices.full_list();
|
||||
|
||||
self.engine.refresh(self.curr_consumption, &devices_list);
|
||||
|
||||
self.engine.persist_relays_state(&devices_list)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for EnergyActor {
|
||||
type Context = Context<Self>;
|
||||
|
||||
fn started(&mut self, ctx: &mut Self::Context) {
|
||||
log::info!("Energy actor successfully started!");
|
||||
|
||||
ctx.run_interval(
|
||||
Duration::from_secs(AppConfig::get().refresh_interval),
|
||||
|act, _ctx| {
|
||||
log::info!("Performing energy refresh operation");
|
||||
if let Err(e) = futures::executor::block_on(act.refresh()) {
|
||||
log::error!("Energy refresh failed! {e}")
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn stopped(&mut self, _ctx: &mut Self::Context) {
|
||||
log::info!("Energy actor successfully stopped!");
|
||||
}
|
||||
}
|
||||
|
||||
pub type EnergyActorAddr = Addr<EnergyActor>;
|
||||
|
||||
/// Get current consumption
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "EnergyConsumption")]
|
||||
pub struct GetCurrConsumption;
|
||||
|
||||
impl Handler<GetCurrConsumption> for EnergyActor {
|
||||
type Result = EnergyConsumption;
|
||||
|
||||
fn handle(&mut self, _msg: GetCurrConsumption, _ctx: &mut Context<Self>) -> Self::Result {
|
||||
self.curr_consumption
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current consumption
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "bool")]
|
||||
pub struct CheckDeviceExists(pub DeviceId);
|
||||
|
||||
impl Handler<CheckDeviceExists> for EnergyActor {
|
||||
type Result = bool;
|
||||
|
||||
fn handle(&mut self, msg: CheckDeviceExists, _ctx: &mut Context<Self>) -> Self::Result {
|
||||
self.devices.exists(&msg.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Enroll device
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "anyhow::Result<()>")]
|
||||
pub struct EnrollDevice(pub DeviceId, pub DeviceInfo, pub X509Req);
|
||||
|
||||
impl Handler<EnrollDevice> for EnergyActor {
|
||||
type Result = anyhow::Result<()>;
|
||||
|
||||
fn handle(&mut self, msg: EnrollDevice, _ctx: &mut Context<Self>) -> Self::Result {
|
||||
self.devices.enroll(&msg.0, &msg.1, &msg.2)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a device
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "anyhow::Result<()>")]
|
||||
pub struct ValidateDevice(pub DeviceId);
|
||||
|
||||
impl Handler<ValidateDevice> for EnergyActor {
|
||||
type Result = anyhow::Result<()>;
|
||||
|
||||
fn handle(&mut self, msg: ValidateDevice, _ctx: &mut Context<Self>) -> Self::Result {
|
||||
log::info!("Requested to validate device {:?}...", &msg.0);
|
||||
self.devices.validate(&msg.0)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a device general information
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "anyhow::Result<()>")]
|
||||
pub struct UpdateDeviceGeneralInfo(pub DeviceId, pub DeviceGeneralInfo);
|
||||
|
||||
impl Handler<UpdateDeviceGeneralInfo> for EnergyActor {
|
||||
type Result = anyhow::Result<()>;
|
||||
|
||||
fn handle(&mut self, msg: UpdateDeviceGeneralInfo, _ctx: &mut Context<Self>) -> Self::Result {
|
||||
log::info!(
|
||||
"Requested to update device general info {:?}... {:#?}",
|
||||
&msg.0,
|
||||
&msg.1
|
||||
);
|
||||
|
||||
self.devices.update_general_info(&msg.0, msg.1)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a device
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "anyhow::Result<()>")]
|
||||
pub struct DeleteDevice(pub DeviceId);
|
||||
|
||||
impl Handler<DeleteDevice> for EnergyActor {
|
||||
type Result = anyhow::Result<()>;
|
||||
|
||||
fn handle(&mut self, msg: DeleteDevice, _ctx: &mut Context<Self>) -> Self::Result {
|
||||
log::info!("Requested to delete device {:?}...", &msg.0);
|
||||
|
||||
let Some(device) = self.devices.get_single(&msg.0) else {
|
||||
log::warn!("Requested to delete non-existent device!");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Delete device relays
|
||||
for relay in device.relays {
|
||||
self.devices.relay_delete(relay.id)?;
|
||||
}
|
||||
|
||||
self.devices.delete(&msg.0)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the list of devices
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "Vec<Device>")]
|
||||
pub struct GetDeviceLists;
|
||||
|
||||
impl Handler<GetDeviceLists> for EnergyActor {
|
||||
type Result = Vec<Device>;
|
||||
|
||||
fn handle(&mut self, _msg: GetDeviceLists, _ctx: &mut Context<Self>) -> Self::Result {
|
||||
self.devices.full_list()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the information about a single device
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "Option<Device>")]
|
||||
pub struct GetSingleDevice(pub DeviceId);
|
||||
|
||||
impl Handler<GetSingleDevice> for EnergyActor {
|
||||
type Result = Option<Device>;
|
||||
|
||||
fn handle(&mut self, msg: GetSingleDevice, _ctx: &mut Context<Self>) -> Self::Result {
|
||||
self.devices.get_single(&msg.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the full list of relays
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "Vec<DeviceRelay>")]
|
||||
pub struct GetRelaysList;
|
||||
|
||||
impl Handler<GetRelaysList> for EnergyActor {
|
||||
type Result = Vec<DeviceRelay>;
|
||||
|
||||
fn handle(&mut self, _msg: GetRelaysList, _ctx: &mut Context<Self>) -> Self::Result {
|
||||
self.devices.relays_list()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new device relay
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "anyhow::Result<()>")]
|
||||
pub struct CreateDeviceRelay(pub DeviceId, pub DeviceRelay);
|
||||
|
||||
impl Handler<CreateDeviceRelay> for EnergyActor {
|
||||
type Result = anyhow::Result<()>;
|
||||
|
||||
fn handle(&mut self, msg: CreateDeviceRelay, _ctx: &mut Context<Self>) -> Self::Result {
|
||||
self.devices.relay_create(&msg.0, msg.1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the information about a single relay
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "Option<DeviceRelay>")]
|
||||
pub struct GetSingleRelay(pub DeviceRelayID);
|
||||
|
||||
impl Handler<GetSingleRelay> for EnergyActor {
|
||||
type Result = Option<DeviceRelay>;
|
||||
|
||||
fn handle(&mut self, msg: GetSingleRelay, _ctx: &mut Context<Self>) -> Self::Result {
|
||||
self.devices.relay_get_single(msg.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a device relay
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "anyhow::Result<()>")]
|
||||
pub struct UpdateDeviceRelay(pub DeviceRelay);
|
||||
|
||||
impl Handler<UpdateDeviceRelay> for EnergyActor {
|
||||
type Result = anyhow::Result<()>;
|
||||
|
||||
fn handle(&mut self, msg: UpdateDeviceRelay, _ctx: &mut Context<Self>) -> Self::Result {
|
||||
self.devices.relay_update(msg.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a device relay
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "anyhow::Result<()>")]
|
||||
pub struct DeleteDeviceRelay(pub DeviceRelayID);
|
||||
|
||||
impl Handler<DeleteDeviceRelay> for EnergyActor {
|
||||
type Result = anyhow::Result<()>;
|
||||
|
||||
fn handle(&mut self, msg: DeleteDeviceRelay, _ctx: &mut Context<Self>) -> Self::Result {
|
||||
self.devices.relay_delete(msg.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct RelaySyncStatus {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
/// Synchronize a device
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "anyhow::Result<Vec<RelaySyncStatus>>")]
|
||||
pub struct SynchronizeDevice(pub DeviceId, pub DeviceInfo);
|
||||
|
||||
impl Handler<SynchronizeDevice> for EnergyActor {
|
||||
type Result = anyhow::Result<Vec<RelaySyncStatus>>;
|
||||
|
||||
fn handle(&mut self, msg: SynchronizeDevice, _ctx: &mut Context<Self>) -> Self::Result {
|
||||
self.devices.synchronise_dev_info(&msg.0, msg.1.clone())?;
|
||||
self.engine.device_state(&msg.0).record_ping();
|
||||
|
||||
// TODO : implement real code
|
||||
let mut v = vec![];
|
||||
for i in 0..msg.1.max_relays {
|
||||
v.push(RelaySyncStatus {
|
||||
enabled: i % 2 == 0,
|
||||
});
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct ResDevState {
|
||||
pub id: DeviceId,
|
||||
last_ping: u64,
|
||||
online: bool,
|
||||
}
|
||||
|
||||
/// Get the state of devices
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "Vec<ResDevState>")]
|
||||
pub struct GetDevicesState;
|
||||
|
||||
impl Handler<GetDevicesState> for EnergyActor {
|
||||
type Result = Vec<ResDevState>;
|
||||
|
||||
fn handle(&mut self, _msg: GetDevicesState, _ctx: &mut Context<Self>) -> Self::Result {
|
||||
self.devices
|
||||
.full_list()
|
||||
.into_iter()
|
||||
.map(|d| {
|
||||
let s = self.engine.device_state(&d.id);
|
||||
ResDevState {
|
||||
id: d.id,
|
||||
last_ping: time_secs() - s.last_ping,
|
||||
online: s.is_online(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
373
central_backend/src/energy/engine.rs
Normal file
373
central_backend/src/energy/engine.rs
Normal file
@ -0,0 +1,373 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::app_config::AppConfig;
|
||||
use prettytable::{row, Table};
|
||||
|
||||
use crate::constants;
|
||||
use crate::devices::device::{Device, DeviceId, DeviceRelay, DeviceRelayID};
|
||||
use crate::energy::consumption::EnergyConsumption;
|
||||
use crate::energy::relay_state_history;
|
||||
use crate::energy::relay_state_history::RelayStateHistory;
|
||||
use crate::utils::time_utils::{curr_hour, time_secs, time_start_of_day};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DeviceState {
|
||||
pub last_ping: u64,
|
||||
}
|
||||
|
||||
impl DeviceState {
|
||||
pub fn record_ping(&mut self) {
|
||||
self.last_ping = time_secs();
|
||||
}
|
||||
|
||||
pub fn is_online(&self) -> bool {
|
||||
(time_secs() - self.last_ping) < constants::DEVICE_MAX_PING_TIME
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct RelayState {
|
||||
on: bool,
|
||||
since: usize,
|
||||
}
|
||||
|
||||
impl RelayState {
|
||||
fn is_on(&self) -> bool {
|
||||
self.on
|
||||
}
|
||||
|
||||
fn is_off(&self) -> bool {
|
||||
!self.on
|
||||
}
|
||||
}
|
||||
|
||||
type RelaysState = HashMap<DeviceRelayID, RelayState>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct EnergyEngine {
|
||||
devices_state: HashMap<DeviceId, DeviceState>,
|
||||
relays_state: RelaysState,
|
||||
}
|
||||
|
||||
impl DeviceRelay {
|
||||
// Note : this function is not recursive
|
||||
fn has_running_dependencies(&self, s: &RelaysState, devices: &[Device]) -> bool {
|
||||
for d in devices {
|
||||
for r in &d.relays {
|
||||
if r.depends_on.contains(&self.id) && s.get(&r.id).unwrap().is_on() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
// Note : this function is not recursive
|
||||
fn is_missing_dependencies(&self, s: &RelaysState) -> bool {
|
||||
self.depends_on.iter().any(|id| s.get(id).unwrap().is_off())
|
||||
}
|
||||
|
||||
fn is_having_conflict(&self, s: &RelaysState, devices: &[Device]) -> bool {
|
||||
if self
|
||||
.conflicts_with
|
||||
.iter()
|
||||
.any(|id| s.get(id).unwrap().is_on())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Reverse search
|
||||
for device in devices {
|
||||
for r in &device.relays {
|
||||
if s.get(&r.id).unwrap().is_on() && r.conflicts_with.contains(&self.id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn sum_relays_consumption(state: &RelaysState, devices: &[Device]) -> usize {
|
||||
let mut consumption = 0;
|
||||
|
||||
for d in devices {
|
||||
for r in &d.relays {
|
||||
if matches!(state.get(&r.id).map(|r| r.on), Some(true)) {
|
||||
consumption += r.consumption;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
consumption
|
||||
}
|
||||
|
||||
impl EnergyEngine {
|
||||
pub fn device_state(&mut self, dev_id: &DeviceId) -> &mut DeviceState {
|
||||
self.devices_state.entry(dev_id.clone()).or_default();
|
||||
self.devices_state.get_mut(dev_id).unwrap()
|
||||
}
|
||||
|
||||
pub fn relay_state(&mut self, relay_id: DeviceRelayID) -> &mut RelayState {
|
||||
self.relays_state.entry(relay_id).or_default();
|
||||
self.relays_state.get_mut(&relay_id).unwrap()
|
||||
}
|
||||
|
||||
fn print_summary(&mut self, curr_consumption: EnergyConsumption, devices: &[Device]) {
|
||||
log::info!("Current consumption: {curr_consumption}");
|
||||
|
||||
let mut table = Table::new();
|
||||
table.add_row(row![
|
||||
"Device",
|
||||
"Relay",
|
||||
"Consumption",
|
||||
"Min downtime / uptime",
|
||||
"On",
|
||||
"Since",
|
||||
"Online",
|
||||
"Enabled device / relay"
|
||||
]);
|
||||
for d in devices {
|
||||
let dev_online = self.device_state(&d.id).is_online();
|
||||
for r in &d.relays {
|
||||
let status = self.relay_state(r.id);
|
||||
table.add_row(row![
|
||||
d.name,
|
||||
r.name,
|
||||
r.consumption,
|
||||
format!("{} / {}", r.minimal_downtime, r.minimal_uptime),
|
||||
status.is_on().to_string(),
|
||||
status.since,
|
||||
match dev_online {
|
||||
true => "Online",
|
||||
false => "Offline",
|
||||
},
|
||||
format!(
|
||||
"{} / {}",
|
||||
match d.enabled {
|
||||
true => "Enabled",
|
||||
false => "Disabled",
|
||||
},
|
||||
match r.enabled {
|
||||
true => "Enabled",
|
||||
false => "Disabled",
|
||||
}
|
||||
)
|
||||
]);
|
||||
}
|
||||
}
|
||||
table.printstd();
|
||||
}
|
||||
|
||||
pub fn estimated_consumption_without_relays(
|
||||
&self,
|
||||
curr_consumption: EnergyConsumption,
|
||||
devices: &[Device],
|
||||
) -> EnergyConsumption {
|
||||
curr_consumption - sum_relays_consumption(&self.relays_state, devices) as i32
|
||||
}
|
||||
|
||||
/// Refresh energy engine; this method shall never fail !
|
||||
pub fn refresh(&mut self, curr_consumption: EnergyConsumption, devices: &[Device]) {
|
||||
let base_production = self.estimated_consumption_without_relays(curr_consumption, devices);
|
||||
log::info!("Estimated base production: {base_production}");
|
||||
|
||||
// Force creation of missing relays state
|
||||
for d in devices {
|
||||
for r in &d.relays {
|
||||
// Requesting relay state is enough to trigger relay creation
|
||||
self.relay_state(r.id);
|
||||
}
|
||||
}
|
||||
|
||||
let mut new_relays_state = self.relays_state.clone();
|
||||
|
||||
// Forcefully turn off relays that belongs to offline devices
|
||||
for d in devices {
|
||||
if !self.device_state(&d.id).is_online() {
|
||||
for r in &d.relays {
|
||||
new_relays_state.get_mut(&r.id).unwrap().on = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Forcefully turn off disabled relays
|
||||
for d in devices {
|
||||
for r in &d.relays {
|
||||
if !r.enabled || !d.enabled {
|
||||
new_relays_state.get_mut(&r.id).unwrap().on = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Forcefully turn off relays with missing dependency
|
||||
loop {
|
||||
let mut changed = false;
|
||||
|
||||
for d in devices {
|
||||
for r in &d.relays {
|
||||
if new_relays_state.get(&r.id).unwrap().is_off() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if any dependency of relay is off
|
||||
if r.is_missing_dependencies(&new_relays_state) {
|
||||
new_relays_state.get_mut(&r.id).unwrap().on = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Virtually turn off all relays that can be stopped
|
||||
loop {
|
||||
let mut changed = false;
|
||||
|
||||
for d in devices {
|
||||
for r in &d.relays {
|
||||
let state = new_relays_state.get(&r.id).unwrap();
|
||||
if state.is_off() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if minimal runtime has not been reached
|
||||
if (state.since + r.minimal_uptime) as i64 > time_secs() as i64 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check that no relay that depends on this relay are turned on
|
||||
if r.has_running_dependencies(&new_relays_state, devices) {
|
||||
continue;
|
||||
}
|
||||
|
||||
new_relays_state.get_mut(&r.id).unwrap().on = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Turn on relays with running constraints (only ENABLED)
|
||||
for d in devices {
|
||||
for r in &d.relays {
|
||||
if !r.enabled || !d.enabled || !self.device_state(&d.id).is_online() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if new_relays_state.get(&r.id).unwrap().is_on() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(constraints) = &r.daily_runtime else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !constraints.catch_up_hours.contains(&curr_hour()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let time_start_day = time_start_of_day().unwrap_or(1726696800);
|
||||
let start_time = time_start_day + constraints.reset_time as u64;
|
||||
let end_time = time_start_day + 3600 * 24 + constraints.reset_time as u64;
|
||||
let total_runtime =
|
||||
relay_state_history::relay_total_runtime(r.id, start_time, end_time)
|
||||
.unwrap_or(3600 * 24);
|
||||
|
||||
if total_runtime > constraints.min_runtime {
|
||||
continue;
|
||||
}
|
||||
|
||||
log::info!("Forcefully turn on relay {} to catch up running constraints (only {}s this day)", r.name, total_runtime);
|
||||
new_relays_state.get_mut(&r.id).unwrap().on = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Order relays
|
||||
let mut ordered_relays = devices
|
||||
.iter()
|
||||
.filter(|d| self.device_state(&d.id).is_online() && d.enabled)
|
||||
.flat_map(|d| &d.relays)
|
||||
.filter(|r| r.enabled)
|
||||
.collect::<Vec<_>>();
|
||||
ordered_relays.sort_by_key(|r| r.priority);
|
||||
ordered_relays.reverse();
|
||||
|
||||
loop {
|
||||
let mut changed = false;
|
||||
for relay in &ordered_relays {
|
||||
if new_relays_state.get(&relay.id).unwrap().is_on() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !relay.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
let real_relay_state = self.relays_state.get(&relay.id).unwrap();
|
||||
if real_relay_state.is_off()
|
||||
&& (real_relay_state.since + relay.minimal_downtime) as u64 > time_secs()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if relay.is_missing_dependencies(&new_relays_state) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if relay.is_having_conflict(&new_relays_state, devices) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let new_consumption = base_production
|
||||
+ sum_relays_consumption(&new_relays_state, devices) as EnergyConsumption;
|
||||
|
||||
if new_consumption + relay.consumption as i32 > AppConfig::get().production_margin {
|
||||
continue;
|
||||
}
|
||||
|
||||
log::info!("Turn on relay {}", relay.name);
|
||||
new_relays_state.get_mut(&relay.id).unwrap().on = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Commit changes
|
||||
for (id, new_state) in &new_relays_state {
|
||||
let curr_state = self.relay_state(*id);
|
||||
if curr_state.on != new_state.on {
|
||||
curr_state.on = new_state.on;
|
||||
curr_state.since = time_secs() as usize;
|
||||
log::info!("Changing state of {id:?} to {}", new_state.on);
|
||||
}
|
||||
}
|
||||
|
||||
self.print_summary(curr_consumption, devices);
|
||||
}
|
||||
|
||||
/// Save relays state to disk
|
||||
pub fn persist_relays_state(&mut self, devices: &[Device]) -> anyhow::Result<()> {
|
||||
// Save all relays state
|
||||
for d in devices {
|
||||
for r in &d.relays {
|
||||
let mut file = RelayStateHistory::open(r.id, time_secs())?;
|
||||
file.set_state(time_secs(), self.relay_state(r.id).is_on())?;
|
||||
file.save()?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
4
central_backend/src/energy/mod.rs
Normal file
4
central_backend/src/energy/mod.rs
Normal file
@ -0,0 +1,4 @@
|
||||
pub mod consumption;
|
||||
pub mod energy_actor;
|
||||
pub mod engine;
|
||||
pub mod relay_state_history;
|
175
central_backend/src/energy/relay_state_history.rs
Normal file
175
central_backend/src/energy/relay_state_history.rs
Normal file
@ -0,0 +1,175 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::devices::device::DeviceRelayID;
|
||||
use crate::utils::files_utils;
|
||||
use crate::utils::time_utils::day_number;
|
||||
|
||||
const TIME_INTERVAL: usize = 30;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum RelayStateHistoryError {
|
||||
#[error("Given time is out of file bounds!")]
|
||||
TimeOutOfFileBound,
|
||||
}
|
||||
|
||||
/// # RelayStateHistory
|
||||
///
|
||||
/// This structures handles the manipulation of relay state history files
|
||||
///
|
||||
/// These file are binary file optimizing used space.
|
||||
pub struct RelayStateHistory {
|
||||
id: DeviceRelayID,
|
||||
day: u64,
|
||||
buff: Vec<u8>,
|
||||
}
|
||||
|
||||
impl RelayStateHistory {
|
||||
/// Open relay state history file, if it exists, or create an empty one
|
||||
pub fn open(id: DeviceRelayID, time: u64) -> anyhow::Result<Self> {
|
||||
let day = day_number(time);
|
||||
let path = AppConfig::get().relay_runtime_day_file_path(id, day);
|
||||
|
||||
if path.exists() {
|
||||
Ok(Self {
|
||||
id,
|
||||
day,
|
||||
buff: std::fs::read(path)?,
|
||||
})
|
||||
} else {
|
||||
log::debug!(
|
||||
"Stats for relay {id:?} for day {day} does not exists yet, creating memory buffer"
|
||||
);
|
||||
Ok(Self::new_memory(id, day))
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new in memory dev relay state history
|
||||
fn new_memory(id: DeviceRelayID, day: u64) -> Self {
|
||||
Self {
|
||||
id,
|
||||
day,
|
||||
buff: vec![0; 3600 * 24 / TIME_INTERVAL],
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve time offset of a given time in buffer
|
||||
fn resolve_offset(&self, time: u64) -> anyhow::Result<(usize, u8)> {
|
||||
let start_of_day = self.day * 3600 * 24;
|
||||
|
||||
if time < start_of_day || time >= start_of_day + 3600 * 24 {
|
||||
return Err(RelayStateHistoryError::TimeOutOfFileBound.into());
|
||||
}
|
||||
|
||||
let relative_time = (time - start_of_day) / TIME_INTERVAL as u64;
|
||||
|
||||
Ok(((relative_time / 8) as usize, (relative_time % 8) as u8))
|
||||
}
|
||||
|
||||
/// Check if a time is contained in this history
|
||||
pub fn contains_time(&self, time: u64) -> bool {
|
||||
self.resolve_offset(time).is_ok()
|
||||
}
|
||||
|
||||
/// Set new state of relay
|
||||
pub fn set_state(&mut self, time: u64, on: bool) -> anyhow::Result<()> {
|
||||
let (idx, offset) = self.resolve_offset(time)?;
|
||||
|
||||
self.buff[idx] = if on {
|
||||
self.buff[idx] | (0x1 << offset)
|
||||
} else {
|
||||
self.buff[idx] & !(0x1 << offset)
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the state of relay at a given time
|
||||
pub fn get_state(&self, time: u64) -> anyhow::Result<bool> {
|
||||
let (idx, offset) = self.resolve_offset(time)?;
|
||||
|
||||
Ok(self.buff[idx] & (0x1 << offset) != 0)
|
||||
}
|
||||
|
||||
/// Persist device relay state history
|
||||
pub fn save(&self) -> anyhow::Result<()> {
|
||||
let path = AppConfig::get().relay_runtime_day_file_path(self.id, self.day);
|
||||
files_utils::create_directory_if_missing(path.parent().unwrap())?;
|
||||
std::fs::write(path, &self.buff)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the total runtime of a relay during a given time window
|
||||
pub fn relay_total_runtime(device_id: DeviceRelayID, from: u64, to: u64) -> anyhow::Result<usize> {
|
||||
let mut total = 0;
|
||||
let mut file = RelayStateHistory::open(device_id, from)?;
|
||||
let mut curr_time = from;
|
||||
|
||||
while curr_time < to {
|
||||
if !file.contains_time(curr_time) {
|
||||
file = RelayStateHistory::open(device_id, curr_time)?;
|
||||
}
|
||||
|
||||
if file.get_state(curr_time)? {
|
||||
total += TIME_INTERVAL;
|
||||
}
|
||||
|
||||
curr_time += TIME_INTERVAL as u64;
|
||||
}
|
||||
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::devices::device::DeviceRelayID;
|
||||
use crate::energy::relay_state_history::{relay_total_runtime, RelayStateHistory};
|
||||
|
||||
#[test]
|
||||
fn test_relay_state_history() {
|
||||
let mut history = RelayStateHistory::new_memory(DeviceRelayID::default(), 0);
|
||||
|
||||
let val_1 = 5 * 30;
|
||||
let val_2 = 7 * 30;
|
||||
|
||||
for i in 0..500 {
|
||||
assert!(!history.get_state(i).unwrap())
|
||||
}
|
||||
|
||||
history.set_state(val_1, true).unwrap();
|
||||
|
||||
for i in 0..500 {
|
||||
assert_eq!(history.get_state(i).unwrap(), (i / 30) * 30 == val_1);
|
||||
}
|
||||
|
||||
history.set_state(val_2, true).unwrap();
|
||||
|
||||
for i in 0..500 {
|
||||
assert_eq!(
|
||||
history.get_state(i).unwrap(),
|
||||
(i / 30) * 30 == val_1 || (i / 30) * 30 == val_2
|
||||
);
|
||||
}
|
||||
|
||||
history.set_state(val_2, false).unwrap();
|
||||
|
||||
for i in 0..500 {
|
||||
assert_eq!(history.get_state(i).unwrap(), (i / 30) * 30 == val_1);
|
||||
}
|
||||
|
||||
history.set_state(val_1, false).unwrap();
|
||||
|
||||
for i in 0..500 {
|
||||
assert!(!history.get_state(i).unwrap())
|
||||
}
|
||||
|
||||
assert!(history.get_state(8989898).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relay_total_runtime() {
|
||||
assert_eq!(
|
||||
relay_total_runtime(DeviceRelayID::default(), 50, 3600 * 24 * 60 + 500).unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
@ -1,3 +1,7 @@
|
||||
pub mod app_config;
|
||||
pub mod pki;
|
||||
pub mod constants;
|
||||
pub mod crypto;
|
||||
pub mod devices;
|
||||
pub mod energy;
|
||||
pub mod server;
|
||||
pub mod utils;
|
@ -1,13 +1,51 @@
|
||||
use actix::Actor;
|
||||
use central_backend::app_config::AppConfig;
|
||||
use central_backend::pki;
|
||||
use central_backend::crypto::pki;
|
||||
use central_backend::energy::energy_actor::EnergyActor;
|
||||
use central_backend::server::servers;
|
||||
use central_backend::utils::files_utils::create_directory_if_missing;
|
||||
use futures::future;
|
||||
use tokio_schedule::{every, Job};
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
// Initialize OpenSSL
|
||||
openssl_sys::init();
|
||||
|
||||
fn main() {
|
||||
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
|
||||
|
||||
// Initialize storage
|
||||
create_directory_if_missing(&AppConfig::get().pki_path()).unwrap();
|
||||
create_directory_if_missing(AppConfig::get().pki_path()).unwrap();
|
||||
create_directory_if_missing(AppConfig::get().devices_config_path()).unwrap();
|
||||
create_directory_if_missing(AppConfig::get().relays_runtime_stats_storage_path()).unwrap();
|
||||
|
||||
// Initialize PKI
|
||||
pki::initialize_root_ca().expect("Failed to initialize Root CA!");
|
||||
pki::initialize_web_ca().expect("Failed to initialize web CA!");
|
||||
pki::initialize_devices_ca().expect("Failed to initialize devices CA!");
|
||||
pki::initialize_server_ca().expect("Failed to initialize server certificate!");
|
||||
|
||||
// Initialize CRL
|
||||
pki::refresh_crls().expect("Failed to initialize Root CA!");
|
||||
let refresh_crl = every(1).hour().perform(|| async {
|
||||
log::info!("Periodic refresh of CRLs...");
|
||||
if let Err(e) = pki::refresh_crls() {
|
||||
log::error!("Failed to perform auto refresh of CRLs! {e}");
|
||||
}
|
||||
});
|
||||
tokio::spawn(refresh_crl);
|
||||
|
||||
// Initialize energy actor
|
||||
let actor = EnergyActor::new()
|
||||
.await
|
||||
.expect("Failed to initialize energy actor!")
|
||||
.start();
|
||||
|
||||
let s1 = servers::secure_server(actor);
|
||||
let s2 = servers::unsecure_server();
|
||||
future::try_join(s1, s2)
|
||||
.await
|
||||
.expect("Failed to start servers!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -1,68 +0,0 @@
|
||||
use openssl::asn1::Asn1Time;
|
||||
use openssl::bn::{BigNum, MsbOption};
|
||||
use openssl::ec::EcGroup;
|
||||
use openssl::hash::MessageDigest;
|
||||
use openssl::nid::Nid;
|
||||
use openssl::pkey::PKey;
|
||||
use openssl::x509::extension::{BasicConstraints, KeyUsage, SubjectKeyIdentifier};
|
||||
use openssl::x509::{X509, X509NameBuilder};
|
||||
use crate::app_config::AppConfig;
|
||||
|
||||
/// Initialize Root CA, if required
|
||||
pub fn initialize_root_ca() -> anyhow::Result<()> {
|
||||
if AppConfig::get().root_ca_cert_path().exists()
|
||||
&& AppConfig::get().root_ca_priv_key_path().exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::info!("Generating root ca...");
|
||||
|
||||
// Generate root private key
|
||||
let nid = Nid::X9_62_PRIME256V1; // NIST P-256 curve
|
||||
let group = EcGroup::from_curve_name(nid)?;
|
||||
let key = openssl::ec::EcKey::generate(&group)?;
|
||||
let key_pair = PKey::from_ec_key(key.clone())?;
|
||||
|
||||
let mut x509_name = X509NameBuilder::new()?;
|
||||
x509_name.append_entry_by_text("C", "FR")?;
|
||||
x509_name.append_entry_by_text("CN", "SolarEnergy Root CA")?;
|
||||
let x509_name = x509_name.build();
|
||||
|
||||
let mut cert_builder = X509::builder()?;
|
||||
cert_builder.set_version(2)?;
|
||||
let serial_number = {
|
||||
let mut serial = BigNum::new()?;
|
||||
serial.rand(159, MsbOption::MAYBE_ZERO, false)?;
|
||||
serial.to_asn1_integer()?
|
||||
};
|
||||
cert_builder.set_serial_number(&serial_number)?;
|
||||
cert_builder.set_subject_name(&x509_name)?;
|
||||
cert_builder.set_issuer_name(&x509_name)?;
|
||||
cert_builder.set_pubkey(&key_pair)?;
|
||||
let not_before = Asn1Time::days_from_now(0)?;
|
||||
cert_builder.set_not_before(¬_before)?;
|
||||
let not_after = Asn1Time::days_from_now(365 * 30)?;
|
||||
cert_builder.set_not_after(¬_after)?;
|
||||
|
||||
cert_builder.append_extension(BasicConstraints::new().critical().ca().build()?)?;
|
||||
cert_builder.append_extension(
|
||||
KeyUsage::new()
|
||||
.critical()
|
||||
.key_cert_sign()
|
||||
.crl_sign()
|
||||
.build()?,
|
||||
)?;
|
||||
|
||||
let subject_key_identifier =
|
||||
SubjectKeyIdentifier::new().build(&cert_builder.x509v3_context(None, None))?;
|
||||
cert_builder.append_extension(subject_key_identifier)?;
|
||||
|
||||
cert_builder.sign(&key_pair, MessageDigest::sha256())?;
|
||||
let cert = cert_builder.build();
|
||||
|
||||
// Serialize generated root CA
|
||||
std::fs::write(AppConfig::get().root_ca_priv_key_path(), key.private_key_to_pem()?)?;
|
||||
std::fs::write(AppConfig::get().root_ca_cert_path(), cert.to_pem()?)?;
|
||||
|
||||
Ok(())
|
||||
}
|
97
central_backend/src/server/auth_middleware.rs
Normal file
97
central_backend/src/server/auth_middleware.rs
Normal file
@ -0,0 +1,97 @@
|
||||
use actix_identity::Identity;
|
||||
use std::future::{ready, Ready};
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::constants;
|
||||
use actix_web::body::EitherBody;
|
||||
use actix_web::dev::Payload;
|
||||
use actix_web::{
|
||||
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
|
||||
Error, FromRequest, HttpResponse,
|
||||
};
|
||||
use futures_util::future::LocalBoxFuture;
|
||||
|
||||
// There are two steps in middleware processing.
|
||||
// 1. Middleware initialization, middleware factory gets called with
|
||||
// next service in chain as parameter.
|
||||
// 2. Middleware's call method gets called with normal request.
|
||||
#[derive(Default)]
|
||||
pub struct AuthChecker;
|
||||
|
||||
// Middleware factory is `Transform` trait
|
||||
// `S` - type of the next service
|
||||
// `B` - type of response's body
|
||||
impl<S, B> Transform<S, ServiceRequest> for AuthChecker
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
|
||||
S::Future: 'static,
|
||||
B: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<EitherBody<B>>;
|
||||
type Error = Error;
|
||||
type Transform = AuthMiddleware<S>;
|
||||
type InitError = ();
|
||||
type Future = Ready<Result<Self::Transform, Self::InitError>>;
|
||||
|
||||
fn new_transform(&self, service: S) -> Self::Future {
|
||||
ready(Ok(AuthMiddleware {
|
||||
service: Rc::new(service),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AuthMiddleware<S> {
|
||||
service: Rc<S>,
|
||||
}
|
||||
|
||||
impl<S, B> Service<ServiceRequest> for AuthMiddleware<S>
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
|
||||
S::Future: 'static,
|
||||
B: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<EitherBody<B>>;
|
||||
type Error = Error;
|
||||
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
|
||||
|
||||
forward_ready!(service);
|
||||
|
||||
fn call(&self, req: ServiceRequest) -> Self::Future {
|
||||
let service = Rc::clone(&self.service);
|
||||
|
||||
Box::pin(async move {
|
||||
// Check if no authentication is required
|
||||
if constants::ROUTES_WITHOUT_AUTH.contains(&req.path())
|
||||
|| !req.path().starts_with("/web_api/")
|
||||
{
|
||||
log::trace!("No authentication is required")
|
||||
}
|
||||
// Dev only, check for auto login
|
||||
else if AppConfig::get().unsecure_disable_login {
|
||||
log::trace!("Authentication is disabled")
|
||||
}
|
||||
// Check cookie authentication
|
||||
else {
|
||||
let identity: Option<Identity> =
|
||||
Identity::from_request(req.request(), &mut Payload::None)
|
||||
.into_inner()
|
||||
.ok();
|
||||
|
||||
if identity.is_none() {
|
||||
log::error!(
|
||||
"Missing identity information in request, user is not authenticated!"
|
||||
);
|
||||
return Ok(req
|
||||
.into_response(HttpResponse::PreconditionFailed().finish())
|
||||
.map_into_right_body());
|
||||
};
|
||||
}
|
||||
|
||||
service
|
||||
.call(req)
|
||||
.await
|
||||
.map(ServiceResponse::map_into_left_body)
|
||||
})
|
||||
}
|
||||
}
|
118
central_backend/src/server/custom_error.rs
Normal file
118
central_backend/src/server/custom_error.rs
Normal file
@ -0,0 +1,118 @@
|
||||
use actix_web::body::BoxBody;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::HttpResponse;
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::io::ErrorKind;
|
||||
|
||||
/// Custom error to ease controller writing
|
||||
#[derive(Debug)]
|
||||
pub enum HttpErr {
|
||||
Err(anyhow::Error),
|
||||
HTTPResponse(HttpResponse),
|
||||
}
|
||||
|
||||
impl Display for HttpErr {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
HttpErr::Err(err) => Display::fmt(err, f),
|
||||
HttpErr::HTTPResponse(res) => {
|
||||
Display::fmt(&format!("HTTP RESPONSE {}", res.status().as_str()), f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl actix_web::error::ResponseError for HttpErr {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
HttpErr::Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
HttpErr::HTTPResponse(r) => r.status(),
|
||||
}
|
||||
}
|
||||
fn error_response(&self) -> HttpResponse<BoxBody> {
|
||||
log::error!("Error while processing request! {}", self);
|
||||
|
||||
HttpResponse::InternalServerError().body("Failed to execute request!")
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for HttpErr {
|
||||
fn from(err: anyhow::Error) -> HttpErr {
|
||||
HttpErr::Err(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for HttpErr {
|
||||
fn from(value: serde_json::Error) -> Self {
|
||||
HttpErr::Err(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Box<dyn Error>> for HttpErr {
|
||||
fn from(value: Box<dyn Error>) -> Self {
|
||||
HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for HttpErr {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
HttpErr::Err(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::num::ParseIntError> for HttpErr {
|
||||
fn from(value: std::num::ParseIntError) -> Self {
|
||||
HttpErr::Err(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for HttpErr {
|
||||
fn from(value: reqwest::Error) -> Self {
|
||||
HttpErr::Err(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::header::ToStrError> for HttpErr {
|
||||
fn from(value: reqwest::header::ToStrError) -> Self {
|
||||
HttpErr::Err(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<actix_web::Error> for HttpErr {
|
||||
fn from(value: actix_web::Error) -> Self {
|
||||
HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<actix::MailboxError> for HttpErr {
|
||||
fn from(value: actix::MailboxError) -> Self {
|
||||
HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<actix_identity::error::GetIdentityError> for HttpErr {
|
||||
fn from(value: actix_identity::error::GetIdentityError) -> Self {
|
||||
HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<actix_identity::error::LoginError> for HttpErr {
|
||||
fn from(value: actix_identity::error::LoginError) -> Self {
|
||||
HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<openssl::error::ErrorStack> for HttpErr {
|
||||
fn from(value: openssl::error::ErrorStack) -> Self {
|
||||
HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HttpResponse> for HttpErr {
|
||||
fn from(value: HttpResponse) -> Self {
|
||||
HttpErr::HTTPResponse(value)
|
||||
}
|
||||
}
|
||||
|
||||
pub type HttpResult = Result<HttpResponse, HttpErr>;
|
207
central_backend/src/server/devices_api/mgmt_controller.rs
Normal file
207
central_backend/src/server/devices_api/mgmt_controller.rs
Normal file
@ -0,0 +1,207 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::crypto::pki;
|
||||
use crate::devices::device::{DeviceId, DeviceInfo};
|
||||
use crate::energy::energy_actor;
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use crate::server::WebEnergyActor;
|
||||
use actix_web::{web, HttpResponse};
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
|
||||
use openssl::nid::Nid;
|
||||
use openssl::x509::{X509Req, X509};
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct EnrollRequest {
|
||||
/// Device CSR
|
||||
csr: String,
|
||||
/// Associated device information
|
||||
info: DeviceInfo,
|
||||
}
|
||||
|
||||
/// Enroll a new device
|
||||
pub async fn enroll(req: web::Json<EnrollRequest>, actor: WebEnergyActor) -> HttpResult {
|
||||
// Check device information
|
||||
if let Some(e) = req.info.error() {
|
||||
log::error!("Failed to validate device information! {e}");
|
||||
return Ok(HttpResponse::BadRequest().json(e));
|
||||
}
|
||||
|
||||
// Check CSR
|
||||
let csr = match X509Req::from_pem(req.csr.as_bytes()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::error!("Failed to parse given CSR! {e}");
|
||||
return Ok(HttpResponse::BadRequest().json("Failed to parse given CSR!"));
|
||||
}
|
||||
};
|
||||
|
||||
if !csr.verify(csr.public_key()?.as_ref())? {
|
||||
log::error!("Invalid CSR signature!");
|
||||
return Ok(HttpResponse::BadRequest().json("Could not verify CSR signature!"));
|
||||
}
|
||||
|
||||
let cn = match csr.subject_name().entries_by_nid(Nid::COMMONNAME).next() {
|
||||
None => {
|
||||
log::error!("Missing Common Name in CSR!");
|
||||
return Ok(HttpResponse::BadRequest().json("Missing Common Name in CSR!"));
|
||||
}
|
||||
Some(cn) => cn.data().as_utf8()?.to_string(),
|
||||
};
|
||||
|
||||
if !lazy_regex::regex!("[a-zA-Z0-9 ]{1,100}").is_match(&cn) {
|
||||
log::error!("Given Common Name is invalid!");
|
||||
return Ok(HttpResponse::BadRequest().json("Invalid Common Name in CSR!"));
|
||||
}
|
||||
|
||||
let device_id = DeviceId(cn);
|
||||
log::info!(
|
||||
"Received enrollment request for device with ID {device_id:?} - {:#?}",
|
||||
req.info
|
||||
);
|
||||
|
||||
if actor
|
||||
.send(energy_actor::CheckDeviceExists(device_id.clone()))
|
||||
.await?
|
||||
{
|
||||
log::error!("Device could not be enrolled: it already exists!");
|
||||
return Ok(
|
||||
HttpResponse::Conflict().json("A device with the same ID has already been enrolled!")
|
||||
);
|
||||
}
|
||||
|
||||
actor
|
||||
.send(energy_actor::EnrollDevice(device_id, req.0.info, csr))
|
||||
.await??;
|
||||
|
||||
Ok(HttpResponse::Accepted().json("Device successfully enrolled"))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ReqWithDevID {
|
||||
id: DeviceId,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(tag = "status")]
|
||||
enum EnrollmentDeviceStatus {
|
||||
Unknown,
|
||||
Pending,
|
||||
Validated,
|
||||
}
|
||||
|
||||
/// Check device enrollment status
|
||||
pub async fn enrollment_status(
|
||||
query: web::Query<ReqWithDevID>,
|
||||
actor: WebEnergyActor,
|
||||
) -> HttpResult {
|
||||
let dev = actor
|
||||
.send(energy_actor::GetSingleDevice(query.id.clone()))
|
||||
.await?;
|
||||
|
||||
let status = match dev {
|
||||
None => EnrollmentDeviceStatus::Unknown,
|
||||
Some(d) if d.validated => EnrollmentDeviceStatus::Validated,
|
||||
_ => EnrollmentDeviceStatus::Pending,
|
||||
};
|
||||
|
||||
Ok(HttpResponse::Ok().json(status))
|
||||
}
|
||||
|
||||
/// Get device certificate
|
||||
pub async fn get_certificate(query: web::Query<ReqWithDevID>, actor: WebEnergyActor) -> HttpResult {
|
||||
let dev = actor
|
||||
.send(energy_actor::GetSingleDevice(query.id.clone()))
|
||||
.await?;
|
||||
|
||||
let dev = match dev {
|
||||
Some(d) if d.validated => d,
|
||||
_ => {
|
||||
log::error!("Device attempted to retrieve an unavailable certificate!");
|
||||
return Ok(HttpResponse::UnprocessableEntity().json("Certificate not available yet!"));
|
||||
}
|
||||
};
|
||||
|
||||
let cert = std::fs::read(AppConfig::get().device_cert_path(&dev.id))?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/x-pem-file")
|
||||
.body(cert))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SyncRequest {
|
||||
payload: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
struct Claims {
|
||||
info: DeviceInfo,
|
||||
}
|
||||
|
||||
/// Synchronize device
|
||||
pub async fn sync_device(body: web::Json<SyncRequest>, actor: WebEnergyActor) -> HttpResult {
|
||||
// First, we need to extract device kid from query
|
||||
let Ok(jwt_header) = jsonwebtoken::decode_header(&body.payload) else {
|
||||
log::error!("Failed to decode JWT header!");
|
||||
return Ok(HttpResponse::BadRequest().json("Failed to decode JWT header!"));
|
||||
};
|
||||
|
||||
let Some(kid) = jwt_header.kid else {
|
||||
log::error!("Missing KID in JWT!");
|
||||
return Ok(HttpResponse::BadRequest().json("Missing KID in JWT!"));
|
||||
};
|
||||
|
||||
// Fetch device information
|
||||
let Some(device) = actor
|
||||
.send(energy_actor::GetSingleDevice(DeviceId(kid)))
|
||||
.await?
|
||||
else {
|
||||
log::error!("Sent a JWT for a device which does not exists!");
|
||||
return Ok(HttpResponse::NotFound().json("Sent a JWT for a device which does not exists!"));
|
||||
};
|
||||
|
||||
if !device.validated {
|
||||
log::error!("Sent a JWT for a device which is not validated!");
|
||||
return Ok(HttpResponse::PreconditionFailed()
|
||||
.json("Sent a JWT for a device which is not validated!"));
|
||||
}
|
||||
|
||||
// Check certificate revocation status
|
||||
let cert_bytes = std::fs::read(AppConfig::get().device_cert_path(&device.id))?;
|
||||
let certificate = X509::from_pem(&cert_bytes)?;
|
||||
|
||||
if pki::CertData::load_devices_ca()?.is_revoked(&certificate)? {
|
||||
log::error!("Sent a JWT using a revoked certificate!");
|
||||
return Ok(
|
||||
HttpResponse::PreconditionFailed().json("Sent a JWT using a revoked certificate!")
|
||||
);
|
||||
}
|
||||
|
||||
let (key, alg) = match DecodingKey::from_ec_pem(&cert_bytes) {
|
||||
Ok(key) => (key, Algorithm::ES256),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to decode certificate as EC certificate {e}, trying RSA...");
|
||||
(
|
||||
DecodingKey::from_rsa_pem(&cert_bytes).expect("Failed to decode RSA certificate"),
|
||||
Algorithm::RS256,
|
||||
)
|
||||
}
|
||||
};
|
||||
let mut validation = Validation::new(alg);
|
||||
validation.validate_exp = false;
|
||||
validation.required_spec_claims = HashSet::default();
|
||||
|
||||
let c = match jsonwebtoken::decode::<Claims>(&body.payload, &key, &validation) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::error!("Failed to validate JWT! {e}");
|
||||
return Ok(HttpResponse::PreconditionFailed().json("Failed to validate JWT!"));
|
||||
}
|
||||
};
|
||||
|
||||
let res = actor
|
||||
.send(energy_actor::SynchronizeDevice(device.id, c.claims.info))
|
||||
.await??;
|
||||
|
||||
Ok(HttpResponse::Ok().json(res))
|
||||
}
|
2
central_backend/src/server/devices_api/mod.rs
Normal file
2
central_backend/src/server/devices_api/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod mgmt_controller;
|
||||
pub mod utils_controller;
|
15
central_backend/src/server/devices_api/utils_controller.rs
Normal file
15
central_backend/src/server/devices_api/utils_controller.rs
Normal file
@ -0,0 +1,15 @@
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use crate::utils::time_utils::time_millis;
|
||||
use actix_web::HttpResponse;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct CurrTime {
|
||||
time_ms: u128,
|
||||
}
|
||||
|
||||
/// Get current time
|
||||
pub async fn curr_time() -> HttpResult {
|
||||
Ok(HttpResponse::Ok().json(CurrTime {
|
||||
time_ms: time_millis(),
|
||||
}))
|
||||
}
|
13
central_backend/src/server/mod.rs
Normal file
13
central_backend/src/server/mod.rs
Normal file
@ -0,0 +1,13 @@
|
||||
use actix_web::web;
|
||||
|
||||
use crate::energy::energy_actor::EnergyActorAddr;
|
||||
|
||||
pub mod auth_middleware;
|
||||
pub mod custom_error;
|
||||
pub mod devices_api;
|
||||
pub mod servers;
|
||||
pub mod unsecure_server;
|
||||
pub mod web_api;
|
||||
pub mod web_app_controller;
|
||||
|
||||
pub type WebEnergyActor = web::Data<EnergyActorAddr>;
|
222
central_backend/src/server/servers.rs
Normal file
222
central_backend/src/server/servers.rs
Normal file
@ -0,0 +1,222 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::constants;
|
||||
use crate::crypto::pki;
|
||||
use crate::energy::energy_actor::EnergyActorAddr;
|
||||
use crate::server::auth_middleware::AuthChecker;
|
||||
use crate::server::devices_api::{mgmt_controller, utils_controller};
|
||||
use crate::server::unsecure_server::*;
|
||||
use crate::server::web_api::*;
|
||||
use crate::server::web_app_controller;
|
||||
use actix_cors::Cors;
|
||||
use actix_identity::config::LogoutBehaviour;
|
||||
use actix_identity::IdentityMiddleware;
|
||||
use actix_remote_ip::RemoteIPConfig;
|
||||
use actix_session::storage::CookieSessionStore;
|
||||
use actix_session::SessionMiddleware;
|
||||
use actix_web::cookie::{Key, SameSite};
|
||||
use actix_web::middleware::Logger;
|
||||
use actix_web::{web, App, HttpServer};
|
||||
use openssl::ssl::{SslAcceptor, SslMethod};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Start unsecure (HTTP) server
|
||||
pub async fn unsecure_server() -> anyhow::Result<()> {
|
||||
log::info!(
|
||||
"Unsecure server starting to listen on {} for {}",
|
||||
AppConfig::get().unsecure_listen_address,
|
||||
AppConfig::get().unsecure_origin()
|
||||
);
|
||||
HttpServer::new(|| {
|
||||
App::new()
|
||||
.wrap(Logger::default())
|
||||
.route(
|
||||
"/",
|
||||
web::get().to(unsecure_server_controller::unsecure_home),
|
||||
)
|
||||
.route(
|
||||
"/secure_origin",
|
||||
web::get().to(unsecure_server_controller::secure_origin),
|
||||
)
|
||||
.route(
|
||||
"/pki/{file}",
|
||||
web::get().to(unsecure_pki_controller::serve_pki_file),
|
||||
)
|
||||
})
|
||||
.bind(&AppConfig::get().unsecure_listen_address)?
|
||||
.run()
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start secure (HTTPS) server
|
||||
pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()> {
|
||||
let web_ca = pki::CertData::load_web_ca()?;
|
||||
let server_cert = pki::CertData::load_server()?;
|
||||
|
||||
let mut builder = SslAcceptor::mozilla_intermediate(SslMethod::tls()).unwrap();
|
||||
builder.set_private_key(&server_cert.key)?;
|
||||
builder.set_certificate(&server_cert.cert)?;
|
||||
builder.add_extra_chain_cert(web_ca.cert)?;
|
||||
|
||||
log::info!(
|
||||
"Secure server starting to listen on {} for {}",
|
||||
AppConfig::get().listen_address,
|
||||
AppConfig::get().secure_origin()
|
||||
);
|
||||
HttpServer::new(move || {
|
||||
let session_mw = SessionMiddleware::builder(
|
||||
CookieSessionStore::default(),
|
||||
Key::from(AppConfig::get().secret().as_bytes()),
|
||||
)
|
||||
.cookie_name(constants::SESSION_COOKIE_NAME.to_string())
|
||||
.cookie_secure(AppConfig::get().cookie_secure)
|
||||
.cookie_same_site(SameSite::Strict)
|
||||
.cookie_domain(AppConfig::get().cookie_domain())
|
||||
.cookie_http_only(true)
|
||||
.build();
|
||||
|
||||
let identity_middleware = IdentityMiddleware::builder()
|
||||
.logout_behaviour(LogoutBehaviour::PurgeSession)
|
||||
.visit_deadline(Some(Duration::from_secs(
|
||||
constants::MAX_INACTIVITY_DURATION,
|
||||
)))
|
||||
.login_deadline(Some(Duration::from_secs(constants::MAX_SESSION_DURATION)))
|
||||
.build();
|
||||
|
||||
let mut cors = Cors::default()
|
||||
.allowed_origin(&AppConfig::get().secure_origin())
|
||||
.allowed_methods(vec!["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
|
||||
.allowed_header("X-Auth-Token")
|
||||
.allow_any_header()
|
||||
.supports_credentials()
|
||||
.max_age(3600);
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
cors = cors.allow_any_origin();
|
||||
}
|
||||
|
||||
App::new()
|
||||
.app_data(web::Data::new(energy_actor.clone()))
|
||||
.wrap(Logger::default())
|
||||
.wrap(AuthChecker)
|
||||
.wrap(identity_middleware)
|
||||
.wrap(session_mw)
|
||||
.wrap(cors)
|
||||
.app_data(web::Data::new(RemoteIPConfig {
|
||||
proxy: AppConfig::get().proxy_ip.clone(),
|
||||
}))
|
||||
//.route("/", web::get().to(server_controller::secure_home))
|
||||
// Web API
|
||||
// Server controller
|
||||
.route(
|
||||
"/web_api/server/config",
|
||||
web::get().to(server_controller::config),
|
||||
)
|
||||
// Auth controller
|
||||
.route(
|
||||
"/web_api/auth/password_auth",
|
||||
web::post().to(auth_controller::password_auth),
|
||||
)
|
||||
.route(
|
||||
"/web_api/auth/info",
|
||||
web::get().to(auth_controller::auth_info),
|
||||
)
|
||||
.route(
|
||||
"/web_api/auth/sign_out",
|
||||
web::get().to(auth_controller::sign_out),
|
||||
)
|
||||
// Energy controller
|
||||
.route(
|
||||
"/web_api/energy/curr_consumption",
|
||||
web::get().to(energy_controller::curr_consumption),
|
||||
)
|
||||
.route(
|
||||
"/web_api/energy/cached_consumption",
|
||||
web::get().to(energy_controller::cached_consumption),
|
||||
)
|
||||
// Devices controller
|
||||
.route(
|
||||
"/web_api/devices/list_pending",
|
||||
web::get().to(devices_controller::list_pending),
|
||||
)
|
||||
.route(
|
||||
"/web_api/devices/list_validated",
|
||||
web::get().to(devices_controller::list_validated),
|
||||
)
|
||||
.route(
|
||||
"/web_api/devices/state",
|
||||
web::get().to(devices_controller::devices_state),
|
||||
)
|
||||
.route(
|
||||
"/web_api/device/{id}",
|
||||
web::get().to(devices_controller::get_single),
|
||||
)
|
||||
.route(
|
||||
"/web_api/device/{id}/state",
|
||||
web::get().to(devices_controller::state_single),
|
||||
)
|
||||
.route(
|
||||
"/web_api/device/{id}/validate",
|
||||
web::post().to(devices_controller::validate_device),
|
||||
)
|
||||
.route(
|
||||
"/web_api/device/{id}",
|
||||
web::patch().to(devices_controller::update_device),
|
||||
)
|
||||
.route(
|
||||
"/web_api/device/{id}",
|
||||
web::delete().to(devices_controller::delete_device),
|
||||
)
|
||||
// Relays API
|
||||
.route(
|
||||
"/web_api/relays/list",
|
||||
web::get().to(relays_controller::get_list),
|
||||
)
|
||||
.route(
|
||||
"/web_api/relay/create",
|
||||
web::post().to(relays_controller::create),
|
||||
)
|
||||
.route(
|
||||
"/web_api/relay/{id}",
|
||||
web::put().to(relays_controller::update),
|
||||
)
|
||||
.route(
|
||||
"/web_api/relay/{id}",
|
||||
web::delete().to(relays_controller::delete),
|
||||
)
|
||||
// Devices API
|
||||
.route(
|
||||
"/devices_api/utils/time",
|
||||
web::get().to(utils_controller::curr_time),
|
||||
)
|
||||
.route(
|
||||
"/devices_api/mgmt/enroll",
|
||||
web::post().to(mgmt_controller::enroll),
|
||||
)
|
||||
.route(
|
||||
"/devices_api/mgmt/enrollment_status",
|
||||
web::get().to(mgmt_controller::enrollment_status),
|
||||
)
|
||||
.route(
|
||||
"/devices_api/mgmt/get_certificate",
|
||||
web::get().to(mgmt_controller::get_certificate),
|
||||
)
|
||||
.route(
|
||||
"/devices_api/mgmt/sync",
|
||||
web::post().to(mgmt_controller::sync_device),
|
||||
)
|
||||
// Web app
|
||||
.route("/", web::get().to(web_app_controller::root_index))
|
||||
.route(
|
||||
"/assets/{tail:.*}",
|
||||
web::get().to(web_app_controller::serve_assets_content),
|
||||
)
|
||||
.route("/{tail:.*}", web::get().to(web_app_controller::root_index))
|
||||
})
|
||||
.bind_openssl(&AppConfig::get().listen_address, builder)?
|
||||
.run()
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
2
central_backend/src/server/unsecure_server/mod.rs
Normal file
2
central_backend/src/server/unsecure_server/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod unsecure_pki_controller;
|
||||
pub mod unsecure_server_controller;
|
@ -0,0 +1,32 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use actix_web::{web, HttpResponse};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ServeCRLPath {
|
||||
file: String,
|
||||
}
|
||||
|
||||
/// Serve PKI files (unsecure server)
|
||||
pub async fn serve_pki_file(path: web::Path<ServeCRLPath>) -> HttpResult {
|
||||
for f in std::fs::read_dir(AppConfig::get().pki_path())? {
|
||||
let f = f?;
|
||||
let file_name = f.file_name().to_string_lossy().to_string();
|
||||
if !file_name.ends_with(".crl") && !file_name.ends_with(".crt") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if file_name != path.file {
|
||||
continue;
|
||||
}
|
||||
|
||||
let crl = std::fs::read(f.path())?;
|
||||
return Ok(HttpResponse::Ok()
|
||||
.content_type("application/x-pem-file")
|
||||
.body(crl));
|
||||
}
|
||||
|
||||
Ok(HttpResponse::NotFound()
|
||||
.content_type("text/plain")
|
||||
.body("file not found!"))
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use actix_web::HttpResponse;
|
||||
|
||||
pub async fn unsecure_home() -> HttpResponse {
|
||||
HttpResponse::Ok()
|
||||
.content_type("text/plain")
|
||||
.body("SolarEnergy unsecure central backend")
|
||||
}
|
||||
|
||||
pub async fn secure_origin() -> HttpResponse {
|
||||
HttpResponse::Ok().body(AppConfig::get().secure_origin())
|
||||
}
|
52
central_backend/src/server/web_api/auth_controller.rs
Normal file
52
central_backend/src/server/web_api/auth_controller.rs
Normal file
@ -0,0 +1,52 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use actix_identity::Identity;
|
||||
use actix_remote_ip::RemoteIP;
|
||||
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct AuthRequest {
|
||||
user: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
/// Perform password authentication
|
||||
pub async fn password_auth(
|
||||
r: web::Json<AuthRequest>,
|
||||
request: HttpRequest,
|
||||
remote_ip: RemoteIP,
|
||||
) -> HttpResult {
|
||||
if r.user != AppConfig::get().admin_username || r.password != AppConfig::get().admin_password {
|
||||
log::error!("Failed login attempt from {}!", remote_ip.0.to_string());
|
||||
return Ok(HttpResponse::Unauthorized().json("Invalid credentials!"));
|
||||
}
|
||||
|
||||
log::info!("Successful login attempt from {}!", remote_ip.0.to_string());
|
||||
Identity::login(&request.extensions(), r.user.to_string())?;
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct AuthInfo {
|
||||
id: String,
|
||||
}
|
||||
|
||||
/// Get current user information
|
||||
pub async fn auth_info(id: Option<Identity>) -> HttpResult {
|
||||
if AppConfig::get().unsecure_disable_login {
|
||||
return Ok(HttpResponse::Ok().json(AuthInfo {
|
||||
id: "auto login".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(HttpResponse::Ok().json(AuthInfo {
|
||||
id: id.unwrap().id()?,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Sign out user
|
||||
pub async fn sign_out(id: Identity) -> HttpResult {
|
||||
id.logout();
|
||||
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
102
central_backend/src/server/web_api/devices_controller.rs
Normal file
102
central_backend/src/server/web_api/devices_controller.rs
Normal file
@ -0,0 +1,102 @@
|
||||
use crate::devices::device::{DeviceGeneralInfo, DeviceId};
|
||||
use crate::energy::energy_actor;
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use crate::server::WebEnergyActor;
|
||||
use actix_web::{web, HttpResponse};
|
||||
|
||||
/// Get the list of pending (not accepted yet) devices
|
||||
pub async fn list_pending(actor: WebEnergyActor) -> HttpResult {
|
||||
let list = actor
|
||||
.send(energy_actor::GetDeviceLists)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|d| !d.validated)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(HttpResponse::Ok().json(list))
|
||||
}
|
||||
|
||||
/// Get the list of validated (not accepted yet) devices
|
||||
pub async fn list_validated(actor: WebEnergyActor) -> HttpResult {
|
||||
let list = actor
|
||||
.send(energy_actor::GetDeviceLists)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|d| d.validated)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(HttpResponse::Ok().json(list))
|
||||
}
|
||||
|
||||
/// Get the state of devices
|
||||
pub async fn devices_state(actor: WebEnergyActor) -> HttpResult {
|
||||
let states = actor.send(energy_actor::GetDevicesState).await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(states))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct DeviceInPath {
|
||||
id: DeviceId,
|
||||
}
|
||||
|
||||
/// Get a single device information
|
||||
pub async fn get_single(actor: WebEnergyActor, id: web::Path<DeviceInPath>) -> HttpResult {
|
||||
let Some(dev) = actor
|
||||
.send(energy_actor::GetSingleDevice(id.id.clone()))
|
||||
.await?
|
||||
else {
|
||||
return Ok(HttpResponse::NotFound().json("Requested device was not found!"));
|
||||
};
|
||||
|
||||
Ok(HttpResponse::Ok().json(dev))
|
||||
}
|
||||
|
||||
/// Get a single device state
|
||||
pub async fn state_single(actor: WebEnergyActor, id: web::Path<DeviceInPath>) -> HttpResult {
|
||||
let states = actor.send(energy_actor::GetDevicesState).await?;
|
||||
|
||||
let Some(state) = states.into_iter().find(|s| s.id == id.id) else {
|
||||
return Ok(HttpResponse::NotFound().body("Requested device not found!"));
|
||||
};
|
||||
|
||||
Ok(HttpResponse::Ok().json(state))
|
||||
}
|
||||
|
||||
/// Validate a device
|
||||
pub async fn validate_device(actor: WebEnergyActor, id: web::Path<DeviceInPath>) -> HttpResult {
|
||||
actor
|
||||
.send(energy_actor::ValidateDevice(id.id.clone()))
|
||||
.await??;
|
||||
|
||||
Ok(HttpResponse::Accepted().finish())
|
||||
}
|
||||
|
||||
/// Update a device information
|
||||
pub async fn update_device(
|
||||
actor: WebEnergyActor,
|
||||
id: web::Path<DeviceInPath>,
|
||||
update: web::Json<DeviceGeneralInfo>,
|
||||
) -> HttpResult {
|
||||
if let Some(e) = update.error() {
|
||||
return Ok(HttpResponse::BadRequest().json(e));
|
||||
}
|
||||
|
||||
actor
|
||||
.send(energy_actor::UpdateDeviceGeneralInfo(
|
||||
id.id.clone(),
|
||||
update.0.clone(),
|
||||
))
|
||||
.await??;
|
||||
|
||||
Ok(HttpResponse::Accepted().finish())
|
||||
}
|
||||
|
||||
/// Delete a device
|
||||
pub async fn delete_device(actor: WebEnergyActor, id: web::Path<DeviceInPath>) -> HttpResult {
|
||||
actor
|
||||
.send(energy_actor::DeleteDevice(id.id.clone()))
|
||||
.await??;
|
||||
|
||||
Ok(HttpResponse::Accepted().finish())
|
||||
}
|
23
central_backend/src/server/web_api/energy_controller.rs
Normal file
23
central_backend/src/server/web_api/energy_controller.rs
Normal file
@ -0,0 +1,23 @@
|
||||
use crate::energy::{consumption, energy_actor};
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use crate::server::WebEnergyActor;
|
||||
use actix_web::HttpResponse;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct Consumption {
|
||||
consumption: i32,
|
||||
}
|
||||
|
||||
/// Get current energy consumption
|
||||
pub async fn curr_consumption() -> HttpResult {
|
||||
let consumption = consumption::get_curr_consumption().await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(Consumption { consumption }))
|
||||
}
|
||||
|
||||
/// Get cached energy consumption
|
||||
pub async fn cached_consumption(energy_actor: WebEnergyActor) -> HttpResult {
|
||||
let consumption = energy_actor.send(energy_actor::GetCurrConsumption).await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(Consumption { consumption }))
|
||||
}
|
5
central_backend/src/server/web_api/mod.rs
Normal file
5
central_backend/src/server/web_api/mod.rs
Normal file
@ -0,0 +1,5 @@
|
||||
pub mod auth_controller;
|
||||
pub mod devices_controller;
|
||||
pub mod energy_controller;
|
||||
pub mod relays_controller;
|
||||
pub mod server_controller;
|
95
central_backend/src/server/web_api/relays_controller.rs
Normal file
95
central_backend/src/server/web_api/relays_controller.rs
Normal file
@ -0,0 +1,95 @@
|
||||
use crate::devices::device::{DeviceId, DeviceRelay, DeviceRelayID};
|
||||
use crate::energy::energy_actor;
|
||||
use crate::server::custom_error::HttpResult;
|
||||
use crate::server::WebEnergyActor;
|
||||
use actix_web::{web, HttpResponse};
|
||||
|
||||
/// Get the full list of relays
|
||||
pub async fn get_list(actor: WebEnergyActor) -> HttpResult {
|
||||
let list = actor.send(energy_actor::GetRelaysList).await?;
|
||||
Ok(HttpResponse::Ok().json(list))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CreateDeviceRelayRequest {
|
||||
device_id: DeviceId,
|
||||
#[serde(flatten)]
|
||||
relay: DeviceRelay,
|
||||
}
|
||||
|
||||
/// Create a new relay
|
||||
pub async fn create(actor: WebEnergyActor, req: web::Json<CreateDeviceRelayRequest>) -> HttpResult {
|
||||
let list = actor.send(energy_actor::GetRelaysList).await?;
|
||||
|
||||
if let Some(e) = req.relay.error(&list) {
|
||||
log::error!("Invalid relay create query: {e}");
|
||||
return Ok(HttpResponse::BadRequest().json(e));
|
||||
}
|
||||
|
||||
let Some(device) = actor
|
||||
.send(energy_actor::GetSingleDevice(req.device_id.clone()))
|
||||
.await?
|
||||
else {
|
||||
log::error!("Invalid relay create query: specified device does not exists!");
|
||||
return Ok(HttpResponse::NotFound().json("Linked device not found!"));
|
||||
};
|
||||
|
||||
if device.relays.len() >= device.info.max_relays {
|
||||
log::error!("Invalid relay create query: too many relay for the target device!");
|
||||
return Ok(HttpResponse::BadRequest().json("Too many relays for the target device!"));
|
||||
}
|
||||
|
||||
if actor
|
||||
.send(energy_actor::GetSingleRelay(req.relay.id))
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
log::error!("Invalid relay create query: A relay with the same ID already exists!");
|
||||
return Ok(HttpResponse::BadRequest().json("A relay with the same ID already exists!"));
|
||||
}
|
||||
|
||||
// Create the device relay
|
||||
actor
|
||||
.send(energy_actor::CreateDeviceRelay(
|
||||
req.device_id.clone(),
|
||||
req.relay.clone(),
|
||||
))
|
||||
.await??;
|
||||
|
||||
Ok(HttpResponse::Accepted().finish())
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct RelayIDInPath {
|
||||
id: DeviceRelayID,
|
||||
}
|
||||
|
||||
/// Update a relay configuration
|
||||
pub async fn update(
|
||||
actor: WebEnergyActor,
|
||||
mut req: web::Json<DeviceRelay>,
|
||||
path: web::Path<RelayIDInPath>,
|
||||
) -> HttpResult {
|
||||
req.id = path.id;
|
||||
|
||||
let list = actor.send(energy_actor::GetRelaysList).await?;
|
||||
|
||||
if let Some(e) = req.error(&list) {
|
||||
log::error!("Invalid relay update query: {e}");
|
||||
return Ok(HttpResponse::BadRequest().json(e));
|
||||
}
|
||||
|
||||
// Create the device relay
|
||||
actor.send(energy_actor::UpdateDeviceRelay(req.0)).await??;
|
||||
|
||||
Ok(HttpResponse::Accepted().finish())
|
||||
}
|
||||
|
||||
/// Delete an existing relay
|
||||
pub async fn delete(actor: WebEnergyActor, path: web::Path<RelayIDInPath>) -> HttpResult {
|
||||
actor
|
||||
.send(energy_actor::DeleteDeviceRelay(path.id))
|
||||
.await??;
|
||||
|
||||
Ok(HttpResponse::Accepted().finish())
|
||||
}
|
28
central_backend/src/server/web_api/server_controller.rs
Normal file
28
central_backend/src/server/web_api/server_controller.rs
Normal file
@ -0,0 +1,28 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::constants::StaticConstraints;
|
||||
use actix_web::HttpResponse;
|
||||
|
||||
pub async fn secure_home() -> HttpResponse {
|
||||
HttpResponse::Ok()
|
||||
.content_type("text/plain")
|
||||
.body("SolarEnergy secure central backend")
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ServerConfig {
|
||||
auth_disabled: bool,
|
||||
constraints: StaticConstraints,
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auth_disabled: AppConfig::get().unsecure_disable_login,
|
||||
constraints: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn config() -> HttpResponse {
|
||||
HttpResponse::Ok().json(ServerConfig::default())
|
||||
}
|
46
central_backend/src/server/web_app_controller.rs
Normal file
46
central_backend/src/server/web_app_controller.rs
Normal file
@ -0,0 +1,46 @@
|
||||
#[cfg(debug_assertions)]
|
||||
pub use serve_static_debug::{root_index, serve_assets_content};
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub use serve_static_release::{root_index, serve_assets_content};
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
mod serve_static_debug {
|
||||
use actix_web::{HttpResponse, Responder};
|
||||
|
||||
pub async fn root_index() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.body("Solar energy secure home: Hello world! Debug=on for Solar platform!")
|
||||
}
|
||||
|
||||
pub async fn serve_assets_content() -> impl Responder {
|
||||
HttpResponse::NotFound().body("Hello world! Static assets are not served in debug mode")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
mod serve_static_release {
|
||||
use actix_web::{web, HttpResponse, Responder};
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "static/"]
|
||||
struct Asset;
|
||||
|
||||
fn handle_embedded_file(path: &str, can_fallback: bool) -> HttpResponse {
|
||||
match (Asset::get(path), can_fallback) {
|
||||
(Some(content), _) => HttpResponse::Ok()
|
||||
.content_type(mime_guess::from_path(path).first_or_octet_stream().as_ref())
|
||||
.body(content.data.into_owned()),
|
||||
(None, false) => HttpResponse::NotFound().body("404 Not Found"),
|
||||
(None, true) => handle_embedded_file("index.html", false),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn root_index() -> impl Responder {
|
||||
handle_embedded_file("index.html", false)
|
||||
}
|
||||
|
||||
pub async fn serve_assets_content(path: web::Path<String>) -> impl Responder {
|
||||
handle_embedded_file(&format!("assets/{}", path.as_ref()), false)
|
||||
}
|
||||
}
|
@ -1 +1,2 @@
|
||||
pub mod files_utils;
|
||||
pub mod time_utils;
|
||||
|
50
central_backend/src/utils/time_utils.rs
Normal file
50
central_backend/src/utils/time_utils.rs
Normal file
@ -0,0 +1,50 @@
|
||||
use chrono::prelude::*;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Get the current time since epoch
|
||||
pub fn time_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
/// Get the current time since epoch
|
||||
pub fn time_millis() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis()
|
||||
}
|
||||
|
||||
/// Get the number of the day since 01-01-1970 of a given UNIX timestamp (UTC)
|
||||
pub fn day_number(time: u64) -> u64 {
|
||||
time / (3600 * 24)
|
||||
}
|
||||
|
||||
/// Get current hour, 00 => 23 (local time)
|
||||
pub fn curr_hour() -> u32 {
|
||||
let local: DateTime<Local> = Local::now();
|
||||
local.hour()
|
||||
}
|
||||
|
||||
/// Get the first second of the day (local time)
|
||||
pub fn time_start_of_day() -> anyhow::Result<u64> {
|
||||
let local: DateTime<Local> = Local::now()
|
||||
.with_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
|
||||
.unwrap();
|
||||
Ok(local.timestamp() as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::utils::time_utils::day_number;
|
||||
|
||||
#[test]
|
||||
fn test_time_of_day() {
|
||||
assert_eq!(day_number(500), 0);
|
||||
assert_eq!(day_number(1726592301), 19983);
|
||||
assert_eq!(day_number(1726592401), 19983);
|
||||
assert_eq!(day_number(1726498701), 19982);
|
||||
}
|
||||
}
|
1
central_frontend/.env
Normal file
1
central_frontend/.env
Normal file
@ -0,0 +1 @@
|
||||
VITE_APP_BACKEND=https://localhost:8443/web_api
|
1
central_frontend/.env.production
Normal file
1
central_frontend/.env.production
Normal file
@ -0,0 +1 @@
|
||||
VITE_APP_BACKEND=/web_api
|
18
central_frontend/.eslintrc.cjs
Normal file
18
central_frontend/.eslintrc.cjs
Normal file
@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
24
central_frontend/.gitignore
vendored
Normal file
24
central_frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
30
central_frontend/README.md
Normal file
30
central_frontend/README.md
Normal file
@ -0,0 +1,30 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default {
|
||||
// other rules...
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
13
central_frontend/index.html
Normal file
13
central_frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/sunny.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SolarEnergy</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
4467
central_frontend/package-lock.json
generated
Normal file
4467
central_frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
central_frontend/package.json
Normal file
40
central_frontend/package.json
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "central_frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@fontsource/roboto": "^5.0.14",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@mdi/react": "^1.6.1",
|
||||
"@mui/icons-material": "^6.0.1",
|
||||
"@mui/material": "^6.0.1",
|
||||
"@mui/x-charts": "^7.15.0",
|
||||
"@mui/x-date-pickers": "^7.15.0",
|
||||
"date-and-time": "^3.5.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
||||
"@typescript-eslint/parser": "^8.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.11",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
BIN
central_frontend/public/sun.jpg
Normal file
BIN
central_frontend/public/sun.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 407 KiB |
1
central_frontend/public/sunny.svg
Normal file
1
central_frontend/public/sunny.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M440-760v-160h80v160h-80Zm266 110-55-55 112-115 56 57-113 113Zm54 210v-80h160v80H760ZM440-40v-160h80v160h-80ZM254-652 140-763l57-56 113 113-56 54Zm508 512L651-255l54-54 114 110-57 59ZM40-440v-80h160v80H40Zm157 300-56-57 112-112 29 27 29 28-114 114Zm283-100q-100 0-170-70t-70-170q0-100 70-170t170-70q100 0 170 70t70 170q0 100-70 170t-170 70Zm0-80q66 0 113-47t47-113q0-66-47-113t-113-47q-66 0-113 47t-47 113q0 66 47 113t113 47Zm0-160Z"/></svg>
|
After Width: | Height: | Size: 557 B |
36
central_frontend/src/App.tsx
Normal file
36
central_frontend/src/App.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import {
|
||||
Route,
|
||||
RouterProvider,
|
||||
createBrowserRouter,
|
||||
createRoutesFromElements,
|
||||
} from "react-router-dom";
|
||||
import { AuthApi } from "./api/AuthApi";
|
||||
import { ServerApi } from "./api/ServerApi";
|
||||
import { DeviceRoute } from "./routes/DeviceRoute/DeviceRoute";
|
||||
import { DevicesRoute } from "./routes/DevicesRoute";
|
||||
import { HomeRoute } from "./routes/HomeRoute";
|
||||
import { LoginRoute } from "./routes/LoginRoute";
|
||||
import { NotFoundRoute } from "./routes/NotFoundRoute";
|
||||
import { PendingDevicesRoute } from "./routes/PendingDevicesRoute";
|
||||
import { RelaysListRoute } from "./routes/RelaysListRoute";
|
||||
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
|
||||
|
||||
export function App() {
|
||||
if (!AuthApi.SignedIn && !ServerApi.Config.auth_disabled)
|
||||
return <LoginRoute />;
|
||||
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<Route path="*" element={<BaseAuthenticatedPage />}>
|
||||
<Route path="" element={<HomeRoute />} />
|
||||
<Route path="pending_devices" element={<PendingDevicesRoute />} />
|
||||
<Route path="devices" element={<DevicesRoute />} />
|
||||
<Route path="dev/:id" element={<DeviceRoute />} />
|
||||
<Route path="relays" element={<RelaysListRoute />} />
|
||||
<Route path="*" element={<NotFoundRoute />} />
|
||||
</Route>
|
||||
)
|
||||
);
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
177
central_frontend/src/api/ApiClient.ts
Normal file
177
central_frontend/src/api/ApiClient.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import { AuthApi } from "./AuthApi";
|
||||
|
||||
interface RequestParams {
|
||||
uri: string;
|
||||
method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
|
||||
allowFail?: boolean;
|
||||
jsonData?: any;
|
||||
formData?: FormData;
|
||||
upProgress?: (progress: number) => void;
|
||||
downProgress?: (e: { progress: number; total: number }) => void;
|
||||
}
|
||||
|
||||
interface APIResponse {
|
||||
data: any;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message: string, public code: number, public data: any) {
|
||||
super(`HTTP status: ${code}\nMessage: ${message}\nData=${data}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class APIClient {
|
||||
/**
|
||||
* Get backend URL
|
||||
*/
|
||||
static backendURL(): string {
|
||||
const URL = import.meta.env.VITE_APP_BACKEND ?? "";
|
||||
if (URL.length === 0) throw new Error("Backend URL undefined!");
|
||||
return URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check out whether the backend is accessed through
|
||||
* HTTPS or not
|
||||
*/
|
||||
static IsBackendSecure(): boolean {
|
||||
return this.backendURL().startsWith("https");
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a request on the backend
|
||||
*/
|
||||
static async exec(args: RequestParams): Promise<APIResponse> {
|
||||
let body: string | undefined | FormData = undefined;
|
||||
let headers: any = {};
|
||||
|
||||
// JSON request
|
||||
if (args.jsonData) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify(args.jsonData);
|
||||
}
|
||||
|
||||
// Form data request
|
||||
else if (args.formData) {
|
||||
body = args.formData;
|
||||
}
|
||||
|
||||
const url = this.backendURL() + args.uri;
|
||||
|
||||
let data;
|
||||
let status: number;
|
||||
|
||||
// Make the request with XMLHttpRequest
|
||||
if (args.upProgress) {
|
||||
const res: XMLHttpRequest = await new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.upload.addEventListener("progress", (e) =>
|
||||
args.upProgress!(e.loaded / e.total)
|
||||
);
|
||||
xhr.addEventListener("load", () => resolve(xhr));
|
||||
xhr.addEventListener("error", () =>
|
||||
reject(new Error("File upload failed"))
|
||||
);
|
||||
xhr.addEventListener("abort", () =>
|
||||
reject(new Error("File upload aborted"))
|
||||
);
|
||||
xhr.addEventListener("timeout", () =>
|
||||
reject(new Error("File upload timeout"))
|
||||
);
|
||||
xhr.open(args.method, url, true);
|
||||
xhr.withCredentials = true;
|
||||
for (const key in headers) {
|
||||
if (headers.hasOwnProperty(key))
|
||||
xhr.setRequestHeader(key, headers[key]);
|
||||
}
|
||||
xhr.send(body);
|
||||
});
|
||||
|
||||
status = res.status;
|
||||
if (res.responseType === "json") data = JSON.parse(res.responseText);
|
||||
else data = res.response;
|
||||
}
|
||||
|
||||
// Make the request with fetch
|
||||
else {
|
||||
const res = await fetch(url, {
|
||||
method: args.method,
|
||||
body: body,
|
||||
headers: headers,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
// Process response
|
||||
// JSON response
|
||||
if (res.headers.get("content-type") === "application/json")
|
||||
data = await res.json();
|
||||
// Text / XML response
|
||||
else if (
|
||||
["application/xml", "text/plain"].includes(
|
||||
res.headers.get("content-type") ?? ""
|
||||
)
|
||||
)
|
||||
data = await res.text();
|
||||
// Binary file, tracking download progress
|
||||
else if (res.body !== null && args.downProgress) {
|
||||
// Track download progress
|
||||
const contentEncoding = res.headers.get("content-encoding");
|
||||
const contentLength = contentEncoding
|
||||
? null
|
||||
: res.headers.get("content-length");
|
||||
|
||||
const total = parseInt(contentLength ?? "0", 10);
|
||||
let loaded = 0;
|
||||
|
||||
const resInt = new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const reader = res.body!.getReader();
|
||||
|
||||
const read = async () => {
|
||||
try {
|
||||
const ret = await reader.read();
|
||||
if (ret.done) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
loaded += ret.value.byteLength;
|
||||
args.downProgress!({ progress: loaded, total });
|
||||
controller.enqueue(ret.value);
|
||||
read();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
controller.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
read();
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
data = await resInt.blob();
|
||||
}
|
||||
|
||||
// Do not track progress (binary file)
|
||||
else data = await res.blob();
|
||||
|
||||
status = res.status;
|
||||
}
|
||||
|
||||
// Handle expired tokens
|
||||
if (status === 412) {
|
||||
AuthApi.UnsetAuthenticated();
|
||||
window.location.href = "/";
|
||||
}
|
||||
|
||||
if (!args.allowFail && (status < 200 || status > 299))
|
||||
throw new ApiError("Request failed!", status, data);
|
||||
|
||||
return {
|
||||
data: data,
|
||||
status: status,
|
||||
};
|
||||
}
|
||||
}
|
74
central_frontend/src/api/AuthApi.ts
Normal file
74
central_frontend/src/api/AuthApi.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { APIClient } from "./ApiClient";
|
||||
|
||||
export interface AuthInfo {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const TokenStateKey = "auth-state";
|
||||
|
||||
export class AuthApi {
|
||||
/**
|
||||
* Check out whether user is signed in or not
|
||||
*/
|
||||
static get SignedIn(): boolean {
|
||||
return localStorage.getItem(TokenStateKey) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark user as authenticated
|
||||
*/
|
||||
static SetAuthenticated() {
|
||||
localStorage.setItem(TokenStateKey, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Un-mark user as authenticated
|
||||
*/
|
||||
static UnsetAuthenticated() {
|
||||
localStorage.removeItem(TokenStateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using user and password
|
||||
*/
|
||||
static async AuthWithPassword(user: string, password: string): Promise<void> {
|
||||
await APIClient.exec({
|
||||
uri: "/auth/password_auth",
|
||||
method: "POST",
|
||||
jsonData: {
|
||||
user,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
this.SetAuthenticated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth information
|
||||
*/
|
||||
static async GetAuthInfo(): Promise<AuthInfo> {
|
||||
return (
|
||||
await APIClient.exec({
|
||||
uri: "/auth/info",
|
||||
method: "GET",
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
static async SignOut(): Promise<void> {
|
||||
this.UnsetAuthenticated();
|
||||
|
||||
try {
|
||||
await APIClient.exec({
|
||||
uri: "/auth/sign_out",
|
||||
method: "GET",
|
||||
});
|
||||
} finally {
|
||||
window.location.href = "/";
|
||||
}
|
||||
}
|
||||
}
|
155
central_frontend/src/api/DeviceApi.ts
Normal file
155
central_frontend/src/api/DeviceApi.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { APIClient } from "./ApiClient";
|
||||
|
||||
export interface DeviceInfo {
|
||||
reference: string;
|
||||
version: string;
|
||||
max_relays: number;
|
||||
}
|
||||
|
||||
export interface DailyMinRuntime {
|
||||
min_runtime: number;
|
||||
reset_time: number;
|
||||
catch_up_hours: number[];
|
||||
}
|
||||
|
||||
export type RelayID = string;
|
||||
|
||||
export interface DeviceRelay {
|
||||
id: RelayID;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
consumption: number;
|
||||
minimal_uptime: number;
|
||||
minimal_downtime: number;
|
||||
daily_runtime?: DailyMinRuntime;
|
||||
depends_on: RelayID[];
|
||||
conflicts_with: RelayID[];
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
id: string;
|
||||
info: DeviceInfo;
|
||||
time_create: number;
|
||||
time_update: number;
|
||||
name: string;
|
||||
description: string;
|
||||
validated: boolean;
|
||||
enabled: boolean;
|
||||
relays: DeviceRelay[];
|
||||
}
|
||||
|
||||
export interface UpdatedInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface DeviceState {
|
||||
id: string;
|
||||
last_ping: number;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export type DevicesState = Map<string, DeviceState>;
|
||||
|
||||
export function DeviceURL(d: Device): string {
|
||||
return `/dev/${encodeURIComponent(d.id)}`;
|
||||
}
|
||||
|
||||
export class DeviceApi {
|
||||
/**
|
||||
* Get the list of pending devices
|
||||
*/
|
||||
static async PendingList(): Promise<Device[]> {
|
||||
return (
|
||||
await APIClient.exec({
|
||||
uri: "/devices/list_pending",
|
||||
method: "GET",
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of validated devices
|
||||
*/
|
||||
static async ValidatedList(): Promise<Device[]> {
|
||||
return (
|
||||
await APIClient.exec({
|
||||
uri: "/devices/list_validated",
|
||||
method: "GET",
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the state of devices
|
||||
*/
|
||||
static async DevicesState(): Promise<DevicesState> {
|
||||
const devs: DeviceState[] = (
|
||||
await APIClient.exec({
|
||||
uri: "/devices/state",
|
||||
method: "GET",
|
||||
})
|
||||
).data;
|
||||
|
||||
const m = new Map();
|
||||
devs.forEach((d) => m.set(d.id, d));
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a device
|
||||
*/
|
||||
static async Validate(d: Device): Promise<void> {
|
||||
await APIClient.exec({
|
||||
uri: `/device/${encodeURIComponent(d.id)}/validate`,
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the information about a single device
|
||||
*/
|
||||
static async GetSingle(id: string): Promise<Device> {
|
||||
return (
|
||||
await APIClient.exec({
|
||||
uri: `/device/${encodeURIComponent(id)}`,
|
||||
method: "GET",
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current state of a single device
|
||||
*/
|
||||
static async GetSingleState(id: string): Promise<DeviceState> {
|
||||
return (
|
||||
await APIClient.exec({
|
||||
uri: `/device/${encodeURIComponent(id)}/state`,
|
||||
method: "GET",
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a device general information
|
||||
*/
|
||||
static async Update(d: Device, info: UpdatedInfo): Promise<void> {
|
||||
await APIClient.exec({
|
||||
uri: `/device/${encodeURIComponent(d.id)}`,
|
||||
method: "PATCH",
|
||||
jsonData: info,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a device
|
||||
*/
|
||||
static async Delete(d: Device): Promise<void> {
|
||||
await APIClient.exec({
|
||||
uri: `/device/${encodeURIComponent(d.id)}`,
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
}
|
26
central_frontend/src/api/EnergyApi.ts
Normal file
26
central_frontend/src/api/EnergyApi.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Api } from "@mui/icons-material";
|
||||
import { APIClient } from "./ApiClient";
|
||||
|
||||
export class EnergyApi {
|
||||
/**
|
||||
* Get current house consumption
|
||||
*/
|
||||
static async CurrConsumption(): Promise<number> {
|
||||
const data = await APIClient.exec({
|
||||
method: "GET",
|
||||
uri: "/energy/curr_consumption",
|
||||
});
|
||||
return data.data.consumption;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current cached consumption
|
||||
*/
|
||||
static async CachedConsumption(): Promise<number> {
|
||||
const data = await APIClient.exec({
|
||||
method: "GET",
|
||||
uri: "/energy/cached_consumption",
|
||||
});
|
||||
return data.data.consumption;
|
||||
}
|
||||
}
|
52
central_frontend/src/api/RelayApi.ts
Normal file
52
central_frontend/src/api/RelayApi.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { APIClient } from "./ApiClient";
|
||||
import { Device, DeviceRelay } from "./DeviceApi";
|
||||
|
||||
export class RelayApi {
|
||||
/**
|
||||
* Get the full list of relays
|
||||
*/
|
||||
static async GetList(): Promise<DeviceRelay[]> {
|
||||
return (
|
||||
await APIClient.exec({
|
||||
method: "GET",
|
||||
uri: "/relays/list",
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new relay
|
||||
*/
|
||||
static async Create(device: Device, relay: DeviceRelay): Promise<void> {
|
||||
await APIClient.exec({
|
||||
method: "POST",
|
||||
uri: "/relay/create",
|
||||
jsonData: {
|
||||
...relay,
|
||||
id: undefined,
|
||||
device_id: device.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a relay information
|
||||
*/
|
||||
static async Update(relay: DeviceRelay): Promise<void> {
|
||||
await APIClient.exec({
|
||||
method: "PUT",
|
||||
uri: `/relay/${relay.id}`,
|
||||
jsonData: relay,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a relay configuration
|
||||
*/
|
||||
static async Delete(relay: DeviceRelay): Promise<void> {
|
||||
await APIClient.exec({
|
||||
method: "DELETE",
|
||||
uri: `/relay/${relay.id}`,
|
||||
});
|
||||
}
|
||||
}
|
46
central_frontend/src/api/ServerApi.ts
Normal file
46
central_frontend/src/api/ServerApi.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { APIClient } from "./ApiClient";
|
||||
|
||||
export interface ServerConfig {
|
||||
auth_disabled: boolean;
|
||||
constraints: ServerConstraint;
|
||||
}
|
||||
|
||||
export interface ServerConstraint {
|
||||
dev_name_len: LenConstraint;
|
||||
dev_description_len: LenConstraint;
|
||||
relay_name_len: LenConstraint;
|
||||
relay_priority: LenConstraint;
|
||||
relay_consumption: LenConstraint;
|
||||
relay_minimal_uptime: LenConstraint;
|
||||
relay_minimal_downtime: LenConstraint;
|
||||
relay_daily_minimal_runtime: LenConstraint;
|
||||
}
|
||||
|
||||
export interface LenConstraint {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
let config: ServerConfig | null = null;
|
||||
|
||||
export class ServerApi {
|
||||
/**
|
||||
* Get server configuration
|
||||
*/
|
||||
static async LoadConfig(): Promise<void> {
|
||||
config = (
|
||||
await APIClient.exec({
|
||||
uri: "/server/config",
|
||||
method: "GET",
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached configuration
|
||||
*/
|
||||
static get Config(): ServerConfig {
|
||||
if (config === null) throw new Error("Missing configuration!");
|
||||
return config;
|
||||
}
|
||||
}
|
87
central_frontend/src/dialogs/EditDeviceMetadataDialog.tsx
Normal file
87
central_frontend/src/dialogs/EditDeviceMetadataDialog.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { Device, DeviceApi } from "../api/DeviceApi";
|
||||
import { ServerApi } from "../api/ServerApi";
|
||||
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
|
||||
import { lenValid } from "../utils/StringsUtils";
|
||||
import { CheckboxInput } from "../widgets/forms/CheckboxInput";
|
||||
import { TextInput } from "../widgets/forms/TextInput";
|
||||
|
||||
export function EditDeviceMetadataDialog(p: {
|
||||
onClose: () => void;
|
||||
device: Device;
|
||||
onUpdated: () => void;
|
||||
}): React.ReactElement {
|
||||
const loadingMessage = useLoadingMessage();
|
||||
const alert = useAlert();
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const [name, setName] = React.useState(p.device.name);
|
||||
const [description, setDescription] = React.useState(p.device.description);
|
||||
const [enabled, setEnabled] = React.useState(p.device.enabled);
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
loadingMessage.show("Updating device information");
|
||||
await DeviceApi.Update(p.device, {
|
||||
name,
|
||||
description,
|
||||
enabled,
|
||||
});
|
||||
|
||||
snackbar("The device information have been successfully updated!");
|
||||
p.onUpdated();
|
||||
} catch (e) {
|
||||
console.error("Failed to update device general information!" + e);
|
||||
alert(`Failed to update device general information! ${e}`);
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit =
|
||||
lenValid(name, ServerApi.Config.constraints.dev_name_len) &&
|
||||
lenValid(description, ServerApi.Config.constraints.dev_description_len);
|
||||
|
||||
return (
|
||||
<Dialog open>
|
||||
<DialogTitle>Edit device general information</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextInput
|
||||
editable
|
||||
label="Device name"
|
||||
value={name}
|
||||
onValueChange={(s) => setName(s ?? "")}
|
||||
size={ServerApi.Config.constraints.dev_name_len}
|
||||
/>
|
||||
<TextInput
|
||||
editable
|
||||
label="Device description"
|
||||
value={description}
|
||||
onValueChange={(s) => setDescription(s ?? "")}
|
||||
size={ServerApi.Config.constraints.dev_description_len}
|
||||
/>
|
||||
<CheckboxInput
|
||||
editable
|
||||
label="Enable device"
|
||||
checked={enabled}
|
||||
onValueChange={setEnabled}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={p.onClose}>Cancel</Button>
|
||||
<Button onClick={onSubmit} autoFocus disabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
332
central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx
Normal file
332
central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx
Normal file
@ -0,0 +1,332 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import Grid from "@mui/material/Grid2";
|
||||
import { TimePicker } from "@mui/x-date-pickers";
|
||||
import React from "react";
|
||||
import { Device, DeviceRelay } from "../api/DeviceApi";
|
||||
import { RelayApi } from "../api/RelayApi";
|
||||
import { ServerApi } from "../api/ServerApi";
|
||||
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
|
||||
import { dayjsToTimeOfDay, timeOfDay } from "../utils/DateUtils";
|
||||
import { lenValid } from "../utils/StringsUtils";
|
||||
import { CheckboxInput } from "../widgets/forms/CheckboxInput";
|
||||
import { MultipleSelectInput } from "../widgets/forms/MultipleSelectInput";
|
||||
import { SelectMultipleRelaysInput } from "../widgets/forms/SelectMultipleRelaysInput";
|
||||
import { TextInput } from "../widgets/forms/TextInput";
|
||||
|
||||
export function EditDeviceRelaysDialog(p: {
|
||||
onClose: () => void;
|
||||
relay?: DeviceRelay;
|
||||
device: Device;
|
||||
onUpdated: () => void;
|
||||
}): React.ReactElement {
|
||||
const loadingMessage = useLoadingMessage();
|
||||
const alert = useAlert();
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const [relay, setRelay] = React.useState<DeviceRelay>(
|
||||
p.relay ?? {
|
||||
id: "",
|
||||
name: "relay",
|
||||
enabled: true,
|
||||
priority: 1,
|
||||
consumption: 500,
|
||||
minimal_downtime: 60 * 5,
|
||||
minimal_uptime: 60 * 5,
|
||||
depends_on: [],
|
||||
conflicts_with: [],
|
||||
}
|
||||
);
|
||||
|
||||
const creating = !p.relay;
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
loadingMessage.show(
|
||||
`${creating ? "Creating" : "Updating"} relay information`
|
||||
);
|
||||
|
||||
if (creating) await RelayApi.Create(p.device, relay);
|
||||
else await RelayApi.Update(relay);
|
||||
|
||||
snackbar(
|
||||
`The relay have been successfully ${creating ? "created" : "updated"}!`
|
||||
);
|
||||
p.onUpdated();
|
||||
} catch (e) {
|
||||
console.error("Failed to update device relay information!" + e);
|
||||
alert(`Failed to ${creating ? "create" : "update"} relay! ${e}`);
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit =
|
||||
lenValid(relay.name, ServerApi.Config.constraints.relay_name_len) &&
|
||||
relay.priority >= 0;
|
||||
|
||||
return (
|
||||
<Dialog open>
|
||||
<DialogTitle>
|
||||
{creating ? "Create a new relay" : "Edit relay information"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogFormTitle>General info</DialogFormTitle>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<TextInput
|
||||
editable
|
||||
label="Relay name"
|
||||
value={relay.name}
|
||||
onValueChange={(v) =>
|
||||
setRelay((r) => {
|
||||
return {
|
||||
...r,
|
||||
name: v ?? "",
|
||||
};
|
||||
})
|
||||
}
|
||||
size={ServerApi.Config.constraints.dev_name_len}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<CheckboxInput
|
||||
editable
|
||||
label="Enable relay"
|
||||
checked={relay.enabled}
|
||||
onValueChange={(v) =>
|
||||
setRelay((r) => {
|
||||
return {
|
||||
...r,
|
||||
enabled: v,
|
||||
};
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<TextInput
|
||||
editable
|
||||
label="Priority"
|
||||
value={relay.priority.toString()}
|
||||
type="number"
|
||||
onValueChange={(v) =>
|
||||
setRelay((r) => {
|
||||
return {
|
||||
...r,
|
||||
priority: Number(v) ?? 0,
|
||||
};
|
||||
})
|
||||
}
|
||||
size={ServerApi.Config.constraints.relay_priority}
|
||||
helperText="Relay priority when selecting relays to turn on. 0 = lowest priority"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<TextInput
|
||||
editable
|
||||
label="Consumption"
|
||||
value={relay.consumption.toString()}
|
||||
type="number"
|
||||
onValueChange={(v) =>
|
||||
setRelay((r) => {
|
||||
return {
|
||||
...r,
|
||||
consumption: Number(v) ?? 0,
|
||||
};
|
||||
})
|
||||
}
|
||||
size={ServerApi.Config.constraints.relay_consumption}
|
||||
helperText="Estimated consumption of device powered by relay"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<TextInput
|
||||
editable
|
||||
label="Minimal uptime"
|
||||
value={relay.minimal_uptime.toString()}
|
||||
type="number"
|
||||
onValueChange={(v) =>
|
||||
setRelay((r) => {
|
||||
return {
|
||||
...r,
|
||||
minimal_uptime: Number(v) ?? 0,
|
||||
};
|
||||
})
|
||||
}
|
||||
size={ServerApi.Config.constraints.relay_minimal_uptime}
|
||||
helperText="Minimal time this relay shall be left on before it can be turned off (in seconds)"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<TextInput
|
||||
editable
|
||||
label="Minimal downtime"
|
||||
value={relay.minimal_downtime.toString()}
|
||||
type="number"
|
||||
onValueChange={(v) =>
|
||||
setRelay((r) => {
|
||||
return {
|
||||
...r,
|
||||
minimal_downtime: Number(v) ?? 0,
|
||||
};
|
||||
})
|
||||
}
|
||||
size={ServerApi.Config.constraints.relay_minimal_downtime}
|
||||
helperText="Minimal time this relay shall be left on before it can be turned off (in seconds)"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<DialogFormTitle>Daily runtime</DialogFormTitle>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<CheckboxInput
|
||||
editable
|
||||
label="Enable minimal runtime"
|
||||
checked={!!relay.daily_runtime}
|
||||
onValueChange={(v) =>
|
||||
setRelay((r) => {
|
||||
return {
|
||||
...r,
|
||||
daily_runtime: v
|
||||
? { reset_time: 0, min_runtime: 3600, catch_up_hours: [] }
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{!!relay.daily_runtime && (
|
||||
<>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<TextInput
|
||||
editable
|
||||
label="Minimal daily runtime"
|
||||
value={relay.daily_runtime!.min_runtime.toString()}
|
||||
type="number"
|
||||
onValueChange={(v) =>
|
||||
setRelay((r) => {
|
||||
return {
|
||||
...r,
|
||||
daily_runtime: {
|
||||
...r.daily_runtime!,
|
||||
min_runtime: Number(v),
|
||||
},
|
||||
};
|
||||
})
|
||||
}
|
||||
size={
|
||||
ServerApi.Config.constraints.relay_daily_minimal_runtime
|
||||
}
|
||||
helperText="Minimum time, in seconds, that this relay should run each day"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<TimePicker
|
||||
label="Reset time"
|
||||
value={timeOfDay(relay.daily_runtime!.reset_time)}
|
||||
onChange={(d) =>
|
||||
setRelay((r) => {
|
||||
return {
|
||||
...r,
|
||||
daily_runtime: {
|
||||
...r.daily_runtime!,
|
||||
reset_time: d ? dayjsToTimeOfDay(d) : 0,
|
||||
},
|
||||
};
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<MultipleSelectInput
|
||||
label="Catchup hours"
|
||||
helperText="The hours during which the relay should be turned on to reach expected runtime"
|
||||
values={Array.apply(null, Array(24)).map((_y, i) => {
|
||||
return {
|
||||
label: `${i.toString().padStart(2, "0")}:00`,
|
||||
value: i,
|
||||
};
|
||||
})}
|
||||
selected={relay.daily_runtime!.catch_up_hours}
|
||||
onChange={(d) =>
|
||||
setRelay((r) => {
|
||||
return {
|
||||
...r,
|
||||
daily_runtime: {
|
||||
...r.daily_runtime!,
|
||||
catch_up_hours: d,
|
||||
},
|
||||
};
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<DialogFormTitle>Constraints</DialogFormTitle>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<SelectMultipleRelaysInput
|
||||
label="Required relays"
|
||||
exclude={[relay.id]}
|
||||
value={relay.depends_on}
|
||||
onValueChange={(v) =>
|
||||
setRelay((r) => {
|
||||
return {
|
||||
...r,
|
||||
depends_on: v,
|
||||
};
|
||||
})
|
||||
}
|
||||
helperText="Relays that must be already up for this relay to be started"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<SelectMultipleRelaysInput
|
||||
label="Conflicting relays"
|
||||
exclude={[relay.id]}
|
||||
value={relay.conflicts_with}
|
||||
onValueChange={(v) =>
|
||||
setRelay((r) => {
|
||||
return {
|
||||
...r,
|
||||
conflicts_with: v,
|
||||
};
|
||||
})
|
||||
}
|
||||
helperText="Relays that must be off before this relay can be started"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={p.onClose}>Cancel</Button>
|
||||
<Button onClick={onSubmit} autoFocus disabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFormTitle(p: React.PropsWithChildren): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
<span style={{ height: "2px" }}></span>
|
||||
<Typography variant="h6">{p.children}</Typography>
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,68 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
} from "@mui/material";
|
||||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
type AlertContext = (message: string, title?: string) => Promise<void>;
|
||||
|
||||
const AlertContextK = React.createContext<AlertContext | null>(null);
|
||||
|
||||
export function AlertDialogProvider(p: PropsWithChildren): React.ReactElement {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const [title, setTitle] = React.useState<string | undefined>(undefined);
|
||||
const [message, setMessage] = React.useState("");
|
||||
|
||||
const cb = React.useRef<null | (() => void)>(null);
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
|
||||
if (cb.current !== null) cb.current();
|
||||
cb.current = null;
|
||||
};
|
||||
|
||||
const hook: AlertContext = (message, title) => {
|
||||
setTitle(title);
|
||||
setMessage(message);
|
||||
setOpen(true);
|
||||
|
||||
return new Promise((res) => {
|
||||
cb.current = res;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertContextK.Provider value={hook}>{p.children}</AlertContextK.Provider>
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
aria-labelledby="alert-dialog-title"
|
||||
aria-describedby="alert-dialog-description"
|
||||
>
|
||||
{title && <DialogTitle id="alert-dialog-title">{title}</DialogTitle>}
|
||||
<DialogContent>
|
||||
<DialogContentText id="alert-dialog-description">
|
||||
{message}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} autoFocus>
|
||||
Ok
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAlert(): AlertContext {
|
||||
return React.useContext(AlertContextK)!;
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
} from "@mui/material";
|
||||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
type ConfirmContext = (
|
||||
message: string,
|
||||
title?: string,
|
||||
confirmButton?: string
|
||||
) => Promise<boolean>;
|
||||
|
||||
const ConfirmContextK = React.createContext<ConfirmContext | null>(null);
|
||||
|
||||
export function ConfirmDialogProvider(
|
||||
p: PropsWithChildren
|
||||
): React.ReactElement {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const [title, setTitle] = React.useState<string | undefined>(undefined);
|
||||
const [message, setMessage] = React.useState("");
|
||||
const [confirmButton, setConfirmButton] = React.useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const cb = React.useRef<null | ((a: boolean) => void)>(null);
|
||||
|
||||
const handleClose = (confirm: boolean) => {
|
||||
setOpen(false);
|
||||
|
||||
if (cb.current !== null) cb.current(confirm);
|
||||
cb.current = null;
|
||||
};
|
||||
|
||||
const hook: ConfirmContext = (message, title, confirmButton) => {
|
||||
setTitle(title);
|
||||
setMessage(message);
|
||||
setConfirmButton(confirmButton);
|
||||
setOpen(true);
|
||||
|
||||
return new Promise((res) => {
|
||||
cb.current = res;
|
||||
});
|
||||
};
|
||||
|
||||
const keyUp = (e: React.KeyboardEvent) => {
|
||||
if (e.code === "Enter") handleClose(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmContextK.Provider value={hook}>
|
||||
{p.children}
|
||||
</ConfirmContextK.Provider>
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => handleClose(false)}
|
||||
aria-labelledby="alert-dialog-title"
|
||||
aria-describedby="alert-dialog-description"
|
||||
onKeyUp={keyUp}
|
||||
>
|
||||
{title && <DialogTitle id="alert-dialog-title">{title}</DialogTitle>}
|
||||
<DialogContent>
|
||||
<DialogContentText id="alert-dialog-description">
|
||||
{message}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => handleClose(false)} autoFocus>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => handleClose(true)} color="error">
|
||||
{confirmButton ?? "Confirm"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConfirm(): ConfirmContext {
|
||||
return React.useContext(ConfirmContextK)!;
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||
import React from "react";
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
const localStorageKey = "dark-theme";
|
||||
|
||||
const darkTheme = createTheme({
|
||||
palette: {
|
||||
mode: "dark",
|
||||
},
|
||||
});
|
||||
|
||||
const lightTheme = createTheme({
|
||||
palette: {
|
||||
mode: "light",
|
||||
},
|
||||
});
|
||||
|
||||
interface DarkThemeContext {
|
||||
enabled: boolean;
|
||||
setEnabled: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const DarkThemeContextK = React.createContext<DarkThemeContext | null>(null);
|
||||
|
||||
export function DarkThemeProvider(p: PropsWithChildren): React.ReactElement {
|
||||
const [enabled, setEnabled] = React.useState(
|
||||
localStorage.getItem(localStorageKey) !== "false"
|
||||
);
|
||||
|
||||
return (
|
||||
<DarkThemeContextK.Provider
|
||||
value={{
|
||||
enabled: enabled,
|
||||
setEnabled(enabled) {
|
||||
localStorage.setItem(localStorageKey, enabled ? "true" : "false");
|
||||
setEnabled(enabled);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ThemeProvider theme={enabled ? darkTheme : lightTheme}>
|
||||
{p.children}
|
||||
</ThemeProvider>
|
||||
</DarkThemeContextK.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDarkTheme(): DarkThemeContext {
|
||||
return React.useContext(DarkThemeContextK)!;
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
import {
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
} from "@mui/material";
|
||||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
type LoadingMessageContext = {
|
||||
show: (message: string) => void;
|
||||
hide: () => void;
|
||||
};
|
||||
|
||||
const LoadingMessageContextK =
|
||||
React.createContext<LoadingMessageContext | null>(null);
|
||||
|
||||
export function LoadingMessageProvider(
|
||||
p: PropsWithChildren
|
||||
): React.ReactElement {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const [message, setMessage] = React.useState("");
|
||||
|
||||
const hook: LoadingMessageContext = {
|
||||
show(message) {
|
||||
setMessage(message);
|
||||
setOpen(true);
|
||||
},
|
||||
hide() {
|
||||
setMessage("");
|
||||
setOpen(false);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingMessageContextK.Provider value={hook}>
|
||||
{p.children}
|
||||
</LoadingMessageContextK.Provider>
|
||||
|
||||
<Dialog open={open}>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<CircularProgress style={{ marginRight: "15px" }} />
|
||||
|
||||
{message}
|
||||
</div>
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLoadingMessage(): LoadingMessageContext {
|
||||
return React.useContext(LoadingMessageContextK)!;
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
import { Snackbar } from "@mui/material";
|
||||
|
||||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
type SnackbarContext = (message: string, duration?: number) => void;
|
||||
|
||||
const SnackbarContextK = React.createContext<SnackbarContext | null>(null);
|
||||
|
||||
export function SnackbarProvider(p: PropsWithChildren): React.ReactElement {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const [message, setMessage] = React.useState("");
|
||||
const [duration, setDuration] = React.useState(0);
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const hook: SnackbarContext = (message, duration) => {
|
||||
setMessage(message);
|
||||
setDuration(duration ?? 6000);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SnackbarContextK.Provider value={hook}>
|
||||
{p.children}
|
||||
</SnackbarContextK.Provider>
|
||||
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={duration}
|
||||
onClose={handleClose}
|
||||
message={message}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSnackbar(): SnackbarContext {
|
||||
return React.useContext(SnackbarContextK)!;
|
||||
}
|
9
central_frontend/src/index.css
Normal file
9
central_frontend/src/index.css
Normal file
@ -0,0 +1,9 @@
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
40
central_frontend/src/main.tsx
Normal file
40
central_frontend/src/main.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import "@fontsource/roboto/300.css";
|
||||
import "@fontsource/roboto/400.css";
|
||||
import "@fontsource/roboto/500.css";
|
||||
import "@fontsource/roboto/700.css";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { AlertDialogProvider } from "./hooks/context_providers/AlertDialogProvider";
|
||||
import { ConfirmDialogProvider } from "./hooks/context_providers/ConfirmDialogProvider";
|
||||
import { DarkThemeProvider } from "./hooks/context_providers/DarkThemeProvider";
|
||||
import { LoadingMessageProvider } from "./hooks/context_providers/LoadingMessageProvider";
|
||||
import { SnackbarProvider } from "./hooks/context_providers/SnackbarProvider";
|
||||
import "./index.css";
|
||||
import { ServerApi } from "./api/ServerApi";
|
||||
import { AsyncWidget } from "./widgets/AsyncWidget";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<DarkThemeProvider>
|
||||
<AlertDialogProvider>
|
||||
<ConfirmDialogProvider>
|
||||
<SnackbarProvider>
|
||||
<LoadingMessageProvider>
|
||||
<AsyncWidget
|
||||
loadKey={1}
|
||||
load={async () => await ServerApi.LoadConfig()}
|
||||
errMsg="Failed to connect to backend to retrieve static config!"
|
||||
build={() => <App />}
|
||||
/>
|
||||
</LoadingMessageProvider>
|
||||
</SnackbarProvider>
|
||||
</ConfirmDialogProvider>
|
||||
</AlertDialogProvider>
|
||||
</DarkThemeProvider>
|
||||
</LocalizationProvider>
|
||||
</React.StrictMode>
|
||||
);
|
@ -0,0 +1,15 @@
|
||||
import { TableCell, TableRow } from "@mui/material";
|
||||
|
||||
export function DeviceInfoProperty(p: {
|
||||
icon?: React.ReactElement;
|
||||
label: string;
|
||||
value: string;
|
||||
color?: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<TableRow hover sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
|
||||
<TableCell>{p.label}</TableCell>
|
||||
<TableCell style={{ color: p.color }}>{p.value}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
124
central_frontend/src/routes/DeviceRoute/DeviceRelays.tsx
Normal file
124
central_frontend/src/routes/DeviceRoute/DeviceRelays.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import {
|
||||
IconButton,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { Device, DeviceRelay } from "../../api/DeviceApi";
|
||||
import { EditDeviceRelaysDialog } from "../../dialogs/EditDeviceRelaysDialog";
|
||||
import { DeviceRouteCard } from "./DeviceRouteCard";
|
||||
import { useConfirm } from "../../hooks/context_providers/ConfirmDialogProvider";
|
||||
import { useLoadingMessage } from "../../hooks/context_providers/LoadingMessageProvider";
|
||||
import { RelayApi } from "../../api/RelayApi";
|
||||
import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider";
|
||||
import { useAlert } from "../../hooks/context_providers/AlertDialogProvider";
|
||||
|
||||
export function DeviceRelays(p: {
|
||||
device: Device;
|
||||
onReload: () => void;
|
||||
}): React.ReactElement {
|
||||
const confirm = useConfirm();
|
||||
const loadingMessage = useLoadingMessage();
|
||||
const snackbar = useSnackbar();
|
||||
const alert = useAlert();
|
||||
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const [currRelay, setCurrRelay] = React.useState<DeviceRelay | undefined>();
|
||||
|
||||
const createNewRelay = () => {
|
||||
setDialogOpen(true);
|
||||
setCurrRelay(undefined);
|
||||
};
|
||||
|
||||
const updateRelay = async (r: DeviceRelay) => {
|
||||
setDialogOpen(true);
|
||||
setCurrRelay(r);
|
||||
};
|
||||
|
||||
const deleteRelay = async (r: DeviceRelay) => {
|
||||
if (
|
||||
!(await confirm("Do you really want to delete this relay configuration?"))
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
await RelayApi.Delete(r);
|
||||
|
||||
p.onReload();
|
||||
snackbar("The relay configuration was successfully deleted!");
|
||||
} catch (e) {
|
||||
console.error("Failed to delete relay!", e);
|
||||
alert(`Failed to delete device relay configuration! ${e}`);
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{dialogOpen && (
|
||||
<EditDeviceRelaysDialog
|
||||
device={p.device}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
relay={currRelay}
|
||||
onUpdated={() => {
|
||||
setDialogOpen(false);
|
||||
p.onReload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<DeviceRouteCard
|
||||
title="Device relays"
|
||||
actions={
|
||||
<Tooltip title="Create new relay">
|
||||
<IconButton
|
||||
onClick={createNewRelay}
|
||||
disabled={p.device.relays.length >= p.device.info.max_relays}
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{p.device.relays.length === 0 ? (
|
||||
<Typography style={{ textAlign: "center" }}>
|
||||
No relay configured yet.
|
||||
</Typography>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{p.device.relays.map((r, i) => (
|
||||
<ListItem
|
||||
alignItems="flex-start"
|
||||
key={r.id}
|
||||
secondaryAction={
|
||||
<>
|
||||
<Tooltip title="Edit the relay configuration">
|
||||
<IconButton onClick={() => updateRelay(r)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{i === p.device.relays.length - 1 && (
|
||||
<Tooltip title="Delete the relay configuration">
|
||||
<IconButton onClick={() => deleteRelay(r)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ListItemText primary={r.name} secondary={"TODO: status"} />
|
||||
</ListItem>
|
||||
))}
|
||||
</DeviceRouteCard>
|
||||
</>
|
||||
);
|
||||
}
|
107
central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx
Normal file
107
central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import { IconButton, Tooltip } from "@mui/material";
|
||||
import Grid from "@mui/material/Grid2";
|
||||
import React from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Device, DeviceApi } from "../../api/DeviceApi";
|
||||
import { useAlert } from "../../hooks/context_providers/AlertDialogProvider";
|
||||
import { useConfirm } from "../../hooks/context_providers/ConfirmDialogProvider";
|
||||
import { useLoadingMessage } from "../../hooks/context_providers/LoadingMessageProvider";
|
||||
import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider";
|
||||
import { AsyncWidget } from "../../widgets/AsyncWidget";
|
||||
import { SolarEnergyRouteContainer } from "../../widgets/SolarEnergyRouteContainer";
|
||||
import { DeviceRelays } from "./DeviceRelays";
|
||||
import { DeviceStateBlock } from "./DeviceStateBlock";
|
||||
import { GeneralDeviceInfo } from "./GeneralDeviceInfo";
|
||||
|
||||
export function DeviceRoute(): React.ReactElement {
|
||||
const { id } = useParams();
|
||||
const [device, setDevice] = React.useState<Device | undefined>();
|
||||
|
||||
const loadKey = React.useRef(1);
|
||||
|
||||
const load = async () => {
|
||||
setDevice(await DeviceApi.GetSingle(id!));
|
||||
};
|
||||
|
||||
const reload = () => {
|
||||
loadKey.current += 1;
|
||||
setDevice(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncWidget
|
||||
loadKey={loadKey.current}
|
||||
errMsg="Failed to load device information"
|
||||
load={load}
|
||||
ready={!!device}
|
||||
build={() => <DeviceRouteInner device={device!} onReload={reload} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceRouteInner(p: {
|
||||
device: Device;
|
||||
onReload: () => void;
|
||||
}): React.ReactElement {
|
||||
const alert = useAlert();
|
||||
const confirm = useConfirm();
|
||||
const snackbar = useSnackbar();
|
||||
const loadingMessage = useLoadingMessage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const deleteDevice = async (d: Device) => {
|
||||
try {
|
||||
if (
|
||||
!(await confirm(
|
||||
`Do you really want to delete the device ${d.id}? The operation cannot be reverted!`
|
||||
))
|
||||
)
|
||||
return;
|
||||
|
||||
loadingMessage.show("Deleting device...");
|
||||
await DeviceApi.Delete(d);
|
||||
|
||||
snackbar("The device has been successfully deleted!");
|
||||
navigate("/devices");
|
||||
} catch (e) {
|
||||
console.error(`Failed to delete device! ${e})`);
|
||||
alert("Failed to delete device!");
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SolarEnergyRouteContainer
|
||||
label={`Device ${p.device.name}`}
|
||||
actions={
|
||||
<span>
|
||||
<Tooltip title="Refresh information">
|
||||
<IconButton onClick={p.onReload}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete device">
|
||||
<IconButton onClick={() => deleteDevice(p.device)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<GeneralDeviceInfo {...p} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<DeviceRelays {...p} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<DeviceStateBlock {...p} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SolarEnergyRouteContainer>
|
||||
);
|
||||
}
|
24
central_frontend/src/routes/DeviceRoute/DeviceRouteCard.tsx
Normal file
24
central_frontend/src/routes/DeviceRoute/DeviceRouteCard.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { Card, Paper, Typography } from "@mui/material";
|
||||
|
||||
export function DeviceRouteCard(
|
||||
p: React.PropsWithChildren<{ title: string; actions?: React.ReactElement }>
|
||||
): React.ReactElement {
|
||||
return (
|
||||
<Card component={Paper}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" style={{ padding: "6px" }}>
|
||||
{p.title}
|
||||
</Typography>
|
||||
{p.actions}
|
||||
</div>
|
||||
{p.children}
|
||||
</Card>
|
||||
);
|
||||
}
|
44
central_frontend/src/routes/DeviceRoute/DeviceStateBlock.tsx
Normal file
44
central_frontend/src/routes/DeviceRoute/DeviceStateBlock.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import { Device, DeviceApi, DeviceState } from "../../api/DeviceApi";
|
||||
import { AsyncWidget } from "../../widgets/AsyncWidget";
|
||||
import { DeviceRouteCard } from "./DeviceRouteCard";
|
||||
import { Table, TableBody } from "@mui/material";
|
||||
import { DeviceInfoProperty } from "./DeviceInfoProperty";
|
||||
import { timeDiff } from "../../widgets/TimeWidget";
|
||||
|
||||
export function DeviceStateBlock(p: { device: Device }): React.ReactElement {
|
||||
const [state, setState] = React.useState<DeviceState>();
|
||||
|
||||
const load = async () => {
|
||||
setState(await DeviceApi.GetSingleState(p.device.id));
|
||||
};
|
||||
|
||||
return (
|
||||
<DeviceRouteCard title="Device state">
|
||||
<AsyncWidget
|
||||
loadKey={p.device.id}
|
||||
load={load}
|
||||
ready={!!state}
|
||||
errMsg="Failed to load device state!"
|
||||
build={() => <DeviceStateInner state={state!} />}
|
||||
/>
|
||||
</DeviceRouteCard>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceStateInner(p: { state: DeviceState }): React.ReactElement {
|
||||
return (
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<DeviceInfoProperty
|
||||
label="Status"
|
||||
value={p.state.online ? "Online" : "Offline"}
|
||||
/>
|
||||
<DeviceInfoProperty
|
||||
label="Last ping"
|
||||
value={timeDiff(0, p.state.last_ping)}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
@ -0,0 +1,74 @@
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import { IconButton, Table, TableBody, Tooltip } from "@mui/material";
|
||||
import React from "react";
|
||||
import { Device } from "../../api/DeviceApi";
|
||||
import { EditDeviceMetadataDialog } from "../../dialogs/EditDeviceMetadataDialog";
|
||||
import { formatDate } from "../../widgets/TimeWidget";
|
||||
import { DeviceInfoProperty } from "./DeviceInfoProperty";
|
||||
import { DeviceRouteCard } from "./DeviceRouteCard";
|
||||
|
||||
export function GeneralDeviceInfo(p: {
|
||||
device: Device;
|
||||
onReload: () => void;
|
||||
}): React.ReactElement {
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{dialogOpen && (
|
||||
<EditDeviceMetadataDialog
|
||||
device={p.device}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onUpdated={p.onReload}
|
||||
/>
|
||||
)}
|
||||
<DeviceRouteCard
|
||||
title="General device information"
|
||||
actions={
|
||||
<Tooltip title="Edit device information">
|
||||
<IconButton onClick={() => setDialogOpen(true)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<DeviceInfoProperty label="ID" value={p.device.id} />
|
||||
<DeviceInfoProperty
|
||||
label="Reference"
|
||||
value={p.device.info.reference}
|
||||
/>
|
||||
<DeviceInfoProperty label="Version" value={p.device.info.version} />
|
||||
<DeviceInfoProperty label="Name" value={p.device.name} />
|
||||
<DeviceInfoProperty
|
||||
label="Description"
|
||||
value={p.device.description}
|
||||
/>
|
||||
<DeviceInfoProperty
|
||||
label="Created"
|
||||
value={formatDate(p.device.time_create)}
|
||||
/>
|
||||
<DeviceInfoProperty
|
||||
label="Updated"
|
||||
value={formatDate(p.device.time_update)}
|
||||
/>
|
||||
<DeviceInfoProperty
|
||||
label="Enabled"
|
||||
value={p.device.enabled ? "YES" : "NO"}
|
||||
color={p.device.enabled ? "green" : "red"}
|
||||
/>
|
||||
<DeviceInfoProperty
|
||||
label="Maximum number of relays"
|
||||
value={p.device.info.max_relays.toString()}
|
||||
/>
|
||||
<DeviceInfoProperty
|
||||
label="Number of configured relays"
|
||||
value={p.device.relays.length.toString()}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DeviceRouteCard>
|
||||
</>
|
||||
);
|
||||
}
|
135
central_frontend/src/routes/DevicesRoute.tsx
Normal file
135
central_frontend/src/routes/DevicesRoute.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import {
|
||||
IconButton,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Device, DeviceApi, DevicesState, DeviceURL } from "../api/DeviceApi";
|
||||
import { AsyncWidget } from "../widgets/AsyncWidget";
|
||||
import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer";
|
||||
import { TimeWidget } from "../widgets/TimeWidget";
|
||||
|
||||
export function DevicesRoute(): React.ReactElement {
|
||||
const loadKey = React.useRef(1);
|
||||
|
||||
const [list, setList] = React.useState<Device[] | undefined>();
|
||||
const [states, setStates] = React.useState<DevicesState | undefined>();
|
||||
|
||||
const load = async () => {
|
||||
setList(await DeviceApi.ValidatedList());
|
||||
setStates(await DeviceApi.DevicesState());
|
||||
};
|
||||
|
||||
const reload = () => {
|
||||
loadKey.current += 1;
|
||||
setList(undefined);
|
||||
setStates(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<SolarEnergyRouteContainer
|
||||
label="Devices"
|
||||
actions={
|
||||
<Tooltip title="Refresh table">
|
||||
<IconButton onClick={reload}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<AsyncWidget
|
||||
loadKey={loadKey.current}
|
||||
ready={!!list && !!states}
|
||||
errMsg="Failed to load the list of validated devices!"
|
||||
load={load}
|
||||
build={() => (
|
||||
<ValidatedDevicesList
|
||||
onReload={reload}
|
||||
list={list!}
|
||||
states={states!}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</SolarEnergyRouteContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ValidatedDevicesList(p: {
|
||||
list: Device[];
|
||||
states: DevicesState;
|
||||
onReload: () => void;
|
||||
}): React.ReactElement {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (p.list.length === 0) {
|
||||
return <p>There is no device validated yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper}>
|
||||
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>#</TableCell>
|
||||
<TableCell>Model</TableCell>
|
||||
<TableCell>Version</TableCell>
|
||||
<TableCell>Max number of relays</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell>Updated</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{p.list.map((dev) => (
|
||||
<TableRow
|
||||
hover
|
||||
key={dev.id}
|
||||
onDoubleClick={() => navigate(DeviceURL(dev))}
|
||||
>
|
||||
<TableCell component="th" scope="row">
|
||||
{dev.id}
|
||||
</TableCell>
|
||||
<TableCell>{dev.info.reference}</TableCell>
|
||||
<TableCell>{dev.info.version}</TableCell>
|
||||
<TableCell>{dev.info.max_relays}</TableCell>
|
||||
<TableCell>
|
||||
<TimeWidget time={dev.time_create} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TimeWidget time={dev.time_update} />
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{p.states.get(dev.id)!.online ? (
|
||||
<strong>Online</strong>
|
||||
) : (
|
||||
<em>Offline</em>
|
||||
)}
|
||||
<br />
|
||||
<TimeWidget diff time={p.states.get(dev.id)!.last_ping} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title="Open device page">
|
||||
<Link to={DeviceURL(dev)}>
|
||||
<IconButton>
|
||||
<VisibilityIcon />
|
||||
</IconButton>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
27
central_frontend/src/routes/HomeRoute.tsx
Normal file
27
central_frontend/src/routes/HomeRoute.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { Typography } from "@mui/material";
|
||||
import { CurrConsumptionWidget } from "./HomeRoute/CurrConsumptionWidget";
|
||||
import Grid from "@mui/material/Grid2";
|
||||
import { CachedConsumptionWidget } from "./HomeRoute/CachedConsumptionWidget";
|
||||
|
||||
export function HomeRoute(): React.ReactElement {
|
||||
return (
|
||||
<div style={{ flex: 1, padding: "10px" }}>
|
||||
<Typography component="h2" variant="h6" sx={{ mb: 2 }}>
|
||||
Overview
|
||||
</Typography>
|
||||
<Grid
|
||||
container
|
||||
spacing={2}
|
||||
columns={12}
|
||||
sx={{ mb: (theme) => theme.spacing(2) }}
|
||||
>
|
||||
<Grid size={{ xs: 12, sm: 6, lg: 3 }}>
|
||||
<CurrConsumptionWidget />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, lg: 3 }}>
|
||||
<CachedConsumptionWidget />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import { EnergyApi } from "../../api/EnergyApi";
|
||||
import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider";
|
||||
import StatCard from "../../widgets/StatCard";
|
||||
|
||||
export function CachedConsumptionWidget(): React.ReactElement {
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const [val, setVal] = React.useState<undefined | number>();
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const s = await EnergyApi.CachedConsumption();
|
||||
setVal(s);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
snackbar("Failed to refresh cached consumption!");
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
refresh();
|
||||
const i = setInterval(() => refresh(), 3000);
|
||||
|
||||
return () => clearInterval(i);
|
||||
});
|
||||
|
||||
return (
|
||||
<StatCard
|
||||
title="Cached consumption"
|
||||
data={[]}
|
||||
interval="Current data"
|
||||
trend="neutral"
|
||||
value={val?.toString() ?? "Loading"}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import { EnergyApi } from "../../api/EnergyApi";
|
||||
import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider";
|
||||
import StatCard from "../../widgets/StatCard";
|
||||
|
||||
export function CurrConsumptionWidget(): React.ReactElement {
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const [val, setVal] = React.useState<undefined | number>();
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const s = await EnergyApi.CurrConsumption();
|
||||
setVal(s);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
snackbar("Failed to refresh current consumption!");
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
refresh();
|
||||
const i = setInterval(() => refresh(), 3000);
|
||||
|
||||
return () => clearInterval(i);
|
||||
});
|
||||
|
||||
return (
|
||||
<StatCard
|
||||
title="Current consumption"
|
||||
data={[]}
|
||||
interval="Current data"
|
||||
trend="neutral"
|
||||
value={val?.toString() ?? "Loading"}
|
||||
/>
|
||||
);
|
||||
}
|
140
central_frontend/src/routes/LoginRoute.tsx
Normal file
140
central_frontend/src/routes/LoginRoute.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
|
||||
import { Alert } from "@mui/material";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import Link from "@mui/material/Link";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import * as React from "react";
|
||||
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||
import { AuthApi } from "../api/AuthApi";
|
||||
import Grid from "@mui/material/Grid2";
|
||||
|
||||
function Copyright(props: any) {
|
||||
return (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
align="center"
|
||||
{...props}
|
||||
>
|
||||
{"Copyright © "}
|
||||
<Link color="inherit" href="https://0ph.fr/">
|
||||
Pierre HUBERT
|
||||
</Link>{" "}
|
||||
{new Date().getFullYear()}
|
||||
{"."}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginRoute() {
|
||||
const loadingMessage = useLoadingMessage();
|
||||
|
||||
const [user, setUser] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
|
||||
const [error, setError] = React.useState<string | undefined>();
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
loadingMessage.show("Signing in...");
|
||||
setError(undefined);
|
||||
|
||||
await AuthApi.AuthWithPassword(user, password);
|
||||
|
||||
location.href = "/";
|
||||
} catch (e) {
|
||||
console.error("Failed to perform login!", e);
|
||||
setError(`Failed to authenticate! ${e}`);
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container component="main" sx={{ height: "100vh" }}>
|
||||
<CssBaseline />
|
||||
<Grid
|
||||
size={{ sm: 4, md: 7, xs: false }}
|
||||
sx={{
|
||||
backgroundImage: 'url("/sun.jpg")',
|
||||
backgroundColor: (t) =>
|
||||
t.palette.mode === "light"
|
||||
? t.palette.grey[50]
|
||||
: t.palette.grey[900],
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "left",
|
||||
}}
|
||||
/>
|
||||
<Grid
|
||||
size={{ xs: 12, sm: 8, md: 5 }}
|
||||
component={Paper}
|
||||
elevation={6}
|
||||
square
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
my: 8,
|
||||
mx: 4,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
|
||||
<LockOutlinedIcon />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
SolarEnergy
|
||||
</Typography>
|
||||
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
|
||||
<Box
|
||||
component="form"
|
||||
noValidate
|
||||
onSubmit={handleSubmit}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
label="Username"
|
||||
autoFocus
|
||||
value={user}
|
||||
onChange={(v) => setUser(v.target.value)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
label="Password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(v) => setPassword(v.target.value)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
|
||||
<Copyright sx={{ mt: 5 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
23
central_frontend/src/routes/NotFoundRoute.tsx
Normal file
23
central_frontend/src/routes/NotFoundRoute.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { Button } from "@mui/material";
|
||||
import { RouterLink } from "../widgets/RouterLink";
|
||||
|
||||
export function NotFoundRoute(): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
flex: "1",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<h1>404 Not found</h1>
|
||||
<p>The page you requested was not found!</p>
|
||||
<RouterLink to="/">
|
||||
<Button>Go back home</Button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
);
|
||||
}
|
156
central_frontend/src/routes/PendingDevicesRoute.tsx
Normal file
156
central_frontend/src/routes/PendingDevicesRoute.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import {
|
||||
IconButton,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { Device, DeviceApi } from "../api/DeviceApi";
|
||||
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||
import { useConfirm } from "../hooks/context_providers/ConfirmDialogProvider";
|
||||
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
|
||||
import { AsyncWidget } from "../widgets/AsyncWidget";
|
||||
import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer";
|
||||
import { TimeWidget } from "../widgets/TimeWidget";
|
||||
|
||||
export function PendingDevicesRoute(): React.ReactElement {
|
||||
const loadKey = React.useRef(1);
|
||||
|
||||
const [pending, setPending] = React.useState<Device[] | undefined>();
|
||||
|
||||
const load = async () => {
|
||||
setPending(await DeviceApi.PendingList());
|
||||
};
|
||||
|
||||
const reload = () => {
|
||||
loadKey.current += 1;
|
||||
setPending(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<SolarEnergyRouteContainer
|
||||
label="Pending devices"
|
||||
actions={
|
||||
<Tooltip title="Refresh table">
|
||||
<IconButton onClick={reload}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<AsyncWidget
|
||||
loadKey={loadKey.current}
|
||||
ready={!!pending}
|
||||
errMsg="Failed to load the list of pending devices!"
|
||||
load={load}
|
||||
build={() => (
|
||||
<PendingDevicesList onReload={reload} pending={pending!} />
|
||||
)}
|
||||
/>
|
||||
</SolarEnergyRouteContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingDevicesList(p: {
|
||||
pending: Device[];
|
||||
onReload: () => void;
|
||||
}): React.ReactElement {
|
||||
const alert = useAlert();
|
||||
const confirm = useConfirm();
|
||||
const snackbar = useSnackbar();
|
||||
const loadingMessage = useLoadingMessage();
|
||||
|
||||
const validateDevice = async (d: Device) => {
|
||||
try {
|
||||
loadingMessage.show("Validating device...");
|
||||
await DeviceApi.Validate(d);
|
||||
|
||||
snackbar("The device has been successfully validated!");
|
||||
p.onReload();
|
||||
} catch (e) {
|
||||
console.error(`Failed to validate device! ${e})`);
|
||||
alert("Failed to validate device!");
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
|
||||
const deleteDevice = async (d: Device) => {
|
||||
try {
|
||||
if (
|
||||
!(await confirm(
|
||||
`Do you really want to delete the device ${d.id}? The operation cannot be reverted!`
|
||||
))
|
||||
)
|
||||
return;
|
||||
|
||||
loadingMessage.show("Deleting device...");
|
||||
await DeviceApi.Delete(d);
|
||||
|
||||
snackbar("The device has been successfully deleted!");
|
||||
p.onReload();
|
||||
} catch (e) {
|
||||
console.error(`Failed to delete device! ${e})`);
|
||||
alert("Failed to delete device!");
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
|
||||
if (p.pending.length === 0) {
|
||||
return <p>There is no device awaiting confirmation right now.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper}>
|
||||
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>#</TableCell>
|
||||
<TableCell>Model</TableCell>
|
||||
<TableCell>Version</TableCell>
|
||||
<TableCell>Max number of relays</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{p.pending.map((dev) => (
|
||||
<TableRow key={dev.id}>
|
||||
<TableCell component="th" scope="row">
|
||||
{dev.id}
|
||||
</TableCell>
|
||||
<TableCell>{dev.info.reference}</TableCell>
|
||||
<TableCell>{dev.info.version}</TableCell>
|
||||
<TableCell>{dev.info.max_relays}</TableCell>
|
||||
<TableCell>
|
||||
<TimeWidget time={dev.time_create} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title="Validate device">
|
||||
<IconButton onClick={() => validateDevice(dev)}>
|
||||
<CheckIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete device">
|
||||
<IconButton onClick={() => deleteDevice(dev)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
96
central_frontend/src/routes/RelaysListRoute.tsx
Normal file
96
central_frontend/src/routes/RelaysListRoute.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import {
|
||||
IconButton,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { DeviceRelay } from "../api/DeviceApi";
|
||||
import { RelayApi } from "../api/RelayApi";
|
||||
import { AsyncWidget } from "../widgets/AsyncWidget";
|
||||
import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer";
|
||||
|
||||
export function RelaysListRoute(): React.ReactElement {
|
||||
const loadKey = React.useRef(1);
|
||||
|
||||
const [list, setList] = React.useState<DeviceRelay[] | undefined>();
|
||||
|
||||
const load = async () => {
|
||||
setList(await RelayApi.GetList());
|
||||
|
||||
list?.sort((a, b) => b.priority - a.priority);
|
||||
};
|
||||
|
||||
const reload = () => {
|
||||
loadKey.current += 1;
|
||||
setList(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<SolarEnergyRouteContainer
|
||||
label="Relays list"
|
||||
actions={
|
||||
<Tooltip title="Refresh list">
|
||||
<IconButton onClick={reload}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<AsyncWidget
|
||||
loadKey={loadKey.current}
|
||||
ready={!!list}
|
||||
errMsg="Failed to load the list of relays!"
|
||||
load={load}
|
||||
build={() => <RelaysList onReload={reload} list={list!} />}
|
||||
/>
|
||||
</SolarEnergyRouteContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function RelaysList(p: {
|
||||
list: DeviceRelay[];
|
||||
onReload: () => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<TableContainer component={Paper}>
|
||||
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Enabled</TableCell>
|
||||
<TableCell>Priority</TableCell>
|
||||
<TableCell>Consumption</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{p.list.map((row) => (
|
||||
<TableRow
|
||||
key={row.name}
|
||||
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
|
||||
>
|
||||
<TableCell>{row.name}</TableCell>
|
||||
<TableCell>
|
||||
{row.enabled ? (
|
||||
<span style={{ color: "green" }}>YES</span>
|
||||
) : (
|
||||
<span style={{ color: "red" }}>NO</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{row.priority}</TableCell>
|
||||
<TableCell>{row.consumption}</TableCell>
|
||||
<TableCell>TODO</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
29
central_frontend/src/utils/DateUtils.ts
Normal file
29
central_frontend/src/utils/DateUtils.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
|
||||
/**
|
||||
* Get current UNIX time, in seconds
|
||||
*/
|
||||
export function time(): number {
|
||||
return Math.floor(new Date().getTime() / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dayjs representation of given time of day
|
||||
*/
|
||||
export function timeOfDay(time: number): Dayjs {
|
||||
const hours = Math.floor(time / 3600);
|
||||
const minutes = Math.floor(time / 60) - hours * 60;
|
||||
|
||||
return dayjs(
|
||||
`2022-04-17T${hours.toString().padStart(2, "0")}:${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time of day (in secs) from a given dayjs representation
|
||||
*/
|
||||
export function dayjsToTimeOfDay(d: Dayjs): number {
|
||||
return d.hour() * 3600 + d.minute() * 60 + d.second();
|
||||
}
|
8
central_frontend/src/utils/StringsUtils.ts
Normal file
8
central_frontend/src/utils/StringsUtils.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { LenConstraint } from "../api/ServerApi";
|
||||
|
||||
/**
|
||||
* Check whether a string length is valid or not
|
||||
*/
|
||||
export function lenValid(s: string, c: LenConstraint): boolean {
|
||||
return s.length >= c.min && s.length <= c.max;
|
||||
}
|
1
central_frontend/src/vite-env.d.ts
vendored
Normal file
1
central_frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
92
central_frontend/src/widgets/AsyncWidget.tsx
Normal file
92
central_frontend/src/widgets/AsyncWidget.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { Alert, Box, Button, CircularProgress } from "@mui/material";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
enum State {
|
||||
Loading,
|
||||
Ready,
|
||||
Error,
|
||||
}
|
||||
|
||||
export function AsyncWidget(p: {
|
||||
loadKey: any;
|
||||
load: () => Promise<void>;
|
||||
errMsg: string;
|
||||
build: () => React.ReactElement;
|
||||
ready?: boolean;
|
||||
errAdditionalElement?: () => React.ReactElement;
|
||||
}): React.ReactElement {
|
||||
const [state, setState] = useState(State.Loading);
|
||||
|
||||
const counter = useRef<any | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setState(State.Loading);
|
||||
await p.load();
|
||||
setState(State.Ready);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setState(State.Error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (counter.current === p.loadKey) return;
|
||||
counter.current = p.loadKey;
|
||||
|
||||
load();
|
||||
});
|
||||
|
||||
if (state === State.Error)
|
||||
return (
|
||||
<Box
|
||||
component="div"
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
flex: "1",
|
||||
flexDirection: "column",
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === "light"
|
||||
? theme.palette.grey[100]
|
||||
: theme.palette.grey[900],
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
variant="outlined"
|
||||
severity="error"
|
||||
style={{ margin: "0px 15px 15px 15px" }}
|
||||
>
|
||||
{p.errMsg}
|
||||
</Alert>
|
||||
|
||||
<Button onClick={load}>Try again</Button>
|
||||
|
||||
{p.errAdditionalElement && p.errAdditionalElement()}
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (state === State.Loading || p.ready === false)
|
||||
return (
|
||||
<Box
|
||||
component="div"
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
flex: "1",
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === "light"
|
||||
? theme.palette.grey[100]
|
||||
: theme.palette.grey[900],
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
|
||||
return p.build();
|
||||
}
|
82
central_frontend/src/widgets/BaseAuthenticatedPage.tsx
Normal file
82
central_frontend/src/widgets/BaseAuthenticatedPage.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { Box, Button } from "@mui/material";
|
||||
import * as React from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { AuthApi, AuthInfo } from "../api/AuthApi";
|
||||
import { AsyncWidget } from "./AsyncWidget";
|
||||
import { SolarEnergyAppBar } from "./SolarEnergyAppBar";
|
||||
import { SolarEnergyNavList } from "./SolarEnergyNavList";
|
||||
|
||||
interface AuthInfoContext {
|
||||
info: AuthInfo;
|
||||
reloadAuthInfo: () => void;
|
||||
}
|
||||
|
||||
const AuthInfoContextK = React.createContext<AuthInfoContext | null>(null);
|
||||
|
||||
export function BaseAuthenticatedPage(): React.ReactElement {
|
||||
const [authInfo, setAuthInfo] = React.useState<null | AuthInfo>(null);
|
||||
|
||||
const signOut = () => {
|
||||
AuthApi.SignOut();
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
setAuthInfo(await AuthApi.GetAuthInfo());
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncWidget
|
||||
loadKey="1"
|
||||
load={load}
|
||||
errMsg="Failed to load user information!"
|
||||
errAdditionalElement={() => (
|
||||
<>
|
||||
<Button onClick={signOut}>Sign out</Button>
|
||||
</>
|
||||
)}
|
||||
build={() => (
|
||||
<AuthInfoContextK.Provider
|
||||
value={{
|
||||
info: authInfo!,
|
||||
reloadAuthInfo: load,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="div"
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === "light"
|
||||
? theme.palette.grey[100]
|
||||
: theme.palette.grey[900],
|
||||
color: (theme) =>
|
||||
theme.palette.mode === "light"
|
||||
? theme.palette.grey[900]
|
||||
: theme.palette.grey[100],
|
||||
}}
|
||||
>
|
||||
<SolarEnergyAppBar onSignOut={signOut} />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flex: "2",
|
||||
}}
|
||||
>
|
||||
<SolarEnergyNavList />
|
||||
<div style={{ flex: 1, display: "flex" }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
</AuthInfoContextK.Provider>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuthInfo(): AuthInfoContext {
|
||||
return React.useContext(AuthInfoContextK)!;
|
||||
}
|
19
central_frontend/src/widgets/DarkThemeButton.tsx
Normal file
19
central_frontend/src/widgets/DarkThemeButton.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import Brightness7Icon from "@mui/icons-material/Brightness7";
|
||||
import DarkModeIcon from "@mui/icons-material/DarkMode";
|
||||
import { IconButton, Tooltip } from "@mui/material";
|
||||
import { useDarkTheme } from "../hooks/context_providers/DarkThemeProvider";
|
||||
|
||||
export function DarkThemeButton(): React.ReactElement {
|
||||
const darkTheme = useDarkTheme();
|
||||
|
||||
return (
|
||||
<Tooltip title="Activer / désactiver le mode sombre">
|
||||
<IconButton
|
||||
onClick={() => darkTheme.setEnabled(!darkTheme.enabled)}
|
||||
style={{ color: "inherit" }}
|
||||
>
|
||||
{!darkTheme.enabled ? <DarkModeIcon /> : <Brightness7Icon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
16
central_frontend/src/widgets/RouterLink.tsx
Normal file
16
central_frontend/src/widgets/RouterLink.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { PropsWithChildren } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function RouterLink(
|
||||
p: PropsWithChildren<{ to: string; target?: React.HTMLAttributeAnchorTarget }>
|
||||
): React.ReactElement {
|
||||
return (
|
||||
<Link
|
||||
to={p.to}
|
||||
target={p.target}
|
||||
style={{ color: "inherit", textDecoration: "inherit" }}
|
||||
>
|
||||
{p.children}
|
||||
</Link>
|
||||
);
|
||||
}
|
85
central_frontend/src/widgets/SolarEnergyAppBar.tsx
Normal file
85
central_frontend/src/widgets/SolarEnergyAppBar.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { mdiWhiteBalanceSunny } from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import { Button } from "@mui/material";
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import * as React from "react";
|
||||
import { useAuthInfo } from "./BaseAuthenticatedPage";
|
||||
import { DarkThemeButton } from "./DarkThemeButton";
|
||||
import { RouterLink } from "./RouterLink";
|
||||
|
||||
export function SolarEnergyAppBar(p: {
|
||||
onSignOut: () => void;
|
||||
}): React.ReactElement {
|
||||
const authInfo = useAuthInfo();
|
||||
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleCloseMenu = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const signOut = () => {
|
||||
handleCloseMenu();
|
||||
p.onSignOut();
|
||||
};
|
||||
|
||||
return (
|
||||
<AppBar position="sticky">
|
||||
<Toolbar>
|
||||
<Icon
|
||||
path={mdiWhiteBalanceSunny}
|
||||
size={1}
|
||||
style={{ marginRight: "1rem" }}
|
||||
/>
|
||||
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||
<RouterLink to="/">Solar Energy</RouterLink>
|
||||
</Typography>
|
||||
|
||||
<div>
|
||||
<DarkThemeButton />
|
||||
|
||||
<Button size="large" color="inherit">
|
||||
{authInfo!.info.id}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="large"
|
||||
aria-label="account of current user"
|
||||
aria-controls="menu-appbar"
|
||||
aria-haspopup="true"
|
||||
onClick={handleMenu}
|
||||
color="inherit"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
<Menu
|
||||
id="menu-appbar"
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
keepMounted
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleCloseMenu}
|
||||
>
|
||||
<MenuItem onClick={signOut}>Déconnexion</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
}
|
59
central_frontend/src/widgets/SolarEnergyNavList.tsx
Normal file
59
central_frontend/src/widgets/SolarEnergyNavList.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { mdiChip, mdiElectricSwitch, mdiHome, mdiNewBox } from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import {
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
} from "@mui/material";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { RouterLink } from "./RouterLink";
|
||||
|
||||
export function SolarEnergyNavList(): React.ReactElement {
|
||||
return (
|
||||
<List
|
||||
dense
|
||||
component="nav"
|
||||
sx={{
|
||||
minWidth: "200px",
|
||||
backgroundColor: "background.paper",
|
||||
}}
|
||||
>
|
||||
<NavLink label="Home" uri="/" icon={<Icon path={mdiHome} size={1} />} />
|
||||
<NavLink
|
||||
label="Devices"
|
||||
uri="/devices"
|
||||
icon={<Icon path={mdiChip} size={1} />}
|
||||
/>
|
||||
<NavLink
|
||||
label="Pending devices"
|
||||
uri="/pending_devices"
|
||||
icon={<Icon path={mdiNewBox} size={1} />}
|
||||
/>
|
||||
<NavLink
|
||||
label="Relays"
|
||||
uri="/relays"
|
||||
icon={<Icon path={mdiElectricSwitch} size={1} />}
|
||||
/>
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink(
|
||||
p: Readonly<{
|
||||
icon: React.ReactElement;
|
||||
uri: string;
|
||||
label: string;
|
||||
secondaryAction?: React.ReactElement;
|
||||
}>
|
||||
): React.ReactElement {
|
||||
const location = useLocation();
|
||||
return (
|
||||
<RouterLink to={p.uri}>
|
||||
<ListItemButton selected={p.uri === location.pathname}>
|
||||
<ListItemIcon>{p.icon}</ListItemIcon>
|
||||
<ListItemText primary={p.label} />
|
||||
</ListItemButton>
|
||||
</RouterLink>
|
||||
);
|
||||
}
|
27
central_frontend/src/widgets/SolarEnergyRouteContainer.tsx
Normal file
27
central_frontend/src/widgets/SolarEnergyRouteContainer.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { Typography } from "@mui/material";
|
||||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
export function SolarEnergyRouteContainer(
|
||||
p: {
|
||||
label: string;
|
||||
actions?: React.ReactElement;
|
||||
} & PropsWithChildren
|
||||
): React.ReactElement {
|
||||
return (
|
||||
<div style={{ margin: "50px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">{p.label}</Typography>
|
||||
{p.actions ?? <></>}
|
||||
</div>
|
||||
|
||||
{p.children}
|
||||
</div>
|
||||
);
|
||||
}
|
128
central_frontend/src/widgets/StatCard.tsx
Normal file
128
central_frontend/src/widgets/StatCard.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import Box from "@mui/material/Box";
|
||||
import Card from "@mui/material/Card";
|
||||
import CardContent from "@mui/material/CardContent";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { SparkLineChart } from "@mui/x-charts/SparkLineChart";
|
||||
import { areaElementClasses } from "@mui/x-charts/LineChart";
|
||||
|
||||
export type StatCardProps = {
|
||||
title: string;
|
||||
value: string;
|
||||
interval: string;
|
||||
trend: "up" | "down" | "neutral";
|
||||
data: number[];
|
||||
};
|
||||
|
||||
function getDaysInMonth(month: number, year: number) {
|
||||
const date = new Date(year, month, 0);
|
||||
const monthName = date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
});
|
||||
const daysInMonth = date.getDate();
|
||||
const days = [];
|
||||
let i = 1;
|
||||
while (days.length < daysInMonth) {
|
||||
days.push(`${monthName} ${i}`);
|
||||
i += 1;
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
function AreaGradient({ color, id }: { color: string; id: string }) {
|
||||
return (
|
||||
<defs>
|
||||
<linearGradient id={id} x1="50%" y1="0%" x2="50%" y2="100%">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StatCard({
|
||||
title,
|
||||
value,
|
||||
interval,
|
||||
trend,
|
||||
data,
|
||||
}: StatCardProps) {
|
||||
const theme = useTheme();
|
||||
const daysInWeek = getDaysInMonth(4, 2024);
|
||||
|
||||
const trendColors = {
|
||||
up:
|
||||
theme.palette.mode === "light"
|
||||
? theme.palette.success.main
|
||||
: theme.palette.success.dark,
|
||||
down:
|
||||
theme.palette.mode === "light"
|
||||
? theme.palette.error.main
|
||||
: theme.palette.error.dark,
|
||||
neutral:
|
||||
theme.palette.mode === "light"
|
||||
? theme.palette.grey[400]
|
||||
: theme.palette.grey[700],
|
||||
};
|
||||
|
||||
const labelColors = {
|
||||
up: "success" as const,
|
||||
down: "error" as const,
|
||||
neutral: "default" as const,
|
||||
};
|
||||
|
||||
const color = labelColors[trend];
|
||||
const chartColor = trendColors[trend];
|
||||
const trendValues = { up: "+25%", down: "-25%", neutral: "+5%" };
|
||||
|
||||
return (
|
||||
<Card variant="outlined" sx={{ height: "100%", flexGrow: 1 }}>
|
||||
<CardContent>
|
||||
<Typography component="h2" variant="subtitle2" gutterBottom>
|
||||
{title}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="column"
|
||||
sx={{ justifyContent: "space-between", flexGrow: "1", gap: 1 }}
|
||||
>
|
||||
<Stack sx={{ justifyContent: "space-between" }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ justifyContent: "space-between", alignItems: "center" }}
|
||||
>
|
||||
<Typography variant="h4" component="p">
|
||||
{value}
|
||||
</Typography>
|
||||
<Chip size="small" color={color} label={trendValues[trend]} />
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{interval}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Box sx={{ width: "100%", height: 50 }}>
|
||||
<SparkLineChart
|
||||
colors={[chartColor]}
|
||||
data={data}
|
||||
area
|
||||
showHighlight
|
||||
showTooltip
|
||||
xAxis={{
|
||||
scaleType: "band",
|
||||
data: daysInWeek, // Use the correct property 'data' for xAxis
|
||||
}}
|
||||
sx={{
|
||||
[`& .${areaElementClasses.root}`]: {
|
||||
fill: `url(#area-gradient-${value})`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AreaGradient color={chartColor} id={`area-gradient-${value}`} />
|
||||
</SparkLineChart>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
68
central_frontend/src/widgets/TimeWidget.tsx
Normal file
68
central_frontend/src/widgets/TimeWidget.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { Tooltip } from "@mui/material";
|
||||
import date from "date-and-time";
|
||||
import { time } from "../utils/DateUtils";
|
||||
|
||||
export function formatDate(time: number): string {
|
||||
const t = new Date();
|
||||
t.setTime(1000 * time);
|
||||
return date.format(t, "DD/MM/YYYY HH:mm:ss");
|
||||
}
|
||||
|
||||
export function timeDiff(a: number, b: number): string {
|
||||
let diff = b - a;
|
||||
|
||||
if (diff === 0) return "now";
|
||||
if (diff === 1) return "1 second";
|
||||
|
||||
if (diff < 60) {
|
||||
return `${diff} seconds`;
|
||||
}
|
||||
|
||||
diff = Math.floor(diff / 60);
|
||||
|
||||
if (diff === 1) return "1 minute";
|
||||
if (diff < 24) {
|
||||
return `${diff} minutes`;
|
||||
}
|
||||
|
||||
diff = Math.floor(diff / 60);
|
||||
|
||||
if (diff === 1) return "1 hour";
|
||||
if (diff < 24) {
|
||||
return `${diff} hours`;
|
||||
}
|
||||
|
||||
const diffDays = Math.floor(diff / 24);
|
||||
|
||||
if (diffDays === 1) return "1 day";
|
||||
if (diffDays < 31) {
|
||||
return `${diffDays} days`;
|
||||
}
|
||||
|
||||
diff = Math.floor(diffDays / 31);
|
||||
|
||||
if (diff < 12) {
|
||||
return `${diff} month`;
|
||||
}
|
||||
|
||||
const diffYears = Math.floor(diffDays / 365);
|
||||
|
||||
if (diffYears === 1) return "1 year";
|
||||
return `${diffYears} years`;
|
||||
}
|
||||
|
||||
export function timeDiffFromNow(t: number): string {
|
||||
return timeDiff(t, time());
|
||||
}
|
||||
|
||||
export function TimeWidget(p: {
|
||||
time?: number;
|
||||
diff?: boolean;
|
||||
}): React.ReactElement {
|
||||
if (!p.time) return <></>;
|
||||
return (
|
||||
<Tooltip title={formatDate(p.time)} arrow>
|
||||
<span>{p.diff ? timeDiff(0, p.time) : timeDiffFromNow(p.time)}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
21
central_frontend/src/widgets/forms/CheckboxInput.tsx
Normal file
21
central_frontend/src/widgets/forms/CheckboxInput.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { Checkbox, FormControlLabel } from "@mui/material";
|
||||
|
||||
export function CheckboxInput(p: {
|
||||
editable: boolean;
|
||||
label: string;
|
||||
checked: boolean | undefined;
|
||||
onValueChange: (v: boolean) => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
disabled={!p.editable}
|
||||
checked={p.checked}
|
||||
onChange={(e) => p.onValueChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={p.label}
|
||||
/>
|
||||
);
|
||||
}
|
113
central_frontend/src/widgets/forms/MultipleSelectInput.tsx
Normal file
113
central_frontend/src/widgets/forms/MultipleSelectInput.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import {
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
OutlinedInput,
|
||||
Box,
|
||||
Chip,
|
||||
MenuItem,
|
||||
Theme,
|
||||
useTheme,
|
||||
SelectChangeEvent,
|
||||
FormHelperText,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
|
||||
export interface Value<E> {
|
||||
label: string;
|
||||
value: E;
|
||||
}
|
||||
|
||||
const ITEM_HEIGHT = 48;
|
||||
const ITEM_PADDING_TOP = 8;
|
||||
|
||||
const MenuProps = {
|
||||
PaperProps: {
|
||||
style: {
|
||||
maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP,
|
||||
width: 250,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function getStyles<E>(v: Value<E>, selected: readonly E[], theme: Theme) {
|
||||
return {
|
||||
fontWeight:
|
||||
selected.find((e) => e === v.value) === undefined
|
||||
? theme.typography.fontWeightRegular
|
||||
: theme.typography.fontWeightMedium,
|
||||
};
|
||||
}
|
||||
|
||||
export function MultipleSelectInput<E>(p: {
|
||||
values: Value<E>[];
|
||||
selected: E[];
|
||||
label: string;
|
||||
onChange: (selected: E[]) => void;
|
||||
helperText?: string;
|
||||
}): React.ReactElement {
|
||||
const [labelId] = React.useState(`id-multi-${Math.random()}`);
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const handleChange = (event: SelectChangeEvent<E>) => {
|
||||
const {
|
||||
target: { value },
|
||||
} = event;
|
||||
|
||||
const values: any[] =
|
||||
typeof value === "string" ? value.split(",") : (value as any);
|
||||
|
||||
const newVals = values.map(
|
||||
(v) => p.values.find((e) => String(e.value) === String(v))!.value
|
||||
);
|
||||
|
||||
// Values that appear multiple times are toggled
|
||||
const setVal = new Set<E>();
|
||||
for (const el of newVals) {
|
||||
if (!setVal.has(el)) setVal.add(el);
|
||||
else setVal.delete(el);
|
||||
}
|
||||
|
||||
p.onChange([...setVal]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id={labelId}>{p.label}</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
labelId={labelId}
|
||||
id="bad"
|
||||
label={p.label}
|
||||
value={p.selected as any}
|
||||
onChange={handleChange}
|
||||
input={<OutlinedInput id="select-multiple-chip" label="Chip" />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
|
||||
{(selected as Array<E>).map((value) => (
|
||||
<Chip
|
||||
key={String(value)}
|
||||
label={p.values.find((e) => e.value === value)!.label}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
MenuProps={MenuProps}
|
||||
>
|
||||
{p.values.map((v) => (
|
||||
<MenuItem
|
||||
key={v.label + String(v)}
|
||||
value={String(v.value)}
|
||||
style={getStyles(v, p.selected, theme)}
|
||||
>
|
||||
{v.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{p.helperText && <FormHelperText>{p.helperText}</FormHelperText>}
|
||||
</FormControl>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import { DeviceRelay, RelayID } from "../../api/DeviceApi";
|
||||
import { RelayApi } from "../../api/RelayApi";
|
||||
import { AsyncWidget } from "../AsyncWidget";
|
||||
import { MultipleSelectInput } from "./MultipleSelectInput";
|
||||
|
||||
export function SelectMultipleRelaysInput(p: {
|
||||
label: string;
|
||||
value: RelayID[];
|
||||
onValueChange: (ids: RelayID[]) => void;
|
||||
exclude?: RelayID[];
|
||||
helperText?: string;
|
||||
}): React.ReactElement {
|
||||
const [list, setList] = React.useState<DeviceRelay[]>();
|
||||
|
||||
const load = async () => {
|
||||
setList(await RelayApi.GetList());
|
||||
};
|
||||
|
||||
const values =
|
||||
list?.map((r) => {
|
||||
return {
|
||||
label: r.name,
|
||||
value: r.id,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
return (
|
||||
<AsyncWidget
|
||||
loadKey={1}
|
||||
load={load}
|
||||
errMsg="Failed to load the list of relays!"
|
||||
build={() => (
|
||||
<MultipleSelectInput
|
||||
label={p.label}
|
||||
onChange={p.onValueChange}
|
||||
selected={p.value}
|
||||
helperText={p.helperText}
|
||||
values={values}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
61
central_frontend/src/widgets/forms/TextInput.tsx
Normal file
61
central_frontend/src/widgets/forms/TextInput.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { TextField } from "@mui/material";
|
||||
import { LenConstraint } from "../../api/ServerApi";
|
||||
|
||||
/**
|
||||
* Text property edition
|
||||
*/
|
||||
export function TextInput(p: {
|
||||
label?: string;
|
||||
editable: boolean;
|
||||
value?: string;
|
||||
onValueChange?: (newVal: string | undefined) => void;
|
||||
size?: LenConstraint;
|
||||
checkValue?: (s: string) => boolean;
|
||||
multiline?: boolean;
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
type?: React.HTMLInputTypeAttribute;
|
||||
style?: React.CSSProperties;
|
||||
helperText?: string;
|
||||
}): React.ReactElement {
|
||||
if (!p.editable && (p.value ?? "") === "") return <></>;
|
||||
|
||||
let valueError = undefined;
|
||||
if (p.value && p.value.length > 0) {
|
||||
if (p.size?.min && p.type !== "number" && p.value.length < p.size.min)
|
||||
valueError = "Value is too short!";
|
||||
if (p.checkValue && !p.checkValue(p.value)) valueError = "Invalid value!";
|
||||
if (
|
||||
p.type === "number" &&
|
||||
p.size &&
|
||||
(Number(p.value) > p.size.max || Number(p.value) < p.size.min)
|
||||
)
|
||||
valueError = "Invalide size range!";
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
label={p.label}
|
||||
value={p.value ?? ""}
|
||||
onChange={(e) =>
|
||||
p.onValueChange?.(
|
||||
e.target.value.length === 0 ? undefined : e.target.value
|
||||
)
|
||||
}
|
||||
inputProps={{
|
||||
maxLength: p.size?.max,
|
||||
}}
|
||||
InputProps={{
|
||||
readOnly: !p.editable,
|
||||
type: p.type,
|
||||
}}
|
||||
variant={"standard"}
|
||||
style={p.style ?? { width: "100%", marginBottom: "15px" }}
|
||||
multiline={p.multiline}
|
||||
minRows={p.minRows}
|
||||
maxRows={p.maxRows}
|
||||
error={valueError !== undefined}
|
||||
helperText={valueError ?? p.helperText}
|
||||
/>
|
||||
);
|
||||
}
|
27
central_frontend/tsconfig.app.json
Normal file
27
central_frontend/tsconfig.app.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
11
central_frontend/tsconfig.json
Normal file
11
central_frontend/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
13
central_frontend/tsconfig.node.json
Normal file
13
central_frontend/tsconfig.node.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user