Compare commits
	
		
			104 Commits
		
	
	
		
			15795a53ba
			...
			renovate/m
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| b5f2188664 | |||
| 66923ab226 | |||
| 3f3b1282bd | |||
| 95a1a446db | |||
| f5fe5b5339 | |||
| c844490c4e | |||
| fe951f4004 | |||
| 8c92b8bf27 | |||
| 8d715b407e | |||
| f11d25b2f0 | |||
| 35e64e977b | |||
| 4d46f9c52c | |||
| c90a05fcfd | |||
| abdca20a66 | |||
| 88a24565b4 | |||
| 3625188706 | |||
| 58eceeda2d | |||
| c44c6c3bf5 | |||
| 4ef714fdbd | |||
| bab4525908 | |||
| 2ed4299032 | |||
| bcc92da065 | |||
| af83130b63 | |||
| 950b576373 | |||
| 2459b0cf99 | |||
| 814204b4ce | |||
| de8fcfbcfb | |||
| 49a7011d84 | |||
| 39aff8054a | |||
| 850f828dca | |||
| 59383debd2 | |||
| 58cf0a9614 | |||
| f30aaf71dd | |||
| 27ce9e9a96 | |||
| 903dd104f3 | |||
| 205981fbc8 | |||
| 623b0a4671 | |||
| fd25e71cf8 | |||
| 842b48e782 | |||
| e8374a8ef7 | |||
| 347c247285 | |||
| c59aeed4ab | |||
| b7f1beb1b7 | |||
| 4bd700c2db | |||
| 3f1c5e4ac0 | |||
| f77506dc46 | |||
| 5b7554b6bf | |||
| 8fdbcc4f3a | |||
| 9f4c2b0e35 | |||
| 0a7138a82b | |||
| 2ce09a94b1 | |||
| 355b2a71ce | |||
| 6b04bf4261 | |||
| 99c6963210 | |||
| 89cfd3ce21 | |||
| 0c40ff2750 | |||
| 75be1ed1d2 | |||
| 6a2baca3f2 | |||
| 4d0d20b424 | |||
| 05cf488be7 | |||
| b8ecc83668 | |||
| c1912717e4 | |||
| a8780e60d3 | |||
| 5005cf84f9 | |||
| 561992010c | |||
| 4725e67ee1 | |||
| fbb55628be | |||
| 79e49ed5a9 | |||
| 138b9d2dbe | |||
| 3413a1ee21 | |||
| 64055568e0 | |||
| af71b574cc | |||
| 6088237b79 | |||
| 05a033b51c | |||
| 3d3ccf5242 | |||
| 68521c238c | |||
| f03278f8c5 | |||
| a63d1f17de | |||
| bb6d8a8be1 | |||
| 693414b1eb | |||
| f74e86a8db | |||
| 53d4cb1de7 | |||
| 498ca3925a | |||
| 155806df78 | |||
| 79f3668021 | |||
| 660e6e8a5b | |||
| 0e3182434f | |||
| cf2f034e6c | |||
| ddd27519a9 | |||
| e8d2e8b318 | |||
| 0dfc25a918 | |||
| 3078b3c645 | |||
| 9d3bed68af | |||
| 9fe00f149c | |||
| 65164055d6 | |||
| 08680122f2 | |||
| 642366540e | |||
| 482da63a3b | |||
| cade9dc02b | |||
| 9b87025b27 | |||
| 46d3f3580c | |||
| bda9f6a9c9 | |||
| 41380a103f | |||
| 2573778b82 | 
							
								
								
									
										37
									
								
								.drone.yml
									
									
									
									
									
								
							
							
						
						
									
										37
									
								
								.drone.yml
									
									
									
									
									
								
							@@ -46,6 +46,11 @@ steps:
 | 
			
		||||
        path: /usr/local/cargo/registry
 | 
			
		||||
      - name: web_app
 | 
			
		||||
        path: /tmp/web_build
 | 
			
		||||
      - name: releases
 | 
			
		||||
        path: /tmp/releases
 | 
			
		||||
    when:
 | 
			
		||||
      event:
 | 
			
		||||
        - tag
 | 
			
		||||
    depends_on:
 | 
			
		||||
      - backend_check
 | 
			
		||||
      - web_build
 | 
			
		||||
@@ -54,13 +59,41 @@ steps:
 | 
			
		||||
      - mv /tmp/web_build/dist static
 | 
			
		||||
      - cargo build --release
 | 
			
		||||
      - ls -lah target/release/central_backend
 | 
			
		||||
      - mv target/release/central_backend /tmp/releases/central_backend
 | 
			
		||||
 | 
			
		||||
  # Build ESP32 program
 | 
			
		||||
  - name: esp32_compile
 | 
			
		||||
    image: espressif/idf:v5.4.2
 | 
			
		||||
    image: espressif/idf:v5.5.1
 | 
			
		||||
    volumes:
 | 
			
		||||
      - name: releases
 | 
			
		||||
        path: /tmp/releases
 | 
			
		||||
    commands:
 | 
			
		||||
      - cd esp32_device
 | 
			
		||||
      - /opt/esp/entrypoint.sh idf.py build
 | 
			
		||||
      - ls -lah build/main.bin
 | 
			
		||||
      - cp build/main.bin /tmp/releases/wt32-eth01.bin
 | 
			
		||||
 | 
			
		||||
  # Auto-release to Gitea
 | 
			
		||||
  - name: gitea_release
 | 
			
		||||
    image: plugins/gitea-release
 | 
			
		||||
    depends_on:
 | 
			
		||||
    - backend_compile
 | 
			
		||||
    - esp32_compile
 | 
			
		||||
    when:
 | 
			
		||||
      event:
 | 
			
		||||
        - tag
 | 
			
		||||
    volumes:
 | 
			
		||||
      - name: releases
 | 
			
		||||
        path: /tmp/releases
 | 
			
		||||
    environment:
 | 
			
		||||
      PLUGIN_API_KEY:
 | 
			
		||||
        from_secret: GITEA_API_KEY # needs permission write:repository
 | 
			
		||||
    settings:
 | 
			
		||||
      base_url: https://gitea.communiquons.org
 | 
			
		||||
      files:
 | 
			
		||||
        - /tmp/releases/central_backend
 | 
			
		||||
        - /tmp/releases/wt32-eth01.bin
 | 
			
		||||
      checksum: sha512
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
volumes:
 | 
			
		||||
@@ -68,3 +101,5 @@ volumes:
 | 
			
		||||
    temp: {}
 | 
			
		||||
  - name: web_app
 | 
			
		||||
    temp: {}
 | 
			
		||||
  - name: releases
 | 
			
		||||
    temp: {}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										677
									
								
								central_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										677
									
								
								central_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,6 +1,6 @@
 | 
			
		||||
[package]
 | 
			
		||||
name = "central_backend"
 | 
			
		||||
version = "1.0.2"
 | 
			
		||||
version = "1.0.3"
 | 
			
		||||
edition = "2024"
 | 
			
		||||
 | 
			
		||||
[dependencies]
 | 
			
		||||
@@ -8,39 +8,39 @@ log = "0.4.28"
 | 
			
		||||
env_logger = "0.11.8"
 | 
			
		||||
lazy_static = "1.5.0"
 | 
			
		||||
dotenvy = "0.15.7"
 | 
			
		||||
clap = { version = "4.5.47", features = ["derive", "env"] }
 | 
			
		||||
anyhow = "1.0.99"
 | 
			
		||||
thiserror = "2.0.16"
 | 
			
		||||
openssl = { version = "0.10.73" }
 | 
			
		||||
openssl-sys = "0.9.109"
 | 
			
		||||
libc = "0.2.175"
 | 
			
		||||
clap = { version = "4.5.51", features = ["derive", "env"] }
 | 
			
		||||
anyhow = "1.0.100"
 | 
			
		||||
thiserror = "2.0.17"
 | 
			
		||||
openssl = { version = "0.10.74" }
 | 
			
		||||
openssl-sys = "0.9.110"
 | 
			
		||||
libc = "0.2.177"
 | 
			
		||||
foreign-types-shared = "0.1.1"
 | 
			
		||||
asn1 = "0.22.0"
 | 
			
		||||
asn1 = "0.23.0"
 | 
			
		||||
actix-web = { version = "4.11.0", features = ["openssl"] }
 | 
			
		||||
futures = "0.3.31"
 | 
			
		||||
serde = { version = "1.0.219", features = ["derive"] }
 | 
			
		||||
reqwest = { version = "0.12.23", features = ["json"] }
 | 
			
		||||
serde_json = "1.0.143"
 | 
			
		||||
rand = "0.9.2"
 | 
			
		||||
serde = { version = "1.0.228", features = ["derive"] }
 | 
			
		||||
reqwest = { version = "0.12.24", features = ["json"] }
 | 
			
		||||
serde_json = "1.0.145"
 | 
			
		||||
rand = "0.10.0-rc.0"
 | 
			
		||||
actix = "0.13.5"
 | 
			
		||||
actix-identity = "0.8.0"
 | 
			
		||||
actix-session = { version = "0.10.1", features = ["cookie-session"] }
 | 
			
		||||
actix-identity = "0.9.0"
 | 
			
		||||
actix-session = { version = "0.11.0", features = ["cookie-session"] }
 | 
			
		||||
actix-cors = "0.7.1"
 | 
			
		||||
actix-multipart = { version = "0.7.2", features = ["derive"] }
 | 
			
		||||
actix-remote-ip = "0.1.0"
 | 
			
		||||
futures-util = "0.3.31"
 | 
			
		||||
uuid = { version = "1.17.0", features = ["v4", "serde"] }
 | 
			
		||||
semver = { version = "1.0.26", features = ["serde"] }
 | 
			
		||||
lazy-regex = "3.4.1"
 | 
			
		||||
tokio = { version = "1.47.1", features = ["full"] }
 | 
			
		||||
uuid = { version = "1.18.1", features = ["v4", "serde"] }
 | 
			
		||||
semver = { version = "1.0.27", features = ["serde"] }
 | 
			
		||||
lazy-regex = "3.4.2"
 | 
			
		||||
tokio = { version = "1.48.0", features = ["full"] }
 | 
			
		||||
tokio_schedule = "0.3.2"
 | 
			
		||||
mime_guess = "2.0.5"
 | 
			
		||||
rust-embed = "8.7.2"
 | 
			
		||||
jsonwebtoken = { version = "9.3.1", features = ["use_pem"] }
 | 
			
		||||
rust-embed = "8.8.0"
 | 
			
		||||
jsonwebtoken = { version = "10.1.0", features = ["use_pem", "rust_crypto"] }
 | 
			
		||||
prettytable-rs = "0.10.0"
 | 
			
		||||
chrono = "0.4.42"
 | 
			
		||||
serde_yml = "0.0.12"
 | 
			
		||||
bincode = "2.0.1"
 | 
			
		||||
fs4 = { version = "0.13.1", features = ["sync"] }
 | 
			
		||||
zip = { version = "2.2.0", features = ["bzip2"] }
 | 
			
		||||
zip = { version = "6.0.0", features = ["bzip2"] }
 | 
			
		||||
walkdir = "2.5.0"
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										33
									
								
								central_backend/engine_test/test_turn_forced_off.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								central_backend/engine_test/test_turn_forced_off.yaml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,33 @@
 | 
			
		||||
devices:
 | 
			
		||||
  - id: dev1
 | 
			
		||||
    info:
 | 
			
		||||
      reference: A
 | 
			
		||||
      version: 0.0.1
 | 
			
		||||
      max_relays: 1
 | 
			
		||||
    time_create: 1
 | 
			
		||||
    time_update: 1
 | 
			
		||||
    name: Dev1
 | 
			
		||||
    description: Day1
 | 
			
		||||
    validated: true
 | 
			
		||||
    enabled: true
 | 
			
		||||
    relays:
 | 
			
		||||
      - id: dcb3fd91-bf9b-4de3-99e5-92c1c7dd72e9
 | 
			
		||||
        name: R1
 | 
			
		||||
        enabled: true
 | 
			
		||||
        priority: 1
 | 
			
		||||
        consumption: 100
 | 
			
		||||
        minimal_uptime: 10
 | 
			
		||||
        minimal_downtime: 10
 | 
			
		||||
        depends_on: []
 | 
			
		||||
        conflicts_with: []
 | 
			
		||||
 | 
			
		||||
        on: false
 | 
			
		||||
        for: 5000
 | 
			
		||||
        forced_state:
 | 
			
		||||
          type: Off
 | 
			
		||||
          for_secs: 500
 | 
			
		||||
        should_be_on: false
 | 
			
		||||
 | 
			
		||||
    online: true
 | 
			
		||||
 | 
			
		||||
curr_consumption: -10000
 | 
			
		||||
							
								
								
									
										49
									
								
								central_backend/engine_test/test_turn_forced_on.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								central_backend/engine_test/test_turn_forced_on.yaml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,49 @@
 | 
			
		||||
devices:
 | 
			
		||||
  - id: dev1
 | 
			
		||||
    info:
 | 
			
		||||
      reference: A
 | 
			
		||||
      version: 0.0.1
 | 
			
		||||
      max_relays: 1
 | 
			
		||||
    time_create: 1
 | 
			
		||||
    time_update: 1
 | 
			
		||||
    name: Dev1
 | 
			
		||||
    description: Day1
 | 
			
		||||
    validated: true
 | 
			
		||||
    enabled: true
 | 
			
		||||
    relays:
 | 
			
		||||
      - id: dcb3fd91-bf9b-4de3-99e5-92c1c7dd72e9
 | 
			
		||||
        name: R1
 | 
			
		||||
        enabled: true
 | 
			
		||||
        priority: 1
 | 
			
		||||
        consumption: 100
 | 
			
		||||
        minimal_uptime: 10
 | 
			
		||||
        minimal_downtime: 10
 | 
			
		||||
        depends_on: []
 | 
			
		||||
        conflicts_with: []
 | 
			
		||||
 | 
			
		||||
        on: false
 | 
			
		||||
        for: 500
 | 
			
		||||
        forced_state:
 | 
			
		||||
          type: On
 | 
			
		||||
          for_secs: 500
 | 
			
		||||
        should_be_on: true
 | 
			
		||||
 | 
			
		||||
      - id: dcb3fd91-bf9b-4de3-99e5-92c1c7dd72f0
 | 
			
		||||
        name: R2
 | 
			
		||||
        enabled: true
 | 
			
		||||
        priority: 1
 | 
			
		||||
        consumption: 100
 | 
			
		||||
        minimal_uptime: 10
 | 
			
		||||
        minimal_downtime: 10
 | 
			
		||||
        depends_on: [ ]
 | 
			
		||||
        conflicts_with: [ ]
 | 
			
		||||
 | 
			
		||||
        on: false
 | 
			
		||||
        for: 500
 | 
			
		||||
        forced_state:
 | 
			
		||||
          type: None
 | 
			
		||||
        should_be_on: false
 | 
			
		||||
 | 
			
		||||
    online: true
 | 
			
		||||
 | 
			
		||||
curr_consumption: 10000
 | 
			
		||||
@@ -8,7 +8,7 @@ use crate::energy::consumption;
 | 
			
		||||
use crate::energy::consumption::EnergyConsumption;
 | 
			
		||||
use crate::energy::consumption_cache::ConsumptionCache;
 | 
			
		||||
use crate::energy::consumption_history_file::ConsumptionHistoryFile;
 | 
			
		||||
use crate::energy::engine::EnergyEngine;
 | 
			
		||||
use crate::energy::engine::{EnergyEngine, RelayForcedState};
 | 
			
		||||
use crate::utils::time_utils::time_secs;
 | 
			
		||||
use actix::prelude::*;
 | 
			
		||||
use openssl::x509::X509Req;
 | 
			
		||||
@@ -328,6 +328,19 @@ impl Handler<UpdateDeviceRelay> for EnergyActor {
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Message)]
 | 
			
		||||
#[rtype(result = "anyhow::Result<()>")]
 | 
			
		||||
pub struct SetRelayForcedState(pub DeviceRelayID, pub RelayForcedState);
 | 
			
		||||
 | 
			
		||||
impl Handler<SetRelayForcedState> for EnergyActor {
 | 
			
		||||
    type Result = anyhow::Result<()>;
 | 
			
		||||
 | 
			
		||||
    fn handle(&mut self, msg: SetRelayForcedState, _ctx: &mut Context<Self>) -> Self::Result {
 | 
			
		||||
        self.engine.relay_state(msg.0).set_forced(msg.1);
 | 
			
		||||
        Ok(())
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Delete a device relay
 | 
			
		||||
#[derive(Message)]
 | 
			
		||||
#[rtype(result = "anyhow::Result<()>")]
 | 
			
		||||
@@ -408,6 +421,7 @@ pub struct ResRelayState {
 | 
			
		||||
    pub id: DeviceRelayID,
 | 
			
		||||
    pub on: bool,
 | 
			
		||||
    pub r#for: usize,
 | 
			
		||||
    pub forced_state: RelayForcedState,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Get the state of all relays
 | 
			
		||||
@@ -427,6 +441,7 @@ impl Handler<GetAllRelaysState> for EnergyActor {
 | 
			
		||||
                id: d.id,
 | 
			
		||||
                on: state.is_on(),
 | 
			
		||||
                r#for: state.state_for(),
 | 
			
		||||
                forced_state: state.actual_forced_state(),
 | 
			
		||||
            })
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -25,19 +25,83 @@ impl DeviceState {
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Default, Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
 | 
			
		||||
#[serde(tag = "type")]
 | 
			
		||||
pub enum SetRelayForcedStateReq {
 | 
			
		||||
    #[default]
 | 
			
		||||
    None,
 | 
			
		||||
    Off {
 | 
			
		||||
        for_secs: u64,
 | 
			
		||||
    },
 | 
			
		||||
    On {
 | 
			
		||||
        for_secs: u64,
 | 
			
		||||
    },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl SetRelayForcedStateReq {
 | 
			
		||||
    pub fn to_forced_state(&self) -> RelayForcedState {
 | 
			
		||||
        match &self {
 | 
			
		||||
            SetRelayForcedStateReq::None => RelayForcedState::None,
 | 
			
		||||
            SetRelayForcedStateReq::Off { for_secs } => RelayForcedState::Off {
 | 
			
		||||
                until: time_secs() + for_secs,
 | 
			
		||||
            },
 | 
			
		||||
            SetRelayForcedStateReq::On { for_secs } => RelayForcedState::On {
 | 
			
		||||
                until: time_secs() + for_secs,
 | 
			
		||||
            },
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Default, Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
 | 
			
		||||
#[serde(tag = "type")]
 | 
			
		||||
pub enum RelayForcedState {
 | 
			
		||||
    #[default]
 | 
			
		||||
    None,
 | 
			
		||||
    Off {
 | 
			
		||||
        until: u64,
 | 
			
		||||
    },
 | 
			
		||||
    On {
 | 
			
		||||
        until: u64,
 | 
			
		||||
    },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Default, Clone)]
 | 
			
		||||
pub struct RelayState {
 | 
			
		||||
    on: bool,
 | 
			
		||||
    since: usize,
 | 
			
		||||
    forced_state: RelayForcedState,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl RelayState {
 | 
			
		||||
    /// Get actual forced state (returns None if state is expired)
 | 
			
		||||
    pub fn actual_forced_state(&self) -> RelayForcedState {
 | 
			
		||||
        match self.forced_state {
 | 
			
		||||
            RelayForcedState::Off { until } if until > time_secs() => {
 | 
			
		||||
                RelayForcedState::Off { until }
 | 
			
		||||
            }
 | 
			
		||||
            RelayForcedState::On { until } if until > time_secs() => RelayForcedState::On { until },
 | 
			
		||||
            _ => RelayForcedState::None,
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn is_on(&self) -> bool {
 | 
			
		||||
        self.on
 | 
			
		||||
        let forced_state = self.actual_forced_state();
 | 
			
		||||
        (self.on || matches!(forced_state, RelayForcedState::On { .. }))
 | 
			
		||||
            && !matches!(forced_state, RelayForcedState::Off { .. })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fn is_off(&self) -> bool {
 | 
			
		||||
        !self.on
 | 
			
		||||
        !self.is_on()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Check if relay state is enforced
 | 
			
		||||
    pub fn is_forced(&self) -> bool {
 | 
			
		||||
        self.actual_forced_state() != RelayForcedState::None
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn set_forced(&mut self, s: RelayForcedState) {
 | 
			
		||||
        self.since = time_secs() as usize;
 | 
			
		||||
        self.forced_state = s;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn state_for(&self) -> usize {
 | 
			
		||||
@@ -146,7 +210,11 @@ impl EnergyEngine {
 | 
			
		||||
                    r.name,
 | 
			
		||||
                    r.consumption,
 | 
			
		||||
                    format!("{} / {}", r.minimal_downtime, r.minimal_uptime),
 | 
			
		||||
                    status.is_on().to_string(),
 | 
			
		||||
                    status.is_on().to_string()
 | 
			
		||||
                        + match status.is_forced() {
 | 
			
		||||
                            true => " (Forced)",
 | 
			
		||||
                            false => "",
 | 
			
		||||
                        },
 | 
			
		||||
                    status.since,
 | 
			
		||||
                    match dev_online {
 | 
			
		||||
                        true => "Online",
 | 
			
		||||
@@ -192,19 +260,28 @@ impl EnergyEngine {
 | 
			
		||||
 | 
			
		||||
        let mut new_relays_state = self.relays_state.clone();
 | 
			
		||||
 | 
			
		||||
        // Forcefully turn off relays that belongs to offline devices
 | 
			
		||||
        // Forcefully turn off disabled relays
 | 
			
		||||
        for d in devices {
 | 
			
		||||
            if !self.device_state(&d.id).is_online() {
 | 
			
		||||
                for r in &d.relays {
 | 
			
		||||
            for r in &d.relays {
 | 
			
		||||
                if !r.enabled || !d.enabled {
 | 
			
		||||
                    new_relays_state.get_mut(&r.id).unwrap().on = false;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Forcefully turn off disabled relays
 | 
			
		||||
        // Apply forced relays state
 | 
			
		||||
        for d in devices {
 | 
			
		||||
            for r in &d.relays {
 | 
			
		||||
                if !r.enabled || !d.enabled {
 | 
			
		||||
                if self.relay_state(r.id).is_forced() {
 | 
			
		||||
                    new_relays_state.get_mut(&r.id).unwrap().on = self.relay_state(r.id).is_on();
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // 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;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
@@ -216,7 +293,9 @@ impl EnergyEngine {
 | 
			
		||||
 | 
			
		||||
            for d in devices {
 | 
			
		||||
                for r in &d.relays {
 | 
			
		||||
                    if new_relays_state.get(&r.id).unwrap().is_off() {
 | 
			
		||||
                    if new_relays_state.get(&r.id).unwrap().is_off()
 | 
			
		||||
                        || new_relays_state.get(&r.id).unwrap().is_forced()
 | 
			
		||||
                    {
 | 
			
		||||
                        continue;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
@@ -240,7 +319,7 @@ impl EnergyEngine {
 | 
			
		||||
            for d in devices {
 | 
			
		||||
                for r in &d.relays {
 | 
			
		||||
                    let state = new_relays_state.get(&r.id).unwrap();
 | 
			
		||||
                    if state.is_off() {
 | 
			
		||||
                    if state.is_off() || state.is_forced() {
 | 
			
		||||
                        continue;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
@@ -271,7 +350,9 @@ impl EnergyEngine {
 | 
			
		||||
                    continue;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if new_relays_state.get(&r.id).unwrap().is_on() {
 | 
			
		||||
                if new_relays_state.get(&r.id).unwrap().is_on()
 | 
			
		||||
                    || new_relays_state.get(&r.id).unwrap().is_forced()
 | 
			
		||||
                {
 | 
			
		||||
                    continue;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
@@ -298,7 +379,7 @@ impl EnergyEngine {
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Order relays
 | 
			
		||||
        // Order relays to select the ones with the most elevated priorities
 | 
			
		||||
        let mut ordered_relays = devices
 | 
			
		||||
            .iter()
 | 
			
		||||
            .filter(|d| self.device_state(&d.id).is_online() && d.enabled)
 | 
			
		||||
@@ -308,10 +389,13 @@ impl EnergyEngine {
 | 
			
		||||
        ordered_relays.sort_by_key(|r| r.priority);
 | 
			
		||||
        ordered_relays.reverse();
 | 
			
		||||
 | 
			
		||||
        // Select relays to start, starting with those with highest priorities
 | 
			
		||||
        loop {
 | 
			
		||||
            let mut changed = false;
 | 
			
		||||
            for relay in &ordered_relays {
 | 
			
		||||
                if new_relays_state.get(&relay.id).unwrap().is_on() {
 | 
			
		||||
                if new_relays_state.get(&relay.id).unwrap().is_on()
 | 
			
		||||
                    || new_relays_state.get(&relay.id).unwrap().is_forced()
 | 
			
		||||
                {
 | 
			
		||||
                    continue;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
@@ -383,7 +467,7 @@ impl EnergyEngine {
 | 
			
		||||
mod test {
 | 
			
		||||
    use crate::devices::device::{Device, DeviceId, DeviceRelayID};
 | 
			
		||||
    use crate::energy::consumption::EnergyConsumption;
 | 
			
		||||
    use crate::energy::engine::EnergyEngine;
 | 
			
		||||
    use crate::energy::engine::{EnergyEngine, SetRelayForcedStateReq};
 | 
			
		||||
    use crate::utils::time_utils::time_secs;
 | 
			
		||||
    use rust_embed::Embed;
 | 
			
		||||
 | 
			
		||||
@@ -392,6 +476,8 @@ mod test {
 | 
			
		||||
        id: DeviceRelayID,
 | 
			
		||||
        on: bool,
 | 
			
		||||
        r#for: usize,
 | 
			
		||||
        #[serde(default)]
 | 
			
		||||
        forced_state: SetRelayForcedStateReq,
 | 
			
		||||
        should_be_on: bool,
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -439,6 +525,7 @@ mod test {
 | 
			
		||||
                let s = engine.relay_state(r.id);
 | 
			
		||||
                s.on = r.on;
 | 
			
		||||
                s.since = time_secs() as usize - r.r#for;
 | 
			
		||||
                s.forced_state = r.forced_state.to_forced_state()
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,7 @@ use crate::server::custom_error::HttpResult;
 | 
			
		||||
use crate::server::devices_api::jwt_parser::JWTRequest;
 | 
			
		||||
use actix_web::{HttpResponse, web};
 | 
			
		||||
 | 
			
		||||
#[derive(Debug, serde::Deserialize)]
 | 
			
		||||
#[derive(Debug, serde::Deserialize, Clone)]
 | 
			
		||||
pub struct LogRequest {
 | 
			
		||||
    severity: LogSeverity,
 | 
			
		||||
    message: String,
 | 
			
		||||
 
 | 
			
		||||
@@ -30,7 +30,7 @@ pub struct JWTRequest {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl JWTRequest {
 | 
			
		||||
    pub async fn parse_jwt<E: DeserializeOwned>(
 | 
			
		||||
    pub async fn parse_jwt<E: DeserializeOwned + std::clone::Clone>(
 | 
			
		||||
        &self,
 | 
			
		||||
        actor: WebEnergyActor,
 | 
			
		||||
    ) -> anyhow::Result<(Device, E)> {
 | 
			
		||||
 
 | 
			
		||||
@@ -130,7 +130,7 @@ pub async fn get_certificate(query: web::Query<ReqWithDevID>, actor: WebEnergyAc
 | 
			
		||||
        .body(cert))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
 | 
			
		||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
 | 
			
		||||
struct Claims {
 | 
			
		||||
    info: DeviceInfo,
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -11,7 +11,7 @@ use crate::server::web_api::*;
 | 
			
		||||
use crate::server::web_app_controller;
 | 
			
		||||
use actix_cors::Cors;
 | 
			
		||||
use actix_identity::IdentityMiddleware;
 | 
			
		||||
use actix_identity::config::LogoutBehaviour;
 | 
			
		||||
use actix_identity::config::LogoutBehavior;
 | 
			
		||||
use actix_remote_ip::RemoteIPConfig;
 | 
			
		||||
use actix_session::SessionMiddleware;
 | 
			
		||||
use actix_session::storage::CookieSessionStore;
 | 
			
		||||
@@ -84,7 +84,7 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
 | 
			
		||||
        .build();
 | 
			
		||||
 | 
			
		||||
        let identity_middleware = IdentityMiddleware::builder()
 | 
			
		||||
            .logout_behaviour(LogoutBehaviour::PurgeSession)
 | 
			
		||||
            .logout_behavior(LogoutBehavior::PurgeSession)
 | 
			
		||||
            .visit_deadline(Some(Duration::from_secs(
 | 
			
		||||
                constants::MAX_INACTIVITY_DURATION,
 | 
			
		||||
            )))
 | 
			
		||||
@@ -231,6 +231,10 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
 | 
			
		||||
                "/web_api/relay/{id}",
 | 
			
		||||
                web::put().to(relays_controller::update),
 | 
			
		||||
            )
 | 
			
		||||
            .route(
 | 
			
		||||
                "/web_api/relay/{id}/forced_state",
 | 
			
		||||
                web::put().to(relays_controller::set_forced_state),
 | 
			
		||||
            )
 | 
			
		||||
            .route(
 | 
			
		||||
                "/web_api/relay/{id}",
 | 
			
		||||
                web::delete().to(relays_controller::delete),
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
use crate::devices::device::{DeviceId, DeviceRelay, DeviceRelayID};
 | 
			
		||||
use crate::energy::energy_actor;
 | 
			
		||||
use crate::energy::engine::SetRelayForcedStateReq;
 | 
			
		||||
use crate::server::WebEnergyActor;
 | 
			
		||||
use crate::server::custom_error::HttpResult;
 | 
			
		||||
use actix_web::{HttpResponse, web};
 | 
			
		||||
@@ -85,6 +86,29 @@ pub async fn update(
 | 
			
		||||
    Ok(HttpResponse::Accepted().finish())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Set relay forced status
 | 
			
		||||
pub async fn set_forced_state(
 | 
			
		||||
    actor: WebEnergyActor,
 | 
			
		||||
    req: web::Json<SetRelayForcedStateReq>,
 | 
			
		||||
    path: web::Path<RelayIDInPath>,
 | 
			
		||||
) -> HttpResult {
 | 
			
		||||
    // Check if relay exists first
 | 
			
		||||
    let list = actor.send(energy_actor::GetAllRelaysState).await?;
 | 
			
		||||
    if !list.into_iter().any(|r| r.id == path.id) {
 | 
			
		||||
        return Ok(HttpResponse::NotFound().json("Relay not found!"));
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Update relay forced state
 | 
			
		||||
    actor
 | 
			
		||||
        .send(energy_actor::SetRelayForcedState(
 | 
			
		||||
            path.id,
 | 
			
		||||
            req.to_forced_state(),
 | 
			
		||||
        ))
 | 
			
		||||
        .await??;
 | 
			
		||||
 | 
			
		||||
    Ok(HttpResponse::Accepted().finish())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Delete an existing relay
 | 
			
		||||
pub async fn delete(actor: WebEnergyActor, path: web::Path<RelayIDInPath>) -> HttpResult {
 | 
			
		||||
    actor
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1723
									
								
								central_frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1723
									
								
								central_frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -12,34 +12,34 @@
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@emotion/react": "^11.14.0",
 | 
			
		||||
    "@emotion/styled": "^11.14.1",
 | 
			
		||||
    "@fontsource/roboto": "^5.2.7",
 | 
			
		||||
    "@fontsource/roboto": "^5.2.8",
 | 
			
		||||
    "@mdi/js": "^7.4.47",
 | 
			
		||||
    "@mdi/react": "^1.6.1",
 | 
			
		||||
    "@mui/icons-material": "^7.0.2",
 | 
			
		||||
    "@mui/material": "^7.0.2",
 | 
			
		||||
    "@mui/x-charts": "^7.29.1",
 | 
			
		||||
    "@mui/x-date-pickers": "^7.29.4",
 | 
			
		||||
    "date-and-time": "^3.6.0",
 | 
			
		||||
    "dayjs": "^1.11.18",
 | 
			
		||||
    "filesize": "^10.1.6",
 | 
			
		||||
    "react": "^19.1.1",
 | 
			
		||||
    "react-dom": "^19.1.1",
 | 
			
		||||
    "react-router-dom": "^7.7.1",
 | 
			
		||||
    "semver": "^7.7.2"
 | 
			
		||||
    "@mui/icons-material": "^7.3.4",
 | 
			
		||||
    "@mui/material": "^7.3.4",
 | 
			
		||||
    "@mui/x-charts": "^8.16.0",
 | 
			
		||||
    "@mui/x-date-pickers": "^8.16.0",
 | 
			
		||||
    "date-and-time": "^4.1.0",
 | 
			
		||||
    "dayjs": "^1.11.19",
 | 
			
		||||
    "filesize": "^11.0.13",
 | 
			
		||||
    "react": "^19.2.0",
 | 
			
		||||
    "react-dom": "^19.2.0",
 | 
			
		||||
    "react-router-dom": "^7.9.5",
 | 
			
		||||
    "semver": "^7.7.3"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@types/react": "^19.1.13",
 | 
			
		||||
    "@types/react-dom": "^19.1.9",
 | 
			
		||||
    "@types/react": "^19.2.2",
 | 
			
		||||
    "@types/react-dom": "^19.2.2",
 | 
			
		||||
    "@types/semver": "^7.7.1",
 | 
			
		||||
    "@typescript-eslint/eslint-plugin": "^8.39.1",
 | 
			
		||||
    "@typescript-eslint/parser": "^8.41.0",
 | 
			
		||||
    "@vitejs/plugin-react": "^4.7.0",
 | 
			
		||||
    "eslint": "^9.33.0",
 | 
			
		||||
    "eslint-plugin-react-hooks": "^5.2.0",
 | 
			
		||||
    "eslint-plugin-react-refresh": "^0.4.20",
 | 
			
		||||
    "globals": "^16.3.0",
 | 
			
		||||
    "typescript": "^5.8.3",
 | 
			
		||||
    "typescript-eslint": "^8.24.1",
 | 
			
		||||
    "vite": "^6.3.6"
 | 
			
		||||
    "@typescript-eslint/eslint-plugin": "^8.46.2",
 | 
			
		||||
    "@typescript-eslint/parser": "^8.46.2",
 | 
			
		||||
    "@vitejs/plugin-react": "^5.1.0",
 | 
			
		||||
    "eslint": "^9.38.0",
 | 
			
		||||
    "eslint-plugin-react-hooks": "^7.0.1",
 | 
			
		||||
    "eslint-plugin-react-refresh": "^0.4.24",
 | 
			
		||||
    "globals": "^16.4.0",
 | 
			
		||||
    "typescript": "^5.9.3",
 | 
			
		||||
    "typescript-eslint": "^8.46.2",
 | 
			
		||||
    "vite": "^7.1.12"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,19 @@
 | 
			
		||||
import { APIClient } from "./ApiClient";
 | 
			
		||||
import { Device, DeviceRelay } from "./DeviceApi";
 | 
			
		||||
 | 
			
		||||
export type RelayForcedState =
 | 
			
		||||
  | { type: "None" }
 | 
			
		||||
  | { type: "Off" | "On"; until: number };
 | 
			
		||||
 | 
			
		||||
export type SetRelayForcedState =
 | 
			
		||||
  | { type: "None" }
 | 
			
		||||
  | { type: "Off" | "On"; for_secs: number };
 | 
			
		||||
 | 
			
		||||
export interface RelayStatus {
 | 
			
		||||
  id: string;
 | 
			
		||||
  on: boolean;
 | 
			
		||||
  for: number;
 | 
			
		||||
  forced_state: RelayForcedState;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type RelaysStatus = Map<string, RelayStatus>;
 | 
			
		||||
@@ -48,6 +57,20 @@ export class RelayApi {
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Set relay forced state
 | 
			
		||||
   */
 | 
			
		||||
  static async SetForcedState(
 | 
			
		||||
    relay: DeviceRelay,
 | 
			
		||||
    forced: SetRelayForcedState
 | 
			
		||||
  ): Promise<void> {
 | 
			
		||||
    await APIClient.exec({
 | 
			
		||||
      method: "PUT",
 | 
			
		||||
      uri: `/relay/${relay.id}/forced_state`,
 | 
			
		||||
      jsonData: forced,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Delete a relay configuration
 | 
			
		||||
   */
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,53 @@
 | 
			
		||||
import {
 | 
			
		||||
  Button,
 | 
			
		||||
  Dialog,
 | 
			
		||||
  DialogActions,
 | 
			
		||||
  DialogContent,
 | 
			
		||||
  DialogContentText,
 | 
			
		||||
  DialogTitle,
 | 
			
		||||
  TextField,
 | 
			
		||||
} from "@mui/material";
 | 
			
		||||
import { DeviceRelay } from "../api/DeviceApi";
 | 
			
		||||
import React from "react";
 | 
			
		||||
 | 
			
		||||
export function SelectForcedStateDurationDialog(p: {
 | 
			
		||||
  relay: DeviceRelay;
 | 
			
		||||
  forcedState: string;
 | 
			
		||||
  onCancel: () => void;
 | 
			
		||||
  onSubmit: (duration: number) => void;
 | 
			
		||||
}): React.ReactElement {
 | 
			
		||||
  const [duration, setDuration] = React.useState(60);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog open onClose={p.onCancel}>
 | 
			
		||||
      <DialogTitle>Set forced relay state</DialogTitle>
 | 
			
		||||
      <DialogContent>
 | 
			
		||||
        <DialogContentText>
 | 
			
		||||
          Please specify the number of minutes the relay <i>{p.relay.name}</i>{" "}
 | 
			
		||||
          will remain in forced state <i>{p.forcedState}</i>:
 | 
			
		||||
        </DialogContentText>
 | 
			
		||||
 | 
			
		||||
        <TextField
 | 
			
		||||
          label="Duration (min)"
 | 
			
		||||
          variant="standard"
 | 
			
		||||
          value={Math.floor(duration / 60)}
 | 
			
		||||
          onChange={(e) => {
 | 
			
		||||
            const val = Number.parseInt(e.target.value);
 | 
			
		||||
            setDuration((Number.isNaN(val) ? 1 : val) * 60);
 | 
			
		||||
          }}
 | 
			
		||||
          fullWidth
 | 
			
		||||
          style={{ marginTop: "5px" }}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        <p>Equivalent in seconds: {duration} secs</p>
 | 
			
		||||
        <p>Equivalent in hours: {duration / 3600} hours</p>
 | 
			
		||||
      </DialogContent>
 | 
			
		||||
      <DialogActions>
 | 
			
		||||
        <Button onClick={p.onCancel}>Cancel</Button>
 | 
			
		||||
        <Button onClick={() => p.onSubmit(duration)} autoFocus>
 | 
			
		||||
          Start timer
 | 
			
		||||
        </Button>
 | 
			
		||||
      </DialogActions>
 | 
			
		||||
    </Dialog>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -10,16 +10,16 @@ import {
 | 
			
		||||
} from "@mui/material";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { Device, DeviceRelay } from "../../api/DeviceApi";
 | 
			
		||||
import { RelayApi, RelayStatus } from "../../api/RelayApi";
 | 
			
		||||
import { EditDeviceRelaysDialog } from "../../dialogs/EditDeviceRelaysDialog";
 | 
			
		||||
import { DeviceRouteCard } from "./DeviceRouteCard";
 | 
			
		||||
import { useAlert } from "../../hooks/context_providers/AlertDialogProvider";
 | 
			
		||||
import { useConfirm } from "../../hooks/context_providers/ConfirmDialogProvider";
 | 
			
		||||
import { useLoadingMessage } from "../../hooks/context_providers/LoadingMessageProvider";
 | 
			
		||||
import { RelayApi, RelayStatus } from "../../api/RelayApi";
 | 
			
		||||
import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider";
 | 
			
		||||
import { useAlert } from "../../hooks/context_providers/AlertDialogProvider";
 | 
			
		||||
import { AsyncWidget } from "../../widgets/AsyncWidget";
 | 
			
		||||
import { TimeWidget } from "../../widgets/TimeWidget";
 | 
			
		||||
import { BoolText } from "../../widgets/BoolText";
 | 
			
		||||
import { TimeWidget } from "../../widgets/TimeWidget";
 | 
			
		||||
import { DeviceRouteCard } from "./DeviceRouteCard";
 | 
			
		||||
 | 
			
		||||
export function DeviceRelays(p: {
 | 
			
		||||
  device: Device;
 | 
			
		||||
@@ -145,7 +145,8 @@ function RelayEntryStatus(
 | 
			
		||||
      errMsg="Failed to load relay status!"
 | 
			
		||||
      build={() => (
 | 
			
		||||
        <>
 | 
			
		||||
          <BoolText val={state!.on} positive="ON" negative="OFF" /> for{" "}
 | 
			
		||||
          <BoolText val={state!.on} positive="ON" negative="OFF" />{" "}
 | 
			
		||||
          {state?.forced_state.type !== "None" && <b>Forced</b>} for{" "}
 | 
			
		||||
          <TimeWidget diff time={state!.for} />
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
 
 | 
			
		||||
@@ -16,12 +16,13 @@ import React from "react";
 | 
			
		||||
import { useNavigate } from "react-router-dom";
 | 
			
		||||
import { Device, DeviceApi, DeviceRelay, DeviceURL } from "../api/DeviceApi";
 | 
			
		||||
import { RelayApi, RelaysStatus } from "../api/RelayApi";
 | 
			
		||||
import { ServerApi } from "../api/ServerApi";
 | 
			
		||||
import { AsyncWidget } from "../widgets/AsyncWidget";
 | 
			
		||||
import { BoolText } from "../widgets/BoolText";
 | 
			
		||||
import { CopyToClipboard } from "../widgets/CopyToClipboard";
 | 
			
		||||
import { RelayForcedState } from "../widgets/RelayForcedState";
 | 
			
		||||
import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer";
 | 
			
		||||
import { TimeWidget } from "../widgets/TimeWidget";
 | 
			
		||||
import { CopyToClipboard } from "../widgets/CopyToClipboard";
 | 
			
		||||
import { ServerApi } from "../api/ServerApi";
 | 
			
		||||
 | 
			
		||||
export function RelaysListRoute(p: {
 | 
			
		||||
  homeWidget?: boolean;
 | 
			
		||||
@@ -104,6 +105,7 @@ function RelaysList(p: {
 | 
			
		||||
            <TableCell>Priority</TableCell>
 | 
			
		||||
            <TableCell>Consumption</TableCell>
 | 
			
		||||
            <TableCell>Status</TableCell>
 | 
			
		||||
            <TableCell>Forced state</TableCell>
 | 
			
		||||
            <TableCell></TableCell>
 | 
			
		||||
          </TableRow>
 | 
			
		||||
        </TableHead>
 | 
			
		||||
@@ -129,6 +131,13 @@ function RelaysList(p: {
 | 
			
		||||
                />{" "}
 | 
			
		||||
                for <TimeWidget diff time={p.status.get(row.id)!.for} />
 | 
			
		||||
              </TableCell>
 | 
			
		||||
              <TableCell>
 | 
			
		||||
                <RelayForcedState
 | 
			
		||||
                  relay={row}
 | 
			
		||||
                  state={p.status.get(row.id)!}
 | 
			
		||||
                  onUpdated={p.onReload}
 | 
			
		||||
                />
 | 
			
		||||
              </TableCell>
 | 
			
		||||
              <TableCell>
 | 
			
		||||
                <Tooltip title="Copy legacy api status">
 | 
			
		||||
                  <CopyToClipboard
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										79
									
								
								central_frontend/src/widgets/RelayForcedState.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								central_frontend/src/widgets/RelayForcedState.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,79 @@
 | 
			
		||||
import { MenuItem, Select, SelectChangeEvent } from "@mui/material";
 | 
			
		||||
import { DeviceRelay } from "../api/DeviceApi";
 | 
			
		||||
import { RelayApi, RelayStatus, SetRelayForcedState } from "../api/RelayApi";
 | 
			
		||||
import { TimeWidget } from "./TimeWidget";
 | 
			
		||||
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
 | 
			
		||||
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
 | 
			
		||||
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { SelectForcedStateDurationDialog } from "../dialogs/SelectForcedStateDurationDialog";
 | 
			
		||||
 | 
			
		||||
export function RelayForcedState(p: {
 | 
			
		||||
  relay: DeviceRelay;
 | 
			
		||||
  state: RelayStatus;
 | 
			
		||||
  onUpdated: () => void;
 | 
			
		||||
}): React.ReactElement {
 | 
			
		||||
  const loadingMessage = useLoadingMessage();
 | 
			
		||||
  const alert = useAlert();
 | 
			
		||||
  const snackbar = useSnackbar();
 | 
			
		||||
 | 
			
		||||
  const [futureStateType, setFutureStateType] = React.useState<
 | 
			
		||||
    string | undefined
 | 
			
		||||
  >();
 | 
			
		||||
 | 
			
		||||
  const handleChange = (event: SelectChangeEvent) => {
 | 
			
		||||
    if (event.target.value == "None") {
 | 
			
		||||
      submitChange({ type: "None" });
 | 
			
		||||
    } else {
 | 
			
		||||
      setFutureStateType(event.target.value);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const submitChange = async (state: SetRelayForcedState) => {
 | 
			
		||||
    try {
 | 
			
		||||
      loadingMessage.show("Setting forced state...");
 | 
			
		||||
      await RelayApi.SetForcedState(p.relay, state);
 | 
			
		||||
      p.onUpdated();
 | 
			
		||||
      snackbar("Forced state successfully updated!");
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      console.error(`Failed to set relay forced state! ${e}`);
 | 
			
		||||
      alert(`Failed to set loading state for relay! ${e}`);
 | 
			
		||||
    } finally {
 | 
			
		||||
      loadingMessage.hide();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Select
 | 
			
		||||
        value={p.state.forced_state.type}
 | 
			
		||||
        onChange={handleChange}
 | 
			
		||||
        size="small"
 | 
			
		||||
        variant="standard"
 | 
			
		||||
      >
 | 
			
		||||
        <MenuItem value={"None"}>None</MenuItem>
 | 
			
		||||
        <MenuItem value={"Off"}>Off</MenuItem>
 | 
			
		||||
        <MenuItem value={"On"}>On</MenuItem>
 | 
			
		||||
      </Select>
 | 
			
		||||
      {p.state.forced_state.type !== "None" && (
 | 
			
		||||
        <>
 | 
			
		||||
          <TimeWidget future time={p.state.forced_state.until} /> left
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {futureStateType !== undefined && (
 | 
			
		||||
        <SelectForcedStateDurationDialog
 | 
			
		||||
          {...p}
 | 
			
		||||
          forcedState={futureStateType}
 | 
			
		||||
          onCancel={() => setFutureStateType(undefined)}
 | 
			
		||||
          onSubmit={(d) =>
 | 
			
		||||
            submitChange({
 | 
			
		||||
              type: futureStateType as any,
 | 
			
		||||
              for_secs: d,
 | 
			
		||||
            })
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -106,7 +106,7 @@ export default function StatCard({
 | 
			
		||||
          <Box sx={{ width: "100%", height: 100 }}>
 | 
			
		||||
            {data && interval && (
 | 
			
		||||
              <SparkLineChart
 | 
			
		||||
                colors={[chartColor]}
 | 
			
		||||
                color={chartColor}
 | 
			
		||||
                data={data}
 | 
			
		||||
                area
 | 
			
		||||
                showHighlight
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,11 @@
 | 
			
		||||
import { Tooltip } from "@mui/material";
 | 
			
		||||
import date from "date-and-time";
 | 
			
		||||
import { format } 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");
 | 
			
		||||
  return format(t, "DD/MM/YYYY HH:mm:ss");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function timeDiff(a: number, b: number): string {
 | 
			
		||||
@@ -51,13 +51,14 @@ export function timeDiff(a: number, b: number): string {
 | 
			
		||||
  return `${diffYears} years`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function timeDiffFromNow(t: number): string {
 | 
			
		||||
  return timeDiff(t, time());
 | 
			
		||||
export function timeDiffFromNow(t: number, future?: boolean): string {
 | 
			
		||||
  return future ? timeDiff(time(), t) : timeDiff(t, time());
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function TimeWidget(p: {
 | 
			
		||||
  time?: number;
 | 
			
		||||
  diff?: boolean;
 | 
			
		||||
  future?: boolean;
 | 
			
		||||
}): React.ReactElement {
 | 
			
		||||
  if (!p.time) return <></>;
 | 
			
		||||
  return (
 | 
			
		||||
@@ -65,7 +66,9 @@ export function TimeWidget(p: {
 | 
			
		||||
      title={formatDate(p.diff ? new Date().getTime() / 1000 - p.time : p.time)}
 | 
			
		||||
      arrow
 | 
			
		||||
    >
 | 
			
		||||
      <span>{p.diff ? timeDiff(0, p.time) : timeDiffFromNow(p.time)}</span>
 | 
			
		||||
      <span>
 | 
			
		||||
        {p.diff ? timeDiff(0, p.time) : timeDiffFromNow(p.time, p.future)}
 | 
			
		||||
      </span>
 | 
			
		||||
    </Tooltip>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										14
									
								
								custom_consumption/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										14
									
								
								custom_consumption/Cargo.lock
									
									
									
										generated
									
									
									
								
							@@ -623,9 +623,9 @@ dependencies = [
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "clap"
 | 
			
		||||
version = "4.5.47"
 | 
			
		||||
version = "4.5.51"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931"
 | 
			
		||||
checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "clap_builder",
 | 
			
		||||
 "clap_derive",
 | 
			
		||||
@@ -633,9 +633,9 @@ dependencies = [
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "clap_builder"
 | 
			
		||||
version = "4.5.47"
 | 
			
		||||
version = "4.5.51"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6"
 | 
			
		||||
checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "anstream",
 | 
			
		||||
 "anstyle",
 | 
			
		||||
@@ -645,9 +645,9 @@ dependencies = [
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "clap_derive"
 | 
			
		||||
version = "4.5.47"
 | 
			
		||||
version = "4.5.49"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c"
 | 
			
		||||
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "heck",
 | 
			
		||||
 "proc-macro2",
 | 
			
		||||
@@ -1714,7 +1714,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "cfg-if",
 | 
			
		||||
 "windows-targets 0.48.5",
 | 
			
		||||
 "windows-targets 0.52.6",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,7 @@ edition = "2024"
 | 
			
		||||
[dependencies]
 | 
			
		||||
env_logger = "0.11.8"
 | 
			
		||||
log = "0.4.28"
 | 
			
		||||
clap = { version = "4.5.47", features = ["derive", "env"] }
 | 
			
		||||
clap = { version = "4.5.51", features = ["derive", "env"] }
 | 
			
		||||
egui = "0.32.3"
 | 
			
		||||
eframe = "0.32.3"
 | 
			
		||||
lazy_static = "1.5.0"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,3 @@
 | 
			
		||||
#define CONFIG_ETH_USE_ESP32_EMAC
 | 
			
		||||
 | 
			
		||||
#include "esp_eth.h"
 | 
			
		||||
#include "esp_eth_mac.h"
 | 
			
		||||
#include "esp_eth_com.h"
 | 
			
		||||
 
 | 
			
		||||
@@ -1 +1 @@
 | 
			
		||||
1.0.2
 | 
			
		||||
1.0.3
 | 
			
		||||
		Reference in New Issue
	
	Block a user