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
|
# Based on: https://pastebin.com/99Fq2b2w
|
||||||
name: Build and deploy automation_rs
|
name: Build and deploy
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
|
@ -8,7 +8,7 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build automation_rs
|
name: Build application
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: catthehacker/ubuntu:act-latest
|
container: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
|
@ -21,61 +21,18 @@ jobs:
|
||||||
rustflags: ""
|
rustflags: ""
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --release --all-targets
|
run: cargo build --release
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: build
|
name: automation
|
||||||
path: target/x86_64-unknown-linux-gnu/release/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:
|
container:
|
||||||
name: Build Docker image
|
name: Build container
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build, checks]
|
needs: [build]
|
||||||
container: catthehacker/ubuntu:act-latest
|
container: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
@ -84,12 +41,23 @@ jobs:
|
||||||
- name: Download artifact
|
- name: Download artifact
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: build
|
name: automation
|
||||||
|
|
||||||
- name: Set permissions
|
- name: Set permissions
|
||||||
run: |
|
run: |
|
||||||
chown 65532:65532 ./build/*
|
chown 65532:65532 ./automation
|
||||||
chmod 0755 ./build/*
|
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
|
- name: Login to registry
|
||||||
uses: https://github.com/docker/login-action@v3
|
uses: https://github.com/docker/login-action@v3
|
||||||
|
@ -102,12 +70,12 @@ jobs:
|
||||||
uses: https://github.com/docker/build-push-action@v5
|
uses: https://github.com/docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ gitea.ref == 'refs/heads/master' }}
|
push: true
|
||||||
# TODO: Automatically add the correct tags here
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
tags: git.huizinga.dev/dreaded_x/automation_rs:latest
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy Docker container
|
name: Deploy container
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: catthehacker/ubuntu:act-latest
|
container: catthehacker/ubuntu:act-latest
|
||||||
needs: [container]
|
needs: [container]
|
||||||
|
@ -129,7 +97,7 @@ jobs:
|
||||||
-e MQTT_PASSWORD=${{ secrets.MQTT_PASSWORD }} \
|
-e MQTT_PASSWORD=${{ secrets.MQTT_PASSWORD }} \
|
||||||
-e HUE_TOKEN=${{ secrets.HUE_TOKEN }} \
|
-e HUE_TOKEN=${{ secrets.HUE_TOKEN }} \
|
||||||
-e NTFY_TOPIC=${{ secrets.NTFY_TOPIC }} \
|
-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
|
docker network connect web automation_rs
|
||||||
|
|
||||||
|
|
|
@ -30,13 +30,3 @@ repos:
|
||||||
rev: v0.1.0
|
rev: v0.1.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: dockerfilelint
|
- 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"
|
name = "automation"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "automation"
|
|
||||||
|
|
||||||
[workspace]
|
[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_macro = { path = "./automation_macro" }
|
||||||
automation_cast = { path = "./automation_cast/" }
|
automation_cast = { path = "./automation_cast" }
|
||||||
rumqttc = "0.18"
|
automation_lib = { path = "./automation_lib" }
|
||||||
serde = { version = "1.0.149", features = ["derive"] }
|
automation_devices = { path = "./automation_devices" }
|
||||||
serde_json = "1.0.89"
|
google_home = { path = "./google_home/google_home" }
|
||||||
google-home = { path = "./google-home" }
|
google_home_macro = { path = "./google_home/google_home_macro" }
|
||||||
paste = "1.0.10"
|
|
||||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
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"
|
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",
|
"json",
|
||||||
"rustls-tls",
|
"rustls-tls",
|
||||||
], default-features = false } # Use rustls, since the other packages also use rustls
|
], 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"
|
serde_repr = "0.1.10"
|
||||||
tracing = "0.1.37"
|
syn = { version = "2.0.60", features = ["extra-traits", "full"] }
|
||||||
bytes = "1.3.0"
|
thiserror = "2.0.5"
|
||||||
pollster = "0.2.5"
|
tokio-cron-scheduler = "0.13.0"
|
||||||
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"
|
|
||||||
tokio-util = { version = "0.7.11", features = ["full"] }
|
tokio-util = { version = "0.7.11", features = ["full"] }
|
||||||
|
tracing-subscriber = "0.3.16"
|
||||||
uuid = "1.8.0"
|
uuid = "1.8.0"
|
||||||
|
wakey = "0.3.0"
|
||||||
|
air_filter_types = { git = "https://git.huizinga.dev/Dreaded_X/airfilter", tag = "v0.4.4" }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
automation_lib = { workspace = true }
|
||||||
|
automation_devices = { workspace = true }
|
||||||
|
google_home = { workspace = true }
|
||||||
|
mlua = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
hostname = { workspace = true }
|
||||||
|
rumqttc = { workspace = true }
|
||||||
|
axum = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
dotenvy = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
wakey = { git = "https://git.huizinga.dev/Dreaded_X/wakey" }
|
wakey = { git = "https://git.huizinga.dev/Dreaded_X/wakey" }
|
||||||
|
|
|
@ -3,6 +3,6 @@ FROM gcr.io/distroless/cc-debian12:nonroot
|
||||||
ENV AUTOMATION_CONFIG=/app/config.lua
|
ENV AUTOMATION_CONFIG=/app/config.lua
|
||||||
COPY ./config.lua /app/config.lua
|
COPY ./config.lua /app/config.lua
|
||||||
|
|
||||||
COPY ./build/automation /app/automation
|
COPY ./automation /app/automation
|
||||||
|
|
||||||
CMD ["/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> {
|
pub trait Cast<P: ?Sized> {
|
||||||
fn cast(&self) -> Option<&P>;
|
fn cast(&self) -> Option<&P>;
|
||||||
fn cast_mut(&mut self) -> Option<&mut P>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<D, P> Cast<P> for D
|
impl<D, P> Cast<P> for D
|
||||||
|
@ -16,10 +15,6 @@ where
|
||||||
default fn cast(&self) -> Option<&P> {
|
default fn cast(&self) -> Option<&P> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
default fn cast_mut(&mut self) -> Option<&mut P> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<D, P> Cast<P> for D
|
impl<D, P> Cast<P> for D
|
||||||
|
@ -30,8 +25,4 @@ where
|
||||||
fn cast(&self) -> Option<&P> {
|
fn cast(&self) -> Option<&P> {
|
||||||
Some(self)
|
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 std::convert::Infallible;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
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 tracing::{trace, warn};
|
||||||
|
|
||||||
use super::LuaDeviceCreate;
|
#[derive(Debug, LuaDeviceConfig, Clone)]
|
||||||
use crate::config::MqttDeviceConfig;
|
pub struct Config {
|
||||||
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 {
|
|
||||||
pub identifier: String,
|
pub identifier: String,
|
||||||
#[device_config(flatten)]
|
#[device_config(flatten)]
|
||||||
pub mqtt: MqttDeviceConfig,
|
pub mqtt: MqttDeviceConfig,
|
||||||
|
@ -20,14 +18,14 @@ pub struct DebugBridgeConfig {
|
||||||
pub client: WrappedAsyncClient,
|
pub client: WrappedAsyncClient,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, LuaDevice)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DebugBridge {
|
pub struct DebugBridge {
|
||||||
config: DebugBridgeConfig,
|
config: Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LuaDeviceCreate for DebugBridge {
|
impl LuaDeviceCreate for DebugBridge {
|
||||||
type Config = DebugBridgeConfig;
|
type Config = Config;
|
||||||
type Error = Infallible;
|
type Error = Infallible;
|
||||||
|
|
||||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||||
|
@ -44,7 +42,7 @@ impl Device for DebugBridge {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnPresence for DebugBridge {
|
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 message = PresenceMessage::new(presence);
|
||||||
let topic = format!("{}/presence", self.config.mqtt.topic);
|
let topic = format!("{}/presence", self.config.mqtt.topic);
|
||||||
self.config
|
self.config
|
||||||
|
@ -68,7 +66,7 @@ impl OnPresence for DebugBridge {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnDarkness for DebugBridge {
|
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 message = DarknessMessage::new(dark);
|
||||||
let topic = format!("{}/darkness", self.config.mqtt.topic);
|
let topic = format!("{}/darkness", self.config.mqtt.topic);
|
||||||
self.config
|
self.config
|
|
@ -1,39 +1,37 @@
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::net::{Ipv4Addr, SocketAddr};
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
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 serde::{Deserialize, Serialize};
|
||||||
use tracing::{error, trace, warn};
|
use tracing::{error, trace, warn};
|
||||||
|
|
||||||
use super::LuaDeviceCreate;
|
|
||||||
use crate::devices::Device;
|
|
||||||
use crate::event::{OnDarkness, OnPresence};
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum Flag {
|
pub enum Flag {
|
||||||
Presence,
|
Presence,
|
||||||
Darkness,
|
Darkness,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, LuaTypeDefinition)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct FlagIDs {
|
pub struct FlagIDs {
|
||||||
presence: isize,
|
presence: isize,
|
||||||
darkness: isize,
|
darkness: isize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, LuaDeviceConfig, Clone, LuaTypeDefinition)]
|
#[derive(Debug, LuaDeviceConfig, Clone)]
|
||||||
pub struct HueBridgeConfig {
|
pub struct Config {
|
||||||
pub identifier: String,
|
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 addr: SocketAddr,
|
||||||
pub login: String,
|
pub login: String,
|
||||||
pub flags: FlagIDs,
|
pub flags: FlagIDs,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, LuaDevice)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct HueBridge {
|
pub struct HueBridge {
|
||||||
config: HueBridgeConfig,
|
config: Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
@ -43,7 +41,7 @@ struct FlagMessage {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LuaDeviceCreate for HueBridge {
|
impl LuaDeviceCreate for HueBridge {
|
||||||
type Config = HueBridgeConfig;
|
type Config = Config;
|
||||||
type Error = Infallible;
|
type Error = Infallible;
|
||||||
|
|
||||||
async fn create(config: Self::Config) -> Result<Self, Infallible> {
|
async fn create(config: Self::Config) -> Result<Self, Infallible> {
|
||||||
|
@ -93,7 +91,7 @@ impl Device for HueBridge {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnPresence for HueBridge {
|
impl OnPresence for HueBridge {
|
||||||
async fn on_presence(&mut self, presence: bool) {
|
async fn on_presence(&self, presence: bool) {
|
||||||
trace!("Bridging presence to hue");
|
trace!("Bridging presence to hue");
|
||||||
self.set_flag(Flag::Presence, presence).await;
|
self.set_flag(Flag::Presence, presence).await;
|
||||||
}
|
}
|
||||||
|
@ -101,7 +99,7 @@ impl OnPresence for HueBridge {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnDarkness for HueBridge {
|
impl OnDarkness for HueBridge {
|
||||||
async fn on_darkness(&mut self, dark: bool) {
|
async fn on_darkness(&self, dark: bool) {
|
||||||
trace!("Bridging darkness to hue");
|
trace!("Bridging darkness to hue");
|
||||||
self.set_flag(Flag::Darkness, dark).await;
|
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::convert::Infallible;
|
||||||
use std::net::{Ipv4Addr, SocketAddr};
|
use std::net::SocketAddr;
|
||||||
use std::str::Utf8Error;
|
use std::str::Utf8Error;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
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 bytes::{Buf, BufMut};
|
||||||
use google_home::errors::{self, DeviceError};
|
use google_home::errors::{self, DeviceError};
|
||||||
use google_home::traits;
|
use google_home::traits::OnOff;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
use tracing::trace;
|
use tracing::{debug, trace};
|
||||||
|
|
||||||
use super::{Device, LuaDeviceCreate};
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
|
pub struct Config {
|
||||||
#[derive(Debug, Clone, LuaDeviceConfig, LuaTypeDefinition)]
|
|
||||||
pub struct KasaOutletConfig {
|
|
||||||
pub identifier: String,
|
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,
|
pub addr: SocketAddr,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, LuaDevice)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct KasaOutlet {
|
pub struct KasaOutlet {
|
||||||
config: KasaOutletConfig,
|
config: Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LuaDeviceCreate for KasaOutlet {
|
impl LuaDeviceCreate for KasaOutlet {
|
||||||
type Config = KasaOutletConfig;
|
type Config = Config;
|
||||||
type Error = Infallible;
|
type Error = Infallible;
|
||||||
|
|
||||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||||
|
@ -206,8 +206,8 @@ impl Response {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl traits::OnOff for KasaOutlet {
|
impl OnOff for KasaOutlet {
|
||||||
async fn is_on(&self) -> Result<bool, errors::ErrorCode> {
|
async fn on(&self) -> Result<bool, errors::ErrorCode> {
|
||||||
let mut stream = TcpStream::connect(self.config.addr)
|
let mut stream = TcpStream::connect(self.config.addr)
|
||||||
.await
|
.await
|
||||||
.or::<DeviceError>(Err(DeviceError::DeviceOffline))?;
|
.or::<DeviceError>(Err(DeviceError::DeviceOffline))?;
|
||||||
|
@ -241,7 +241,7 @@ impl traits::OnOff for KasaOutlet {
|
||||||
.or(Err(DeviceError::TransientError.into()))
|
.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)
|
let mut stream = TcpStream::connect(self.config.addr)
|
||||||
.await
|
.await
|
||||||
.or::<DeviceError>(Err(DeviceError::DeviceOffline))?;
|
.or::<DeviceError>(Err(DeviceError::DeviceOffline))?;
|
||||||
|
@ -275,3 +275,13 @@ impl traits::OnOff for KasaOutlet {
|
||||||
.or(Err(DeviceError::TransientError.into()))
|
.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 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 rumqttc::Publish;
|
||||||
|
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||||
use tracing::{debug, trace, warn};
|
use tracing::{debug, trace, warn};
|
||||||
|
|
||||||
use super::LuaDeviceCreate;
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
use crate::config::MqttDeviceConfig;
|
pub struct Config {
|
||||||
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 {
|
|
||||||
pub identifier: String,
|
pub identifier: String,
|
||||||
#[device_config(flatten)]
|
#[device_config(flatten)]
|
||||||
pub mqtt: MqttDeviceConfig,
|
pub mqtt: MqttDeviceConfig,
|
||||||
pub min: isize,
|
pub min: isize,
|
||||||
pub max: 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,
|
pub tx: event::Sender,
|
||||||
#[device_config(from_lua)]
|
#[device_config(from_lua)]
|
||||||
pub client: WrappedAsyncClient,
|
pub client: WrappedAsyncClient,
|
||||||
|
@ -25,16 +26,30 @@ pub struct LightSensorConfig {
|
||||||
|
|
||||||
const DEFAULT: bool = false;
|
const DEFAULT: bool = false;
|
||||||
|
|
||||||
#[derive(Debug, LuaDevice)]
|
#[derive(Debug)]
|
||||||
pub struct LightSensor {
|
pub struct State {
|
||||||
config: LightSensorConfig,
|
|
||||||
|
|
||||||
is_dark: bool,
|
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]
|
#[async_trait]
|
||||||
impl LuaDeviceCreate for LightSensor {
|
impl LuaDeviceCreate for LightSensor {
|
||||||
type Config = LightSensorConfig;
|
type Config = Config;
|
||||||
type Error = rumqttc::ClientError;
|
type Error = rumqttc::ClientError;
|
||||||
|
|
||||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
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)
|
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Self {
|
let state = State { is_dark: DEFAULT };
|
||||||
config,
|
let state = Arc::new(RwLock::new(state));
|
||||||
is_dark: DEFAULT,
|
|
||||||
})
|
Ok(Self { config, state })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,7 +75,7 @@ impl Device for LightSensor {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnMqtt for LightSensor {
|
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) {
|
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -81,18 +96,19 @@ impl OnMqtt for LightSensor {
|
||||||
trace!("It is light");
|
trace!("It is light");
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
|
let is_dark = self.state().await.is_dark;
|
||||||
trace!(
|
trace!(
|
||||||
"In between min ({}) and max ({}) value, keeping current state: {}",
|
"In between min ({}) and max ({}) value, keeping current state: {}",
|
||||||
self.config.min,
|
self.config.min,
|
||||||
self.config.max,
|
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}");
|
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() {
|
if self.config.tx.send(Event::Darkness(is_dark)).await.is_err() {
|
||||||
warn!("There are no receivers on the event channel");
|
warn!("There are no receivers on the event channel");
|
|
@ -1,23 +1,22 @@
|
||||||
use std::net::Ipv4Addr;
|
use std::net::Ipv4Addr;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
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 eui48::MacAddress;
|
||||||
|
use google_home::device;
|
||||||
use google_home::errors::ErrorCode;
|
use google_home::errors::ErrorCode;
|
||||||
use google_home::traits::{self, Scene};
|
use google_home::traits::{self, Scene};
|
||||||
use google_home::types::Type;
|
use google_home::types::Type;
|
||||||
use google_home::{device, GoogleHomeDevice};
|
|
||||||
use rumqttc::Publish;
|
use rumqttc::Publish;
|
||||||
use tracing::{debug, error, trace};
|
use tracing::{debug, error, trace};
|
||||||
|
|
||||||
use super::{Device, LuaDeviceCreate};
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
use crate::config::{InfoConfig, MqttDeviceConfig};
|
pub struct Config {
|
||||||
use crate::event::OnMqtt;
|
|
||||||
use crate::messages::ActivateMessage;
|
|
||||||
use crate::mqtt::WrappedAsyncClient;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, LuaDeviceConfig, LuaTypeDefinition)]
|
|
||||||
pub struct WakeOnLANConfig {
|
|
||||||
#[device_config(flatten)]
|
#[device_config(flatten)]
|
||||||
pub info: InfoConfig,
|
pub info: InfoConfig,
|
||||||
#[device_config(flatten)]
|
#[device_config(flatten)]
|
||||||
|
@ -29,14 +28,14 @@ pub struct WakeOnLANConfig {
|
||||||
pub client: WrappedAsyncClient,
|
pub client: WrappedAsyncClient,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, LuaDevice)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct WakeOnLAN {
|
pub struct WakeOnLAN {
|
||||||
config: WakeOnLANConfig,
|
config: Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LuaDeviceCreate for WakeOnLAN {
|
impl LuaDeviceCreate for WakeOnLAN {
|
||||||
type Config = WakeOnLANConfig;
|
type Config = Config;
|
||||||
type Error = rumqttc::ClientError;
|
type Error = rumqttc::ClientError;
|
||||||
|
|
||||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||||
|
@ -59,7 +58,7 @@ impl Device for WakeOnLAN {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnMqtt for WakeOnLAN {
|
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) {
|
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
||||||
return;
|
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 {
|
fn get_device_type(&self) -> Type {
|
||||||
Type::Scene
|
Type::Scene
|
||||||
}
|
}
|
||||||
|
@ -92,7 +92,7 @@ impl GoogleHomeDevice for WakeOnLAN {
|
||||||
Device::get_id(self)
|
Device::get_id(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_online(&self) -> bool {
|
async fn is_online(&self) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,8 +103,17 @@ impl GoogleHomeDevice for WakeOnLAN {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl traits::Scene for WakeOnLAN {
|
impl traits::Scene for WakeOnLAN {
|
||||||
async fn set_active(&self, activate: bool) -> Result<(), ErrorCode> {
|
async fn set_active(&self, deactivate: bool) -> Result<(), ErrorCode> {
|
||||||
if activate {
|
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!(
|
debug!(
|
||||||
id = Device::get_id(self),
|
id = Device::get_id(self),
|
||||||
"Activating Computer: {} (Sending to {})",
|
"Activating Computer: {} (Sending to {})",
|
||||||
|
@ -131,15 +140,6 @@ impl traits::Scene for WakeOnLAN {
|
||||||
google_home::errors::DeviceError::TransientError.into()
|
google_home::errors::DeviceError::TransientError.into()
|
||||||
})
|
})
|
||||||
.map(|_| debug!(id = Device::get_id(self), "Success!"))
|
.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 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 rumqttc::Publish;
|
||||||
|
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||||
use tracing::{debug, error, trace, warn};
|
use tracing::{debug, error, trace, warn};
|
||||||
|
|
||||||
use super::ntfy::Priority;
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
use super::{Device, LuaDeviceCreate};
|
pub struct Config {
|
||||||
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 {
|
|
||||||
pub identifier: String,
|
pub identifier: String,
|
||||||
#[device_config(flatten)]
|
#[device_config(flatten)]
|
||||||
pub mqtt: MqttDeviceConfig,
|
pub mqtt: MqttDeviceConfig,
|
||||||
// Power in Watt
|
// Power in Watt
|
||||||
pub threshold: f32,
|
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,
|
pub tx: event::Sender,
|
||||||
#[device_config(from_lua)]
|
#[device_config(from_lua)]
|
||||||
pub client: WrappedAsyncClient,
|
pub client: WrappedAsyncClient,
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Add google home integration
|
#[derive(Debug)]
|
||||||
#[derive(Debug, LuaDevice)]
|
pub struct State {
|
||||||
pub struct Washer {
|
|
||||||
config: WasherConfig,
|
|
||||||
|
|
||||||
running: isize,
|
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]
|
#[async_trait]
|
||||||
impl LuaDeviceCreate for Washer {
|
impl LuaDeviceCreate for Washer {
|
||||||
type Config = WasherConfig;
|
type Config = Config;
|
||||||
type Error = rumqttc::ClientError;
|
type Error = rumqttc::ClientError;
|
||||||
|
|
||||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
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)
|
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||||
.await?;
|
.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]
|
#[async_trait]
|
||||||
impl OnMqtt for Washer {
|
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) {
|
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -80,7 +98,7 @@ impl OnMqtt for Washer {
|
||||||
|
|
||||||
// debug!(id = self.identifier, power, "Washer state update");
|
// 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
|
// The washer is done running
|
||||||
debug!(
|
debug!(
|
||||||
id = self.config.identifier,
|
id = self.config.identifier,
|
||||||
|
@ -89,7 +107,7 @@ impl OnMqtt for Washer {
|
||||||
"Washer is done"
|
"Washer is done"
|
||||||
);
|
);
|
||||||
|
|
||||||
self.running = 0;
|
self.state_mut().await.running = 0;
|
||||||
let notification = Notification::new()
|
let notification = Notification::new()
|
||||||
.set_title("Laundy is done")
|
.set_title("Laundy is done")
|
||||||
.set_message("Don't forget to hang it!")
|
.set_message("Don't forget to hang it!")
|
||||||
|
@ -107,8 +125,8 @@ impl OnMqtt for Washer {
|
||||||
}
|
}
|
||||||
} else if power < self.config.threshold {
|
} else if power < self.config.threshold {
|
||||||
// Prevent false positives
|
// Prevent false positives
|
||||||
self.running = 0;
|
self.state_mut().await.running = 0;
|
||||||
} else if power >= self.config.threshold && self.running < HYSTERESIS {
|
} else if power >= self.config.threshold && self.state().await.running < HYSTERESIS {
|
||||||
// Washer could be starting
|
// Washer could be starting
|
||||||
debug!(
|
debug!(
|
||||||
id = self.config.identifier,
|
id = self.config.identifier,
|
||||||
|
@ -117,7 +135,7 @@ impl OnMqtt for Washer {
|
||||||
"Washer is starting"
|
"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::net::{Ipv4Addr, SocketAddr};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use automation_macro::LuaTypeDefinition;
|
|
||||||
use rumqttc::{MqttOptions, Transport};
|
use rumqttc::{MqttOptions, Transport};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
@ -53,7 +52,7 @@ fn default_fulfillment_port() -> u16 {
|
||||||
7878
|
7878
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, LuaTypeDefinition)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct InfoConfig {
|
pub struct InfoConfig {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub room: Option<String>,
|
pub room: Option<String>,
|
||||||
|
@ -69,7 +68,7 @@ impl InfoConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, LuaTypeDefinition)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct MqttDeviceConfig {
|
pub struct MqttDeviceConfig {
|
||||||
pub topic: String,
|
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::collections::HashMap;
|
||||||
use std::ops::{Deref, DerefMut};
|
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
use futures::Future;
|
use futures::Future;
|
||||||
use google_home::traits::OnOff;
|
|
||||||
use mlua::FromLua;
|
|
||||||
use tokio::sync::{RwLock, RwLockReadGuard};
|
use tokio::sync::{RwLock, RwLockReadGuard};
|
||||||
use tokio_cron_scheduler::{Job, JobScheduler};
|
use tokio_cron_scheduler::{Job, JobScheduler};
|
||||||
use tokio_util::task::LocalPoolHandle;
|
|
||||||
use tracing::{debug, instrument, trace};
|
use tracing::{debug, instrument, trace};
|
||||||
|
|
||||||
use crate::devices::Device;
|
use crate::device::Device;
|
||||||
use crate::event::{Event, EventChannel, OnDarkness, OnMqtt, OnNotification, OnPresence};
|
use crate::event::{Event, EventChannel, OnDarkness, OnMqtt, OnNotification, OnPresence};
|
||||||
use crate::LUA;
|
|
||||||
|
|
||||||
#[derive(Debug, FromLua, Clone)]
|
pub type DeviceMap = HashMap<String, Box<dyn Device>>;
|
||||||
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>>>>;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct DeviceManager {
|
pub struct DeviceManager {
|
||||||
|
@ -94,25 +48,20 @@ impl DeviceManager {
|
||||||
device_manager
|
device_manager
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add(&self, device: &WrappedDevice) {
|
pub async fn add(&self, device: Box<dyn Device>) {
|
||||||
let id = device.read().await.get_id().to_owned();
|
let id = device.get_id();
|
||||||
|
|
||||||
debug!(id, "Adding device");
|
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 {
|
pub fn event_channel(&self) -> EventChannel {
|
||||||
self.event_channel.clone()
|
self.event_channel.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get(&self, name: &str) -> Option<WrappedDevice> {
|
pub async fn get(&self, name: &str) -> Option<Box<dyn Device>> {
|
||||||
self.devices
|
self.devices.read().await.get(name).cloned()
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.get(name)
|
|
||||||
.cloned()
|
|
||||||
.map(WrappedDevice)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn devices(&self) -> RwLockReadGuard<DeviceMap> {
|
pub async fn devices(&self) -> RwLockReadGuard<DeviceMap> {
|
||||||
|
@ -127,8 +76,7 @@ impl DeviceManager {
|
||||||
let iter = devices.iter().map(|(id, device)| {
|
let iter = devices.iter().map(|(id, device)| {
|
||||||
let message = message.clone();
|
let message = message.clone();
|
||||||
async move {
|
async move {
|
||||||
let mut device = device.write().await;
|
let device: Option<&dyn OnMqtt> = device.cast();
|
||||||
let device: Option<&mut dyn OnMqtt> = device.as_mut().cast_mut();
|
|
||||||
if let Some(device) = device {
|
if let Some(device) = device {
|
||||||
// let subscribed = device
|
// let subscribed = device
|
||||||
// .topics()
|
// .topics()
|
||||||
|
@ -149,8 +97,7 @@ impl DeviceManager {
|
||||||
Event::Darkness(dark) => {
|
Event::Darkness(dark) => {
|
||||||
let devices = self.devices.read().await;
|
let devices = self.devices.read().await;
|
||||||
let iter = devices.iter().map(|(id, device)| async move {
|
let iter = devices.iter().map(|(id, device)| async move {
|
||||||
let mut device = device.write().await;
|
let device: Option<&dyn OnDarkness> = device.cast();
|
||||||
let device: Option<&mut dyn OnDarkness> = device.as_mut().cast_mut();
|
|
||||||
if let Some(device) = device {
|
if let Some(device) = device {
|
||||||
trace!(id, "Handling");
|
trace!(id, "Handling");
|
||||||
device.on_darkness(dark).await;
|
device.on_darkness(dark).await;
|
||||||
|
@ -163,8 +110,7 @@ impl DeviceManager {
|
||||||
Event::Presence(presence) => {
|
Event::Presence(presence) => {
|
||||||
let devices = self.devices.read().await;
|
let devices = self.devices.read().await;
|
||||||
let iter = devices.iter().map(|(id, device)| async move {
|
let iter = devices.iter().map(|(id, device)| async move {
|
||||||
let mut device = device.write().await;
|
let device: Option<&dyn OnPresence> = device.cast();
|
||||||
let device: Option<&mut dyn OnPresence> = device.as_mut().cast_mut();
|
|
||||||
if let Some(device) = device {
|
if let Some(device) = device {
|
||||||
trace!(id, "Handling");
|
trace!(id, "Handling");
|
||||||
device.on_presence(presence).await;
|
device.on_presence(presence).await;
|
||||||
|
@ -179,8 +125,7 @@ impl DeviceManager {
|
||||||
let iter = devices.iter().map(|(id, device)| {
|
let iter = devices.iter().map(|(id, device)| {
|
||||||
let notification = notification.clone();
|
let notification = notification.clone();
|
||||||
async move {
|
async move {
|
||||||
let mut device = device.write().await;
|
let device: Option<&dyn OnNotification> = device.cast();
|
||||||
let device: Option<&mut dyn OnNotification> = device.as_mut().cast_mut();
|
|
||||||
if let Some(device) = device {
|
if let Some(device) = device {
|
||||||
trace!(id, "Handling");
|
trace!(id, "Handling");
|
||||||
device.on_notification(notification).await;
|
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 {
|
impl mlua::UserData for DeviceManager {
|
||||||
fn add_methods<'lua, M: mlua::UserDataMethods<'lua, Self>>(methods: &mut M) {
|
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
|
||||||
methods.add_async_method("add", |_lua, this, device: WrappedDevice| async move {
|
methods.add_async_method("add", |_lua, this, device: Box<dyn Device>| async move {
|
||||||
this.add(&device).await;
|
this.add(device).await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
@ -224,7 +152,27 @@ impl mlua::UserData for DeviceManager {
|
||||||
"schedule",
|
"schedule",
|
||||||
|lua, this, (schedule, f): (String, mlua::Function)| async move {
|
|lua, this, (schedule, f): (String, mlua::Function)| async move {
|
||||||
debug!("schedule = {schedule}");
|
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();
|
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()))
|
methods.add_method("event_channel", |_lua, this, ()| Ok(this.event_channel()))
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,10 +1,7 @@
|
||||||
use std::{error, fmt, result};
|
use std::{error, fmt, result};
|
||||||
|
|
||||||
use axum::http::status::InvalidStatusCode;
|
|
||||||
use axum::response::IntoResponse;
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use rumqttc::ClientError;
|
use rumqttc::ClientError;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
@ -101,68 +98,3 @@ pub enum LightSensorError {
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
SubscribeError(#[from] ClientError),
|
SubscribeError(#[from] ClientError),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
#[error("{source}")]
|
|
||||||
pub struct ApiError {
|
|
||||||
status_code: axum::http::StatusCode,
|
|
||||||
source: Box<dyn std::error::Error>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ApiError {
|
|
||||||
pub fn new(status_code: axum::http::StatusCode, source: Box<dyn std::error::Error>) -> Self {
|
|
||||||
Self {
|
|
||||||
status_code,
|
|
||||||
source,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<ApiError> for ApiErrorJson {
|
|
||||||
fn from(value: ApiError) -> Self {
|
|
||||||
let error = ApiErrorJsonError {
|
|
||||||
code: value.status_code.as_u16(),
|
|
||||||
status: value.status_code.to_string(),
|
|
||||||
reason: value.source.to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
Self { error }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoResponse for ApiError {
|
|
||||||
fn into_response(self) -> axum::response::Response {
|
|
||||||
(
|
|
||||||
self.status_code,
|
|
||||||
serde_json::to_string::<ApiErrorJson>(&self.into())
|
|
||||||
.expect("Serialization should not fail"),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct ApiErrorJsonError {
|
|
||||||
code: u16,
|
|
||||||
status: String,
|
|
||||||
reason: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct ApiErrorJson {
|
|
||||||
error: ApiErrorJsonError,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<ApiErrorJson> for ApiError {
|
|
||||||
type Error = InvalidStatusCode;
|
|
||||||
|
|
||||||
fn try_from(value: ApiErrorJson) -> result::Result<Self, Self::Error> {
|
|
||||||
let status_code = axum::http::StatusCode::from_u16(value.error.code)?;
|
|
||||||
let source = value.error.reason.into();
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
status_code,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,7 +3,7 @@ use mlua::FromLua;
|
||||||
use rumqttc::Publish;
|
use rumqttc::Publish;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::devices::ntfy::Notification;
|
use crate::ntfy::Notification;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Event {
|
pub enum Event {
|
||||||
|
@ -36,20 +36,20 @@ impl mlua::UserData for EventChannel {}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait OnMqtt: Sync + Send {
|
pub trait OnMqtt: Sync + Send {
|
||||||
// fn topics(&self) -> Vec<&str>;
|
// fn topics(&self) -> Vec<&str>;
|
||||||
async fn on_mqtt(&mut self, message: Publish);
|
async fn on_mqtt(&self, message: Publish);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait OnPresence: Sync + Send {
|
pub trait OnPresence: Sync + Send {
|
||||||
async fn on_presence(&mut self, presence: bool);
|
async fn on_presence(&self, presence: bool);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait OnDarkness: Sync + Send {
|
pub trait OnDarkness: Sync + Send {
|
||||||
async fn on_darkness(&mut self, dark: bool);
|
async fn on_darkness(&self, dark: bool);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait OnNotification: Sync + Send {
|
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(specialization)]
|
||||||
#![feature(let_chains)]
|
#![feature(let_chains)]
|
||||||
|
|
||||||
use once_cell::sync::Lazy;
|
pub mod action_callback;
|
||||||
use tokio::sync::Mutex;
|
|
||||||
pub mod auth;
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod device;
|
||||||
pub mod device_manager;
|
pub mod device_manager;
|
||||||
pub mod devices;
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
|
pub mod helpers;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
pub mod mqtt;
|
pub mod mqtt;
|
||||||
|
pub mod ntfy;
|
||||||
|
pub mod presence;
|
||||||
pub mod schedule;
|
pub mod schedule;
|
||||||
pub mod traits;
|
|
||||||
|
|
||||||
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())))
|
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::collections::HashMap;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
|
use std::ops::Deref;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use automation_macro::{LuaDevice, LuaDeviceConfig, LuaTypeDefinition};
|
use automation_cast::Cast;
|
||||||
|
use automation_macro::LuaDeviceConfig;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_repr::*;
|
use serde_repr::*;
|
||||||
use tracing::{error, trace, warn};
|
use tracing::{error, trace, warn};
|
||||||
|
|
||||||
use super::LuaDeviceCreate;
|
use crate::device::{impl_device, Device, LuaDeviceCreate};
|
||||||
use crate::devices::Device;
|
|
||||||
use crate::event::{self, Event, EventChannel, OnNotification, OnPresence};
|
use crate::event::{self, Event, EventChannel, OnNotification, OnPresence};
|
||||||
|
|
||||||
#[derive(Debug, Serialize_repr, Clone, Copy)]
|
#[derive(Debug, Serialize_repr, Clone, Copy)]
|
||||||
|
@ -111,23 +112,25 @@ impl Default for Notification {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, LuaDeviceConfig, LuaTypeDefinition)]
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
pub struct NtfyConfig {
|
pub struct Config {
|
||||||
#[device_config(default("https://ntfy.sh".into()))]
|
#[device_config(default("https://ntfy.sh".into()))]
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub topic: 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,
|
pub tx: event::Sender,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, LuaDevice)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Ntfy {
|
pub struct Ntfy {
|
||||||
config: NtfyConfig,
|
config: Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl_device!(Ntfy);
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LuaDeviceCreate for Ntfy {
|
impl LuaDeviceCreate for Ntfy {
|
||||||
type Config = NtfyConfig;
|
type Config = Config;
|
||||||
type Error = Infallible;
|
type Error = Infallible;
|
||||||
|
|
||||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||||
|
@ -166,7 +169,7 @@ impl Ntfy {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnPresence for Ntfy {
|
impl OnPresence for Ntfy {
|
||||||
async fn on_presence(&mut self, presence: bool) {
|
async fn on_presence(&self, presence: bool) {
|
||||||
// Setup extras for the broadcast
|
// Setup extras for the broadcast
|
||||||
let extras = HashMap::from([
|
let extras = HashMap::from([
|
||||||
("cmd".into(), "presence".into()),
|
("cmd".into(), "presence".into()),
|
||||||
|
@ -202,7 +205,7 @@ impl OnPresence for Ntfy {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnNotification for Ntfy {
|
impl OnNotification for Ntfy {
|
||||||
async fn on_notification(&mut self, notification: Notification) {
|
async fn on_notification(&self, notification: Notification) {
|
||||||
self.send(notification).await;
|
self.send(notification).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,22 +1,25 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::ops::Deref;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use automation_macro::{LuaDevice, LuaDeviceConfig, LuaTypeDefinition};
|
use automation_cast::Cast;
|
||||||
|
use automation_macro::LuaDeviceConfig;
|
||||||
use rumqttc::Publish;
|
use rumqttc::Publish;
|
||||||
|
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||||
use tracing::{debug, trace, warn};
|
use tracing::{debug, trace, warn};
|
||||||
|
|
||||||
use super::LuaDeviceCreate;
|
|
||||||
use crate::config::MqttDeviceConfig;
|
use crate::config::MqttDeviceConfig;
|
||||||
use crate::devices::Device;
|
use crate::device::{impl_device, Device, LuaDeviceCreate};
|
||||||
use crate::event::{self, Event, EventChannel, OnMqtt};
|
use crate::event::{self, Event, EventChannel, OnMqtt};
|
||||||
use crate::messages::PresenceMessage;
|
use crate::messages::PresenceMessage;
|
||||||
use crate::mqtt::WrappedAsyncClient;
|
use crate::mqtt::WrappedAsyncClient;
|
||||||
|
|
||||||
#[derive(Debug, LuaDeviceConfig, LuaTypeDefinition)]
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
pub struct PresenceConfig {
|
pub struct Config {
|
||||||
#[device_config(flatten)]
|
#[device_config(flatten)]
|
||||||
pub mqtt: MqttDeviceConfig,
|
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,
|
pub tx: event::Sender,
|
||||||
#[device_config(from_lua)]
|
#[device_config(from_lua)]
|
||||||
pub client: WrappedAsyncClient,
|
pub client: WrappedAsyncClient,
|
||||||
|
@ -24,31 +27,50 @@ pub struct PresenceConfig {
|
||||||
|
|
||||||
pub const DEFAULT_PRESENCE: bool = false;
|
pub const DEFAULT_PRESENCE: bool = false;
|
||||||
|
|
||||||
#[derive(Debug, LuaDevice)]
|
#[derive(Debug)]
|
||||||
pub struct Presence {
|
pub struct State {
|
||||||
config: PresenceConfig,
|
|
||||||
devices: HashMap<String, bool>,
|
devices: HashMap<String, bool>,
|
||||||
current_overall_presence: 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]
|
#[async_trait]
|
||||||
impl LuaDeviceCreate for Presence {
|
impl LuaDeviceCreate for Presence {
|
||||||
type Config = PresenceConfig;
|
type Config = Config;
|
||||||
type Error = rumqttc::ClientError;
|
type Error = rumqttc::ClientError;
|
||||||
|
|
||||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||||
trace!(id = "ntfy", "Setting up Presence");
|
trace!(id = "presence", "Setting up Presence");
|
||||||
|
|
||||||
config
|
config
|
||||||
.client
|
.client
|
||||||
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Self {
|
let state = State {
|
||||||
config,
|
|
||||||
devices: HashMap::new(),
|
devices: HashMap::new(),
|
||||||
current_overall_presence: DEFAULT_PRESENCE,
|
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]
|
#[async_trait]
|
||||||
impl OnMqtt for Presence {
|
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) {
|
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -77,7 +99,7 @@ impl OnMqtt for Presence {
|
||||||
if message.payload.is_empty() {
|
if message.payload.is_empty() {
|
||||||
// Remove the device from the map
|
// Remove the device from the map
|
||||||
debug!("State of device [{device_name}] has been removed");
|
debug!("State of device [{device_name}] has been removed");
|
||||||
self.devices.remove(&device_name);
|
self.state_mut().await.devices.remove(&device_name);
|
||||||
} else {
|
} else {
|
||||||
let present = match PresenceMessage::try_from(message) {
|
let present = match PresenceMessage::try_from(message) {
|
||||||
Ok(state) => state.presence(),
|
Ok(state) => state.presence(),
|
||||||
|
@ -88,13 +110,13 @@ impl OnMqtt for Presence {
|
||||||
};
|
};
|
||||||
|
|
||||||
debug!("State of device [{device_name}] has changed: {}", present);
|
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);
|
let overall_presence = self.state().await.devices.iter().any(|(_, v)| *v);
|
||||||
if overall_presence != self.current_overall_presence {
|
if overall_presence != self.state().await.current_overall_presence {
|
||||||
debug!("Overall presence updated: {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
|
if self
|
||||||
.config
|
.config
|
|
@ -7,7 +7,7 @@ edition = "2021"
|
||||||
proc-macro = true
|
proc-macro = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
itertools = "0.12.1"
|
itertools = { workspace = true }
|
||||||
proc-macro2 = "1.0.81"
|
proc-macro2 = { workspace = true }
|
||||||
quote = "1.0.36"
|
quote = { workspace = true }
|
||||||
syn = { version = "2.0.60", features = ["extra-traits", "full"] }
|
syn = { workspace = true }
|
||||||
|
|
|
@ -1,29 +1,13 @@
|
||||||
mod lua_device;
|
#![feature(let_chains)]
|
||||||
|
#![feature(iter_intersperse)]
|
||||||
mod lua_device_config;
|
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_device_config::impl_lua_device_config_macro;
|
||||||
use lua_type_definition::impl_lua_type_definition;
|
|
||||||
use syn::{parse_macro_input, DeriveInput};
|
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))]
|
#[proc_macro_derive(LuaDeviceConfig, attributes(device_config))]
|
||||||
pub fn lua_device_config_derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
pub fn lua_device_config_derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||||
let ast = parse_macro_input!(input as DeriveInput);
|
let ast = parse_macro_input!(input as DeriveInput);
|
||||||
|
|
||||||
impl_lua_device_config_macro(&ast).into()
|
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)]
|
#[derive(Debug)]
|
||||||
pub enum Argument {
|
enum Argument {
|
||||||
Flatten {
|
Flatten {
|
||||||
_keyword: kw::flatten,
|
_keyword: kw::flatten,
|
||||||
},
|
},
|
||||||
|
@ -107,8 +107,8 @@ impl Parse for Argument {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct Args {
|
struct Args {
|
||||||
pub(crate) args: Punctuated<Argument, Token![,]>,
|
args: Punctuated<Argument, Token![,]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Parse for Args {
|
impl Parse for Args {
|
||||||
|
@ -218,21 +218,6 @@ fn field_from_lua(field: &Field) -> TokenStream {
|
||||||
temp.into()
|
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! {
|
Argument::With { expr, .. } => Some(quote! {
|
||||||
{
|
{
|
||||||
let temp = #value;
|
let temp = #value;
|
||||||
|
@ -247,7 +232,7 @@ fn field_from_lua(field: &Field) -> TokenStream {
|
||||||
[] => value,
|
[] => value,
|
||||||
[value] => value.to_owned(),
|
[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();
|
.collect();
|
||||||
|
|
||||||
|
let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
|
||||||
let impl_from_lua = quote! {
|
let impl_from_lua = quote! {
|
||||||
impl<'lua> mlua::FromLua<'lua> for #name {
|
impl #impl_generics mlua::FromLua for #name #type_generics #where_clause {
|
||||||
fn from_lua(value: mlua::Value<'lua>, lua: &'lua mlua::Lua) -> mlua::Result<Self> {
|
fn from_lua(value: mlua::Value, lua: &mlua::Lua) -> mlua::Result<Self> {
|
||||||
if !value.is_table() {
|
if !value.is_table() {
|
||||||
panic!("Expected table");
|
panic!("Expected table");
|
||||||
}
|
}
|
||||||
|
|
|
@ -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({
|
local mqtt_client = automation.new_mqtt_client({
|
||||||
host = (host == "zeus" and "olympus.lan.huizinga.dev")
|
host = ((host == "zeus" or host == "hephaestus") and "olympus.lan.huizinga.dev") or "mosquitto",
|
||||||
or (host == "hephaestus" and "olympus.vpn.huizinga.dev")
|
|
||||||
or "mosquitto",
|
|
||||||
port = 8883,
|
port = 8883,
|
||||||
client_name = "automation-" .. host,
|
client_name = "automation-" .. host,
|
||||||
username = "mqtt",
|
username = "mqtt",
|
||||||
|
@ -37,7 +35,7 @@ automation.device_manager:add(Ntfy.new({
|
||||||
}))
|
}))
|
||||||
|
|
||||||
automation.device_manager:add(Presence.new({
|
automation.device_manager:add(Presence.new({
|
||||||
topic = "automation_dev/presence/+/#",
|
topic = mqtt_automation("presence/+/#"),
|
||||||
client = mqtt_client,
|
client = mqtt_client,
|
||||||
event_channel = automation.device_manager:event_channel(),
|
event_channel = automation.device_manager:event_channel(),
|
||||||
}))
|
}))
|
||||||
|
@ -48,7 +46,7 @@ automation.device_manager:add(DebugBridge.new({
|
||||||
client = mqtt_client,
|
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")
|
local hue_token = automation.util.get_env("HUE_TOKEN")
|
||||||
|
|
||||||
automation.device_manager:add(HueBridge.new({
|
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({
|
automation.device_manager:add(LightSensor.new({
|
||||||
identifier = "living_light_sensor",
|
identifier = "living_light_sensor",
|
||||||
topic = mqtt_z2m("living/light"),
|
topic = mqtt_z2m("living/light"),
|
||||||
|
@ -76,103 +115,390 @@ automation.device_manager:add(WakeOnLAN.new({
|
||||||
topic = mqtt_automation("appliance/living_room/zeus"),
|
topic = mqtt_automation("appliance/living_room/zeus"),
|
||||||
client = mqtt_client,
|
client = mqtt_client,
|
||||||
mac_address = "30:9c:23:60:9c:13",
|
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)
|
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(living_speakers)
|
||||||
|
|
||||||
automation.device_manager:add(AudioSetup.new({
|
automation.device_manager:add(IkeaRemote.new({
|
||||||
identifier = "living_audio",
|
name = "Remote",
|
||||||
topic = mqtt_z2m("living/remote"),
|
room = "Living Room",
|
||||||
client = mqtt_client,
|
client = mqtt_client,
|
||||||
mixer = living_mixer,
|
topic = mqtt_z2m("living/remote"),
|
||||||
speakers = living_speakers,
|
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",
|
outlet_type = "Kettle",
|
||||||
name = "Kettle",
|
name = "Kettle",
|
||||||
room = "Kitchen",
|
room = "Kitchen",
|
||||||
topic = mqtt_z2m("kitchen/kettle"),
|
topic = mqtt_z2m("kitchen/kettle"),
|
||||||
client = mqtt_client,
|
client = mqtt_client,
|
||||||
timeout = debug and 5 or 300,
|
callback = kettle_timeout(),
|
||||||
remotes = {
|
})
|
||||||
{ topic = mqtt_z2m("bedroom/remote") },
|
automation.device_manager:add(kettle)
|
||||||
{ topic = mqtt_z2m("kitchen/remote") },
|
|
||||||
},
|
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({
|
automation.device_manager:add(IkeaRemote.new({
|
||||||
outlet_type = "Light",
|
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",
|
name = "Light",
|
||||||
room = "Bathroom",
|
room = "Bathroom",
|
||||||
topic = mqtt_z2m("batchroom/light"),
|
topic = mqtt_z2m("bathroom/light"),
|
||||||
client = mqtt_client,
|
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({
|
automation.device_manager:add(Washer.new({
|
||||||
identifier = "bathroom_washer",
|
identifier = "bathroom_washer",
|
||||||
topic = mqtt_z2m("batchroom/washer"),
|
topic = mqtt_z2m("bathroom/washer"),
|
||||||
client = mqtt_client,
|
client = mqtt_client,
|
||||||
threshold = 1,
|
threshold = 1,
|
||||||
event_channel = automation.device_manager:event_channel(),
|
event_channel = automation.device_manager:event_channel(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
automation.device_manager:add(IkeaOutlet.new({
|
automation.device_manager:add(OutletOnOff.new({
|
||||||
outlet_type = "Charger",
|
presence_auto_off = false,
|
||||||
name = "Charger",
|
name = "Charger",
|
||||||
room = "Workbench",
|
room = "Workbench",
|
||||||
topic = mqtt_z2m("workbench/charger"),
|
topic = mqtt_z2m("workbench/charger"),
|
||||||
client = mqtt_client,
|
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",
|
name = "Outlet",
|
||||||
room = "Workbench",
|
room = "Workbench",
|
||||||
topic = mqtt_z2m("workbench/outlet"),
|
topic = mqtt_z2m("workbench/outlet"),
|
||||||
client = mqtt_client,
|
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,
|
ip = hue_ip,
|
||||||
login = hue_token,
|
login = hue_token,
|
||||||
group_id = 81,
|
group_id = 81,
|
||||||
scene_id = "3qWKxGVadXFFG4o",
|
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({
|
hallway_light_automation.group = {
|
||||||
identifier = "hallway_frontdoor",
|
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"),
|
topic = mqtt_z2m("hallway/frontdoor"),
|
||||||
client = mqtt_client,
|
client = mqtt_client,
|
||||||
presence = {
|
presence = {
|
||||||
topic = mqtt_automation("presence/contact/frontdoor"),
|
topic = mqtt_automation("presence/contact/frontdoor"),
|
||||||
timeout = debug and 10 or 15 * 60,
|
timeout = debug and 10 or 15 * 60,
|
||||||
},
|
},
|
||||||
trigger = {
|
callback = function(_, open)
|
||||||
devices = { hallway_lights },
|
hallway_light_automation:door_callback(open)
|
||||||
timeout = debug and 10 or 2 * 60,
|
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({
|
local bedroom_air_filter = AirFilter.new({
|
||||||
name = "Air Filter",
|
name = "Air Filter",
|
||||||
room = "Bedroom",
|
room = "Bedroom",
|
||||||
topic = "pico/filter/bedroom",
|
url = "http://10.0.0.103",
|
||||||
client = mqtt_client,
|
|
||||||
})
|
})
|
||||||
automation.device_manager:add(bedroom_air_filter)
|
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()
|
automation.device_manager:schedule("0 0 19 * * *", function()
|
||||||
bedroom_air_filter:set_on(true)
|
bedroom_air_filter:set_on(true)
|
||||||
end)
|
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 automation_cast::Cast;
|
||||||
use futures::future::{join_all, OptionFuture};
|
use futures::future::{join_all, OptionFuture};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::sync::{Mutex, RwLock};
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use crate::errors::{DeviceError, ErrorCode};
|
use crate::errors::{DeviceError, ErrorCode};
|
||||||
use crate::request::{self, Intent, Request};
|
use crate::request::{self, Intent, Request};
|
||||||
use crate::response::{self, execute, query, sync, Response, ResponsePayload, State};
|
use crate::response::{self, execute, query, sync, Response, ResponsePayload};
|
||||||
use crate::GoogleHomeDevice;
|
use crate::Device;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct GoogleHome {
|
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,
|
&self,
|
||||||
request: Request,
|
request: Request,
|
||||||
devices: &HashMap<String, Arc<RwLock<Box<T>>>>,
|
devices: &HashMap<String, Box<T>>,
|
||||||
) -> Result<Response, FulfillmentError> {
|
) -> Result<Response, FulfillmentError> {
|
||||||
// TODO: What do we do if we actually get more then one thing in the input array, right now
|
// 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
|
// we only respond to the first thing
|
||||||
|
@ -59,14 +59,14 @@ impl GoogleHome {
|
||||||
.map(|payload| Response::new(&request.request_id, payload))
|
.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,
|
&self,
|
||||||
devices: &HashMap<String, Arc<RwLock<Box<T>>>>,
|
devices: &HashMap<String, Box<T>>,
|
||||||
) -> sync::Payload {
|
) -> sync::Payload {
|
||||||
let mut resp_payload = sync::Payload::new(&self.user_id);
|
let mut resp_payload = sync::Payload::new(&self.user_id);
|
||||||
let f = devices.iter().map(|(_, device)| async move {
|
let f = devices.iter().map(|(_, device)| async move {
|
||||||
if let Some(device) = device.read().await.as_ref().cast() {
|
if let Some(device) = device.as_ref().cast() {
|
||||||
Some(device.sync().await)
|
Some(Device::sync(device).await)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
@ -76,10 +76,10 @@ impl GoogleHome {
|
||||||
resp_payload
|
resp_payload
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn query<T: Cast<dyn GoogleHomeDevice> + ?Sized + 'static>(
|
async fn query<T: Cast<dyn Device> + ?Sized + 'static>(
|
||||||
&self,
|
&self,
|
||||||
payload: request::query::Payload,
|
payload: request::query::Payload,
|
||||||
devices: &HashMap<String, Arc<RwLock<Box<T>>>>,
|
devices: &HashMap<String, Box<T>>,
|
||||||
) -> query::Payload {
|
) -> query::Payload {
|
||||||
let mut resp_payload = query::Payload::new();
|
let mut resp_payload = query::Payload::new();
|
||||||
let f = payload
|
let f = payload
|
||||||
|
@ -89,9 +89,9 @@ impl GoogleHome {
|
||||||
.map(|id| async move {
|
.map(|id| async move {
|
||||||
// NOTE: Requires let_chains feature
|
// NOTE: Requires let_chains feature
|
||||||
let device = if let Some(device) = devices.get(id.as_str())
|
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 {
|
} else {
|
||||||
let mut device = query::Device::new();
|
let mut device = query::Device::new();
|
||||||
device.set_offline();
|
device.set_offline();
|
||||||
|
@ -108,10 +108,10 @@ impl GoogleHome {
|
||||||
resp_payload
|
resp_payload
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute<T: Cast<dyn GoogleHomeDevice> + ?Sized + 'static>(
|
async fn execute<T: Cast<dyn Device> + ?Sized + 'static>(
|
||||||
&self,
|
&self,
|
||||||
payload: request::execute::Payload,
|
payload: request::execute::Payload,
|
||||||
devices: &HashMap<String, Arc<RwLock<Box<T>>>>,
|
devices: &HashMap<String, Box<T>>,
|
||||||
) -> execute::Payload {
|
) -> execute::Payload {
|
||||||
let resp_payload = Arc::new(Mutex::new(response::execute::Payload::new()));
|
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);
|
let mut success = response::execute::Command::new(execute::Status::Success);
|
||||||
success.states = Some(execute::States {
|
success.states = Some(execute::States {
|
||||||
online: true,
|
online: true,
|
||||||
state: State::default(),
|
state: Default::default(),
|
||||||
});
|
});
|
||||||
let mut offline = response::execute::Command::new(execute::Status::Offline);
|
let mut offline = response::execute::Command::new(execute::Status::Offline);
|
||||||
offline.states = Some(execute::States {
|
offline.states = Some(execute::States {
|
||||||
online: false,
|
online: false,
|
||||||
state: State::default(),
|
state: Default::default(),
|
||||||
});
|
});
|
||||||
let mut errors: HashMap<ErrorCode, response::execute::Command> = HashMap::new();
|
let mut errors: HashMap<ErrorCode, response::execute::Command> = HashMap::new();
|
||||||
|
|
||||||
|
@ -138,16 +138,16 @@ impl GoogleHome {
|
||||||
let execution = command.execution.clone();
|
let execution = command.execution.clone();
|
||||||
async move {
|
async move {
|
||||||
if let Some(device) = devices.get(id.as_str())
|
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));
|
return (id, Ok(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: We can not use .map here because async =(
|
// NOTE: We can not use .map here because async =(
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
for cmd in &execution {
|
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
|
// Convert vec of results to a result with a vec and the first
|
|
@ -7,12 +7,11 @@ mod fulfillment;
|
||||||
mod request;
|
mod request;
|
||||||
mod response;
|
mod response;
|
||||||
|
|
||||||
mod attributes;
|
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
pub use device::GoogleHomeDevice;
|
pub use device::Device;
|
||||||
pub use fulfillment::{FulfillmentError, GoogleHome};
|
pub use fulfillment::{FulfillmentError, GoogleHome};
|
||||||
pub use request::Request;
|
pub use request::Request;
|
||||||
pub use response::Response;
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::request::{Intent, Request};
|
use crate::request::{Intent, Request};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deserialize() {
|
fn deserialize() {
|
||||||
let json = r#"{
|
let req = json!({
|
||||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
|
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
"intent": "action.devices.QUERY",
|
"intent": "action.devices.QUERY",
|
||||||
"payload": {
|
"payload": {
|
||||||
"devices": [
|
"devices": [
|
||||||
{
|
{
|
||||||
"id": "123",
|
"id": "123",
|
||||||
"customData": {
|
"customData": {
|
||||||
"fooValue": 74,
|
"fooValue": 74,
|
||||||
"barValue": true,
|
"barValue": true,
|
||||||
"bazValue": "foo"
|
"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);
|
println!("{:?}", req);
|
||||||
|
|
|
@ -1,19 +1,21 @@
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::request::{Intent, Request};
|
use crate::request::{Intent, Request};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deserialize() {
|
fn deserialize() {
|
||||||
let json = r#"{
|
let req = json!({
|
||||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
|
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
"intent": "action.devices.SYNC"
|
"intent": "action.devices.SYNC"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}"#;
|
});
|
||||||
|
|
||||||
let req: Request = serde_json::from_str(json).unwrap();
|
let req: Request = serde_json::from_value(req).unwrap();
|
||||||
|
|
||||||
println!("{:?}", req);
|
println!("{:?}", req);
|
||||||
|
|
|
@ -27,16 +27,3 @@ pub enum ResponsePayload {
|
||||||
Query(query::Payload),
|
Query(query::Payload),
|
||||||
Execute(execute::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 serde::Serialize;
|
||||||
|
|
||||||
use crate::errors::ErrorCode;
|
use crate::errors::ErrorCode;
|
||||||
use crate::response::State;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Clone)]
|
#[derive(Debug, Serialize, Clone)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
@ -72,7 +71,7 @@ pub struct States {
|
||||||
pub online: bool,
|
pub online: bool,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub state: State,
|
pub state: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Clone)]
|
#[derive(Debug, Serialize, Clone)]
|
||||||
|
@ -87,19 +86,19 @@ pub enum Status {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::errors::DeviceError;
|
use crate::errors::DeviceError;
|
||||||
use crate::response::{Response, ResponsePayload, State};
|
use crate::response::{Response, ResponsePayload};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn serialize() {
|
fn serialize() {
|
||||||
let mut execute_resp = Payload::new();
|
let mut execute_resp = Payload::new();
|
||||||
|
|
||||||
let state = State {
|
let state = json!({
|
||||||
on: Some(true),
|
"on": true,
|
||||||
current_fan_speed_setting: None,
|
});
|
||||||
humidity_ambient_percent: None,
|
|
||||||
};
|
|
||||||
let mut command = Command::new(Status::Success);
|
let mut command = Command::new(Status::Success);
|
||||||
command.states = Some(States {
|
command.states = Some(States {
|
||||||
online: true,
|
online: true,
|
||||||
|
@ -118,10 +117,28 @@ mod tests {
|
||||||
ResponsePayload::Execute(execute_resp),
|
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 serde::Serialize;
|
||||||
|
|
||||||
use crate::errors::ErrorCode;
|
use crate::errors::ErrorCode;
|
||||||
use crate::response::State;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
@ -53,7 +52,7 @@ pub struct Device {
|
||||||
error_code: Option<ErrorCode>,
|
error_code: Option<ErrorCode>,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub state: State,
|
pub state: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Device {
|
impl Device {
|
||||||
|
@ -62,7 +61,7 @@ impl Device {
|
||||||
online: true,
|
online: true,
|
||||||
status: Status::Success,
|
status: Status::Success,
|
||||||
error_code: None,
|
error_code: None,
|
||||||
state: State::default(),
|
state: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,6 +87,8 @@ impl Default for Device {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::response::{Response, ResponsePayload};
|
use crate::response::{Response, ResponsePayload};
|
||||||
|
|
||||||
|
@ -96,11 +97,15 @@ mod tests {
|
||||||
let mut query_resp = Payload::new();
|
let mut query_resp = Payload::new();
|
||||||
|
|
||||||
let mut device = Device::new();
|
let mut device = Device::new();
|
||||||
device.state.on = Some(true);
|
device.state = json!({
|
||||||
|
"on": true,
|
||||||
|
});
|
||||||
query_resp.add_device("123", device);
|
query_resp.add_device("123", device);
|
||||||
|
|
||||||
let mut device = Device::new();
|
let mut device = Device::new();
|
||||||
device.state.on = Some(false);
|
device.state = json!({
|
||||||
|
"on": true,
|
||||||
|
});
|
||||||
query_resp.add_device("456", device);
|
query_resp.add_device("456", device);
|
||||||
|
|
||||||
let resp = Response::new(
|
let resp = Response::new(
|
||||||
|
@ -108,10 +113,26 @@ mod tests {
|
||||||
ResponsePayload::Query(query_resp),
|
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 serde::Serialize;
|
||||||
|
|
||||||
use crate::attributes::Attributes;
|
|
||||||
use crate::device;
|
use crate::device;
|
||||||
use crate::errors::ErrorCode;
|
use crate::errors::ErrorCode;
|
||||||
use crate::traits::Trait;
|
use crate::traits::Trait;
|
||||||
|
@ -47,7 +46,8 @@ pub struct Device {
|
||||||
pub room_hint: Option<String>,
|
pub room_hint: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub device_info: Option<device::Info>,
|
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 {
|
impl Device {
|
||||||
|
@ -61,13 +61,15 @@ impl Device {
|
||||||
notification_supported_by_agent: None,
|
notification_supported_by_agent: None,
|
||||||
room_hint: None,
|
room_hint: None,
|
||||||
device_info: None,
|
device_info: None,
|
||||||
attributes: Attributes::default(),
|
attributes: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::response::{Response, ResponsePayload};
|
use crate::response::{Response, ResponsePayload};
|
||||||
use crate::traits::Trait;
|
use crate::traits::Trait;
|
||||||
|
@ -97,10 +99,35 @@ mod tests {
|
||||||
ResponsePayload::Sync(sync_resp),
|
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,
|
Scene,
|
||||||
#[serde(rename = "action.devices.types.AIRPURIFIER")]
|
#[serde(rename = "action.devices.types.AIRPURIFIER")]
|
||||||
AirPurifier,
|
AirPurifier,
|
||||||
|
#[serde(rename = "action.devices.types.DOOR")]
|
||||||
|
Door,
|
||||||
|
#[serde(rename = "action.devices.types.WINDOW")]
|
||||||
|
Window,
|
||||||
|
#[serde(rename = "action.devices.types.DRAWER")]
|
||||||
|
Drawer,
|
||||||
}
|
}
|
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]
|
[toolchain]
|
||||||
channel = "nightly-2023-11-15"
|
channel = "nightly-2024-12-06"
|
||||||
components = ["rustfmt", "clippy"]
|
components = ["rustfmt", "clippy", "rust-analyzer"]
|
||||||
profile = "minimal"
|
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::path::Path;
|
||||||
use std::process;
|
use std::process;
|
||||||
|
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
use automation::auth::User;
|
use automation_lib::config::{FulfillmentConfig, MqttConfig};
|
||||||
use automation::config::{FulfillmentConfig, MqttConfig};
|
use automation_lib::device_manager::DeviceManager;
|
||||||
use automation::device_manager::DeviceManager;
|
use automation_lib::helpers;
|
||||||
use automation::error::ApiError;
|
use automation_lib::mqtt::{self, WrappedAsyncClient};
|
||||||
use automation::mqtt::{self, WrappedAsyncClient};
|
use automation_lib::ntfy::Ntfy;
|
||||||
use automation::{devices, LUA};
|
use automation_lib::presence::Presence;
|
||||||
use axum::extract::FromRef;
|
use axum::extract::{FromRef, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::IntoResponse;
|
|
||||||
use axum::routing::post;
|
use axum::routing::post;
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
use google_home::{GoogleHome, Request};
|
use google_home::{GoogleHome, Request, Response};
|
||||||
use mlua::LuaSerdeExt;
|
use mlua::LuaSerdeExt;
|
||||||
use rumqttc::AsyncClient;
|
use rumqttc::AsyncClient;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
use web::{ApiError, User};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct AppState {
|
struct AppState {
|
||||||
pub openid_url: String,
|
pub openid_url: String,
|
||||||
|
pub device_manager: DeviceManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromRef<AppState> for String {
|
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<()> {
|
async fn app() -> anyhow::Result<()> {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
|
|
||||||
|
@ -56,7 +78,7 @@ async fn app() -> anyhow::Result<()> {
|
||||||
let device_manager = DeviceManager::new().await;
|
let device_manager = DeviceManager::new().await;
|
||||||
|
|
||||||
let fulfillment_config = {
|
let fulfillment_config = {
|
||||||
let lua = LUA.lock().await;
|
let lua = mlua::Lua::new();
|
||||||
|
|
||||||
lua.set_warning_function(|_lua, text, _cont| {
|
lua.set_warning_function(|_lua, text, _cont| {
|
||||||
warn!("{text}");
|
warn!("{text}");
|
||||||
|
@ -94,7 +116,11 @@ async fn app() -> anyhow::Result<()> {
|
||||||
|
|
||||||
lua.globals().set("automation", automation)?;
|
lua.globals().set("automation", automation)?;
|
||||||
|
|
||||||
devices::register_with_lua(&lua)?;
|
automation_devices::register_with_lua(&lua)?;
|
||||||
|
helpers::register_with_lua(&lua)?;
|
||||||
|
lua.globals().set("Ntfy", lua.create_proxy::<Ntfy>()?)?;
|
||||||
|
lua.globals()
|
||||||
|
.set("Presence", lua.create_proxy::<Presence>()?)?;
|
||||||
|
|
||||||
// TODO: Make this not hardcoded
|
// TODO: Make this not hardcoded
|
||||||
let config_filename = std::env::var("AUTOMATION_CONFIG").unwrap_or("./config.lua".into());
|
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
|
// Create google home fulfillment route
|
||||||
let fulfillment = Router::new().route(
|
let fulfillment = Router::new().route("/google_home", post(fulfillment));
|
||||||
"/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()
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Combine together all the routes
|
// Combine together all the routes
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.nest("/fulfillment", fulfillment)
|
.nest("/fulfillment", fulfillment)
|
||||||
.with_state(AppState {
|
.with_state(AppState {
|
||||||
openid_url: fulfillment_config.openid_url.clone(),
|
openid_url: fulfillment_config.openid_url.clone(),
|
||||||
|
device_manager,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start the web server
|
// Start the web server
|
||||||
let addr = fulfillment_config.into();
|
let addr: SocketAddr = fulfillment_config.into();
|
||||||
info!("Server started on http://{addr}");
|
info!("Server started on http://{addr}");
|
||||||
axum::Server::try_bind(&addr)?
|
let listener = TcpListener::bind(addr).await?;
|
||||||
.serve(app.into_make_service())
|
axum::serve(listener, app).await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait Timeout: Sync + Send {
|
|
||||||
async fn start_timeout(&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::async_trait;
|
||||||
use axum::extract::{FromRef, FromRequestParts};
|
use axum::extract::{FromRef, FromRequestParts};
|
||||||
use axum::http::request::Parts;
|
use axum::http::request::Parts;
|
||||||
|
use axum::http::status::InvalidStatusCode;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use serde::Deserialize;
|
use axum::response::IntoResponse;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::error::{ApiError, ApiErrorJson};
|
#[derive(Debug, Error)]
|
||||||
|
#[error("{source}")]
|
||||||
|
pub struct ApiError {
|
||||||
|
status_code: axum::http::StatusCode,
|
||||||
|
source: Box<dyn std::error::Error>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiError {
|
||||||
|
pub fn new(status_code: axum::http::StatusCode, source: Box<dyn std::error::Error>) -> Self {
|
||||||
|
Self {
|
||||||
|
status_code,
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ApiError> for ApiErrorJson {
|
||||||
|
fn from(value: ApiError) -> Self {
|
||||||
|
let error = ApiErrorJsonError {
|
||||||
|
code: value.status_code.as_u16(),
|
||||||
|
status: value.status_code.to_string(),
|
||||||
|
reason: value.source.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Self { error }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
(
|
||||||
|
self.status_code,
|
||||||
|
serde_json::to_string::<ApiErrorJson>(&self.into())
|
||||||
|
.expect("Serialization should not fail"),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct ApiErrorJsonError {
|
||||||
|
code: u16,
|
||||||
|
status: String,
|
||||||
|
reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ApiErrorJson {
|
||||||
|
error: ApiErrorJsonError,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<ApiErrorJson> for ApiError {
|
||||||
|
type Error = InvalidStatusCode;
|
||||||
|
|
||||||
|
fn try_from(value: ApiErrorJson) -> result::Result<Self, Self::Error> {
|
||||||
|
let status_code = axum::http::StatusCode::from_u16(value.error.code)?;
|
||||||
|
let source = value.error.reason.into();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
status_code,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
|
@ -25,6 +93,8 @@ where
|
||||||
|
|
||||||
// Create a request to the auth server
|
// Create a request to the auth server
|
||||||
// TODO: Do some discovery to find the correct url for this instead of assuming
|
// TODO: Do some discovery to find the correct url for this instead of assuming
|
||||||
|
// TODO: I think we can also just run Authlia in front of the endpoint instead
|
||||||
|
// This would then give us a header containing the logged in user info?
|
||||||
let mut req = reqwest::Client::new().get(format!("{}/userinfo", openid_url));
|
let mut req = reqwest::Client::new().get(format!("{}/userinfo", openid_url));
|
||||||
|
|
||||||
// Add auth header to the request if it exists
|
// Add auth header to the request if it exists
|
Loading…
Reference in New Issue
Block a user