Merge branch 'master' of ssh://gitea.communiquons.org:52001/pierre/SolarEnergy

This commit is contained in:
Pierre HUBERT 2024-08-16 09:52:49 +02:00
commit 6b9d5e9d85
38 changed files with 1923 additions and 116 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

View File

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

View File

@ -386,6 +386,21 @@ dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "android-tzdata"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "0.6.14"
@ -595,14 +610,18 @@ dependencies = [
"lazy_static",
"libc",
"log",
"mime_guess",
"openssl",
"openssl-sys",
"rand",
"reqwest",
"rust-embed",
"semver",
"serde",
"serde_json",
"thiserror",
"tokio",
"tokio_schedule",
"uuid",
]
@ -612,6 +631,20 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-targets 0.52.5",
]
[[package]]
name = "cipher"
version = "0.4.4"
@ -1064,6 +1097,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "hkdf"
version = "0.12.4"
@ -1218,6 +1257,29 @@ dependencies = [
"tracing",
]
[[package]]
name = "iana-time-zone"
version = "0.1.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "idna"
version = "0.5.0"
@ -1381,6 +1443,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.7.4"
@ -1425,6 +1497,25 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "num_cpus"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "object"
version = "0.36.0"
@ -1737,6 +1828,40 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rust-embed"
version = "8.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa66af4a4fdd5e7ebc276f115e895611a34739a9c1c01028383d612d550953c0"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6125dbc8867951125eec87294137f4e9c2c96566e61bf72c45095a7c77761478"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5347777e9aacb56039b0e1f28785929a8a3b709e87482e7442c72e7c12529d"
dependencies = [
"sha2",
"walkdir",
]
[[package]]
name = "rustc-demangle"
version = "0.1.24"
@ -1811,6 +1936,15 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.23"
@ -2093,21 +2227,34 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.38.0"
version = "1.38.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a"
checksum = "eb2caba9f80616f438e09748d5acda951967e1ea58508ef53d9c6402485a46df"
dependencies = [
"backtrace",
"bytes",
"libc",
"mio",
"num_cpus",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.48.0",
]
[[package]]
name = "tokio-macros"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
@ -2154,6 +2301,16 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio_schedule"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c291c554da3518d6ef69c76ea35aabc78f736185a16b6017f6d1c224dac2e0"
dependencies = [
"chrono",
"tokio",
]
[[package]]
name = "tower"
version = "0.4.13"
@ -2213,6 +2370,15 @@ version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
[[package]]
name = "unicase"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89"
dependencies = [
"version_check",
]
[[package]]
name = "unicode-bidi"
version = "0.3.15"
@ -2289,6 +2455,16 @@ version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.1"
@ -2380,6 +2556,24 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "winapi-util"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "windows-core"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
dependencies = [
"windows-targets 0.52.5",
]
[[package]]
name = "windows-sys"
version = "0.48.0"

View File

@ -30,3 +30,7 @@ futures-util = "0.3.30"
uuid = { version = "1.9.1", features = ["v4", "serde"] }
semver = { version = "1.0.23", features = ["serde"] }
lazy-regex = "3.1.0"
tokio = { version = "1.38.1", features = ["full"] }
tokio_schedule = "0.3.2"
mime_guess = "2.0.5"
rust-embed = "8.5.0"

View File

@ -18,3 +18,62 @@ 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

@ -13,10 +13,11 @@ use openssl::pkey::{PKey, Private};
use openssl::x509::extension::{
BasicConstraints, ExtendedKeyUsage, KeyUsage, SubjectAlternativeName, SubjectKeyIdentifier,
};
use openssl::x509::{X509Crl, X509Name, X509NameBuilder, X509Req, X509};
use openssl::x509::{CrlStatus, X509Crl, X509Name, X509NameBuilder, X509Req, X509};
use openssl_sys::{
X509_CRL_add0_revoked, X509_CRL_set1_lastUpdate, X509_CRL_set1_nextUpdate,
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;
@ -365,7 +366,7 @@ pub fn initialize_server_ca() -> anyhow::Result<()> {
}
/// Initialize or refresh a CRL
fn refresh_crl(d: &CertData) -> anyhow::Result<()> {
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() {
@ -373,7 +374,9 @@ fn refresh_crl(d: &CertData) -> anyhow::Result<()> {
// 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 {
if next_update.compare(Asn1Time::days_from_now(0)?.as_ref())? == Ordering::Greater
&& new_cert.is_none()
{
return Ok(());
}
@ -386,7 +389,7 @@ fn refresh_crl(d: &CertData) -> anyhow::Result<()> {
// based on https://github.com/openssl/openssl/blob/master/crypto/x509/x509_vfy.c
unsafe {
let crl = openssl_sys::X509_CRL_new();
let crl = X509_CRL_new();
if crl.is_null() {
return Err(PKIError::GenCRLError("Could not construct CRL!").into());
}
@ -420,6 +423,31 @@ fn refresh_crl(d: &CertData) -> anyhow::Result<()> {
}
}
// 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());
@ -434,9 +462,9 @@ fn refresh_crl(d: &CertData) -> anyhow::Result<()> {
/// Refresh revocation lists
pub fn refresh_crls() -> anyhow::Result<()> {
refresh_crl(&CertData::load_root_ca()?)?;
refresh_crl(&CertData::load_web_ca()?)?;
refresh_crl(&CertData::load_devices_ca()?)?;
refresh_crl(&CertData::load_root_ca()?, None)?;
refresh_crl(&CertData::load_web_ca()?, None)?;
refresh_crl(&CertData::load_devices_ca()?, None)?;
Ok(())
}
@ -451,3 +479,31 @@ pub fn gen_certificate_for_device(csr: &X509Req) -> anyhow::Result<String> {
Ok(String::from_utf8(cert)?)
}
/// Check if a certificate is revoked
fn is_revoked(cert: &X509, ca: &CertData) -> anyhow::Result<bool> {
let crl = X509Crl::from_pem(&std::fs::read(
ca.crl.as_ref().ok_or(PKIError::MissingCRL)?,
)?)?;
let res = crl.get_by_cert(cert);
Ok(matches!(res, CrlStatus::Revoked(_)))
}
/// Revoke a certificate
pub fn revoke(cert: &X509, ca: &CertData) -> anyhow::Result<()> {
// Check if certificate is already revoked
if is_revoked(cert, ca)? {
// 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

@ -1,11 +1,23 @@
//! # Devices entities definition
use crate::constants::StaticConstraints;
use std::collections::HashMap;
/// 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
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!");
@ -19,14 +31,19 @@ impl DeviceInfo {
}
}
/// 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,
@ -42,6 +59,8 @@ pub struct Device {
/// 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>,
}
@ -49,24 +68,175 @@ pub struct Device {
/// 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<usize>,
}
#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq, Hash)]
pub struct DeviceRelayID(uuid::Uuid);
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct DeviceRelay {
id: DeviceRelayID,
name: String,
enabled: bool,
priority: usize,
consumption: usize,
minimal_uptime: usize,
minimal_downtime: usize,
daily_runtime: Option<DailyMinRuntime>,
depends_on: Vec<DeviceRelay>,
conflicts_with: Vec<DeviceRelay>,
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)]
id: DeviceRelayID,
/// Human-readable name for the relay
name: String,
/// Whether this relay can be turned on or not
enabled: bool,
/// Relay priority when selecting relays to turn on. 0 = lowest priority
priority: usize,
/// Estimated consumption of the electrical equipment triggered by the relay
consumption: usize,
/// Minimal time this relay shall be left on before it can be turned off (in seconds)
minimal_uptime: usize,
/// Minimal time this relay shall be left off before it can be turned on again (in seconds)
minimal_downtime: usize,
/// Optional minimal runtime requirements for this relay
daily_runtime: Option<DailyMinRuntime>,
/// Specify relay that must be turned on before this relay can be started
depends_on: Vec<DeviceRelayID>,
/// Specify relays that must be turned off before this relay can be started
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 relays_map = list.iter().map(|r| (r.id, r)).collect::<HashMap<_, _>>();
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!");
}
// TODO : check for loops
None
}
}
/// 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;
#[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());
}
}

View File

@ -1,8 +1,8 @@
use crate::app_config::AppConfig;
use crate::crypto::pki;
use crate::devices::device::{Device, DeviceId, DeviceInfo};
use crate::devices::device::{Device, DeviceGeneralInfo, DeviceId, DeviceInfo, DeviceRelay};
use crate::utils::time_utils::time_secs;
use openssl::x509::X509Req;
use openssl::x509::{X509Req, X509};
use std::collections::HashMap;
#[derive(thiserror::Error, Debug)]
@ -15,6 +15,12 @@ pub enum DevicesListError {
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,
}
pub struct DevicesList(HashMap<DeviceId, Device>);
@ -129,12 +135,47 @@ impl DevicesList {
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<()> {
let crt_path = AppConfig::get().device_cert_path(id);
if crt_path.is_file() {
// TODO : implement
unimplemented!("Certificate revocation not implemented yet!");
let cert = self.get_cert(id)?;
pki::revoke_device_cert(&cert)?;
}
let csr_path = AppConfig::get().device_csr_path(id);
@ -151,4 +192,12 @@ impl DevicesList {
Ok(())
}
/// Get the full list of relays
pub fn relays_list(&mut self) -> Vec<DeviceRelay> {
self.0
.iter()
.flat_map(|(_id, d)| d.relays.clone())
.collect()
}
}

View File

@ -1,5 +1,5 @@
use crate::constants;
use crate::devices::device::{Device, DeviceId, DeviceInfo};
use crate::devices::device::{Device, DeviceGeneralInfo, DeviceId, DeviceInfo, DeviceRelay};
use crate::devices::devices_list::DevicesList;
use crate::energy::consumption;
use crate::energy::consumption::EnergyConsumption;
@ -109,6 +109,27 @@ impl Handler<ValidateDevice> for EnergyActor {
}
}
/// 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<()>")]
@ -150,3 +171,16 @@ impl Handler<GetSingleDevice> for EnergyActor {
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()
}
}

View File

@ -5,6 +5,7 @@ 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<()> {
@ -23,7 +24,15 @@ async fn main() -> std::io::Result<()> {
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()

View File

@ -8,5 +8,6 @@ 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

@ -6,6 +6,7 @@ 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;
@ -105,12 +106,14 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
.app_data(web::Data::new(RemoteIPConfig {
proxy: AppConfig::get().proxy_ip.clone(),
}))
.route("/", web::get().to(server_controller::secure_home))
//.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),
@ -123,6 +126,7 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
"/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),
@ -131,6 +135,7 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
"/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),
@ -139,14 +144,27 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
"/web_api/devices/list_validated",
web::get().to(devices_controller::list_validated),
)
.route(
"/web_api/device/{id}",
web::get().to(devices_controller::get_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),
)
// Devices API
.route(
"/devices_api/utils/time",
@ -164,6 +182,13 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
"/devices_api/mgmt/get_certificate",
web::get().to(mgmt_controller::get_certificate),
)
// 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()

View File

@ -1,4 +1,4 @@
use crate::devices::device::DeviceId;
use crate::devices::device::{DeviceGeneralInfo, DeviceId};
use crate::energy::energy_actor;
use crate::server::custom_error::HttpResult;
use crate::server::WebEnergyActor;
@ -33,6 +33,18 @@ 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))
}
/// Validate a device
pub async fn validate_device(actor: WebEnergyActor, id: web::Path<DeviceInPath>) -> HttpResult {
actor
@ -42,6 +54,26 @@ pub async fn validate_device(actor: WebEnergyActor, id: web::Path<DeviceInPath>)
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

View File

@ -1,4 +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,10 @@
use crate::energy::energy_actor;
use crate::server::custom_error::HttpResult;
use crate::server::WebEnergyActor;
use actix_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))
}

View File

@ -1,4 +1,5 @@
use crate::app_config::AppConfig;
use crate::constants::StaticConstraints;
use actix_web::HttpResponse;
pub async fn secure_home() -> HttpResponse {
@ -10,12 +11,14 @@ pub async fn secure_home() -> HttpResponse {
#[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(),
}
}
}

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

@ -15,7 +15,9 @@
"@mdi/react": "^1.6.1",
"@mui/icons-material": "^5.15.21",
"@mui/material": "^5.15.21",
"@mui/x-date-pickers": "^7.11.1",
"date-and-time": "^3.3.0",
"dayjs": "^1.11.12",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.0"
@ -337,9 +339,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.7.tgz",
"integrity": "sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==",
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.0.tgz",
"integrity": "sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@ -1265,12 +1267,12 @@
}
},
"node_modules/@mui/private-theming": {
"version": "5.15.20",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.20.tgz",
"integrity": "sha512-BK8F94AIqSrnaPYXf2KAOjGZJgWfvqAVQ2gVR3EryvQFtuBnG6RwodxrCvd3B48VuMy6Wsk897+lQMUxJyk+6g==",
"version": "5.16.5",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.16.5.tgz",
"integrity": "sha512-CSLg0YkpDqg0aXOxtjo3oTMd3XWMxvNb5d0v4AYVqwOltU8q6GvnZjhWyCLjGSCrcgfwm6/VDjaKLPlR14wxIA==",
"dependencies": {
"@babel/runtime": "^7.23.9",
"@mui/utils": "^5.15.20",
"@mui/utils": "^5.16.5",
"prop-types": "^15.8.1"
},
"engines": {
@ -1291,9 +1293,9 @@
}
},
"node_modules/@mui/styled-engine": {
"version": "5.15.14",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.14.tgz",
"integrity": "sha512-RILkuVD8gY6PvjZjqnWhz8fu68dVkqhM5+jYWfB5yhlSQKg+2rHkmEwm75XIeAqI3qwOndK6zELK5H6Zxn4NHw==",
"version": "5.16.4",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.16.4.tgz",
"integrity": "sha512-0+mnkf+UiAmTVB8PZFqOhqf729Yh0Cxq29/5cA3VAyDVTRIUUQ8FXQhiAhUIbijFmM72rY80ahFPXIm4WDbzcA==",
"dependencies": {
"@babel/runtime": "^7.23.9",
"@emotion/cache": "^11.11.0",
@ -1322,15 +1324,15 @@
}
},
"node_modules/@mui/system": {
"version": "5.15.20",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.20.tgz",
"integrity": "sha512-LoMq4IlAAhxzL2VNUDBTQxAb4chnBe8JvRINVNDiMtHE2PiPOoHlhOPutSxEbaL5mkECPVWSv6p8JEV+uykwIA==",
"version": "5.16.5",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.16.5.tgz",
"integrity": "sha512-uzIUGdrWddUx1HPxW4+B2o4vpgKyRxGe/8BxbfXVDPNPHX75c782TseoCnR/VyfnZJfqX87GcxDmnZEE1c031g==",
"dependencies": {
"@babel/runtime": "^7.23.9",
"@mui/private-theming": "^5.15.20",
"@mui/styled-engine": "^5.15.14",
"@mui/types": "^7.2.14",
"@mui/utils": "^5.15.20",
"@mui/private-theming": "^5.16.5",
"@mui/styled-engine": "^5.16.4",
"@mui/types": "^7.2.15",
"@mui/utils": "^5.16.5",
"clsx": "^2.1.0",
"csstype": "^3.1.3",
"prop-types": "^15.8.1"
@ -1361,9 +1363,9 @@
}
},
"node_modules/@mui/types": {
"version": "7.2.14",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.14.tgz",
"integrity": "sha512-MZsBZ4q4HfzBsywtXgM1Ksj6HDThtiwmOKUXH1pKYISI9gAVXCNHNpo7TlGoGrBaYWZTdNoirIN7JsQcQUjmQQ==",
"version": "7.2.15",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.15.tgz",
"integrity": "sha512-nbo7yPhtKJkdf9kcVOF8JZHPZTmqXjJ/tI0bdWgHg5tp9AnIN4Y7f7wm9T+0SyGYJk76+GYZ8Q5XaTYAsUHN0Q==",
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0"
},
@ -1374,14 +1376,16 @@
}
},
"node_modules/@mui/utils": {
"version": "5.15.20",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.20.tgz",
"integrity": "sha512-mAbYx0sovrnpAu1zHc3MDIhPqL8RPVC5W5xcO1b7PiSCJPtckIZmBkp8hefamAvUiAV8gpfMOM6Zb+eSisbI2A==",
"version": "5.16.5",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.16.5.tgz",
"integrity": "sha512-CwhcA9y44XwK7k2joL3Y29mRUnoBt+gOZZdGyw7YihbEwEErJYBtDwbZwVgH68zAljGe/b+Kd5bzfl63Gi3R2A==",
"dependencies": {
"@babel/runtime": "^7.23.9",
"@types/prop-types": "^15.7.11",
"@mui/types": "^7.2.15",
"@types/prop-types": "^15.7.12",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-is": "^18.2.0"
"react-is": "^18.3.1"
},
"engines": {
"node": ">=12.0.0"
@ -1400,6 +1404,71 @@
}
}
},
"node_modules/@mui/x-date-pickers": {
"version": "7.11.1",
"resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.11.1.tgz",
"integrity": "sha512-CflouzTNSv0YeOA8iiYpJMtqGlwGC8LI9EE9egDGhatR9Mn5geRDTXsm0rRG/4pMOfaRxyJc6Yzr/axBhEXM7w==",
"dependencies": {
"@babel/runtime": "^7.24.8",
"@mui/base": "^5.0.0-beta.40",
"@mui/system": "^5.16.5",
"@mui/utils": "^5.16.5",
"@types/react-transition-group": "^4.4.10",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@mui/material": "^5.15.14",
"date-fns": "^2.25.0 || ^3.2.0",
"date-fns-jalali": "^2.13.0-0 || ^3.2.0-0",
"dayjs": "^1.10.7",
"luxon": "^3.0.2",
"moment": "^2.29.4",
"moment-hijri": "^2.1.2",
"moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
},
"date-fns": {
"optional": true
},
"date-fns-jalali": {
"optional": true
},
"dayjs": {
"optional": true
},
"luxon": {
"optional": true
},
"moment": {
"optional": true
},
"moment-hijri": {
"optional": true
},
"moment-jalaali": {
"optional": true
}
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -2211,6 +2280,11 @@
"resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-3.3.0.tgz",
"integrity": "sha512-UguWfh9LkUecVrGSE0B7SpAnGRMPATmpwSoSij24/lDnwET3A641abfDBD/TdL0T+E04f8NWlbMkD9BscVvIZg=="
},
"node_modules/dayjs": {
"version": "1.11.12",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.12.tgz",
"integrity": "sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg=="
},
"node_modules/debug": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz",

View File

@ -17,7 +17,9 @@
"@mdi/react": "^1.6.1",
"@mui/icons-material": "^5.15.21",
"@mui/material": "^5.15.21",
"@mui/x-date-pickers": "^7.11.1",
"date-and-time": "^3.3.0",
"dayjs": "^1.11.12",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.0"

View File

@ -12,6 +12,7 @@ import { HomeRoute } from "./routes/HomeRoute";
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
import { PendingDevicesRoute } from "./routes/PendingDevicesRoute";
import { DevicesRoute } from "./routes/DevicesRoute";
import { DeviceRoute } from "./routes/DeviceRoute/DeviceRoute";
export function App() {
if (!AuthApi.SignedIn && !ServerApi.Config.auth_disabled)
@ -21,8 +22,9 @@ export function App() {
createRoutesFromElements(
<Route path="*" element={<BaseAuthenticatedPage />}>
<Route path="" element={<HomeRoute />} />
<Route path="devices" element={<DevicesRoute />} />
<Route path="pending_devices" element={<PendingDevicesRoute />} />
<Route path="devices" element={<DevicesRoute />} />
<Route path="dev/:id" element={<DeviceRoute />} />
<Route path="*" element={<NotFoundRoute />} />
</Route>
)

View File

@ -12,8 +12,10 @@ export interface DailyMinRuntime {
catch_up_hours: number[];
}
export type RelayID = string;
export interface DeviceRelay {
id: string;
id: RelayID;
name: string;
enabled: boolean;
priority: number;
@ -21,8 +23,8 @@ export interface DeviceRelay {
minimal_uptime: number;
minimal_downtime: number;
daily_runtime?: DailyMinRuntime;
depends_on: DeviceRelay[];
conflicts_with: DeviceRelay[];
depends_on: RelayID[];
conflicts_with: RelayID[];
}
export interface Device {
@ -37,6 +39,16 @@ export interface Device {
relays: DeviceRelay[];
}
export interface UpdatedInfo {
name: string;
description: string;
enabled: boolean;
}
export function DeviceURL(d: Device): string {
return `/dev/${encodeURIComponent(d.id)}`;
}
export class DeviceApi {
/**
* Get the list of pending devices
@ -72,6 +84,29 @@ export class DeviceApi {
});
}
/**
* 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;
}
/**
* 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
*/

View File

@ -0,0 +1,41 @@
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,
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,
});
}
}

View File

@ -2,6 +2,23 @@ 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;

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,
Grid,
Typography,
} from "@mui/material";
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: false,
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 item 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 item xs={6}>
<CheckboxInput
editable
label="Enable relay"
checked={relay.enabled}
onValueChange={(v) =>
setRelay((r) => {
return {
...r,
enabled: v,
};
})
}
/>
</Grid>
<Grid item 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 item 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 item 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 item 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 item 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 item 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 item 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 item 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 item 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 item 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

@ -13,9 +13,12 @@ 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>
@ -32,5 +35,6 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
</ConfirmDialogProvider>
</AlertDialogProvider>
</DarkThemeProvider>
</LocalizationProvider>
</React.StrictMode>
);

View File

@ -0,0 +1,50 @@
import AddIcon from "@mui/icons-material/Add";
import { IconButton, Tooltip } from "@mui/material";
import React from "react";
import { Device, DeviceRelay } from "../../api/DeviceApi";
import { DeviceRouteCard } from "./DeviceRouteCard";
import { EditDeviceRelaysDialog } from "../../dialogs/EditDeviceRelaysDialog";
export function DeviceRelays(p: {
device: Device;
onReload: () => void;
}): React.ReactElement {
const [dialogOpen, setDialogOpen] = React.useState(false);
const [currRelay, setCurrRelay] = React.useState<DeviceRelay | undefined>();
const createNewRelay = () => {
setDialogOpen(true);
setCurrRelay(undefined);
};
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>
}
>
TODO : relays list ({p.device.relays.length}) relays now)
</DeviceRouteCard>
</>
);
}

View File

@ -0,0 +1,93 @@
import DeleteIcon from "@mui/icons-material/Delete";
import { Grid, IconButton, Tooltip } from "@mui/material";
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 { GeneralDeviceInfo } from "./GeneralDeviceInfo";
import { DeviceRelays } from "./DeviceRelays";
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={
<Tooltip title="Delete device">
<IconButton onClick={() => deleteDevice(p.device)}>
<DeleteIcon />
</IconButton>
</Tooltip>
}
>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<GeneralDeviceInfo {...p} />
</Grid>
<Grid item xs={12} md={6}>
<DeviceRelays {...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,94 @@
import EditIcon from "@mui/icons-material/Edit";
import {
IconButton,
Table,
TableBody,
TableCell,
TableRow,
Tooltip,
} from "@mui/material";
import React from "react";
import { Device } from "../../api/DeviceApi";
import { EditDeviceMetadataDialog } from "../../dialogs/EditDeviceMetadataDialog";
import { formatDate } from "../../widgets/TimeWidget";
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>
</>
);
}
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

@ -1,5 +1,6 @@
import RefreshIcon from "@mui/icons-material/Refresh";
import VisibilityIcon from "@mui/icons-material/Visibility";
import {
Tooltip,
IconButton,
Paper,
Table,
@ -8,18 +9,14 @@ import {
TableContainer,
TableHead,
TableRow,
Tooltip,
} from "@mui/material";
import React from "react";
import { Device, DeviceApi } from "../api/DeviceApi";
import { Link, useNavigate } from "react-router-dom";
import { Device, DeviceApi, DeviceURL } from "../api/DeviceApi";
import { AsyncWidget } from "../widgets/AsyncWidget";
import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer";
import RefreshIcon from "@mui/icons-material/Refresh";
import { TimeWidget } from "../widgets/TimeWidget";
import DeleteIcon from "@mui/icons-material/Delete";
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";
export function DevicesRoute(): React.ReactElement {
const loadKey = React.useRef(1);
@ -61,32 +58,7 @@ function ValidatedDevicesList(p: {
list: Device[];
onReload: () => void;
}): React.ReactElement {
const alert = useAlert();
const confirm = useConfirm();
const snackbar = useSnackbar();
const loadingMessage = useLoadingMessage();
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();
}
};
const navigate = useNavigate();
if (p.list.length === 0) {
return <p>There is no device validated yet.</p>;
@ -108,7 +80,11 @@ function ValidatedDevicesList(p: {
</TableHead>
<TableBody>
{p.list.map((dev) => (
<TableRow key={dev.id}>
<TableRow
hover
key={dev.id}
onDoubleClick={() => navigate(DeviceURL(dev))}
>
<TableCell component="th" scope="row">
{dev.id}
</TableCell>
@ -122,10 +98,12 @@ function ValidatedDevicesList(p: {
<TimeWidget time={dev.time_update} />
</TableCell>
<TableCell>
<Tooltip title="Delete device">
<IconButton onClick={() => deleteDevice(dev)}>
<DeleteIcon />
<Tooltip title="Open device page">
<Link to={DeviceURL(dev)}>
<IconButton>
<VisibilityIcon />
</IconButton>
</Link>
</Tooltip>
</TableCell>
</TableRow>

View File

@ -10,7 +10,6 @@ import Paper from "@mui/material/Paper";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import * as React from "react";
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
import { AuthApi } from "../api/AuthApi";

View File

@ -1,6 +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;
}

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