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
|
target
|
||||||
.idea
|
.idea
|
||||||
storage
|
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"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
log = "0.4.21"
|
log = "0.4.22"
|
||||||
env_logger = "0.11.3"
|
env_logger = "0.11.5"
|
||||||
lazy_static = "1.5.0"
|
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"
|
anyhow = "1.0.86"
|
||||||
thiserror = "1.0.61"
|
thiserror = "1.0.63"
|
||||||
openssl = { version = "0.10.64" }
|
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 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
|
/// Solar system central backend
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(version, about, long_about = None)]
|
#[command(version, about, long_about = None)]
|
||||||
pub struct AppConfig {
|
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)
|
/// The port the server will listen to (using HTTPS)
|
||||||
#[arg(short, long, env, default_value = "0.0.0.0:8443")]
|
#[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
|
/// Server storage path
|
||||||
#[arg(short, long, env, default_value = "storage")]
|
#[arg(short, long, env, default_value = "storage")]
|
||||||
storage: String,
|
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! {
|
lazy_static::lazy_static! {
|
||||||
@ -26,6 +101,55 @@ impl AppConfig {
|
|||||||
&ARGS
|
&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
|
/// Get storage path
|
||||||
pub fn storage_path(&self) -> PathBuf {
|
pub fn storage_path(&self) -> PathBuf {
|
||||||
@ -39,13 +163,94 @@ impl AppConfig {
|
|||||||
|
|
||||||
/// Get PKI root CA cert path
|
/// Get PKI root CA cert path
|
||||||
pub fn root_ca_cert_path(&self) -> PathBuf {
|
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
|
/// Get PKI root CA private key path
|
||||||
pub fn root_ca_priv_key_path(&self) -> PathBuf {
|
pub fn root_ca_priv_key_path(&self) -> PathBuf {
|
||||||
self.pki_path().join("root_ca.key")
|
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)]
|
#[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 app_config;
|
||||||
pub mod pki;
|
pub mod constants;
|
||||||
|
pub mod crypto;
|
||||||
|
pub mod devices;
|
||||||
|
pub mod energy;
|
||||||
|
pub mod server;
|
||||||
pub mod utils;
|
pub mod utils;
|
@ -1,13 +1,51 @@
|
|||||||
|
use actix::Actor;
|
||||||
use central_backend::app_config::AppConfig;
|
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 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"));
|
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
|
||||||
|
|
||||||
// Initialize storage
|
// 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
|
// Initialize PKI
|
||||||
pki::initialize_root_ca().expect("Failed to initialize Root CA!");
|
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 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