mirror of
https://codeberg.org/madmo/trvcontrol.git
synced 2024-12-03 10:33:56 +00:00
initial import
This commit is contained in:
commit
901b44284f
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/target
|
8
.idea/.gitignore
vendored
Normal file
8
.idea/.gitignore
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
8
.idea/modules.xml
Normal file
8
.idea/modules.xml
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/space_heater.iml" filepath="$PROJECT_DIR$/.idea/space_heater.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
11
.idea/space_heater.iml
Normal file
11
.idea/space_heater.iml
Normal file
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="CPP_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
6
.idea/vcs.xml
Normal file
6
.idea/vcs.xml
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
1112
Cargo.lock
generated
Normal file
1112
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal file
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "trvcontrol"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
toml = "0.8.8"
|
||||
rumqttc = "0.23.0"
|
||||
clap = { version = "4.3.19", features = ["derive"] }
|
||||
snafu = "0.7.5"
|
||||
clokwerk = "0.4.0"
|
||||
serde = { version = "1.0.190", features = ["derive"] }
|
||||
chrono = "0.4.31"
|
||||
log = "0.4.20"
|
||||
env_logger = "0.10.0"
|
13
LICENSE.txt
Normal file
13
LICENSE.txt
Normal file
|
@ -0,0 +1,13 @@
|
|||
Copyright 2023 Moritz Bitsch <moritz@h6t.eu>
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
40
README.md
Normal file
40
README.md
Normal file
|
@ -0,0 +1,40 @@
|
|||
# TRV Control
|
||||
|
||||
A small program to control thermostatic radiator valves using zigbee2mqtt. The idea is, that you write a configuration
|
||||
file specifying profiles, which get activated on a specified schedule.
|
||||
|
||||
# Installation
|
||||
|
||||
```shell
|
||||
cargo install --locked --git https://github.com/madmo/trvcontrol
|
||||
```
|
||||
|
||||
# Usage
|
||||
|
||||
Sample config:
|
||||
|
||||
```toml
|
||||
[mqtt]
|
||||
host = "localhost"
|
||||
port = 1883
|
||||
user = "user"
|
||||
password = "password"
|
||||
|
||||
[profiles.workday_morning]
|
||||
alarm = { days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], time = 04:00:00 }
|
||||
temperature = { bath_trv = 23.0, bedroom_trv = 21.0 }
|
||||
|
||||
[profiles.workday_day]
|
||||
alarm = { days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], time = 08:00:00 }
|
||||
temperature = { bath_trv = 20.0, office_trv = 23 }
|
||||
```
|
||||
|
||||
Starting the program:
|
||||
|
||||
```shell
|
||||
trvcontrol config.toml
|
||||
```
|
||||
|
||||
# License
|
||||
|
||||
Distributed under the ISC License. See LICENSE.txt for more information.
|
160
src/main.rs
Normal file
160
src/main.rs
Normal file
|
@ -0,0 +1,160 @@
|
|||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
use clokwerk::Interval::{Friday, Saturday, Sunday, Thursday, Wednesday};
|
||||
use clokwerk::{Interval, Job, Scheduler, SyncJob};
|
||||
use log::{debug, error, warn};
|
||||
use rumqttc::{Client, ClientError, MqttOptions, QoS};
|
||||
use serde::Deserialize;
|
||||
use snafu::{ResultExt, Whatever};
|
||||
use toml::value::Datetime;
|
||||
|
||||
#[derive(Parser)]
|
||||
struct Args {
|
||||
/// path to config file
|
||||
config: String,
|
||||
|
||||
#[clap(long)]
|
||||
dump_config: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Config {
|
||||
mqtt: Mqtt,
|
||||
profiles: HashMap<String, Profile>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Mqtt {
|
||||
host: String,
|
||||
port: u16,
|
||||
user: Option<String>,
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
type FriendlyName = String;
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct Profile {
|
||||
alarm: Option<Alarm>,
|
||||
temperature: HashMap<FriendlyName, f32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct Alarm {
|
||||
days: Vec<Days>,
|
||||
time: Datetime,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub enum Days {
|
||||
Monday,
|
||||
Tuesday,
|
||||
Wednesday,
|
||||
Thursday,
|
||||
Friday,
|
||||
Saturday,
|
||||
Sunday,
|
||||
}
|
||||
|
||||
impl From<Days> for Interval {
|
||||
fn from(val: Days) -> Interval {
|
||||
match val {
|
||||
Days::Monday => Interval::Monday,
|
||||
Days::Tuesday => Interval::Tuesday,
|
||||
Days::Wednesday => Wednesday,
|
||||
Days::Thursday => Thursday,
|
||||
Days::Friday => Friday,
|
||||
Days::Saturday => Saturday,
|
||||
Days::Sunday => Sunday,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Whatever> {
|
||||
let args = Args::parse();
|
||||
let config: Config = toml::from_str(std::fs::read_to_string(&args.config).unwrap().as_str())
|
||||
.with_whatever_context(|_| format!("Could not parse config file {}", args.config))?;
|
||||
|
||||
let mut scheduler = Scheduler::new();
|
||||
|
||||
let mut mqttoptions = MqttOptions::new("space_heater", config.mqtt.host, config.mqtt.port);
|
||||
mqttoptions.set_keep_alive(Duration::from_secs(5));
|
||||
if let (Some(user), Some(password)) = (config.mqtt.user, config.mqtt.password) {
|
||||
mqttoptions.set_credentials(user, password);
|
||||
}
|
||||
|
||||
let (client, mut connection) = Client::new(mqttoptions, 10);
|
||||
|
||||
for (_, profile) in config.profiles {
|
||||
handle_profile(&mut scheduler, profile, &client, args.dump_config);
|
||||
}
|
||||
|
||||
let watch_thread = scheduler.watch_thread(Duration::from_millis(500));
|
||||
|
||||
for (_i, notification) in connection.iter().enumerate() {
|
||||
match notification {
|
||||
Ok(n) => debug!("mqtt notification: {:?}", n),
|
||||
Err(e) => error!("mqtt error: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
watch_thread.stop();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_profile(scheduler: &mut Scheduler, profile: Profile, client: &Client, dump: bool) {
|
||||
if let Some(ref alarm) = profile.alarm {
|
||||
let mut jobq: Option<&mut SyncJob> = Option::None;
|
||||
|
||||
let time = alarm.time.time.unwrap();
|
||||
let time = chrono::naive::NaiveTime::from_hms_nano_opt(
|
||||
time.hour as u32,
|
||||
time.minute as u32,
|
||||
time.second as u32,
|
||||
time.nanosecond,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
for day in alarm.days.iter() {
|
||||
let day: Interval = (*day).clone().into();
|
||||
if let Some(prev) = jobq {
|
||||
jobq = Some(prev.and_every(day).at_time(time))
|
||||
} else {
|
||||
jobq = Some(scheduler.every(day).at_time(time));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(jobq) = jobq {
|
||||
if dump {
|
||||
println!("{:#?}", jobq);
|
||||
}
|
||||
|
||||
let mut client = client.clone();
|
||||
jobq.run(move || {
|
||||
for (room, temp) in profile.temperature.iter() {
|
||||
match set_temperature(&mut client, room, *temp) {
|
||||
Ok(_) => debug!("temperature in {} set to {}", room, temp),
|
||||
Err(e) => warn!("could not set temperature: {}", e),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn set_temperature(
|
||||
client: &mut Client,
|
||||
friendly_name: &str,
|
||||
temperature: f32,
|
||||
) -> Result<(), ClientError> {
|
||||
let json = format!(
|
||||
r#"{{"system_mode":"heat", "current_heating_setpoint":{}}}"#,
|
||||
temperature
|
||||
);
|
||||
let topic = format!("zigbee2mqtt/{}/set", friendly_name);
|
||||
println!("publish at {}: {}", topic, json);
|
||||
client.publish(topic, QoS::AtMostOnce, false, json.as_bytes())
|
||||
}
|
Loading…
Reference in a new issue