Compare commits
No commits in common. "master" and "feature/lua" have entirely different histories.
master
...
feature/lu
15
.git-hooks/pre-commit
Executable file
15
.git-hooks/pre-commit
Executable file
|
@ -0,0 +1,15 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -o nounset # Fail on use of unset variable.
|
||||||
|
set -o errexit # Exit on command failure.
|
||||||
|
set -o pipefail # Exit on failure of any command in a pipeline.
|
||||||
|
set -o errtrace # Trap errors in functions and subshells.
|
||||||
|
set -o noglob # Disable filename expansion (globbing),
|
||||||
|
# since it could otherwise happen during
|
||||||
|
# path splitting.
|
||||||
|
shopt -s inherit_errexit # Inherit the errexit option status in subshells.
|
||||||
|
|
||||||
|
set -x
|
||||||
|
|
||||||
|
git update-index --refresh
|
||||||
|
cargo clippy --all-targets --all -- -D warnings
|
||||||
|
cargo fmt -- --check
|
1
.gitattributes
vendored
1
.gitattributes
vendored
|
@ -1 +0,0 @@
|
||||||
*.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
|
name: Build and deploy automation_rs
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
|
@ -8,7 +8,7 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build application
|
name: Build automation_rs
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: catthehacker/ubuntu:act-latest
|
container: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
|
@ -20,17 +20,23 @@ jobs:
|
||||||
with:
|
with:
|
||||||
rustflags: ""
|
rustflags: ""
|
||||||
|
|
||||||
|
- name: Formatting
|
||||||
|
uses: actions-rust-lang/rustfmt@v1
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --all-targets --all -- -D warnings
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --release
|
run: cargo build --release
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: automation
|
name: build
|
||||||
path: target/x86_64-unknown-linux-gnu/release/automation
|
path: target/x86_64-unknown-linux-gnu/release/automation
|
||||||
|
|
||||||
container:
|
container:
|
||||||
name: Build container
|
name: Build Docker image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build]
|
needs: [build]
|
||||||
container: catthehacker/ubuntu:act-latest
|
container: catthehacker/ubuntu:act-latest
|
||||||
|
@ -41,23 +47,12 @@ jobs:
|
||||||
- name: Download artifact
|
- name: Download artifact
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: automation
|
name: build
|
||||||
|
|
||||||
- name: Set permissions
|
- name: Set permissions
|
||||||
run: |
|
run: |
|
||||||
chown 65532:65532 ./automation
|
chown 65532:65532 ./build/*
|
||||||
chmod 0755 ./automation
|
chmod 0755 ./build/*
|
||||||
|
|
||||||
- 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
|
||||||
|
@ -70,12 +65,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: true
|
push: ${{ gitea.ref == 'refs/heads/master' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
# TODO: Automatically add the correct tags here
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
tags: git.huizinga.dev/dreaded_x/automation_rs:latest
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy container
|
name: Deploy Docker container
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: catthehacker/ubuntu:act-latest
|
container: catthehacker/ubuntu:act-latest
|
||||||
needs: [container]
|
needs: [container]
|
||||||
|
@ -97,7 +92,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:master
|
git.huizinga.dev/dreaded_x/automation_rs:latest
|
||||||
|
|
||||||
docker network connect web automation_rs
|
docker network connect web automation_rs
|
||||||
|
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
repos:
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
||||||
rev: v4.6.0
|
|
||||||
hooks:
|
|
||||||
- id: trailing-whitespace
|
|
||||||
- id: end-of-file-fixer
|
|
||||||
- id: check-yaml
|
|
||||||
- id: check-toml
|
|
||||||
- id: check-added-large-files
|
|
||||||
- id: check-merge-conflict
|
|
||||||
|
|
||||||
- repo: https://github.com/doublify/pre-commit-rust
|
|
||||||
rev: v1.0
|
|
||||||
hooks:
|
|
||||||
- id: clippy
|
|
||||||
- id: fmt
|
|
||||||
|
|
||||||
- repo: https://github.com/JohnnyMorganz/StyLua
|
|
||||||
rev: v0.20.0
|
|
||||||
hooks:
|
|
||||||
- id: stylua
|
|
||||||
|
|
||||||
- repo: https://github.com/crate-ci/typos
|
|
||||||
rev: v1.21.0
|
|
||||||
hooks:
|
|
||||||
- id: typos
|
|
||||||
args: ["--force-exclude"]
|
|
||||||
|
|
||||||
- repo: https://github.com/pryorda/dockerfilelint-precommit-hooks
|
|
||||||
rev: v0.1.0
|
|
||||||
hooks:
|
|
||||||
- id: dockerfilelint
|
|
1424
Cargo.lock
generated
1424
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
105
Cargo.toml
105
Cargo.toml
|
@ -4,85 +4,50 @@ version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = ["google-home", "automation_macro", "automation_cast"]
|
||||||
"automation_macro",
|
|
||||||
"automation_cast",
|
|
||||||
"google_home/google_home",
|
|
||||||
"google_home/google_home_macro",
|
|
||||||
"automation_devices",
|
|
||||||
"automation_lib",
|
|
||||||
]
|
|
||||||
|
|
||||||
[workspace.dependencies]
|
|
||||||
mlua = { version = "0.10.1", features = [
|
[dependencies]
|
||||||
"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/" }
|
||||||
automation_lib = { path = "./automation_lib" }
|
rumqttc = "0.18"
|
||||||
automation_devices = { path = "./automation_devices" }
|
serde = { version = "1.0.149", features = ["derive"] }
|
||||||
google_home = { path = "./google_home/google_home" }
|
serde_json = "1.0.89"
|
||||||
google_home_macro = { path = "./google_home/google_home_macro" }
|
google-home = { path = "./google-home" }
|
||||||
|
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"
|
||||||
dyn-clone = "1.0.17"
|
reqwest = { version = "0.11.13", features = [
|
||||||
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
|
||||||
serde = { version = "1.0.149", features = ["derive"] }
|
axum = "0.6.1"
|
||||||
serde_json = "1.0.89"
|
|
||||||
serde_repr = "0.1.10"
|
serde_repr = "0.1.10"
|
||||||
syn = { version = "2.0.60", features = ["extra-traits", "full"] }
|
tracing = "0.1.37"
|
||||||
thiserror = "2.0.5"
|
bytes = "1.3.0"
|
||||||
tokio-cron-scheduler = "0.13.0"
|
pollster = "0.2.5"
|
||||||
tokio-util = { version = "0.7.11", features = ["full"] }
|
regex = "1.7.0"
|
||||||
tracing-subscriber = "0.3.16"
|
async-trait = "0.1.61"
|
||||||
uuid = "1.8.0"
|
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"
|
wakey = "0.3.0"
|
||||||
air_filter_types = { git = "https://git.huizinga.dev/Dreaded_X/airfilter", tag = "v0.4.4" }
|
console-subscriber = "0.1.8"
|
||||||
|
tracing-subscriber = "0.3.16"
|
||||||
[dependencies]
|
serde_with = "3.2.0"
|
||||||
automation_lib = { workspace = true }
|
enum_dispatch = "0.3.12"
|
||||||
automation_devices = { workspace = true }
|
indexmap = { version = "2.0.0", features = ["serde"] }
|
||||||
google_home = { workspace = true }
|
serde_yaml = "0.9.27"
|
||||||
mlua = { workspace = true }
|
tokio-cron-scheduler = "0.9.4"
|
||||||
tokio = { workspace = true }
|
mlua = { version = "0.9.7", features = ["lua54", "vendored", "macros", "serialize", "async", "send"] }
|
||||||
hostname = { workspace = true }
|
once_cell = "1.19.0"
|
||||||
rumqttc = { workspace = true }
|
hostname = "0.4.0"
|
||||||
axum = { workspace = true }
|
tokio-util = { version = "0.7.11", features = ["full"] }
|
||||||
tracing = { workspace = true }
|
uuid = "1.8.0"
|
||||||
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 ./automation /app/automation
|
COPY ./build/automation /app/automation
|
||||||
|
|
||||||
CMD ["/app/automation"]
|
CMD ["/app/automation"]
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
# automation_rs
|
# automation_rs
|
||||||
|
|
||||||
Custom home automation solution with Google Home integration and lua scripting.
|
Custom home automation solution with google-home integration
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
This repository uses [pre-commit](https://pre-commit.com) to make sure everything is ready to go when committing.
|
Make sure to setup git hooks by running
|
||||||
Install the pre-commit hooks by running the following command:
|
|
||||||
|
|
||||||
```bash
|
```sh
|
||||||
pre-commit install
|
git config --local core.hooksPath .git-hooks/
|
||||||
```
|
```
|
||||||
|
|
BIN
assets/logo.png
BIN
assets/logo.png
Binary file not shown.
Before Width: | Height: | Size: 7.7 KiB |
BIN
assets/logo.xcf
(Stored with Git LFS)
BIN
assets/logo.xcf
(Stored with Git LFS)
Binary file not shown.
|
@ -6,6 +6,7 @@ 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
|
||||||
|
@ -15,6 +16,10 @@ 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
|
||||||
|
@ -25,4 +30,8 @@ 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,27 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "automation_devices"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
automation_lib = { workspace = true }
|
|
||||||
automation_macro = { workspace = true }
|
|
||||||
automation_cast = { workspace = true }
|
|
||||||
google_home = { workspace = true }
|
|
||||||
mlua = { workspace = true }
|
|
||||||
async-trait = { workspace = true }
|
|
||||||
dyn-clone = { workspace = true }
|
|
||||||
rumqttc = { workspace = true }
|
|
||||||
tokio = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
impls = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
|
||||||
reqwest = { workspace = true } # Use rustls, since the other packages also use rustls
|
|
||||||
anyhow = { workspace = true }
|
|
||||||
axum = { workspace = true }
|
|
||||||
bytes = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
eui48 = { workspace = true }
|
|
||||||
wakey = { workspace = true }
|
|
||||||
air_filter_types = { workspace = true }
|
|
|
@ -1,226 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,254 +0,0 @@
|
||||||
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,162 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,116 +0,0 @@
|
||||||
use async_trait::async_trait;
|
|
||||||
use automation_lib::action_callback::ActionCallback;
|
|
||||||
use automation_lib::config::{InfoConfig, MqttDeviceConfig};
|
|
||||||
use automation_lib::device::{Device, LuaDeviceCreate};
|
|
||||||
use automation_lib::event::OnMqtt;
|
|
||||||
use automation_lib::mqtt::WrappedAsyncClient;
|
|
||||||
use automation_macro::LuaDeviceConfig;
|
|
||||||
use rumqttc::{matches, Publish};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use tracing::{debug, trace, warn};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
|
||||||
pub struct Config {
|
|
||||||
#[device_config(flatten)]
|
|
||||||
pub info: InfoConfig,
|
|
||||||
|
|
||||||
#[device_config(flatten)]
|
|
||||||
pub mqtt: MqttDeviceConfig,
|
|
||||||
|
|
||||||
#[device_config(from_lua)]
|
|
||||||
pub client: WrappedAsyncClient,
|
|
||||||
|
|
||||||
#[device_config(from_lua, default)]
|
|
||||||
pub left_callback: ActionCallback<HueSwitch, ()>,
|
|
||||||
|
|
||||||
#[device_config(from_lua, default)]
|
|
||||||
pub right_callback: ActionCallback<HueSwitch, ()>,
|
|
||||||
|
|
||||||
#[device_config(from_lua, default)]
|
|
||||||
pub left_hold_callback: ActionCallback<HueSwitch, ()>,
|
|
||||||
|
|
||||||
#[device_config(from_lua, default)]
|
|
||||||
pub right_hold_callback: ActionCallback<HueSwitch, ()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
enum Action {
|
|
||||||
LeftPress,
|
|
||||||
LeftPressRelease,
|
|
||||||
LeftHold,
|
|
||||||
LeftHoldRelease,
|
|
||||||
RightPress,
|
|
||||||
RightPressRelease,
|
|
||||||
RightHold,
|
|
||||||
RightHoldRelease,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
struct State {
|
|
||||||
action: Action,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct HueSwitch {
|
|
||||||
config: Config,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Device for HueSwitch {
|
|
||||||
fn get_id(&self) -> String {
|
|
||||||
self.config.info.identifier()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl LuaDeviceCreate for HueSwitch {
|
|
||||||
type Config = Config;
|
|
||||||
type Error = rumqttc::ClientError;
|
|
||||||
|
|
||||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
|
||||||
trace!(id = config.info.identifier(), "Setting up HueSwitch");
|
|
||||||
|
|
||||||
config
|
|
||||||
.client
|
|
||||||
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Self { config })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl OnMqtt for HueSwitch {
|
|
||||||
async fn on_mqtt(&self, message: Publish) {
|
|
||||||
// Check if the message is from the device itself or from a remote
|
|
||||||
if matches(&message.topic, &self.config.mqtt.topic) {
|
|
||||||
let action = match serde_json::from_slice::<State>(&message.payload) {
|
|
||||||
Ok(message) => message.action,
|
|
||||||
Err(err) => {
|
|
||||||
warn!(id = Device::get_id(self), "Failed to parse message: {err}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
debug!(id = Device::get_id(self), "Remote action = {:?}", action);
|
|
||||||
|
|
||||||
match action {
|
|
||||||
Action::LeftPressRelease => self.config.left_callback.call(self, &()).await,
|
|
||||||
Action::RightPressRelease => self.config.right_callback.call(self, &()).await,
|
|
||||||
Action::LeftHold => self.config.left_hold_callback.call(self, &()).await,
|
|
||||||
Action::RightHold => self.config.right_hold_callback.call(self, &()).await,
|
|
||||||
// If there is no hold action, the switch will act like a normal release
|
|
||||||
Action::RightHoldRelease => {
|
|
||||||
if !self.config.right_hold_callback.is_set() {
|
|
||||||
self.config.right_callback.call(self, &()).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Action::LeftHoldRelease => {
|
|
||||||
if !self.config.left_hold_callback.is_set() {
|
|
||||||
self.config.left_callback.call(self, &()).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,91 +0,0 @@
|
||||||
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,159 +0,0 @@
|
||||||
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,299 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,2 +0,0 @@
|
||||||
pub mod light;
|
|
||||||
pub mod outlet;
|
|
|
@ -1,275 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,28 +0,0 @@
|
||||||
[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 }
|
|
|
@ -1,71 +0,0 @@
|
||||||
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,99 +0,0 @@
|
||||||
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,11 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
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",
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,76 +0,0 @@
|
||||||
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)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -7,7 +7,7 @@ edition = "2021"
|
||||||
proc-macro = true
|
proc-macro = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
itertools = { workspace = true }
|
itertools = "0.12.1"
|
||||||
proc-macro2 = { workspace = true }
|
proc-macro2 = "1.0.81"
|
||||||
quote = { workspace = true }
|
quote = "1.0.36"
|
||||||
syn = { workspace = true }
|
syn = { version = "2.0.60", features = ["extra-traits", "full"] }
|
||||||
|
|
|
@ -1,10 +1,17 @@
|
||||||
#![feature(let_chains)]
|
mod lua_device;
|
||||||
#![feature(iter_intersperse)]
|
|
||||||
mod lua_device_config;
|
mod lua_device_config;
|
||||||
|
|
||||||
|
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 syn::{parse_macro_input, DeriveInput};
|
use syn::{parse_macro_input, DeriveInput};
|
||||||
|
|
||||||
|
#[proc_macro_derive(LuaDevice, attributes(config))]
|
||||||
|
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);
|
||||||
|
|
28
automation_macro/src/lua_device.rs
Normal file
28
automation_macro/src/lua_device.rs
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
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>()?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
|
@ -260,10 +260,9 @@ pub fn impl_lua_device_config_macro(ast: &DeriveInput) -> TokenStream {
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
|
|
||||||
let impl_from_lua = quote! {
|
let impl_from_lua = quote! {
|
||||||
impl #impl_generics mlua::FromLua for #name #type_generics #where_clause {
|
impl<'lua> mlua::FromLua<'lua> for #name {
|
||||||
fn from_lua(value: mlua::Value, lua: &mlua::Lua) -> mlua::Result<Self> {
|
fn from_lua(value: mlua::Value<'lua>, lua: &'lua mlua::Lua) -> mlua::Result<Self> {
|
||||||
if !value.is_table() {
|
if !value.is_table() {
|
||||||
panic!("Expected table");
|
panic!("Expected table");
|
||||||
}
|
}
|
||||||
|
|
416
config.lua
416
config.lua
|
@ -21,7 +21,9 @@ automation.fulfillment = {
|
||||||
}
|
}
|
||||||
|
|
||||||
local mqtt_client = automation.new_mqtt_client({
|
local mqtt_client = automation.new_mqtt_client({
|
||||||
host = ((host == "zeus" or host == "hephaestus") and "olympus.lan.huizinga.dev") or "mosquitto",
|
host = (host == "zeus" and "olympus.lan.huizinga.dev")
|
||||||
|
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",
|
||||||
|
@ -35,7 +37,7 @@ automation.device_manager:add(Ntfy.new({
|
||||||
}))
|
}))
|
||||||
|
|
||||||
automation.device_manager:add(Presence.new({
|
automation.device_manager:add(Presence.new({
|
||||||
topic = mqtt_automation("presence/+/#"),
|
topic = "automation_dev/presence/+/#",
|
||||||
client = mqtt_client,
|
client = mqtt_client,
|
||||||
event_channel = automation.device_manager:event_channel(),
|
event_channel = automation.device_manager:event_channel(),
|
||||||
}))
|
}))
|
||||||
|
@ -46,7 +48,7 @@ automation.device_manager:add(DebugBridge.new({
|
||||||
client = mqtt_client,
|
client = mqtt_client,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
local hue_ip = "10.0.0.102"
|
local hue_ip = "10.0.0.146"
|
||||||
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({
|
||||||
|
@ -59,47 +61,6 @@ 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"),
|
||||||
|
@ -115,390 +76,103 @@ 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.3.255",
|
broadcast_ip = "10.0.0.255",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
local living_mixer = OutletOnOff.new({
|
local living_mixer = KasaOutlet.new({ identifier = "living_mixer", ip = "10.0.0.49" })
|
||||||
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 = OutletOnOff.new({
|
local living_speakers = KasaOutlet.new({ identifier = "living_speakers", ip = "10.0.0.182" })
|
||||||
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(IkeaRemote.new({
|
automation.device_manager:add(AudioSetup.new({
|
||||||
name = "Remote",
|
identifier = "living_audio",
|
||||||
room = "Living Room",
|
|
||||||
client = mqtt_client,
|
|
||||||
topic = mqtt_z2m("living/remote"),
|
topic = mqtt_z2m("living/remote"),
|
||||||
single_button = true,
|
client = mqtt_client,
|
||||||
callback = function(_, on)
|
mixer = living_mixer,
|
||||||
if on then
|
speakers = living_speakers,
|
||||||
if living_mixer:on() then
|
|
||||||
living_mixer:set_on(false)
|
|
||||||
living_speakers:set_on(false)
|
|
||||||
else
|
|
||||||
living_mixer:set_on(true)
|
|
||||||
living_speakers:set_on(true)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
if not living_mixer:on() then
|
|
||||||
living_mixer:set_on(true)
|
|
||||||
else
|
|
||||||
living_speakers:set_on(not living_speakers:on())
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
local function kettle_timeout()
|
automation.device_manager:add(IkeaOutlet.new({
|
||||||
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,
|
||||||
callback = kettle_timeout(),
|
timeout = debug and 5 or 300,
|
||||||
})
|
remotes = {
|
||||||
automation.device_manager:add(kettle)
|
{ topic = mqtt_z2m("bedroom/remote") },
|
||||||
|
{ 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(IkeaRemote.new({
|
automation.device_manager:add(IkeaOutlet.new({
|
||||||
name = "Remote",
|
outlet_type = "Light",
|
||||||
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("bathroom/light"),
|
topic = mqtt_z2m("batchroom/light"),
|
||||||
client = mqtt_client,
|
client = mqtt_client,
|
||||||
callback = off_timeout(debug and 60 or 45 * 60),
|
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("bathroom/washer"),
|
topic = mqtt_z2m("batchroom/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(OutletOnOff.new({
|
automation.device_manager:add(IkeaOutlet.new({
|
||||||
presence_auto_off = false,
|
outlet_type = "Charger",
|
||||||
name = "Charger",
|
name = "Charger",
|
||||||
room = "Workbench",
|
room = "Workbench",
|
||||||
topic = mqtt_z2m("workbench/charger"),
|
topic = mqtt_z2m("workbench/charger"),
|
||||||
client = mqtt_client,
|
client = mqtt_client,
|
||||||
callback = off_timeout(debug and 5 or 20 * 3600),
|
timeout = debug and 5 or 20 * 3600,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
automation.device_manager:add(OutletOnOff.new({
|
automation.device_manager:add(IkeaOutlet.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({
|
||||||
local workbench_light = LightBrightness.new({
|
identifier = "hallway_lights",
|
||||||
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,
|
||||||
automation.device_manager:add(hallway_bottom_lights)
|
remotes = {
|
||||||
|
{ topic = mqtt_z2m("hallway/remote") },
|
||||||
hallway_light_automation.group = {
|
},
|
||||||
set_on = function(on)
|
|
||||||
if on then
|
|
||||||
hallway_storage:set_brightness(80)
|
|
||||||
else
|
|
||||||
hallway_storage:set_on(false)
|
|
||||||
end
|
|
||||||
hallway_bottom_lights:set_on(on)
|
|
||||||
end,
|
|
||||||
}
|
|
||||||
|
|
||||||
automation.device_manager:add(IkeaRemote.new({
|
|
||||||
name = "Remote",
|
|
||||||
room = "Hallway",
|
|
||||||
client = mqtt_client,
|
client = mqtt_client,
|
||||||
topic = mqtt_z2m("hallway/remote"),
|
})
|
||||||
callback = function(_, on)
|
automation.device_manager:add(hallway_lights)
|
||||||
hallway_light_automation:switch_callback(on)
|
|
||||||
end,
|
automation.device_manager:add(ContactSensor.new({
|
||||||
}))
|
identifier = "hallway_frontdoor",
|
||||||
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,
|
||||||
},
|
},
|
||||||
callback = function(_, open)
|
trigger = {
|
||||||
hallway_light_automation:door_callback(open)
|
devices = { hallway_lights },
|
||||||
end,
|
timeout = debug and 10 or 2 * 60,
|
||||||
})
|
},
|
||||||
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",
|
||||||
url = "http://10.0.0.103",
|
topic = "pico/filter/bedroom",
|
||||||
|
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)
|
||||||
|
|
16
google-home/Cargo.toml
Normal file
16
google-home/Cargo.toml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
[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"
|
22
google-home/src/attributes.rs
Normal file
22
google-home/src/attributes.rs
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
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>,
|
||||||
|
}
|
174
google-home/src/device.rs
Normal file
174
google-home/src/device.rs
Normal file
|
@ -0,0 +1,174 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -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;
|
use tokio::sync::{Mutex, RwLock};
|
||||||
|
|
||||||
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};
|
use crate::response::{self, execute, query, sync, Response, ResponsePayload, State};
|
||||||
use crate::Device;
|
use crate::GoogleHomeDevice;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct GoogleHome {
|
pub struct GoogleHome {
|
||||||
|
@ -30,10 +30,10 @@ impl GoogleHome {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle_request<T: Cast<dyn Device> + ?Sized + 'static>(
|
pub async fn handle_request<T: Cast<dyn GoogleHomeDevice> + ?Sized + 'static>(
|
||||||
&self,
|
&self,
|
||||||
request: Request,
|
request: Request,
|
||||||
devices: &HashMap<String, Box<T>>,
|
devices: &HashMap<String, Arc<RwLock<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 Device> + ?Sized + 'static>(
|
async fn sync<T: Cast<dyn GoogleHomeDevice> + ?Sized + 'static>(
|
||||||
&self,
|
&self,
|
||||||
devices: &HashMap<String, Box<T>>,
|
devices: &HashMap<String, Arc<RwLock<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.as_ref().cast() {
|
if let Some(device) = device.read().await.as_ref().cast() {
|
||||||
Some(Device::sync(device).await)
|
Some(device.sync().await)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
@ -76,10 +76,10 @@ impl GoogleHome {
|
||||||
resp_payload
|
resp_payload
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn query<T: Cast<dyn Device> + ?Sized + 'static>(
|
async fn query<T: Cast<dyn GoogleHomeDevice> + ?Sized + 'static>(
|
||||||
&self,
|
&self,
|
||||||
payload: request::query::Payload,
|
payload: request::query::Payload,
|
||||||
devices: &HashMap<String, Box<T>>,
|
devices: &HashMap<String, Arc<RwLock<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.as_ref().cast()
|
&& let Some(device) = device.read().await.as_ref().cast()
|
||||||
{
|
{
|
||||||
Device::query(device).await
|
device.query().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 Device> + ?Sized + 'static>(
|
async fn execute<T: Cast<dyn GoogleHomeDevice> + ?Sized + 'static>(
|
||||||
&self,
|
&self,
|
||||||
payload: request::execute::Payload,
|
payload: request::execute::Payload,
|
||||||
devices: &HashMap<String, Box<T>>,
|
devices: &HashMap<String, Arc<RwLock<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: Default::default(),
|
state: State::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: Default::default(),
|
state: State::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.as_ref().cast()
|
&& let Some(device) = device.write().await.as_mut().cast_mut()
|
||||||
{
|
{
|
||||||
if !device.is_online().await {
|
if !device.is_online() {
|
||||||
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(device, cmd.clone()).await);
|
results.push(device.execute(cmd).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,11 +7,12 @@ 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::Device;
|
pub use device::GoogleHomeDevice;
|
||||||
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;
|
106
google-home/src/request/execute.rs
Normal file
106
google-home/src/request/execute.rs
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
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"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,42 +15,40 @@ 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 req = json!({
|
let json = r#"{
|
||||||
"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_value(req).unwrap();
|
let req: Request = serde_json::from_str(json).unwrap();
|
||||||
|
|
||||||
println!("{:?}", req);
|
println!("{:?}", req);
|
||||||
|
|
|
@ -1,21 +1,19 @@
|
||||||
#[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 req = json!({
|
let json = r#"{
|
||||||
"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_value(req).unwrap();
|
let req: Request = serde_json::from_str(json).unwrap();
|
||||||
|
|
||||||
println!("{:?}", req);
|
println!("{:?}", req);
|
||||||
|
|
|
@ -27,3 +27,16 @@ 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,6 +1,7 @@
|
||||||
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")]
|
||||||
|
@ -71,7 +72,7 @@ pub struct States {
|
||||||
pub online: bool,
|
pub online: bool,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub state: serde_json::Value,
|
pub state: State,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Clone)]
|
#[derive(Debug, Serialize, Clone)]
|
||||||
|
@ -86,19 +87,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};
|
use crate::response::{Response, ResponsePayload, State};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn serialize() {
|
fn serialize() {
|
||||||
let mut execute_resp = Payload::new();
|
let mut execute_resp = Payload::new();
|
||||||
|
|
||||||
let state = json!({
|
let state = State {
|
||||||
"on": true,
|
on: Some(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,
|
||||||
|
@ -117,28 +118,10 @@ mod tests {
|
||||||
ResponsePayload::Execute(execute_resp),
|
ResponsePayload::Execute(execute_resp),
|
||||||
);
|
);
|
||||||
|
|
||||||
let resp = serde_json::to_value(resp).unwrap();
|
let json = serde_json::to_string(&resp).unwrap();
|
||||||
|
|
||||||
let resp_expected = json!({
|
println!("{}", json);
|
||||||
"payload": {
|
|
||||||
"commands": [
|
|
||||||
{
|
|
||||||
"states": {
|
|
||||||
"on": true,
|
|
||||||
"online": true
|
|
||||||
},
|
|
||||||
"ids": ["123"],
|
|
||||||
"status": "SUCCESS"
|
|
||||||
}, {
|
|
||||||
"errorCode": "deviceNotFound",
|
|
||||||
"ids": ["456"],
|
|
||||||
"status":"ERROR"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf"
|
|
||||||
});
|
|
||||||
|
|
||||||
assert_eq!(resp, resp_expected);
|
// TODO: Add a known correct output to test against
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -3,6 +3,7 @@ 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")]
|
||||||
|
@ -52,7 +53,7 @@ pub struct Device {
|
||||||
error_code: Option<ErrorCode>,
|
error_code: Option<ErrorCode>,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub state: serde_json::Value,
|
pub state: State,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Device {
|
impl Device {
|
||||||
|
@ -61,7 +62,7 @@ impl Device {
|
||||||
online: true,
|
online: true,
|
||||||
status: Status::Success,
|
status: Status::Success,
|
||||||
error_code: None,
|
error_code: None,
|
||||||
state: Default::default(),
|
state: State::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -87,8 +88,6 @@ 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};
|
||||||
|
|
||||||
|
@ -97,15 +96,11 @@ 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 = json!({
|
device.state.on = Some(true);
|
||||||
"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 = json!({
|
device.state.on = Some(false);
|
||||||
"on": true,
|
|
||||||
});
|
|
||||||
query_resp.add_device("456", device);
|
query_resp.add_device("456", device);
|
||||||
|
|
||||||
let resp = Response::new(
|
let resp = Response::new(
|
||||||
|
@ -113,26 +108,10 @@ mod tests {
|
||||||
ResponsePayload::Query(query_resp),
|
ResponsePayload::Query(query_resp),
|
||||||
);
|
);
|
||||||
|
|
||||||
let resp = serde_json::to_value(resp).unwrap();
|
let json = serde_json::to_string(&resp).unwrap();
|
||||||
|
|
||||||
let resp_expected = json!({
|
println!("{}", json);
|
||||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
|
|
||||||
"payload": {
|
|
||||||
"devices": {
|
|
||||||
"123": {
|
|
||||||
"online": true,
|
|
||||||
"status": "SUCCESS",
|
|
||||||
"on": true
|
|
||||||
},
|
|
||||||
"456": {
|
|
||||||
"online": true,
|
|
||||||
"status": "SUCCESS",
|
|
||||||
"on":true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
assert_eq!(resp, resp_expected);
|
// TODO: Add a known correct output to test against
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
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;
|
||||||
|
@ -46,8 +47,7 @@ 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>,
|
||||||
#[serde(skip_serializing_if = "serde_json::Value::is_null")]
|
pub attributes: Attributes,
|
||||||
pub attributes: serde_json::Value,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Device {
|
impl Device {
|
||||||
|
@ -61,15 +61,13 @@ 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: Default::default(),
|
attributes: Attributes::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;
|
||||||
|
@ -99,35 +97,10 @@ mod tests {
|
||||||
ResponsePayload::Sync(sync_resp),
|
ResponsePayload::Sync(sync_resp),
|
||||||
);
|
);
|
||||||
|
|
||||||
let resp = serde_json::to_value(resp).unwrap();
|
let json = serde_json::to_string(&resp).unwrap();
|
||||||
|
|
||||||
let resp_expected = json!({
|
println!("{}", 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!(resp, resp_expected);
|
// 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"}}]}}"#)
|
||||||
}
|
}
|
||||||
}
|
}
|
83
google-home/src/traits.rs
Normal file
83
google-home/src/traits.rs
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
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;
|
||||||
|
}
|
|
@ -12,10 +12,4 @@ 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,
|
|
||||||
}
|
}
|
|
@ -1,17 +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 = { 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 }
|
|
|
@ -1,120 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,142 +0,0 @@
|
||||||
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"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,83 +0,0 @@
|
||||||
#![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,
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
[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 }
|
|
|
@ -1,569 +0,0 @@
|
||||||
#![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-2024-12-06"
|
channel = "nightly-2023-11-15"
|
||||||
components = ["rustfmt", "clippy", "rust-analyzer"]
|
components = ["rustfmt", "clippy"]
|
||||||
profile = "minimal"
|
profile = "minimal"
|
||||||
|
|
|
@ -1,78 +1,10 @@
|
||||||
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 axum::response::IntoResponse;
|
use serde::Deserialize;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
use crate::error::{ApiError, ApiErrorJson};
|
||||||
#[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 {
|
||||||
|
@ -93,8 +25,6 @@ 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
|
|
@ -1,17 +1,63 @@
|
||||||
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::device::Device;
|
use crate::devices::Device;
|
||||||
use crate::event::{Event, EventChannel, OnDarkness, OnMqtt, OnNotification, OnPresence};
|
use crate::event::{Event, EventChannel, OnDarkness, OnMqtt, OnNotification, OnPresence};
|
||||||
|
use crate::LUA;
|
||||||
|
|
||||||
pub type DeviceMap = HashMap<String, Box<dyn Device>>;
|
#[derive(Debug, FromLua, Clone)]
|
||||||
|
pub struct WrappedDevice(Arc<RwLock<Box<dyn Device>>>);
|
||||||
|
|
||||||
|
impl WrappedDevice {
|
||||||
|
pub fn new(device: Box<dyn Device>) -> Self {
|
||||||
|
Self(Arc::new(RwLock::new(device)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for WrappedDevice {
|
||||||
|
type Target = Arc<RwLock<Box<dyn Device>>>;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DerefMut for WrappedDevice {
|
||||||
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||||
|
&mut self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl mlua::UserData for WrappedDevice {
|
||||||
|
fn add_methods<'lua, M: mlua::prelude::LuaUserDataMethods<'lua, Self>>(methods: &mut M) {
|
||||||
|
methods.add_async_method("get_id", |_lua, this, _: ()| async {
|
||||||
|
Ok(crate::devices::Device::get_id(this.0.read().await.as_ref()))
|
||||||
|
});
|
||||||
|
|
||||||
|
methods.add_async_method("set_on", |_lua, this, on: bool| async move {
|
||||||
|
let mut device = this.0.write().await;
|
||||||
|
let device = device.as_mut();
|
||||||
|
|
||||||
|
if let Some(device) = device.cast_mut() as Option<&mut dyn OnOff> {
|
||||||
|
device.set_on(on).await.unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type DeviceMap = HashMap<String, Arc<RwLock<Box<dyn Device>>>>;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct DeviceManager {
|
pub struct DeviceManager {
|
||||||
|
@ -48,20 +94,25 @@ impl DeviceManager {
|
||||||
device_manager
|
device_manager
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add(&self, device: Box<dyn Device>) {
|
pub async fn add(&self, device: &WrappedDevice) {
|
||||||
let id = device.get_id();
|
let id = device.read().await.get_id().to_owned();
|
||||||
|
|
||||||
debug!(id, "Adding device");
|
debug!(id, "Adding device");
|
||||||
|
|
||||||
self.devices.write().await.insert(id, device);
|
self.devices.write().await.insert(id, device.0.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
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<Box<dyn Device>> {
|
pub async fn get(&self, name: &str) -> Option<WrappedDevice> {
|
||||||
self.devices.read().await.get(name).cloned()
|
self.devices
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.get(name)
|
||||||
|
.cloned()
|
||||||
|
.map(WrappedDevice)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn devices(&self) -> RwLockReadGuard<DeviceMap> {
|
pub async fn devices(&self) -> RwLockReadGuard<DeviceMap> {
|
||||||
|
@ -76,7 +127,8 @@ 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 device: Option<&dyn OnMqtt> = device.cast();
|
let mut device = device.write().await;
|
||||||
|
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()
|
||||||
|
@ -97,7 +149,8 @@ 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 device: Option<&dyn OnDarkness> = device.cast();
|
let mut device = device.write().await;
|
||||||
|
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;
|
||||||
|
@ -110,7 +163,8 @@ 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 device: Option<&dyn OnPresence> = device.cast();
|
let mut device = device.write().await;
|
||||||
|
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;
|
||||||
|
@ -125,7 +179,8 @@ 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 device: Option<&dyn OnNotification> = device.cast();
|
let mut device = device.write().await;
|
||||||
|
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;
|
||||||
|
@ -140,10 +195,27 @@ 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<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
|
fn add_methods<'lua, M: mlua::UserDataMethods<'lua, Self>>(methods: &mut M) {
|
||||||
methods.add_async_method("add", |_lua, this, device: Box<dyn Device>| async move {
|
methods.add_async_method("add", |_lua, this, device: WrappedDevice| async move {
|
||||||
this.add(device).await;
|
this.add(&device).await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
@ -152,27 +224,7 @@ 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}");
|
||||||
// This creates a function, that returns the actual job we want to run
|
let job = Job::new_async(schedule.as_str(), run_schedule).unwrap();
|
||||||
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();
|
||||||
|
|
||||||
|
@ -184,6 +236,12 @@ 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()))
|
||||||
}
|
}
|
||||||
}
|
}
|
231
src/devices/air_filter.rs
Normal file
231
src/devices/air_filter.rs
Normal file
|
@ -0,0 +1,231 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use automation_macro::{LuaDevice, LuaDeviceConfig};
|
||||||
|
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)]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
137
src/devices/audio_setup.rs
Normal file
137
src/devices/audio_setup.rs
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use automation_macro::{LuaDevice, LuaDeviceConfig};
|
||||||
|
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)]
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
235
src/devices/contact_sensor.rs
Normal file
235
src/devices/contact_sensor.rs
Normal file
|
@ -0,0 +1,235 @@
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use automation_macro::{LuaDevice, LuaDeviceConfig};
|
||||||
|
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)]
|
||||||
|
pub struct PresenceDeviceConfig {
|
||||||
|
#[device_config(flatten)]
|
||||||
|
pub mqtt: MqttDeviceConfig,
|
||||||
|
#[device_config(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)]
|
||||||
|
pub struct TriggerConfig {
|
||||||
|
#[device_config(from_lua, from(TriggerDevicesHelper))]
|
||||||
|
pub devices: Vec<(WrappedDevice, bool)>,
|
||||||
|
#[device_config(default, with(|t: Option<_>| t.map(Duration::from_secs)))]
|
||||||
|
pub timeout: Option<Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
|
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,16 +1,18 @@
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use automation_lib::config::MqttDeviceConfig;
|
use automation_macro::{LuaDevice, LuaDeviceConfig};
|
||||||
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;
|
||||||
|
use crate::config::MqttDeviceConfig;
|
||||||
|
use crate::devices::Device;
|
||||||
|
use crate::event::{OnDarkness, OnPresence};
|
||||||
|
use crate::messages::{DarknessMessage, PresenceMessage};
|
||||||
|
use crate::mqtt::WrappedAsyncClient;
|
||||||
|
|
||||||
#[derive(Debug, LuaDeviceConfig, Clone)]
|
#[derive(Debug, LuaDeviceConfig, Clone)]
|
||||||
pub struct Config {
|
pub struct DebugBridgeConfig {
|
||||||
pub identifier: String,
|
pub identifier: String,
|
||||||
#[device_config(flatten)]
|
#[device_config(flatten)]
|
||||||
pub mqtt: MqttDeviceConfig,
|
pub mqtt: MqttDeviceConfig,
|
||||||
|
@ -18,14 +20,14 @@ pub struct Config {
|
||||||
pub client: WrappedAsyncClient,
|
pub client: WrappedAsyncClient,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, LuaDevice)]
|
||||||
pub struct DebugBridge {
|
pub struct DebugBridge {
|
||||||
config: Config,
|
config: DebugBridgeConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LuaDeviceCreate for DebugBridge {
|
impl LuaDeviceCreate for DebugBridge {
|
||||||
type Config = Config;
|
type Config = DebugBridgeConfig;
|
||||||
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> {
|
||||||
|
@ -42,7 +44,7 @@ impl Device for DebugBridge {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnPresence for DebugBridge {
|
impl OnPresence for DebugBridge {
|
||||||
async fn on_presence(&self, presence: bool) {
|
async fn on_presence(&mut 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
|
||||||
|
@ -66,7 +68,7 @@ impl OnPresence for DebugBridge {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnDarkness for DebugBridge {
|
impl OnDarkness for DebugBridge {
|
||||||
async fn on_darkness(&self, dark: bool) {
|
async fn on_darkness(&mut 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
|
|
@ -2,12 +2,14 @@ use std::convert::Infallible;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use automation_lib::device::{Device, LuaDeviceCreate};
|
use automation_macro::{LuaDevice, LuaDeviceConfig};
|
||||||
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,
|
||||||
|
@ -21,7 +23,7 @@ pub struct FlagIDs {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, LuaDeviceConfig, Clone)]
|
#[derive(Debug, LuaDeviceConfig, Clone)]
|
||||||
pub struct Config {
|
pub struct HueBridgeConfig {
|
||||||
pub identifier: String,
|
pub identifier: String,
|
||||||
#[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 80)))]
|
#[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 80)))]
|
||||||
pub addr: SocketAddr,
|
pub addr: SocketAddr,
|
||||||
|
@ -29,9 +31,9 @@ pub struct Config {
|
||||||
pub flags: FlagIDs,
|
pub flags: FlagIDs,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, LuaDevice)]
|
||||||
pub struct HueBridge {
|
pub struct HueBridge {
|
||||||
config: Config,
|
config: HueBridgeConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
@ -41,7 +43,7 @@ struct FlagMessage {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LuaDeviceCreate for HueBridge {
|
impl LuaDeviceCreate for HueBridge {
|
||||||
type Config = Config;
|
type Config = HueBridgeConfig;
|
||||||
type Error = Infallible;
|
type Error = Infallible;
|
||||||
|
|
||||||
async fn create(config: Self::Config) -> Result<Self, Infallible> {
|
async fn create(config: Self::Config) -> Result<Self, Infallible> {
|
||||||
|
@ -91,7 +93,7 @@ impl Device for HueBridge {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnPresence for HueBridge {
|
impl OnPresence for HueBridge {
|
||||||
async fn on_presence(&self, presence: bool) {
|
async fn on_presence(&mut 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;
|
||||||
}
|
}
|
||||||
|
@ -99,7 +101,7 @@ impl OnPresence for HueBridge {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnDarkness for HueBridge {
|
impl OnDarkness for HueBridge {
|
||||||
async fn on_darkness(&self, dark: bool) {
|
async fn on_darkness(&mut 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;
|
||||||
}
|
}
|
324
src/devices/hue_group.rs
Normal file
324
src/devices/hue_group.rs
Normal file
|
@ -0,0 +1,324 @@
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use automation_macro::{LuaDevice, LuaDeviceConfig};
|
||||||
|
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)]
|
||||||
|
pub struct HueGroupConfig {
|
||||||
|
pub identifier: String,
|
||||||
|
#[device_config(rename("ip"), 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
251
src/devices/ikea_outlet.rs
Normal file
251
src/devices/ikea_outlet.rs
Normal file
|
@ -0,0 +1,251 @@
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use automation_macro::{LuaDevice, LuaDeviceConfig};
|
||||||
|
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)]
|
||||||
|
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, 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(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,33 +3,33 @@ use std::net::SocketAddr;
|
||||||
use std::str::Utf8Error;
|
use std::str::Utf8Error;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use automation_lib::device::{Device, LuaDeviceCreate};
|
use automation_macro::{LuaDevice, LuaDeviceConfig};
|
||||||
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::OnOff;
|
use google_home::traits;
|
||||||
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::{debug, trace};
|
use tracing::trace;
|
||||||
|
|
||||||
|
use super::{Device, LuaDeviceCreate};
|
||||||
|
|
||||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
pub struct Config {
|
pub struct KasaOutletConfig {
|
||||||
pub identifier: String,
|
pub identifier: String,
|
||||||
#[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 9999)))]
|
#[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 9999)))]
|
||||||
pub addr: SocketAddr,
|
pub addr: SocketAddr,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, LuaDevice)]
|
||||||
pub struct KasaOutlet {
|
pub struct KasaOutlet {
|
||||||
config: Config,
|
config: KasaOutletConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LuaDeviceCreate for KasaOutlet {
|
impl LuaDeviceCreate for KasaOutlet {
|
||||||
type Config = Config;
|
type Config = KasaOutletConfig;
|
||||||
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 OnOff for KasaOutlet {
|
impl traits::OnOff for KasaOutlet {
|
||||||
async fn on(&self) -> Result<bool, errors::ErrorCode> {
|
async fn is_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 OnOff for KasaOutlet {
|
||||||
.or(Err(DeviceError::TransientError.into()))
|
.or(Err(DeviceError::TransientError.into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn set_on(&self, on: bool) -> Result<(), errors::ErrorCode> {
|
async fn set_on(&mut 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,13 +275,3 @@ impl 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,18 +1,17 @@
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use automation_lib::config::MqttDeviceConfig;
|
use automation_macro::{LuaDevice, LuaDeviceConfig};
|
||||||
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;
|
||||||
|
use crate::config::MqttDeviceConfig;
|
||||||
|
use crate::devices::Device;
|
||||||
|
use crate::event::{self, Event, EventChannel, OnMqtt};
|
||||||
|
use crate::messages::BrightnessMessage;
|
||||||
|
use crate::mqtt::WrappedAsyncClient;
|
||||||
|
|
||||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
pub struct Config {
|
pub struct LightSensorConfig {
|
||||||
pub identifier: String,
|
pub identifier: String,
|
||||||
#[device_config(flatten)]
|
#[device_config(flatten)]
|
||||||
pub mqtt: MqttDeviceConfig,
|
pub mqtt: MqttDeviceConfig,
|
||||||
|
@ -26,30 +25,16 @@ pub struct Config {
|
||||||
|
|
||||||
const DEFAULT: bool = false;
|
const DEFAULT: bool = false;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, LuaDevice)]
|
||||||
pub struct State {
|
|
||||||
is_dark: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct LightSensor {
|
pub struct LightSensor {
|
||||||
config: Config,
|
config: LightSensorConfig,
|
||||||
state: Arc<RwLock<State>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LightSensor {
|
is_dark: bool,
|
||||||
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 = Config;
|
type Config = LightSensorConfig;
|
||||||
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> {
|
||||||
|
@ -60,10 +45,10 @@ impl LuaDeviceCreate for LightSensor {
|
||||||
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let state = State { is_dark: DEFAULT };
|
Ok(Self {
|
||||||
let state = Arc::new(RwLock::new(state));
|
config,
|
||||||
|
is_dark: DEFAULT,
|
||||||
Ok(Self { config, state })
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -75,7 +60,7 @@ impl Device for LightSensor {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnMqtt for LightSensor {
|
impl OnMqtt for LightSensor {
|
||||||
async fn on_mqtt(&self, message: Publish) {
|
async fn on_mqtt(&mut self, message: Publish) {
|
||||||
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -96,19 +81,18 @@ 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,
|
||||||
is_dark
|
self.is_dark
|
||||||
);
|
);
|
||||||
is_dark
|
self.is_dark
|
||||||
};
|
};
|
||||||
|
|
||||||
if is_dark != self.state().await.is_dark {
|
if is_dark != self.is_dark {
|
||||||
debug!("Dark state has changed: {is_dark}");
|
debug!("Dark state has changed: {is_dark}");
|
||||||
self.state_mut().await.is_dark = is_dark;
|
self.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");
|
80
src/devices/mod.rs
Normal file
80
src/devices/mod.rs
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
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;
|
||||||
|
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::{Notification, Ntfy};
|
||||||
|
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;
|
||||||
|
}
|
|
@ -1,15 +1,14 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::ops::Deref;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use automation_cast::Cast;
|
use automation_macro::{LuaDevice, LuaDeviceConfig};
|
||||||
use automation_macro::LuaDeviceConfig;
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_repr::*;
|
use serde_repr::*;
|
||||||
use tracing::{error, trace, warn};
|
use tracing::{error, trace, warn};
|
||||||
|
|
||||||
use crate::device::{impl_device, Device, LuaDeviceCreate};
|
use super::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)]
|
||||||
|
@ -112,8 +111,8 @@ impl Default for Notification {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
#[derive(Debug, LuaDeviceConfig)]
|
||||||
pub struct Config {
|
pub struct NtfyConfig {
|
||||||
#[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,
|
||||||
|
@ -121,16 +120,14 @@ pub struct Config {
|
||||||
pub tx: event::Sender,
|
pub tx: event::Sender,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, LuaDevice)]
|
||||||
pub struct Ntfy {
|
pub struct Ntfy {
|
||||||
config: Config,
|
config: NtfyConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl_device!(Ntfy);
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LuaDeviceCreate for Ntfy {
|
impl LuaDeviceCreate for Ntfy {
|
||||||
type Config = Config;
|
type Config = NtfyConfig;
|
||||||
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> {
|
||||||
|
@ -169,7 +166,7 @@ impl Ntfy {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnPresence for Ntfy {
|
impl OnPresence for Ntfy {
|
||||||
async fn on_presence(&self, presence: bool) {
|
async fn on_presence(&mut 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()),
|
||||||
|
@ -205,7 +202,7 @@ impl OnPresence for Ntfy {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnNotification for Ntfy {
|
impl OnNotification for Ntfy {
|
||||||
async fn on_notification(&self, notification: Notification) {
|
async fn on_notification(&mut self, notification: Notification) {
|
||||||
self.send(notification).await;
|
self.send(notification).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,22 +1,19 @@
|
||||||
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_cast::Cast;
|
use automation_macro::{LuaDevice, LuaDeviceConfig};
|
||||||
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::device::{impl_device, Device, LuaDeviceCreate};
|
use crate::devices::Device;
|
||||||
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, Clone, LuaDeviceConfig)]
|
#[derive(Debug, LuaDeviceConfig)]
|
||||||
pub struct Config {
|
pub struct PresenceConfig {
|
||||||
#[device_config(flatten)]
|
#[device_config(flatten)]
|
||||||
pub mqtt: MqttDeviceConfig,
|
pub mqtt: MqttDeviceConfig,
|
||||||
#[device_config(from_lua, rename("event_channel"), with(|ec: EventChannel| ec.get_tx()))]
|
#[device_config(from_lua, rename("event_channel"), with(|ec: EventChannel| ec.get_tx()))]
|
||||||
|
@ -27,50 +24,31 @@ pub struct Config {
|
||||||
|
|
||||||
pub const DEFAULT_PRESENCE: bool = false;
|
pub const DEFAULT_PRESENCE: bool = false;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, LuaDevice)]
|
||||||
pub struct State {
|
pub struct Presence {
|
||||||
|
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 = Config;
|
type Config = PresenceConfig;
|
||||||
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 = "presence", "Setting up Presence");
|
trace!(id = "ntfy", "Setting up Presence");
|
||||||
|
|
||||||
config
|
config
|
||||||
.client
|
.client
|
||||||
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let state = State {
|
Ok(Self {
|
||||||
|
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 })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,7 +60,7 @@ impl Device for Presence {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnMqtt for Presence {
|
impl OnMqtt for Presence {
|
||||||
async fn on_mqtt(&self, message: Publish) {
|
async fn on_mqtt(&mut self, message: Publish) {
|
||||||
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -99,7 +77,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.state_mut().await.devices.remove(&device_name);
|
self.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(),
|
||||||
|
@ -110,13 +88,13 @@ impl OnMqtt for Presence {
|
||||||
};
|
};
|
||||||
|
|
||||||
debug!("State of device [{device_name}] has changed: {}", present);
|
debug!("State of device [{device_name}] has changed: {}", present);
|
||||||
self.state_mut().await.devices.insert(device_name, present);
|
self.devices.insert(device_name, present);
|
||||||
}
|
}
|
||||||
|
|
||||||
let overall_presence = self.state().await.devices.iter().any(|(_, v)| *v);
|
let overall_presence = self.devices.iter().any(|(_, v)| *v);
|
||||||
if overall_presence != self.state().await.current_overall_presence {
|
if overall_presence != self.current_overall_presence {
|
||||||
debug!("Overall presence updated: {overall_presence}");
|
debug!("Overall presence updated: {overall_presence}");
|
||||||
self.state_mut().await.current_overall_presence = overall_presence;
|
self.current_overall_presence = overall_presence;
|
||||||
|
|
||||||
if self
|
if self
|
||||||
.config
|
.config
|
|
@ -1,22 +1,23 @@
|
||||||
use std::net::Ipv4Addr;
|
use std::net::Ipv4Addr;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use automation_lib::config::{InfoConfig, MqttDeviceConfig};
|
use automation_macro::{LuaDevice, LuaDeviceConfig};
|
||||||
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};
|
||||||
|
use crate::config::{InfoConfig, MqttDeviceConfig};
|
||||||
|
use crate::event::OnMqtt;
|
||||||
|
use crate::messages::ActivateMessage;
|
||||||
|
use crate::mqtt::WrappedAsyncClient;
|
||||||
|
|
||||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
pub struct Config {
|
pub struct WakeOnLANConfig {
|
||||||
#[device_config(flatten)]
|
#[device_config(flatten)]
|
||||||
pub info: InfoConfig,
|
pub info: InfoConfig,
|
||||||
#[device_config(flatten)]
|
#[device_config(flatten)]
|
||||||
|
@ -28,14 +29,14 @@ pub struct Config {
|
||||||
pub client: WrappedAsyncClient,
|
pub client: WrappedAsyncClient,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, LuaDevice)]
|
||||||
pub struct WakeOnLAN {
|
pub struct WakeOnLAN {
|
||||||
config: Config,
|
config: WakeOnLANConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LuaDeviceCreate for WakeOnLAN {
|
impl LuaDeviceCreate for WakeOnLAN {
|
||||||
type Config = Config;
|
type Config = WakeOnLANConfig;
|
||||||
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> {
|
||||||
|
@ -58,7 +59,7 @@ impl Device for WakeOnLAN {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnMqtt for WakeOnLAN {
|
impl OnMqtt for WakeOnLAN {
|
||||||
async fn on_mqtt(&self, message: Publish) {
|
async fn on_mqtt(&mut self, message: Publish) {
|
||||||
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -75,8 +76,7 @@ impl OnMqtt for WakeOnLAN {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
impl GoogleHomeDevice for WakeOnLAN {
|
||||||
impl google_home::Device for WakeOnLAN {
|
|
||||||
fn get_device_type(&self) -> Type {
|
fn get_device_type(&self) -> Type {
|
||||||
Type::Scene
|
Type::Scene
|
||||||
}
|
}
|
||||||
|
@ -92,7 +92,7 @@ impl google_home::Device for WakeOnLAN {
|
||||||
Device::get_id(self)
|
Device::get_id(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn is_online(&self) -> bool {
|
fn is_online(&self) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,17 +103,8 @@ impl google_home::Device for WakeOnLAN {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl traits::Scene for WakeOnLAN {
|
impl traits::Scene for WakeOnLAN {
|
||||||
async fn set_active(&self, deactivate: bool) -> Result<(), ErrorCode> {
|
async fn set_active(&self, activate: bool) -> Result<(), ErrorCode> {
|
||||||
if deactivate {
|
if activate {
|
||||||
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 {})",
|
||||||
|
@ -140,6 +131,15 @@ 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,19 +1,17 @@
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use automation_lib::config::MqttDeviceConfig;
|
use automation_macro::{LuaDevice, LuaDeviceConfig};
|
||||||
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;
|
||||||
|
use super::{Device, LuaDeviceCreate, Notification};
|
||||||
|
use crate::config::MqttDeviceConfig;
|
||||||
|
use crate::event::{self, Event, EventChannel, OnMqtt};
|
||||||
|
use crate::messages::PowerMessage;
|
||||||
|
use crate::mqtt::WrappedAsyncClient;
|
||||||
|
|
||||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||||
pub struct Config {
|
pub struct WasherConfig {
|
||||||
pub identifier: String,
|
pub identifier: String,
|
||||||
#[device_config(flatten)]
|
#[device_config(flatten)]
|
||||||
pub mqtt: MqttDeviceConfig,
|
pub mqtt: MqttDeviceConfig,
|
||||||
|
@ -25,31 +23,17 @@ pub struct Config {
|
||||||
pub client: WrappedAsyncClient,
|
pub client: WrappedAsyncClient,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct State {
|
|
||||||
running: isize,
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Add google home integration
|
// TODO: Add google home integration
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, LuaDevice)]
|
||||||
pub struct Washer {
|
pub struct Washer {
|
||||||
config: Config,
|
config: WasherConfig,
|
||||||
state: Arc<RwLock<State>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Washer {
|
running: isize,
|
||||||
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 = Config;
|
type Config = WasherConfig;
|
||||||
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> {
|
||||||
|
@ -60,10 +44,7 @@ impl LuaDeviceCreate for Washer {
|
||||||
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let state = State { running: 0 };
|
Ok(Self { config, running: 0 })
|
||||||
let state = Arc::new(RwLock::new(state));
|
|
||||||
|
|
||||||
Ok(Self { config, state })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,7 +61,7 @@ const HYSTERESIS: isize = 10;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnMqtt for Washer {
|
impl OnMqtt for Washer {
|
||||||
async fn on_mqtt(&self, message: Publish) {
|
async fn on_mqtt(&mut self, message: Publish) {
|
||||||
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -98,7 +79,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.state().await.running >= HYSTERESIS {
|
if power < self.config.threshold && self.running >= HYSTERESIS {
|
||||||
// The washer is done running
|
// The washer is done running
|
||||||
debug!(
|
debug!(
|
||||||
id = self.config.identifier,
|
id = self.config.identifier,
|
||||||
|
@ -107,7 +88,7 @@ impl OnMqtt for Washer {
|
||||||
"Washer is done"
|
"Washer is done"
|
||||||
);
|
);
|
||||||
|
|
||||||
self.state_mut().await.running = 0;
|
self.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!")
|
||||||
|
@ -125,8 +106,8 @@ impl OnMqtt for Washer {
|
||||||
}
|
}
|
||||||
} else if power < self.config.threshold {
|
} else if power < self.config.threshold {
|
||||||
// Prevent false positives
|
// Prevent false positives
|
||||||
self.state_mut().await.running = 0;
|
self.running = 0;
|
||||||
} else if power >= self.config.threshold && self.state().await.running < HYSTERESIS {
|
} else if power >= self.config.threshold && self.running < HYSTERESIS {
|
||||||
// Washer could be starting
|
// Washer could be starting
|
||||||
debug!(
|
debug!(
|
||||||
id = self.config.identifier,
|
id = self.config.identifier,
|
||||||
|
@ -135,7 +116,7 @@ impl OnMqtt for Washer {
|
||||||
"Washer is starting"
|
"Washer is starting"
|
||||||
);
|
);
|
||||||
|
|
||||||
self.state_mut().await.running += 1;
|
self.running += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,7 +1,10 @@
|
||||||
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)]
|
||||||
|
@ -98,3 +101,68 @@ 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::ntfy::Notification;
|
use crate::devices::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(&self, message: Publish);
|
async fn on_mqtt(&mut self, message: Publish);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait OnPresence: Sync + Send {
|
pub trait OnPresence: Sync + Send {
|
||||||
async fn on_presence(&self, presence: bool);
|
async fn on_presence(&mut self, presence: bool);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait OnDarkness: Sync + Send {
|
pub trait OnDarkness: Sync + Send {
|
||||||
async fn on_darkness(&self, dark: bool);
|
async fn on_darkness(&mut self, dark: bool);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait OnNotification: Sync + Send {
|
pub trait OnNotification: Sync + Send {
|
||||||
async fn on_notification(&self, notification: Notification);
|
async fn on_notification(&mut self, notification: Notification);
|
||||||
}
|
}
|
|
@ -2,15 +2,17 @@
|
||||||
#![feature(specialization)]
|
#![feature(specialization)]
|
||||||
#![feature(let_chains)]
|
#![feature(let_chains)]
|
||||||
|
|
||||||
pub mod action_callback;
|
use once_cell::sync::Lazy;
|
||||||
|
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()));
|
78
src/main.rs
78
src/main.rs
|
@ -1,32 +1,28 @@
|
||||||
mod web;
|
#![feature(async_closure)]
|
||||||
|
|
||||||
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_lib::config::{FulfillmentConfig, MqttConfig};
|
use automation::auth::User;
|
||||||
use automation_lib::device_manager::DeviceManager;
|
use automation::config::{FulfillmentConfig, MqttConfig};
|
||||||
use automation_lib::helpers;
|
use automation::device_manager::DeviceManager;
|
||||||
use automation_lib::mqtt::{self, WrappedAsyncClient};
|
use automation::error::ApiError;
|
||||||
use automation_lib::ntfy::Ntfy;
|
use automation::mqtt::{self, WrappedAsyncClient};
|
||||||
use automation_lib::presence::Presence;
|
use automation::{devices, LUA};
|
||||||
use axum::extract::{FromRef, State};
|
use axum::extract::FromRef;
|
||||||
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, Response};
|
use google_home::{GoogleHome, Request};
|
||||||
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 {
|
||||||
|
@ -48,24 +44,6 @@ 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();
|
||||||
|
|
||||||
|
@ -78,7 +56,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 = mlua::Lua::new();
|
let lua = LUA.lock().await;
|
||||||
|
|
||||||
lua.set_warning_function(|_lua, text, _cont| {
|
lua.set_warning_function(|_lua, text, _cont| {
|
||||||
warn!("{text}");
|
warn!("{text}");
|
||||||
|
@ -116,11 +94,7 @@ async fn app() -> anyhow::Result<()> {
|
||||||
|
|
||||||
lua.globals().set("automation", automation)?;
|
lua.globals().set("automation", automation)?;
|
||||||
|
|
||||||
automation_devices::register_with_lua(&lua)?;
|
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());
|
||||||
|
@ -145,21 +119,39 @@ async fn app() -> anyhow::Result<()> {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create google home fulfillment route
|
// Create google home fulfillment route
|
||||||
let fulfillment = Router::new().route("/google_home", post(fulfillment));
|
let fulfillment = Router::new().route(
|
||||||
|
"/google_home",
|
||||||
|
post(async move |user: User, Json(payload): Json<Request>| {
|
||||||
|
debug!(username = user.preferred_username, "{payload:#?}");
|
||||||
|
let gc = GoogleHome::new(&user.preferred_username);
|
||||||
|
let devices = device_manager.devices().await;
|
||||||
|
let result = match gc.handle_request(payload, &devices).await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(err) => {
|
||||||
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.into())
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!(username = user.preferred_username, "{result:#?}");
|
||||||
|
|
||||||
|
(StatusCode::OK, Json(result)).into_response()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// 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: SocketAddr = fulfillment_config.into();
|
let addr = fulfillment_config.into();
|
||||||
info!("Server started on http://{addr}");
|
info!("Server started on http://{addr}");
|
||||||
let listener = TcpListener::bind(addr).await?;
|
axum::Server::try_bind(&addr)?
|
||||||
axum::serve(listener, app).await?;
|
.serve(app.into_make_service())
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -241,3 +241,39 @@ 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())))
|
||||||
|
}
|
||||||
|
}
|
10
src/traits.rs
Normal file
10
src/traits.rs
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
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<()>;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user