Compare commits

...

110 Commits

Author SHA1 Message Date
719b0a0c5c Energy actor shall never fail 2024-09-19 21:29:31 +02:00
fe0bc03c03 Implement catchup hours logic 2024-09-19 21:26:57 +02:00
09c25a67c5 Can count the time a relay was up during a given amount of time 2024-09-18 22:05:23 +02:00
92878e6548 Delete relay energy information 2024-09-17 22:42:24 +02:00
565db05fb0 Record relays state 2024-09-17 22:31:51 +02:00
368eb13089 Add relay state history proto 2024-09-16 22:41:20 +02:00
20bc71851d Take relays consumption in account 2024-09-16 22:27:43 +02:00
79b2ad12d8 Add missing device information synchronization 2024-09-15 22:06:42 +02:00
9c45e541dd Better handle enabled / disabled relays 2024-09-15 22:01:06 +02:00
2262b98952 WIP engine 2024-09-15 21:53:08 +02:00
f0081eb4bf Virtually turn off all relays that can be stopped 2024-09-13 22:11:40 +02:00
1d11c3a968 WIP energy engine update 2024-09-12 21:45:58 +02:00
c1c01058d8 Ready to implement update logic 2024-09-10 19:55:51 +02:00
c74ed0cfbb Refactor energy management 2024-09-10 19:40:06 +02:00
36ba4efd9f Update general device information 2024-09-09 21:43:57 +02:00
a97614ce44 Display relay status on relays page 2024-09-09 21:27:15 +02:00
7cac6aeb35 Store last ping of devices 2024-09-09 21:06:33 +02:00
6bdebe6932 Revert bad change 2024-09-04 22:45:51 +02:00
1b02a812b4 Start to build sync route 2024-09-04 22:43:23 +02:00
ee938a3aa6 Encode JWT 2024-09-04 20:17:11 +02:00
1784a0a1f8 Display live and cached consumption on dashboard 2024-09-02 22:17:34 +02:00
539703b904 Display the list of relays 2024-09-02 21:52:45 +02:00
583dd7c8f7 Fix bad self-loop check 2024-08-31 20:54:14 +02:00
78663854cc Check for dependencies conflict before deleting a device 2024-08-31 20:46:02 +02:00
bbe128e055 Add the route to update a relay 2024-08-31 20:26:16 +02:00
b0023a5167 Can delete a device relay from UI 2024-08-31 20:03:46 +02:00
f35aac04f6 Add the route to delete a relay 2024-08-31 20:00:40 +02:00
871d5109bf Fix authentication issue 2024-08-31 18:52:29 +02:00
2022e99274 Update dependencies 2024-08-31 18:26:24 +02:00
de277cc306 Update frontend dependencies 2024-08-31 18:13:29 +02:00
8c2dcd3855 Merge branch 'master' of http://mygit.internal/pierre/SolarEnergy 2024-08-31 18:06:30 +02:00
9e24587541 Update check 2024-08-29 00:27:06 +02:00
31f4203c43 Request device certificate 2024-08-29 00:09:47 +02:00
6028be92ef Anticipate relay ID collision 2024-08-27 22:35:07 +02:00
87fb3360fb Can create a relay 2024-08-27 22:32:22 +02:00
f46a7dbc94 Update tests 2024-08-27 18:41:09 +02:00
50e61707cc Check for loops in relays 2024-08-27 18:38:49 +02:00
d890b23670 Submit CSR to server 2024-08-23 23:06:14 +02:00
3b7e2f9a0c WIP enroll device 2024-08-23 21:00:18 +02:00
dd0a957a63 Fix identation 2024-08-18 21:04:23 +02:00
336c838eb0 Remove check 2024-08-18 21:01:48 +02:00
05e347e80c Check for memory leaks 2024-08-18 21:01:34 +02:00
38197afd79 Decode enrollment status JSON response 2024-08-18 20:33:26 +02:00
3b5d2abcc0 Manage to perfom secure request 2024-08-18 20:13:03 +02:00
a6b283d023 Can get root CA 2024-08-18 19:42:40 +02:00
3b6e79e5e4 Store central secure origin 2024-08-18 17:40:41 +02:00
3867a38ff9 Perform HTTP request on backend to retrieve secure endpoint location 2024-08-18 16:56:05 +02:00
59ba55793e Can wait for network to finish boot 2024-08-17 17:40:14 +02:00
f60f6f6ccc Register network events 2024-08-17 17:22:29 +02:00
d5dc6dae46 First Ethernet activation 2024-08-17 17:19:47 +02:00
0d90973842 Start to work on networking 2024-08-17 13:49:55 +02:00
6b9d5e9d85 Merge branch 'master' of ssh://gitea.communiquons.org:52001/pierre/SolarEnergy 2024-08-16 09:52:49 +02:00
9966904e4d Get the CSR 2024-08-16 11:51:33 +02:00
0c11703cea Show device private key 2024-08-15 13:32:01 +02:00
752bf50ad3 Write private key 2024-08-15 13:09:01 +02:00
e18162b32d Can build central in production mode 2024-08-07 16:44:30 +02:00
48a2f728de Start to check device relay information 2024-07-31 23:33:58 +02:00
5497c36c75 Create API routes to request relay information 2024-07-30 23:04:53 +02:00
3004b03d92 Add select for relays 2024-07-30 22:54:47 +02:00
596d22739d Can select catchup hours 2024-07-29 23:13:53 +02:00
8a65687970 Start to build relay dialog 2024-07-29 22:11:13 +02:00
402edb44d5 Start to generate private key 2024-07-27 16:34:41 +02:00
0c6c0f4a7f WIP 2024-07-27 16:15:35 +02:00
900b436856 Generate device name 2024-07-27 15:31:17 +02:00
73163e6e69 Can get the full list of relays through the API 2024-07-24 23:35:58 +02:00
4d5ba939d1 Can update device general information 2024-07-22 22:19:48 +02:00
baf341d505 Move delete device button to button page 2024-07-22 18:20:36 +02:00
1ce9ca3321 Display basic device information 2024-07-18 20:06:46 +02:00
7be81fe0e9 Add link to device page 2024-07-17 23:19:04 +02:00
370084b3bb Add devices definitions 2024-07-17 18:57:23 +02:00
37406faa32 Automatically regenerate CRLs at regular interval 2024-07-17 18:44:09 +02:00
717ad5b5e0 Can revoke issued certificates 2024-07-17 18:31:57 +02:00
0e32622720 Create ESP32 project 2024-07-16 21:05:20 +02:00
751e33cb72 Display the list of devices 2024-07-04 19:52:09 +02:00
b59e807de1 On Python device, automatically delete invalid certificate if status leaves the "Validated" mode 2024-07-03 22:22:36 +02:00
6ad50657a5 Automatically download certificate on Python device 2024-07-03 22:19:56 +02:00
9cba9c5f0a Add a button to refresh table 2024-07-03 22:07:41 +02:00
8674d25512 Can get a single device enrollment status 2024-07-03 22:05:19 +02:00
e97ef6fe45 Validate devices 2024-07-03 21:32:32 +02:00
2502ed6bcf Can delete a pending device 2024-07-03 21:10:15 +02:00
716af6219a Display the list of pending devices in the UI 2024-07-03 19:17:47 +02:00
01ffe085d7 Complete enroll route 2024-07-02 22:55:51 +02:00
e64a444bd0 Can issue certificate for devices 2024-07-01 22:24:03 +02:00
9ba4aa5194 Start to implement devices enrollment 2024-07-01 21:10:45 +02:00
378c296e71 Devices can request current time with a precision to the millisecond 2024-07-01 17:56:10 +02:00
8918547375 Custom consumption widget is operational 2024-06-30 23:04:04 +02:00
1f14cf8212 Draw ui 2024-06-30 22:54:23 +02:00
f468f192d8 Can read consumption from a file 2024-06-30 20:14:23 +02:00
c5c11970a1 Sign CSR 2024-06-30 10:14:42 +02:00
426c25fce5 Generate private key from Python client 2024-06-30 09:46:15 +02:00
4c4d1e13cb Load root CA 2024-06-29 18:08:57 +02:00
dca8848ec9 Start to create Python client 2024-06-29 18:05:58 +02:00
1d32ca1559 Create home page 2024-06-29 16:45:28 +02:00
e1739d9818 Add authentication layer 2024-06-29 14:43:56 +02:00
738c53c8b9 Add base login route 2024-06-29 13:26:12 +02:00
236871e241 Add base react app 2024-06-29 13:01:50 +02:00
d4a81f5fdf Create energy actor 2024-06-29 11:45:39 +02:00
49a3e3a669 Start to implement energy consumption backend 2024-06-29 10:11:31 +02:00
9d3e2beb81 Serve PKI files 2024-06-28 22:28:43 +02:00
b4647d70a0 Leaf certificates are explicitly marked as non CA 2024-06-28 22:04:36 +02:00
11054385a6 Add servers 2024-06-28 22:00:20 +02:00
09f526bfb7 Generate server certificate 2024-06-28 21:34:18 +02:00
f4fde9bc46 Refresh all CRLs 2024-06-28 19:43:33 +02:00
24f8f8f842 Fix issue 2024-06-28 19:39:07 +02:00
aa97d28657 Generate first CRL 2024-06-28 19:29:18 +02:00
32d5707055 Update 2024-06-28 19:19:17 +02:00
8bac181552 Improve certificates issuance 2024-06-28 17:21:40 +02:00
716e524bf4 WIP cert authorities 2024-06-28 01:34:22 +02:00
ffb8cbb6eb WIP cert authorities 2024-06-28 01:34:15 +02:00
f4e2bb69b6 WIP cert authorities 2024-06-28 01:05:02 +02:00
151 changed files with 24365 additions and 168 deletions

11
Makefile Normal file
View 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

2
README.md Normal file
View File

@ -0,0 +1,2 @@
# SolarEnergy
WIP project

View File

@ -1,3 +1,4 @@
target
.idea
storage
static

File diff suppressed because it is too large Load Diff

View File

@ -4,10 +4,36 @@ version = "0.1.0"
edition = "2021"
[dependencies]
log = "0.4.21"
env_logger = "0.11.3"
log = "0.4.22"
env_logger = "0.11.5"
lazy_static = "1.5.0"
clap = { version = "4.5.7", features = ["derive", "env"] }
clap = { version = "4.5.15", features = ["derive", "env"] }
anyhow = "1.0.86"
thiserror = "1.0.61"
openssl = { version = "0.10.64" }
thiserror = "1.0.63"
openssl = { version = "0.10.66" }
openssl-sys = "0.9.102"
libc = "0.2.155"
foreign-types-shared = "0.1.1"
asn1 = "0.17"
actix-web = { version = "4", features = ["openssl"] }
futures = "0.3.30"
serde = { version = "1.0.206", features = ["derive"] }
reqwest = "0.12.5"
serde_json = "1.0.123"
rand = "0.8.5"
actix = "0.13.5"
actix-identity = "0.7.1"
actix-session = { version = "0.9.0", features = ["cookie-session"] }
actix-cors = "0.7.0"
actix-remote-ip = "0.1.0"
futures-util = "0.3.30"
uuid = { version = "1.10.0", features = ["v4", "serde"] }
semver = { version = "1.0.23", features = ["serde"] }
lazy-regex = "3.2.0"
tokio = { version = "1.39.2", features = ["full"] }
tokio_schedule = "0.3.2"
mime_guess = "2.0.5"
rust-embed = "8.5.0"
jsonwebtoken = { version = "9.3.0", features = ["use_pem"] }
prettytable-rs = "0.10.0"
chrono = "0.4.38"

View File

@ -1,17 +1,92 @@
use crate::devices::device::{DeviceId, DeviceRelayID};
use clap::{Parser, Subcommand};
use std::path::{Path, PathBuf};
use clap::Parser;
/// Electrical consumption fetcher backend
#[derive(Subcommand, Debug, Clone)]
pub enum ConsumptionBackend {
/// Constant consumption value
Constant {
/// The constant value to use
#[clap(short, long, default_value_t = 500)]
value: i32,
},
/// Generate random consumption value
Random {
/// Minimum acceptable generated value
#[clap(long, default_value_t = -5000)]
min: i32,
/// Maximum acceptable generated value
#[clap(long, default_value_t = 20000)]
max: i32,
},
/// Read consumption value in a file, on the filesystem
File {
/// The path to the file that will be read to process consumption values
#[clap(short, long, default_value = "/dev/shm/consumption.txt")]
path: String,
},
}
/// Solar system central backend
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct AppConfig {
/// Proxy IP, might end with a star "*"
#[clap(short, long, env)]
pub proxy_ip: Option<String>,
/// Secret key, used to sign some resources. Must be randomly generated
#[clap(short = 'S', long, env, default_value = "")]
secret: String,
/// Specify whether the cookie should be transmitted only over secure connections
///
/// This should be always true when running in production mode
#[clap(long, env)]
pub cookie_secure: bool,
/// Unsecure : for development, bypass authentication
#[clap(long, env)]
pub unsecure_disable_login: bool,
/// Admin username
#[clap(long, env, default_value = "admin")]
pub admin_username: String,
/// Admin password
#[clap(long, env, default_value = "admin")]
pub admin_password: String,
/// The port the server will listen to (using HTTPS)
#[arg(short, long, env, default_value = "0.0.0.0:8443")]
listen_address: String,
pub listen_address: String,
/// The port the server will listen to (using HTTP, for unsecure connections)
#[arg(short, long, env, default_value = "0.0.0.0:8080")]
pub unsecure_listen_address: String,
/// Public server hostname (assuming that the ports used are the same for listen address)
#[arg(short('H'), long, env, default_value = "localhost")]
pub hostname: String,
/// Server storage path
#[arg(short, long, env, default_value = "storage")]
storage: String,
/// The minimal production that must be excluded when selecting relays to turn on
#[arg(short('m'), long, env, default_value_t = -500)]
pub production_margin: i32,
/// Energy refresh operations interval, in seconds
#[arg(short('i'), long, env, default_value_t = 20)]
pub refresh_interval: u64,
/// Consumption backend provider
#[clap(subcommand)]
pub consumption_backend: Option<ConsumptionBackend>,
}
lazy_static::lazy_static! {
@ -26,6 +101,55 @@ impl AppConfig {
&ARGS
}
/// Get app secret
pub fn secret(&self) -> &str {
let mut secret = self.secret.as_str();
if cfg!(debug_assertions) && secret.is_empty() {
secret = "DEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEY";
}
if secret.is_empty() {
panic!("SECRET is undefined or too short (min 64 chars)!")
}
secret
}
/// URL for unsecure connections
pub fn unsecure_origin(&self) -> String {
format!(
"http://{}:{}",
self.hostname,
self.unsecure_listen_address.split_once(':').unwrap().1
)
}
/// URL for secure connections
pub fn secure_origin(&self) -> String {
format!(
"https://{}:{}",
self.hostname,
self.listen_address.split_once(':').unwrap().1
)
}
/// Get auth cookie domain
pub fn cookie_domain(&self) -> Option<String> {
if cfg!(debug_assertions) {
let domain = self.secure_origin().split_once("://")?.1.to_string();
Some(
domain
.split_once(':')
.map(|s| s.0)
.unwrap_or(&domain)
.to_string(),
)
} else {
// In release mode, the web app is hosted on the same origin as the API
None
}
}
/// Get storage path
pub fn storage_path(&self) -> PathBuf {
@ -39,13 +163,94 @@ impl AppConfig {
/// Get PKI root CA cert path
pub fn root_ca_cert_path(&self) -> PathBuf {
self.pki_path().join("root_ca.pem")
self.pki_path().join("root_ca.crt")
}
/// Get PKI root CA CRL path
pub fn root_ca_crl_path(&self) -> PathBuf {
self.pki_path().join("root_ca.crl")
}
/// Get PKI root CA private key path
pub fn root_ca_priv_key_path(&self) -> PathBuf {
self.pki_path().join("root_ca.key")
}
/// Get PKI web CA cert path
pub fn web_ca_cert_path(&self) -> PathBuf {
self.pki_path().join("web_ca.crt")
}
/// Get PKI web CA CRL path
pub fn web_ca_crl_path(&self) -> PathBuf {
self.pki_path().join("web_ca.crl")
}
/// Get PKI web CA private key path
pub fn web_ca_priv_key_path(&self) -> PathBuf {
self.pki_path().join("web_ca.key")
}
/// Get PKI devices CA cert path
pub fn devices_ca_cert_path(&self) -> PathBuf {
self.pki_path().join("devices_ca.crt")
}
/// Get PKI devices CA CRL path
pub fn devices_ca_crl_path(&self) -> PathBuf {
self.pki_path().join("devices_ca.crl")
}
/// Get PKI devices CA private key path
pub fn devices_ca_priv_key_path(&self) -> PathBuf {
self.pki_path().join("devices_ca.key")
}
/// Get PKI server cert path
pub fn server_cert_path(&self) -> PathBuf {
self.pki_path().join("server.crt")
}
/// Get PKI server private key path
pub fn server_priv_key_path(&self) -> PathBuf {
self.pki_path().join("server.key")
}
/// Get devices configuration storage path
pub fn devices_config_path(&self) -> PathBuf {
self.storage_path().join("devices")
}
/// Get device configuration path
pub fn device_config_path(&self, id: &DeviceId) -> PathBuf {
self.devices_config_path().join(format!("{}.conf", id.0))
}
/// Get device certificate path
pub fn device_cert_path(&self, id: &DeviceId) -> PathBuf {
self.devices_config_path().join(format!("{}.crt", id.0))
}
/// Get device CSR path
pub fn device_csr_path(&self, id: &DeviceId) -> PathBuf {
self.devices_config_path().join(format!("{}.csr", id.0))
}
/// Get relays runtime storage path
pub fn relays_runtime_stats_storage_path(&self) -> PathBuf {
self.storage_path().join("relays_runtime")
}
/// Get relay runtime stats path for a given relay
pub fn relay_runtime_stats_dir(&self, relay_id: DeviceRelayID) -> PathBuf {
self.relays_runtime_stats_storage_path()
.join(relay_id.0.to_string())
}
/// Get relay runtime stats path for a given relay for a given day
pub fn relay_runtime_day_file_path(&self, relay_id: DeviceRelayID, day: u64) -> PathBuf {
self.relay_runtime_stats_dir(relay_id).join(day.to_string())
}
}
#[cfg(test)]
@ -57,4 +262,4 @@ mod test {
use clap::CommandFactory;
AppConfig::command().debug_assert()
}
}
}

View 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),
}
}
}

View 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(),
)?)
}
}

View File

@ -0,0 +1,3 @@
pub mod crl_extension;
pub mod openssl_utils;
pub mod pki;

View 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);
}
}

View 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(&not_before)?;
let not_after = Asn1Time::days_from_now(365 * 30)?;
cert_builder.set_not_after(&not_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()?)
}

View 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());
}
}

View 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(())
}
}

View File

@ -0,0 +1,2 @@
pub mod device;
pub mod devices_list;

View 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)?)
}
}
}

View 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()
}
}

View 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(())
}
}

View File

@ -0,0 +1,4 @@
pub mod consumption;
pub mod energy_actor;
pub mod engine;
pub mod relay_state_history;

View 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
);
}
}

View File

@ -1,3 +1,7 @@
pub mod app_config;
pub mod pki;
pub mod utils;
pub mod constants;
pub mod crypto;
pub mod devices;
pub mod energy;
pub mod server;
pub mod utils;

View File

@ -1,13 +1,51 @@
use actix::Actor;
use central_backend::app_config::AppConfig;
use central_backend::pki;
use central_backend::crypto::pki;
use central_backend::energy::energy_actor::EnergyActor;
use central_backend::server::servers;
use central_backend::utils::files_utils::create_directory_if_missing;
use futures::future;
use tokio_schedule::{every, Job};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// Initialize OpenSSL
openssl_sys::init();
fn main() {
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
// Initialize storage
create_directory_if_missing(&AppConfig::get().pki_path()).unwrap();
create_directory_if_missing(AppConfig::get().pki_path()).unwrap();
create_directory_if_missing(AppConfig::get().devices_config_path()).unwrap();
create_directory_if_missing(AppConfig::get().relays_runtime_stats_storage_path()).unwrap();
// Initialize PKI
pki::initialize_root_ca().expect("Failed to initialize Root CA!");
pki::initialize_web_ca().expect("Failed to initialize web CA!");
pki::initialize_devices_ca().expect("Failed to initialize devices CA!");
pki::initialize_server_ca().expect("Failed to initialize server certificate!");
// Initialize CRL
pki::refresh_crls().expect("Failed to initialize Root CA!");
let refresh_crl = every(1).hour().perform(|| async {
log::info!("Periodic refresh of CRLs...");
if let Err(e) = pki::refresh_crls() {
log::error!("Failed to perform auto refresh of CRLs! {e}");
}
});
tokio::spawn(refresh_crl);
// Initialize energy actor
let actor = EnergyActor::new()
.await
.expect("Failed to initialize energy actor!")
.start();
let s1 = servers::secure_server(actor);
let s2 = servers::unsecure_server();
future::try_join(s1, s2)
.await
.expect("Failed to start servers!");
Ok(())
}

View File

@ -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(&not_before)?;
let not_after = Asn1Time::days_from_now(365 * 30)?;
cert_builder.set_not_after(&not_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(())
}

View 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)
})
}
}

View 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>;

View 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))
}

View File

@ -0,0 +1,2 @@
pub mod mgmt_controller;
pub mod utils_controller;

View 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(),
}))
}

View 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>;

View 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(())
}

View File

@ -0,0 +1,2 @@
pub mod unsecure_pki_controller;
pub mod unsecure_server_controller;

View File

@ -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!"))
}

View File

@ -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())
}

View 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())
}

View 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())
}

View 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 }))
}

View 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;

View 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())
}

View 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())
}

View 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)
}
}

View File

@ -7,4 +7,4 @@ pub fn create_directory_if_missing<P: AsRef<Path>>(path: P) -> anyhow::Result<()
std::fs::create_dir_all(path)?;
}
Ok(())
}
}

View File

@ -1 +1,2 @@
pub mod files_utils;
pub mod files_utils;
pub mod time_utils;

View 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
View File

@ -0,0 +1 @@
VITE_APP_BACKEND=https://localhost:8443/web_api

View File

@ -0,0 +1 @@
VITE_APP_BACKEND=/web_api

View 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
View 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?

View 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

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

View 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

View 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} />;
}

View 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,
};
}
}

View 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 = "/";
}
}
}

View 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",
});
}
}

View 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;
}
}

View 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}`,
});
}
}

View 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;
}
}

View 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>
);
}

View 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>
</>
);
}

View File

@ -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)!;
}

View File

@ -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)!;
}

View File

@ -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)!;
}

View File

@ -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)!;
}

View File

@ -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)!;
}

View File

@ -0,0 +1,9 @@
body {
margin: 0;
}
html,
body,
#root {
height: 100%;
}

View 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>
);

View File

@ -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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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>
</>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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"}
/>
);
}

View File

@ -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"}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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();
}

View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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();
}

View 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)!;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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>
);
}

View File

@ -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}
/>
)}
/>
);
}

View 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}
/>
);
}

View 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"]
}

View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}

Some files were not shown because too many files have changed in this diff Show More