From 503bab511a147400f3e8878e9a924974f65c7fa6 Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Sun, 6 Apr 2025 13:32:02 -0600 Subject: [PATCH 1/7] code cleanup + better printing --- src/main.rs | 1 - src/monitor.rs | 6 +++--- src/tasmota.rs | 5 +++++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index 73f1b28..129371c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,6 @@ impl Commands { tokio::spawn(async move { m.get_power().await.unwrap(); }) - // println!("[TODO] Power: ----W") } }; handle.await.unwrap(); diff --git a/src/monitor.rs b/src/monitor.rs index dec5afd..c43974a 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -41,8 +41,6 @@ impl Monitor { pub fn new_from_file(config_file: &str) -> Result { let config_str = fs::read_to_string(config_file)?; let config: MonitorConfig = toml::from_str(&config_str)?; - config.print(); - Ok(Self { targets: Monitor::load_targets(&config.targets), client: Client::new(), @@ -60,7 +58,9 @@ impl Monitor { pub async fn get_power(&self) -> Result<(), Error> { for target in &self.targets { if let Ok(res) = target.get_power().await { - println!("POWER: {}W", res); + target.print(); + println!("* POWER: {}W", res); + println!("------------------") } } Ok(()) diff --git a/src/tasmota.rs b/src/tasmota.rs index dcc010b..1ad6f5b 100644 --- a/src/tasmota.rs +++ b/src/tasmota.rs @@ -26,11 +26,13 @@ pub struct StatusResponse { #[derive(Deserialize, Clone)] pub struct TasmotaInterfaceConfig { + name: String, target: String, } impl TasmotaInterfaceConfig { pub fn print(&self) { + println!("{}", self.name); println!("* {}", self.target); } } @@ -47,6 +49,9 @@ impl TasmotaInterface { client: Client::new(), } } + pub fn print(&self) { + println!("{}", self.config.name) + } } // Monitoring From 987150c9b630eb2b1540e4044db2f3b010b18c2b Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Sun, 6 Apr 2025 14:34:15 -0600 Subject: [PATCH 2/7] make SET stub + move elements into system wrapper --- src/control.rs | 5 +++++ src/main.rs | 16 ++++++++++--- src/monitor.rs | 61 +------------------------------------------------- src/system.rs | 51 +++++++++++++++++++++++++++++++++++++++++ src/tasmota.rs | 20 +++++++++++++++-- src/types.rs | 20 +++++++++++++++++ 6 files changed, 108 insertions(+), 65 deletions(-) create mode 100644 src/control.rs create mode 100644 src/system.rs create mode 100644 src/types.rs diff --git a/src/control.rs b/src/control.rs new file mode 100644 index 0000000..2ecde08 --- /dev/null +++ b/src/control.rs @@ -0,0 +1,5 @@ +use crate::types::Error; + +pub trait Control { + async fn set_power(&self) -> Result<(), Error>; +} diff --git a/src/main.rs b/src/main.rs index 129371c..42e68a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,21 +1,31 @@ use clap::{Parser, Subcommand}; -use monitor::Monitor; +mod control; mod monitor; +mod system; mod tasmota; +mod types; #[derive(Subcommand)] pub enum Commands { Monitor, + #[command(subcommand)] + Set(types::PowerState), } impl Commands { pub async fn execute(self, config_file: &str) { let handle = match self { Self::Monitor => { - let m = Monitor::new_from_file(config_file).unwrap(); + let s = system::System::new_from_file(config_file).unwrap(); tokio::spawn(async move { - m.get_power().await.unwrap(); + s.get_power().await.unwrap(); + }) + } + Self::Set(state) => { + // let c = Controller::new_from_file(config_file).unwrap(); + tokio::spawn(async move { + println!("SET"); }) } }; diff --git a/src/monitor.rs b/src/monitor.rs index c43974a..6d121d9 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -1,68 +1,9 @@ use crate::tasmota::{PowerStatusData, StatusResponse, TasmotaInterface, TasmotaInterfaceConfig}; +use crate::types::Error; use reqwest::Client; use serde::Deserialize; use std::fs; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum Error { - #[error("io error")] - IoError(#[from] std::io::Error), - #[error("toml parsing error")] - ParseError(#[from] toml::de::Error), - #[error("request error")] - RequestError(#[from] reqwest::Error), - #[error("JSON Parse error")] - JsonParseError(#[from] serde_json::Error), -} pub trait Monitoring { async fn get_power(&self) -> Result; } - -#[derive(Deserialize)] -pub struct MonitorConfig { - targets: Vec, -} -impl MonitorConfig { - fn print(&self) { - for t in &self.targets { - t.print(); - } - } -} - -pub struct Monitor { - targets: Vec, - client: Client, -} - -impl Monitor { - pub fn new_from_file(config_file: &str) -> Result { - let config_str = fs::read_to_string(config_file)?; - let config: MonitorConfig = toml::from_str(&config_str)?; - Ok(Self { - targets: Monitor::load_targets(&config.targets), - client: Client::new(), - }) - } - - pub fn load_targets(targets: &Vec) -> Vec { - let mut v = Vec::new(); - for target in targets { - v.push(TasmotaInterface::new(target.clone())); - } - v - } - - pub async fn get_power(&self) -> Result<(), Error> { - for target in &self.targets { - if let Ok(res) = target.get_power().await { - target.print(); - println!("* POWER: {}W", res); - println!("------------------") - } - } - Ok(()) - } -} diff --git a/src/system.rs b/src/system.rs new file mode 100644 index 0000000..61b7791 --- /dev/null +++ b/src/system.rs @@ -0,0 +1,51 @@ +use crate::monitor::Monitoring; +use crate::tasmota::{TasmotaInterface, TasmotaInterfaceConfig}; +use crate::types::Error; +use reqwest::Client; +use serde::Deserialize; +use std::fs; + +#[derive(Deserialize)] +pub struct SystemConfig { + components: Vec, +} +impl SystemConfig { + fn print(&self) { + for t in &self.components { + t.print(); + } + } +} + +pub struct System { + components: Vec, +} + +impl System { + pub fn new_from_file(config_file: &str) -> Result { + let config_str = fs::read_to_string(config_file)?; + let config: SystemConfig = toml::from_str(&config_str)?; + Ok(Self { + components: System::load_targets(&config.components), + }) + } + + pub fn load_targets(targets: &Vec) -> Vec { + let mut v = Vec::new(); + for target in targets { + v.push(TasmotaInterface::new(target.clone())); + } + v + } + + pub async fn get_power(&self) -> Result<(), Error> { + for component in &self.components { + if let Ok(res) = component.get_power().await { + component.print(); + println!("* POWER: {}W", res); + println!("------------------") + } + } + Ok(()) + } +} diff --git a/src/tasmota.rs b/src/tasmota.rs index 1ad6f5b..769eaf2 100644 --- a/src/tasmota.rs +++ b/src/tasmota.rs @@ -1,8 +1,7 @@ use reqwest::Client; use serde::Deserialize; -use crate::monitor::Error; -use crate::monitor::Monitoring; +use crate::{control::Control, monitor::Monitoring}; #[derive(Deserialize)] pub struct EnergyData { @@ -69,3 +68,20 @@ impl Monitoring for TasmotaInterface { Ok(data.status.energy.power) } } + +impl Control for TasmotaInterface { + async fn set_power(&self) -> Result<(), Error> { + let res = self + .client + .get(format!( + "http://{}/cm?cmnd=Power%20TOGGLE", + &self.config.target + )) + .send() + .await? + .text() + .await?; + + Ok(()) + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..889bcb6 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,20 @@ +use clap::Subcommand; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("io error")] + IoError(#[from] std::io::Error), + #[error("toml parsing error")] + ParseError(#[from] toml::de::Error), + #[error("request error")] + RequestError(#[from] reqwest::Error), + #[error("JSON Parse error")] + JsonParseError(#[from] serde_json::Error), +} + +#[derive(Subcommand, Clone)] +pub enum PowerState { + Off, + On, +} From 633a57ff239c5a538e33271a236571f99ab063a1 Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Sun, 6 Apr 2025 15:21:38 -0600 Subject: [PATCH 3/7] add set functionality to control outlets --- src/control.rs | 4 ++-- src/main.rs | 25 +++++++++++++++---------- src/system.rs | 11 +++++++++-- src/tasmota.rs | 19 +++++++++++++------ src/types.rs | 5 +++-- 5 files changed, 42 insertions(+), 22 deletions(-) diff --git a/src/control.rs b/src/control.rs index 2ecde08..7b9ae29 100644 --- a/src/control.rs +++ b/src/control.rs @@ -1,5 +1,5 @@ -use crate::types::Error; +use crate::types::{Error, PowerState}; pub trait Control { - async fn set_power(&self) -> Result<(), Error>; + async fn set_power(&self, state: PowerState) -> Result<(), Error>; } diff --git a/src/main.rs b/src/main.rs index 42e68a9..a85d5e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,26 +6,31 @@ mod system; mod tasmota; mod types; +#[derive(Parser)] +pub struct PowerCommand { + index: usize, + #[command(subcommand)] + state: types::PowerState, +} + #[derive(Subcommand)] pub enum Commands { Monitor, - #[command(subcommand)] - Set(types::PowerState), + Set(PowerCommand), } impl Commands { pub async fn execute(self, config_file: &str) { + let s = system::System::new_from_file(config_file).unwrap(); + let handle = match self { - Self::Monitor => { - let s = system::System::new_from_file(config_file).unwrap(); - tokio::spawn(async move { - s.get_power().await.unwrap(); - }) - } - Self::Set(state) => { + Self::Monitor => tokio::spawn(async move { + s.try_get_power().await.unwrap(); + }), + Self::Set(command) => { // let c = Controller::new_from_file(config_file).unwrap(); tokio::spawn(async move { - println!("SET"); + s.try_set_power(command.index, command.state).await.unwrap(); }) } }; diff --git a/src/system.rs b/src/system.rs index 61b7791..b5ffa8c 100644 --- a/src/system.rs +++ b/src/system.rs @@ -1,6 +1,7 @@ +use crate::control::Control; use crate::monitor::Monitoring; use crate::tasmota::{TasmotaInterface, TasmotaInterfaceConfig}; -use crate::types::Error; +use crate::types::{self, Error}; use reqwest::Client; use serde::Deserialize; use std::fs; @@ -38,7 +39,7 @@ impl System { v } - pub async fn get_power(&self) -> Result<(), Error> { + pub async fn try_get_power(&self) -> Result<(), Error> { for component in &self.components { if let Ok(res) = component.get_power().await { component.print(); @@ -48,4 +49,10 @@ impl System { } Ok(()) } + + pub async fn try_set_power(&self, index: usize, state: types::PowerState) -> Result<(), Error> { + //TODO: check bounds + self.components[index].set_power(state).await?; + Ok(()) + } } diff --git a/src/tasmota.rs b/src/tasmota.rs index 769eaf2..b8bae8a 100644 --- a/src/tasmota.rs +++ b/src/tasmota.rs @@ -1,7 +1,11 @@ use reqwest::Client; use serde::Deserialize; -use crate::{control::Control, monitor::Monitoring}; +use crate::{ + control::Control, + monitor::Monitoring, + types::{Error, PowerState}, +}; #[derive(Deserialize)] pub struct EnergyData { @@ -70,18 +74,21 @@ impl Monitoring for TasmotaInterface { } impl Control for TasmotaInterface { - async fn set_power(&self) -> Result<(), Error> { - let res = self + async fn set_power(&self, state: PowerState) -> Result<(), Error> { + let cmd = match state { + PowerState::Off => "OFF", + PowerState::On => "ON", + }; + let _res = self .client .get(format!( - "http://{}/cm?cmnd=Power%20TOGGLE", - &self.config.target + "http://{}/cm?cmnd=Power%20{}", + &self.config.target, cmd )) .send() .await? .text() .await?; - Ok(()) } } diff --git a/src/types.rs b/src/types.rs index 889bcb6..d25711e 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,4 +1,5 @@ -use clap::Subcommand; +use clap::Parser; +use serde::{Deserialize, Serialize}; use thiserror::Error; #[derive(Error, Debug)] @@ -13,7 +14,7 @@ pub enum Error { JsonParseError(#[from] serde_json::Error), } -#[derive(Subcommand, Clone)] +#[derive(Serialize, Deserialize, Parser, Clone)] pub enum PowerState { Off, On, From 230034c7f0fa8e19fdab2b686885f3c7db99710e Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Sun, 6 Apr 2025 15:35:10 -0600 Subject: [PATCH 4/7] code cleanup + add CLI bin --- src/bin/cli.rs | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/control.rs | 5 ++++- src/lib.rs | 5 +++++ src/monitor.rs | 6 +----- src/system.rs | 2 +- 5 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 src/bin/cli.rs create mode 100644 src/lib.rs diff --git a/src/bin/cli.rs b/src/bin/cli.rs new file mode 100644 index 0000000..533519b --- /dev/null +++ b/src/bin/cli.rs @@ -0,0 +1,53 @@ +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +pub struct PowerCommand { + index: usize, + #[command(subcommand)] + state: power::types::PowerState, +} + +#[derive(Subcommand)] +pub enum Commands { + Monitor, + Set(PowerCommand), +} + +impl Commands { + pub async fn execute(self, config_file: &str) { + let s = power::system::System::new_from_file(config_file).unwrap(); + + let handle = match self { + Self::Monitor => tokio::spawn(async move { + s.try_get_power().await.unwrap(); + }), + Self::Set(command) => { + // let c = Controller::new_from_file(config_file).unwrap(); + tokio::spawn(async move { + s.try_set_power(command.index, command.state).await.unwrap(); + }) + } + }; + handle.await.unwrap(); + } +} + +#[derive(Parser)] +pub struct Cli { + #[arg(short, long)] + config_file: String, + #[command(subcommand)] + command: Commands, +} + +impl Cli { + pub async fn execute(self) { + self.command.execute(&self.config_file).await; + } +} + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + cli.execute().await; +} diff --git a/src/control.rs b/src/control.rs index 7b9ae29..05f44a9 100644 --- a/src/control.rs +++ b/src/control.rs @@ -1,5 +1,8 @@ use crate::types::{Error, PowerState}; pub trait Control { - async fn set_power(&self, state: PowerState) -> Result<(), Error>; + fn set_power( + &self, + state: PowerState, + ) -> impl std::future::Future> + Send; } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..aa9de85 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,5 @@ +pub mod control; +pub mod monitor; +pub mod system; +pub mod tasmota; +pub mod types; diff --git a/src/monitor.rs b/src/monitor.rs index 6d121d9..ce999ff 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -1,9 +1,5 @@ -use crate::tasmota::{PowerStatusData, StatusResponse, TasmotaInterface, TasmotaInterfaceConfig}; use crate::types::Error; -use reqwest::Client; -use serde::Deserialize; -use std::fs; pub trait Monitoring { - async fn get_power(&self) -> Result; + fn get_power(&self) -> impl std::future::Future> + Send; } diff --git a/src/system.rs b/src/system.rs index b5ffa8c..e917d22 100644 --- a/src/system.rs +++ b/src/system.rs @@ -2,7 +2,6 @@ use crate::control::Control; use crate::monitor::Monitoring; use crate::tasmota::{TasmotaInterface, TasmotaInterfaceConfig}; use crate::types::{self, Error}; -use reqwest::Client; use serde::Deserialize; use std::fs; @@ -11,6 +10,7 @@ pub struct SystemConfig { components: Vec, } impl SystemConfig { + #[allow(unused)] fn print(&self) { for t in &self.components { t.print(); From 99350853ca0d052617a8fe4f54669f9458a03043 Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Thu, 17 Apr 2025 18:33:09 -0600 Subject: [PATCH 5/7] update README to actually be helpful --- README.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++++---- config.toml | 8 +++++--- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d4dd52e..09e892f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,57 @@ -CLI application for interfacing with powered devices. +# POWER +this repository provides both +1. modular library for interfacing with powered devices +2. CLI application which uses this library to control powered devices from the command line -in this case, tasmota smart outlets being used on the hydroponics project +**IMPORTANT:** this is a work in progress, and the API will likely have breaking changes as it grows. ADDITIONALLY, currently there is only trait implementations for Tasmota outlets, however other devices can be supported by implementing these traits for your device-specific API. You can use `tasmota.rs` as an example of how to do this. once implemented, your devices will be compatible with this library. +# CLI +## BUILDING THE CLI +with a working rust toolchain on your machine, run the following: -TODO: -* add reqwest support to access the tasmota outlets \ No newline at end of file +`cargo build --release --bin cli` + +once built, the CLI binary will be available at the following path: +`/target/release/cli` + +## CONFIGURING THE SYSTEM DESCRIPTION FILE +a `system` consists of a number of `components` which are defined in a configuration `toml` file. + +an example, `config.toml` has been provided. + +each entry corresponds to a `component` and provides a `name` as well as the `target` IP address of the (in this case tasmota) power device. + +```toml +components = [ + {name="OUTLET 1", target="OUTLET_1_IP"}, # OUTLET 1 + {name="OUTLET 2", target="OUTLET_2_IP"}, # OUTLET 2 + {name="OUTLET 3", target="OUTLET_3_IP"}, # OUTLET 3 ... +] +``` + +## RUNNING THE CLI +the CLI takes as an argument the `config-file` (described above) as well as a subcommand. + +more usage information about the CLI can be obtained by running + +`./cli help` + +## COMMANDS +### MONITOR +the `monitor` command will output the power consumption of the components listed in the config file. + +example: +```log +OUTLET_1 +* POWER: 31W +------------------ +OUTLET_2 +* POWER: 0W +------------------ +OUTLET_3 +* POWER: 0W +------------------ +``` + +### SET +the `set` command allows a user to set the power state of a `component` at a given `index`. `component`s are indexed starting at 0 in the order they are defined in your configuration file. \ No newline at end of file diff --git a/config.toml b/config.toml index 43620fe..a3605da 100644 --- a/config.toml +++ b/config.toml @@ -1,3 +1,5 @@ -target = [ - "YOUR IP HERE" - ] \ No newline at end of file +components = [ + {name="OUTLET 1", target="OUTLET_1_IP"}, # OUTLET 1 + {name="OUTLET 2", target="OUTLET_2_IP"}, # OUTLET 2 + {name="OUTLET 3", target="OUTLET_3_IP"}, # OUTLET 3 ... +] \ No newline at end of file From 90810474c5ca1f1580beea87ebf640083635c4f4 Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Thu, 17 Apr 2025 18:33:23 -0600 Subject: [PATCH 6/7] update deleted file --- src/main.rs | 59 ----------------------------------------------------- 1 file changed, 59 deletions(-) delete mode 100644 src/main.rs diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index a85d5e0..0000000 --- a/src/main.rs +++ /dev/null @@ -1,59 +0,0 @@ -use clap::{Parser, Subcommand}; - -mod control; -mod monitor; -mod system; -mod tasmota; -mod types; - -#[derive(Parser)] -pub struct PowerCommand { - index: usize, - #[command(subcommand)] - state: types::PowerState, -} - -#[derive(Subcommand)] -pub enum Commands { - Monitor, - Set(PowerCommand), -} - -impl Commands { - pub async fn execute(self, config_file: &str) { - let s = system::System::new_from_file(config_file).unwrap(); - - let handle = match self { - Self::Monitor => tokio::spawn(async move { - s.try_get_power().await.unwrap(); - }), - Self::Set(command) => { - // let c = Controller::new_from_file(config_file).unwrap(); - tokio::spawn(async move { - s.try_set_power(command.index, command.state).await.unwrap(); - }) - } - }; - handle.await.unwrap(); - } -} - -#[derive(Parser)] -pub struct Cli { - #[arg(short, long)] - config_file: String, - #[command(subcommand)] - command: Commands, -} - -impl Cli { - pub async fn execute(self) { - self.command.execute(&self.config_file).await; - } -} - -#[tokio::main] -async fn main() { - let cli = Cli::parse(); - cli.execute().await; -} From 028c5eeb36d7f5d93677860fa9a9dd27c68fe6b7 Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Thu, 17 Apr 2025 18:34:11 -0600 Subject: [PATCH 7/7] update Cargo.toml to support new CLI bin --- Cargo.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index bf23c38..70f4958 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,3 +11,7 @@ serde_json = "1.0.140" thiserror = "2.0.12" tokio = { version = "1.44.1", features = ["full"] } toml = "0.8.20" + +[[bin]] +name="cli" +path="src/bin/cli.rs" \ No newline at end of file