Compare commits
63 Commits
feature/lu
...
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 | |||
4bb49a381b | |||
a353ba3d08 | |||
157bbf923f | |||
9719c46136 | |||
8b04435537 | |||
ae2c27551f | |||
d11e79cdfa | |||
b0467b8012 | |||
3105c266b0 | |||
88e31699ad | |||
23e78fe5a7 | |||
14e14ca479 | |||
3fd8dddeb2 | |||
6c797820dc | |||
2cf4e40ad5 | |||
98ab265fed | |||
006320be18 | |||
3b8f15eb88 | |||
f7b709a2c7 | |||
bab85a092e | |||
01dfc6b81e | |||
758500a071 | |||
9aa16e3ef8 | |||
d84ff8ec8e | |||
fb7af4a8b1 | |||
c6e63750d0 | |||
5bf6e6bc3c | |||
526c82096c | |||
32aa981e31 | |||
d8d348d906 | |||
6ed1ee6ebc |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
*.xcf filter=lfs diff=lfs merge=lfs -text
|
|
@ -1,5 +1,5 @@
|
|||
# Based on: https://pastebin.com/99Fq2b2w
|
||||
name: Build and deploy automation_rs
|
||||
name: Build and deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
|
@ -8,7 +8,7 @@ on:
|
|||
|
||||
jobs:
|
||||
build:
|
||||
name: Build automation_rs
|
||||
name: Build application
|
||||
runs-on: ubuntu-latest
|
||||
container: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
|
@ -21,61 +21,18 @@ jobs:
|
|||
rustflags: ""
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release --all-targets
|
||||
run: cargo build --release
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: build
|
||||
name: automation
|
||||
path: target/x86_64-unknown-linux-gnu/release/automation
|
||||
|
||||
check:
|
||||
name: Run pre-commit checks
|
||||
runs-on: ubuntu-latest
|
||||
# The lua definition check require that generate_definitions is build first
|
||||
needs: [build]
|
||||
container: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ">=1.20.1"
|
||||
- name: Calculate cache hash
|
||||
uses: https://gitea.com/actions/go-hashfiles@v0.0.1
|
||||
id: hash-go
|
||||
with:
|
||||
patterns: |
|
||||
.pre-commit-config.yaml
|
||||
- name: Setup cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pre-commit
|
||||
key: pre-commit-3|${{ steps.hash-go.outputs.hash }}
|
||||
|
||||
- name: Install pre-commit
|
||||
run: python -m pip install pre-commit
|
||||
shell: bash
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
rustflags: ""
|
||||
|
||||
- name: Set rustup default
|
||||
run: rustup default stable
|
||||
shell: bash
|
||||
|
||||
- name: Run pre-commit
|
||||
run: pre-commit run --show-diff-on-failure --color=always --all-files
|
||||
shell: bash
|
||||
|
||||
container:
|
||||
name: Build Docker image
|
||||
name: Build container
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, checks]
|
||||
needs: [build]
|
||||
container: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
@ -84,12 +41,23 @@ jobs:
|
|||
- name: Download artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: build
|
||||
name: automation
|
||||
|
||||
- name: Set permissions
|
||||
run: |
|
||||
chown 65532:65532 ./build/*
|
||||
chmod 0755 ./build/*
|
||||
chown 65532:65532 ./automation
|
||||
chmod 0755 ./automation
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: https://github.com/docker/metadata-action@v5
|
||||
with:
|
||||
images: git.huizinga.dev/dreaded_x/automation_rs
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Login to registry
|
||||
uses: https://github.com/docker/login-action@v3
|
||||
|
@ -102,12 +70,12 @@ jobs:
|
|||
uses: https://github.com/docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ gitea.ref == 'refs/heads/master' }}
|
||||
# TODO: Automatically add the correct tags here
|
||||
tags: git.huizinga.dev/dreaded_x/automation_rs:latest
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
deploy:
|
||||
name: Deploy Docker container
|
||||
name: Deploy container
|
||||
runs-on: ubuntu-latest
|
||||
container: catthehacker/ubuntu:act-latest
|
||||
needs: [container]
|
||||
|
@ -129,7 +97,7 @@ jobs:
|
|||
-e MQTT_PASSWORD=${{ secrets.MQTT_PASSWORD }} \
|
||||
-e HUE_TOKEN=${{ secrets.HUE_TOKEN }} \
|
||||
-e NTFY_TOPIC=${{ secrets.NTFY_TOPIC }} \
|
||||
git.huizinga.dev/dreaded_x/automation_rs:latest
|
||||
git.huizinga.dev/dreaded_x/automation_rs:master
|
||||
|
||||
docker network connect web automation_rs
|
||||
|
||||
|
|
|
@ -30,13 +30,3 @@ repos:
|
|||
rev: v0.1.0
|
||||
hooks:
|
||||
- id: dockerfilelint
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: lua-definitions
|
||||
name: Generate lua definitions
|
||||
description: This hook generates up to date lua definitions.
|
||||
entry: cargo run --release --bin generate_definitions
|
||||
language: system
|
||||
types: [rust]
|
||||
pass_filenames: false
|
||||
|
|
1392
Cargo.lock
generated
1392
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
103
Cargo.toml
103
Cargo.toml
|
@ -2,52 +2,87 @@
|
|||
name = "automation"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
default-run = "automation"
|
||||
|
||||
[workspace]
|
||||
members = ["google-home", "automation_macro", "automation_cast"]
|
||||
members = [
|
||||
"automation_macro",
|
||||
"automation_cast",
|
||||
"google_home/google_home",
|
||||
"google_home/google_home_macro",
|
||||
"automation_devices",
|
||||
"automation_lib",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
[workspace.dependencies]
|
||||
mlua = { version = "0.10.1", features = [
|
||||
"lua54",
|
||||
"vendored",
|
||||
"macros",
|
||||
"serialize",
|
||||
"async",
|
||||
"send",
|
||||
] }
|
||||
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" }
|
||||
paste = "1.0.10"
|
||||
automation_cast = { path = "./automation_cast" }
|
||||
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"
|
||||
reqwest = { version = "0.11.13", features = [
|
||||
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"
|
||||
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
|
||||
axum = "0.6.1"
|
||||
serde = { version = "1.0.149", features = ["derive"] }
|
||||
serde_json = "1.0.89"
|
||||
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.9.7", features = ["lua54", "vendored", "macros", "serialize", "async", "send"] }
|
||||
once_cell = "1.19.0"
|
||||
hostname = "0.4.0"
|
||||
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]
|
||||
wakey = { git = "https://git.huizinga.dev/Dreaded_X/wakey" }
|
||||
|
|
|
@ -3,6 +3,6 @@ FROM gcr.io/distroless/cc-debian12:nonroot
|
|||
ENV AUTOMATION_CONFIG=/app/config.lua
|
||||
COPY ./config.lua /app/config.lua
|
||||
|
||||
COPY ./build/automation /app/automation
|
||||
COPY ./automation /app/automation
|
||||
|
||||
CMD ["/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.
|
@ -6,7 +6,6 @@ use std::marker::Unsize;
|
|||
|
||||
pub trait Cast<P: ?Sized> {
|
||||
fn cast(&self) -> Option<&P>;
|
||||
fn cast_mut(&mut self) -> Option<&mut P>;
|
||||
}
|
||||
|
||||
impl<D, P> Cast<P> for D
|
||||
|
@ -16,10 +15,6 @@ where
|
|||
default fn cast(&self) -> Option<&P> {
|
||||
None
|
||||
}
|
||||
|
||||
default fn cast_mut(&mut self) -> Option<&mut P> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl<D, P> Cast<P> for D
|
||||
|
@ -30,8 +25,4 @@ where
|
|||
fn cast(&self) -> Option<&P> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn cast_mut(&mut self) -> Option<&mut P> {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
|
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 }
|
226
automation_devices/src/air_filter.rs
Normal file
226
automation_devices/src/air_filter.rs
Normal file
|
@ -0,0 +1,226 @@
|
|||
use async_trait::async_trait;
|
||||
use automation_lib::config::InfoConfig;
|
||||
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||
use automation_macro::LuaDeviceConfig;
|
||||
use google_home::device::Name;
|
||||
use google_home::errors::ErrorCode;
|
||||
use google_home::traits::{
|
||||
AvailableSpeeds, FanSpeed, HumiditySetting, OnOff, Speed, SpeedValue, TemperatureSetting,
|
||||
TemperatureUnit,
|
||||
};
|
||||
use google_home::types::Type;
|
||||
use thiserror::Error;
|
||||
use tracing::{debug, trace};
|
||||
|
||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||
pub struct Config {
|
||||
#[device_config(flatten)]
|
||||
pub info: InfoConfig,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AirFilter {
|
||||
config: Config,
|
||||
}
|
||||
|
||||
#[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 {
|
||||
async fn set_fan_speed(&self, speed: air_filter_types::FanSpeed) -> Result<(), Error> {
|
||||
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?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_fan_state(&self) -> Result<air_filter_types::FanState, Error> {
|
||||
let url = format!("{}/state/fan", self.config.url);
|
||||
Ok(reqwest::get(url).await?.json().await?)
|
||||
}
|
||||
|
||||
async fn get_sensor_data(&self) -> Result<air_filter_types::SensorData, Error> {
|
||||
let url = format!("{}/state/sensor", self.config.url);
|
||||
Ok(reqwest::get(url).await?.json().await?)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for AirFilter {
|
||||
type Config = Config;
|
||||
type Error = rumqttc::ClientError;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
trace!(id = config.info.identifier(), "Setting up AirFilter");
|
||||
|
||||
Ok(Self { config })
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for AirFilter {
|
||||
fn get_id(&self) -> String {
|
||||
self.config.info.identifier()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl google_home::Device for AirFilter {
|
||||
fn get_device_type(&self) -> Type {
|
||||
Type::AirPurifier
|
||||
}
|
||||
|
||||
fn get_device_name(&self) -> Name {
|
||||
Name::new(&self.config.info.name)
|
||||
}
|
||||
|
||||
fn get_id(&self) -> String {
|
||||
Device::get_id(self)
|
||||
}
|
||||
|
||||
async fn is_online(&self) -> bool {
|
||||
self.get_sensor_data().await.is_ok()
|
||||
}
|
||||
|
||||
fn get_room_hint(&self) -> Option<&str> {
|
||||
self.config.info.room.as_deref()
|
||||
}
|
||||
|
||||
fn will_report_state(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnOff for AirFilter {
|
||||
async fn on(&self) -> Result<bool, ErrorCode> {
|
||||
Ok(self.get_fan_state().await?.speed != air_filter_types::FanSpeed::Off)
|
||||
}
|
||||
|
||||
async fn set_on(&self, on: bool) -> Result<(), ErrorCode> {
|
||||
debug!("Turning on air filter: {on}");
|
||||
|
||||
if on {
|
||||
self.set_fan_speed(air_filter_types::FanSpeed::High).await?;
|
||||
} else {
|
||||
self.set_fan_speed(air_filter_types::FanSpeed::Off).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FanSpeed for AirFilter {
|
||||
fn available_fan_speeds(&self) -> AvailableSpeeds {
|
||||
AvailableSpeeds {
|
||||
speeds: vec![
|
||||
Speed {
|
||||
speed_name: "off".into(),
|
||||
speed_values: vec![SpeedValue {
|
||||
speed_synonym: vec!["Off".into()],
|
||||
lang: "en".into(),
|
||||
}],
|
||||
},
|
||||
Speed {
|
||||
speed_name: "low".into(),
|
||||
speed_values: vec![SpeedValue {
|
||||
speed_synonym: vec!["Low".into()],
|
||||
lang: "en".into(),
|
||||
}],
|
||||
},
|
||||
Speed {
|
||||
speed_name: "medium".into(),
|
||||
speed_values: vec![SpeedValue {
|
||||
speed_synonym: vec!["Medium".into()],
|
||||
lang: "en".into(),
|
||||
}],
|
||||
},
|
||||
Speed {
|
||||
speed_name: "high".into(),
|
||||
speed_values: vec![SpeedValue {
|
||||
speed_synonym: vec!["High".into()],
|
||||
lang: "en".into(),
|
||||
}],
|
||||
},
|
||||
],
|
||||
ordered: true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn current_fan_speed_setting(&self) -> Result<String, ErrorCode> {
|
||||
let speed = self.get_fan_state().await?.speed;
|
||||
let speed = match speed {
|
||||
air_filter_types::FanSpeed::Off => "off",
|
||||
air_filter_types::FanSpeed::Low => "low",
|
||||
air_filter_types::FanSpeed::Medium => "medium",
|
||||
air_filter_types::FanSpeed::High => "high",
|
||||
};
|
||||
|
||||
Ok(speed.into())
|
||||
}
|
||||
|
||||
async fn set_fan_speed(&self, fan_speed: String) -> Result<(), ErrorCode> {
|
||||
let fan_speed = fan_speed.as_str();
|
||||
let speed = if fan_speed == "off" {
|
||||
air_filter_types::FanSpeed::Off
|
||||
} else if fan_speed == "low" {
|
||||
air_filter_types::FanSpeed::Low
|
||||
} else if fan_speed == "medium" {
|
||||
air_filter_types::FanSpeed::Medium
|
||||
} else if fan_speed == "high" {
|
||||
air_filter_types::FanSpeed::High
|
||||
} else {
|
||||
return Err(google_home::errors::DeviceError::TransientError.into());
|
||||
};
|
||||
|
||||
self.set_fan_speed(speed).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HumiditySetting for AirFilter {
|
||||
fn query_only_humidity_setting(&self) -> Option<bool> {
|
||||
Some(true)
|
||||
}
|
||||
|
||||
async fn humidity_ambient_percent(&self) -> Result<isize, ErrorCode> {
|
||||
Ok(self.get_sensor_data().await?.humidity().round() as isize)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TemperatureSetting for AirFilter {
|
||||
fn query_only_temperature_control(&self) -> Option<bool> {
|
||||
Some(true)
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
fn temperatureUnitForUX(&self) -> TemperatureUnit {
|
||||
TemperatureUnit::Celsius
|
||||
}
|
||||
|
||||
async fn temperature_ambient_celsius(&self) -> Result<f32, ErrorCode> {
|
||||
// HACK: Round to one decimal place
|
||||
Ok((10.0 * self.get_sensor_data().await?.temperature()).round() / 10.0)
|
||||
}
|
||||
}
|
254
automation_devices/src/contact_sensor.rs
Normal file
254
automation_devices/src/contact_sensor.rs
Normal file
|
@ -0,0 +1,254 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
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 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::task::JoinHandle;
|
||||
use tracing::{debug, error, trace, warn};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Copy)]
|
||||
pub enum SensorType {
|
||||
Door,
|
||||
Drawer,
|
||||
Window,
|
||||
}
|
||||
|
||||
// NOTE: If we add more presence devices we might need to move this out of here
|
||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||
pub struct PresenceDeviceConfig {
|
||||
#[device_config(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
#[device_config(with(Duration::from_secs))]
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||
pub struct Config {
|
||||
#[device_config(flatten)]
|
||||
pub info: InfoConfig,
|
||||
#[device_config(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
#[device_config(from_lua, default)]
|
||||
pub presence: Option<PresenceDeviceConfig>,
|
||||
|
||||
#[device_config(default(SensorType::Window))]
|
||||
pub sensor_type: SensorType,
|
||||
|
||||
#[device_config(from_lua, default)]
|
||||
pub callback: ActionCallback<ContactSensor, bool>,
|
||||
#[device_config(from_lua)]
|
||||
pub client: WrappedAsyncClient,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct State {
|
||||
overall_presence: bool,
|
||||
is_closed: bool,
|
||||
handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContactSensor {
|
||||
config: Config,
|
||||
state: Arc<RwLock<State>>,
|
||||
}
|
||||
|
||||
impl ContactSensor {
|
||||
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 ContactSensor {
|
||||
type Config = Config;
|
||||
type Error = DeviceConfigError;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
trace!(id = config.info.identifier(), "Setting up ContactSensor");
|
||||
|
||||
config
|
||||
.client
|
||||
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||
.await?;
|
||||
|
||||
let state = State {
|
||||
overall_presence: DEFAULT_PRESENCE,
|
||||
is_closed: true,
|
||||
handle: None,
|
||||
};
|
||||
let state = Arc::new(RwLock::new(state));
|
||||
|
||||
Ok(Self { config, state })
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for ContactSensor {
|
||||
fn get_id(&self) -> String {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnPresence for ContactSensor {
|
||||
async fn on_presence(&self, presence: bool) {
|
||||
self.state_mut().await.overall_presence = presence;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnMqtt for ContactSensor {
|
||||
async fn on_mqtt(&self, message: rumqttc::Publish) {
|
||||
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
||||
return;
|
||||
}
|
||||
|
||||
let is_closed = match ContactMessage::try_from(message) {
|
||||
Ok(state) => state.is_closed(),
|
||||
Err(err) => {
|
||||
error!(id = self.get_id(), "Failed to parse message: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if is_closed == self.state().await.is_closed {
|
||||
return;
|
||||
}
|
||||
|
||||
self.config.callback.call(self, &!is_closed).await;
|
||||
|
||||
debug!(id = self.get_id(), "Updating state to {is_closed}");
|
||||
self.state_mut().await.is_closed = is_closed;
|
||||
|
||||
// Check if this contact sensor works as a presence device
|
||||
// If not we are done here
|
||||
let presence = match &self.config.presence {
|
||||
Some(presence) => presence.clone(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
if !is_closed {
|
||||
// Activate presence and stop any timeout once we open the door
|
||||
if let Some(handle) = self.state_mut().await.handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
// Only use the door as an presence sensor if there the current presence is set false
|
||||
// This is to prevent the house from being marked as present for however long the
|
||||
// timeout is set when leaving the house
|
||||
if !self.state().await.overall_presence {
|
||||
self.config
|
||||
.client
|
||||
.publish(
|
||||
&presence.mqtt.topic,
|
||||
rumqttc::QoS::AtLeastOnce,
|
||||
false,
|
||||
serde_json::to_string(&PresenceMessage::new(true)).unwrap(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(
|
||||
"Failed to publish presence on {}: {err}",
|
||||
presence.mqtt.topic
|
||||
)
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
} else {
|
||||
// Once the door is closed again we start a timeout for removing the presence
|
||||
let device = self.clone();
|
||||
self.state_mut().await.handle = Some(tokio::spawn(async move {
|
||||
debug!(
|
||||
id = device.get_id(),
|
||||
"Starting timeout ({:?}) for contact sensor...", presence.timeout
|
||||
);
|
||||
tokio::time::sleep(presence.timeout).await;
|
||||
debug!(id = device.get_id(), "Removing door device!");
|
||||
device
|
||||
.config
|
||||
.client
|
||||
.publish(&presence.mqtt.topic, rumqttc::QoS::AtLeastOnce, false, "")
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(
|
||||
"Failed to publish presence on {}: {err}",
|
||||
presence.mqtt.topic
|
||||
)
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,18 +1,16 @@
|
|||
use std::convert::Infallible;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use automation_macro::{LuaDevice, LuaDeviceConfig, LuaTypeDefinition};
|
||||
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 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, LuaTypeDefinition)]
|
||||
pub struct DebugBridgeConfig {
|
||||
#[derive(Debug, LuaDeviceConfig, Clone)]
|
||||
pub struct Config {
|
||||
pub identifier: String,
|
||||
#[device_config(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
|
@ -20,14 +18,14 @@ pub struct DebugBridgeConfig {
|
|||
pub client: WrappedAsyncClient,
|
||||
}
|
||||
|
||||
#[derive(Debug, LuaDevice)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DebugBridge {
|
||||
config: DebugBridgeConfig,
|
||||
config: Config,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for DebugBridge {
|
||||
type Config = DebugBridgeConfig;
|
||||
type Config = Config;
|
||||
type Error = Infallible;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
|
@ -44,7 +42,7 @@ impl Device for DebugBridge {
|
|||
|
||||
#[async_trait]
|
||||
impl OnPresence for DebugBridge {
|
||||
async fn on_presence(&mut self, presence: bool) {
|
||||
async fn on_presence(&self, presence: bool) {
|
||||
let message = PresenceMessage::new(presence);
|
||||
let topic = format!("{}/presence", self.config.mqtt.topic);
|
||||
self.config
|
||||
|
@ -68,7 +66,7 @@ impl OnPresence for DebugBridge {
|
|||
|
||||
#[async_trait]
|
||||
impl OnDarkness for DebugBridge {
|
||||
async fn on_darkness(&mut self, dark: bool) {
|
||||
async fn on_darkness(&self, dark: bool) {
|
||||
let message = DarknessMessage::new(dark);
|
||||
let topic = format!("{}/darkness", self.config.mqtt.topic);
|
||||
self.config
|
|
@ -1,39 +1,37 @@
|
|||
use std::convert::Infallible;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use automation_macro::{LuaDevice, LuaDeviceConfig, LuaTypeDefinition};
|
||||
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||
use automation_lib::event::{OnDarkness, OnPresence};
|
||||
use automation_macro::LuaDeviceConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, trace, warn};
|
||||
|
||||
use super::LuaDeviceCreate;
|
||||
use crate::devices::Device;
|
||||
use crate::event::{OnDarkness, OnPresence};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Flag {
|
||||
Presence,
|
||||
Darkness,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, LuaTypeDefinition)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct FlagIDs {
|
||||
presence: isize,
|
||||
darkness: isize,
|
||||
}
|
||||
|
||||
#[derive(Debug, LuaDeviceConfig, Clone, LuaTypeDefinition)]
|
||||
pub struct HueBridgeConfig {
|
||||
#[derive(Debug, LuaDeviceConfig, Clone)]
|
||||
pub struct Config {
|
||||
pub identifier: String,
|
||||
#[device_config(rename("ip"), from(Ipv4Addr), with(|ip| SocketAddr::new(ip, 80)))]
|
||||
#[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 80)))]
|
||||
pub addr: SocketAddr,
|
||||
pub login: String,
|
||||
pub flags: FlagIDs,
|
||||
}
|
||||
|
||||
#[derive(Debug, LuaDevice)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HueBridge {
|
||||
config: HueBridgeConfig,
|
||||
config: Config,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
@ -43,7 +41,7 @@ struct FlagMessage {
|
|||
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for HueBridge {
|
||||
type Config = HueBridgeConfig;
|
||||
type Config = Config;
|
||||
type Error = Infallible;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Infallible> {
|
||||
|
@ -93,7 +91,7 @@ impl Device for HueBridge {
|
|||
|
||||
#[async_trait]
|
||||
impl OnPresence for HueBridge {
|
||||
async fn on_presence(&mut self, presence: bool) {
|
||||
async fn on_presence(&self, presence: bool) {
|
||||
trace!("Bridging presence to hue");
|
||||
self.set_flag(Flag::Presence, presence).await;
|
||||
}
|
||||
|
@ -101,7 +99,7 @@ impl OnPresence for HueBridge {
|
|||
|
||||
#[async_trait]
|
||||
impl OnDarkness for HueBridge {
|
||||
async fn on_darkness(&mut self, dark: bool) {
|
||||
async fn on_darkness(&self, dark: bool) {
|
||||
trace!("Bridging darkness to hue");
|
||||
self.set_flag(Flag::Darkness, dark).await;
|
||||
}
|
162
automation_devices/src/hue_group.rs
Normal file
162
automation_devices/src/hue_group.rs
Normal file
|
@ -0,0 +1,162 @@
|
|||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use automation_macro::LuaDeviceConfig;
|
||||
use google_home::errors::ErrorCode;
|
||||
use google_home::traits::OnOff;
|
||||
use tracing::{error, trace, warn};
|
||||
|
||||
use super::{Device, LuaDeviceCreate};
|
||||
|
||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||
pub struct Config {
|
||||
pub identifier: String,
|
||||
#[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 80)))]
|
||||
pub addr: SocketAddr,
|
||||
pub login: String,
|
||||
pub group_id: isize,
|
||||
pub scene_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HueGroup {
|
||||
config: Config,
|
||||
}
|
||||
|
||||
// Couple of helper function to get the correct urls
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for HueGroup {
|
||||
type Config = Config;
|
||||
type Error = rumqttc::ClientError;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
trace!(id = config.identifier, "Setting up AudioSetup");
|
||||
|
||||
Ok(Self { config })
|
||||
}
|
||||
}
|
||||
|
||||
impl HueGroup {
|
||||
fn url_base(&self) -> String {
|
||||
format!("http://{}/api/{}", self.config.addr, self.config.login)
|
||||
}
|
||||
|
||||
fn url_set_action(&self) -> String {
|
||||
format!("{}/groups/{}/action", self.url_base(), self.config.group_id)
|
||||
}
|
||||
|
||||
fn url_get_state(&self) -> String {
|
||||
format!("{}/groups/{}", self.url_base(), self.config.group_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for HueGroup {
|
||||
fn get_id(&self) -> String {
|
||||
self.config.identifier.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnOff for HueGroup {
|
||||
async fn set_on(&self, on: bool) -> Result<(), ErrorCode> {
|
||||
let message = if on {
|
||||
message::Action::scene(self.config.scene_id.clone())
|
||||
} else {
|
||||
message::Action::on(false)
|
||||
};
|
||||
|
||||
let res = reqwest::Client::new()
|
||||
.put(self.url_set_action())
|
||||
.json(&message)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(res) => {
|
||||
let status = res.status();
|
||||
if !status.is_success() {
|
||||
warn!(id = self.get_id(), "Status code is not success: {status}");
|
||||
}
|
||||
}
|
||||
Err(err) => error!(id = self.get_id(), "Error: {err}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on(&self) -> Result<bool, ErrorCode> {
|
||||
let res = reqwest::Client::new()
|
||||
.get(self.url_get_state())
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(res) => {
|
||||
let status = res.status();
|
||||
if !status.is_success() {
|
||||
warn!(id = self.get_id(), "Status code is not success: {status}");
|
||||
}
|
||||
|
||||
let on = match res.json::<message::Info>().await {
|
||||
Ok(info) => info.any_on(),
|
||||
Err(err) => {
|
||||
error!(id = self.get_id(), "Failed to parse message: {err}");
|
||||
// TODO: Error code
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
return Ok(on);
|
||||
}
|
||||
Err(err) => error!(id = self.get_id(), "Error: {err}"),
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
mod message {
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Action {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
on: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
scene: Option<String>,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn on(on: bool) -> Self {
|
||||
Self {
|
||||
on: Some(on),
|
||||
scene: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scene(scene: String) -> Self {
|
||||
Self {
|
||||
on: None,
|
||||
scene: Some(scene),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct State {
|
||||
all_on: bool,
|
||||
any_on: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Info {
|
||||
state: State,
|
||||
}
|
||||
|
||||
impl Info {
|
||||
pub fn any_on(&self) -> bool {
|
||||
self.state.any_on
|
||||
}
|
||||
}
|
||||
}
|
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
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
91
automation_devices/src/ikea_remote.rs
Normal file
91
automation_devices/src/ikea_remote.rs
Normal file
|
@ -0,0 +1,91 @@
|
|||
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 axum::async_trait;
|
||||
use rumqttc::{matches, Publish};
|
||||
use tracing::{debug, error, trace};
|
||||
|
||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||
pub struct Config {
|
||||
#[device_config(flatten)]
|
||||
pub info: InfoConfig,
|
||||
|
||||
#[device_config(default)]
|
||||
pub single_button: bool,
|
||||
|
||||
#[device_config(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
|
||||
#[device_config(from_lua)]
|
||||
pub client: WrappedAsyncClient,
|
||||
|
||||
#[device_config(from_lua)]
|
||||
pub callback: ActionCallback<IkeaRemote, bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IkeaRemote {
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl Device for IkeaRemote {
|
||||
fn get_id(&self) -> String {
|
||||
self.config.info.identifier()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for IkeaRemote {
|
||||
type Config = Config;
|
||||
type Error = rumqttc::ClientError;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
trace!(id = config.info.identifier(), "Setting up IkeaRemote");
|
||||
|
||||
config
|
||||
.client
|
||||
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||
.await?;
|
||||
|
||||
Ok(Self { config })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnMqtt for IkeaRemote {
|
||||
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 action = match RemoteMessage::try_from(message) {
|
||||
Ok(message) => message.action(),
|
||||
Err(err) => {
|
||||
error!(id = Device::get_id(self), "Failed to parse message: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
debug!(id = Device::get_id(self), "Remote action = {:?}", action);
|
||||
|
||||
let on = if self.config.single_button {
|
||||
match action {
|
||||
RemoteAction::On => Some(true),
|
||||
RemoteAction::BrightnessMoveUp => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
match action {
|
||||
RemoteAction::On => Some(true),
|
||||
RemoteAction::Off => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(on) = on {
|
||||
self.config.callback.call(self, &on).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,35 +1,35 @@
|
|||
use std::convert::Infallible;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::net::SocketAddr;
|
||||
use std::str::Utf8Error;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use automation_macro::{LuaDevice, LuaDeviceConfig, LuaTypeDefinition};
|
||||
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||
use automation_lib::event::OnPresence;
|
||||
use automation_macro::LuaDeviceConfig;
|
||||
use bytes::{Buf, BufMut};
|
||||
use google_home::errors::{self, DeviceError};
|
||||
use google_home::traits;
|
||||
use google_home::traits::OnOff;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tracing::trace;
|
||||
use tracing::{debug, trace};
|
||||
|
||||
use super::{Device, LuaDeviceCreate};
|
||||
|
||||
#[derive(Debug, Clone, LuaDeviceConfig, LuaTypeDefinition)]
|
||||
pub struct KasaOutletConfig {
|
||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||
pub struct Config {
|
||||
pub identifier: String,
|
||||
#[device_config(rename("ip"), from(Ipv4Addr), with(|ip| SocketAddr::new(ip, 9999)))]
|
||||
#[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 9999)))]
|
||||
pub addr: SocketAddr,
|
||||
}
|
||||
|
||||
#[derive(Debug, LuaDevice)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KasaOutlet {
|
||||
config: KasaOutletConfig,
|
||||
config: Config,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for KasaOutlet {
|
||||
type Config = KasaOutletConfig;
|
||||
type Config = Config;
|
||||
type Error = Infallible;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
|
@ -206,8 +206,8 @@ impl Response {
|
|||
}
|
||||
|
||||
#[async_trait]
|
||||
impl traits::OnOff for KasaOutlet {
|
||||
async fn is_on(&self) -> Result<bool, errors::ErrorCode> {
|
||||
impl OnOff for KasaOutlet {
|
||||
async fn on(&self) -> Result<bool, errors::ErrorCode> {
|
||||
let mut stream = TcpStream::connect(self.config.addr)
|
||||
.await
|
||||
.or::<DeviceError>(Err(DeviceError::DeviceOffline))?;
|
||||
|
@ -241,7 +241,7 @@ impl traits::OnOff for KasaOutlet {
|
|||
.or(Err(DeviceError::TransientError.into()))
|
||||
}
|
||||
|
||||
async fn set_on(&mut self, on: bool) -> Result<(), errors::ErrorCode> {
|
||||
async fn set_on(&self, on: bool) -> Result<(), errors::ErrorCode> {
|
||||
let mut stream = TcpStream::connect(self.config.addr)
|
||||
.await
|
||||
.or::<DeviceError>(Err(DeviceError::DeviceOffline))?;
|
||||
|
@ -275,3 +275,13 @@ impl traits::OnOff for KasaOutlet {
|
|||
.or(Err(DeviceError::TransientError.into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnPresence for KasaOutlet {
|
||||
async fn on_presence(&self, presence: bool) {
|
||||
if !presence {
|
||||
debug!(id = Device::get_id(self), "Turning device off");
|
||||
self.set_on(false).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
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,23 +1,24 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use automation_macro::{LuaDevice, LuaDeviceConfig, LuaTypeDefinition};
|
||||
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 rumqttc::Publish;
|
||||
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||
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, LuaTypeDefinition)]
|
||||
pub struct LightSensorConfig {
|
||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||
pub struct Config {
|
||||
pub identifier: String,
|
||||
#[device_config(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
pub min: isize,
|
||||
pub max: isize,
|
||||
#[device_config(rename("event_channel"), from(EventChannel), from_lua, with(|ec: EventChannel| ec.get_tx()))]
|
||||
#[device_config(rename("event_channel"), from_lua, with(|ec: EventChannel| ec.get_tx()))]
|
||||
pub tx: event::Sender,
|
||||
#[device_config(from_lua)]
|
||||
pub client: WrappedAsyncClient,
|
||||
|
@ -25,16 +26,30 @@ pub struct LightSensorConfig {
|
|||
|
||||
const DEFAULT: bool = false;
|
||||
|
||||
#[derive(Debug, LuaDevice)]
|
||||
pub struct LightSensor {
|
||||
config: LightSensorConfig,
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct State {
|
||||
is_dark: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LightSensor {
|
||||
config: Config,
|
||||
state: Arc<RwLock<State>>,
|
||||
}
|
||||
|
||||
impl LightSensor {
|
||||
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 LightSensor {
|
||||
type Config = LightSensorConfig;
|
||||
type Config = Config;
|
||||
type Error = rumqttc::ClientError;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
|
@ -45,10 +60,10 @@ impl LuaDeviceCreate for LightSensor {
|
|||
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||
.await?;
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
is_dark: DEFAULT,
|
||||
})
|
||||
let state = State { is_dark: DEFAULT };
|
||||
let state = Arc::new(RwLock::new(state));
|
||||
|
||||
Ok(Self { config, state })
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -60,7 +75,7 @@ impl Device for LightSensor {
|
|||
|
||||
#[async_trait]
|
||||
impl OnMqtt for LightSensor {
|
||||
async fn on_mqtt(&mut self, message: Publish) {
|
||||
async fn on_mqtt(&self, message: Publish) {
|
||||
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
||||
return;
|
||||
}
|
||||
|
@ -81,18 +96,19 @@ impl OnMqtt for LightSensor {
|
|||
trace!("It is light");
|
||||
false
|
||||
} else {
|
||||
let is_dark = self.state().await.is_dark;
|
||||
trace!(
|
||||
"In between min ({}) and max ({}) value, keeping current state: {}",
|
||||
self.config.min,
|
||||
self.config.max,
|
||||
self.is_dark
|
||||
is_dark
|
||||
);
|
||||
self.is_dark
|
||||
is_dark
|
||||
};
|
||||
|
||||
if is_dark != self.is_dark {
|
||||
if is_dark != self.state().await.is_dark {
|
||||
debug!("Dark state has changed: {is_dark}");
|
||||
self.is_dark = is_dark;
|
||||
self.state_mut().await.is_dark = is_dark;
|
||||
|
||||
if self.config.tx.send(Event::Darkness(is_dark)).await.is_err() {
|
||||
warn!("There are no receivers on the event channel");
|
|
@ -1,23 +1,22 @@
|
|||
use std::net::Ipv4Addr;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use automation_macro::{LuaDevice, LuaDeviceConfig, LuaTypeDefinition};
|
||||
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 eui48::MacAddress;
|
||||
use google_home::device;
|
||||
use google_home::errors::ErrorCode;
|
||||
use google_home::traits::{self, Scene};
|
||||
use google_home::types::Type;
|
||||
use google_home::{device, GoogleHomeDevice};
|
||||
use rumqttc::Publish;
|
||||
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, LuaTypeDefinition)]
|
||||
pub struct WakeOnLANConfig {
|
||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||
pub struct Config {
|
||||
#[device_config(flatten)]
|
||||
pub info: InfoConfig,
|
||||
#[device_config(flatten)]
|
||||
|
@ -29,14 +28,14 @@ pub struct WakeOnLANConfig {
|
|||
pub client: WrappedAsyncClient,
|
||||
}
|
||||
|
||||
#[derive(Debug, LuaDevice)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WakeOnLAN {
|
||||
config: WakeOnLANConfig,
|
||||
config: Config,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for WakeOnLAN {
|
||||
type Config = WakeOnLANConfig;
|
||||
type Config = Config;
|
||||
type Error = rumqttc::ClientError;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
|
@ -59,7 +58,7 @@ impl Device for WakeOnLAN {
|
|||
|
||||
#[async_trait]
|
||||
impl OnMqtt for WakeOnLAN {
|
||||
async fn on_mqtt(&mut self, message: Publish) {
|
||||
async fn on_mqtt(&self, message: Publish) {
|
||||
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
||||
return;
|
||||
}
|
||||
|
@ -76,7 +75,8 @@ impl OnMqtt for WakeOnLAN {
|
|||
}
|
||||
}
|
||||
|
||||
impl GoogleHomeDevice for WakeOnLAN {
|
||||
#[async_trait]
|
||||
impl google_home::Device for WakeOnLAN {
|
||||
fn get_device_type(&self) -> Type {
|
||||
Type::Scene
|
||||
}
|
||||
|
@ -92,7 +92,7 @@ impl GoogleHomeDevice for WakeOnLAN {
|
|||
Device::get_id(self)
|
||||
}
|
||||
|
||||
fn is_online(&self) -> bool {
|
||||
async fn is_online(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
|
@ -103,8 +103,17 @@ impl GoogleHomeDevice for WakeOnLAN {
|
|||
|
||||
#[async_trait]
|
||||
impl traits::Scene for WakeOnLAN {
|
||||
async fn set_active(&self, activate: bool) -> Result<(), ErrorCode> {
|
||||
if activate {
|
||||
async fn set_active(&self, deactivate: bool) -> Result<(), ErrorCode> {
|
||||
if deactivate {
|
||||
debug!(
|
||||
id = Device::get_id(self),
|
||||
"Trying to deactivate computer, this is not currently supported"
|
||||
);
|
||||
// We do not support deactivating this scene
|
||||
Err(ErrorCode::DeviceError(
|
||||
google_home::errors::DeviceError::ActionNotAvailable,
|
||||
))
|
||||
} else {
|
||||
debug!(
|
||||
id = Device::get_id(self),
|
||||
"Activating Computer: {} (Sending to {})",
|
||||
|
@ -131,15 +140,6 @@ impl traits::Scene for WakeOnLAN {
|
|||
google_home::errors::DeviceError::TransientError.into()
|
||||
})
|
||||
.map(|_| debug!(id = Device::get_id(self), "Success!"))
|
||||
} else {
|
||||
debug!(
|
||||
id = Device::get_id(self),
|
||||
"Trying to deactivate computer, this is not currently supported"
|
||||
);
|
||||
// We do not support deactivating this scene
|
||||
Err(ErrorCode::DeviceError(
|
||||
google_home::errors::DeviceError::ActionNotAvailable,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,40 +1,55 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use automation_macro::{LuaDevice, LuaDeviceConfig, LuaTypeDefinition};
|
||||
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 rumqttc::Publish;
|
||||
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||
use tracing::{debug, error, trace, warn};
|
||||
|
||||
use super::ntfy::Priority;
|
||||
use super::{Device, LuaDeviceCreate};
|
||||
use crate::config::MqttDeviceConfig;
|
||||
use crate::devices::ntfy::Notification;
|
||||
use crate::event::{self, Event, EventChannel, OnMqtt};
|
||||
use crate::messages::PowerMessage;
|
||||
use crate::mqtt::WrappedAsyncClient;
|
||||
|
||||
#[derive(Debug, Clone, LuaDeviceConfig, LuaTypeDefinition)]
|
||||
pub struct WasherConfig {
|
||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||
pub struct Config {
|
||||
pub identifier: String,
|
||||
#[device_config(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
// Power in Watt
|
||||
pub threshold: f32,
|
||||
#[device_config(rename("event_channel"), from_lua, from(EventChannel), with(|ec: EventChannel| ec.get_tx()))]
|
||||
#[device_config(rename("event_channel"), from_lua, with(|ec: EventChannel| ec.get_tx()))]
|
||||
pub tx: event::Sender,
|
||||
#[device_config(from_lua)]
|
||||
pub client: WrappedAsyncClient,
|
||||
}
|
||||
|
||||
// TODO: Add google home integration
|
||||
#[derive(Debug, LuaDevice)]
|
||||
pub struct Washer {
|
||||
config: WasherConfig,
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct State {
|
||||
running: isize,
|
||||
}
|
||||
|
||||
// TODO: Add google home integration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Washer {
|
||||
config: Config,
|
||||
state: Arc<RwLock<State>>,
|
||||
}
|
||||
|
||||
impl Washer {
|
||||
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 Washer {
|
||||
type Config = WasherConfig;
|
||||
type Config = Config;
|
||||
type Error = rumqttc::ClientError;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
|
@ -45,7 +60,10 @@ impl LuaDeviceCreate for Washer {
|
|||
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||
.await?;
|
||||
|
||||
Ok(Self { config, running: 0 })
|
||||
let state = State { running: 0 };
|
||||
let state = Arc::new(RwLock::new(state));
|
||||
|
||||
Ok(Self { config, state })
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -62,7 +80,7 @@ const HYSTERESIS: isize = 10;
|
|||
|
||||
#[async_trait]
|
||||
impl OnMqtt for Washer {
|
||||
async fn on_mqtt(&mut self, message: Publish) {
|
||||
async fn on_mqtt(&self, message: Publish) {
|
||||
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
||||
return;
|
||||
}
|
||||
|
@ -80,7 +98,7 @@ impl OnMqtt for Washer {
|
|||
|
||||
// debug!(id = self.identifier, power, "Washer state update");
|
||||
|
||||
if power < self.config.threshold && self.running >= HYSTERESIS {
|
||||
if power < self.config.threshold && self.state().await.running >= HYSTERESIS {
|
||||
// The washer is done running
|
||||
debug!(
|
||||
id = self.config.identifier,
|
||||
|
@ -89,7 +107,7 @@ impl OnMqtt for Washer {
|
|||
"Washer is done"
|
||||
);
|
||||
|
||||
self.running = 0;
|
||||
self.state_mut().await.running = 0;
|
||||
let notification = Notification::new()
|
||||
.set_title("Laundy is done")
|
||||
.set_message("Don't forget to hang it!")
|
||||
|
@ -107,8 +125,8 @@ impl OnMqtt for Washer {
|
|||
}
|
||||
} else if power < self.config.threshold {
|
||||
// Prevent false positives
|
||||
self.running = 0;
|
||||
} else if power >= self.config.threshold && self.running < HYSTERESIS {
|
||||
self.state_mut().await.running = 0;
|
||||
} else if power >= self.config.threshold && self.state().await.running < HYSTERESIS {
|
||||
// Washer could be starting
|
||||
debug!(
|
||||
id = self.config.identifier,
|
||||
|
@ -117,7 +135,7 @@ impl OnMqtt for Washer {
|
|||
"Washer is starting"
|
||||
);
|
||||
|
||||
self.running += 1;
|
||||
self.state_mut().await.running += 1;
|
||||
}
|
||||
}
|
||||
}
|
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,7 +1,6 @@
|
|||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use automation_macro::LuaTypeDefinition;
|
||||
use rumqttc::{MqttOptions, Transport};
|
||||
use serde::Deserialize;
|
||||
|
||||
|
@ -53,7 +52,7 @@ fn default_fulfillment_port() -> u16 {
|
|||
7878
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, LuaTypeDefinition)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct InfoConfig {
|
||||
pub name: String,
|
||||
pub room: Option<String>,
|
||||
|
@ -69,7 +68,7 @@ impl InfoConfig {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, LuaTypeDefinition)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MqttDeviceConfig {
|
||||
pub topic: String,
|
||||
}
|
99
automation_lib/src/device.rs
Normal file
99
automation_lib/src/device.rs
Normal file
|
@ -0,0 +1,99 @@
|
|||
use std::fmt::Debug;
|
||||
|
||||
use automation_cast::Cast;
|
||||
use dyn_clone::DynClone;
|
||||
use google_home::traits::OnOff;
|
||||
use mlua::ObjectLike;
|
||||
|
||||
use crate::event::{OnDarkness, OnMqtt, OnNotification, OnPresence};
|
||||
|
||||
// TODO: Make this a proper macro
|
||||
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("is_on", |_lua, this, _: ()| async move {
|
||||
Ok((this.deref().cast() as Option<&dyn google_home::traits::OnOff>)
|
||||
.expect("Cast should be valid")
|
||||
.on()
|
||||
.await
|
||||
.unwrap())
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
pub(crate) use impl_device;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait LuaDeviceCreate {
|
||||
type Config;
|
||||
type Error;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error>
|
||||
where
|
||||
Self: Sized;
|
||||
}
|
||||
|
||||
pub trait Device:
|
||||
Debug
|
||||
+ DynClone
|
||||
+ Sync
|
||||
+ Send
|
||||
+ Cast<dyn google_home::Device>
|
||||
+ Cast<dyn OnMqtt>
|
||||
+ Cast<dyn OnPresence>
|
||||
+ Cast<dyn OnDarkness>
|
||||
+ Cast<dyn OnNotification>
|
||||
+ Cast<dyn OnOff>
|
||||
{
|
||||
fn get_id(&self) -> String;
|
||||
}
|
||||
|
||||
impl mlua::FromLua for Box<dyn Device> {
|
||||
fn from_lua(value: mlua::Value, _lua: &mlua::Lua) -> mlua::Result<Self> {
|
||||
match value {
|
||||
mlua::Value::UserData(ud) => {
|
||||
let ud = if ud.is::<Box<dyn Device>>() {
|
||||
ud
|
||||
} else {
|
||||
ud.call_method::<_>("__box", ())?
|
||||
};
|
||||
|
||||
let b = ud.borrow::<Self>()?.clone();
|
||||
Ok(b)
|
||||
}
|
||||
_ => Err(mlua::Error::RuntimeError("Expected user data".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl mlua::UserData for Box<dyn Device> {}
|
||||
|
||||
dyn_clone::clone_trait_object!(Device);
|
|
@ -1,63 +1,17 @@
|
|||
use std::collections::HashMap;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::future::join_all;
|
||||
use futures::Future;
|
||||
use google_home::traits::OnOff;
|
||||
use mlua::FromLua;
|
||||
use tokio::sync::{RwLock, RwLockReadGuard};
|
||||
use tokio_cron_scheduler::{Job, JobScheduler};
|
||||
use tokio_util::task::LocalPoolHandle;
|
||||
use tracing::{debug, instrument, trace};
|
||||
|
||||
use crate::devices::Device;
|
||||
use crate::device::Device;
|
||||
use crate::event::{Event, EventChannel, OnDarkness, OnMqtt, OnNotification, OnPresence};
|
||||
use crate::LUA;
|
||||
|
||||
#[derive(Debug, FromLua, Clone)]
|
||||
pub struct WrappedDevice(Arc<RwLock<Box<dyn Device>>>);
|
||||
|
||||
impl WrappedDevice {
|
||||
pub fn new(device: Box<dyn Device>) -> Self {
|
||||
Self(Arc::new(RwLock::new(device)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for WrappedDevice {
|
||||
type Target = Arc<RwLock<Box<dyn Device>>>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for WrappedDevice {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
impl mlua::UserData for WrappedDevice {
|
||||
fn add_methods<'lua, M: mlua::prelude::LuaUserDataMethods<'lua, Self>>(methods: &mut M) {
|
||||
methods.add_async_method("get_id", |_lua, this, _: ()| async {
|
||||
Ok(crate::devices::Device::get_id(this.0.read().await.as_ref()))
|
||||
});
|
||||
|
||||
methods.add_async_method("set_on", |_lua, this, on: bool| async move {
|
||||
let mut device = this.0.write().await;
|
||||
let device = device.as_mut();
|
||||
|
||||
if let Some(device) = device.cast_mut() as Option<&mut dyn OnOff> {
|
||||
device.set_on(on).await.unwrap()
|
||||
};
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub type DeviceMap = HashMap<String, Arc<RwLock<Box<dyn Device>>>>;
|
||||
pub type DeviceMap = HashMap<String, Box<dyn Device>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DeviceManager {
|
||||
|
@ -94,25 +48,20 @@ impl DeviceManager {
|
|||
device_manager
|
||||
}
|
||||
|
||||
pub async fn add(&self, device: &WrappedDevice) {
|
||||
let id = device.read().await.get_id().to_owned();
|
||||
pub async fn add(&self, device: Box<dyn Device>) {
|
||||
let id = device.get_id();
|
||||
|
||||
debug!(id, "Adding device");
|
||||
|
||||
self.devices.write().await.insert(id, device.0.clone());
|
||||
self.devices.write().await.insert(id, device);
|
||||
}
|
||||
|
||||
pub fn event_channel(&self) -> EventChannel {
|
||||
self.event_channel.clone()
|
||||
}
|
||||
|
||||
pub async fn get(&self, name: &str) -> Option<WrappedDevice> {
|
||||
self.devices
|
||||
.read()
|
||||
.await
|
||||
.get(name)
|
||||
.cloned()
|
||||
.map(WrappedDevice)
|
||||
pub async fn get(&self, name: &str) -> Option<Box<dyn Device>> {
|
||||
self.devices.read().await.get(name).cloned()
|
||||
}
|
||||
|
||||
pub async fn devices(&self) -> RwLockReadGuard<DeviceMap> {
|
||||
|
@ -127,8 +76,7 @@ impl DeviceManager {
|
|||
let iter = devices.iter().map(|(id, device)| {
|
||||
let message = message.clone();
|
||||
async move {
|
||||
let mut device = device.write().await;
|
||||
let device: Option<&mut dyn OnMqtt> = device.as_mut().cast_mut();
|
||||
let device: Option<&dyn OnMqtt> = device.cast();
|
||||
if let Some(device) = device {
|
||||
// let subscribed = device
|
||||
// .topics()
|
||||
|
@ -149,8 +97,7 @@ impl DeviceManager {
|
|||
Event::Darkness(dark) => {
|
||||
let devices = self.devices.read().await;
|
||||
let iter = devices.iter().map(|(id, device)| async move {
|
||||
let mut device = device.write().await;
|
||||
let device: Option<&mut dyn OnDarkness> = device.as_mut().cast_mut();
|
||||
let device: Option<&dyn OnDarkness> = device.cast();
|
||||
if let Some(device) = device {
|
||||
trace!(id, "Handling");
|
||||
device.on_darkness(dark).await;
|
||||
|
@ -163,8 +110,7 @@ impl DeviceManager {
|
|||
Event::Presence(presence) => {
|
||||
let devices = self.devices.read().await;
|
||||
let iter = devices.iter().map(|(id, device)| async move {
|
||||
let mut device = device.write().await;
|
||||
let device: Option<&mut dyn OnPresence> = device.as_mut().cast_mut();
|
||||
let device: Option<&dyn OnPresence> = device.cast();
|
||||
if let Some(device) = device {
|
||||
trace!(id, "Handling");
|
||||
device.on_presence(presence).await;
|
||||
|
@ -179,8 +125,7 @@ impl DeviceManager {
|
|||
let iter = devices.iter().map(|(id, device)| {
|
||||
let notification = notification.clone();
|
||||
async move {
|
||||
let mut device = device.write().await;
|
||||
let device: Option<&mut dyn OnNotification> = device.as_mut().cast_mut();
|
||||
let device: Option<&dyn OnNotification> = device.cast();
|
||||
if let Some(device) = device {
|
||||
trace!(id, "Handling");
|
||||
device.on_notification(notification).await;
|
||||
|
@ -195,27 +140,10 @@ impl DeviceManager {
|
|||
}
|
||||
}
|
||||
|
||||
fn run_schedule(
|
||||
uuid: uuid::Uuid,
|
||||
_: tokio_cron_scheduler::JobScheduler,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||
Box::pin(async move {
|
||||
// Lua is not Send, so we need to make sure that the task stays on the same thread
|
||||
let pool = LocalPoolHandle::new(1);
|
||||
pool.spawn_pinned(move || async move {
|
||||
let lua = LUA.lock().await;
|
||||
let f: mlua::Function = lua.named_registry_value(uuid.to_string().as_str()).unwrap();
|
||||
f.call_async::<_, ()>(()).await.unwrap();
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
})
|
||||
}
|
||||
|
||||
impl mlua::UserData for DeviceManager {
|
||||
fn add_methods<'lua, M: mlua::UserDataMethods<'lua, Self>>(methods: &mut M) {
|
||||
methods.add_async_method("add", |_lua, this, device: WrappedDevice| async move {
|
||||
this.add(&device).await;
|
||||
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
|
||||
methods.add_async_method("add", |_lua, this, device: Box<dyn Device>| async move {
|
||||
this.add(device).await;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
@ -224,7 +152,27 @@ impl mlua::UserData for DeviceManager {
|
|||
"schedule",
|
||||
|lua, this, (schedule, f): (String, mlua::Function)| async move {
|
||||
debug!("schedule = {schedule}");
|
||||
let job = Job::new_async(schedule.as_str(), run_schedule).unwrap();
|
||||
// This creates a function, that returns the actual job we want to run
|
||||
let create_job = {
|
||||
let lua = lua.clone();
|
||||
|
||||
move |uuid: uuid::Uuid,
|
||||
_: tokio_cron_scheduler::JobScheduler|
|
||||
-> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||
let lua = lua.clone();
|
||||
|
||||
// Create the actual function we want to run on a schedule
|
||||
let future = async move {
|
||||
let f: mlua::Function =
|
||||
lua.named_registry_value(uuid.to_string().as_str()).unwrap();
|
||||
f.call_async::<()>(()).await.unwrap();
|
||||
};
|
||||
|
||||
Box::pin(future)
|
||||
}
|
||||
};
|
||||
|
||||
let job = Job::new_async(schedule.as_str(), create_job).unwrap();
|
||||
|
||||
let uuid = this.scheduler.add(job).await.unwrap();
|
||||
|
||||
|
@ -236,12 +184,6 @@ impl mlua::UserData for DeviceManager {
|
|||
},
|
||||
);
|
||||
|
||||
// methods.add_async_method("add_schedule", |lua, this, schedule| async {
|
||||
// let schedule = lua.from_value(schedule)?;
|
||||
// this.add_schedule(schedule).await;
|
||||
// Ok(())
|
||||
// });
|
||||
|
||||
methods.add_method("event_channel", |_lua, this, ()| Ok(this.event_channel()))
|
||||
}
|
||||
}
|
|
@ -1,10 +1,7 @@
|
|||
use std::{error, fmt, result};
|
||||
|
||||
use axum::http::status::InvalidStatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use bytes::Bytes;
|
||||
use rumqttc::ClientError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
@ -101,68 +98,3 @@ pub enum LightSensorError {
|
|||
#[error(transparent)]
|
||||
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 tokio::sync::mpsc;
|
||||
|
||||
use crate::devices::ntfy::Notification;
|
||||
use crate::ntfy::Notification;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
|
@ -36,20 +36,20 @@ impl mlua::UserData for EventChannel {}
|
|||
#[async_trait]
|
||||
pub trait OnMqtt: Sync + Send {
|
||||
// fn topics(&self) -> Vec<&str>;
|
||||
async fn on_mqtt(&mut self, message: Publish);
|
||||
async fn on_mqtt(&self, message: Publish);
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnPresence: Sync + Send {
|
||||
async fn on_presence(&mut self, presence: bool);
|
||||
async fn on_presence(&self, presence: bool);
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnDarkness: Sync + Send {
|
||||
async fn on_darkness(&mut self, dark: bool);
|
||||
async fn on_darkness(&self, dark: bool);
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnNotification: Sync + Send {
|
||||
async fn on_notification(&mut self, notification: Notification);
|
||||
async fn on_notification(&self, notification: Notification);
|
||||
}
|
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)
|
||||
});
|
||||
}
|
||||
}
|
|
@ -2,17 +2,15 @@
|
|||
#![feature(specialization)]
|
||||
#![feature(let_chains)]
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use tokio::sync::Mutex;
|
||||
pub mod auth;
|
||||
pub mod action_callback;
|
||||
pub mod config;
|
||||
pub mod device;
|
||||
pub mod device_manager;
|
||||
pub mod devices;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod helpers;
|
||||
pub mod messages;
|
||||
pub mod mqtt;
|
||||
pub mod ntfy;
|
||||
pub mod presence;
|
||||
pub mod schedule;
|
||||
pub mod traits;
|
||||
|
||||
pub static LUA: Lazy<Mutex<mlua::Lua>> = Lazy::new(|| Mutex::new(mlua::Lua::new()));
|
|
@ -241,39 +241,3 @@ impl TryFrom<Bytes> for HueMessage {
|
|||
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,
|
||||
}
|
||||
|
||||
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::convert::Infallible;
|
||||
use std::ops::Deref;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use automation_macro::{LuaDevice, LuaDeviceConfig, LuaTypeDefinition};
|
||||
use automation_cast::Cast;
|
||||
use automation_macro::LuaDeviceConfig;
|
||||
use serde::Serialize;
|
||||
use serde_repr::*;
|
||||
use tracing::{error, trace, warn};
|
||||
|
||||
use super::LuaDeviceCreate;
|
||||
use crate::devices::Device;
|
||||
use crate::device::{impl_device, Device, LuaDeviceCreate};
|
||||
use crate::event::{self, Event, EventChannel, OnNotification, OnPresence};
|
||||
|
||||
#[derive(Debug, Serialize_repr, Clone, Copy)]
|
||||
|
@ -111,23 +112,25 @@ impl Default for Notification {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, LuaDeviceConfig, LuaTypeDefinition)]
|
||||
pub struct NtfyConfig {
|
||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||
pub struct Config {
|
||||
#[device_config(default("https://ntfy.sh".into()))]
|
||||
pub url: String,
|
||||
pub topic: String,
|
||||
#[device_config(rename("event_channel"), from_lua, from(EventChannel), with(|ec: EventChannel| ec.get_tx()))]
|
||||
#[device_config(rename("event_channel"), from_lua, with(|ec: EventChannel| ec.get_tx()))]
|
||||
pub tx: event::Sender,
|
||||
}
|
||||
|
||||
#[derive(Debug, LuaDevice)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Ntfy {
|
||||
config: NtfyConfig,
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl_device!(Ntfy);
|
||||
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for Ntfy {
|
||||
type Config = NtfyConfig;
|
||||
type Config = Config;
|
||||
type Error = Infallible;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
|
@ -166,7 +169,7 @@ impl Ntfy {
|
|||
|
||||
#[async_trait]
|
||||
impl OnPresence for Ntfy {
|
||||
async fn on_presence(&mut self, presence: bool) {
|
||||
async fn on_presence(&self, presence: bool) {
|
||||
// Setup extras for the broadcast
|
||||
let extras = HashMap::from([
|
||||
("cmd".into(), "presence".into()),
|
||||
|
@ -202,7 +205,7 @@ impl OnPresence for Ntfy {
|
|||
|
||||
#[async_trait]
|
||||
impl OnNotification for Ntfy {
|
||||
async fn on_notification(&mut self, notification: Notification) {
|
||||
async fn on_notification(&self, notification: Notification) {
|
||||
self.send(notification).await;
|
||||
}
|
||||
}
|
|
@ -1,22 +1,25 @@
|
|||
use std::collections::HashMap;
|
||||
use std::ops::Deref;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use automation_macro::{LuaDevice, LuaDeviceConfig, LuaTypeDefinition};
|
||||
use automation_cast::Cast;
|
||||
use automation_macro::LuaDeviceConfig;
|
||||
use rumqttc::Publish;
|
||||
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
use super::LuaDeviceCreate;
|
||||
use crate::config::MqttDeviceConfig;
|
||||
use crate::devices::Device;
|
||||
use crate::device::{impl_device, Device, LuaDeviceCreate};
|
||||
use crate::event::{self, Event, EventChannel, OnMqtt};
|
||||
use crate::messages::PresenceMessage;
|
||||
use crate::mqtt::WrappedAsyncClient;
|
||||
|
||||
#[derive(Debug, LuaDeviceConfig, LuaTypeDefinition)]
|
||||
pub struct PresenceConfig {
|
||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||
pub struct Config {
|
||||
#[device_config(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
#[device_config(from_lua, rename("event_channel"), from(EventChannel), with(|ec: EventChannel| ec.get_tx()))]
|
||||
#[device_config(from_lua, rename("event_channel"), with(|ec: EventChannel| ec.get_tx()))]
|
||||
pub tx: event::Sender,
|
||||
#[device_config(from_lua)]
|
||||
pub client: WrappedAsyncClient,
|
||||
|
@ -24,31 +27,50 @@ pub struct PresenceConfig {
|
|||
|
||||
pub const DEFAULT_PRESENCE: bool = false;
|
||||
|
||||
#[derive(Debug, LuaDevice)]
|
||||
pub struct Presence {
|
||||
config: PresenceConfig,
|
||||
#[derive(Debug)]
|
||||
pub struct State {
|
||||
devices: HashMap<String, bool>,
|
||||
current_overall_presence: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Presence {
|
||||
config: Config,
|
||||
state: Arc<RwLock<State>>,
|
||||
}
|
||||
|
||||
impl Presence {
|
||||
async fn state(&self) -> RwLockReadGuard<State> {
|
||||
self.state.read().await
|
||||
}
|
||||
|
||||
async fn state_mut(&self) -> RwLockWriteGuard<State> {
|
||||
self.state.write().await
|
||||
}
|
||||
}
|
||||
|
||||
impl_device!(Presence);
|
||||
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for Presence {
|
||||
type Config = PresenceConfig;
|
||||
type Config = Config;
|
||||
type Error = rumqttc::ClientError;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
trace!(id = "ntfy", "Setting up Presence");
|
||||
trace!(id = "presence", "Setting up Presence");
|
||||
|
||||
config
|
||||
.client
|
||||
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||
.await?;
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
let state = State {
|
||||
devices: HashMap::new(),
|
||||
current_overall_presence: DEFAULT_PRESENCE,
|
||||
})
|
||||
};
|
||||
let state = Arc::new(RwLock::new(state));
|
||||
|
||||
Ok(Self { config, state })
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -60,7 +82,7 @@ impl Device for Presence {
|
|||
|
||||
#[async_trait]
|
||||
impl OnMqtt for Presence {
|
||||
async fn on_mqtt(&mut self, message: Publish) {
|
||||
async fn on_mqtt(&self, message: Publish) {
|
||||
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
||||
return;
|
||||
}
|
||||
|
@ -77,7 +99,7 @@ impl OnMqtt for Presence {
|
|||
if message.payload.is_empty() {
|
||||
// Remove the device from the map
|
||||
debug!("State of device [{device_name}] has been removed");
|
||||
self.devices.remove(&device_name);
|
||||
self.state_mut().await.devices.remove(&device_name);
|
||||
} else {
|
||||
let present = match PresenceMessage::try_from(message) {
|
||||
Ok(state) => state.presence(),
|
||||
|
@ -88,13 +110,13 @@ impl OnMqtt for Presence {
|
|||
};
|
||||
|
||||
debug!("State of device [{device_name}] has changed: {}", present);
|
||||
self.devices.insert(device_name, present);
|
||||
self.state_mut().await.devices.insert(device_name, present);
|
||||
}
|
||||
|
||||
let overall_presence = self.devices.iter().any(|(_, v)| *v);
|
||||
if overall_presence != self.current_overall_presence {
|
||||
let overall_presence = self.state().await.devices.iter().any(|(_, v)| *v);
|
||||
if overall_presence != self.state().await.current_overall_presence {
|
||||
debug!("Overall presence updated: {overall_presence}");
|
||||
self.current_overall_presence = overall_presence;
|
||||
self.state_mut().await.current_overall_presence = overall_presence;
|
||||
|
||||
if self
|
||||
.config
|
|
@ -7,7 +7,7 @@ edition = "2021"
|
|||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
itertools = "0.12.1"
|
||||
proc-macro2 = "1.0.81"
|
||||
quote = "1.0.36"
|
||||
syn = { version = "2.0.60", features = ["extra-traits", "full"] }
|
||||
itertools = { workspace = true }
|
||||
proc-macro2 = { workspace = true }
|
||||
quote = { workspace = true }
|
||||
syn = { workspace = true }
|
||||
|
|
|
@ -1,29 +1,13 @@
|
|||
mod lua_device;
|
||||
#![feature(let_chains)]
|
||||
#![feature(iter_intersperse)]
|
||||
mod lua_device_config;
|
||||
mod lua_type_definition;
|
||||
|
||||
use lua_device::impl_lua_device_macro;
|
||||
use lua_device_config::impl_lua_device_config_macro;
|
||||
use lua_type_definition::impl_lua_type_definition;
|
||||
use syn::{parse_macro_input, DeriveInput};
|
||||
|
||||
#[proc_macro_derive(LuaDevice)]
|
||||
pub fn lua_device_derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||
let ast = parse_macro_input!(input as DeriveInput);
|
||||
|
||||
impl_lua_device_macro(&ast).into()
|
||||
}
|
||||
|
||||
#[proc_macro_derive(LuaDeviceConfig, attributes(device_config))]
|
||||
pub fn lua_device_config_derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||
let ast = parse_macro_input!(input as DeriveInput);
|
||||
|
||||
impl_lua_device_config_macro(&ast).into()
|
||||
}
|
||||
|
||||
#[proc_macro_derive(LuaTypeDefinition, attributes(device_config))]
|
||||
pub fn lua_type_definition_derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||
let ast = parse_macro_input!(input as DeriveInput);
|
||||
|
||||
impl_lua_type_definition(&ast).into()
|
||||
}
|
||||
|
|
|
@ -1,42 +0,0 @@
|
|||
use proc_macro2::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::DeriveInput;
|
||||
|
||||
pub fn impl_lua_device_macro(ast: &DeriveInput) -> TokenStream {
|
||||
let name = &ast.ident;
|
||||
let gen = quote! {
|
||||
impl #name {
|
||||
pub fn register_with_lua(lua: &mlua::Lua) -> mlua::Result<()> {
|
||||
lua.globals().set(stringify!(#name), lua.create_proxy::<#name>()?)
|
||||
}
|
||||
|
||||
pub fn generate_lua_definition() -> String {
|
||||
// TODO: Do not hardcode the name of the config type
|
||||
let def = format!(
|
||||
r#"--- @class {0}
|
||||
{0} = {{}}
|
||||
--- @param config {0}Config
|
||||
--- @return WrappedDevice
|
||||
function {0}.new(config) end
|
||||
"#, stringify!(#name)
|
||||
);
|
||||
|
||||
def
|
||||
}
|
||||
}
|
||||
impl mlua::UserData for #name {
|
||||
fn add_methods<'lua, M: mlua::UserDataMethods<'lua, Self>>(methods: &mut M) {
|
||||
methods.add_async_function("new", |lua, config: mlua::Value| async {
|
||||
let config = mlua::FromLua::from_lua(config, lua)?;
|
||||
|
||||
// TODO: Using crate:: could cause issues
|
||||
let device: #name = crate::devices::LuaDeviceCreate::create(config).await.map_err(mlua::ExternalError::into_lua_err)?;
|
||||
|
||||
Ok(crate::device_manager::WrappedDevice::new(Box::new(device)))
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
gen
|
||||
}
|
|
@ -23,7 +23,7 @@ mod kw {
|
|||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Argument {
|
||||
enum Argument {
|
||||
Flatten {
|
||||
_keyword: kw::flatten,
|
||||
},
|
||||
|
@ -107,8 +107,8 @@ impl Parse for Argument {
|
|||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Args {
|
||||
pub(crate) args: Punctuated<Argument, Token![,]>,
|
||||
struct Args {
|
||||
args: Punctuated<Argument, Token![,]>,
|
||||
}
|
||||
|
||||
impl Parse for Args {
|
||||
|
@ -218,21 +218,6 @@ fn field_from_lua(field: &Field) -> TokenStream {
|
|||
temp.into()
|
||||
}
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice()
|
||||
{
|
||||
[] => value,
|
||||
[value] => value.to_owned(),
|
||||
_ => {
|
||||
return quote_spanned! {field.span() => compile_error!("Field contains duplicate 'from'")}
|
||||
}
|
||||
};
|
||||
|
||||
let value = match args
|
||||
.iter()
|
||||
.filter_map(|arg| match arg {
|
||||
Argument::With { expr, .. } => Some(quote! {
|
||||
{
|
||||
let temp = #value;
|
||||
|
@ -247,7 +232,7 @@ fn field_from_lua(field: &Field) -> TokenStream {
|
|||
[] => value,
|
||||
[value] => value.to_owned(),
|
||||
_ => {
|
||||
return quote_spanned! {field.span() => compile_error!("Field contains duplicate 'with'")}
|
||||
return quote_spanned! {field.span() => compile_error!("Only one of either 'from' or 'with' is allowed")}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -275,9 +260,10 @@ pub fn impl_lua_device_config_macro(ast: &DeriveInput) -> TokenStream {
|
|||
})
|
||||
.collect();
|
||||
|
||||
let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
|
||||
let impl_from_lua = quote! {
|
||||
impl<'lua> mlua::FromLua<'lua> for #name {
|
||||
fn from_lua(value: mlua::Value<'lua>, lua: &'lua mlua::Lua) -> mlua::Result<Self> {
|
||||
impl #impl_generics mlua::FromLua for #name #type_generics #where_clause {
|
||||
fn from_lua(value: mlua::Value, lua: &mlua::Lua) -> mlua::Result<Self> {
|
||||
if !value.is_table() {
|
||||
panic!("Expected table");
|
||||
}
|
||||
|
|
|
@ -1,137 +0,0 @@
|
|||
use itertools::Itertools;
|
||||
use proc_macro2::TokenStream;
|
||||
use quote::{quote, quote_spanned};
|
||||
use syn::spanned::Spanned;
|
||||
use syn::{
|
||||
AngleBracketedGenericArguments, Data, DataStruct, DeriveInput, Field, Fields, FieldsNamed,
|
||||
PathArguments, Type, TypePath,
|
||||
};
|
||||
|
||||
use crate::lua_device_config::{Args, Argument};
|
||||
|
||||
fn field_definition(field: &Field) -> TokenStream {
|
||||
let (args, _): (Vec<_>, Vec<_>) = field
|
||||
.attrs
|
||||
.iter()
|
||||
.filter_map(|attr| {
|
||||
if attr.path().is_ident("device_config") {
|
||||
Some(attr.parse_args::<Args>().map(|args| args.args))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.partition_result();
|
||||
let args: Vec<_> = args.into_iter().flatten().collect();
|
||||
|
||||
let field_name = if let Some(field_name) = args.iter().find_map(|arg| match arg {
|
||||
Argument::Rename { ident, .. } => Some(ident),
|
||||
_ => None,
|
||||
}) {
|
||||
field_name.value()
|
||||
} else {
|
||||
format!("{}", field.ident.clone().unwrap())
|
||||
};
|
||||
|
||||
let mut optional = args
|
||||
.iter()
|
||||
.filter(|arg| matches!(arg, Argument::Default { .. } | Argument::DefaultExpr { .. }))
|
||||
.count()
|
||||
>= 1;
|
||||
|
||||
if args
|
||||
.iter()
|
||||
.filter(|arg| matches!(arg, Argument::Flatten { .. }))
|
||||
.count()
|
||||
>= 1
|
||||
{
|
||||
let field_type = &field.ty;
|
||||
quote! {
|
||||
#field_type::generate_lua_fields().as_str()
|
||||
}
|
||||
} else {
|
||||
let path = if let Some(ty) = args.iter().find_map(|arg| match arg {
|
||||
Argument::From { ty, .. } => Some(ty),
|
||||
_ => None,
|
||||
}) {
|
||||
if let Type::Path(TypePath { path, .. }) = ty {
|
||||
path.clone()
|
||||
} else {
|
||||
todo!();
|
||||
}
|
||||
} else if let Type::Path(TypePath { path, .. }) = field.ty.clone() {
|
||||
path
|
||||
} else {
|
||||
todo!()
|
||||
};
|
||||
|
||||
let seg = path.segments.first().unwrap();
|
||||
let field_type = if seg.ident == "Option" {
|
||||
if let PathArguments::AngleBracketed(AngleBracketedGenericArguments { args, .. }) =
|
||||
seg.arguments.clone()
|
||||
{
|
||||
optional = true;
|
||||
quote! { stringify!(#args) }
|
||||
} else {
|
||||
unreachable!("Option should always have angle brackets");
|
||||
}
|
||||
} else if seg.ident == "Vec" {
|
||||
if let PathArguments::AngleBracketed(AngleBracketedGenericArguments { args, .. }) =
|
||||
seg.arguments.clone()
|
||||
{
|
||||
optional = true;
|
||||
quote! { stringify!(#args[]) }
|
||||
} else {
|
||||
unreachable!("Option should always have angle brackets");
|
||||
}
|
||||
} else {
|
||||
quote! { stringify!(#path).replace(" :: ", "_") }
|
||||
};
|
||||
|
||||
let mut format = "--- @field {} {}".to_string();
|
||||
if optional {
|
||||
format += "|nil";
|
||||
}
|
||||
format += "\n";
|
||||
|
||||
quote! {
|
||||
format!(#format, #field_name, #field_type).as_str()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn impl_lua_type_definition(ast: &DeriveInput) -> TokenStream {
|
||||
let name = &ast.ident;
|
||||
let fields = if let Data::Struct(DataStruct {
|
||||
fields: Fields::Named(FieldsNamed { ref named, .. }),
|
||||
..
|
||||
}) = ast.data
|
||||
{
|
||||
named
|
||||
} else {
|
||||
return quote_spanned! {ast.span() => compile_error!("This macro only works on named structs")};
|
||||
};
|
||||
|
||||
let fields: Vec<_> = fields.iter().map(field_definition).collect();
|
||||
|
||||
let gen = quote! {
|
||||
impl #name {
|
||||
pub fn generate_lua_definition() -> String {
|
||||
let mut def = format!("--- @class {}\n", stringify!(#name));
|
||||
|
||||
def += #name::generate_lua_fields().as_str();
|
||||
|
||||
def
|
||||
}
|
||||
|
||||
pub fn generate_lua_fields() -> String {
|
||||
let mut def = String::new();
|
||||
|
||||
#(def += #fields;)*
|
||||
|
||||
def
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
gen
|
||||
}
|
414
config.lua
414
config.lua
|
@ -21,9 +21,7 @@ automation.fulfillment = {
|
|||
}
|
||||
|
||||
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",
|
||||
host = ((host == "zeus" or host == "hephaestus") and "olympus.lan.huizinga.dev") or "mosquitto",
|
||||
port = 8883,
|
||||
client_name = "automation-" .. host,
|
||||
username = "mqtt",
|
||||
|
@ -37,7 +35,7 @@ automation.device_manager:add(Ntfy.new({
|
|||
}))
|
||||
|
||||
automation.device_manager:add(Presence.new({
|
||||
topic = "automation_dev/presence/+/#",
|
||||
topic = mqtt_automation("presence/+/#"),
|
||||
client = mqtt_client,
|
||||
event_channel = automation.device_manager:event_channel(),
|
||||
}))
|
||||
|
@ -48,7 +46,7 @@ automation.device_manager:add(DebugBridge.new({
|
|||
client = mqtt_client,
|
||||
}))
|
||||
|
||||
local hue_ip = "10.0.0.146"
|
||||
local hue_ip = "10.0.0.102"
|
||||
local hue_token = automation.util.get_env("HUE_TOKEN")
|
||||
|
||||
automation.device_manager:add(HueBridge.new({
|
||||
|
@ -61,6 +59,47 @@ automation.device_manager:add(HueBridge.new({
|
|||
},
|
||||
}))
|
||||
|
||||
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"),
|
||||
|
@ -76,103 +115,390 @@ automation.device_manager:add(WakeOnLAN.new({
|
|||
topic = mqtt_automation("appliance/living_room/zeus"),
|
||||
client = mqtt_client,
|
||||
mac_address = "30:9c:23:60:9c:13",
|
||||
broadcast_ip = "10.0.0.255",
|
||||
broadcast_ip = "10.0.3.255",
|
||||
}))
|
||||
|
||||
local living_mixer = KasaOutlet.new({ identifier = "living_mixer", ip = "10.0.0.49" })
|
||||
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 = KasaOutlet.new({ identifier = "living_speakers", ip = "10.0.0.182" })
|
||||
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(AudioSetup.new({
|
||||
identifier = "living_audio",
|
||||
topic = mqtt_z2m("living/remote"),
|
||||
automation.device_manager:add(IkeaRemote.new({
|
||||
name = "Remote",
|
||||
room = "Living Room",
|
||||
client = mqtt_client,
|
||||
mixer = living_mixer,
|
||||
speakers = living_speakers,
|
||||
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,
|
||||
}))
|
||||
|
||||
automation.device_manager:add(IkeaOutlet.new({
|
||||
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,
|
||||
timeout = debug and 5 or 300,
|
||||
remotes = {
|
||||
{ topic = mqtt_z2m("bedroom/remote") },
|
||||
{ topic = mqtt_z2m("kitchen/remote") },
|
||||
},
|
||||
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(IkeaOutlet.new({
|
||||
outlet_type = "Light",
|
||||
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("batchroom/light"),
|
||||
topic = mqtt_z2m("bathroom/light"),
|
||||
client = mqtt_client,
|
||||
timeout = debug and 60 or 45 * 60,
|
||||
callback = off_timeout(debug and 60 or 45 * 60),
|
||||
}))
|
||||
|
||||
automation.device_manager:add(Washer.new({
|
||||
identifier = "bathroom_washer",
|
||||
topic = mqtt_z2m("batchroom/washer"),
|
||||
topic = 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",
|
||||
automation.device_manager:add(OutletOnOff.new({
|
||||
presence_auto_off = false,
|
||||
name = "Charger",
|
||||
room = "Workbench",
|
||||
topic = mqtt_z2m("workbench/charger"),
|
||||
client = mqtt_client,
|
||||
timeout = debug and 5 or 20 * 3600,
|
||||
callback = off_timeout(debug and 5 or 20 * 3600),
|
||||
}))
|
||||
|
||||
automation.device_manager:add(IkeaOutlet.new({
|
||||
automation.device_manager:add(OutletOnOff.new({
|
||||
name = "Outlet",
|
||||
room = "Workbench",
|
||||
topic = mqtt_z2m("workbench/outlet"),
|
||||
client = mqtt_client,
|
||||
}))
|
||||
local hallway_lights = HueGroup.new({
|
||||
identifier = "hallway_lights",
|
||||
|
||||
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",
|
||||
timer_id = 1,
|
||||
remotes = {
|
||||
{ topic = mqtt_z2m("hallway/remote") },
|
||||
},
|
||||
client = mqtt_client,
|
||||
})
|
||||
automation.device_manager:add(hallway_lights)
|
||||
automation.device_manager:add(hallway_bottom_lights)
|
||||
|
||||
automation.device_manager:add(ContactSensor.new({
|
||||
identifier = "hallway_frontdoor",
|
||||
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,
|
||||
},
|
||||
trigger = {
|
||||
devices = { hallway_lights },
|
||||
timeout = debug and 10 or 2 * 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",
|
||||
topic = "pico/filter/bedroom",
|
||||
client = mqtt_client,
|
||||
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)
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
--- @meta
|
||||
|
||||
--- @class WrappedDevice
|
||||
WrappedDevice = {}
|
||||
--- @return string
|
||||
function WrappedDevice:get_id() end
|
||||
|
||||
--- @class WrappedAsyncClient
|
||||
|
||||
--- @class EventChannel
|
||||
--- @return EventChannel
|
||||
function automation.device_manager:event_channel() end
|
||||
|
||||
automation = {}
|
||||
|
||||
automation.device_manager = {}
|
||||
--- @param device WrappedDevice
|
||||
function automation.device_manager:add(device) end
|
||||
|
||||
--- @param when string
|
||||
--- @param func function
|
||||
function automation.device_manager:schedule(when, func) end
|
||||
|
||||
automation.util = {}
|
||||
--- @param env string
|
||||
--- @return string
|
||||
function automation.util.get_env(env) end
|
||||
|
||||
--- @class Fulfillment
|
||||
--- @field openid_url string|nil
|
||||
automation.fulfillment = {}
|
||||
|
||||
--- @class MqttConfig
|
||||
--- @param config MqttConfig
|
||||
--- @return WrappedAsyncClient
|
||||
function automation.new_mqtt_client(config) end
|
||||
|
||||
--- TODO: Generate this automatically
|
||||
--- @alias OutletType "Outlet"|"Kettle"|"Charger"|"Light"
|
||||
--- @alias TriggerDevicesHelper WrappedDevice[]
|
|
@ -1,184 +0,0 @@
|
|||
-- WARN: This file is automatically generated, do not manually edit
|
||||
|
||||
---@meta
|
||||
--- @class MqttDeviceConfig
|
||||
--- @field topic String
|
||||
|
||||
--- @class AirFilter
|
||||
AirFilter = {}
|
||||
--- @param config AirFilterConfig
|
||||
--- @return WrappedDevice
|
||||
function AirFilter.new(config) end
|
||||
|
||||
--- @class AirFilterConfig
|
||||
--- @field name String
|
||||
--- @field room String|nil
|
||||
--- @field topic String
|
||||
--- @field client WrappedAsyncClient
|
||||
|
||||
--- @class AudioSetup
|
||||
AudioSetup = {}
|
||||
--- @param config AudioSetupConfig
|
||||
--- @return WrappedDevice
|
||||
function AudioSetup.new(config) end
|
||||
|
||||
--- @class AudioSetupConfig
|
||||
--- @field identifier String
|
||||
--- @field topic String
|
||||
--- @field mixer WrappedDevice
|
||||
--- @field speakers WrappedDevice
|
||||
--- @field client WrappedAsyncClient
|
||||
|
||||
--- @class ContactSensor
|
||||
ContactSensor = {}
|
||||
--- @param config ContactSensorConfig
|
||||
--- @return WrappedDevice
|
||||
function ContactSensor.new(config) end
|
||||
|
||||
--- @class ContactSensorConfig
|
||||
--- @field identifier String
|
||||
--- @field topic String
|
||||
--- @field presence PresenceDeviceConfig|nil
|
||||
--- @field trigger TriggerConfig|nil
|
||||
--- @field client WrappedAsyncClient
|
||||
|
||||
--- @class PresenceDeviceConfig
|
||||
--- @field topic String
|
||||
--- @field timeout u64
|
||||
|
||||
--- @class TriggerConfig
|
||||
--- @field devices TriggerDevicesHelper
|
||||
--- @field timeout u64|nil
|
||||
|
||||
--- @class DebugBridge
|
||||
DebugBridge = {}
|
||||
--- @param config DebugBridgeConfig
|
||||
--- @return WrappedDevice
|
||||
function DebugBridge.new(config) end
|
||||
|
||||
--- @class DebugBridgeConfig
|
||||
--- @field identifier String
|
||||
--- @field topic String
|
||||
--- @field client WrappedAsyncClient
|
||||
|
||||
--- @class HueBridge
|
||||
HueBridge = {}
|
||||
--- @param config HueBridgeConfig
|
||||
--- @return WrappedDevice
|
||||
function HueBridge.new(config) end
|
||||
|
||||
--- @class HueBridgeConfig
|
||||
--- @field identifier String
|
||||
--- @field ip Ipv4Addr
|
||||
--- @field login String
|
||||
--- @field flags FlagIDs
|
||||
|
||||
--- @class FlagIDs
|
||||
--- @field presence isize
|
||||
--- @field darkness isize
|
||||
|
||||
--- @class HueGroup
|
||||
HueGroup = {}
|
||||
--- @param config HueGroupConfig
|
||||
--- @return WrappedDevice
|
||||
function HueGroup.new(config) end
|
||||
|
||||
--- @class HueGroupConfig
|
||||
--- @field identifier String
|
||||
--- @field ip Ipv4Addr
|
||||
--- @field login String
|
||||
--- @field group_id isize
|
||||
--- @field timer_id isize
|
||||
--- @field scene_id String
|
||||
--- @field remotes MqttDeviceConfig []|nil
|
||||
--- @field client WrappedAsyncClient
|
||||
|
||||
--- @class IkeaOutlet
|
||||
IkeaOutlet = {}
|
||||
--- @param config IkeaOutletConfig
|
||||
--- @return WrappedDevice
|
||||
function IkeaOutlet.new(config) end
|
||||
|
||||
--- @class IkeaOutletConfig
|
||||
--- @field name String
|
||||
--- @field room String|nil
|
||||
--- @field topic String
|
||||
--- @field outlet_type OutletType|nil
|
||||
--- @field timeout u64|nil
|
||||
--- @field remotes MqttDeviceConfig []|nil
|
||||
--- @field client WrappedAsyncClient
|
||||
|
||||
--- @class KasaOutlet
|
||||
KasaOutlet = {}
|
||||
--- @param config KasaOutletConfig
|
||||
--- @return WrappedDevice
|
||||
function KasaOutlet.new(config) end
|
||||
|
||||
--- @class KasaOutletConfig
|
||||
--- @field identifier String
|
||||
--- @field ip Ipv4Addr
|
||||
|
||||
--- @class LightSensor
|
||||
LightSensor = {}
|
||||
--- @param config LightSensorConfig
|
||||
--- @return WrappedDevice
|
||||
function LightSensor.new(config) end
|
||||
|
||||
--- @class LightSensorConfig
|
||||
--- @field identifier String
|
||||
--- @field topic String
|
||||
--- @field min isize
|
||||
--- @field max isize
|
||||
--- @field event_channel EventChannel
|
||||
--- @field client WrappedAsyncClient
|
||||
|
||||
--- @class Ntfy
|
||||
Ntfy = {}
|
||||
--- @param config NtfyConfig
|
||||
--- @return WrappedDevice
|
||||
function Ntfy.new(config) end
|
||||
|
||||
--- @class NtfyConfig
|
||||
--- @field url String|nil
|
||||
--- @field topic String
|
||||
--- @field event_channel EventChannel
|
||||
|
||||
--- @class Presence
|
||||
Presence = {}
|
||||
--- @param config PresenceConfig
|
||||
--- @return WrappedDevice
|
||||
function Presence.new(config) end
|
||||
|
||||
--- @class PresenceConfig
|
||||
--- @field topic String
|
||||
--- @field event_channel EventChannel
|
||||
--- @field client WrappedAsyncClient
|
||||
|
||||
--- @class WakeOnLAN
|
||||
WakeOnLAN = {}
|
||||
--- @param config WakeOnLANConfig
|
||||
--- @return WrappedDevice
|
||||
function WakeOnLAN.new(config) end
|
||||
|
||||
--- @class WakeOnLANConfig
|
||||
--- @field name String
|
||||
--- @field room String|nil
|
||||
--- @field topic String
|
||||
--- @field mac_address MacAddress
|
||||
--- @field broadcast_ip Ipv4Addr|nil
|
||||
--- @field client WrappedAsyncClient
|
||||
|
||||
--- @class Washer
|
||||
Washer = {}
|
||||
--- @param config WasherConfig
|
||||
--- @return WrappedDevice
|
||||
function Washer.new(config) end
|
||||
|
||||
--- @class WasherConfig
|
||||
--- @field identifier String
|
||||
--- @field topic String
|
||||
--- @field threshold f32
|
||||
--- @field event_channel EventChannel
|
||||
--- @field client WrappedAsyncClient
|
||||
|
||||
-- End of generated file
|
|
@ -1,10 +0,0 @@
|
|||
--- @meta
|
||||
|
||||
--- @alias String string
|
||||
|
||||
--- @alias u64 number
|
||||
--- @alias isize number
|
||||
--- @alias f32 number
|
||||
|
||||
--- @alias Ipv4Addr string
|
||||
--- @alias MacAddress string
|
|
@ -1,16 +0,0 @@
|
|||
[package]
|
||||
name = "google-home"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
automation_cast = { path = "../automation_cast/" }
|
||||
serde = { version = "1.0.149", features = ["derive"] }
|
||||
serde_json = "1.0.89"
|
||||
thiserror = "1.0.37"
|
||||
tokio = { version = "1", features = ["sync"] }
|
||||
async-trait = "0.1.61"
|
||||
futures = "0.3.25"
|
||||
anyhow = "1.0.75"
|
|
@ -1,22 +0,0 @@
|
|||
use serde::Serialize;
|
||||
|
||||
use crate::traits::AvailableSpeeds;
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Attributes {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub command_only_on_off: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub query_only_on_off: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scene_reversible: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reversible: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub command_only_fan_speed: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub available_fan_speeds: Option<AvailableSpeeds>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub query_only_humidity_setting: Option<bool>,
|
||||
}
|
|
@ -1,174 +0,0 @@
|
|||
use async_trait::async_trait;
|
||||
use automation_cast::Cast;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::errors::{DeviceError, ErrorCode};
|
||||
use crate::request::execute::CommandType;
|
||||
use crate::response;
|
||||
use crate::traits::{FanSpeed, HumiditySetting, OnOff, Scene, Trait};
|
||||
use crate::types::Type;
|
||||
|
||||
#[async_trait]
|
||||
pub trait GoogleHomeDevice:
|
||||
Sync + Send + Cast<dyn OnOff> + Cast<dyn Scene> + Cast<dyn FanSpeed> + Cast<dyn HumiditySetting>
|
||||
{
|
||||
fn get_device_type(&self) -> Type;
|
||||
fn get_device_name(&self) -> Name;
|
||||
fn get_id(&self) -> String;
|
||||
fn is_online(&self) -> bool;
|
||||
|
||||
// Default values that can optionally be overridden
|
||||
fn will_report_state(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn get_room_hint(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
fn get_device_info(&self) -> Option<Info> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn sync(&self) -> response::sync::Device {
|
||||
let name = self.get_device_name();
|
||||
let mut device =
|
||||
response::sync::Device::new(&self.get_id(), &name.name, self.get_device_type());
|
||||
|
||||
device.name = name;
|
||||
device.will_report_state = self.will_report_state();
|
||||
// notification_supported_by_agent
|
||||
if let Some(room) = self.get_room_hint() {
|
||||
device.room_hint = Some(room.into());
|
||||
}
|
||||
device.device_info = self.get_device_info();
|
||||
|
||||
let mut traits = Vec::new();
|
||||
|
||||
// OnOff
|
||||
if let Some(on_off) = self.cast() as Option<&dyn OnOff> {
|
||||
traits.push(Trait::OnOff);
|
||||
device.attributes.command_only_on_off = on_off.is_command_only();
|
||||
device.attributes.query_only_on_off = on_off.is_query_only();
|
||||
}
|
||||
|
||||
// Scene
|
||||
if let Some(scene) = self.cast() as Option<&dyn Scene> {
|
||||
traits.push(Trait::Scene);
|
||||
device.attributes.scene_reversible = scene.is_scene_reversible();
|
||||
}
|
||||
|
||||
// FanSpeed
|
||||
if let Some(fan_speed) = self.cast() as Option<&dyn FanSpeed> {
|
||||
traits.push(Trait::FanSpeed);
|
||||
device.attributes.command_only_fan_speed = fan_speed.command_only_fan_speed();
|
||||
device.attributes.available_fan_speeds = Some(fan_speed.available_speeds());
|
||||
}
|
||||
|
||||
if let Some(humidity_setting) = self.cast() as Option<&dyn HumiditySetting> {
|
||||
traits.push(Trait::HumiditySetting);
|
||||
device.attributes.query_only_humidity_setting =
|
||||
humidity_setting.query_only_humidity_setting();
|
||||
}
|
||||
|
||||
device.traits = traits;
|
||||
|
||||
device
|
||||
}
|
||||
|
||||
async fn query(&self) -> response::query::Device {
|
||||
let mut device = response::query::Device::new();
|
||||
if !self.is_online() {
|
||||
device.set_offline();
|
||||
}
|
||||
|
||||
// OnOff
|
||||
if let Some(on_off) = self.cast() as Option<&dyn OnOff> {
|
||||
device.state.on = on_off
|
||||
.is_on()
|
||||
.await
|
||||
.map_err(|err| device.set_error(err))
|
||||
.ok();
|
||||
}
|
||||
|
||||
// FanSpeed
|
||||
if let Some(fan_speed) = self.cast() as Option<&dyn FanSpeed> {
|
||||
device.state.current_fan_speed_setting = Some(fan_speed.current_speed().await);
|
||||
}
|
||||
|
||||
if let Some(humidity_setting) = self.cast() as Option<&dyn HumiditySetting> {
|
||||
device.state.humidity_ambient_percent =
|
||||
Some(humidity_setting.humidity_ambient_percent().await);
|
||||
}
|
||||
|
||||
device
|
||||
}
|
||||
|
||||
async fn execute(&mut self, command: &CommandType) -> Result<(), ErrorCode> {
|
||||
match command {
|
||||
CommandType::OnOff { on } => {
|
||||
if let Some(t) = self.cast_mut() as Option<&mut dyn OnOff> {
|
||||
t.set_on(*on).await?;
|
||||
} else {
|
||||
return Err(DeviceError::ActionNotAvailable.into());
|
||||
}
|
||||
}
|
||||
CommandType::ActivateScene { deactivate } => {
|
||||
if let Some(t) = self.cast_mut() as Option<&mut dyn Scene> {
|
||||
t.set_active(!deactivate).await?;
|
||||
} else {
|
||||
return Err(DeviceError::ActionNotAvailable.into());
|
||||
}
|
||||
}
|
||||
CommandType::SetFanSpeed { fan_speed } => {
|
||||
if let Some(t) = self.cast_mut() as Option<&mut dyn FanSpeed> {
|
||||
t.set_speed(fan_speed).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Name {
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
default_names: Vec<String>,
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
nicknames: Vec<String>,
|
||||
}
|
||||
|
||||
impl Name {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self {
|
||||
default_names: Vec::new(),
|
||||
name: name.into(),
|
||||
nicknames: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_default_name(&mut self, name: &str) {
|
||||
self.default_names.push(name.into());
|
||||
}
|
||||
|
||||
pub fn add_nickname(&mut self, name: &str) {
|
||||
self.nicknames.push(name.into());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Info {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub manufacturer: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hw_version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sw_version: Option<String>,
|
||||
// attributes
|
||||
// customData
|
||||
// otherDeviceIds
|
||||
}
|
|
@ -1,106 +0,0 @@
|
|||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Payload {
|
||||
pub commands: Vec<Command>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Command {
|
||||
pub devices: Vec<Device>,
|
||||
pub execution: Vec<CommandType>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Device {
|
||||
pub id: String,
|
||||
// customData
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(tag = "command", content = "params")]
|
||||
pub enum CommandType {
|
||||
#[serde(rename = "action.devices.commands.OnOff")]
|
||||
OnOff { on: bool },
|
||||
#[serde(rename = "action.devices.commands.ActivateScene")]
|
||||
ActivateScene { deactivate: bool },
|
||||
#[serde(rename = "action.devices.commands.SetFanSpeed")]
|
||||
SetFanSpeed { fan_speed: String },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::request::{Intent, Request};
|
||||
|
||||
#[test]
|
||||
fn deserialize() {
|
||||
let json = r#"{
|
||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
|
||||
"inputs": [
|
||||
{
|
||||
"intent": "action.devices.EXECUTE",
|
||||
"payload": {
|
||||
"commands": [
|
||||
{
|
||||
"devices": [
|
||||
{
|
||||
"id": "123",
|
||||
"customData": {
|
||||
"fooValue": 74,
|
||||
"barValue": true,
|
||||
"bazValue": "sheepdip"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "456",
|
||||
"customData": {
|
||||
"fooValue": 36,
|
||||
"barValue": false,
|
||||
"bazValue": "moarsheep"
|
||||
}
|
||||
}
|
||||
],
|
||||
"execution": [
|
||||
{
|
||||
"command": "action.devices.commands.OnOff",
|
||||
"params": {
|
||||
"on": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let req: Request = serde_json::from_str(json).unwrap();
|
||||
|
||||
println!("{:?}", req);
|
||||
|
||||
assert_eq!(
|
||||
req.request_id,
|
||||
"ff36a3cc-ec34-11e6-b1a0-64510650abcf".to_string()
|
||||
);
|
||||
assert_eq!(req.inputs.len(), 1);
|
||||
match &req.inputs[0] {
|
||||
Intent::Execute(payload) => {
|
||||
assert_eq!(payload.commands.len(), 1);
|
||||
assert_eq!(payload.commands[0].devices.len(), 2);
|
||||
assert_eq!(payload.commands[0].devices[0].id, "123");
|
||||
assert_eq!(payload.commands[0].devices[1].id, "456");
|
||||
assert_eq!(payload.commands[0].execution.len(), 1);
|
||||
match payload.commands[0].execution[0] {
|
||||
CommandType::OnOff { on } => assert!(on),
|
||||
_ => panic!("Expected OnOff"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected Execute intent"),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -1,83 +0,0 @@
|
|||
use async_trait::async_trait;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::errors::ErrorCode;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub enum Trait {
|
||||
#[serde(rename = "action.devices.traits.OnOff")]
|
||||
OnOff,
|
||||
#[serde(rename = "action.devices.traits.Scene")]
|
||||
Scene,
|
||||
#[serde(rename = "action.devices.traits.FanSpeed")]
|
||||
FanSpeed,
|
||||
#[serde(rename = "action.devices.traits.HumiditySetting")]
|
||||
HumiditySetting,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnOff: Sync + Send {
|
||||
fn is_command_only(&self) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
fn is_query_only(&self) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
// TODO: Implement correct error so we can handle them properly
|
||||
async fn is_on(&self) -> Result<bool, ErrorCode>;
|
||||
async fn set_on(&mut self, on: bool) -> Result<(), ErrorCode>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Scene: Sync + Send {
|
||||
fn is_scene_reversible(&self) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn set_active(&self, activate: bool) -> Result<(), ErrorCode>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SpeedValues {
|
||||
pub speed_synonym: Vec<String>,
|
||||
pub lang: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Speed {
|
||||
pub speed_name: String,
|
||||
pub speed_values: Vec<SpeedValues>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AvailableSpeeds {
|
||||
pub speeds: Vec<Speed>,
|
||||
pub ordered: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait FanSpeed: Sync + Send {
|
||||
fn reversible(&self) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
fn command_only_fan_speed(&self) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
fn available_speeds(&self) -> AvailableSpeeds;
|
||||
async fn current_speed(&self) -> String;
|
||||
async fn set_speed(&self, speed: &str) -> Result<(), ErrorCode>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait HumiditySetting: Sync + Send {
|
||||
// TODO: This implementation is not complete, I have only implemented what I need right now
|
||||
fn query_only_humidity_setting(&self) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn humidity_ambient_percent(&self) -> isize;
|
||||
}
|
17
google_home/google_home/Cargo.toml
Normal file
17
google_home/google_home/Cargo.toml
Normal file
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "google_home"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
automation_cast = { workspace = true }
|
||||
google_home_macro = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
json_value_merge = { workspace = true }
|
120
google_home/google_home/src/device.rs
Normal file
120
google_home/google_home/src/device.rs
Normal file
|
@ -0,0 +1,120 @@
|
|||
use async_trait::async_trait;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::errors::ErrorCode;
|
||||
use crate::response;
|
||||
use crate::traits::{Command, DeviceFulfillment};
|
||||
use crate::types::Type;
|
||||
|
||||
#[async_trait]
|
||||
pub trait Device: DeviceFulfillment {
|
||||
fn get_device_type(&self) -> Type;
|
||||
fn get_device_name(&self) -> Name;
|
||||
fn get_id(&self) -> String;
|
||||
async fn is_online(&self) -> bool;
|
||||
|
||||
// Default values that can optionally be overridden
|
||||
fn will_report_state(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn get_room_hint(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
fn get_device_info(&self) -> Option<Info> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn sync(&self) -> response::sync::Device {
|
||||
let name = self.get_device_name();
|
||||
let mut device =
|
||||
response::sync::Device::new(&self.get_id(), &name.name, self.get_device_type());
|
||||
|
||||
device.name = name;
|
||||
device.will_report_state = self.will_report_state();
|
||||
// notification_supported_by_agent
|
||||
if let Some(room) = self.get_room_hint() {
|
||||
device.room_hint = Some(room.into());
|
||||
}
|
||||
device.device_info = self.get_device_info();
|
||||
|
||||
// TODO: Return the appropriate error
|
||||
if let Ok((traits, attributes)) = DeviceFulfillment::sync(self).await {
|
||||
device.traits = traits;
|
||||
device.attributes = attributes;
|
||||
}
|
||||
|
||||
device
|
||||
}
|
||||
|
||||
async fn query(&self) -> response::query::Device {
|
||||
let mut device = response::query::Device::new();
|
||||
if !self.is_online().await {
|
||||
device.set_offline();
|
||||
}
|
||||
|
||||
// TODO: Return the appropriate error
|
||||
if let Ok(state) = DeviceFulfillment::query(self).await {
|
||||
device.state = state;
|
||||
}
|
||||
|
||||
device
|
||||
}
|
||||
|
||||
async fn execute(&self, command: Command) -> Result<(), ErrorCode> {
|
||||
// TODO: Do something with the return value, or just get rut of the return value?
|
||||
if DeviceFulfillment::execute(self, command.clone())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(ErrorCode::DeviceError(
|
||||
crate::errors::DeviceError::TransientError,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Name {
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
default_names: Vec<String>,
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
nicknames: Vec<String>,
|
||||
}
|
||||
|
||||
impl Name {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self {
|
||||
default_names: Vec::new(),
|
||||
name: name.into(),
|
||||
nicknames: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_default_name(&mut self, name: &str) {
|
||||
self.default_names.push(name.into());
|
||||
}
|
||||
|
||||
pub fn add_nickname(&mut self, name: &str) {
|
||||
self.nicknames.push(name.into());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Info {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub manufacturer: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hw_version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sw_version: Option<String>,
|
||||
// attributes
|
||||
// customData
|
||||
// otherDeviceIds
|
||||
}
|
|
@ -4,12 +4,12 @@ use std::sync::Arc;
|
|||
use automation_cast::Cast;
|
||||
use futures::future::{join_all, OptionFuture};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::errors::{DeviceError, ErrorCode};
|
||||
use crate::request::{self, Intent, Request};
|
||||
use crate::response::{self, execute, query, sync, Response, ResponsePayload, State};
|
||||
use crate::GoogleHomeDevice;
|
||||
use crate::response::{self, execute, query, sync, Response, ResponsePayload};
|
||||
use crate::Device;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct GoogleHome {
|
||||
|
@ -30,10 +30,10 @@ impl GoogleHome {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn handle_request<T: Cast<dyn GoogleHomeDevice> + ?Sized + 'static>(
|
||||
pub async fn handle_request<T: Cast<dyn Device> + ?Sized + 'static>(
|
||||
&self,
|
||||
request: Request,
|
||||
devices: &HashMap<String, Arc<RwLock<Box<T>>>>,
|
||||
devices: &HashMap<String, Box<T>>,
|
||||
) -> Result<Response, FulfillmentError> {
|
||||
// TODO: What do we do if we actually get more then one thing in the input array, right now
|
||||
// we only respond to the first thing
|
||||
|
@ -59,14 +59,14 @@ impl GoogleHome {
|
|||
.map(|payload| Response::new(&request.request_id, payload))
|
||||
}
|
||||
|
||||
async fn sync<T: Cast<dyn GoogleHomeDevice> + ?Sized + 'static>(
|
||||
async fn sync<T: Cast<dyn Device> + ?Sized + 'static>(
|
||||
&self,
|
||||
devices: &HashMap<String, Arc<RwLock<Box<T>>>>,
|
||||
devices: &HashMap<String, Box<T>>,
|
||||
) -> sync::Payload {
|
||||
let mut resp_payload = sync::Payload::new(&self.user_id);
|
||||
let f = devices.iter().map(|(_, device)| async move {
|
||||
if let Some(device) = device.read().await.as_ref().cast() {
|
||||
Some(device.sync().await)
|
||||
if let Some(device) = device.as_ref().cast() {
|
||||
Some(Device::sync(device).await)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
@ -76,10 +76,10 @@ impl GoogleHome {
|
|||
resp_payload
|
||||
}
|
||||
|
||||
async fn query<T: Cast<dyn GoogleHomeDevice> + ?Sized + 'static>(
|
||||
async fn query<T: Cast<dyn Device> + ?Sized + 'static>(
|
||||
&self,
|
||||
payload: request::query::Payload,
|
||||
devices: &HashMap<String, Arc<RwLock<Box<T>>>>,
|
||||
devices: &HashMap<String, Box<T>>,
|
||||
) -> query::Payload {
|
||||
let mut resp_payload = query::Payload::new();
|
||||
let f = payload
|
||||
|
@ -89,9 +89,9 @@ impl GoogleHome {
|
|||
.map(|id| async move {
|
||||
// NOTE: Requires let_chains feature
|
||||
let device = if let Some(device) = devices.get(id.as_str())
|
||||
&& let Some(device) = device.read().await.as_ref().cast()
|
||||
&& let Some(device) = device.as_ref().cast()
|
||||
{
|
||||
device.query().await
|
||||
Device::query(device).await
|
||||
} else {
|
||||
let mut device = query::Device::new();
|
||||
device.set_offline();
|
||||
|
@ -108,10 +108,10 @@ impl GoogleHome {
|
|||
resp_payload
|
||||
}
|
||||
|
||||
async fn execute<T: Cast<dyn GoogleHomeDevice> + ?Sized + 'static>(
|
||||
async fn execute<T: Cast<dyn Device> + ?Sized + 'static>(
|
||||
&self,
|
||||
payload: request::execute::Payload,
|
||||
devices: &HashMap<String, Arc<RwLock<Box<T>>>>,
|
||||
devices: &HashMap<String, Box<T>>,
|
||||
) -> execute::Payload {
|
||||
let resp_payload = Arc::new(Mutex::new(response::execute::Payload::new()));
|
||||
|
||||
|
@ -121,12 +121,12 @@ impl GoogleHome {
|
|||
let mut success = response::execute::Command::new(execute::Status::Success);
|
||||
success.states = Some(execute::States {
|
||||
online: true,
|
||||
state: State::default(),
|
||||
state: Default::default(),
|
||||
});
|
||||
let mut offline = response::execute::Command::new(execute::Status::Offline);
|
||||
offline.states = Some(execute::States {
|
||||
online: false,
|
||||
state: State::default(),
|
||||
state: Default::default(),
|
||||
});
|
||||
let mut errors: HashMap<ErrorCode, response::execute::Command> = HashMap::new();
|
||||
|
||||
|
@ -138,16 +138,16 @@ impl GoogleHome {
|
|||
let execution = command.execution.clone();
|
||||
async move {
|
||||
if let Some(device) = devices.get(id.as_str())
|
||||
&& let Some(device) = device.write().await.as_mut().cast_mut()
|
||||
&& let Some(device) = device.as_ref().cast()
|
||||
{
|
||||
if !device.is_online() {
|
||||
if !device.is_online().await {
|
||||
return (id, Ok(false));
|
||||
}
|
||||
|
||||
// NOTE: We can not use .map here because async =(
|
||||
let mut results = Vec::new();
|
||||
for cmd in &execution {
|
||||
results.push(device.execute(cmd).await);
|
||||
results.push(Device::execute(device, cmd.clone()).await);
|
||||
}
|
||||
|
||||
// Convert vec of results to a result with a vec and the first
|
|
@ -7,12 +7,11 @@ mod fulfillment;
|
|||
mod request;
|
||||
mod response;
|
||||
|
||||
mod attributes;
|
||||
pub mod errors;
|
||||
pub mod traits;
|
||||
pub mod types;
|
||||
|
||||
pub use device::GoogleHomeDevice;
|
||||
pub use device::Device;
|
||||
pub use fulfillment::{FulfillmentError, GoogleHome};
|
||||
pub use request::Request;
|
||||
pub use response::Response;
|
142
google_home/google_home/src/request/execute.rs
Normal file
142
google_home/google_home/src/request/execute.rs
Normal file
|
@ -0,0 +1,142 @@
|
|||
use serde::Deserialize;
|
||||
|
||||
use crate::traits;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Payload {
|
||||
pub commands: Vec<Command>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Command {
|
||||
pub devices: Vec<Device>,
|
||||
pub execution: Vec<traits::Command>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Device {
|
||||
pub id: String,
|
||||
// customData
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::request::{Intent, Request};
|
||||
|
||||
#[test]
|
||||
fn deserialize_set_fan_speed() {
|
||||
let req = json!({
|
||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
|
||||
"inputs": [
|
||||
{
|
||||
"intent": "action.devices.EXECUTE",
|
||||
"payload": {
|
||||
"commands": [
|
||||
{
|
||||
"devices": [],
|
||||
"execution": [
|
||||
{
|
||||
"command": "action.devices.commands.SetFanSpeed",
|
||||
"params": {
|
||||
"fanSpeed": "Test"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let req: Request = serde_json::from_value(req).unwrap();
|
||||
|
||||
assert_eq!(req.inputs.len(), 1);
|
||||
match &req.inputs[0] {
|
||||
Intent::Execute(payload) => {
|
||||
assert_eq!(payload.commands.len(), 1);
|
||||
assert_eq!(payload.commands[0].devices.len(), 0);
|
||||
assert_eq!(payload.commands[0].execution.len(), 1);
|
||||
match &payload.commands[0].execution[0] {
|
||||
traits::Command::SetFanSpeed { fan_speed } => assert_eq!(fan_speed, "Test"),
|
||||
_ => panic!("Expected SetFanSpeed"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected Execute intent"),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize() {
|
||||
let req = json!({
|
||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
|
||||
"inputs": [
|
||||
{
|
||||
"intent": "action.devices.EXECUTE",
|
||||
"payload": {
|
||||
"commands": [
|
||||
{
|
||||
"devices": [
|
||||
{
|
||||
"id": "123",
|
||||
"customData": {
|
||||
"fooValue": 74,
|
||||
"barValue": true,
|
||||
"bazValue": "sheepdip"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "456",
|
||||
"customData": {
|
||||
"fooValue": 36,
|
||||
"barValue": false,
|
||||
"bazValue": "moarsheep"
|
||||
}
|
||||
}
|
||||
],
|
||||
"execution": [
|
||||
{
|
||||
"command": "action.devices.commands.OnOff",
|
||||
"params": {
|
||||
"on": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let req: Request = serde_json::from_value(req).unwrap();
|
||||
|
||||
println!("{:?}", req);
|
||||
|
||||
assert_eq!(
|
||||
req.request_id,
|
||||
"ff36a3cc-ec34-11e6-b1a0-64510650abcf".to_string()
|
||||
);
|
||||
assert_eq!(req.inputs.len(), 1);
|
||||
match &req.inputs[0] {
|
||||
Intent::Execute(payload) => {
|
||||
assert_eq!(payload.commands.len(), 1);
|
||||
assert_eq!(payload.commands[0].devices.len(), 2);
|
||||
assert_eq!(payload.commands[0].devices[0].id, "123");
|
||||
assert_eq!(payload.commands[0].devices[1].id, "456");
|
||||
assert_eq!(payload.commands[0].execution.len(), 1);
|
||||
match payload.commands[0].execution[0] {
|
||||
traits::Command::OnOff { on } => assert!(on),
|
||||
_ => panic!("Expected OnOff"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected Execute intent"),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -15,40 +15,42 @@ pub struct Device {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::request::{Intent, Request};
|
||||
|
||||
#[test]
|
||||
fn deserialize() {
|
||||
let json = r#"{
|
||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
|
||||
"inputs": [
|
||||
{
|
||||
"intent": "action.devices.QUERY",
|
||||
"payload": {
|
||||
"devices": [
|
||||
{
|
||||
"id": "123",
|
||||
"customData": {
|
||||
"fooValue": 74,
|
||||
"barValue": true,
|
||||
"bazValue": "foo"
|
||||
let req = json!({
|
||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
|
||||
"inputs": [
|
||||
{
|
||||
"intent": "action.devices.QUERY",
|
||||
"payload": {
|
||||
"devices": [
|
||||
{
|
||||
"id": "123",
|
||||
"customData": {
|
||||
"fooValue": 74,
|
||||
"barValue": true,
|
||||
"bazValue": "foo"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "456",
|
||||
"customData": {
|
||||
"fooValue": 12,
|
||||
"barValue": false,
|
||||
"bazValue": "bar"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "456",
|
||||
"customData": {
|
||||
"fooValue": 12,
|
||||
"barValue": false,
|
||||
"bazValue": "bar"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
]
|
||||
});
|
||||
|
||||
let req: Request = serde_json::from_str(json).unwrap();
|
||||
let req: Request = serde_json::from_value(req).unwrap();
|
||||
|
||||
println!("{:?}", req);
|
||||
|
|
@ -1,19 +1,21 @@
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::request::{Intent, Request};
|
||||
|
||||
#[test]
|
||||
fn deserialize() {
|
||||
let json = r#"{
|
||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
|
||||
"inputs": [
|
||||
{
|
||||
"intent": "action.devices.SYNC"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
let req = json!({
|
||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
|
||||
"inputs": [
|
||||
{
|
||||
"intent": "action.devices.SYNC"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let req: Request = serde_json::from_str(json).unwrap();
|
||||
let req: Request = serde_json::from_value(req).unwrap();
|
||||
|
||||
println!("{:?}", req);
|
||||
|
|
@ -27,16 +27,3 @@ pub enum ResponsePayload {
|
|||
Query(query::Payload),
|
||||
Execute(execute::Payload),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct State {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub on: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub current_fan_speed_setting: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub humidity_ambient_percent: Option<isize>,
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
use serde::Serialize;
|
||||
|
||||
use crate::errors::ErrorCode;
|
||||
use crate::response::State;
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
@ -72,7 +71,7 @@ pub struct States {
|
|||
pub online: bool,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub state: State,
|
||||
pub state: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
|
@ -87,19 +86,19 @@ pub enum Status {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::errors::DeviceError;
|
||||
use crate::response::{Response, ResponsePayload, State};
|
||||
use crate::response::{Response, ResponsePayload};
|
||||
|
||||
#[test]
|
||||
fn serialize() {
|
||||
let mut execute_resp = Payload::new();
|
||||
|
||||
let state = State {
|
||||
on: Some(true),
|
||||
current_fan_speed_setting: None,
|
||||
humidity_ambient_percent: None,
|
||||
};
|
||||
let state = json!({
|
||||
"on": true,
|
||||
});
|
||||
let mut command = Command::new(Status::Success);
|
||||
command.states = Some(States {
|
||||
online: true,
|
||||
|
@ -118,10 +117,28 @@ mod tests {
|
|||
ResponsePayload::Execute(execute_resp),
|
||||
);
|
||||
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
let resp = serde_json::to_value(resp).unwrap();
|
||||
|
||||
println!("{}", json);
|
||||
let resp_expected = json!({
|
||||
"payload": {
|
||||
"commands": [
|
||||
{
|
||||
"states": {
|
||||
"on": true,
|
||||
"online": true
|
||||
},
|
||||
"ids": ["123"],
|
||||
"status": "SUCCESS"
|
||||
}, {
|
||||
"errorCode": "deviceNotFound",
|
||||
"ids": ["456"],
|
||||
"status":"ERROR"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf"
|
||||
});
|
||||
|
||||
// TODO: Add a known correct output to test against
|
||||
assert_eq!(resp, resp_expected);
|
||||
}
|
||||
}
|
|
@ -3,7 +3,6 @@ use std::collections::HashMap;
|
|||
use serde::Serialize;
|
||||
|
||||
use crate::errors::ErrorCode;
|
||||
use crate::response::State;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
@ -53,7 +52,7 @@ pub struct Device {
|
|||
error_code: Option<ErrorCode>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub state: State,
|
||||
pub state: serde_json::Value,
|
||||
}
|
||||
|
||||
impl Device {
|
||||
|
@ -62,7 +61,7 @@ impl Device {
|
|||
online: true,
|
||||
status: Status::Success,
|
||||
error_code: None,
|
||||
state: State::default(),
|
||||
state: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -88,6 +87,8 @@ impl Default for Device {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::response::{Response, ResponsePayload};
|
||||
|
||||
|
@ -96,11 +97,15 @@ mod tests {
|
|||
let mut query_resp = Payload::new();
|
||||
|
||||
let mut device = Device::new();
|
||||
device.state.on = Some(true);
|
||||
device.state = json!({
|
||||
"on": true,
|
||||
});
|
||||
query_resp.add_device("123", device);
|
||||
|
||||
let mut device = Device::new();
|
||||
device.state.on = Some(false);
|
||||
device.state = json!({
|
||||
"on": true,
|
||||
});
|
||||
query_resp.add_device("456", device);
|
||||
|
||||
let resp = Response::new(
|
||||
|
@ -108,10 +113,26 @@ mod tests {
|
|||
ResponsePayload::Query(query_resp),
|
||||
);
|
||||
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
let resp = serde_json::to_value(resp).unwrap();
|
||||
|
||||
println!("{}", json);
|
||||
let resp_expected = json!({
|
||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
|
||||
"payload": {
|
||||
"devices": {
|
||||
"123": {
|
||||
"online": true,
|
||||
"status": "SUCCESS",
|
||||
"on": true
|
||||
},
|
||||
"456": {
|
||||
"online": true,
|
||||
"status": "SUCCESS",
|
||||
"on":true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: Add a known correct output to test against
|
||||
assert_eq!(resp, resp_expected);
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
use serde::Serialize;
|
||||
|
||||
use crate::attributes::Attributes;
|
||||
use crate::device;
|
||||
use crate::errors::ErrorCode;
|
||||
use crate::traits::Trait;
|
||||
|
@ -47,7 +46,8 @@ pub struct Device {
|
|||
pub room_hint: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub device_info: Option<device::Info>,
|
||||
pub attributes: Attributes,
|
||||
#[serde(skip_serializing_if = "serde_json::Value::is_null")]
|
||||
pub attributes: serde_json::Value,
|
||||
}
|
||||
|
||||
impl Device {
|
||||
|
@ -61,13 +61,15 @@ impl Device {
|
|||
notification_supported_by_agent: None,
|
||||
room_hint: None,
|
||||
device_info: None,
|
||||
attributes: Attributes::default(),
|
||||
attributes: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::response::{Response, ResponsePayload};
|
||||
use crate::traits::Trait;
|
||||
|
@ -97,10 +99,35 @@ mod tests {
|
|||
ResponsePayload::Sync(sync_resp),
|
||||
);
|
||||
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
let resp = serde_json::to_value(resp).unwrap();
|
||||
|
||||
println!("{}", json);
|
||||
let resp_expected = json!({
|
||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
|
||||
"payload": {
|
||||
"agentUserId": "1836.15267389",
|
||||
"devices": [
|
||||
{
|
||||
"id": "123",
|
||||
"type": "action.devices.types.KETTLE",
|
||||
"traits": ["action.devices.traits.OnOff"],
|
||||
"name": {
|
||||
"defaultNames": ["My Outlet 1234"],
|
||||
"name": "Night light",
|
||||
"nicknames": ["wall plug"]
|
||||
},
|
||||
"willReportState": false,
|
||||
"roomHint": "kitchen",
|
||||
"deviceInfo": {
|
||||
"manufacturer": "lights-out-inc",
|
||||
"model": "hs1234",
|
||||
"hwVersion": "3.2",
|
||||
"swVersion": "11.4"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// assert_eq!(json, r#"{"requestId":"ff36a3cc-ec34-11e6-b1a0-64510650abcf","payload":{"agentUserId":"1836.15267389","devices":[{"id":"123","type":"action.devices.types.KETTLE","traits":["action.devices.traits.OnOff"],"name":{"defaultNames":["My Outlet 1234"],"name":"Night light","nicknames":["wall plug"]},"willReportState":false,"roomHint":"kitchen","deviceInfo":{"manufacturer":"lights-out-inc","model":"hs1234","hwVersion":"3.2","swVersion":"11.4"}}]}}"#)
|
||||
assert_eq!(resp, resp_expected);
|
||||
}
|
||||
}
|
83
google_home/google_home/src/traits.rs
Normal file
83
google_home/google_home/src/traits.rs
Normal file
|
@ -0,0 +1,83 @@
|
|||
#![allow(non_snake_case)]
|
||||
use automation_cast::Cast;
|
||||
use google_home_macro::traits;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::errors::ErrorCode;
|
||||
use crate::Device;
|
||||
|
||||
traits! {
|
||||
Device,
|
||||
"action.devices.traits.OnOff" => trait OnOff {
|
||||
command_only_on_off: Option<bool>,
|
||||
query_only_on_off: Option<bool>,
|
||||
async fn on(&self) -> Result<bool, 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 {
|
||||
scene_reversible: Option<bool>,
|
||||
|
||||
"action.devices.commands.ActivateScene" => async fn set_active(&self, deactivate: bool) -> Result<(), ErrorCode>,
|
||||
},
|
||||
"action.devices.traits.FanSpeed" => trait FanSpeed {
|
||||
reversible: Option<bool>,
|
||||
command_only_fan_speed: Option<bool>,
|
||||
available_fan_speeds: AvailableSpeeds,
|
||||
|
||||
async fn current_fan_speed_setting(&self) -> Result<String, ErrorCode>,
|
||||
|
||||
// TODO: Figure out some syntax for optional command?
|
||||
// Probably better to just force the user to always implement commands?
|
||||
"action.devices.commands.SetFanSpeed" => async fn set_fan_speed(&self, fan_speed: String) -> Result<(), ErrorCode>,
|
||||
},
|
||||
"action.devices.traits.HumiditySetting" => trait HumiditySetting {
|
||||
query_only_humidity_setting: Option<bool>,
|
||||
|
||||
async fn humidity_ambient_percent(&self) -> Result<isize, ErrorCode>,
|
||||
},
|
||||
"action.devices.traits.TemperatureControl" => trait TemperatureSetting {
|
||||
query_only_temperature_control: Option<bool>,
|
||||
// TODO: Add rename
|
||||
temperatureUnitForUX: TemperatureUnit,
|
||||
|
||||
async fn temperature_ambient_celsius(&self) -> Result<f32, ErrorCode>,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SpeedValue {
|
||||
pub speed_synonym: Vec<String>,
|
||||
pub lang: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Speed {
|
||||
pub speed_name: String,
|
||||
pub speed_values: Vec<SpeedValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AvailableSpeeds {
|
||||
pub speeds: Vec<Speed>,
|
||||
pub ordered: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub enum TemperatureUnit {
|
||||
#[serde(rename = "C")]
|
||||
Celsius,
|
||||
#[serde(rename = "F")]
|
||||
Fahrenheit,
|
||||
}
|
|
@ -12,4 +12,10 @@ pub enum Type {
|
|||
Scene,
|
||||
#[serde(rename = "action.devices.types.AIRPURIFIER")]
|
||||
AirPurifier,
|
||||
#[serde(rename = "action.devices.types.DOOR")]
|
||||
Door,
|
||||
#[serde(rename = "action.devices.types.WINDOW")]
|
||||
Window,
|
||||
#[serde(rename = "action.devices.types.DRAWER")]
|
||||
Drawer,
|
||||
}
|
12
google_home/google_home_macro/Cargo.toml
Normal file
12
google_home/google_home_macro/Cargo.toml
Normal file
|
@ -0,0 +1,12 @@
|
|||
[package]
|
||||
name = "google_home_macro"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
proc-macro2 = { workspace = true }
|
||||
quote = { workspace = true }
|
||||
syn = { workspace = true }
|
569
google_home/google_home_macro/src/lib.rs
Normal file
569
google_home/google_home_macro/src/lib.rs
Normal file
|
@ -0,0 +1,569 @@
|
|||
#![feature(let_chains)]
|
||||
#![feature(iter_intersperse)]
|
||||
use proc_macro::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::parse::Parse;
|
||||
use syn::punctuated::Punctuated;
|
||||
use syn::token::Brace;
|
||||
use syn::{
|
||||
braced, parse_macro_input, GenericArgument, Ident, LitStr, Path, PathArguments, PathSegment,
|
||||
ReturnType, Signature, Token, Type, TypePath,
|
||||
};
|
||||
|
||||
mod kw {
|
||||
use syn::custom_keyword;
|
||||
|
||||
custom_keyword!(required);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FieldAttribute {
|
||||
ident: Ident,
|
||||
_colon_token: Token![:],
|
||||
ty: Type,
|
||||
}
|
||||
|
||||
impl Parse for FieldAttribute {
|
||||
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
|
||||
Ok(Self {
|
||||
ident: input.parse()?,
|
||||
_colon_token: input.parse()?,
|
||||
ty: input.parse()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FieldState {
|
||||
sign: Signature,
|
||||
}
|
||||
|
||||
impl Parse for FieldState {
|
||||
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
|
||||
Ok(Self {
|
||||
sign: input.parse()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FieldExecute {
|
||||
name: LitStr,
|
||||
_fat_arrow_token: Token![=>],
|
||||
sign: Signature,
|
||||
}
|
||||
|
||||
impl Parse for FieldExecute {
|
||||
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
|
||||
Ok(Self {
|
||||
name: input.parse()?,
|
||||
_fat_arrow_token: input.parse()?,
|
||||
sign: input.parse()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Field {
|
||||
Attribute(FieldAttribute),
|
||||
State(FieldState),
|
||||
Execute(FieldExecute),
|
||||
}
|
||||
|
||||
impl Parse for Field {
|
||||
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
|
||||
if input.peek(Ident) {
|
||||
Ok(Field::Attribute(input.parse()?))
|
||||
} else if input.peek(LitStr) {
|
||||
Ok(Field::Execute(input.parse()?))
|
||||
} else {
|
||||
Ok(Field::State(input.parse()?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Trait {
|
||||
name: LitStr,
|
||||
_fat_arrow_token: Token![=>],
|
||||
_trait_token: Token![trait],
|
||||
ident: Ident,
|
||||
_brace_token: Brace,
|
||||
fields: Punctuated<Field, Token![,]>,
|
||||
}
|
||||
|
||||
impl Parse for Trait {
|
||||
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
|
||||
let content;
|
||||
Ok(Self {
|
||||
name: input.parse()?,
|
||||
_fat_arrow_token: input.parse()?,
|
||||
_trait_token: input.parse()?,
|
||||
ident: input.parse()?,
|
||||
_brace_token: braced!(content in input),
|
||||
fields: content.parse_terminated(Field::parse, Token![,])?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Input {
|
||||
ty: TypePath,
|
||||
_comma: Token![,],
|
||||
traits: Punctuated<Trait, Token![,]>,
|
||||
}
|
||||
|
||||
// TODO: Error on duplicate name?
|
||||
impl Parse for Input {
|
||||
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
|
||||
Ok(Self {
|
||||
ty: input.parse()?,
|
||||
_comma: input.parse()?,
|
||||
traits: input.parse_terminated(Trait::parse, Token![,])?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_type_path(ty: &syn::Type) -> Option<&Path> {
|
||||
match *ty {
|
||||
Type::Path(ref typepath) if typepath.qself.is_none() => Some(&typepath.path),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_segment<'a>(path: &'a Path, options: &[&str]) -> Option<&'a PathSegment> {
|
||||
let idents_of_path = path
|
||||
.segments
|
||||
.iter()
|
||||
.map(|segment| segment.ident.to_string())
|
||||
.intersperse('|'.into())
|
||||
.collect::<String>();
|
||||
|
||||
options
|
||||
.iter()
|
||||
.find(|s| &idents_of_path == *s)
|
||||
.and_then(|_| path.segments.last())
|
||||
}
|
||||
|
||||
// Based on: https://stackoverflow.com/a/56264023
|
||||
fn extract_type_from_option(ty: &syn::Type) -> Option<&syn::Type> {
|
||||
extract_type_path(ty)
|
||||
.and_then(|path| {
|
||||
extract_segment(path, &["Option", "std|option|Option", "core|option|Option"])
|
||||
})
|
||||
.and_then(|path_seg| {
|
||||
let type_params = &path_seg.arguments;
|
||||
// It should have only on angle-bracketed param ("<String>"):
|
||||
match *type_params {
|
||||
PathArguments::AngleBracketed(ref params) => params.args.first(),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.and_then(|generic_arg| match *generic_arg {
|
||||
GenericArgument::Type(ref ty) => Some(ty),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_type_from_result(ty: &syn::Type) -> Option<&syn::Type> {
|
||||
extract_type_path(ty)
|
||||
.and_then(|path| {
|
||||
extract_segment(path, &["Result", "std|result|Result", "core|result|Result"])
|
||||
})
|
||||
.and_then(|path_seg| {
|
||||
let type_params = &path_seg.arguments;
|
||||
// It should have only on angle-bracketed param ("<String>"):
|
||||
match *type_params {
|
||||
PathArguments::AngleBracketed(ref params) => params.args.first(),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.and_then(|generic_arg| match *generic_arg {
|
||||
GenericArgument::Type(ref ty) => Some(ty),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_attributes_struct_ident(t: &Trait) -> Ident {
|
||||
syn::Ident::new(&format!("{}Attributes", t.ident), t.ident.span())
|
||||
}
|
||||
|
||||
fn get_attributes_struct(t: &Trait) -> proc_macro2::TokenStream {
|
||||
let fields = t.fields.iter().filter_map(|f| match f {
|
||||
Field::Attribute(attr) => {
|
||||
let ident = &attr.ident;
|
||||
let ty = &attr.ty;
|
||||
|
||||
// TODO: Extract into function
|
||||
if let Some(ty) = extract_type_from_option(ty) {
|
||||
Some(quote! {
|
||||
#[serde(skip_serializing_if = "core::option::Option::is_none")]
|
||||
#ident: ::core::option::Option<#ty>
|
||||
})
|
||||
} else {
|
||||
Some(quote! {
|
||||
#ident: #ty
|
||||
})
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
|
||||
let name = get_attributes_struct_ident(t);
|
||||
quote! {
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct #name {
|
||||
#(#fields,)*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_state_struct_ident(t: &Trait) -> Ident {
|
||||
syn::Ident::new(&format!("{}State", t.ident), t.ident.span())
|
||||
}
|
||||
|
||||
fn get_state_struct(t: &Trait) -> proc_macro2::TokenStream {
|
||||
let fields = t.fields.iter().filter_map(|f| match f {
|
||||
Field::State(state) => {
|
||||
let ident = &state.sign.ident;
|
||||
|
||||
let ReturnType::Type(_, ty) = &state.sign.output else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let ty = extract_type_from_result(ty).unwrap_or(ty);
|
||||
|
||||
if let Some(ty) = extract_type_from_option(ty) {
|
||||
Some(quote! {
|
||||
#[serde(skip_serializing_if = "core::option::Option::is_none")]
|
||||
#ident: ::core::option::Option<#ty>
|
||||
})
|
||||
} else {
|
||||
Some(quote! {#ident: #ty})
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
|
||||
let name = get_state_struct_ident(t);
|
||||
quote! {
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct #name {
|
||||
#(#fields,)*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_command_enum(traits: &Punctuated<Trait, Token![,]>) -> proc_macro2::TokenStream {
|
||||
let items = traits.iter().flat_map(|t| {
|
||||
t.fields.iter().filter_map(|f| match f {
|
||||
Field::Execute(execute) => {
|
||||
let name = execute.name.value();
|
||||
let ident = Ident::new(
|
||||
name.split_at(name.rfind('.').map(|v| v + 1).unwrap_or(0)).1,
|
||||
execute.name.span(),
|
||||
);
|
||||
|
||||
let parameters = execute.sign.inputs.iter().skip(1);
|
||||
|
||||
Some(quote! {
|
||||
#[serde(rename = #name, rename_all = "camelCase")]
|
||||
#ident {
|
||||
#(#parameters,)*
|
||||
}
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
|
||||
quote! {
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(tag = "command", content = "params", rename_all = "camelCase")]
|
||||
pub enum Command {
|
||||
#(#items,)*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_trait_enum(traits: &Punctuated<Trait, Token![,]>) -> proc_macro2::TokenStream {
|
||||
let items = traits.iter().map(|t| {
|
||||
let name = &t.name;
|
||||
let ident = &t.ident;
|
||||
quote! {
|
||||
#[serde(rename = #name)]
|
||||
#ident
|
||||
}
|
||||
});
|
||||
|
||||
quote! {
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub enum Trait {
|
||||
#(#items,)*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_trait(t: &Trait) -> proc_macro2::TokenStream {
|
||||
let fields = t.fields.iter().map(|f| match f {
|
||||
Field::Attribute(attr) => {
|
||||
let name = &attr.ident;
|
||||
let ty = &attr.ty;
|
||||
|
||||
// If the default type is marked as optional, respond None by default
|
||||
if let Some(ty) = extract_type_from_option(ty) {
|
||||
quote! {
|
||||
fn #name(&self) -> Option<#ty> {
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
fn #name(&self) -> #ty;
|
||||
}
|
||||
}
|
||||
}
|
||||
Field::State(state) => {
|
||||
let sign = &state.sign;
|
||||
|
||||
let ReturnType::Type(_, ty) = &state.sign.output else {
|
||||
todo!("Handle weird function return types");
|
||||
};
|
||||
|
||||
let inner = extract_type_from_result(ty);
|
||||
// If the default type is marked as optional, respond None by default
|
||||
if extract_type_from_option(inner.unwrap_or(ty)).is_some() {
|
||||
if inner.is_some() {
|
||||
quote! {
|
||||
#sign {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
#sign {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
#sign;
|
||||
}
|
||||
}
|
||||
}
|
||||
Field::Execute(execute) => {
|
||||
let sign = &execute.sign;
|
||||
quote! {
|
||||
#sign;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let ident = &t.ident;
|
||||
|
||||
let attr_ident = get_attributes_struct_ident(t);
|
||||
let attr = t.fields.iter().filter_map(|f| match f {
|
||||
Field::Attribute(attr) => {
|
||||
let name = &attr.ident;
|
||||
|
||||
Some(quote! {
|
||||
#name: self.#name()
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
|
||||
let state_ident = get_state_struct_ident(t);
|
||||
let state = t.fields.iter().filter_map(|f| match f {
|
||||
Field::State(state) => {
|
||||
let ident = &state.sign.ident;
|
||||
let f_ident = &state.sign.ident;
|
||||
|
||||
let asyncness = if state.sign.asyncness.is_some() {
|
||||
quote! {.await}
|
||||
} else {
|
||||
quote! {}
|
||||
};
|
||||
|
||||
let errors = if let ReturnType::Type(_, ty) = &state.sign.output
|
||||
&& extract_type_from_result(ty).is_some()
|
||||
{
|
||||
quote! {?}
|
||||
} else {
|
||||
quote! {}
|
||||
};
|
||||
|
||||
Some(quote! {
|
||||
#ident: self.#f_ident() #asyncness #errors,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
|
||||
quote! {
|
||||
#[async_trait::async_trait]
|
||||
pub trait #ident: Sync + Send {
|
||||
#(#fields)*
|
||||
|
||||
fn get_attributes(&self) -> #attr_ident {
|
||||
#attr_ident { #(#attr,)* }
|
||||
}
|
||||
|
||||
async fn get_state(&self) -> Result<#state_ident, Box<dyn ::std::error::Error>> {
|
||||
Ok(#state_ident { #(#state)* })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[proc_macro]
|
||||
pub fn traits(item: TokenStream) -> TokenStream {
|
||||
let input = parse_macro_input!(item as Input);
|
||||
let traits = input.traits;
|
||||
|
||||
let structs = traits.iter().map(|t| {
|
||||
let attr = get_attributes_struct(t);
|
||||
let state = get_state_struct(t);
|
||||
let tra = get_trait(t);
|
||||
|
||||
quote! {
|
||||
#attr
|
||||
#state
|
||||
#tra
|
||||
}
|
||||
});
|
||||
|
||||
let command_enum = get_command_enum(&traits);
|
||||
let trait_enum = get_trait_enum(&traits);
|
||||
|
||||
let sync = traits.iter().map(|t| {
|
||||
let ident = &t.ident;
|
||||
|
||||
quote! {
|
||||
if let Some(t) = self.cast() as Option<&dyn #ident> {
|
||||
traits.push(Trait::#ident);
|
||||
let value = serde_json::to_value(t.get_attributes())?;
|
||||
json_value_merge::Merge::merge(&mut attrs, &value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let query = traits.iter().map(|t| {
|
||||
let ident = &t.ident;
|
||||
|
||||
quote! {
|
||||
if let Some(t) = self.cast() as Option<&dyn #ident> {
|
||||
let value = serde_json::to_value(t.get_state().await?)?;
|
||||
json_value_merge::Merge::merge(&mut state, &value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let execute = traits.iter().flat_map(|t| {
|
||||
t.fields.iter().filter_map(|f| match f {
|
||||
Field::Execute(execute) => {
|
||||
let ident = &t.ident;
|
||||
let name = execute.name.value();
|
||||
let command_name = Ident::new(
|
||||
name.split_at(name.rfind('.').map(|v| v + 1).unwrap_or(0)).1,
|
||||
execute.name.span(),
|
||||
);
|
||||
let f_name = &&execute.sign.ident;
|
||||
let parameters = execute
|
||||
.sign
|
||||
.inputs
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
if let syn::FnArg::Typed(p) = p {
|
||||
Some(&p.pat)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let asyncness = if execute.sign.asyncness.is_some() {
|
||||
quote! {.await}
|
||||
} else {
|
||||
quote! {}
|
||||
};
|
||||
|
||||
let errors = if let ReturnType::Type(_, ty) = &execute.sign.output
|
||||
&& extract_type_from_result(ty).is_some()
|
||||
{
|
||||
quote! {?}
|
||||
} else {
|
||||
quote! {}
|
||||
};
|
||||
|
||||
Some(quote! {
|
||||
Command::#command_name {#(#parameters,)*} => {
|
||||
if let Some(t) = self.cast() as Option<&dyn #ident> {
|
||||
t.#f_name(#(#parameters,)*) #asyncness #errors;
|
||||
serde_json::to_value(t.get_state().await?)?
|
||||
} else {
|
||||
todo!("Device does not support action, return proper error");
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
|
||||
let ty = input.ty;
|
||||
|
||||
let fulfillment = Ident::new(
|
||||
&format!("{}Fulfillment", ty.path.segments.last().unwrap().ident),
|
||||
ty.path.segments.last().unwrap().ident.span(),
|
||||
);
|
||||
|
||||
quote! {
|
||||
// TODO: This is always the same, so should not be part of the macro, but instead something
|
||||
// else
|
||||
#[async_trait::async_trait]
|
||||
pub trait #fulfillment: Sync + Send {
|
||||
async fn sync(&self) -> Result<(Vec<Trait>, serde_json::Value), Box<dyn ::std::error::Error>>;
|
||||
async fn query(&self) -> Result<serde_json::Value, Box<dyn ::std::error::Error>>;
|
||||
async fn execute(&self, command: Command) -> Result<serde_json::Value, Box<dyn std::error::Error>>;
|
||||
}
|
||||
|
||||
#(#structs)*
|
||||
|
||||
#command_enum
|
||||
#trait_enum
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl<D> #fulfillment for D where D: #ty
|
||||
{
|
||||
async fn sync(&self) -> Result<(Vec<Trait>, serde_json::Value), Box<dyn ::std::error::Error>> {
|
||||
let mut traits = Vec::new();
|
||||
let mut attrs = serde_json::Value::Null;
|
||||
|
||||
#(#sync)*
|
||||
|
||||
Ok((traits, attrs))
|
||||
}
|
||||
|
||||
async fn query(&self) -> Result<serde_json::Value, Box<dyn ::std::error::Error>> {
|
||||
let mut state = serde_json::Value::Null;
|
||||
|
||||
#(#query)*
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
async fn execute(&self, command: Command) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
||||
let value = match command {
|
||||
#(#execute)*
|
||||
};
|
||||
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
.into()
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
[toolchain]
|
||||
channel = "nightly-2023-11-15"
|
||||
components = ["rustfmt", "clippy"]
|
||||
channel = "nightly-2024-12-06"
|
||||
components = ["rustfmt", "clippy", "rust-analyzer"]
|
||||
profile = "minimal"
|
||||
|
|
|
@ -1,73 +0,0 @@
|
|||
use std::fs::File;
|
||||
use std::io::{Result, Write};
|
||||
|
||||
use automation::config::MqttDeviceConfig;
|
||||
use automation::devices::{
|
||||
AirFilter, AirFilterConfig, AudioSetup, AudioSetupConfig, ContactSensor, ContactSensorConfig,
|
||||
DebugBridge, DebugBridgeConfig, FlagIDs, HueBridge, HueBridgeConfig, HueGroup, HueGroupConfig,
|
||||
IkeaOutlet, IkeaOutletConfig, KasaOutlet, KasaOutletConfig, LightSensor, LightSensorConfig,
|
||||
Ntfy, NtfyConfig, Presence, PresenceConfig, PresenceDeviceConfig, TriggerConfig, WakeOnLAN,
|
||||
WakeOnLANConfig, Washer, WasherConfig,
|
||||
};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut d = File::create("./definitions/generated.lua")?;
|
||||
|
||||
writeln!(
|
||||
&mut d,
|
||||
"-- WARN: This file is automatically generated, do not manually edit\n"
|
||||
)?;
|
||||
writeln!(&mut d, "---@meta")?;
|
||||
|
||||
writeln!(&mut d, "{}", MqttDeviceConfig::generate_lua_definition())?;
|
||||
|
||||
writeln!(&mut d, "{}", AirFilter::generate_lua_definition())?;
|
||||
writeln!(&mut d, "{}", AirFilterConfig::generate_lua_definition())?;
|
||||
|
||||
writeln!(&mut d, "{}", AudioSetup::generate_lua_definition())?;
|
||||
writeln!(&mut d, "{}", AudioSetupConfig::generate_lua_definition())?;
|
||||
|
||||
writeln!(&mut d, "{}", ContactSensor::generate_lua_definition())?;
|
||||
writeln!(&mut d, "{}", ContactSensorConfig::generate_lua_definition())?;
|
||||
writeln!(
|
||||
&mut d,
|
||||
"{}",
|
||||
PresenceDeviceConfig::generate_lua_definition()
|
||||
)?;
|
||||
writeln!(&mut d, "{}", TriggerConfig::generate_lua_definition())?;
|
||||
|
||||
writeln!(&mut d, "{}", DebugBridge::generate_lua_definition())?;
|
||||
writeln!(&mut d, "{}", DebugBridgeConfig::generate_lua_definition())?;
|
||||
|
||||
writeln!(&mut d, "{}", HueBridge::generate_lua_definition())?;
|
||||
writeln!(&mut d, "{}", HueBridgeConfig::generate_lua_definition())?;
|
||||
writeln!(&mut d, "{}", FlagIDs::generate_lua_definition())?;
|
||||
|
||||
writeln!(&mut d, "{}", HueGroup::generate_lua_definition())?;
|
||||
writeln!(&mut d, "{}", HueGroupConfig::generate_lua_definition())?;
|
||||
|
||||
writeln!(&mut d, "{}", IkeaOutlet::generate_lua_definition())?;
|
||||
writeln!(&mut d, "{}", IkeaOutletConfig::generate_lua_definition())?;
|
||||
|
||||
writeln!(&mut d, "{}", KasaOutlet::generate_lua_definition())?;
|
||||
writeln!(&mut d, "{}", KasaOutletConfig::generate_lua_definition())?;
|
||||
|
||||
writeln!(&mut d, "{}", LightSensor::generate_lua_definition())?;
|
||||
writeln!(&mut d, "{}", LightSensorConfig::generate_lua_definition())?;
|
||||
|
||||
writeln!(&mut d, "{}", Ntfy::generate_lua_definition())?;
|
||||
writeln!(&mut d, "{}", NtfyConfig::generate_lua_definition())?;
|
||||
|
||||
writeln!(&mut d, "{}", Presence::generate_lua_definition())?;
|
||||
writeln!(&mut d, "{}", PresenceConfig::generate_lua_definition())?;
|
||||
|
||||
writeln!(&mut d, "{}", WakeOnLAN::generate_lua_definition())?;
|
||||
writeln!(&mut d, "{}", WakeOnLANConfig::generate_lua_definition())?;
|
||||
|
||||
writeln!(&mut d, "{}", Washer::generate_lua_definition())?;
|
||||
writeln!(&mut d, "{}", WasherConfig::generate_lua_definition())?;
|
||||
|
||||
writeln!(&mut d, "-- End of generated file")?;
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -1,231 +0,0 @@
|
|||
use async_trait::async_trait;
|
||||
use automation_macro::{LuaDevice, LuaDeviceConfig, LuaTypeDefinition};
|
||||
use google_home::device::Name;
|
||||
use google_home::errors::ErrorCode;
|
||||
use google_home::traits::{AvailableSpeeds, FanSpeed, HumiditySetting, OnOff, Speed, SpeedValues};
|
||||
use google_home::types::Type;
|
||||
use google_home::GoogleHomeDevice;
|
||||
use rumqttc::Publish;
|
||||
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, LuaTypeDefinition)]
|
||||
pub struct AirFilterConfig {
|
||||
#[device_config(flatten)]
|
||||
pub info: InfoConfig,
|
||||
#[device_config(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
#[device_config(from_lua)]
|
||||
pub client: WrappedAsyncClient,
|
||||
}
|
||||
|
||||
#[derive(Debug, LuaDevice)]
|
||||
pub struct AirFilter {
|
||||
config: AirFilterConfig,
|
||||
|
||||
last_known_state: AirFilterState,
|
||||
}
|
||||
|
||||
impl AirFilter {
|
||||
async fn set_speed(&self, state: AirFilterFanState) {
|
||||
let message = SetAirFilterFanState::new(state);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for AirFilter {
|
||||
type Config = AirFilterConfig;
|
||||
type Error = rumqttc::ClientError;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
trace!(id = config.info.identifier(), "Setting up AirFilter");
|
||||
|
||||
config
|
||||
.client
|
||||
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||
.await?;
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
last_known_state: AirFilterState {
|
||||
state: AirFilterFanState::Off,
|
||||
humidity: 0.0,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for AirFilter {
|
||||
fn get_id(&self) -> String {
|
||||
self.config.info.identifier()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnMqtt for AirFilter {
|
||||
async fn on_mqtt(&mut 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.last_known_state {
|
||||
return;
|
||||
}
|
||||
|
||||
debug!(id = Device::get_id(self), "Updating state to {state:?}");
|
||||
|
||||
self.last_known_state = state;
|
||||
}
|
||||
}
|
||||
|
||||
impl GoogleHomeDevice for AirFilter {
|
||||
fn get_device_type(&self) -> Type {
|
||||
Type::AirPurifier
|
||||
}
|
||||
|
||||
fn get_device_name(&self) -> Name {
|
||||
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 {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnOff for AirFilter {
|
||||
async fn is_on(&self) -> Result<bool, ErrorCode> {
|
||||
Ok(self.last_known_state.state != AirFilterFanState::Off)
|
||||
}
|
||||
|
||||
async fn set_on(&mut self, on: bool) -> Result<(), ErrorCode> {
|
||||
debug!("Turning on air filter: {on}");
|
||||
|
||||
if on {
|
||||
self.set_speed(AirFilterFanState::High).await;
|
||||
} else {
|
||||
self.set_speed(AirFilterFanState::Off).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FanSpeed for AirFilter {
|
||||
fn available_speeds(&self) -> AvailableSpeeds {
|
||||
AvailableSpeeds {
|
||||
speeds: vec![
|
||||
Speed {
|
||||
speed_name: "off".into(),
|
||||
speed_values: vec![SpeedValues {
|
||||
speed_synonym: vec!["Off".into()],
|
||||
lang: "en".into(),
|
||||
}],
|
||||
},
|
||||
Speed {
|
||||
speed_name: "low".into(),
|
||||
speed_values: vec![SpeedValues {
|
||||
speed_synonym: vec!["Low".into()],
|
||||
lang: "en".into(),
|
||||
}],
|
||||
},
|
||||
Speed {
|
||||
speed_name: "medium".into(),
|
||||
speed_values: vec![SpeedValues {
|
||||
speed_synonym: vec!["Medium".into()],
|
||||
lang: "en".into(),
|
||||
}],
|
||||
},
|
||||
Speed {
|
||||
speed_name: "high".into(),
|
||||
speed_values: vec![SpeedValues {
|
||||
speed_synonym: vec!["High".into()],
|
||||
lang: "en".into(),
|
||||
}],
|
||||
},
|
||||
],
|
||||
ordered: true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn current_speed(&self) -> String {
|
||||
let speed = match self.last_known_state.state {
|
||||
AirFilterFanState::Off => "off",
|
||||
AirFilterFanState::Low => "low",
|
||||
AirFilterFanState::Medium => "medium",
|
||||
AirFilterFanState::High => "high",
|
||||
};
|
||||
|
||||
speed.into()
|
||||
}
|
||||
|
||||
async fn set_speed(&self, speed: &str) -> Result<(), ErrorCode> {
|
||||
let state = if speed == "off" {
|
||||
AirFilterFanState::Off
|
||||
} else if speed == "low" {
|
||||
AirFilterFanState::Low
|
||||
} else if speed == "medium" {
|
||||
AirFilterFanState::Medium
|
||||
} else if speed == "high" {
|
||||
AirFilterFanState::High
|
||||
} else {
|
||||
return Err(google_home::errors::DeviceError::TransientError.into());
|
||||
};
|
||||
|
||||
self.set_speed(state).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HumiditySetting for AirFilter {
|
||||
fn query_only_humidity_setting(&self) -> Option<bool> {
|
||||
Some(true)
|
||||
}
|
||||
|
||||
async fn humidity_ambient_percent(&self) -> isize {
|
||||
self.last_known_state.humidity.round() as isize
|
||||
}
|
||||
}
|
|
@ -1,137 +0,0 @@
|
|||
use async_trait::async_trait;
|
||||
use automation_macro::{LuaDevice, LuaDeviceConfig, LuaTypeDefinition};
|
||||
use google_home::traits::OnOff;
|
||||
use tracing::{debug, error, trace, warn};
|
||||
|
||||
use super::{Device, LuaDeviceCreate};
|
||||
use crate::config::MqttDeviceConfig;
|
||||
use crate::device_manager::WrappedDevice;
|
||||
use crate::error::DeviceConfigError;
|
||||
use crate::event::{OnMqtt, OnPresence};
|
||||
use crate::messages::{RemoteAction, RemoteMessage};
|
||||
use crate::mqtt::WrappedAsyncClient;
|
||||
|
||||
#[derive(Debug, Clone, LuaDeviceConfig, LuaTypeDefinition)]
|
||||
pub struct AudioSetupConfig {
|
||||
pub identifier: String,
|
||||
#[device_config(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
#[device_config(from_lua)]
|
||||
pub mixer: WrappedDevice,
|
||||
#[device_config(from_lua)]
|
||||
pub speakers: WrappedDevice,
|
||||
#[device_config(from_lua)]
|
||||
pub client: WrappedAsyncClient,
|
||||
}
|
||||
|
||||
#[derive(Debug, LuaDevice)]
|
||||
pub struct AudioSetup {
|
||||
config: AudioSetupConfig,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for AudioSetup {
|
||||
type Config = AudioSetupConfig;
|
||||
type Error = DeviceConfigError;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
trace!(id = config.identifier, "Setting up AudioSetup");
|
||||
|
||||
{
|
||||
let mixer = config.mixer.read().await;
|
||||
let mixer_id = mixer.get_id().to_owned();
|
||||
if (mixer.as_ref().cast() as Option<&dyn OnOff>).is_none() {
|
||||
return Err(DeviceConfigError::MissingTrait(mixer_id, "OnOff".into()));
|
||||
}
|
||||
|
||||
let speakers = config.speakers.read().await;
|
||||
let speakers_id = speakers.get_id().to_owned();
|
||||
if (speakers.as_ref().cast() as Option<&dyn OnOff>).is_none() {
|
||||
return Err(DeviceConfigError::MissingTrait(speakers_id, "OnOff".into()));
|
||||
}
|
||||
}
|
||||
|
||||
config
|
||||
.client
|
||||
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||
.await?;
|
||||
|
||||
Ok(AudioSetup { config })
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for AudioSetup {
|
||||
fn get_id(&self) -> String {
|
||||
self.config.identifier.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnMqtt for AudioSetup {
|
||||
async fn on_mqtt(&mut self, message: rumqttc::Publish) {
|
||||
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
||||
return;
|
||||
}
|
||||
|
||||
let action = match RemoteMessage::try_from(message) {
|
||||
Ok(message) => message.action(),
|
||||
Err(err) => {
|
||||
error!(
|
||||
id = self.config.identifier,
|
||||
"Failed to parse message: {err}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut mixer = self.config.mixer.write().await;
|
||||
let mut speakers = self.config.speakers.write().await;
|
||||
if let (Some(mixer), Some(speakers)) = (
|
||||
mixer.as_mut().cast_mut() as Option<&mut dyn OnOff>,
|
||||
speakers.as_mut().cast_mut() as Option<&mut dyn OnOff>,
|
||||
) {
|
||||
match action {
|
||||
RemoteAction::On => {
|
||||
if mixer.is_on().await.unwrap() {
|
||||
speakers.set_on(false).await.unwrap();
|
||||
mixer.set_on(false).await.unwrap();
|
||||
} else {
|
||||
speakers.set_on(true).await.unwrap();
|
||||
mixer.set_on(true).await.unwrap();
|
||||
}
|
||||
},
|
||||
RemoteAction::BrightnessMoveUp => {
|
||||
if !mixer.is_on().await.unwrap() {
|
||||
mixer.set_on(true).await.unwrap();
|
||||
} else if speakers.is_on().await.unwrap() {
|
||||
speakers.set_on(false).await.unwrap();
|
||||
} else {
|
||||
speakers.set_on(true).await.unwrap();
|
||||
}
|
||||
},
|
||||
RemoteAction::BrightnessStop => { /* Ignore this action */ },
|
||||
_ => warn!("Expected ikea shortcut button which only supports 'on' and 'brightness_move_up', got: {action:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnPresence for AudioSetup {
|
||||
async fn on_presence(&mut self, presence: bool) {
|
||||
let mut mixer = self.config.mixer.write().await;
|
||||
let mut speakers = self.config.speakers.write().await;
|
||||
|
||||
if let (Some(mixer), Some(speakers)) = (
|
||||
mixer.as_mut().cast_mut() as Option<&mut dyn OnOff>,
|
||||
speakers.as_mut().cast_mut() as Option<&mut dyn OnOff>,
|
||||
) {
|
||||
// Turn off the audio setup when we leave the house
|
||||
if !presence {
|
||||
debug!(id = self.config.identifier, "Turning devices off");
|
||||
speakers.set_on(false).await.unwrap();
|
||||
mixer.set_on(false).await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,235 +0,0 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use automation_macro::{LuaDevice, LuaDeviceConfig, LuaTypeDefinition};
|
||||
use google_home::traits::OnOff;
|
||||
use mlua::FromLua;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{debug, error, trace, warn};
|
||||
|
||||
use super::{Device, LuaDeviceCreate};
|
||||
use crate::config::MqttDeviceConfig;
|
||||
use crate::device_manager::WrappedDevice;
|
||||
use crate::devices::DEFAULT_PRESENCE;
|
||||
use crate::error::DeviceConfigError;
|
||||
use crate::event::{OnMqtt, OnPresence};
|
||||
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
|
||||
#[derive(Debug, Clone, LuaDeviceConfig, LuaTypeDefinition)]
|
||||
pub struct PresenceDeviceConfig {
|
||||
#[device_config(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
#[device_config(from(u64), with(Duration::from_secs))]
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TriggerDevicesHelper(Vec<WrappedDevice>);
|
||||
|
||||
impl<'lua> FromLua<'lua> for TriggerDevicesHelper {
|
||||
fn from_lua(value: mlua::Value<'lua>, lua: &'lua mlua::Lua) -> mlua::Result<Self> {
|
||||
Ok(TriggerDevicesHelper(mlua::FromLua::from_lua(value, lua)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TriggerDevicesHelper> for Vec<(WrappedDevice, bool)> {
|
||||
fn from(value: TriggerDevicesHelper) -> Self {
|
||||
value.0.into_iter().map(|device| (device, false)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, LuaDeviceConfig, LuaTypeDefinition)]
|
||||
pub struct TriggerConfig {
|
||||
#[device_config(from_lua, from(TriggerDevicesHelper))]
|
||||
pub devices: Vec<(WrappedDevice, bool)>,
|
||||
#[device_config(default, from(Option<u64>), with(|t: Option<_>| t.map(Duration::from_secs)))]
|
||||
pub timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, LuaDeviceConfig, LuaTypeDefinition)]
|
||||
pub struct ContactSensorConfig {
|
||||
pub identifier: String,
|
||||
#[device_config(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
#[device_config(from_lua)]
|
||||
pub presence: Option<PresenceDeviceConfig>,
|
||||
#[device_config(from_lua)]
|
||||
pub trigger: Option<TriggerConfig>,
|
||||
#[device_config(from_lua)]
|
||||
pub client: WrappedAsyncClient,
|
||||
}
|
||||
|
||||
#[derive(Debug, LuaDevice)]
|
||||
pub struct ContactSensor {
|
||||
config: ContactSensorConfig,
|
||||
|
||||
overall_presence: bool,
|
||||
is_closed: bool,
|
||||
handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for ContactSensor {
|
||||
type Config = ContactSensorConfig;
|
||||
type Error = DeviceConfigError;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
trace!(id = config.identifier, "Setting up ContactSensor");
|
||||
|
||||
// Make sure the devices implement the required traits
|
||||
if let Some(trigger) = &config.trigger {
|
||||
for (device, _) in &trigger.devices {
|
||||
{
|
||||
let device = device.read().await;
|
||||
let id = device.get_id().to_owned();
|
||||
if (device.as_ref().cast() as Option<&dyn OnOff>).is_none() {
|
||||
return Err(DeviceConfigError::MissingTrait(id, "OnOff".into()));
|
||||
}
|
||||
|
||||
if trigger.timeout.is_none()
|
||||
&& (device.as_ref().cast() as Option<&dyn Timeout>).is_none()
|
||||
{
|
||||
return Err(DeviceConfigError::MissingTrait(id, "Timeout".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config
|
||||
.client
|
||||
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||
.await?;
|
||||
|
||||
Ok(Self {
|
||||
config: config.clone(),
|
||||
overall_presence: DEFAULT_PRESENCE,
|
||||
is_closed: true,
|
||||
handle: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for ContactSensor {
|
||||
fn get_id(&self) -> String {
|
||||
self.config.identifier.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnPresence for ContactSensor {
|
||||
async fn on_presence(&mut self, presence: bool) {
|
||||
self.overall_presence = presence;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnMqtt for ContactSensor {
|
||||
async fn on_mqtt(&mut self, message: rumqttc::Publish) {
|
||||
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
||||
return;
|
||||
}
|
||||
|
||||
let is_closed = match ContactMessage::try_from(message) {
|
||||
Ok(state) => state.is_closed(),
|
||||
Err(err) => {
|
||||
error!(
|
||||
id = self.config.identifier,
|
||||
"Failed to parse message: {err}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if is_closed == self.is_closed {
|
||||
return;
|
||||
}
|
||||
|
||||
debug!(id = self.config.identifier, "Updating state to {is_closed}");
|
||||
self.is_closed = is_closed;
|
||||
|
||||
if let Some(trigger) = &mut self.config.trigger {
|
||||
if !self.is_closed {
|
||||
for (light, previous) in &mut trigger.devices {
|
||||
let mut light = light.write().await;
|
||||
if let Some(light) = light.as_mut().cast_mut() as Option<&mut dyn OnOff> {
|
||||
*previous = light.is_on().await.unwrap();
|
||||
light.set_on(true).await.ok();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (light, previous) in &trigger.devices {
|
||||
let mut light = light.write().await;
|
||||
if !previous {
|
||||
// If the timeout is zero just turn the light off directly
|
||||
if trigger.timeout.is_none()
|
||||
&& let Some(light) = light.as_mut().cast_mut() as Option<&mut dyn OnOff>
|
||||
{
|
||||
light.set_on(false).await.ok();
|
||||
} else if let Some(timeout) = trigger.timeout
|
||||
&& let Some(light) =
|
||||
light.as_mut().cast_mut() as Option<&mut 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
|
||||
// If not we are done here
|
||||
let presence = match &self.config.presence {
|
||||
Some(presence) => presence,
|
||||
None => return,
|
||||
};
|
||||
|
||||
if !is_closed {
|
||||
// Activate presence and stop any timeout once we open the door
|
||||
if let Some(handle) = self.handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
// Only use the door as an presence sensor if there the current presence is set false
|
||||
// This is to prevent the house from being marked as present for however long the
|
||||
// timeout is set when leaving the house
|
||||
if !self.overall_presence {
|
||||
self.config
|
||||
.client
|
||||
.publish(
|
||||
&presence.mqtt.topic,
|
||||
rumqttc::QoS::AtLeastOnce,
|
||||
false,
|
||||
serde_json::to_string(&PresenceMessage::new(true)).unwrap(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(
|
||||
"Failed to publish presence on {}: {err}",
|
||||
presence.mqtt.topic
|
||||
)
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
} else {
|
||||
// Once the door is closed again we start a timeout for removing the presence
|
||||
let client = self.config.client.clone();
|
||||
let id = self.config.identifier.clone();
|
||||
let timeout = presence.timeout;
|
||||
let topic = presence.mqtt.topic.clone();
|
||||
self.handle = Some(tokio::spawn(async move {
|
||||
debug!(id, "Starting timeout ({timeout:?}) for contact sensor...");
|
||||
tokio::time::sleep(timeout).await;
|
||||
debug!(id, "Removing door device!");
|
||||
client
|
||||
.publish(&topic, rumqttc::QoS::AtLeastOnce, false, "")
|
||||
.await
|
||||
.map_err(|err| warn!("Failed to publish presence on {topic}: {err}"))
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,324 +0,0 @@
|
|||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use automation_macro::{LuaDevice, LuaDeviceConfig, LuaTypeDefinition};
|
||||
use google_home::errors::ErrorCode;
|
||||
use google_home::traits::OnOff;
|
||||
use rumqttc::{Publish, SubscribeFilter};
|
||||
use tracing::{debug, error, trace, warn};
|
||||
|
||||
use super::{Device, LuaDeviceCreate};
|
||||
use crate::config::MqttDeviceConfig;
|
||||
use crate::event::OnMqtt;
|
||||
use crate::messages::{RemoteAction, RemoteMessage};
|
||||
use crate::mqtt::WrappedAsyncClient;
|
||||
use crate::traits::Timeout;
|
||||
|
||||
#[derive(Debug, Clone, LuaDeviceConfig, LuaTypeDefinition)]
|
||||
pub struct HueGroupConfig {
|
||||
pub identifier: String,
|
||||
#[device_config(rename("ip"), from(Ipv4Addr), with(|ip| SocketAddr::new(ip, 80)))]
|
||||
pub addr: SocketAddr,
|
||||
pub login: String,
|
||||
pub group_id: isize,
|
||||
pub timer_id: isize,
|
||||
pub scene_id: String,
|
||||
#[device_config(default)]
|
||||
pub remotes: Vec<MqttDeviceConfig>,
|
||||
#[device_config(from_lua)]
|
||||
pub client: WrappedAsyncClient,
|
||||
}
|
||||
|
||||
#[derive(Debug, LuaDevice)]
|
||||
pub struct HueGroup {
|
||||
config: HueGroupConfig,
|
||||
}
|
||||
|
||||
// Couple of helper function to get the correct urls
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for HueGroup {
|
||||
type Config = HueGroupConfig;
|
||||
type Error = rumqttc::ClientError;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
trace!(id = config.identifier, "Setting up AudioSetup");
|
||||
|
||||
if !config.remotes.is_empty() {
|
||||
config
|
||||
.client
|
||||
.subscribe_many(config.remotes.iter().map(|remote| SubscribeFilter {
|
||||
path: remote.topic.clone(),
|
||||
qos: rumqttc::QoS::AtLeastOnce,
|
||||
}))
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(Self { config })
|
||||
}
|
||||
}
|
||||
|
||||
impl HueGroup {
|
||||
fn url_base(&self) -> String {
|
||||
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 {
|
||||
format!("{}/groups/{}/action", self.url_base(), self.config.group_id)
|
||||
}
|
||||
|
||||
fn url_get_state(&self) -> String {
|
||||
format!("{}/groups/{}", self.url_base(), self.config.group_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for HueGroup {
|
||||
fn get_id(&self) -> String {
|
||||
self.config.identifier.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnMqtt for HueGroup {
|
||||
async fn on_mqtt(&mut self, message: Publish) {
|
||||
if !self
|
||||
.config
|
||||
.remotes
|
||||
.iter()
|
||||
.any(|remote| rumqttc::matches(&message.topic, &remote.topic))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let action = match RemoteMessage::try_from(message) {
|
||||
Ok(message) => message.action(),
|
||||
Err(err) => {
|
||||
error!(
|
||||
id = self.config.identifier,
|
||||
"Failed to parse message: {err}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
debug!("Action: {action:#?}");
|
||||
|
||||
match action {
|
||||
RemoteAction::On | RemoteAction::BrightnessMoveUp => self.set_on(true).await.unwrap(),
|
||||
RemoteAction::Off | RemoteAction::BrightnessMoveDown => {
|
||||
self.set_on(false).await.unwrap()
|
||||
}
|
||||
RemoteAction::BrightnessStop => { /* Ignore this action */ }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnOff for HueGroup {
|
||||
async fn set_on(&mut self, on: bool) -> Result<(), ErrorCode> {
|
||||
// Abort any timer that is currently running
|
||||
self.stop_timeout().await.unwrap();
|
||||
|
||||
let message = if on {
|
||||
message::Action::scene(self.config.scene_id.clone())
|
||||
} else {
|
||||
message::Action::on(false)
|
||||
};
|
||||
|
||||
let res = reqwest::Client::new()
|
||||
.put(self.url_set_action())
|
||||
.json(&message)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(res) => {
|
||||
let status = res.status();
|
||||
if !status.is_success() {
|
||||
warn!(
|
||||
id = self.config.identifier,
|
||||
"Status code is not success: {status}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => error!(id = self.config.identifier, "Error: {err}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_on(&self) -> Result<bool, ErrorCode> {
|
||||
let res = reqwest::Client::new()
|
||||
.get(self.url_get_state())
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(res) => {
|
||||
let status = res.status();
|
||||
if !status.is_success() {
|
||||
warn!(
|
||||
id = self.config.identifier,
|
||||
"Status code is not success: {status}"
|
||||
);
|
||||
}
|
||||
|
||||
let on = match res.json::<message::Info>().await {
|
||||
Ok(info) => info.any_on(),
|
||||
Err(err) => {
|
||||
error!(
|
||||
id = self.config.identifier,
|
||||
"Failed to parse message: {err}"
|
||||
);
|
||||
// TODO: Error code
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
return Ok(on);
|
||||
}
|
||||
Err(err) => error!(id = self.config.identifier, "Error: {err}"),
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Timeout for HueGroup {
|
||||
async fn start_timeout(&mut 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(&mut 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 {
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::ser::SerializeStruct;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Action {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
on: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
scene: Option<String>,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn on(on: bool) -> Self {
|
||||
Self {
|
||||
on: Some(on),
|
||||
scene: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scene(scene: String) -> Self {
|
||||
Self {
|
||||
on: None,
|
||||
scene: Some(scene),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct State {
|
||||
all_on: bool,
|
||||
any_on: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Info {
|
||||
state: State,
|
||||
}
|
||||
|
||||
impl Info {
|
||||
pub fn any_on(&self) -> bool {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,251 +0,0 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use automation_macro::{LuaDevice, LuaDeviceConfig, LuaTypeDefinition};
|
||||
use google_home::errors::ErrorCode;
|
||||
use google_home::traits::{self, OnOff};
|
||||
use google_home::types::Type;
|
||||
use google_home::{device, GoogleHomeDevice};
|
||||
use rumqttc::{matches, Publish, SubscribeFilter};
|
||||
use serde::Deserialize;
|
||||
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, RemoteAction, RemoteMessage};
|
||||
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, LuaTypeDefinition)]
|
||||
pub struct IkeaOutletConfig {
|
||||
#[device_config(flatten)]
|
||||
pub info: InfoConfig,
|
||||
#[device_config(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
#[device_config(default(OutletType::Outlet))]
|
||||
pub outlet_type: OutletType,
|
||||
#[device_config(default, from(Option<u64>), with(|t: Option<_>| t.map(Duration::from_secs)))]
|
||||
pub timeout: Option<Duration>,
|
||||
#[device_config(default)]
|
||||
pub remotes: Vec<MqttDeviceConfig>,
|
||||
|
||||
#[device_config(from_lua)]
|
||||
pub client: WrappedAsyncClient,
|
||||
}
|
||||
|
||||
#[derive(Debug, LuaDevice)]
|
||||
pub struct IkeaOutlet {
|
||||
config: IkeaOutletConfig,
|
||||
|
||||
last_known_state: bool,
|
||||
handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
async fn set_on(client: WrappedAsyncClient, topic: &str, on: bool) {
|
||||
let message = OnOffMessage::new(on);
|
||||
|
||||
let topic = format!("{}/set", topic);
|
||||
// TODO: Handle potential errors here
|
||||
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_trait]
|
||||
impl LuaDeviceCreate for IkeaOutlet {
|
||||
type Config = IkeaOutletConfig;
|
||||
type Error = rumqttc::ClientError;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
trace!(id = config.info.identifier(), "Setting up IkeaOutlet");
|
||||
|
||||
if !config.remotes.is_empty() {
|
||||
config
|
||||
.client
|
||||
.subscribe_many(config.remotes.iter().map(|remote| SubscribeFilter {
|
||||
path: remote.topic.clone(),
|
||||
qos: rumqttc::QoS::AtLeastOnce,
|
||||
}))
|
||||
.await?;
|
||||
}
|
||||
|
||||
config
|
||||
.client
|
||||
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||
.await?;
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
last_known_state: false,
|
||||
handle: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for IkeaOutlet {
|
||||
fn get_id(&self) -> String {
|
||||
self.config.info.identifier()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnMqtt for IkeaOutlet {
|
||||
async fn on_mqtt(&mut 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.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.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();
|
||||
}
|
||||
} else if self
|
||||
.config
|
||||
.remotes
|
||||
.iter()
|
||||
.any(|remote| rumqttc::matches(&message.topic, &remote.topic))
|
||||
{
|
||||
let action = match RemoteMessage::try_from(message) {
|
||||
Ok(message) => message.action(),
|
||||
Err(err) => {
|
||||
error!(id = Device::get_id(self), "Failed to parse message: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match action {
|
||||
RemoteAction::On => self.set_on(true).await.unwrap(),
|
||||
RemoteAction::BrightnessMoveUp => self.set_on(false).await.unwrap(),
|
||||
RemoteAction::BrightnessStop => { /* Ignore this action */ },
|
||||
_ => warn!("Expected ikea shortcut button which only supports 'on' and 'brightness_move_up', got: {action:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnPresence for IkeaOutlet {
|
||||
async fn on_presence(&mut 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 GoogleHomeDevice 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 is_on(&self) -> Result<bool, ErrorCode> {
|
||||
Ok(self.last_known_state)
|
||||
}
|
||||
|
||||
async fn set_on(&mut self, on: bool) -> Result<(), ErrorCode> {
|
||||
set_on(self.config.client.clone(), &self.config.mqtt.topic, on).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl crate::traits::Timeout for IkeaOutlet {
|
||||
async fn start_timeout(&mut self, timeout: Duration) -> Result<()> {
|
||||
// Abort any timer that is currently running
|
||||
self.stop_timeout().await?;
|
||||
|
||||
// Turn the kettle of after the specified timeout
|
||||
// TODO: Impl Drop for IkeaOutlet that will abort the handle if the IkeaOutlet
|
||||
// get dropped
|
||||
let client = self.config.client.clone();
|
||||
let topic = self.config.mqtt.topic.clone();
|
||||
let id = Device::get_id(self).clone();
|
||||
self.handle = Some(tokio::spawn(async move {
|
||||
debug!(id, "Starting timeout ({timeout:?})...");
|
||||
tokio::time::sleep(timeout).await;
|
||||
debug!(id, "Turning outlet off!");
|
||||
// TODO: Idealy we would call self.set_on(false), however since we want to do
|
||||
// it after a timeout we have to put it in a separate task.
|
||||
// I don't think we can really get around calling outside function
|
||||
set_on(client, &topic, false).await;
|
||||
}));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop_timeout(&mut self) -> Result<()> {
|
||||
if let Some(handle) = self.handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -1,80 +0,0 @@
|
|||
mod air_filter;
|
||||
mod audio_setup;
|
||||
mod contact_sensor;
|
||||
mod debug_bridge;
|
||||
mod hue_bridge;
|
||||
mod hue_group;
|
||||
mod ikea_outlet;
|
||||
mod kasa_outlet;
|
||||
mod light_sensor;
|
||||
pub mod ntfy;
|
||||
mod presence;
|
||||
mod wake_on_lan;
|
||||
mod washer;
|
||||
|
||||
use std::fmt::Debug;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use automation_cast::Cast;
|
||||
use google_home::traits::OnOff;
|
||||
use google_home::GoogleHomeDevice;
|
||||
|
||||
pub use self::air_filter::*;
|
||||
pub use self::audio_setup::*;
|
||||
pub use self::contact_sensor::*;
|
||||
pub use self::debug_bridge::*;
|
||||
pub use self::hue_bridge::*;
|
||||
pub use self::hue_group::*;
|
||||
pub use self::ikea_outlet::*;
|
||||
pub use self::kasa_outlet::*;
|
||||
pub use self::light_sensor::*;
|
||||
pub use self::ntfy::{Ntfy, NtfyConfig};
|
||||
pub use self::presence::{Presence, PresenceConfig, DEFAULT_PRESENCE};
|
||||
pub use self::wake_on_lan::*;
|
||||
pub use self::washer::*;
|
||||
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;
|
||||
}
|
||||
|
||||
pub fn register_with_lua(lua: &mlua::Lua) -> mlua::Result<()> {
|
||||
AirFilter::register_with_lua(lua)?;
|
||||
AudioSetup::register_with_lua(lua)?;
|
||||
ContactSensor::register_with_lua(lua)?;
|
||||
DebugBridge::register_with_lua(lua)?;
|
||||
HueBridge::register_with_lua(lua)?;
|
||||
HueGroup::register_with_lua(lua)?;
|
||||
IkeaOutlet::register_with_lua(lua)?;
|
||||
KasaOutlet::register_with_lua(lua)?;
|
||||
LightSensor::register_with_lua(lua)?;
|
||||
Ntfy::register_with_lua(lua)?;
|
||||
Presence::register_with_lua(lua)?;
|
||||
WakeOnLAN::register_with_lua(lua)?;
|
||||
Washer::register_with_lua(lua)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub trait Device:
|
||||
Debug
|
||||
+ Sync
|
||||
+ Send
|
||||
+ Cast<dyn GoogleHomeDevice>
|
||||
+ Cast<dyn OnMqtt>
|
||||
+ Cast<dyn OnMqtt>
|
||||
+ Cast<dyn OnPresence>
|
||||
+ Cast<dyn OnDarkness>
|
||||
+ Cast<dyn OnNotification>
|
||||
+ Cast<dyn OnOff>
|
||||
+ Cast<dyn Timeout>
|
||||
{
|
||||
fn get_id(&self) -> String;
|
||||
}
|
78
src/main.rs
78
src/main.rs
|
@ -1,28 +1,32 @@
|
|||
#![feature(async_closure)]
|
||||
mod web;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
use std::process;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use automation::auth::User;
|
||||
use automation::config::{FulfillmentConfig, MqttConfig};
|
||||
use automation::device_manager::DeviceManager;
|
||||
use automation::error::ApiError;
|
||||
use automation::mqtt::{self, WrappedAsyncClient};
|
||||
use automation::{devices, LUA};
|
||||
use axum::extract::FromRef;
|
||||
use automation_lib::config::{FulfillmentConfig, MqttConfig};
|
||||
use automation_lib::device_manager::DeviceManager;
|
||||
use automation_lib::helpers;
|
||||
use automation_lib::mqtt::{self, WrappedAsyncClient};
|
||||
use automation_lib::ntfy::Ntfy;
|
||||
use automation_lib::presence::Presence;
|
||||
use axum::extract::{FromRef, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use dotenvy::dotenv;
|
||||
use google_home::{GoogleHome, Request};
|
||||
use google_home::{GoogleHome, Request, Response};
|
||||
use mlua::LuaSerdeExt;
|
||||
use rumqttc::AsyncClient;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use web::{ApiError, User};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
pub openid_url: String,
|
||||
pub device_manager: DeviceManager,
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for String {
|
||||
|
@ -44,6 +48,24 @@ async fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
async fn fulfillment(
|
||||
State(state): State<AppState>,
|
||||
user: User,
|
||||
Json(payload): Json<Request>,
|
||||
) -> Result<Json<Response>, ApiError> {
|
||||
debug!(username = user.preferred_username, "{payload:#?}");
|
||||
let gc = GoogleHome::new(&user.preferred_username);
|
||||
let devices = state.device_manager.devices().await;
|
||||
let result = gc
|
||||
.handle_request(payload, &devices)
|
||||
.await
|
||||
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.into()))?;
|
||||
|
||||
debug!(username = user.preferred_username, "{result:#?}");
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
async fn app() -> anyhow::Result<()> {
|
||||
dotenv().ok();
|
||||
|
||||
|
@ -56,7 +78,7 @@ async fn app() -> anyhow::Result<()> {
|
|||
let device_manager = DeviceManager::new().await;
|
||||
|
||||
let fulfillment_config = {
|
||||
let lua = LUA.lock().await;
|
||||
let lua = mlua::Lua::new();
|
||||
|
||||
lua.set_warning_function(|_lua, text, _cont| {
|
||||
warn!("{text}");
|
||||
|
@ -94,7 +116,11 @@ async fn app() -> anyhow::Result<()> {
|
|||
|
||||
lua.globals().set("automation", automation)?;
|
||||
|
||||
devices::register_with_lua(&lua)?;
|
||||
automation_devices::register_with_lua(&lua)?;
|
||||
helpers::register_with_lua(&lua)?;
|
||||
lua.globals().set("Ntfy", lua.create_proxy::<Ntfy>()?)?;
|
||||
lua.globals()
|
||||
.set("Presence", lua.create_proxy::<Presence>()?)?;
|
||||
|
||||
// TODO: Make this not hardcoded
|
||||
let config_filename = std::env::var("AUTOMATION_CONFIG").unwrap_or("./config.lua".into());
|
||||
|
@ -119,39 +145,21 @@ async fn app() -> anyhow::Result<()> {
|
|||
};
|
||||
|
||||
// Create google home fulfillment route
|
||||
let fulfillment = Router::new().route(
|
||||
"/google_home",
|
||||
post(async move |user: User, Json(payload): Json<Request>| {
|
||||
debug!(username = user.preferred_username, "{payload:#?}");
|
||||
let gc = GoogleHome::new(&user.preferred_username);
|
||||
let devices = device_manager.devices().await;
|
||||
let result = match gc.handle_request(payload, &devices).await {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.into())
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
debug!(username = user.preferred_username, "{result:#?}");
|
||||
|
||||
(StatusCode::OK, Json(result)).into_response()
|
||||
}),
|
||||
);
|
||||
let fulfillment = Router::new().route("/google_home", post(fulfillment));
|
||||
|
||||
// Combine together all the routes
|
||||
let app = Router::new()
|
||||
.nest("/fulfillment", fulfillment)
|
||||
.with_state(AppState {
|
||||
openid_url: fulfillment_config.openid_url.clone(),
|
||||
device_manager,
|
||||
});
|
||||
|
||||
// Start the web server
|
||||
let addr = fulfillment_config.into();
|
||||
let addr: SocketAddr = fulfillment_config.into();
|
||||
info!("Server started on http://{addr}");
|
||||
axum::Server::try_bind(&addr)?
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
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(&mut self, _timeout: Duration) -> Result<()>;
|
||||
async fn stop_timeout(&mut self) -> Result<()>;
|
||||
}
|
|
@ -1,10 +1,78 @@
|
|||
use std::result;
|
||||
|
||||
use axum::async_trait;
|
||||
use axum::extract::{FromRef, FromRequestParts};
|
||||
use axum::http::request::Parts;
|
||||
use axum::http::status::InvalidStatusCode;
|
||||
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)]
|
||||
pub struct User {
|
||||
|
@ -25,6 +93,8 @@ where
|
|||
|
||||
// Create a request to the auth server
|
||||
// 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));
|
||||
|
||||
// Add auth header to the request if it exists
|
Loading…
Reference in New Issue
Block a user