Compare commits
32 Commits
feature/mo
...
master
Author | SHA1 | Date | |
---|---|---|---|
d9e83a49a1 | |||
00cd0366fd | |||
68684d9410 | |||
746e19eb8c | |||
47d509cec1 | |||
856bc3cc96 | |||
fbabc978b1 | |||
48c600b9cb | |||
3905df690b | |||
5af713cf8f | |||
ae61cf5dd2 | |||
8ad75a1148 | |||
ef180f6261 | |||
1462755f36 | |||
90a94934fb | |||
24815edd34 | |||
bf6d80ded9 | |||
175056416e | |||
e4c211a278 | |||
8c9e93dcc4 | |||
41d2af655b | |||
eefb476d7f | |||
14aabe202d | |||
e8d5698835 | |||
8877b24e84 | |||
42f391cde6 | |||
e9f080ef19 | |||
9d4b52b511 | |||
03f1790627 | |||
d39432fa22 | |||
6b8d0b7d56 | |||
5185b0d3ba |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
*.xcf filter=lfs diff=lfs merge=lfs -text
|
1331
Cargo.lock
generated
1331
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
102
Cargo.toml
102
Cargo.toml
|
@ -9,45 +9,11 @@ members = [
|
||||||
"automation_cast",
|
"automation_cast",
|
||||||
"google_home/google_home",
|
"google_home/google_home",
|
||||||
"google_home/google_home_macro",
|
"google_home/google_home_macro",
|
||||||
|
"automation_devices",
|
||||||
|
"automation_lib",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
[dependencies]
|
|
||||||
automation_macro = { path = "./automation_macro" }
|
|
||||||
automation_cast = { path = "./automation_cast/" }
|
|
||||||
rumqttc = "0.18"
|
|
||||||
serde = { version = "1.0.149", features = ["derive"] }
|
|
||||||
serde_json = "1.0.89"
|
|
||||||
google_home = { path = "./google_home/google_home/" }
|
|
||||||
paste = "1.0.10"
|
|
||||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
|
||||||
dotenvy = "0.15.0"
|
|
||||||
reqwest = { version = "0.11.13", features = [
|
|
||||||
"json",
|
|
||||||
"rustls-tls",
|
|
||||||
], default-features = false } # Use rustls, since the other packages also use rustls
|
|
||||||
axum = "0.6.1"
|
|
||||||
serde_repr = "0.1.10"
|
|
||||||
tracing = "0.1.37"
|
|
||||||
bytes = "1.3.0"
|
|
||||||
pollster = "0.2.5"
|
|
||||||
regex = "1.7.0"
|
|
||||||
async-trait = "0.1.61"
|
|
||||||
futures = "0.3.25"
|
|
||||||
eui48 = { version = "1.1.0", default-features = false, features = [
|
|
||||||
"disp_hexstring",
|
|
||||||
"serde",
|
|
||||||
] }
|
|
||||||
thiserror = "1.0.38"
|
|
||||||
anyhow = "1.0.68"
|
|
||||||
wakey = "0.3.0"
|
|
||||||
console-subscriber = "0.1.8"
|
|
||||||
tracing-subscriber = "0.3.16"
|
|
||||||
serde_with = "3.2.0"
|
|
||||||
enum_dispatch = "0.3.12"
|
|
||||||
indexmap = { version = "2.0.0", features = ["serde"] }
|
|
||||||
serde_yaml = "0.9.27"
|
|
||||||
tokio-cron-scheduler = "0.9.4"
|
|
||||||
mlua = { version = "0.10.1", features = [
|
mlua = { version = "0.10.1", features = [
|
||||||
"lua54",
|
"lua54",
|
||||||
"vendored",
|
"vendored",
|
||||||
|
@ -56,11 +22,67 @@ mlua = { version = "0.10.1", features = [
|
||||||
"async",
|
"async",
|
||||||
"send",
|
"send",
|
||||||
] }
|
] }
|
||||||
hostname = "0.4.0"
|
automation_macro = { path = "./automation_macro" }
|
||||||
tokio-util = { version = "0.7.11", features = ["full"] }
|
automation_cast = { path = "./automation_cast" }
|
||||||
uuid = "1.8.0"
|
automation_lib = { path = "./automation_lib" }
|
||||||
|
automation_devices = { path = "./automation_devices" }
|
||||||
|
google_home = { path = "./google_home/google_home" }
|
||||||
|
google_home_macro = { path = "./google_home/google_home_macro" }
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||||
|
rumqttc = "0.24.0"
|
||||||
|
tracing = "0.1.37"
|
||||||
|
anyhow = "1.0.68"
|
||||||
|
async-trait = "0.1.83"
|
||||||
|
axum = "0.7.9"
|
||||||
|
bytes = "1.3.0"
|
||||||
|
dotenvy = "0.15.0"
|
||||||
dyn-clone = "1.0.17"
|
dyn-clone = "1.0.17"
|
||||||
|
eui48 = { version = "1.1.0", features = [
|
||||||
|
"disp_hexstring",
|
||||||
|
"serde",
|
||||||
|
], default-features = false }
|
||||||
|
futures = "0.3.25"
|
||||||
|
hostname = "0.4.0"
|
||||||
impls = "1.0.3"
|
impls = "1.0.3"
|
||||||
|
indexmap = { version = "2.0.0", features = ["serde"] }
|
||||||
|
itertools = "0.13.0"
|
||||||
|
json_value_merge = "2.0.0"
|
||||||
|
pollster = "0.4.0"
|
||||||
|
proc-macro2 = "1.0.81"
|
||||||
|
quote = "1.0.36"
|
||||||
|
reqwest = { version = "0.12.9", features = [
|
||||||
|
"json",
|
||||||
|
"rustls-tls",
|
||||||
|
], default-features = false } # Use rustls, since the other packages also use rustls
|
||||||
|
serde = { version = "1.0.149", features = ["derive"] }
|
||||||
|
serde_json = "1.0.89"
|
||||||
|
serde_repr = "0.1.10"
|
||||||
|
syn = { version = "2.0.60", features = ["extra-traits", "full"] }
|
||||||
|
thiserror = "2.0.5"
|
||||||
|
tokio-cron-scheduler = "0.13.0"
|
||||||
|
tokio-util = { version = "0.7.11", features = ["full"] }
|
||||||
|
tracing-subscriber = "0.3.16"
|
||||||
|
uuid = "1.8.0"
|
||||||
|
wakey = "0.3.0"
|
||||||
|
air_filter_types = { git = "https://git.huizinga.dev/Dreaded_X/airfilter", tag = "v0.4.4" }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
automation_lib = { workspace = true }
|
||||||
|
automation_devices = { workspace = true }
|
||||||
|
google_home = { workspace = true }
|
||||||
|
mlua = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
hostname = { workspace = true }
|
||||||
|
rumqttc = { workspace = true }
|
||||||
|
axum = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
dotenvy = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
wakey = { git = "https://git.huizinga.dev/Dreaded_X/wakey" }
|
wakey = { git = "https://git.huizinga.dev/Dreaded_X/wakey" }
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
FROM gcr.io/distroless/cc-debian12:nonroot
|
FROM gcr.io/distroless/cc-debian12:nonroot
|
||||||
|
|
||||||
ENV AUTOMATION_CONFIG=/app/config.lua
|
ENV AUTOMATION_CONFIG=/app/config.lua
|
||||||
COPY ./config /app/config
|
COPY ./config.lua /app/config.lua
|
||||||
|
|
||||||
COPY ./automation /app/automation
|
COPY ./automation /app/automation
|
||||||
|
|
||||||
|
|
BIN
assets/logo.png
Normal file
BIN
assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.7 KiB |
BIN
assets/logo.xcf
(Stored with Git LFS)
Normal file
BIN
assets/logo.xcf
(Stored with Git LFS)
Normal file
Binary file not shown.
27
automation_devices/Cargo.toml
Normal file
27
automation_devices/Cargo.toml
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
[package]
|
||||||
|
name = "automation_devices"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
automation_lib = { workspace = true }
|
||||||
|
automation_macro = { workspace = true }
|
||||||
|
automation_cast = { workspace = true }
|
||||||
|
google_home = { workspace = true }
|
||||||
|
mlua = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
dyn-clone = { workspace = true }
|
||||||
|
rumqttc = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
impls = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
reqwest = { workspace = true } # Use rustls, since the other packages also use rustls
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
axum = { workspace = true }
|
||||||
|
bytes = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
eui48 = { workspace = true }
|
||||||
|
wakey = { workspace = true }
|
||||||
|
air_filter_types = { workspace = true }
|
|
@ -1,6 +1,6 @@
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use automation_lib::config::InfoConfig;
|
||||||
|
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||||
use automation_macro::LuaDeviceConfig;
|
use automation_macro::LuaDeviceConfig;
|
||||||
use google_home::device::Name;
|
use google_home::device::Name;
|
||||||
use google_home::errors::ErrorCode;
|
use google_home::errors::ErrorCode;
|
||||||
|
@ -9,58 +9,57 @@ use google_home::traits::{
|
||||||
TemperatureUnit,
|
TemperatureUnit,
|
||||||
};
|
};
|
||||||
use google_home::types::Type;
|
use google_home::types::Type;
|
||||||
use rumqttc::Publish;
|
use thiserror::Error;
|
||||||
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
use tracing::{debug, trace};
|
||||||
use tracing::{debug, error, trace, warn};
|
|
||||||
|
|
||||||
use super::LuaDeviceCreate;
|
|
||||||
use crate::config::{InfoConfig, MqttDeviceConfig};
|
|
||||||
use crate::devices::Device;
|
|
||||||
use crate::event::OnMqtt;
|
|
||||||
use crate::messages::{AirFilterFanState, AirFilterState, SetAirFilterFanState};
|
|
||||||
use crate::mqtt::WrappedAsyncClient;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[device_config(flatten)]
|
#[device_config(flatten)]
|
||||||
pub info: InfoConfig,
|
pub info: InfoConfig,
|
||||||
#[device_config(flatten)]
|
pub url: String,
|
||||||
pub mqtt: MqttDeviceConfig,
|
|
||||||
#[device_config(from_lua)]
|
|
||||||
pub client: WrappedAsyncClient,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AirFilter {
|
pub struct AirFilter {
|
||||||
config: Config,
|
config: Config,
|
||||||
state: Arc<RwLock<AirFilterState>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("Connection error")]
|
||||||
|
ReqwestError(#[from] reqwest::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Error> for google_home::errors::ErrorCode {
|
||||||
|
fn from(value: Error) -> Self {
|
||||||
|
match value {
|
||||||
|
// Assume that if we encounter a ReqwestError the device is offline
|
||||||
|
Error::ReqwestError(_) => {
|
||||||
|
Self::DeviceError(google_home::errors::DeviceError::DeviceOffline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Handle error properly
|
||||||
impl AirFilter {
|
impl AirFilter {
|
||||||
async fn set_speed(&self, state: AirFilterFanState) {
|
async fn set_fan_speed(&self, speed: air_filter_types::FanSpeed) -> Result<(), Error> {
|
||||||
let message = SetAirFilterFanState::new(state);
|
let message = air_filter_types::SetFanSpeed::new(speed);
|
||||||
|
let url = format!("{}/state/fan", self.config.url);
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
client.put(url).json(&message).send().await?;
|
||||||
|
|
||||||
let topic = format!("{}/set", self.config.mqtt.topic);
|
Ok(())
|
||||||
// TODO: Handle potential errors here
|
|
||||||
self.config
|
|
||||||
.client
|
|
||||||
.publish(
|
|
||||||
&topic,
|
|
||||||
rumqttc::QoS::AtLeastOnce,
|
|
||||||
false,
|
|
||||||
serde_json::to_string(&message).unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|err| warn!("Failed to update state on {topic}: {err}"))
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn state(&self) -> RwLockReadGuard<AirFilterState> {
|
async fn get_fan_state(&self) -> Result<air_filter_types::FanState, Error> {
|
||||||
self.state.read().await
|
let url = format!("{}/state/fan", self.config.url);
|
||||||
|
Ok(reqwest::get(url).await?.json().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn state_mut(&self) -> RwLockWriteGuard<AirFilterState> {
|
async fn get_sensor_data(&self) -> Result<air_filter_types::SensorData, Error> {
|
||||||
self.state.write().await
|
let url = format!("{}/state/sensor", self.config.url);
|
||||||
|
Ok(reqwest::get(url).await?.json().await?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -72,19 +71,7 @@ impl LuaDeviceCreate for AirFilter {
|
||||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||||
trace!(id = config.info.identifier(), "Setting up AirFilter");
|
trace!(id = config.info.identifier(), "Setting up AirFilter");
|
||||||
|
|
||||||
config
|
Ok(Self { config })
|
||||||
.client
|
|
||||||
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let state = AirFilterState {
|
|
||||||
state: AirFilterFanState::Off,
|
|
||||||
humidity: 0.0,
|
|
||||||
temperature: 0.0,
|
|
||||||
};
|
|
||||||
let state = Arc::new(RwLock::new(state));
|
|
||||||
|
|
||||||
Ok(Self { config, state })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -95,30 +82,6 @@ impl Device for AirFilter {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnMqtt for AirFilter {
|
|
||||||
async fn on_mqtt(&self, message: Publish) {
|
|
||||||
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = match AirFilterState::try_from(message) {
|
|
||||||
Ok(state) => state,
|
|
||||||
Err(err) => {
|
|
||||||
error!(id = Device::get_id(self), "Failed to parse message: {err}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if state == *self.state().await {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!(id = Device::get_id(self), "Updating state to {state:?}");
|
|
||||||
|
|
||||||
*self.state_mut().await = state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl google_home::Device for AirFilter {
|
impl google_home::Device for AirFilter {
|
||||||
fn get_device_type(&self) -> Type {
|
fn get_device_type(&self) -> Type {
|
||||||
Type::AirPurifier
|
Type::AirPurifier
|
||||||
|
@ -132,8 +95,8 @@ impl google_home::Device for AirFilter {
|
||||||
Device::get_id(self)
|
Device::get_id(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_online(&self) -> bool {
|
async fn is_online(&self) -> bool {
|
||||||
true
|
self.get_sensor_data().await.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_room_hint(&self) -> Option<&str> {
|
fn get_room_hint(&self) -> Option<&str> {
|
||||||
|
@ -148,16 +111,16 @@ impl google_home::Device for AirFilter {
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnOff for AirFilter {
|
impl OnOff for AirFilter {
|
||||||
async fn on(&self) -> Result<bool, ErrorCode> {
|
async fn on(&self) -> Result<bool, ErrorCode> {
|
||||||
Ok(self.state().await.state != AirFilterFanState::Off)
|
Ok(self.get_fan_state().await?.speed != air_filter_types::FanSpeed::Off)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn set_on(&self, on: bool) -> Result<(), ErrorCode> {
|
async fn set_on(&self, on: bool) -> Result<(), ErrorCode> {
|
||||||
debug!("Turning on air filter: {on}");
|
debug!("Turning on air filter: {on}");
|
||||||
|
|
||||||
if on {
|
if on {
|
||||||
self.set_speed(AirFilterFanState::High).await;
|
self.set_fan_speed(air_filter_types::FanSpeed::High).await?;
|
||||||
} else {
|
} else {
|
||||||
self.set_speed(AirFilterFanState::Off).await;
|
self.set_fan_speed(air_filter_types::FanSpeed::Off).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
@ -203,11 +166,12 @@ impl FanSpeed for AirFilter {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn current_fan_speed_setting(&self) -> Result<String, ErrorCode> {
|
async fn current_fan_speed_setting(&self) -> Result<String, ErrorCode> {
|
||||||
let speed = match self.state().await.state {
|
let speed = self.get_fan_state().await?.speed;
|
||||||
AirFilterFanState::Off => "off",
|
let speed = match speed {
|
||||||
AirFilterFanState::Low => "low",
|
air_filter_types::FanSpeed::Off => "off",
|
||||||
AirFilterFanState::Medium => "medium",
|
air_filter_types::FanSpeed::Low => "low",
|
||||||
AirFilterFanState::High => "high",
|
air_filter_types::FanSpeed::Medium => "medium",
|
||||||
|
air_filter_types::FanSpeed::High => "high",
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(speed.into())
|
Ok(speed.into())
|
||||||
|
@ -215,19 +179,19 @@ impl FanSpeed for AirFilter {
|
||||||
|
|
||||||
async fn set_fan_speed(&self, fan_speed: String) -> Result<(), ErrorCode> {
|
async fn set_fan_speed(&self, fan_speed: String) -> Result<(), ErrorCode> {
|
||||||
let fan_speed = fan_speed.as_str();
|
let fan_speed = fan_speed.as_str();
|
||||||
let state = if fan_speed == "off" {
|
let speed = if fan_speed == "off" {
|
||||||
AirFilterFanState::Off
|
air_filter_types::FanSpeed::Off
|
||||||
} else if fan_speed == "low" {
|
} else if fan_speed == "low" {
|
||||||
AirFilterFanState::Low
|
air_filter_types::FanSpeed::Low
|
||||||
} else if fan_speed == "medium" {
|
} else if fan_speed == "medium" {
|
||||||
AirFilterFanState::Medium
|
air_filter_types::FanSpeed::Medium
|
||||||
} else if fan_speed == "high" {
|
} else if fan_speed == "high" {
|
||||||
AirFilterFanState::High
|
air_filter_types::FanSpeed::High
|
||||||
} else {
|
} else {
|
||||||
return Err(google_home::errors::DeviceError::TransientError.into());
|
return Err(google_home::errors::DeviceError::TransientError.into());
|
||||||
};
|
};
|
||||||
|
|
||||||
self.set_speed(state).await;
|
self.set_fan_speed(speed).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -240,7 +204,7 @@ impl HumiditySetting for AirFilter {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn humidity_ambient_percent(&self) -> Result<isize, ErrorCode> {
|
async fn humidity_ambient_percent(&self) -> Result<isize, ErrorCode> {
|
||||||
Ok(self.state().await.humidity.round() as isize)
|
Ok(self.get_sensor_data().await?.humidity().round() as isize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -255,8 +219,8 @@ impl TemperatureSetting for AirFilter {
|
||||||
TemperatureUnit::Celsius
|
TemperatureUnit::Celsius
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn temperature_ambient_celsius(&self) -> f32 {
|
async fn temperature_ambient_celsius(&self) -> Result<f32, ErrorCode> {
|
||||||
// HACK: Round to one decimal place
|
// HACK: Round to one decimal place
|
||||||
(10.0 * self.state().await.temperature).round() / 10.0
|
Ok((10.0 * self.get_sensor_data().await?.temperature()).round() / 10.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -2,20 +2,30 @@ use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use automation_lib::action_callback::ActionCallback;
|
||||||
|
use automation_lib::config::{InfoConfig, MqttDeviceConfig};
|
||||||
|
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||||
|
use automation_lib::error::DeviceConfigError;
|
||||||
|
use automation_lib::event::{OnMqtt, OnPresence};
|
||||||
|
use automation_lib::messages::{ContactMessage, PresenceMessage};
|
||||||
|
use automation_lib::mqtt::WrappedAsyncClient;
|
||||||
|
use automation_lib::presence::DEFAULT_PRESENCE;
|
||||||
use automation_macro::LuaDeviceConfig;
|
use automation_macro::LuaDeviceConfig;
|
||||||
use google_home::traits::OnOff;
|
use google_home::device;
|
||||||
|
use google_home::errors::{DeviceError, ErrorCode};
|
||||||
|
use google_home::traits::OpenClose;
|
||||||
|
use google_home::types::Type;
|
||||||
|
use serde::Deserialize;
|
||||||
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tracing::{debug, error, trace, warn};
|
use tracing::{debug, error, trace, warn};
|
||||||
|
|
||||||
use super::{Device, LuaDeviceCreate};
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Copy)]
|
||||||
use crate::config::MqttDeviceConfig;
|
pub enum SensorType {
|
||||||
use crate::devices::DEFAULT_PRESENCE;
|
Door,
|
||||||
use crate::error::DeviceConfigError;
|
Drawer,
|
||||||
use crate::event::{OnMqtt, OnPresence};
|
Window,
|
||||||
use crate::messages::{ContactMessage, PresenceMessage};
|
}
|
||||||
use crate::mqtt::WrappedAsyncClient;
|
|
||||||
use crate::traits::Timeout;
|
|
||||||
|
|
||||||
// NOTE: If we add more presence devices we might need to move this out of here
|
// NOTE: If we add more presence devices we might need to move this out of here
|
||||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
|
@ -26,23 +36,20 @@ pub struct PresenceDeviceConfig {
|
||||||
pub timeout: Duration,
|
pub timeout: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
|
||||||
pub struct TriggerConfig {
|
|
||||||
#[device_config(from_lua)]
|
|
||||||
pub devices: Vec<Box<dyn Device>>,
|
|
||||||
#[device_config(default, with(|t: Option<_>| t.map(Duration::from_secs)))]
|
|
||||||
pub timeout: Option<Duration>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub identifier: String,
|
#[device_config(flatten)]
|
||||||
|
pub info: InfoConfig,
|
||||||
#[device_config(flatten)]
|
#[device_config(flatten)]
|
||||||
pub mqtt: MqttDeviceConfig,
|
pub mqtt: MqttDeviceConfig,
|
||||||
#[device_config(from_lua, default)]
|
#[device_config(from_lua, default)]
|
||||||
pub presence: Option<PresenceDeviceConfig>,
|
pub presence: Option<PresenceDeviceConfig>,
|
||||||
#[device_config(from_lua)]
|
|
||||||
pub trigger: Option<TriggerConfig>,
|
#[device_config(default(SensorType::Window))]
|
||||||
|
pub sensor_type: SensorType,
|
||||||
|
|
||||||
|
#[device_config(from_lua, default)]
|
||||||
|
pub callback: ActionCallback<ContactSensor, bool>,
|
||||||
#[device_config(from_lua)]
|
#[device_config(from_lua)]
|
||||||
pub client: WrappedAsyncClient,
|
pub client: WrappedAsyncClient,
|
||||||
}
|
}
|
||||||
|
@ -51,7 +58,6 @@ pub struct Config {
|
||||||
struct State {
|
struct State {
|
||||||
overall_presence: bool,
|
overall_presence: bool,
|
||||||
is_closed: bool,
|
is_closed: bool,
|
||||||
previous: Vec<bool>,
|
|
||||||
handle: Option<JoinHandle<()>>,
|
handle: Option<JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,27 +83,7 @@ impl LuaDeviceCreate for ContactSensor {
|
||||||
type Error = DeviceConfigError;
|
type Error = DeviceConfigError;
|
||||||
|
|
||||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||||
trace!(id = config.identifier, "Setting up ContactSensor");
|
trace!(id = config.info.identifier(), "Setting up ContactSensor");
|
||||||
|
|
||||||
let mut previous = Vec::new();
|
|
||||||
// Make sure the devices implement the required traits
|
|
||||||
if let Some(trigger) = &config.trigger {
|
|
||||||
for device in &trigger.devices {
|
|
||||||
{
|
|
||||||
let id = device.get_id().to_owned();
|
|
||||||
if (device.cast() as Option<&dyn OnOff>).is_none() {
|
|
||||||
return Err(DeviceConfigError::MissingTrait(id, "OnOff".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if trigger.timeout.is_none()
|
|
||||||
&& (device.cast() as Option<&dyn Timeout>).is_none()
|
|
||||||
{
|
|
||||||
return Err(DeviceConfigError::MissingTrait(id, "Timeout".into()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
previous.resize(trigger.devices.len(), false);
|
|
||||||
}
|
|
||||||
|
|
||||||
config
|
config
|
||||||
.client
|
.client
|
||||||
|
@ -107,7 +93,6 @@ impl LuaDeviceCreate for ContactSensor {
|
||||||
let state = State {
|
let state = State {
|
||||||
overall_presence: DEFAULT_PRESENCE,
|
overall_presence: DEFAULT_PRESENCE,
|
||||||
is_closed: true,
|
is_closed: true,
|
||||||
previous,
|
|
||||||
handle: None,
|
handle: None,
|
||||||
};
|
};
|
||||||
let state = Arc::new(RwLock::new(state));
|
let state = Arc::new(RwLock::new(state));
|
||||||
|
@ -118,7 +103,61 @@ impl LuaDeviceCreate for ContactSensor {
|
||||||
|
|
||||||
impl Device for ContactSensor {
|
impl Device for ContactSensor {
|
||||||
fn get_id(&self) -> String {
|
fn get_id(&self) -> String {
|
||||||
self.config.identifier.clone()
|
self.config.info.identifier()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl google_home::Device for ContactSensor {
|
||||||
|
fn get_device_type(&self) -> google_home::types::Type {
|
||||||
|
match self.config.sensor_type {
|
||||||
|
SensorType::Door => Type::Door,
|
||||||
|
SensorType::Drawer => Type::Drawer,
|
||||||
|
SensorType::Window => Type::Window,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_id(&self) -> String {
|
||||||
|
Device::get_id(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_device_name(&self) -> google_home::device::Name {
|
||||||
|
device::Name::new(&self.config.info.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_room_hint(&self) -> Option<&str> {
|
||||||
|
self.config.info.room.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn will_report_state(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_online(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl OpenClose for ContactSensor {
|
||||||
|
fn discrete_only_open_close(&self) -> Option<bool> {
|
||||||
|
Some(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_only_open_close(&self) -> Option<bool> {
|
||||||
|
Some(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open_percent(&self) -> Result<u8, ErrorCode> {
|
||||||
|
if self.state().await.is_closed {
|
||||||
|
Ok(0)
|
||||||
|
} else {
|
||||||
|
Ok(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_open_percent(&self, _open_percent: u8) -> Result<(), ErrorCode> {
|
||||||
|
Err(DeviceError::ActionNotAvailable.into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -148,44 +187,11 @@ impl OnMqtt for ContactSensor {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.config.callback.call(self, &!is_closed).await;
|
||||||
|
|
||||||
debug!(id = self.get_id(), "Updating state to {is_closed}");
|
debug!(id = self.get_id(), "Updating state to {is_closed}");
|
||||||
self.state_mut().await.is_closed = is_closed;
|
self.state_mut().await.is_closed = is_closed;
|
||||||
|
|
||||||
if let Some(trigger) = &self.config.trigger {
|
|
||||||
if !is_closed {
|
|
||||||
for (light, previous) in trigger
|
|
||||||
.devices
|
|
||||||
.iter()
|
|
||||||
.zip(self.state_mut().await.previous.iter_mut())
|
|
||||||
{
|
|
||||||
if let Some(light) = light.cast() as Option<&dyn OnOff> {
|
|
||||||
*previous = light.on().await.unwrap();
|
|
||||||
light.set_on(true).await.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (light, previous) in trigger
|
|
||||||
.devices
|
|
||||||
.iter()
|
|
||||||
.zip(self.state_mut().await.previous.iter())
|
|
||||||
{
|
|
||||||
if !previous {
|
|
||||||
// If the timeout is zero just turn the light off directly
|
|
||||||
if trigger.timeout.is_none()
|
|
||||||
&& let Some(light) = light.cast() as Option<&dyn OnOff>
|
|
||||||
{
|
|
||||||
light.set_on(false).await.ok();
|
|
||||||
} else if let Some(timeout) = trigger.timeout
|
|
||||||
&& let Some(light) = light.cast() as Option<&dyn Timeout>
|
|
||||||
{
|
|
||||||
light.start_timeout(timeout).await.unwrap();
|
|
||||||
}
|
|
||||||
// TODO: Put a warning/error on creation if either of this has to option to fail
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this contact sensor works as a presence device
|
// Check if this contact sensor works as a presence device
|
||||||
// If not we are done here
|
// If not we are done here
|
||||||
let presence = match &self.config.presence {
|
let presence = match &self.config.presence {
|
|
@ -1,16 +1,14 @@
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use automation_lib::config::MqttDeviceConfig;
|
||||||
|
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||||
|
use automation_lib::event::{OnDarkness, OnPresence};
|
||||||
|
use automation_lib::messages::{DarknessMessage, PresenceMessage};
|
||||||
|
use automation_lib::mqtt::WrappedAsyncClient;
|
||||||
use automation_macro::LuaDeviceConfig;
|
use automation_macro::LuaDeviceConfig;
|
||||||
use tracing::{trace, warn};
|
use tracing::{trace, warn};
|
||||||
|
|
||||||
use super::LuaDeviceCreate;
|
|
||||||
use crate::config::MqttDeviceConfig;
|
|
||||||
use crate::devices::Device;
|
|
||||||
use crate::event::{OnDarkness, OnPresence};
|
|
||||||
use crate::messages::{DarknessMessage, PresenceMessage};
|
|
||||||
use crate::mqtt::WrappedAsyncClient;
|
|
||||||
|
|
||||||
#[derive(Debug, LuaDeviceConfig, Clone)]
|
#[derive(Debug, LuaDeviceConfig, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub identifier: String,
|
pub identifier: String,
|
|
@ -2,14 +2,12 @@ use std::convert::Infallible;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||||
|
use automation_lib::event::{OnDarkness, OnPresence};
|
||||||
use automation_macro::LuaDeviceConfig;
|
use automation_macro::LuaDeviceConfig;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{error, trace, warn};
|
use tracing::{error, trace, warn};
|
||||||
|
|
||||||
use super::LuaDeviceCreate;
|
|
||||||
use crate::devices::Device;
|
|
||||||
use crate::event::{OnDarkness, OnPresence};
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum Flag {
|
pub enum Flag {
|
||||||
Presence,
|
Presence,
|
|
@ -1,7 +1,6 @@
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::Result;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use automation_macro::LuaDeviceConfig;
|
use automation_macro::LuaDeviceConfig;
|
||||||
use google_home::errors::ErrorCode;
|
use google_home::errors::ErrorCode;
|
||||||
|
@ -9,8 +8,6 @@ use google_home::traits::OnOff;
|
||||||
use tracing::{error, trace, warn};
|
use tracing::{error, trace, warn};
|
||||||
|
|
||||||
use super::{Device, LuaDeviceCreate};
|
use super::{Device, LuaDeviceCreate};
|
||||||
use crate::mqtt::WrappedAsyncClient;
|
|
||||||
use crate::traits::Timeout;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
|
@ -19,10 +16,7 @@ pub struct Config {
|
||||||
pub addr: SocketAddr,
|
pub addr: SocketAddr,
|
||||||
pub login: String,
|
pub login: String,
|
||||||
pub group_id: isize,
|
pub group_id: isize,
|
||||||
pub timer_id: isize,
|
|
||||||
pub scene_id: String,
|
pub scene_id: String,
|
||||||
#[device_config(from_lua)]
|
|
||||||
pub client: WrappedAsyncClient,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
@ -48,10 +42,6 @@ impl HueGroup {
|
||||||
format!("http://{}/api/{}", self.config.addr, self.config.login)
|
format!("http://{}/api/{}", self.config.addr, self.config.login)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn url_set_schedule(&self) -> String {
|
|
||||||
format!("{}/schedules/{}", self.url_base(), self.config.timer_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn url_set_action(&self) -> String {
|
fn url_set_action(&self) -> String {
|
||||||
format!("{}/groups/{}/action", self.url_base(), self.config.group_id)
|
format!("{}/groups/{}/action", self.url_base(), self.config.group_id)
|
||||||
}
|
}
|
||||||
|
@ -70,9 +60,6 @@ impl Device for HueGroup {
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnOff for HueGroup {
|
impl OnOff for HueGroup {
|
||||||
async fn set_on(&self, on: bool) -> Result<(), ErrorCode> {
|
async fn set_on(&self, on: bool) -> Result<(), ErrorCode> {
|
||||||
// Abort any timer that is currently running
|
|
||||||
self.stop_timeout().await.unwrap();
|
|
||||||
|
|
||||||
let message = if on {
|
let message = if on {
|
||||||
message::Action::scene(self.config.scene_id.clone())
|
message::Action::scene(self.config.scene_id.clone())
|
||||||
} else {
|
} else {
|
||||||
|
@ -129,55 +116,7 @@ impl OnOff for HueGroup {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Timeout for HueGroup {
|
|
||||||
async fn start_timeout(&self, timeout: Duration) -> Result<()> {
|
|
||||||
// Abort any timer that is currently running
|
|
||||||
self.stop_timeout().await?;
|
|
||||||
|
|
||||||
// NOTE: This uses an existing timer, as we are unable to cancel it on the hub otherwise
|
|
||||||
let message = message::Timeout::new(Some(timeout));
|
|
||||||
let res = reqwest::Client::new()
|
|
||||||
.put(self.url_set_schedule())
|
|
||||||
.json(&message)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("Failed to start timeout")?;
|
|
||||||
|
|
||||||
let status = res.status();
|
|
||||||
if !status.is_success() {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"Hue bridge returned unsuccessful status '{status}'"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn stop_timeout(&self) -> Result<()> {
|
|
||||||
let message = message::Timeout::new(None);
|
|
||||||
let res = reqwest::Client::new()
|
|
||||||
.put(self.url_set_schedule())
|
|
||||||
.json(&message)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("Failed to stop timeout")?;
|
|
||||||
|
|
||||||
let status = res.status();
|
|
||||||
if !status.is_success() {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"Hue bridge returned unsuccessful status '{status}'"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mod message {
|
mod message {
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use serde::ser::SerializeStruct;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
@ -219,46 +158,5 @@ mod message {
|
||||||
pub fn any_on(&self) -> bool {
|
pub fn any_on(&self) -> bool {
|
||||||
self.state.any_on
|
self.state.any_on
|
||||||
}
|
}
|
||||||
|
|
||||||
// pub fn all_on(&self) -> bool {
|
|
||||||
// self.state.all_on
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Timeout {
|
|
||||||
timeout: Option<Duration>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Timeout {
|
|
||||||
pub fn new(timeout: Option<Duration>) -> Self {
|
|
||||||
Self { timeout }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Serialize for Timeout {
|
|
||||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
where
|
|
||||||
S: serde::Serializer,
|
|
||||||
{
|
|
||||||
let len = if self.timeout.is_some() { 2 } else { 1 };
|
|
||||||
let mut state = serializer.serialize_struct("TimerMessage", len)?;
|
|
||||||
if self.timeout.is_some() {
|
|
||||||
state.serialize_field("status", "enabled")?;
|
|
||||||
} else {
|
|
||||||
state.serialize_field("status", "disabled")?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(timeout) = self.timeout {
|
|
||||||
let seconds = timeout.as_secs() % 60;
|
|
||||||
let minutes = (timeout.as_secs() / 60) % 60;
|
|
||||||
let hours = timeout.as_secs() / 3600;
|
|
||||||
|
|
||||||
let time = format!("PT{hours:<02}:{minutes:<02}:{seconds:<02}");
|
|
||||||
state.serialize_field("localtime", &time)?;
|
|
||||||
};
|
|
||||||
|
|
||||||
state.end()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
116
automation_devices/src/hue_switch.rs
Normal file
116
automation_devices/src/hue_switch.rs
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use automation_lib::action_callback::ActionCallback;
|
||||||
|
use automation_lib::config::{InfoConfig, MqttDeviceConfig};
|
||||||
|
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||||
|
use automation_lib::event::OnMqtt;
|
||||||
|
use automation_lib::mqtt::WrappedAsyncClient;
|
||||||
|
use automation_macro::LuaDeviceConfig;
|
||||||
|
use rumqttc::{matches, Publish};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tracing::{debug, trace, warn};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
|
pub struct Config {
|
||||||
|
#[device_config(flatten)]
|
||||||
|
pub info: InfoConfig,
|
||||||
|
|
||||||
|
#[device_config(flatten)]
|
||||||
|
pub mqtt: MqttDeviceConfig,
|
||||||
|
|
||||||
|
#[device_config(from_lua)]
|
||||||
|
pub client: WrappedAsyncClient,
|
||||||
|
|
||||||
|
#[device_config(from_lua, default)]
|
||||||
|
pub left_callback: ActionCallback<HueSwitch, ()>,
|
||||||
|
|
||||||
|
#[device_config(from_lua, default)]
|
||||||
|
pub right_callback: ActionCallback<HueSwitch, ()>,
|
||||||
|
|
||||||
|
#[device_config(from_lua, default)]
|
||||||
|
pub left_hold_callback: ActionCallback<HueSwitch, ()>,
|
||||||
|
|
||||||
|
#[device_config(from_lua, default)]
|
||||||
|
pub right_hold_callback: ActionCallback<HueSwitch, ()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
enum Action {
|
||||||
|
LeftPress,
|
||||||
|
LeftPressRelease,
|
||||||
|
LeftHold,
|
||||||
|
LeftHoldRelease,
|
||||||
|
RightPress,
|
||||||
|
RightPressRelease,
|
||||||
|
RightHold,
|
||||||
|
RightHoldRelease,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct State {
|
||||||
|
action: Action,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct HueSwitch {
|
||||||
|
config: Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Device for HueSwitch {
|
||||||
|
fn get_id(&self) -> String {
|
||||||
|
self.config.info.identifier()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LuaDeviceCreate for HueSwitch {
|
||||||
|
type Config = Config;
|
||||||
|
type Error = rumqttc::ClientError;
|
||||||
|
|
||||||
|
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||||
|
trace!(id = config.info.identifier(), "Setting up HueSwitch");
|
||||||
|
|
||||||
|
config
|
||||||
|
.client
|
||||||
|
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Self { config })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl OnMqtt for HueSwitch {
|
||||||
|
async fn on_mqtt(&self, message: Publish) {
|
||||||
|
// Check if the message is from the device itself or from a remote
|
||||||
|
if matches(&message.topic, &self.config.mqtt.topic) {
|
||||||
|
let action = match serde_json::from_slice::<State>(&message.payload) {
|
||||||
|
Ok(message) => message.action,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(id = Device::get_id(self), "Failed to parse message: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
debug!(id = Device::get_id(self), "Remote action = {:?}", action);
|
||||||
|
|
||||||
|
match action {
|
||||||
|
Action::LeftPressRelease => self.config.left_callback.call(self, &()).await,
|
||||||
|
Action::RightPressRelease => self.config.right_callback.call(self, &()).await,
|
||||||
|
Action::LeftHold => self.config.left_hold_callback.call(self, &()).await,
|
||||||
|
Action::RightHold => self.config.right_hold_callback.call(self, &()).await,
|
||||||
|
// If there is no hold action, the switch will act like a normal release
|
||||||
|
Action::RightHoldRelease => {
|
||||||
|
if !self.config.right_hold_callback.is_set() {
|
||||||
|
self.config.right_callback.call(self, &()).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::LeftHoldRelease => {
|
||||||
|
if !self.config.left_hold_callback.is_set() {
|
||||||
|
self.config.left_callback.call(self, &()).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,16 +1,14 @@
|
||||||
|
use automation_lib::action_callback::ActionCallback;
|
||||||
|
use automation_lib::config::{InfoConfig, MqttDeviceConfig};
|
||||||
|
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||||
|
use automation_lib::event::OnMqtt;
|
||||||
|
use automation_lib::messages::{RemoteAction, RemoteMessage};
|
||||||
|
use automation_lib::mqtt::WrappedAsyncClient;
|
||||||
use automation_macro::LuaDeviceConfig;
|
use automation_macro::LuaDeviceConfig;
|
||||||
use axum::async_trait;
|
use axum::async_trait;
|
||||||
use rumqttc::{matches, Publish};
|
use rumqttc::{matches, Publish};
|
||||||
use tracing::{debug, error, trace};
|
use tracing::{debug, error, trace};
|
||||||
|
|
||||||
use super::LuaDeviceCreate;
|
|
||||||
use crate::action_callback::ActionCallback;
|
|
||||||
use crate::config::{InfoConfig, MqttDeviceConfig};
|
|
||||||
use crate::devices::Device;
|
|
||||||
use crate::event::OnMqtt;
|
|
||||||
use crate::messages::RemoteMessage;
|
|
||||||
use crate::mqtt::WrappedAsyncClient;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[device_config(flatten)]
|
#[device_config(flatten)]
|
||||||
|
@ -26,7 +24,7 @@ pub struct Config {
|
||||||
pub client: WrappedAsyncClient,
|
pub client: WrappedAsyncClient,
|
||||||
|
|
||||||
#[device_config(from_lua)]
|
#[device_config(from_lua)]
|
||||||
pub callback: ActionCallback<bool>,
|
pub callback: ActionCallback<IkeaRemote, bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
@ -61,7 +59,6 @@ impl LuaDeviceCreate for IkeaRemote {
|
||||||
impl OnMqtt for IkeaRemote {
|
impl OnMqtt for IkeaRemote {
|
||||||
async fn on_mqtt(&self, message: Publish) {
|
async fn on_mqtt(&self, message: Publish) {
|
||||||
// Check if the message is from the deviec itself or from a remote
|
// Check if the message is from the deviec itself or from a remote
|
||||||
debug!(id = Device::get_id(self), "Mqtt message received");
|
|
||||||
if matches(&message.topic, &self.config.mqtt.topic) {
|
if matches(&message.topic, &self.config.mqtt.topic) {
|
||||||
let action = match RemoteMessage::try_from(message) {
|
let action = match RemoteMessage::try_from(message) {
|
||||||
Ok(message) => message.action(),
|
Ok(message) => message.action(),
|
||||||
|
@ -74,20 +71,20 @@ impl OnMqtt for IkeaRemote {
|
||||||
|
|
||||||
let on = if self.config.single_button {
|
let on = if self.config.single_button {
|
||||||
match action {
|
match action {
|
||||||
crate::messages::RemoteAction::On => Some(true),
|
RemoteAction::On => Some(true),
|
||||||
crate::messages::RemoteAction::BrightnessMoveUp => Some(false),
|
RemoteAction::BrightnessMoveUp => Some(false),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match action {
|
match action {
|
||||||
crate::messages::RemoteAction::On => Some(true),
|
RemoteAction::On => Some(true),
|
||||||
crate::messages::RemoteAction::Off => Some(false),
|
RemoteAction::Off => Some(false),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(on) = on {
|
if let Some(on) = on {
|
||||||
self.config.callback.call(on).await;
|
self.config.callback.call(self, &on).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -3,6 +3,8 @@ use std::net::SocketAddr;
|
||||||
use std::str::Utf8Error;
|
use std::str::Utf8Error;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||||
|
use automation_lib::event::OnPresence;
|
||||||
use automation_macro::LuaDeviceConfig;
|
use automation_macro::LuaDeviceConfig;
|
||||||
use bytes::{Buf, BufMut};
|
use bytes::{Buf, BufMut};
|
||||||
use google_home::errors::{self, DeviceError};
|
use google_home::errors::{self, DeviceError};
|
||||||
|
@ -13,9 +15,6 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
use tracing::{debug, trace};
|
use tracing::{debug, trace};
|
||||||
|
|
||||||
use super::{Device, LuaDeviceCreate};
|
|
||||||
use crate::event::OnPresence;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub identifier: String,
|
pub identifier: String,
|
159
automation_devices/src/lib.rs
Normal file
159
automation_devices/src/lib.rs
Normal file
|
@ -0,0 +1,159 @@
|
||||||
|
mod air_filter;
|
||||||
|
mod contact_sensor;
|
||||||
|
mod debug_bridge;
|
||||||
|
mod hue_bridge;
|
||||||
|
mod hue_group;
|
||||||
|
mod hue_switch;
|
||||||
|
mod ikea_remote;
|
||||||
|
mod kasa_outlet;
|
||||||
|
mod light_sensor;
|
||||||
|
mod wake_on_lan;
|
||||||
|
mod washer;
|
||||||
|
mod zigbee;
|
||||||
|
|
||||||
|
use std::ops::Deref;
|
||||||
|
|
||||||
|
use automation_cast::Cast;
|
||||||
|
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||||
|
use zigbee::light::{LightBrightness, LightOnOff};
|
||||||
|
use zigbee::outlet::{OutletOnOff, OutletPower};
|
||||||
|
|
||||||
|
pub use self::air_filter::AirFilter;
|
||||||
|
pub use self::contact_sensor::ContactSensor;
|
||||||
|
pub use self::debug_bridge::DebugBridge;
|
||||||
|
pub use self::hue_bridge::HueBridge;
|
||||||
|
pub use self::hue_group::HueGroup;
|
||||||
|
pub use self::hue_switch::HueSwitch;
|
||||||
|
pub use self::ikea_remote::IkeaRemote;
|
||||||
|
pub use self::kasa_outlet::KasaOutlet;
|
||||||
|
pub use self::light_sensor::LightSensor;
|
||||||
|
pub use self::wake_on_lan::WakeOnLAN;
|
||||||
|
pub use self::washer::Washer;
|
||||||
|
|
||||||
|
macro_rules! register_device {
|
||||||
|
($lua:expr, $device:ty) => {
|
||||||
|
$lua.globals()
|
||||||
|
.set(stringify!($device), $lua.create_proxy::<$device>()?)?;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! impl_device {
|
||||||
|
($device:ty) => {
|
||||||
|
impl mlua::UserData for $device {
|
||||||
|
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
|
||||||
|
methods.add_async_function("new", |_lua, config| async {
|
||||||
|
let device: $device = LuaDeviceCreate::create(config)
|
||||||
|
.await
|
||||||
|
.map_err(mlua::ExternalError::into_lua_err)?;
|
||||||
|
|
||||||
|
Ok(device)
|
||||||
|
});
|
||||||
|
|
||||||
|
methods.add_method("__box", |_lua, this, _: ()| {
|
||||||
|
let b: Box<dyn Device> = Box::new(this.clone());
|
||||||
|
Ok(b)
|
||||||
|
});
|
||||||
|
|
||||||
|
methods.add_async_method("get_id", |_lua, this, _: ()| async move { Ok(this.get_id()) });
|
||||||
|
|
||||||
|
if impls::impls!($device: google_home::traits::OnOff) {
|
||||||
|
methods.add_async_method("set_on", |_lua, this, on: bool| async move {
|
||||||
|
(this.deref().cast() as Option<&dyn google_home::traits::OnOff>)
|
||||||
|
.expect("Cast should be valid")
|
||||||
|
.set_on(on)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
methods.add_async_method("on", |_lua, this, _: ()| async move {
|
||||||
|
Ok((this.deref().cast() as Option<&dyn google_home::traits::OnOff>)
|
||||||
|
.expect("Cast should be valid")
|
||||||
|
.on()
|
||||||
|
.await
|
||||||
|
.unwrap())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if impls::impls!($device: google_home::traits::Brightness) {
|
||||||
|
methods.add_async_method("set_brightness", |_lua, this, brightness: u8| async move {
|
||||||
|
(this.deref().cast() as Option<&dyn google_home::traits::Brightness>)
|
||||||
|
.expect("Cast should be valid")
|
||||||
|
.set_brightness(brightness)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
methods.add_async_method("brightness", |_lua, this, _: ()| async move {
|
||||||
|
Ok((this.deref().cast() as Option<&dyn google_home::traits::Brightness>)
|
||||||
|
.expect("Cast should be valid")
|
||||||
|
.brightness()
|
||||||
|
.await
|
||||||
|
.unwrap())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if impls::impls!($device: google_home::traits::OpenClose) {
|
||||||
|
// TODO: Make discrete_only_open_close and query_only_open_close static, that way we can
|
||||||
|
// add only the supported functions and drop _percet if discrete is true
|
||||||
|
methods.add_async_method("set_open_percent", |_lua, this, open_percent: u8| async move {
|
||||||
|
(this.deref().cast() as Option<&dyn google_home::traits::OpenClose>)
|
||||||
|
.expect("Cast should be valid")
|
||||||
|
.set_open_percent(open_percent)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
methods.add_async_method("open_percent", |_lua, this, _: ()| async move {
|
||||||
|
Ok((this.deref().cast() as Option<&dyn google_home::traits::OpenClose>)
|
||||||
|
.expect("Cast should be valid")
|
||||||
|
.open_percent()
|
||||||
|
.await
|
||||||
|
.unwrap())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_device!(LightOnOff);
|
||||||
|
impl_device!(LightBrightness);
|
||||||
|
impl_device!(OutletOnOff);
|
||||||
|
impl_device!(OutletPower);
|
||||||
|
impl_device!(AirFilter);
|
||||||
|
impl_device!(ContactSensor);
|
||||||
|
impl_device!(DebugBridge);
|
||||||
|
impl_device!(HueBridge);
|
||||||
|
impl_device!(HueGroup);
|
||||||
|
impl_device!(HueSwitch);
|
||||||
|
impl_device!(IkeaRemote);
|
||||||
|
impl_device!(KasaOutlet);
|
||||||
|
impl_device!(LightSensor);
|
||||||
|
impl_device!(WakeOnLAN);
|
||||||
|
impl_device!(Washer);
|
||||||
|
|
||||||
|
pub fn register_with_lua(lua: &mlua::Lua) -> mlua::Result<()> {
|
||||||
|
register_device!(lua, LightOnOff);
|
||||||
|
register_device!(lua, LightBrightness);
|
||||||
|
register_device!(lua, OutletOnOff);
|
||||||
|
register_device!(lua, OutletPower);
|
||||||
|
register_device!(lua, AirFilter);
|
||||||
|
register_device!(lua, ContactSensor);
|
||||||
|
register_device!(lua, DebugBridge);
|
||||||
|
register_device!(lua, HueBridge);
|
||||||
|
register_device!(lua, HueGroup);
|
||||||
|
register_device!(lua, HueSwitch);
|
||||||
|
register_device!(lua, IkeaRemote);
|
||||||
|
register_device!(lua, KasaOutlet);
|
||||||
|
register_device!(lua, LightSensor);
|
||||||
|
register_device!(lua, WakeOnLAN);
|
||||||
|
register_device!(lua, Washer);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
|
@ -1,18 +1,16 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use automation_lib::config::MqttDeviceConfig;
|
||||||
|
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||||
|
use automation_lib::event::{self, Event, EventChannel, OnMqtt};
|
||||||
|
use automation_lib::messages::BrightnessMessage;
|
||||||
|
use automation_lib::mqtt::WrappedAsyncClient;
|
||||||
use automation_macro::LuaDeviceConfig;
|
use automation_macro::LuaDeviceConfig;
|
||||||
use rumqttc::Publish;
|
use rumqttc::Publish;
|
||||||
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||||
use tracing::{debug, trace, warn};
|
use tracing::{debug, trace, warn};
|
||||||
|
|
||||||
use super::LuaDeviceCreate;
|
|
||||||
use crate::config::MqttDeviceConfig;
|
|
||||||
use crate::devices::Device;
|
|
||||||
use crate::event::{self, Event, EventChannel, OnMqtt};
|
|
||||||
use crate::messages::BrightnessMessage;
|
|
||||||
use crate::mqtt::WrappedAsyncClient;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub identifier: String,
|
pub identifier: String,
|
|
@ -1,6 +1,11 @@
|
||||||
use std::net::Ipv4Addr;
|
use std::net::Ipv4Addr;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use automation_lib::config::{InfoConfig, MqttDeviceConfig};
|
||||||
|
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||||
|
use automation_lib::event::OnMqtt;
|
||||||
|
use automation_lib::messages::ActivateMessage;
|
||||||
|
use automation_lib::mqtt::WrappedAsyncClient;
|
||||||
use automation_macro::LuaDeviceConfig;
|
use automation_macro::LuaDeviceConfig;
|
||||||
use eui48::MacAddress;
|
use eui48::MacAddress;
|
||||||
use google_home::device;
|
use google_home::device;
|
||||||
|
@ -10,12 +15,6 @@ use google_home::types::Type;
|
||||||
use rumqttc::Publish;
|
use rumqttc::Publish;
|
||||||
use tracing::{debug, error, trace};
|
use tracing::{debug, error, trace};
|
||||||
|
|
||||||
use super::{Device, LuaDeviceCreate};
|
|
||||||
use crate::config::{InfoConfig, MqttDeviceConfig};
|
|
||||||
use crate::event::OnMqtt;
|
|
||||||
use crate::messages::ActivateMessage;
|
|
||||||
use crate::mqtt::WrappedAsyncClient;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[device_config(flatten)]
|
#[device_config(flatten)]
|
||||||
|
@ -76,6 +75,7 @@ impl OnMqtt for WakeOnLAN {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
impl google_home::Device for WakeOnLAN {
|
impl google_home::Device for WakeOnLAN {
|
||||||
fn get_device_type(&self) -> Type {
|
fn get_device_type(&self) -> Type {
|
||||||
Type::Scene
|
Type::Scene
|
||||||
|
@ -92,7 +92,7 @@ impl google_home::Device for WakeOnLAN {
|
||||||
Device::get_id(self)
|
Device::get_id(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_online(&self) -> bool {
|
async fn is_online(&self) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,17 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use automation_lib::config::MqttDeviceConfig;
|
||||||
|
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||||
|
use automation_lib::event::{self, Event, EventChannel, OnMqtt};
|
||||||
|
use automation_lib::messages::PowerMessage;
|
||||||
|
use automation_lib::mqtt::WrappedAsyncClient;
|
||||||
|
use automation_lib::ntfy::{Notification, Priority};
|
||||||
use automation_macro::LuaDeviceConfig;
|
use automation_macro::LuaDeviceConfig;
|
||||||
use rumqttc::Publish;
|
use rumqttc::Publish;
|
||||||
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||||
use tracing::{debug, error, trace, warn};
|
use tracing::{debug, error, trace, warn};
|
||||||
|
|
||||||
use super::ntfy::Priority;
|
|
||||||
use super::{Device, LuaDeviceCreate, Notification};
|
|
||||||
use crate::config::MqttDeviceConfig;
|
|
||||||
use crate::event::{self, Event, EventChannel, OnMqtt};
|
|
||||||
use crate::messages::PowerMessage;
|
|
||||||
use crate::mqtt::WrappedAsyncClient;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub identifier: String,
|
pub identifier: String,
|
299
automation_devices/src/zigbee/light.rs
Normal file
299
automation_devices/src/zigbee/light.rs
Normal file
|
@ -0,0 +1,299 @@
|
||||||
|
use std::fmt::Debug;
|
||||||
|
use std::ops::Deref;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use automation_lib::action_callback::ActionCallback;
|
||||||
|
use automation_lib::config::{InfoConfig, MqttDeviceConfig};
|
||||||
|
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||||
|
use automation_lib::event::{OnMqtt, OnPresence};
|
||||||
|
use automation_lib::helpers::serialization::state_deserializer;
|
||||||
|
use automation_lib::mqtt::WrappedAsyncClient;
|
||||||
|
use automation_macro::LuaDeviceConfig;
|
||||||
|
use google_home::device;
|
||||||
|
use google_home::errors::ErrorCode;
|
||||||
|
use google_home::traits::{Brightness, OnOff};
|
||||||
|
use google_home::types::Type;
|
||||||
|
use rumqttc::{matches, Publish};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||||
|
use tracing::{debug, trace, warn};
|
||||||
|
|
||||||
|
pub trait LightState:
|
||||||
|
Debug + Clone + Default + Sync + Send + Serialize + Into<StateOnOff> + 'static
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
|
pub struct Config<T: LightState> {
|
||||||
|
#[device_config(flatten)]
|
||||||
|
pub info: InfoConfig,
|
||||||
|
#[device_config(flatten)]
|
||||||
|
pub mqtt: MqttDeviceConfig,
|
||||||
|
|
||||||
|
#[device_config(from_lua, default)]
|
||||||
|
pub callback: ActionCallback<Light<T>, T>,
|
||||||
|
|
||||||
|
#[device_config(from_lua)]
|
||||||
|
pub client: WrappedAsyncClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct StateOnOff {
|
||||||
|
#[serde(deserialize_with = "state_deserializer")]
|
||||||
|
state: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LightState for StateOnOff {}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct StateBrightness {
|
||||||
|
#[serde(deserialize_with = "state_deserializer")]
|
||||||
|
state: bool,
|
||||||
|
brightness: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LightState for StateBrightness {}
|
||||||
|
|
||||||
|
impl From<StateBrightness> for StateOnOff {
|
||||||
|
fn from(state: StateBrightness) -> Self {
|
||||||
|
StateOnOff { state: state.state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Light<T: LightState> {
|
||||||
|
config: Config<T>,
|
||||||
|
|
||||||
|
state: Arc<RwLock<T>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type LightOnOff = Light<StateOnOff>;
|
||||||
|
pub type LightBrightness = Light<StateBrightness>;
|
||||||
|
|
||||||
|
impl<T: LightState> Light<T> {
|
||||||
|
async fn state(&self) -> RwLockReadGuard<T> {
|
||||||
|
self.state.read().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn state_mut(&self) -> RwLockWriteGuard<T> {
|
||||||
|
self.state.write().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<T: LightState> LuaDeviceCreate for Light<T> {
|
||||||
|
type Config = Config<T>;
|
||||||
|
type Error = rumqttc::ClientError;
|
||||||
|
|
||||||
|
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||||
|
trace!(id = config.info.identifier(), "Setting up IkeaOutlet");
|
||||||
|
|
||||||
|
config
|
||||||
|
.client
|
||||||
|
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
config,
|
||||||
|
state: Default::default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: LightState> Device for Light<T> {
|
||||||
|
fn get_id(&self) -> String {
|
||||||
|
self.config.info.identifier()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl OnMqtt for Light<StateOnOff> {
|
||||||
|
async fn on_mqtt(&self, message: Publish) {
|
||||||
|
// Check if the message is from the device itself or from a remote
|
||||||
|
if matches(&message.topic, &self.config.mqtt.topic) {
|
||||||
|
let state = match serde_json::from_slice::<StateOnOff>(&message.payload) {
|
||||||
|
Ok(state) => state,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(id = Device::get_id(self), "Failed to parse message: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// No need to do anything if the state has not changed
|
||||||
|
if state.state == self.state().await.state {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.state_mut().await.state = state.state;
|
||||||
|
debug!(
|
||||||
|
id = Device::get_id(self),
|
||||||
|
"Updating state to {:?}",
|
||||||
|
self.state().await
|
||||||
|
);
|
||||||
|
|
||||||
|
self.config
|
||||||
|
.callback
|
||||||
|
.call(self, self.state().await.deref())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl OnMqtt for Light<StateBrightness> {
|
||||||
|
async fn on_mqtt(&self, message: Publish) {
|
||||||
|
// Check if the message is from the deviec itself or from a remote
|
||||||
|
if matches(&message.topic, &self.config.mqtt.topic) {
|
||||||
|
let state = match serde_json::from_slice::<StateBrightness>(&message.payload) {
|
||||||
|
Ok(state) => state,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(id = Device::get_id(self), "Failed to parse message: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let current_state = self.state().await;
|
||||||
|
// No need to do anything if the state has not changed
|
||||||
|
if state.state == current_state.state
|
||||||
|
&& state.brightness == current_state.brightness
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.state_mut().await.state = state.state;
|
||||||
|
self.state_mut().await.brightness = state.brightness;
|
||||||
|
debug!(
|
||||||
|
id = Device::get_id(self),
|
||||||
|
"Updating state to {:?}",
|
||||||
|
self.state().await
|
||||||
|
);
|
||||||
|
|
||||||
|
self.config
|
||||||
|
.callback
|
||||||
|
.call(self, self.state().await.deref())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<T: LightState> OnPresence for Light<T> {
|
||||||
|
async fn on_presence(&self, presence: bool) {
|
||||||
|
if !presence {
|
||||||
|
debug!(id = Device::get_id(self), "Turning device off");
|
||||||
|
self.set_on(false).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<T: LightState> google_home::Device for Light<T> {
|
||||||
|
fn get_device_type(&self) -> Type {
|
||||||
|
Type::Light
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_device_name(&self) -> device::Name {
|
||||||
|
device::Name::new(&self.config.info.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_id(&self) -> String {
|
||||||
|
Device::get_id(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_online(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_room_hint(&self) -> Option<&str> {
|
||||||
|
self.config.info.room.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn will_report_state(&self) -> bool {
|
||||||
|
// TODO: Implement state reporting
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<T> OnOff for Light<T>
|
||||||
|
where
|
||||||
|
T: LightState,
|
||||||
|
{
|
||||||
|
async fn on(&self) -> Result<bool, ErrorCode> {
|
||||||
|
let state = self.state().await;
|
||||||
|
let state: StateOnOff = state.deref().clone().into();
|
||||||
|
Ok(state.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_on(&self, on: bool) -> Result<(), ErrorCode> {
|
||||||
|
let message = json!({
|
||||||
|
"state": if on { "ON" } else { "OFF"}
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!(id = Device::get_id(self), "{message}");
|
||||||
|
|
||||||
|
let topic = format!("{}/set", self.config.mqtt.topic);
|
||||||
|
// TODO: Handle potential errors here
|
||||||
|
self.config
|
||||||
|
.client
|
||||||
|
.publish(
|
||||||
|
&topic,
|
||||||
|
rumqttc::QoS::AtLeastOnce,
|
||||||
|
false,
|
||||||
|
serde_json::to_string(&message).unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| warn!("Failed to update state on {topic}: {err}"))
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FACTOR: f64 = 30.0;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<T> Brightness for Light<T>
|
||||||
|
where
|
||||||
|
T: LightState,
|
||||||
|
T: Into<StateBrightness>,
|
||||||
|
{
|
||||||
|
async fn brightness(&self) -> Result<u8, ErrorCode> {
|
||||||
|
let state = self.state().await;
|
||||||
|
let state: StateBrightness = state.deref().clone().into();
|
||||||
|
let brightness =
|
||||||
|
100.0 * f64::log10(state.brightness / FACTOR + 1.0) / f64::log10(254.0 / FACTOR + 1.0);
|
||||||
|
|
||||||
|
Ok(brightness.clamp(0.0, 100.0).round() as u8)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_brightness(&self, brightness: u8) -> Result<(), ErrorCode> {
|
||||||
|
let brightness =
|
||||||
|
FACTOR * ((FACTOR / (FACTOR + 254.0)).powf(-(brightness as f64) / 100.0) - 1.0);
|
||||||
|
|
||||||
|
let message = json!({
|
||||||
|
"brightness": brightness.clamp(0.0, 254.0).round() as u8
|
||||||
|
});
|
||||||
|
|
||||||
|
let topic = format!("{}/set", self.config.mqtt.topic);
|
||||||
|
// TODO: Handle potential errors here
|
||||||
|
self.config
|
||||||
|
.client
|
||||||
|
.publish(
|
||||||
|
&topic,
|
||||||
|
rumqttc::QoS::AtLeastOnce,
|
||||||
|
false,
|
||||||
|
serde_json::to_string(&message).unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| warn!("Failed to update state on {topic}: {err}"))
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
2
automation_devices/src/zigbee/mod.rs
Normal file
2
automation_devices/src/zigbee/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod light;
|
||||||
|
pub mod outlet;
|
275
automation_devices/src/zigbee/outlet.rs
Normal file
275
automation_devices/src/zigbee/outlet.rs
Normal file
|
@ -0,0 +1,275 @@
|
||||||
|
use std::fmt::Debug;
|
||||||
|
use std::ops::Deref;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use automation_lib::action_callback::ActionCallback;
|
||||||
|
use automation_lib::config::{InfoConfig, MqttDeviceConfig};
|
||||||
|
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||||
|
use automation_lib::event::{OnMqtt, OnPresence};
|
||||||
|
use automation_lib::helpers::serialization::state_deserializer;
|
||||||
|
use automation_lib::mqtt::WrappedAsyncClient;
|
||||||
|
use automation_macro::LuaDeviceConfig;
|
||||||
|
use google_home::device;
|
||||||
|
use google_home::errors::ErrorCode;
|
||||||
|
use google_home::traits::OnOff;
|
||||||
|
use google_home::types::Type;
|
||||||
|
use rumqttc::{matches, Publish};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||||
|
use tracing::{debug, trace, warn};
|
||||||
|
|
||||||
|
pub trait OutletState:
|
||||||
|
Debug + Clone + Default + Sync + Send + Serialize + Into<StateOnOff> + 'static
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Copy)]
|
||||||
|
pub enum OutletType {
|
||||||
|
Outlet,
|
||||||
|
Kettle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<OutletType> for Type {
|
||||||
|
fn from(outlet: OutletType) -> Self {
|
||||||
|
match outlet {
|
||||||
|
OutletType::Outlet => Type::Outlet,
|
||||||
|
OutletType::Kettle => Type::Kettle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
|
pub struct Config<T: OutletState> {
|
||||||
|
#[device_config(flatten)]
|
||||||
|
pub info: InfoConfig,
|
||||||
|
#[device_config(flatten)]
|
||||||
|
pub mqtt: MqttDeviceConfig,
|
||||||
|
#[device_config(default(OutletType::Outlet))]
|
||||||
|
pub outlet_type: OutletType,
|
||||||
|
|
||||||
|
// TODO: One presence is reworked, this should be removed!
|
||||||
|
#[device_config(default(true))]
|
||||||
|
pub presence_auto_off: bool,
|
||||||
|
|
||||||
|
#[device_config(from_lua, default)]
|
||||||
|
pub callback: ActionCallback<Outlet<T>, T>,
|
||||||
|
|
||||||
|
#[device_config(from_lua)]
|
||||||
|
pub client: WrappedAsyncClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct StateOnOff {
|
||||||
|
#[serde(deserialize_with = "state_deserializer")]
|
||||||
|
state: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutletState for StateOnOff {}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct StatePower {
|
||||||
|
#[serde(deserialize_with = "state_deserializer")]
|
||||||
|
state: bool,
|
||||||
|
power: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutletState for StatePower {}
|
||||||
|
|
||||||
|
impl From<StatePower> for StateOnOff {
|
||||||
|
fn from(state: StatePower) -> Self {
|
||||||
|
StateOnOff { state: state.state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Outlet<T: OutletState> {
|
||||||
|
config: Config<T>,
|
||||||
|
|
||||||
|
state: Arc<RwLock<T>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type OutletOnOff = Outlet<StateOnOff>;
|
||||||
|
pub type OutletPower = Outlet<StatePower>;
|
||||||
|
|
||||||
|
impl<T: OutletState> Outlet<T> {
|
||||||
|
async fn state(&self) -> RwLockReadGuard<T> {
|
||||||
|
self.state.read().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn state_mut(&self) -> RwLockWriteGuard<T> {
|
||||||
|
self.state.write().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<T: OutletState> LuaDeviceCreate for Outlet<T> {
|
||||||
|
type Config = Config<T>;
|
||||||
|
type Error = rumqttc::ClientError;
|
||||||
|
|
||||||
|
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||||
|
trace!(id = config.info.identifier(), "Setting up IkeaOutlet");
|
||||||
|
|
||||||
|
config
|
||||||
|
.client
|
||||||
|
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
config,
|
||||||
|
state: Default::default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: OutletState> Device for Outlet<T> {
|
||||||
|
fn get_id(&self) -> String {
|
||||||
|
self.config.info.identifier()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl OnMqtt for Outlet<StateOnOff> {
|
||||||
|
async fn on_mqtt(&self, message: Publish) {
|
||||||
|
// Check if the message is from the device itself or from a remote
|
||||||
|
if matches(&message.topic, &self.config.mqtt.topic) {
|
||||||
|
let state = match serde_json::from_slice::<StateOnOff>(&message.payload) {
|
||||||
|
Ok(state) => state,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(id = Device::get_id(self), "Failed to parse message: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// No need to do anything if the state has not changed
|
||||||
|
if state.state == self.state().await.state {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.state_mut().await.state = state.state;
|
||||||
|
debug!(
|
||||||
|
id = Device::get_id(self),
|
||||||
|
"Updating state to {:?}",
|
||||||
|
self.state().await
|
||||||
|
);
|
||||||
|
|
||||||
|
self.config
|
||||||
|
.callback
|
||||||
|
.call(self, self.state().await.deref())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl OnMqtt for Outlet<StatePower> {
|
||||||
|
async fn on_mqtt(&self, message: Publish) {
|
||||||
|
// Check if the message is from the deviec itself or from a remote
|
||||||
|
if matches(&message.topic, &self.config.mqtt.topic) {
|
||||||
|
let state = match serde_json::from_slice::<StatePower>(&message.payload) {
|
||||||
|
Ok(state) => state,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(id = Device::get_id(self), "Failed to parse message: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let current_state = self.state().await;
|
||||||
|
// No need to do anything if the state has not changed
|
||||||
|
if state.state == current_state.state && state.power == current_state.power {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.state_mut().await.state = state.state;
|
||||||
|
self.state_mut().await.power = state.power;
|
||||||
|
debug!(
|
||||||
|
id = Device::get_id(self),
|
||||||
|
"Updating state to {:?}",
|
||||||
|
self.state().await
|
||||||
|
);
|
||||||
|
|
||||||
|
self.config
|
||||||
|
.callback
|
||||||
|
.call(self, self.state().await.deref())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<T: OutletState> OnPresence for Outlet<T> {
|
||||||
|
async fn on_presence(&self, presence: bool) {
|
||||||
|
if self.config.presence_auto_off && !presence {
|
||||||
|
debug!(id = Device::get_id(self), "Turning device off");
|
||||||
|
self.set_on(false).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<T: OutletState> google_home::Device for Outlet<T> {
|
||||||
|
fn get_device_type(&self) -> Type {
|
||||||
|
self.config.outlet_type.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_device_name(&self) -> device::Name {
|
||||||
|
device::Name::new(&self.config.info.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_id(&self) -> String {
|
||||||
|
Device::get_id(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_online(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_room_hint(&self) -> Option<&str> {
|
||||||
|
self.config.info.room.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn will_report_state(&self) -> bool {
|
||||||
|
// TODO: Implement state reporting
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<T> OnOff for Outlet<T>
|
||||||
|
where
|
||||||
|
T: OutletState,
|
||||||
|
{
|
||||||
|
async fn on(&self) -> Result<bool, ErrorCode> {
|
||||||
|
let state = self.state().await;
|
||||||
|
let state: StateOnOff = state.deref().clone().into();
|
||||||
|
Ok(state.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_on(&self, on: bool) -> Result<(), ErrorCode> {
|
||||||
|
let message = json!({
|
||||||
|
"state": if on { "ON" } else { "OFF"}
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!(id = Device::get_id(self), "{message}");
|
||||||
|
|
||||||
|
let topic = format!("{}/set", self.config.mqtt.topic);
|
||||||
|
// TODO: Handle potential errors here
|
||||||
|
self.config
|
||||||
|
.client
|
||||||
|
.publish(
|
||||||
|
&topic,
|
||||||
|
rumqttc::QoS::AtLeastOnce,
|
||||||
|
false,
|
||||||
|
serde_json::to_string(&message).unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| warn!("Failed to update state on {topic}: {err}"))
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
28
automation_lib/Cargo.toml
Normal file
28
automation_lib/Cargo.toml
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
[package]
|
||||||
|
name = "automation_lib"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
automation_macro = { workspace = true }
|
||||||
|
automation_cast = { workspace = true }
|
||||||
|
google_home = { workspace = true }
|
||||||
|
rumqttc = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
serde_repr = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
bytes = { workspace = true }
|
||||||
|
pollster = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
indexmap = { workspace = true }
|
||||||
|
tokio-cron-scheduler = { workspace = true }
|
||||||
|
mlua = { workspace = true }
|
||||||
|
tokio-util = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
dyn-clone = { workspace = true }
|
||||||
|
impls = { workspace = true }
|
71
automation_lib/src/action_callback.rs
Normal file
71
automation_lib/src/action_callback.rs
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
use std::marker::PhantomData;
|
||||||
|
|
||||||
|
use mlua::{FromLua, IntoLua, LuaSerdeExt};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct Internal {
|
||||||
|
uuid: uuid::Uuid,
|
||||||
|
lua: mlua::Lua,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ActionCallback<T, S> {
|
||||||
|
internal: Option<Internal>,
|
||||||
|
_this: PhantomData<T>,
|
||||||
|
_state: PhantomData<S>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T, S> Default for ActionCallback<T, S> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
internal: None,
|
||||||
|
_this: PhantomData::<T>,
|
||||||
|
_state: PhantomData::<S>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T, S> FromLua for ActionCallback<T, S> {
|
||||||
|
fn from_lua(value: mlua::Value, lua: &mlua::Lua) -> mlua::Result<Self> {
|
||||||
|
let uuid = uuid::Uuid::new_v4();
|
||||||
|
lua.set_named_registry_value(&uuid.to_string(), value)?;
|
||||||
|
|
||||||
|
Ok(ActionCallback {
|
||||||
|
internal: Some(Internal {
|
||||||
|
uuid,
|
||||||
|
lua: lua.clone(),
|
||||||
|
}),
|
||||||
|
_this: PhantomData::<T>,
|
||||||
|
_state: PhantomData::<S>,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Return proper error here
|
||||||
|
impl<T, S> ActionCallback<T, S>
|
||||||
|
where
|
||||||
|
T: IntoLua + Sync + Send + Clone + 'static,
|
||||||
|
S: Serialize,
|
||||||
|
{
|
||||||
|
pub async fn call(&self, this: &T, state: &S) {
|
||||||
|
let Some(internal) = self.internal.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let state = internal.lua.to_value(state).unwrap();
|
||||||
|
|
||||||
|
let callback: mlua::Value = internal
|
||||||
|
.lua
|
||||||
|
.named_registry_value(&internal.uuid.to_string())
|
||||||
|
.unwrap();
|
||||||
|
match callback {
|
||||||
|
mlua::Value::Function(f) => f.call_async::<()>((this.clone(), state)).await.unwrap(),
|
||||||
|
_ => todo!("Only functions are currently supported"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_set(&self) -> bool {
|
||||||
|
self.internal.is_some()
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,65 +1,19 @@
|
||||||
mod air_filter;
|
|
||||||
mod contact_sensor;
|
|
||||||
mod debug_bridge;
|
|
||||||
mod hue_bridge;
|
|
||||||
mod hue_group;
|
|
||||||
mod ikea_outlet;
|
|
||||||
mod ikea_remote;
|
|
||||||
mod kasa_outlet;
|
|
||||||
mod light_sensor;
|
|
||||||
mod ntfy;
|
|
||||||
mod presence;
|
|
||||||
mod wake_on_lan;
|
|
||||||
mod washer;
|
|
||||||
|
|
||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
use std::ops::Deref;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use automation_cast::Cast;
|
use automation_cast::Cast;
|
||||||
use dyn_clone::DynClone;
|
use dyn_clone::DynClone;
|
||||||
use google_home::traits::OnOff;
|
use google_home::traits::OnOff;
|
||||||
use mlua::ObjectLike;
|
use mlua::ObjectLike;
|
||||||
|
|
||||||
pub use self::air_filter::AirFilter;
|
|
||||||
pub use self::contact_sensor::ContactSensor;
|
|
||||||
pub use self::debug_bridge::DebugBridge;
|
|
||||||
pub use self::hue_bridge::HueBridge;
|
|
||||||
pub use self::hue_group::HueGroup;
|
|
||||||
pub use self::ikea_outlet::IkeaOutlet;
|
|
||||||
pub use self::ikea_remote::IkeaRemote;
|
|
||||||
pub use self::kasa_outlet::KasaOutlet;
|
|
||||||
pub use self::light_sensor::LightSensor;
|
|
||||||
pub use self::ntfy::{Notification, Ntfy};
|
|
||||||
pub use self::presence::{Presence, DEFAULT_PRESENCE};
|
|
||||||
pub use self::wake_on_lan::WakeOnLAN;
|
|
||||||
pub use self::washer::Washer;
|
|
||||||
use crate::event::{OnDarkness, OnMqtt, OnNotification, OnPresence};
|
use crate::event::{OnDarkness, OnMqtt, OnNotification, OnPresence};
|
||||||
use crate::traits::Timeout;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait LuaDeviceCreate {
|
|
||||||
type Config;
|
|
||||||
type Error;
|
|
||||||
|
|
||||||
async fn create(config: Self::Config) -> Result<Self, Self::Error>
|
|
||||||
where
|
|
||||||
Self: Sized;
|
|
||||||
}
|
|
||||||
|
|
||||||
macro_rules! register_device {
|
|
||||||
($lua:expr, $device:ty) => {
|
|
||||||
$lua.globals()
|
|
||||||
.set(stringify!($device), $lua.create_proxy::<$device>()?)?;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// TODO: Make this a proper macro
|
||||||
macro_rules! impl_device {
|
macro_rules! impl_device {
|
||||||
($device:ty) => {
|
($device:ty) => {
|
||||||
impl mlua::UserData for $device {
|
impl mlua::UserData for $device {
|
||||||
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
|
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
|
||||||
methods.add_async_function("new", |_lua, config| async {
|
methods.add_async_function("new", |_lua, config| async {
|
||||||
let device: $device = crate::devices::LuaDeviceCreate::create(config)
|
let device: $device = LuaDeviceCreate::create(config)
|
||||||
.await
|
.await
|
||||||
.map_err(mlua::ExternalError::into_lua_err)?;
|
.map_err(mlua::ExternalError::into_lua_err)?;
|
||||||
|
|
||||||
|
@ -73,9 +27,9 @@ macro_rules! impl_device {
|
||||||
|
|
||||||
methods.add_async_method("get_id", |_lua, this, _: ()| async move { Ok(this.get_id()) });
|
methods.add_async_method("get_id", |_lua, this, _: ()| async move { Ok(this.get_id()) });
|
||||||
|
|
||||||
if impls::impls!($device: OnOff) {
|
if impls::impls!($device: google_home::traits::OnOff) {
|
||||||
methods.add_async_method("set_on", |_lua, this, on: bool| async move {
|
methods.add_async_method("set_on", |_lua, this, on: bool| async move {
|
||||||
(this.deref().cast() as Option<&dyn OnOff>)
|
(this.deref().cast() as Option<&dyn google_home::traits::OnOff>)
|
||||||
.expect("Cast should be valid")
|
.expect("Cast should be valid")
|
||||||
.set_on(on)
|
.set_on(on)
|
||||||
.await
|
.await
|
||||||
|
@ -85,7 +39,7 @@ macro_rules! impl_device {
|
||||||
});
|
});
|
||||||
|
|
||||||
methods.add_async_method("is_on", |_lua, this, _: ()| async move {
|
methods.add_async_method("is_on", |_lua, this, _: ()| async move {
|
||||||
Ok((this.deref().cast() as Option<&dyn OnOff>)
|
Ok((this.deref().cast() as Option<&dyn google_home::traits::OnOff>)
|
||||||
.expect("Cast should be valid")
|
.expect("Cast should be valid")
|
||||||
.on()
|
.on()
|
||||||
.await
|
.await
|
||||||
|
@ -96,37 +50,16 @@ macro_rules! impl_device {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
pub(crate) use impl_device;
|
||||||
|
|
||||||
impl_device!(AirFilter);
|
#[async_trait::async_trait]
|
||||||
impl_device!(ContactSensor);
|
pub trait LuaDeviceCreate {
|
||||||
impl_device!(DebugBridge);
|
type Config;
|
||||||
impl_device!(HueBridge);
|
type Error;
|
||||||
impl_device!(HueGroup);
|
|
||||||
impl_device!(IkeaOutlet);
|
|
||||||
impl_device!(IkeaRemote);
|
|
||||||
impl_device!(KasaOutlet);
|
|
||||||
impl_device!(LightSensor);
|
|
||||||
impl_device!(Ntfy);
|
|
||||||
impl_device!(Presence);
|
|
||||||
impl_device!(WakeOnLAN);
|
|
||||||
impl_device!(Washer);
|
|
||||||
|
|
||||||
pub fn register_with_lua(lua: &mlua::Lua) -> mlua::Result<()> {
|
async fn create(config: Self::Config) -> Result<Self, Self::Error>
|
||||||
register_device!(lua, AirFilter);
|
where
|
||||||
register_device!(lua, ContactSensor);
|
Self: Sized;
|
||||||
register_device!(lua, DebugBridge);
|
|
||||||
register_device!(lua, HueBridge);
|
|
||||||
register_device!(lua, HueGroup);
|
|
||||||
register_device!(lua, IkeaOutlet);
|
|
||||||
register_device!(lua, IkeaRemote);
|
|
||||||
register_device!(lua, KasaOutlet);
|
|
||||||
register_device!(lua, LightSensor);
|
|
||||||
register_device!(lua, Ntfy);
|
|
||||||
register_device!(lua, Presence);
|
|
||||||
register_device!(lua, WakeOnLAN);
|
|
||||||
register_device!(lua, Washer);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait Device:
|
pub trait Device:
|
||||||
|
@ -136,12 +69,10 @@ pub trait Device:
|
||||||
+ Send
|
+ Send
|
||||||
+ Cast<dyn google_home::Device>
|
+ Cast<dyn google_home::Device>
|
||||||
+ Cast<dyn OnMqtt>
|
+ Cast<dyn OnMqtt>
|
||||||
+ Cast<dyn OnMqtt>
|
|
||||||
+ Cast<dyn OnPresence>
|
+ Cast<dyn OnPresence>
|
||||||
+ Cast<dyn OnDarkness>
|
+ Cast<dyn OnDarkness>
|
||||||
+ Cast<dyn OnNotification>
|
+ Cast<dyn OnNotification>
|
||||||
+ Cast<dyn OnOff>
|
+ Cast<dyn OnOff>
|
||||||
+ Cast<dyn Timeout>
|
|
||||||
{
|
{
|
||||||
fn get_id(&self) -> String;
|
fn get_id(&self) -> String;
|
||||||
}
|
}
|
|
@ -8,7 +8,7 @@ use tokio::sync::{RwLock, RwLockReadGuard};
|
||||||
use tokio_cron_scheduler::{Job, JobScheduler};
|
use tokio_cron_scheduler::{Job, JobScheduler};
|
||||||
use tracing::{debug, instrument, trace};
|
use tracing::{debug, instrument, trace};
|
||||||
|
|
||||||
use crate::devices::Device;
|
use crate::device::Device;
|
||||||
use crate::event::{Event, EventChannel, OnDarkness, OnMqtt, OnNotification, OnPresence};
|
use crate::event::{Event, EventChannel, OnDarkness, OnMqtt, OnNotification, OnPresence};
|
||||||
|
|
||||||
pub type DeviceMap = HashMap<String, Box<dyn Device>>;
|
pub type DeviceMap = HashMap<String, Box<dyn Device>>;
|
|
@ -1,10 +1,7 @@
|
||||||
use std::{error, fmt, result};
|
use std::{error, fmt, result};
|
||||||
|
|
||||||
use axum::http::status::InvalidStatusCode;
|
|
||||||
use axum::response::IntoResponse;
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use rumqttc::ClientError;
|
use rumqttc::ClientError;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
@ -101,68 +98,3 @@ pub enum LightSensorError {
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
SubscribeError(#[from] ClientError),
|
SubscribeError(#[from] ClientError),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
#[error("{source}")]
|
|
||||||
pub struct ApiError {
|
|
||||||
status_code: axum::http::StatusCode,
|
|
||||||
source: Box<dyn std::error::Error>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ApiError {
|
|
||||||
pub fn new(status_code: axum::http::StatusCode, source: Box<dyn std::error::Error>) -> Self {
|
|
||||||
Self {
|
|
||||||
status_code,
|
|
||||||
source,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<ApiError> for ApiErrorJson {
|
|
||||||
fn from(value: ApiError) -> Self {
|
|
||||||
let error = ApiErrorJsonError {
|
|
||||||
code: value.status_code.as_u16(),
|
|
||||||
status: value.status_code.to_string(),
|
|
||||||
reason: value.source.to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
Self { error }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoResponse for ApiError {
|
|
||||||
fn into_response(self) -> axum::response::Response {
|
|
||||||
(
|
|
||||||
self.status_code,
|
|
||||||
serde_json::to_string::<ApiErrorJson>(&self.into())
|
|
||||||
.expect("Serialization should not fail"),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct ApiErrorJsonError {
|
|
||||||
code: u16,
|
|
||||||
status: String,
|
|
||||||
reason: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct ApiErrorJson {
|
|
||||||
error: ApiErrorJsonError,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<ApiErrorJson> for ApiError {
|
|
||||||
type Error = InvalidStatusCode;
|
|
||||||
|
|
||||||
fn try_from(value: ApiErrorJson) -> result::Result<Self, Self::Error> {
|
|
||||||
let status_code = axum::http::StatusCode::from_u16(value.error.code)?;
|
|
||||||
let source = value.error.reason.into();
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
status_code,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,7 +3,7 @@ use mlua::FromLua;
|
||||||
use rumqttc::Publish;
|
use rumqttc::Publish;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::devices::Notification;
|
use crate::ntfy::Notification;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Event {
|
pub enum Event {
|
11
automation_lib/src/helpers/mod.rs
Normal file
11
automation_lib/src/helpers/mod.rs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
pub mod serialization;
|
||||||
|
mod timeout;
|
||||||
|
|
||||||
|
pub use timeout::Timeout;
|
||||||
|
|
||||||
|
pub fn register_with_lua(lua: &mlua::Lua) -> mlua::Result<()> {
|
||||||
|
lua.globals()
|
||||||
|
.set("Timeout", lua.create_proxy::<Timeout>()?)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
16
automation_lib/src/helpers/serialization.rs
Normal file
16
automation_lib/src/helpers/serialization.rs
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
use serde::de::{self, Unexpected};
|
||||||
|
use serde::{Deserialize, Deserializer};
|
||||||
|
|
||||||
|
pub fn state_deserializer<'de, D>(deserializer: D) -> Result<bool, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
match String::deserialize(deserializer)?.as_ref() {
|
||||||
|
"ON" => Ok(true),
|
||||||
|
"OFF" => Ok(false),
|
||||||
|
other => Err(de::Error::invalid_value(
|
||||||
|
Unexpected::Str(other),
|
||||||
|
&"Value expected was either ON or OFF",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
76
automation_lib/src/helpers/timeout.rs
Normal file
76
automation_lib/src/helpers/timeout.rs
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::action_callback::ActionCallback;
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct State {
|
||||||
|
handle: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Timeout {
|
||||||
|
state: Arc<RwLock<State>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl mlua::UserData for Timeout {
|
||||||
|
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
|
||||||
|
methods.add_function("new", |_lua, ()| {
|
||||||
|
let device = Self {
|
||||||
|
state: Default::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(device)
|
||||||
|
});
|
||||||
|
|
||||||
|
methods.add_async_method(
|
||||||
|
"start",
|
||||||
|
|_lua, this, (timeout, callback): (u64, ActionCallback<mlua::Value, bool>)| async move {
|
||||||
|
if let Some(handle) = this.state.write().await.handle.take() {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Running timeout callback after {timeout}s");
|
||||||
|
|
||||||
|
let timeout = Duration::from_secs(timeout);
|
||||||
|
|
||||||
|
this.state.write().await.handle = Some(tokio::spawn({
|
||||||
|
async move {
|
||||||
|
tokio::time::sleep(timeout).await;
|
||||||
|
|
||||||
|
callback.call(&mlua::Nil, &false).await;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
methods.add_async_method("cancel", |_lua, this, ()| async move {
|
||||||
|
debug!("Canceling timeout callback");
|
||||||
|
|
||||||
|
if let Some(handle) = this.state.write().await.handle.take() {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
methods.add_async_method("is_waiting", |_lua, this, ()| async move {
|
||||||
|
debug!("Canceling timeout callback");
|
||||||
|
|
||||||
|
if let Some(handle) = this.state.read().await.handle.as_ref() {
|
||||||
|
debug!("Join handle: {}", handle.is_finished());
|
||||||
|
return Ok(!handle.is_finished());
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Join handle: None");
|
||||||
|
|
||||||
|
Ok(false)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,13 +3,14 @@
|
||||||
#![feature(let_chains)]
|
#![feature(let_chains)]
|
||||||
|
|
||||||
pub mod action_callback;
|
pub mod action_callback;
|
||||||
pub mod auth;
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod device;
|
||||||
pub mod device_manager;
|
pub mod device_manager;
|
||||||
pub mod devices;
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
|
pub mod helpers;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
pub mod mqtt;
|
pub mod mqtt;
|
||||||
|
pub mod ntfy;
|
||||||
|
pub mod presence;
|
||||||
pub mod schedule;
|
pub mod schedule;
|
||||||
pub mod traits;
|
|
|
@ -241,40 +241,3 @@ impl TryFrom<Bytes> for HueMessage {
|
||||||
serde_json::from_slice(&bytes).or(Err(ParseError::InvalidPayload(bytes.clone())))
|
serde_json::from_slice(&bytes).or(Err(ParseError::InvalidPayload(bytes.clone())))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Import this from the air_filter code itself instead of copying
|
|
||||||
#[derive(PartialEq, Eq, Debug, Clone, Copy, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum AirFilterFanState {
|
|
||||||
Off,
|
|
||||||
Low,
|
|
||||||
Medium,
|
|
||||||
High,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
|
|
||||||
pub struct SetAirFilterFanState {
|
|
||||||
state: AirFilterFanState,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(PartialEq, Debug, Clone, Copy, Deserialize, Serialize)]
|
|
||||||
pub struct AirFilterState {
|
|
||||||
pub state: AirFilterFanState,
|
|
||||||
pub humidity: f32,
|
|
||||||
pub temperature: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SetAirFilterFanState {
|
|
||||||
pub fn new(state: AirFilterFanState) -> Self {
|
|
||||||
Self { state }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<Publish> for AirFilterState {
|
|
||||||
type Error = ParseError;
|
|
||||||
|
|
||||||
fn try_from(message: Publish) -> Result<Self, Self::Error> {
|
|
||||||
serde_json::from_slice(&message.payload)
|
|
||||||
.or(Err(ParseError::InvalidPayload(message.payload.clone())))
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,14 +1,15 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
|
use std::ops::Deref;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use automation_cast::Cast;
|
||||||
use automation_macro::LuaDeviceConfig;
|
use automation_macro::LuaDeviceConfig;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_repr::*;
|
use serde_repr::*;
|
||||||
use tracing::{error, trace, warn};
|
use tracing::{error, trace, warn};
|
||||||
|
|
||||||
use super::LuaDeviceCreate;
|
use crate::device::{impl_device, Device, LuaDeviceCreate};
|
||||||
use crate::devices::Device;
|
|
||||||
use crate::event::{self, Event, EventChannel, OnNotification, OnPresence};
|
use crate::event::{self, Event, EventChannel, OnNotification, OnPresence};
|
||||||
|
|
||||||
#[derive(Debug, Serialize_repr, Clone, Copy)]
|
#[derive(Debug, Serialize_repr, Clone, Copy)]
|
||||||
|
@ -125,6 +126,8 @@ pub struct Ntfy {
|
||||||
config: Config,
|
config: Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl_device!(Ntfy);
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LuaDeviceCreate for Ntfy {
|
impl LuaDeviceCreate for Ntfy {
|
||||||
type Config = Config;
|
type Config = Config;
|
|
@ -1,15 +1,16 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::ops::Deref;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use automation_cast::Cast;
|
||||||
use automation_macro::LuaDeviceConfig;
|
use automation_macro::LuaDeviceConfig;
|
||||||
use rumqttc::Publish;
|
use rumqttc::Publish;
|
||||||
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||||
use tracing::{debug, trace, warn};
|
use tracing::{debug, trace, warn};
|
||||||
|
|
||||||
use super::LuaDeviceCreate;
|
|
||||||
use crate::config::MqttDeviceConfig;
|
use crate::config::MqttDeviceConfig;
|
||||||
use crate::devices::Device;
|
use crate::device::{impl_device, Device, LuaDeviceCreate};
|
||||||
use crate::event::{self, Event, EventChannel, OnMqtt};
|
use crate::event::{self, Event, EventChannel, OnMqtt};
|
||||||
use crate::messages::PresenceMessage;
|
use crate::messages::PresenceMessage;
|
||||||
use crate::mqtt::WrappedAsyncClient;
|
use crate::mqtt::WrappedAsyncClient;
|
||||||
|
@ -48,6 +49,8 @@ impl Presence {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl_device!(Presence);
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LuaDeviceCreate for Presence {
|
impl LuaDeviceCreate for Presence {
|
||||||
type Config = Config;
|
type Config = Config;
|
|
@ -7,14 +7,7 @@ edition = "2021"
|
||||||
proc-macro = true
|
proc-macro = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
automation_cast = { path = "../automation_cast" }
|
itertools = { workspace = true }
|
||||||
async-trait = "0.1.80"
|
proc-macro2 = { workspace = true }
|
||||||
itertools = "0.12.1"
|
quote = { workspace = true }
|
||||||
proc-macro2 = "1.0.81"
|
syn = { workspace = true }
|
||||||
quote = "1.0.36"
|
|
||||||
serde = { version = "1.0.202", features = ["derive"] }
|
|
||||||
syn = { version = "2.0.60", features = ["extra-traits", "full"] }
|
|
||||||
serde_json = "1.0.118"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
serde = { version = "1.0.202", features = ["derive"] }
|
|
||||||
|
|
|
@ -260,8 +260,9 @@ pub fn impl_lua_device_config_macro(ast: &DeriveInput) -> TokenStream {
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
|
||||||
let impl_from_lua = quote! {
|
let impl_from_lua = quote! {
|
||||||
impl mlua::FromLua for #name {
|
impl #impl_generics mlua::FromLua for #name #type_generics #where_clause {
|
||||||
fn from_lua(value: mlua::Value, lua: &mlua::Lua) -> mlua::Result<Self> {
|
fn from_lua(value: mlua::Value, lua: &mlua::Lua) -> mlua::Result<Self> {
|
||||||
if !value.is_table() {
|
if !value.is_table() {
|
||||||
panic!("Expected table");
|
panic!("Expected table");
|
||||||
|
|
507
config.lua
Normal file
507
config.lua
Normal file
|
@ -0,0 +1,507 @@
|
||||||
|
print("Hello from lua")
|
||||||
|
|
||||||
|
local host = automation.util.get_hostname()
|
||||||
|
print("Running @" .. host)
|
||||||
|
|
||||||
|
local debug, value = pcall(automation.util.get_env, "DEBUG")
|
||||||
|
if debug and value ~= "true" then
|
||||||
|
debug = false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mqtt_z2m(topic)
|
||||||
|
return "zigbee2mqtt/" .. topic
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mqtt_automation(topic)
|
||||||
|
return "automation/" .. topic
|
||||||
|
end
|
||||||
|
|
||||||
|
automation.fulfillment = {
|
||||||
|
openid_url = "https://login.huizinga.dev/api/oidc",
|
||||||
|
}
|
||||||
|
|
||||||
|
local mqtt_client = automation.new_mqtt_client({
|
||||||
|
host = ((host == "zeus" or host == "hephaestus") and "olympus.lan.huizinga.dev") or "mosquitto",
|
||||||
|
port = 8883,
|
||||||
|
client_name = "automation-" .. host,
|
||||||
|
username = "mqtt",
|
||||||
|
password = automation.util.get_env("MQTT_PASSWORD"),
|
||||||
|
tls = host == "zeus" or host == "hephaestus",
|
||||||
|
})
|
||||||
|
|
||||||
|
automation.device_manager:add(Ntfy.new({
|
||||||
|
topic = automation.util.get_env("NTFY_TOPIC"),
|
||||||
|
event_channel = automation.device_manager:event_channel(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
automation.device_manager:add(Presence.new({
|
||||||
|
topic = mqtt_automation("presence/+/#"),
|
||||||
|
client = mqtt_client,
|
||||||
|
event_channel = automation.device_manager:event_channel(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
automation.device_manager:add(DebugBridge.new({
|
||||||
|
identifier = "debug_bridge",
|
||||||
|
topic = mqtt_automation("debug"),
|
||||||
|
client = mqtt_client,
|
||||||
|
}))
|
||||||
|
|
||||||
|
local hue_ip = "10.0.0.102"
|
||||||
|
local hue_token = automation.util.get_env("HUE_TOKEN")
|
||||||
|
|
||||||
|
automation.device_manager:add(HueBridge.new({
|
||||||
|
identifier = "hue_bridge",
|
||||||
|
ip = hue_ip,
|
||||||
|
login = hue_token,
|
||||||
|
flags = {
|
||||||
|
presence = 41,
|
||||||
|
darkness = 43,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
local kitchen_lights = HueGroup.new({
|
||||||
|
identifier = "kitchen_lights",
|
||||||
|
ip = hue_ip,
|
||||||
|
login = hue_token,
|
||||||
|
group_id = 7,
|
||||||
|
scene_id = "7MJLG27RzeRAEVJ",
|
||||||
|
})
|
||||||
|
automation.device_manager:add(kitchen_lights)
|
||||||
|
local living_lights = HueGroup.new({
|
||||||
|
identifier = "living_lights",
|
||||||
|
ip = hue_ip,
|
||||||
|
login = hue_token,
|
||||||
|
group_id = 1,
|
||||||
|
scene_id = "SNZw7jUhQ3cXSjkj",
|
||||||
|
})
|
||||||
|
automation.device_manager:add(living_lights)
|
||||||
|
local living_lights_relax = HueGroup.new({
|
||||||
|
identifier = "living_lights",
|
||||||
|
ip = hue_ip,
|
||||||
|
login = hue_token,
|
||||||
|
group_id = 1,
|
||||||
|
scene_id = "eRJ3fvGHCcb6yNw",
|
||||||
|
})
|
||||||
|
automation.device_manager:add(living_lights_relax)
|
||||||
|
|
||||||
|
automation.device_manager:add(HueSwitch.new({
|
||||||
|
name = "Switch",
|
||||||
|
room = "Living",
|
||||||
|
client = mqtt_client,
|
||||||
|
topic = mqtt_z2m("living/switch"),
|
||||||
|
left_callback = function()
|
||||||
|
kitchen_lights:set_on(not kitchen_lights:on())
|
||||||
|
end,
|
||||||
|
right_callback = function()
|
||||||
|
living_lights:set_on(not living_lights:on())
|
||||||
|
end,
|
||||||
|
right_hold_callback = function()
|
||||||
|
living_lights_relax:set_on(true)
|
||||||
|
end,
|
||||||
|
}))
|
||||||
|
|
||||||
|
automation.device_manager:add(LightSensor.new({
|
||||||
|
identifier = "living_light_sensor",
|
||||||
|
topic = mqtt_z2m("living/light"),
|
||||||
|
client = mqtt_client,
|
||||||
|
min = 22000,
|
||||||
|
max = 23500,
|
||||||
|
event_channel = automation.device_manager:event_channel(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
automation.device_manager:add(WakeOnLAN.new({
|
||||||
|
name = "Zeus",
|
||||||
|
room = "Living Room",
|
||||||
|
topic = mqtt_automation("appliance/living_room/zeus"),
|
||||||
|
client = mqtt_client,
|
||||||
|
mac_address = "30:9c:23:60:9c:13",
|
||||||
|
broadcast_ip = "10.0.3.255",
|
||||||
|
}))
|
||||||
|
|
||||||
|
local living_mixer = OutletOnOff.new({
|
||||||
|
name = "Mixer",
|
||||||
|
room = "Living Room",
|
||||||
|
topic = mqtt_z2m("living/mixer"),
|
||||||
|
client = mqtt_client,
|
||||||
|
})
|
||||||
|
automation.device_manager:add(living_mixer)
|
||||||
|
local living_speakers = OutletOnOff.new({
|
||||||
|
name = "Speakers",
|
||||||
|
room = "Living Room",
|
||||||
|
topic = mqtt_z2m("living/speakers"),
|
||||||
|
client = mqtt_client,
|
||||||
|
})
|
||||||
|
automation.device_manager:add(living_speakers)
|
||||||
|
|
||||||
|
automation.device_manager:add(IkeaRemote.new({
|
||||||
|
name = "Remote",
|
||||||
|
room = "Living Room",
|
||||||
|
client = mqtt_client,
|
||||||
|
topic = mqtt_z2m("living/remote"),
|
||||||
|
single_button = true,
|
||||||
|
callback = function(_, on)
|
||||||
|
if on then
|
||||||
|
if living_mixer:on() then
|
||||||
|
living_mixer:set_on(false)
|
||||||
|
living_speakers:set_on(false)
|
||||||
|
else
|
||||||
|
living_mixer:set_on(true)
|
||||||
|
living_speakers:set_on(true)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
if not living_mixer:on() then
|
||||||
|
living_mixer:set_on(true)
|
||||||
|
else
|
||||||
|
living_speakers:set_on(not living_speakers:on())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
}))
|
||||||
|
|
||||||
|
local function kettle_timeout()
|
||||||
|
local timeout = Timeout.new()
|
||||||
|
|
||||||
|
return function(self, state)
|
||||||
|
if state.state and state.power < 100 then
|
||||||
|
timeout:start(3, function()
|
||||||
|
self:set_on(false)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
timeout:cancel()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local kettle = OutletPower.new({
|
||||||
|
outlet_type = "Kettle",
|
||||||
|
name = "Kettle",
|
||||||
|
room = "Kitchen",
|
||||||
|
topic = mqtt_z2m("kitchen/kettle"),
|
||||||
|
client = mqtt_client,
|
||||||
|
callback = kettle_timeout(),
|
||||||
|
})
|
||||||
|
automation.device_manager:add(kettle)
|
||||||
|
|
||||||
|
local function set_kettle(_, on)
|
||||||
|
kettle:set_on(on)
|
||||||
|
end
|
||||||
|
|
||||||
|
automation.device_manager:add(IkeaRemote.new({
|
||||||
|
name = "Remote",
|
||||||
|
room = "Bedroom",
|
||||||
|
client = mqtt_client,
|
||||||
|
topic = mqtt_z2m("bedroom/remote"),
|
||||||
|
single_button = true,
|
||||||
|
callback = set_kettle,
|
||||||
|
}))
|
||||||
|
|
||||||
|
automation.device_manager:add(IkeaRemote.new({
|
||||||
|
name = "Remote",
|
||||||
|
room = "Kitchen",
|
||||||
|
client = mqtt_client,
|
||||||
|
topic = mqtt_z2m("kitchen/remote"),
|
||||||
|
single_button = true,
|
||||||
|
callback = set_kettle,
|
||||||
|
}))
|
||||||
|
|
||||||
|
local function off_timeout(duration)
|
||||||
|
local timeout = Timeout.new()
|
||||||
|
|
||||||
|
return function(self, state)
|
||||||
|
if state.state then
|
||||||
|
timeout:start(duration, function()
|
||||||
|
self:set_on(false)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
timeout:cancel()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
automation.device_manager:add(LightOnOff.new({
|
||||||
|
name = "Light",
|
||||||
|
room = "Bathroom",
|
||||||
|
topic = mqtt_z2m("bathroom/light"),
|
||||||
|
client = mqtt_client,
|
||||||
|
callback = off_timeout(debug and 60 or 45 * 60),
|
||||||
|
}))
|
||||||
|
|
||||||
|
automation.device_manager:add(Washer.new({
|
||||||
|
identifier = "bathroom_washer",
|
||||||
|
topic = mqtt_z2m("bathroom/washer"),
|
||||||
|
client = mqtt_client,
|
||||||
|
threshold = 1,
|
||||||
|
event_channel = automation.device_manager:event_channel(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
automation.device_manager:add(OutletOnOff.new({
|
||||||
|
presence_auto_off = false,
|
||||||
|
name = "Charger",
|
||||||
|
room = "Workbench",
|
||||||
|
topic = mqtt_z2m("workbench/charger"),
|
||||||
|
client = mqtt_client,
|
||||||
|
callback = off_timeout(debug and 5 or 20 * 3600),
|
||||||
|
}))
|
||||||
|
|
||||||
|
automation.device_manager:add(OutletOnOff.new({
|
||||||
|
name = "Outlet",
|
||||||
|
room = "Workbench",
|
||||||
|
topic = mqtt_z2m("workbench/outlet"),
|
||||||
|
client = mqtt_client,
|
||||||
|
}))
|
||||||
|
|
||||||
|
local workbench_light = LightBrightness.new({
|
||||||
|
name = "Light",
|
||||||
|
room = "Workbench",
|
||||||
|
topic = mqtt_z2m("workbench/light"),
|
||||||
|
client = mqtt_client,
|
||||||
|
})
|
||||||
|
automation.device_manager:add(workbench_light)
|
||||||
|
|
||||||
|
automation.device_manager:add(IkeaRemote.new({
|
||||||
|
name = "Remote",
|
||||||
|
room = "Workbench",
|
||||||
|
client = mqtt_client,
|
||||||
|
topic = mqtt_z2m("workbench/remote"),
|
||||||
|
callback = function(_, on)
|
||||||
|
workbench_light:set_on(on)
|
||||||
|
end,
|
||||||
|
}))
|
||||||
|
|
||||||
|
local hallway_top_light = HueGroup.new({
|
||||||
|
identifier = "hallway_top_light",
|
||||||
|
ip = hue_ip,
|
||||||
|
login = hue_token,
|
||||||
|
group_id = 83,
|
||||||
|
scene_id = "QeufkFDICEHWeKJ7",
|
||||||
|
})
|
||||||
|
automation.device_manager:add(HueSwitch.new({
|
||||||
|
name = "SwitchBottom",
|
||||||
|
room = "Hallway",
|
||||||
|
client = mqtt_client,
|
||||||
|
topic = mqtt_z2m("hallway/switchbottom"),
|
||||||
|
left_callback = function()
|
||||||
|
hallway_top_light:set_on(not hallway_top_light:on())
|
||||||
|
end,
|
||||||
|
}))
|
||||||
|
automation.device_manager:add(HueSwitch.new({
|
||||||
|
name = "SwitchTop",
|
||||||
|
room = "Hallway",
|
||||||
|
client = mqtt_client,
|
||||||
|
topic = mqtt_z2m("hallway/switchtop"),
|
||||||
|
left_callback = function()
|
||||||
|
hallway_top_light:set_on(not hallway_top_light:on())
|
||||||
|
end,
|
||||||
|
}))
|
||||||
|
|
||||||
|
local hallway_light_automation = {
|
||||||
|
timeout = Timeout.new(),
|
||||||
|
forced = false,
|
||||||
|
switch_callback = function(self, on)
|
||||||
|
self.timeout:cancel()
|
||||||
|
self.group.set_on(on)
|
||||||
|
self.forced = on
|
||||||
|
end,
|
||||||
|
door_callback = function(self, open)
|
||||||
|
if open then
|
||||||
|
self.timeout:cancel()
|
||||||
|
|
||||||
|
self.group.set_on(true)
|
||||||
|
elseif not self.forced then
|
||||||
|
self.timeout:start(debug and 10 or 2 * 60, function()
|
||||||
|
if self.trash:open_percent() == 0 then
|
||||||
|
self.group.set_on(false)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
trash_callback = function(self, open)
|
||||||
|
if open then
|
||||||
|
self.group.set_on(true)
|
||||||
|
else
|
||||||
|
if not self.timeout:is_waiting() and self.door:open_percent() == 0 and not self.forced then
|
||||||
|
self.group.set_on(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
light_callback = function(self, on)
|
||||||
|
if on and self.trash:open_percent() == 0 and self.door:open_percent() == 0 then
|
||||||
|
-- If the door and trash are not open, that means the light got turned on manually
|
||||||
|
self.timeout:cancel()
|
||||||
|
self.forced = true
|
||||||
|
elseif not on then
|
||||||
|
-- The light is never forced when it is off
|
||||||
|
self.forced = false
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local hallway_storage = LightBrightness.new({
|
||||||
|
name = "Storage",
|
||||||
|
room = "Hallway",
|
||||||
|
topic = mqtt_z2m("hallway/storage"),
|
||||||
|
client = mqtt_client,
|
||||||
|
callback = function(_, state)
|
||||||
|
hallway_light_automation:light_callback(state.state)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
automation.device_manager:add(hallway_storage)
|
||||||
|
|
||||||
|
local hallway_bottom_lights = HueGroup.new({
|
||||||
|
identifier = "hallway_bottom_lights",
|
||||||
|
ip = hue_ip,
|
||||||
|
login = hue_token,
|
||||||
|
group_id = 81,
|
||||||
|
scene_id = "3qWKxGVadXFFG4o",
|
||||||
|
})
|
||||||
|
automation.device_manager:add(hallway_bottom_lights)
|
||||||
|
|
||||||
|
hallway_light_automation.group = {
|
||||||
|
set_on = function(on)
|
||||||
|
if on then
|
||||||
|
hallway_storage:set_brightness(80)
|
||||||
|
else
|
||||||
|
hallway_storage:set_on(false)
|
||||||
|
end
|
||||||
|
hallway_bottom_lights:set_on(on)
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
automation.device_manager:add(IkeaRemote.new({
|
||||||
|
name = "Remote",
|
||||||
|
room = "Hallway",
|
||||||
|
client = mqtt_client,
|
||||||
|
topic = mqtt_z2m("hallway/remote"),
|
||||||
|
callback = function(_, on)
|
||||||
|
hallway_light_automation:switch_callback(on)
|
||||||
|
end,
|
||||||
|
}))
|
||||||
|
local hallway_frontdoor = ContactSensor.new({
|
||||||
|
name = "Frontdoor",
|
||||||
|
room = "Hallway",
|
||||||
|
sensor_type = "Door",
|
||||||
|
topic = mqtt_z2m("hallway/frontdoor"),
|
||||||
|
client = mqtt_client,
|
||||||
|
presence = {
|
||||||
|
topic = mqtt_automation("presence/contact/frontdoor"),
|
||||||
|
timeout = debug and 10 or 15 * 60,
|
||||||
|
},
|
||||||
|
callback = function(_, open)
|
||||||
|
hallway_light_automation:door_callback(open)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
automation.device_manager:add(hallway_frontdoor)
|
||||||
|
hallway_light_automation.door = hallway_frontdoor
|
||||||
|
|
||||||
|
local hallway_trash = ContactSensor.new({
|
||||||
|
name = "Trash",
|
||||||
|
room = "Hallway",
|
||||||
|
sensor_type = "Drawer",
|
||||||
|
topic = mqtt_z2m("hallway/trash"),
|
||||||
|
client = mqtt_client,
|
||||||
|
callback = function(_, open)
|
||||||
|
hallway_light_automation:trash_callback(open)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
automation.device_manager:add(hallway_trash)
|
||||||
|
hallway_light_automation.trash = hallway_trash
|
||||||
|
|
||||||
|
automation.device_manager:add(LightOnOff.new({
|
||||||
|
name = "Light",
|
||||||
|
room = "Guest Room",
|
||||||
|
topic = mqtt_z2m("guest/light"),
|
||||||
|
client = mqtt_client,
|
||||||
|
}))
|
||||||
|
|
||||||
|
local bedroom_air_filter = AirFilter.new({
|
||||||
|
name = "Air Filter",
|
||||||
|
room = "Bedroom",
|
||||||
|
url = "http://10.0.0.103",
|
||||||
|
})
|
||||||
|
automation.device_manager:add(bedroom_air_filter)
|
||||||
|
|
||||||
|
local bedroom_lights = HueGroup.new({
|
||||||
|
identifier = "bedroom_lights",
|
||||||
|
ip = hue_ip,
|
||||||
|
login = hue_token,
|
||||||
|
group_id = 3,
|
||||||
|
scene_id = "PvRs-lGD4VRytL9",
|
||||||
|
})
|
||||||
|
automation.device_manager:add(bedroom_lights)
|
||||||
|
local bedroom_lights_relax = HueGroup.new({
|
||||||
|
identifier = "bedroom_lights",
|
||||||
|
ip = hue_ip,
|
||||||
|
login = hue_token,
|
||||||
|
group_id = 3,
|
||||||
|
scene_id = "60tfTyR168v2csz",
|
||||||
|
})
|
||||||
|
automation.device_manager:add(bedroom_lights_relax)
|
||||||
|
|
||||||
|
automation.device_manager:add(HueSwitch.new({
|
||||||
|
name = "Switch",
|
||||||
|
room = "Bedroom",
|
||||||
|
client = mqtt_client,
|
||||||
|
topic = mqtt_z2m("bedroom/switch"),
|
||||||
|
left_callback = function()
|
||||||
|
bedroom_lights:set_on(not bedroom_lights:on())
|
||||||
|
end,
|
||||||
|
left_hold_callback = function()
|
||||||
|
bedroom_lights_relax:set_on(true)
|
||||||
|
end,
|
||||||
|
}))
|
||||||
|
|
||||||
|
automation.device_manager:add(ContactSensor.new({
|
||||||
|
name = "Balcony",
|
||||||
|
room = "Living Room",
|
||||||
|
sensor_type = "Door",
|
||||||
|
topic = mqtt_z2m("living/balcony"),
|
||||||
|
client = mqtt_client,
|
||||||
|
}))
|
||||||
|
automation.device_manager:add(ContactSensor.new({
|
||||||
|
name = "Window",
|
||||||
|
room = "Living Room",
|
||||||
|
topic = mqtt_z2m("living/window"),
|
||||||
|
client = mqtt_client,
|
||||||
|
}))
|
||||||
|
automation.device_manager:add(ContactSensor.new({
|
||||||
|
name = "Window",
|
||||||
|
room = "Bedroom",
|
||||||
|
topic = mqtt_z2m("bedroom/window"),
|
||||||
|
client = mqtt_client,
|
||||||
|
}))
|
||||||
|
automation.device_manager:add(ContactSensor.new({
|
||||||
|
name = "Window",
|
||||||
|
room = "Guest Room",
|
||||||
|
topic = mqtt_z2m("guest/window"),
|
||||||
|
client = mqtt_client,
|
||||||
|
}))
|
||||||
|
|
||||||
|
local storage_light = LightBrightness.new({
|
||||||
|
name = "Light",
|
||||||
|
room = "Storage",
|
||||||
|
topic = mqtt_z2m("storage/light"),
|
||||||
|
client = mqtt_client,
|
||||||
|
})
|
||||||
|
automation.device_manager:add(storage_light)
|
||||||
|
|
||||||
|
automation.device_manager:add(ContactSensor.new({
|
||||||
|
name = "Door",
|
||||||
|
room = "Storage",
|
||||||
|
sensor_type = "Door",
|
||||||
|
topic = mqtt_z2m("storage/door"),
|
||||||
|
client = mqtt_client,
|
||||||
|
callback = function(_, open)
|
||||||
|
if open then
|
||||||
|
storage_light:set_brightness(100)
|
||||||
|
else
|
||||||
|
storage_light:set_on(false)
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
}))
|
||||||
|
|
||||||
|
automation.device_manager:schedule("0 0 19 * * *", function()
|
||||||
|
bedroom_air_filter:set_on(true)
|
||||||
|
end)
|
||||||
|
automation.device_manager:schedule("0 0 20 * * *", function()
|
||||||
|
bedroom_air_filter:set_on(false)
|
||||||
|
end)
|
|
@ -1,9 +0,0 @@
|
||||||
return {
|
|
||||||
mqtt_z2m = function(topic)
|
|
||||||
return "zigbee2mqtt/" .. topic
|
|
||||||
end,
|
|
||||||
|
|
||||||
mqtt_automation = function(topic)
|
|
||||||
return "automation/" .. topic
|
|
||||||
end,
|
|
||||||
}
|
|
|
@ -1,35 +0,0 @@
|
||||||
local h = require("helper")
|
|
||||||
|
|
||||||
return function(mqtt_client, debug)
|
|
||||||
local kettle = IkeaOutlet.new({
|
|
||||||
outlet_type = "Kettle",
|
|
||||||
name = "Kettle",
|
|
||||||
room = "Kitchen",
|
|
||||||
topic = h.mqtt_z2m("kitchen/kettle"),
|
|
||||||
client = mqtt_client,
|
|
||||||
timeout = debug and 5 or 300,
|
|
||||||
})
|
|
||||||
automation.device_manager:add(kettle)
|
|
||||||
|
|
||||||
local function set_kettle(on)
|
|
||||||
kettle:set_on(on)
|
|
||||||
end
|
|
||||||
|
|
||||||
automation.device_manager:add(IkeaRemote.new({
|
|
||||||
name = "Remote",
|
|
||||||
room = "Bedroom",
|
|
||||||
client = mqtt_client,
|
|
||||||
topic = h.mqtt_z2m("bedroom/remote"),
|
|
||||||
single_button = true,
|
|
||||||
callback = set_kettle,
|
|
||||||
}))
|
|
||||||
|
|
||||||
automation.device_manager:add(IkeaRemote.new({
|
|
||||||
name = "Remote",
|
|
||||||
room = "Kitchen",
|
|
||||||
client = mqtt_client,
|
|
||||||
topic = h.mqtt_z2m("kitchen/remote"),
|
|
||||||
single_button = true,
|
|
||||||
callback = set_kettle,
|
|
||||||
}))
|
|
||||||
end
|
|
198
config/main.lua
198
config/main.lua
|
@ -1,198 +0,0 @@
|
||||||
print(package.path)
|
|
||||||
print("Hello from lua")
|
|
||||||
|
|
||||||
local host = automation.util.get_hostname()
|
|
||||||
print("Running @" .. host)
|
|
||||||
|
|
||||||
local debug, value = pcall(automation.util.get_env, "DEBUG")
|
|
||||||
if debug and value ~= "true" then
|
|
||||||
debug = false
|
|
||||||
end
|
|
||||||
|
|
||||||
local h = require("helper")
|
|
||||||
|
|
||||||
automation.fulfillment = {
|
|
||||||
openid_url = "https://login.huizinga.dev/api/oidc",
|
|
||||||
}
|
|
||||||
|
|
||||||
local mqtt_client = automation.new_mqtt_client({
|
|
||||||
host = (host == "zeus" and "olympus.lan.huizinga.dev")
|
|
||||||
or (host == "hephaestus" and "olympus.vpn.huizinga.dev")
|
|
||||||
or "mosquitto",
|
|
||||||
port = 8883,
|
|
||||||
client_name = "automation-" .. host,
|
|
||||||
username = "mqtt",
|
|
||||||
password = automation.util.get_env("MQTT_PASSWORD"),
|
|
||||||
tls = host == "zeus" or host == "hephaestus",
|
|
||||||
})
|
|
||||||
|
|
||||||
automation.device_manager:add(Ntfy.new({
|
|
||||||
topic = automation.util.get_env("NTFY_TOPIC"),
|
|
||||||
event_channel = automation.device_manager:event_channel(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
automation.device_manager:add(Presence.new({
|
|
||||||
topic = h.mqtt_automation("presence/+/#"),
|
|
||||||
client = mqtt_client,
|
|
||||||
event_channel = automation.device_manager:event_channel(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
automation.device_manager:add(DebugBridge.new({
|
|
||||||
identifier = "debug_bridge",
|
|
||||||
topic = h.mqtt_automation("debug"),
|
|
||||||
client = mqtt_client,
|
|
||||||
}))
|
|
||||||
|
|
||||||
local hue_ip = "10.0.0.136"
|
|
||||||
local hue_token = automation.util.get_env("HUE_TOKEN")
|
|
||||||
|
|
||||||
automation.device_manager:add(HueBridge.new({
|
|
||||||
identifier = "hue_bridge",
|
|
||||||
ip = hue_ip,
|
|
||||||
login = hue_token,
|
|
||||||
flags = {
|
|
||||||
presence = 41,
|
|
||||||
darkness = 43,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
automation.device_manager:add(LightSensor.new({
|
|
||||||
identifier = "living_light_sensor",
|
|
||||||
topic = h.mqtt_z2m("living/light"),
|
|
||||||
client = mqtt_client,
|
|
||||||
min = 22000,
|
|
||||||
max = 23500,
|
|
||||||
event_channel = automation.device_manager:event_channel(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
automation.device_manager:add(WakeOnLAN.new({
|
|
||||||
name = "Zeus",
|
|
||||||
room = "Living Room",
|
|
||||||
topic = h.mqtt_automation("appliance/living_room/zeus"),
|
|
||||||
client = mqtt_client,
|
|
||||||
mac_address = "30:9c:23:60:9c:13",
|
|
||||||
broadcast_ip = "10.0.0.255",
|
|
||||||
}))
|
|
||||||
|
|
||||||
local living_mixer = KasaOutlet.new({ identifier = "living_mixer", ip = "10.0.0.84" })
|
|
||||||
automation.device_manager:add(living_mixer)
|
|
||||||
local living_speakers = KasaOutlet.new({ identifier = "living_speakers", ip = "10.0.0.127" })
|
|
||||||
automation.device_manager:add(living_speakers)
|
|
||||||
|
|
||||||
automation.device_manager:add(IkeaRemote.new({
|
|
||||||
name = "Remote",
|
|
||||||
room = "Living",
|
|
||||||
client = mqtt_client,
|
|
||||||
topic = h.mqtt_z2m("living/remote"),
|
|
||||||
single_button = true,
|
|
||||||
callback = function(on)
|
|
||||||
if on then
|
|
||||||
if living_mixer:is_on() then
|
|
||||||
living_mixer:set_on(false)
|
|
||||||
living_speakers:set_on(false)
|
|
||||||
else
|
|
||||||
living_mixer:set_on(true)
|
|
||||||
living_speakers:set_on(true)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
if not living_mixer:is_on() then
|
|
||||||
living_mixer:set_on(true)
|
|
||||||
else
|
|
||||||
living_speakers:set_on(not living_speakers:is_on())
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end,
|
|
||||||
}))
|
|
||||||
|
|
||||||
require("kettle")(mqtt_client, debug)
|
|
||||||
|
|
||||||
automation.device_manager:add(IkeaOutlet.new({
|
|
||||||
outlet_type = "Light",
|
|
||||||
name = "Light",
|
|
||||||
room = "Bathroom",
|
|
||||||
topic = h.mqtt_z2m("bathroom/light"),
|
|
||||||
client = mqtt_client,
|
|
||||||
timeout = debug and 60 or 45 * 60,
|
|
||||||
}))
|
|
||||||
|
|
||||||
automation.device_manager:add(Washer.new({
|
|
||||||
identifier = "bathroom_washer",
|
|
||||||
topic = h.mqtt_z2m("bathroom/washer"),
|
|
||||||
client = mqtt_client,
|
|
||||||
threshold = 1,
|
|
||||||
event_channel = automation.device_manager:event_channel(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
automation.device_manager:add(IkeaOutlet.new({
|
|
||||||
outlet_type = "Charger",
|
|
||||||
name = "Charger",
|
|
||||||
room = "Workbench",
|
|
||||||
topic = h.mqtt_z2m("workbench/charger"),
|
|
||||||
client = mqtt_client,
|
|
||||||
timeout = debug and 5 or 20 * 3600,
|
|
||||||
}))
|
|
||||||
|
|
||||||
automation.device_manager:add(IkeaOutlet.new({
|
|
||||||
name = "Outlet",
|
|
||||||
room = "Workbench",
|
|
||||||
topic = h.mqtt_z2m("workbench/outlet"),
|
|
||||||
client = mqtt_client,
|
|
||||||
}))
|
|
||||||
|
|
||||||
local hallway_lights = HueGroup.new({
|
|
||||||
identifier = "hallway_lights",
|
|
||||||
ip = hue_ip,
|
|
||||||
login = hue_token,
|
|
||||||
group_id = 81,
|
|
||||||
scene_id = "3qWKxGVadXFFG4o",
|
|
||||||
timer_id = 1,
|
|
||||||
client = mqtt_client,
|
|
||||||
})
|
|
||||||
automation.device_manager:add(hallway_lights)
|
|
||||||
automation.device_manager:add(IkeaRemote.new({
|
|
||||||
name = "Remote",
|
|
||||||
room = "Hallway",
|
|
||||||
client = mqtt_client,
|
|
||||||
topic = h.mqtt_z2m("hallway/remote"),
|
|
||||||
callback = function(on)
|
|
||||||
hallway_lights:set_on(on)
|
|
||||||
end,
|
|
||||||
}))
|
|
||||||
|
|
||||||
automation.device_manager:add(ContactSensor.new({
|
|
||||||
identifier = "hallway_frontdoor",
|
|
||||||
topic = h.mqtt_z2m("hallway/frontdoor"),
|
|
||||||
client = mqtt_client,
|
|
||||||
presence = {
|
|
||||||
topic = h.mqtt_automation("presence/contact/frontdoor"),
|
|
||||||
timeout = debug and 10 or 15 * 60,
|
|
||||||
},
|
|
||||||
trigger = {
|
|
||||||
devices = { hallway_lights },
|
|
||||||
timeout = debug and 10 or 2 * 60,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
automation.device_manager:add(ContactSensor.new({
|
|
||||||
identifier = "hallway_trash",
|
|
||||||
topic = h.mqtt_z2m("hallway/trash"),
|
|
||||||
client = mqtt_client,
|
|
||||||
trigger = {
|
|
||||||
devices = { hallway_lights },
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
local bedroom_air_filter = AirFilter.new({
|
|
||||||
name = "Air Filter",
|
|
||||||
room = "Bedroom",
|
|
||||||
topic = "pico/filter/bedroom",
|
|
||||||
client = mqtt_client,
|
|
||||||
})
|
|
||||||
automation.device_manager:add(bedroom_air_filter)
|
|
||||||
|
|
||||||
automation.device_manager:schedule("0 0 19 * * *", function()
|
|
||||||
bedroom_air_filter:set_on(true)
|
|
||||||
end)
|
|
||||||
automation.device_manager:schedule("0 0 20 * * *", function()
|
|
||||||
bedroom_air_filter:set_on(false)
|
|
||||||
end)
|
|
|
@ -6,13 +6,12 @@ edition = "2021"
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
automation_cast = { path = "../../automation_cast/" }
|
automation_cast = { workspace = true }
|
||||||
google_home_macro = { path = "../google_home_macro/" }
|
google_home_macro = { workspace = true }
|
||||||
serde = { version = "1.0.149", features = ["derive"] }
|
serde = { workspace = true }
|
||||||
serde_json = "1.0.89"
|
serde_json = { workspace = true }
|
||||||
thiserror = "1.0.37"
|
thiserror = { workspace = true }
|
||||||
tokio = { version = "1", features = ["sync", "full"] }
|
tokio = { workspace = true }
|
||||||
async-trait = "0.1.61"
|
async-trait = { workspace = true }
|
||||||
futures = "0.3.25"
|
futures = { workspace = true }
|
||||||
anyhow = "1.0.75"
|
json_value_merge = { workspace = true }
|
||||||
json_value_merge = "2.0.0"
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ pub trait Device: DeviceFulfillment {
|
||||||
fn get_device_type(&self) -> Type;
|
fn get_device_type(&self) -> Type;
|
||||||
fn get_device_name(&self) -> Name;
|
fn get_device_name(&self) -> Name;
|
||||||
fn get_id(&self) -> String;
|
fn get_id(&self) -> String;
|
||||||
fn is_online(&self) -> bool;
|
async fn is_online(&self) -> bool;
|
||||||
|
|
||||||
// Default values that can optionally be overridden
|
// Default values that can optionally be overridden
|
||||||
fn will_report_state(&self) -> bool {
|
fn will_report_state(&self) -> bool {
|
||||||
|
@ -37,29 +37,39 @@ pub trait Device: DeviceFulfillment {
|
||||||
}
|
}
|
||||||
device.device_info = self.get_device_info();
|
device.device_info = self.get_device_info();
|
||||||
|
|
||||||
let (traits, attributes) = DeviceFulfillment::sync(self).await.unwrap();
|
// TODO: Return the appropriate error
|
||||||
|
if let Ok((traits, attributes)) = DeviceFulfillment::sync(self).await {
|
||||||
device.traits = traits;
|
device.traits = traits;
|
||||||
device.attributes = attributes;
|
device.attributes = attributes;
|
||||||
|
}
|
||||||
|
|
||||||
device
|
device
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn query(&self) -> response::query::Device {
|
async fn query(&self) -> response::query::Device {
|
||||||
let mut device = response::query::Device::new();
|
let mut device = response::query::Device::new();
|
||||||
if !self.is_online() {
|
if !self.is_online().await {
|
||||||
device.set_offline();
|
device.set_offline();
|
||||||
}
|
}
|
||||||
|
|
||||||
device.state = DeviceFulfillment::query(self).await.unwrap();
|
// TODO: Return the appropriate error
|
||||||
|
if let Ok(state) = DeviceFulfillment::query(self).await {
|
||||||
|
device.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
device
|
device
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, command: Command) -> Result<(), ErrorCode> {
|
async fn execute(&self, command: Command) -> Result<(), ErrorCode> {
|
||||||
DeviceFulfillment::execute(self, command.clone())
|
// TODO: Do something with the return value, or just get rut of the return value?
|
||||||
|
if DeviceFulfillment::execute(self, command.clone())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.is_err()
|
||||||
|
{
|
||||||
|
return Err(ErrorCode::DeviceError(
|
||||||
|
crate::errors::DeviceError::TransientError,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -140,7 +140,7 @@ impl GoogleHome {
|
||||||
if let Some(device) = devices.get(id.as_str())
|
if let Some(device) = devices.get(id.as_str())
|
||||||
&& let Some(device) = device.as_ref().cast()
|
&& let Some(device) = device.as_ref().cast()
|
||||||
{
|
{
|
||||||
if !device.is_online() {
|
if !device.is_online().await {
|
||||||
return (id, Ok(false));
|
return (id, Ok(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,18 @@ traits! {
|
||||||
async fn on(&self) -> Result<bool, ErrorCode>,
|
async fn on(&self) -> Result<bool, ErrorCode>,
|
||||||
"action.devices.commands.OnOff" => async fn set_on(&self, on: bool) -> Result<(), ErrorCode>,
|
"action.devices.commands.OnOff" => async fn set_on(&self, on: bool) -> Result<(), ErrorCode>,
|
||||||
},
|
},
|
||||||
|
"action.devices.traits.OpenClose" => trait OpenClose {
|
||||||
|
discrete_only_open_close: Option<bool>,
|
||||||
|
command_only_open_close: Option<bool>,
|
||||||
|
query_only_open_close: Option<bool>,
|
||||||
|
async fn open_percent(&self) -> Result<u8, ErrorCode>,
|
||||||
|
"action.devices.commands.OpenClose" => async fn set_open_percent(&self, open_percent: u8) -> Result<(), ErrorCode>,
|
||||||
|
},
|
||||||
|
"action.devices.traits.Brightness" => trait Brightness {
|
||||||
|
command_only_brightness: Option<bool>,
|
||||||
|
async fn brightness(&self) -> Result<u8, ErrorCode>,
|
||||||
|
"action.devices.commands.BrightnessAbsolute" => async fn set_brightness(&self, brightness: u8) -> Result<(), ErrorCode>,
|
||||||
|
},
|
||||||
"action.devices.traits.Scene" => trait Scene {
|
"action.devices.traits.Scene" => trait Scene {
|
||||||
scene_reversible: Option<bool>,
|
scene_reversible: Option<bool>,
|
||||||
|
|
||||||
|
@ -40,7 +52,7 @@ traits! {
|
||||||
// TODO: Add rename
|
// TODO: Add rename
|
||||||
temperatureUnitForUX: TemperatureUnit,
|
temperatureUnitForUX: TemperatureUnit,
|
||||||
|
|
||||||
async fn temperature_ambient_celsius(&self) -> f32,
|
async fn temperature_ambient_celsius(&self) -> Result<f32, ErrorCode>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,4 +12,10 @@ pub enum Type {
|
||||||
Scene,
|
Scene,
|
||||||
#[serde(rename = "action.devices.types.AIRPURIFIER")]
|
#[serde(rename = "action.devices.types.AIRPURIFIER")]
|
||||||
AirPurifier,
|
AirPurifier,
|
||||||
|
#[serde(rename = "action.devices.types.DOOR")]
|
||||||
|
Door,
|
||||||
|
#[serde(rename = "action.devices.types.WINDOW")]
|
||||||
|
Window,
|
||||||
|
#[serde(rename = "action.devices.types.DRAWER")]
|
||||||
|
Drawer,
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,6 @@ edition = "2021"
|
||||||
proc-macro = true
|
proc-macro = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
proc-macro2 = "1.0.81"
|
proc-macro2 = { workspace = true }
|
||||||
quote = "1.0.36"
|
quote = { workspace = true }
|
||||||
syn = { version = "2.0.60", features = ["extra-traits", "full"] }
|
syn = { workspace = true }
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "nightly-2024-07-25"
|
channel = "nightly-2024-12-06"
|
||||||
components = ["rustfmt", "clippy", "rust-analyzer"]
|
components = ["rustfmt", "clippy", "rust-analyzer"]
|
||||||
profile = "minimal"
|
profile = "minimal"
|
||||||
|
|
|
@ -1,39 +0,0 @@
|
||||||
use std::marker::PhantomData;
|
|
||||||
|
|
||||||
use mlua::{FromLua, IntoLua};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ActionCallback<T> {
|
|
||||||
uuid: uuid::Uuid,
|
|
||||||
lua: mlua::Lua,
|
|
||||||
phantom: PhantomData<T>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> FromLua for ActionCallback<T> {
|
|
||||||
fn from_lua(value: mlua::Value, lua: &mlua::Lua) -> mlua::Result<Self> {
|
|
||||||
let uuid = uuid::Uuid::new_v4();
|
|
||||||
lua.set_named_registry_value(&uuid.to_string(), value)?;
|
|
||||||
|
|
||||||
Ok(ActionCallback {
|
|
||||||
uuid,
|
|
||||||
lua: lua.clone(),
|
|
||||||
phantom: PhantomData::<T>,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Return proper error here
|
|
||||||
impl<T> ActionCallback<T>
|
|
||||||
where
|
|
||||||
T: IntoLua + Sync + Send + Clone + Copy + 'static,
|
|
||||||
{
|
|
||||||
pub async fn call(&self, state: T) {
|
|
||||||
let uuid = self.uuid;
|
|
||||||
|
|
||||||
let callback: mlua::Value = self.lua.named_registry_value(&uuid.to_string()).unwrap();
|
|
||||||
match callback {
|
|
||||||
mlua::Value::Function(f) => f.call_async::<()>(state).await.unwrap(),
|
|
||||||
_ => todo!("Only functions are currently supported"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,227 +0,0 @@
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use automation_macro::LuaDeviceConfig;
|
|
||||||
use google_home::device;
|
|
||||||
use google_home::errors::ErrorCode;
|
|
||||||
use google_home::traits::{self, OnOff};
|
|
||||||
use google_home::types::Type;
|
|
||||||
use rumqttc::{matches, Publish};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
use tracing::{debug, error, trace, warn};
|
|
||||||
|
|
||||||
use super::LuaDeviceCreate;
|
|
||||||
use crate::config::{InfoConfig, MqttDeviceConfig};
|
|
||||||
use crate::devices::Device;
|
|
||||||
use crate::event::{OnMqtt, OnPresence};
|
|
||||||
use crate::messages::OnOffMessage;
|
|
||||||
use crate::mqtt::WrappedAsyncClient;
|
|
||||||
use crate::traits::Timeout;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Copy)]
|
|
||||||
pub enum OutletType {
|
|
||||||
Outlet,
|
|
||||||
Kettle,
|
|
||||||
Charger,
|
|
||||||
Light,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
|
||||||
pub struct Config {
|
|
||||||
#[device_config(flatten)]
|
|
||||||
pub info: InfoConfig,
|
|
||||||
#[device_config(flatten)]
|
|
||||||
pub mqtt: MqttDeviceConfig,
|
|
||||||
#[device_config(default(OutletType::Outlet))]
|
|
||||||
pub outlet_type: OutletType,
|
|
||||||
#[device_config(default, with(|t: Option<_>| t.map(Duration::from_secs)))]
|
|
||||||
pub timeout: Option<Duration>,
|
|
||||||
|
|
||||||
#[device_config(from_lua)]
|
|
||||||
pub client: WrappedAsyncClient,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct State {
|
|
||||||
last_known_state: bool,
|
|
||||||
handle: Option<JoinHandle<()>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct IkeaOutlet {
|
|
||||||
config: Config,
|
|
||||||
|
|
||||||
state: Arc<RwLock<State>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IkeaOutlet {
|
|
||||||
async fn state(&self) -> RwLockReadGuard<State> {
|
|
||||||
self.state.read().await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn state_mut(&self) -> RwLockWriteGuard<State> {
|
|
||||||
self.state.write().await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl LuaDeviceCreate for IkeaOutlet {
|
|
||||||
type Config = Config;
|
|
||||||
type Error = rumqttc::ClientError;
|
|
||||||
|
|
||||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
|
||||||
trace!(id = config.info.identifier(), "Setting up IkeaOutlet");
|
|
||||||
|
|
||||||
config
|
|
||||||
.client
|
|
||||||
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let state = State {
|
|
||||||
last_known_state: false,
|
|
||||||
handle: None,
|
|
||||||
};
|
|
||||||
let state = Arc::new(RwLock::new(state));
|
|
||||||
|
|
||||||
Ok(Self { config, state })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Device for IkeaOutlet {
|
|
||||||
fn get_id(&self) -> String {
|
|
||||||
self.config.info.identifier()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl OnMqtt for IkeaOutlet {
|
|
||||||
async fn on_mqtt(&self, message: Publish) {
|
|
||||||
// Check if the message is from the deviec itself or from a remote
|
|
||||||
if matches(&message.topic, &self.config.mqtt.topic) {
|
|
||||||
// Update the internal state based on what the device has reported
|
|
||||||
let state = match OnOffMessage::try_from(message) {
|
|
||||||
Ok(state) => state.state(),
|
|
||||||
Err(err) => {
|
|
||||||
error!(id = Device::get_id(self), "Failed to parse message: {err}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// No need to do anything if the state has not changed
|
|
||||||
if state == self.state().await.last_known_state {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Abort any timer that is currently running
|
|
||||||
self.stop_timeout().await.unwrap();
|
|
||||||
|
|
||||||
debug!(id = Device::get_id(self), "Updating state to {state}");
|
|
||||||
self.state_mut().await.last_known_state = state;
|
|
||||||
|
|
||||||
// If this is a kettle start a timeout for turning it of again
|
|
||||||
if state && let Some(timeout) = self.config.timeout {
|
|
||||||
self.start_timeout(timeout).await.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl OnPresence for IkeaOutlet {
|
|
||||||
async fn on_presence(&self, presence: bool) {
|
|
||||||
// Turn off the outlet when we leave the house (Not if it is a battery charger)
|
|
||||||
if !presence && self.config.outlet_type != OutletType::Charger {
|
|
||||||
debug!(id = Device::get_id(self), "Turning device off");
|
|
||||||
self.set_on(false).await.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl google_home::Device for IkeaOutlet {
|
|
||||||
fn get_device_type(&self) -> Type {
|
|
||||||
match self.config.outlet_type {
|
|
||||||
OutletType::Outlet => Type::Outlet,
|
|
||||||
OutletType::Kettle => Type::Kettle,
|
|
||||||
OutletType::Light => Type::Light, // Find a better device type for this, ideally would like to use charger, but that needs more work
|
|
||||||
OutletType::Charger => Type::Outlet, // Find a better device type for this, ideally would like to use charger, but that needs more work
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_device_name(&self) -> device::Name {
|
|
||||||
device::Name::new(&self.config.info.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_id(&self) -> String {
|
|
||||||
Device::get_id(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_online(&self) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_room_hint(&self) -> Option<&str> {
|
|
||||||
self.config.info.room.as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn will_report_state(&self) -> bool {
|
|
||||||
// TODO: Implement state reporting
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl traits::OnOff for IkeaOutlet {
|
|
||||||
async fn on(&self) -> Result<bool, ErrorCode> {
|
|
||||||
Ok(self.state().await.last_known_state)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn set_on(&self, on: bool) -> Result<(), ErrorCode> {
|
|
||||||
let message = OnOffMessage::new(on);
|
|
||||||
|
|
||||||
let topic = format!("{}/set", self.config.mqtt.topic);
|
|
||||||
// TODO: Handle potential errors here
|
|
||||||
self.config
|
|
||||||
.client
|
|
||||||
.publish(
|
|
||||||
&topic,
|
|
||||||
rumqttc::QoS::AtLeastOnce,
|
|
||||||
false,
|
|
||||||
serde_json::to_string(&message).unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|err| warn!("Failed to update state on {topic}: {err}"))
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl crate::traits::Timeout for IkeaOutlet {
|
|
||||||
async fn start_timeout(&self, timeout: Duration) -> Result<()> {
|
|
||||||
// Abort any timer that is currently running
|
|
||||||
self.stop_timeout().await?;
|
|
||||||
|
|
||||||
let device = self.clone();
|
|
||||||
self.state_mut().await.handle = Some(tokio::spawn(async move {
|
|
||||||
debug!(id = device.get_id(), "Starting timeout ({timeout:?})...");
|
|
||||||
tokio::time::sleep(timeout).await;
|
|
||||||
debug!(id = device.get_id(), "Turning outlet off!");
|
|
||||||
device.set_on(false).await.unwrap();
|
|
||||||
}));
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn stop_timeout(&self) -> Result<()> {
|
|
||||||
if let Some(handle) = self.state_mut().await.handle.take() {
|
|
||||||
handle.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
41
src/main.rs
41
src/main.rs
|
@ -1,13 +1,16 @@
|
||||||
|
mod web;
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process;
|
use std::process;
|
||||||
|
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
use automation::auth::User;
|
use automation_lib::config::{FulfillmentConfig, MqttConfig};
|
||||||
use automation::config::{FulfillmentConfig, MqttConfig};
|
use automation_lib::device_manager::DeviceManager;
|
||||||
use automation::device_manager::DeviceManager;
|
use automation_lib::helpers;
|
||||||
use automation::devices;
|
use automation_lib::mqtt::{self, WrappedAsyncClient};
|
||||||
use automation::error::ApiError;
|
use automation_lib::ntfy::Ntfy;
|
||||||
use automation::mqtt::{self, WrappedAsyncClient};
|
use automation_lib::presence::Presence;
|
||||||
use axum::extract::{FromRef, State};
|
use axum::extract::{FromRef, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::routing::post;
|
use axum::routing::post;
|
||||||
|
@ -16,7 +19,9 @@ use dotenvy::dotenv;
|
||||||
use google_home::{GoogleHome, Request, Response};
|
use google_home::{GoogleHome, Request, Response};
|
||||||
use mlua::LuaSerdeExt;
|
use mlua::LuaSerdeExt;
|
||||||
use rumqttc::AsyncClient;
|
use rumqttc::AsyncClient;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
use web::{ApiError, User};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct AppState {
|
struct AppState {
|
||||||
|
@ -111,17 +116,16 @@ async fn app() -> anyhow::Result<()> {
|
||||||
|
|
||||||
lua.globals().set("automation", automation)?;
|
lua.globals().set("automation", automation)?;
|
||||||
|
|
||||||
devices::register_with_lua(&lua)?;
|
automation_devices::register_with_lua(&lua)?;
|
||||||
|
helpers::register_with_lua(&lua)?;
|
||||||
let config_dir = std::env::var("AUTOMATION_CONFIG").unwrap_or("./config".into());
|
lua.globals().set("Ntfy", lua.create_proxy::<Ntfy>()?)?;
|
||||||
let config_main = Path::new(&config_dir).join("main.lua");
|
|
||||||
lua.globals()
|
lua.globals()
|
||||||
.get::<mlua::Table>("package")
|
.set("Presence", lua.create_proxy::<Presence>()?)?;
|
||||||
.unwrap()
|
|
||||||
.set("path", format!("{}/?.lua;", config_dir))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
match lua.load(config_main).exec_async().await {
|
// TODO: Make this not hardcoded
|
||||||
|
let config_filename = std::env::var("AUTOMATION_CONFIG").unwrap_or("./config.lua".into());
|
||||||
|
let config_path = Path::new(&config_filename);
|
||||||
|
match lua.load(config_path).exec_async().await {
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
println!("{error}");
|
println!("{error}");
|
||||||
Err(error)
|
Err(error)
|
||||||
|
@ -152,11 +156,10 @@ async fn app() -> anyhow::Result<()> {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start the web server
|
// Start the web server
|
||||||
let addr = fulfillment_config.into();
|
let addr: SocketAddr = fulfillment_config.into();
|
||||||
info!("Server started on http://{addr}");
|
info!("Server started on http://{addr}");
|
||||||
axum::Server::try_bind(&addr)?
|
let listener = TcpListener::bind(addr).await?;
|
||||||
.serve(app.into_make_service())
|
axum::serve(listener, app).await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait Timeout: Sync + Send {
|
|
||||||
async fn start_timeout(&self, _timeout: Duration) -> Result<()>;
|
|
||||||
async fn stop_timeout(&self) -> Result<()>;
|
|
||||||
}
|
|
|
@ -1,10 +1,78 @@
|
||||||
|
use std::result;
|
||||||
|
|
||||||
use axum::async_trait;
|
use axum::async_trait;
|
||||||
use axum::extract::{FromRef, FromRequestParts};
|
use axum::extract::{FromRef, FromRequestParts};
|
||||||
use axum::http::request::Parts;
|
use axum::http::request::Parts;
|
||||||
|
use axum::http::status::InvalidStatusCode;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use serde::Deserialize;
|
use axum::response::IntoResponse;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::error::{ApiError, ApiErrorJson};
|
#[derive(Debug, Error)]
|
||||||
|
#[error("{source}")]
|
||||||
|
pub struct ApiError {
|
||||||
|
status_code: axum::http::StatusCode,
|
||||||
|
source: Box<dyn std::error::Error>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiError {
|
||||||
|
pub fn new(status_code: axum::http::StatusCode, source: Box<dyn std::error::Error>) -> Self {
|
||||||
|
Self {
|
||||||
|
status_code,
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ApiError> for ApiErrorJson {
|
||||||
|
fn from(value: ApiError) -> Self {
|
||||||
|
let error = ApiErrorJsonError {
|
||||||
|
code: value.status_code.as_u16(),
|
||||||
|
status: value.status_code.to_string(),
|
||||||
|
reason: value.source.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Self { error }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
(
|
||||||
|
self.status_code,
|
||||||
|
serde_json::to_string::<ApiErrorJson>(&self.into())
|
||||||
|
.expect("Serialization should not fail"),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct ApiErrorJsonError {
|
||||||
|
code: u16,
|
||||||
|
status: String,
|
||||||
|
reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ApiErrorJson {
|
||||||
|
error: ApiErrorJsonError,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<ApiErrorJson> for ApiError {
|
||||||
|
type Error = InvalidStatusCode;
|
||||||
|
|
||||||
|
fn try_from(value: ApiErrorJson) -> result::Result<Self, Self::Error> {
|
||||||
|
let status_code = axum::http::StatusCode::from_u16(value.error.code)?;
|
||||||
|
let source = value.error.reason.into();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
status_code,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
|
@ -25,6 +93,8 @@ where
|
||||||
|
|
||||||
// Create a request to the auth server
|
// Create a request to the auth server
|
||||||
// TODO: Do some discovery to find the correct url for this instead of assuming
|
// TODO: Do some discovery to find the correct url for this instead of assuming
|
||||||
|
// TODO: I think we can also just run Authlia in front of the endpoint instead
|
||||||
|
// This would then give us a header containing the logged in user info?
|
||||||
let mut req = reqwest::Client::new().get(format!("{}/userinfo", openid_url));
|
let mut req = reqwest::Client::new().get(format!("{}/userinfo", openid_url));
|
||||||
|
|
||||||
// Add auth header to the request if it exists
|
// Add auth header to the request if it exists
|
Loading…
Reference in New Issue
Block a user