Compare commits
No commits in common. "master" and "feature/actions" have entirely different histories.
master
...
feature/ac
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
|
||||
name: Build and deploy
|
||||
name: Build and deploy automation_rs
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
|
@ -8,7 +8,7 @@ on:
|
|||
|
||||
jobs:
|
||||
build:
|
||||
name: Build application
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
container: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
|
@ -20,17 +20,23 @@ jobs:
|
|||
with:
|
||||
rustflags: ""
|
||||
|
||||
- name: Formatting
|
||||
uses: actions-rust-lang/rustfmt@v1
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets --all -- -D warnings
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: automation
|
||||
name: build
|
||||
path: target/x86_64-unknown-linux-gnu/release/automation
|
||||
|
||||
container:
|
||||
name: Build container
|
||||
name: Create container
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
container: catthehacker/ubuntu:act-latest
|
||||
|
@ -41,23 +47,12 @@ jobs:
|
|||
- name: Download artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: automation
|
||||
name: build
|
||||
|
||||
- name: Set permissions
|
||||
run: |
|
||||
chown 65532:65532 ./automation
|
||||
chmod 0755 ./automation
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: https://github.com/docker/metadata-action@v5
|
||||
with:
|
||||
images: git.huizinga.dev/dreaded_x/automation_rs
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
chown 65532:65532 ./build/*
|
||||
chmod 0755 ./build/*
|
||||
|
||||
- name: Login to registry
|
||||
uses: https://github.com/docker/login-action@v3
|
||||
|
@ -66,16 +61,16 @@ jobs:
|
|||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: Build & Push Docker Image
|
||||
uses: https://github.com/docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
push: ${{ gitea.ref == 'refs/heads/master' }}
|
||||
# TODO: Automatically add the correct tags here
|
||||
tags: git.huizinga.dev/dreaded_x/automation_rs:latest
|
||||
|
||||
deploy:
|
||||
name: Deploy container
|
||||
name: Deploy Docker container
|
||||
runs-on: ubuntu-latest
|
||||
container: catthehacker/ubuntu:act-latest
|
||||
needs: [container]
|
||||
|
@ -92,13 +87,13 @@ jobs:
|
|||
--pull always \
|
||||
--restart unless-stopped \
|
||||
--name automation_rs \
|
||||
--network mqtt \
|
||||
-e RUST_LOG=automation=debug \
|
||||
-e MQTT_PASSWORD=${{ secrets.MQTT_PASSWORD }} \
|
||||
-e HUE_TOKEN=${{ secrets.HUE_TOKEN }} \
|
||||
-e NTFY_TOPIC=${{ secrets.NTFY_TOPIC }} \
|
||||
git.huizinga.dev/dreaded_x/automation_rs:master
|
||||
git.huizinga.dev/dreaded_x/automation_rs:latest
|
||||
|
||||
docker network connect mqtt automation_rs
|
||||
docker network connect web automation_rs
|
||||
|
||||
- name: Start container
|
||||
|
|
|
@ -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
|
|
@ -1,2 +0,0 @@
|
|||
[default.extend-words]
|
||||
mosquitto = "mosquitto"
|
1638
Cargo.lock
generated
1638
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
112
Cargo.toml
112
Cargo.toml
|
@ -4,85 +4,43 @@ version = "0.1.0"
|
|||
edition = "2021"
|
||||
|
||||
[workspace]
|
||||
members = [
|
||||
"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 = [
|
||||
"lua54",
|
||||
"vendored",
|
||||
"macros",
|
||||
"serialize",
|
||||
"async",
|
||||
"send",
|
||||
] }
|
||||
automation_macro = { path = "./automation_macro" }
|
||||
automation_cast = { path = "./automation_cast" }
|
||||
automation_lib = { path = "./automation_lib" }
|
||||
automation_devices = { path = "./automation_devices" }
|
||||
google_home = { path = "./google_home/google_home" }
|
||||
google_home_macro = { path = "./google_home/google_home_macro" }
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
rumqttc = "0.24.0"
|
||||
tracing = "0.1.37"
|
||||
anyhow = "1.0.68"
|
||||
async-trait = "0.1.83"
|
||||
axum = "0.7.9"
|
||||
bytes = "1.3.0"
|
||||
dotenvy = "0.15.0"
|
||||
dyn-clone = "1.0.17"
|
||||
eui48 = { version = "1.1.0", features = [
|
||||
"disp_hexstring",
|
||||
"serde",
|
||||
], default-features = false }
|
||||
futures = "0.3.25"
|
||||
hostname = "0.4.0"
|
||||
impls = "1.0.3"
|
||||
indexmap = { version = "2.0.0", features = ["serde"] }
|
||||
itertools = "0.13.0"
|
||||
json_value_merge = "2.0.0"
|
||||
pollster = "0.4.0"
|
||||
proc-macro2 = "1.0.81"
|
||||
quote = "1.0.36"
|
||||
reqwest = { version = "0.12.9", features = [
|
||||
"json",
|
||||
"rustls-tls",
|
||||
], default-features = false } # Use rustls, since the other packages also use rustls
|
||||
serde = { version = "1.0.149", features = ["derive"] }
|
||||
serde_json = "1.0.89"
|
||||
serde_repr = "0.1.10"
|
||||
syn = { version = "2.0.60", features = ["extra-traits", "full"] }
|
||||
thiserror = "2.0.5"
|
||||
tokio-cron-scheduler = "0.13.0"
|
||||
tokio-util = { version = "0.7.11", features = ["full"] }
|
||||
tracing-subscriber = "0.3.16"
|
||||
uuid = "1.8.0"
|
||||
wakey = "0.3.0"
|
||||
air_filter_types = { git = "https://git.huizinga.dev/Dreaded_X/airfilter", tag = "v0.4.4" }
|
||||
members = ["impl_cast", "google-home"]
|
||||
|
||||
[dependencies]
|
||||
automation_lib = { workspace = true }
|
||||
automation_devices = { workspace = true }
|
||||
google_home = { workspace = true }
|
||||
mlua = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
hostname = { workspace = true }
|
||||
rumqttc = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
rumqttc = "0.18"
|
||||
serde = { version = "1.0.149", features = ["derive"] }
|
||||
serde_json = "1.0.89"
|
||||
impl_cast = { path = "./impl_cast", features = ["debug"] }
|
||||
google-home = { path = "./google-home" }
|
||||
paste = "1.0.10"
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
dotenvy = "0.15.0"
|
||||
reqwest = { version = "0.11.13", features = [
|
||||
"json",
|
||||
"rustls-tls",
|
||||
], default-features = false } # Use rustls, since the other packages also use rustls
|
||||
axum = "0.6.1"
|
||||
serde_repr = "0.1.10"
|
||||
tracing = "0.1.37"
|
||||
bytes = "1.3.0"
|
||||
pollster = "0.2.5"
|
||||
regex = "1.7.0"
|
||||
async-trait = "0.1.61"
|
||||
futures = "0.3.25"
|
||||
eui48 = { version = "1.1.0", default-features = false, features = [
|
||||
"disp_hexstring",
|
||||
"serde",
|
||||
] }
|
||||
thiserror = "1.0.38"
|
||||
anyhow = "1.0.68"
|
||||
wakey = "0.3.0"
|
||||
console-subscriber = "0.1.8"
|
||||
tracing-subscriber = "0.3.16"
|
||||
serde_with = "3.2.0"
|
||||
enum_dispatch = "0.3.12"
|
||||
indexmap = { version = "2.0.0", features = ["serde"] }
|
||||
serde_yaml = "0.9.27"
|
||||
tokio-cron-scheduler = "0.9.4"
|
||||
|
||||
[patch.crates-io]
|
||||
wakey = { git = "https://git.huizinga.dev/Dreaded_X/wakey" }
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
FROM gcr.io/distroless/cc-debian12:nonroot
|
||||
|
||||
ENV AUTOMATION_CONFIG=/app/config.lua
|
||||
COPY ./config.lua /app/config.lua
|
||||
ENV AUTOMATION_CONFIG=/app/config.yml
|
||||
COPY ./config/config.yml /app/config.yml
|
||||
|
||||
COPY ./automation /app/automation
|
||||
COPY ./build/automation /app/automation
|
||||
|
||||
CMD ["/app/automation"]
|
||||
|
|
12
README.md
12
README.md
|
@ -1,12 +1,8 @@
|
|||
# automation_rs
|
||||
|
||||
Custom home automation solution with Google Home integration and lua scripting.
|
||||
Custom home automation solution with google-home intergration
|
||||
|
||||
## Development
|
||||
|
||||
This repository uses [pre-commit](https://pre-commit.com) to make sure everything is ready to go when committing.
|
||||
Install the pre-commit hooks by running the following command:
|
||||
|
||||
```bash
|
||||
pre-commit install
|
||||
Make sure to setup git hooks by running
|
||||
```sh
|
||||
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.
|
@ -1,8 +0,0 @@
|
|||
[package]
|
||||
name = "automation_cast"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
|
@ -1,28 +0,0 @@
|
|||
#![allow(incomplete_features)]
|
||||
#![feature(specialization)]
|
||||
#![feature(unsize)]
|
||||
|
||||
use std::marker::Unsize;
|
||||
|
||||
pub trait Cast<P: ?Sized> {
|
||||
fn cast(&self) -> Option<&P>;
|
||||
}
|
||||
|
||||
impl<D, P> Cast<P> for D
|
||||
where
|
||||
P: ?Sized,
|
||||
{
|
||||
default fn cast(&self) -> Option<&P> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl<D, P> Cast<P> for D
|
||||
where
|
||||
D: Unsize<P>,
|
||||
P: ?Sized,
|
||||
{
|
||||
fn cast(&self) -> Option<&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,89 +0,0 @@
|
|||
use std::convert::Infallible;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use automation_lib::config::MqttDeviceConfig;
|
||||
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||
use automation_lib::event::{OnDarkness, OnPresence};
|
||||
use automation_lib::messages::{DarknessMessage, PresenceMessage};
|
||||
use automation_lib::mqtt::WrappedAsyncClient;
|
||||
use automation_macro::LuaDeviceConfig;
|
||||
use tracing::{trace, warn};
|
||||
|
||||
#[derive(Debug, LuaDeviceConfig, Clone)]
|
||||
pub struct Config {
|
||||
pub identifier: String,
|
||||
#[device_config(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
#[device_config(from_lua)]
|
||||
pub client: WrappedAsyncClient,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DebugBridge {
|
||||
config: Config,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for DebugBridge {
|
||||
type Config = Config;
|
||||
type Error = Infallible;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
trace!(id = config.identifier, "Setting up DebugBridge");
|
||||
Ok(Self { config })
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for DebugBridge {
|
||||
fn get_id(&self) -> String {
|
||||
self.config.identifier.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnPresence for DebugBridge {
|
||||
async fn on_presence(&self, presence: bool) {
|
||||
let message = PresenceMessage::new(presence);
|
||||
let topic = format!("{}/presence", self.config.mqtt.topic);
|
||||
self.config
|
||||
.client
|
||||
.publish(
|
||||
topic,
|
||||
rumqttc::QoS::AtLeastOnce,
|
||||
true,
|
||||
serde_json::to_string(&message).expect("Serialization should not fail"),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(
|
||||
"Failed to update presence on {}/presence: {err}",
|
||||
self.config.mqtt.topic
|
||||
)
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnDarkness for DebugBridge {
|
||||
async fn on_darkness(&self, dark: bool) {
|
||||
let message = DarknessMessage::new(dark);
|
||||
let topic = format!("{}/darkness", self.config.mqtt.topic);
|
||||
self.config
|
||||
.client
|
||||
.publish(
|
||||
topic,
|
||||
rumqttc::QoS::AtLeastOnce,
|
||||
true,
|
||||
serde_json::to_string(&message).unwrap(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(
|
||||
"Failed to update presence on {}/presence: {err}",
|
||||
self.config.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,118 +0,0 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use automation_lib::config::MqttDeviceConfig;
|
||||
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||
use automation_lib::event::{self, Event, EventChannel, OnMqtt};
|
||||
use automation_lib::messages::BrightnessMessage;
|
||||
use automation_lib::mqtt::WrappedAsyncClient;
|
||||
use automation_macro::LuaDeviceConfig;
|
||||
use rumqttc::Publish;
|
||||
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||
pub struct Config {
|
||||
pub identifier: String,
|
||||
#[device_config(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
pub min: isize,
|
||||
pub max: isize,
|
||||
#[device_config(rename("event_channel"), from_lua, with(|ec: EventChannel| ec.get_tx()))]
|
||||
pub tx: event::Sender,
|
||||
#[device_config(from_lua)]
|
||||
pub client: WrappedAsyncClient,
|
||||
}
|
||||
|
||||
const DEFAULT: bool = false;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct State {
|
||||
is_dark: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LightSensor {
|
||||
config: Config,
|
||||
state: Arc<RwLock<State>>,
|
||||
}
|
||||
|
||||
impl LightSensor {
|
||||
async fn state(&self) -> RwLockReadGuard<State> {
|
||||
self.state.read().await
|
||||
}
|
||||
|
||||
async fn state_mut(&self) -> RwLockWriteGuard<State> {
|
||||
self.state.write().await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for LightSensor {
|
||||
type Config = Config;
|
||||
type Error = rumqttc::ClientError;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
trace!(id = config.identifier, "Setting up LightSensor");
|
||||
|
||||
config
|
||||
.client
|
||||
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||
.await?;
|
||||
|
||||
let state = State { is_dark: DEFAULT };
|
||||
let state = Arc::new(RwLock::new(state));
|
||||
|
||||
Ok(Self { config, state })
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for LightSensor {
|
||||
fn get_id(&self) -> String {
|
||||
self.config.identifier.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnMqtt for LightSensor {
|
||||
async fn on_mqtt(&self, message: Publish) {
|
||||
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
||||
return;
|
||||
}
|
||||
|
||||
let illuminance = match BrightnessMessage::try_from(message) {
|
||||
Ok(state) => state.illuminance(),
|
||||
Err(err) => {
|
||||
warn!("Failed to parse message: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
debug!("Illuminance: {illuminance}");
|
||||
let is_dark = if illuminance <= self.config.min {
|
||||
trace!("It is dark");
|
||||
true
|
||||
} else if illuminance >= self.config.max {
|
||||
trace!("It is light");
|
||||
false
|
||||
} else {
|
||||
let is_dark = self.state().await.is_dark;
|
||||
trace!(
|
||||
"In between min ({}) and max ({}) value, keeping current state: {}",
|
||||
self.config.min,
|
||||
self.config.max,
|
||||
is_dark
|
||||
);
|
||||
is_dark
|
||||
};
|
||||
|
||||
if is_dark != self.state().await.is_dark {
|
||||
debug!("Dark state has changed: {is_dark}");
|
||||
self.state_mut().await.is_dark = is_dark;
|
||||
|
||||
if self.config.tx.send(Event::Darkness(is_dark)).await.is_err() {
|
||||
warn!("There are no receivers on the event channel");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,145 +0,0 @@
|
|||
use std::net::Ipv4Addr;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use automation_lib::config::{InfoConfig, MqttDeviceConfig};
|
||||
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||
use automation_lib::event::OnMqtt;
|
||||
use automation_lib::messages::ActivateMessage;
|
||||
use automation_lib::mqtt::WrappedAsyncClient;
|
||||
use automation_macro::LuaDeviceConfig;
|
||||
use eui48::MacAddress;
|
||||
use google_home::device;
|
||||
use google_home::errors::ErrorCode;
|
||||
use google_home::traits::{self, Scene};
|
||||
use google_home::types::Type;
|
||||
use rumqttc::Publish;
|
||||
use tracing::{debug, error, trace};
|
||||
|
||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||
pub struct Config {
|
||||
#[device_config(flatten)]
|
||||
pub info: InfoConfig,
|
||||
#[device_config(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
pub mac_address: MacAddress,
|
||||
#[device_config(default(Ipv4Addr::new(255, 255, 255, 255)))]
|
||||
pub broadcast_ip: Ipv4Addr,
|
||||
#[device_config(from_lua)]
|
||||
pub client: WrappedAsyncClient,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WakeOnLAN {
|
||||
config: Config,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for WakeOnLAN {
|
||||
type Config = Config;
|
||||
type Error = rumqttc::ClientError;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
trace!(id = config.info.identifier(), "Setting up WakeOnLAN");
|
||||
|
||||
config
|
||||
.client
|
||||
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||
.await?;
|
||||
|
||||
Ok(Self { config })
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for WakeOnLAN {
|
||||
fn get_id(&self) -> String {
|
||||
self.config.info.identifier()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnMqtt for WakeOnLAN {
|
||||
async fn on_mqtt(&self, message: Publish) {
|
||||
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
||||
return;
|
||||
}
|
||||
|
||||
let activate = match ActivateMessage::try_from(message) {
|
||||
Ok(message) => message.activate(),
|
||||
Err(err) => {
|
||||
error!(id = Device::get_id(self), "Failed to parse message: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
self.set_active(activate).await.ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl google_home::Device for WakeOnLAN {
|
||||
fn get_device_type(&self) -> Type {
|
||||
Type::Scene
|
||||
}
|
||||
|
||||
fn get_device_name(&self) -> device::Name {
|
||||
let mut name = device::Name::new(&self.config.info.name);
|
||||
name.add_default_name("Computer");
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl traits::Scene for WakeOnLAN {
|
||||
async fn set_active(&self, deactivate: bool) -> Result<(), ErrorCode> {
|
||||
if deactivate {
|
||||
debug!(
|
||||
id = Device::get_id(self),
|
||||
"Trying to deactivate computer, this is not currently supported"
|
||||
);
|
||||
// We do not support deactivating this scene
|
||||
Err(ErrorCode::DeviceError(
|
||||
google_home::errors::DeviceError::ActionNotAvailable,
|
||||
))
|
||||
} else {
|
||||
debug!(
|
||||
id = Device::get_id(self),
|
||||
"Activating Computer: {} (Sending to {})",
|
||||
self.config.mac_address,
|
||||
self.config.broadcast_ip
|
||||
);
|
||||
let wol = wakey::WolPacket::from_bytes(&self.config.mac_address.to_array()).map_err(
|
||||
|err| {
|
||||
error!(id = Device::get_id(self), "invalid mac address: {err}");
|
||||
google_home::errors::DeviceError::TransientError
|
||||
},
|
||||
)?;
|
||||
|
||||
wol.send_magic_to(
|
||||
(Ipv4Addr::new(0, 0, 0, 0), 0),
|
||||
(self.config.broadcast_ip, 9),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!(
|
||||
id = Device::get_id(self),
|
||||
"Failed to activate computer: {err}"
|
||||
);
|
||||
google_home::errors::DeviceError::TransientError.into()
|
||||
})
|
||||
.map(|_| debug!(id = Device::get_id(self), "Success!"))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,141 +0,0 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use automation_lib::config::MqttDeviceConfig;
|
||||
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||
use automation_lib::event::{self, Event, EventChannel, OnMqtt};
|
||||
use automation_lib::messages::PowerMessage;
|
||||
use automation_lib::mqtt::WrappedAsyncClient;
|
||||
use automation_lib::ntfy::{Notification, Priority};
|
||||
use automation_macro::LuaDeviceConfig;
|
||||
use rumqttc::Publish;
|
||||
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||
use tracing::{debug, error, trace, warn};
|
||||
|
||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||
pub struct Config {
|
||||
pub identifier: String,
|
||||
#[device_config(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
// Power in Watt
|
||||
pub threshold: f32,
|
||||
#[device_config(rename("event_channel"), from_lua, with(|ec: EventChannel| ec.get_tx()))]
|
||||
pub tx: event::Sender,
|
||||
#[device_config(from_lua)]
|
||||
pub client: WrappedAsyncClient,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct State {
|
||||
running: isize,
|
||||
}
|
||||
|
||||
// TODO: Add google home integration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Washer {
|
||||
config: Config,
|
||||
state: Arc<RwLock<State>>,
|
||||
}
|
||||
|
||||
impl Washer {
|
||||
async fn state(&self) -> RwLockReadGuard<State> {
|
||||
self.state.read().await
|
||||
}
|
||||
|
||||
async fn state_mut(&self) -> RwLockWriteGuard<State> {
|
||||
self.state.write().await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for Washer {
|
||||
type Config = Config;
|
||||
type Error = rumqttc::ClientError;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
trace!(id = config.identifier, "Setting up Washer");
|
||||
|
||||
config
|
||||
.client
|
||||
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||
.await?;
|
||||
|
||||
let state = State { running: 0 };
|
||||
let state = Arc::new(RwLock::new(state));
|
||||
|
||||
Ok(Self { config, state })
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for Washer {
|
||||
fn get_id(&self) -> String {
|
||||
self.config.identifier.clone()
|
||||
}
|
||||
}
|
||||
|
||||
// The washer needs to have a power draw above the threshold multiple times before the washer is
|
||||
// actually marked as running
|
||||
// This helps prevent false positives
|
||||
const HYSTERESIS: isize = 10;
|
||||
|
||||
#[async_trait]
|
||||
impl OnMqtt for Washer {
|
||||
async fn on_mqtt(&self, message: Publish) {
|
||||
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
||||
return;
|
||||
}
|
||||
|
||||
let power = match PowerMessage::try_from(message) {
|
||||
Ok(state) => state.power(),
|
||||
Err(err) => {
|
||||
error!(
|
||||
id = self.config.identifier,
|
||||
"Failed to parse message: {err}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// debug!(id = self.identifier, power, "Washer state update");
|
||||
|
||||
if power < self.config.threshold && self.state().await.running >= HYSTERESIS {
|
||||
// The washer is done running
|
||||
debug!(
|
||||
id = self.config.identifier,
|
||||
power,
|
||||
threshold = self.config.threshold,
|
||||
"Washer is done"
|
||||
);
|
||||
|
||||
self.state_mut().await.running = 0;
|
||||
let notification = Notification::new()
|
||||
.set_title("Laundy is done")
|
||||
.set_message("Don't forget to hang it!")
|
||||
.add_tag("womans_clothes")
|
||||
.set_priority(Priority::High);
|
||||
|
||||
if self
|
||||
.config
|
||||
.tx
|
||||
.send(Event::Ntfy(notification))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!("There are no receivers on the event channel");
|
||||
}
|
||||
} else if power < self.config.threshold {
|
||||
// Prevent false positives
|
||||
self.state_mut().await.running = 0;
|
||||
} else if power >= self.config.threshold && self.state().await.running < HYSTERESIS {
|
||||
// Washer could be starting
|
||||
debug!(
|
||||
id = self.config.identifier,
|
||||
power,
|
||||
threshold = self.config.threshold,
|
||||
"Washer is starting"
|
||||
);
|
||||
|
||||
self.state_mut().await.running += 1;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,74 +0,0 @@
|
|||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use rumqttc::{MqttOptions, Transport};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MqttConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub client_name: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
pub tls: bool,
|
||||
}
|
||||
|
||||
impl From<MqttConfig> for MqttOptions {
|
||||
fn from(value: MqttConfig) -> Self {
|
||||
let mut mqtt_options = MqttOptions::new(value.client_name, value.host, value.port);
|
||||
mqtt_options.set_credentials(value.username, value.password);
|
||||
mqtt_options.set_keep_alive(Duration::from_secs(5));
|
||||
|
||||
if value.tls {
|
||||
mqtt_options.set_transport(Transport::tls_with_default_config());
|
||||
}
|
||||
|
||||
mqtt_options
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FulfillmentConfig {
|
||||
pub openid_url: String,
|
||||
#[serde(default = "default_fulfillment_ip")]
|
||||
pub ip: Ipv4Addr,
|
||||
#[serde(default = "default_fulfillment_port")]
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl From<FulfillmentConfig> for SocketAddr {
|
||||
fn from(fulfillment: FulfillmentConfig) -> Self {
|
||||
(fulfillment.ip, fulfillment.port).into()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_fulfillment_ip() -> Ipv4Addr {
|
||||
[0, 0, 0, 0].into()
|
||||
}
|
||||
|
||||
fn default_fulfillment_port() -> u16 {
|
||||
7878
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct InfoConfig {
|
||||
pub name: String,
|
||||
pub room: Option<String>,
|
||||
}
|
||||
|
||||
impl InfoConfig {
|
||||
pub fn identifier(&self) -> String {
|
||||
(if let Some(room) = &self.room {
|
||||
room.to_ascii_lowercase().replace(' ', "_") + "_"
|
||||
} else {
|
||||
String::new()
|
||||
}) + &self.name.to_ascii_lowercase().replace(' ', "_")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MqttDeviceConfig {
|
||||
pub topic: String,
|
||||
}
|
|
@ -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,189 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::future::join_all;
|
||||
use futures::Future;
|
||||
use tokio::sync::{RwLock, RwLockReadGuard};
|
||||
use tokio_cron_scheduler::{Job, JobScheduler};
|
||||
use tracing::{debug, instrument, trace};
|
||||
|
||||
use crate::device::Device;
|
||||
use crate::event::{Event, EventChannel, OnDarkness, OnMqtt, OnNotification, OnPresence};
|
||||
|
||||
pub type DeviceMap = HashMap<String, Box<dyn Device>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DeviceManager {
|
||||
devices: Arc<RwLock<DeviceMap>>,
|
||||
event_channel: EventChannel,
|
||||
scheduler: JobScheduler,
|
||||
}
|
||||
|
||||
impl DeviceManager {
|
||||
pub async fn new() -> Self {
|
||||
let (event_channel, mut event_rx) = EventChannel::new();
|
||||
|
||||
let device_manager = Self {
|
||||
devices: Arc::new(RwLock::new(HashMap::new())),
|
||||
event_channel,
|
||||
scheduler: JobScheduler::new().await.unwrap(),
|
||||
};
|
||||
|
||||
tokio::spawn({
|
||||
let device_manager = device_manager.clone();
|
||||
async move {
|
||||
loop {
|
||||
if let Some(event) = event_rx.recv().await {
|
||||
device_manager.handle_event(event).await;
|
||||
} else {
|
||||
todo!("Handle errors with the event channel properly")
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
device_manager.scheduler.start().await.unwrap();
|
||||
|
||||
device_manager
|
||||
}
|
||||
|
||||
pub async fn add(&self, device: Box<dyn Device>) {
|
||||
let id = device.get_id();
|
||||
|
||||
debug!(id, "Adding device");
|
||||
|
||||
self.devices.write().await.insert(id, device);
|
||||
}
|
||||
|
||||
pub fn event_channel(&self) -> EventChannel {
|
||||
self.event_channel.clone()
|
||||
}
|
||||
|
||||
pub async fn get(&self, name: &str) -> Option<Box<dyn Device>> {
|
||||
self.devices.read().await.get(name).cloned()
|
||||
}
|
||||
|
||||
pub async fn devices(&self) -> RwLockReadGuard<DeviceMap> {
|
||||
self.devices.read().await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn handle_event(&self, event: Event) {
|
||||
match event {
|
||||
Event::MqttMessage(message) => {
|
||||
let devices = self.devices.read().await;
|
||||
let iter = devices.iter().map(|(id, device)| {
|
||||
let message = message.clone();
|
||||
async move {
|
||||
let device: Option<&dyn OnMqtt> = device.cast();
|
||||
if let Some(device) = device {
|
||||
// let subscribed = device
|
||||
// .topics()
|
||||
// .iter()
|
||||
// .any(|topic| matches(&message.topic, topic));
|
||||
//
|
||||
// if subscribed {
|
||||
trace!(id, "Handling");
|
||||
device.on_mqtt(message).await;
|
||||
trace!(id, "Done");
|
||||
// }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
join_all(iter).await;
|
||||
}
|
||||
Event::Darkness(dark) => {
|
||||
let devices = self.devices.read().await;
|
||||
let iter = devices.iter().map(|(id, device)| async move {
|
||||
let device: Option<&dyn OnDarkness> = device.cast();
|
||||
if let Some(device) = device {
|
||||
trace!(id, "Handling");
|
||||
device.on_darkness(dark).await;
|
||||
trace!(id, "Done");
|
||||
}
|
||||
});
|
||||
|
||||
join_all(iter).await;
|
||||
}
|
||||
Event::Presence(presence) => {
|
||||
let devices = self.devices.read().await;
|
||||
let iter = devices.iter().map(|(id, device)| async move {
|
||||
let device: Option<&dyn OnPresence> = device.cast();
|
||||
if let Some(device) = device {
|
||||
trace!(id, "Handling");
|
||||
device.on_presence(presence).await;
|
||||
trace!(id, "Done");
|
||||
}
|
||||
});
|
||||
|
||||
join_all(iter).await;
|
||||
}
|
||||
Event::Ntfy(notification) => {
|
||||
let devices = self.devices.read().await;
|
||||
let iter = devices.iter().map(|(id, device)| {
|
||||
let notification = notification.clone();
|
||||
async move {
|
||||
let device: Option<&dyn OnNotification> = device.cast();
|
||||
if let Some(device) = device {
|
||||
trace!(id, "Handling");
|
||||
device.on_notification(notification).await;
|
||||
trace!(id, "Done");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
join_all(iter).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl mlua::UserData for DeviceManager {
|
||||
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
|
||||
methods.add_async_method("add", |_lua, this, device: Box<dyn Device>| async move {
|
||||
this.add(device).await;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
methods.add_async_method(
|
||||
"schedule",
|
||||
|lua, this, (schedule, f): (String, mlua::Function)| async move {
|
||||
debug!("schedule = {schedule}");
|
||||
// This creates a function, that returns the actual job we want to run
|
||||
let create_job = {
|
||||
let lua = lua.clone();
|
||||
|
||||
move |uuid: uuid::Uuid,
|
||||
_: tokio_cron_scheduler::JobScheduler|
|
||||
-> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||
let lua = lua.clone();
|
||||
|
||||
// Create the actual function we want to run on a schedule
|
||||
let future = async move {
|
||||
let f: mlua::Function =
|
||||
lua.named_registry_value(uuid.to_string().as_str()).unwrap();
|
||||
f.call_async::<()>(()).await.unwrap();
|
||||
};
|
||||
|
||||
Box::pin(future)
|
||||
}
|
||||
};
|
||||
|
||||
let job = Job::new_async(schedule.as_str(), create_job).unwrap();
|
||||
|
||||
let uuid = this.scheduler.add(job).await.unwrap();
|
||||
|
||||
// Store the function in the registry
|
||||
lua.set_named_registry_value(uuid.to_string().as_str(), f)
|
||||
.unwrap();
|
||||
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
|
||||
methods.add_method("event_channel", |_lua, this, ()| Ok(this.event_channel()))
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,132 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
use std::ops::Deref;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use automation_cast::Cast;
|
||||
use automation_macro::LuaDeviceConfig;
|
||||
use rumqttc::Publish;
|
||||
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
use crate::config::MqttDeviceConfig;
|
||||
use crate::device::{impl_device, Device, LuaDeviceCreate};
|
||||
use crate::event::{self, Event, EventChannel, OnMqtt};
|
||||
use crate::messages::PresenceMessage;
|
||||
use crate::mqtt::WrappedAsyncClient;
|
||||
|
||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||
pub struct Config {
|
||||
#[device_config(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
#[device_config(from_lua, rename("event_channel"), with(|ec: EventChannel| ec.get_tx()))]
|
||||
pub tx: event::Sender,
|
||||
#[device_config(from_lua)]
|
||||
pub client: WrappedAsyncClient,
|
||||
}
|
||||
|
||||
pub const DEFAULT_PRESENCE: bool = false;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct State {
|
||||
devices: HashMap<String, bool>,
|
||||
current_overall_presence: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Presence {
|
||||
config: Config,
|
||||
state: Arc<RwLock<State>>,
|
||||
}
|
||||
|
||||
impl Presence {
|
||||
async fn state(&self) -> RwLockReadGuard<State> {
|
||||
self.state.read().await
|
||||
}
|
||||
|
||||
async fn state_mut(&self) -> RwLockWriteGuard<State> {
|
||||
self.state.write().await
|
||||
}
|
||||
}
|
||||
|
||||
impl_device!(Presence);
|
||||
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for Presence {
|
||||
type Config = Config;
|
||||
type Error = rumqttc::ClientError;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
trace!(id = "presence", "Setting up Presence");
|
||||
|
||||
config
|
||||
.client
|
||||
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
|
||||
.await?;
|
||||
|
||||
let state = State {
|
||||
devices: HashMap::new(),
|
||||
current_overall_presence: DEFAULT_PRESENCE,
|
||||
};
|
||||
let state = Arc::new(RwLock::new(state));
|
||||
|
||||
Ok(Self { config, state })
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for Presence {
|
||||
fn get_id(&self) -> String {
|
||||
"presence".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnMqtt for Presence {
|
||||
async fn on_mqtt(&self, message: Publish) {
|
||||
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
|
||||
return;
|
||||
}
|
||||
|
||||
let offset = self
|
||||
.config
|
||||
.mqtt
|
||||
.topic
|
||||
.find('+')
|
||||
.or(self.config.mqtt.topic.find('#'))
|
||||
.expect("Presence::create fails if it does not contain wildcards");
|
||||
let device_name = message.topic[offset..].into();
|
||||
|
||||
if message.payload.is_empty() {
|
||||
// Remove the device from the map
|
||||
debug!("State of device [{device_name}] has been removed");
|
||||
self.state_mut().await.devices.remove(&device_name);
|
||||
} else {
|
||||
let present = match PresenceMessage::try_from(message) {
|
||||
Ok(state) => state.presence(),
|
||||
Err(err) => {
|
||||
warn!("Failed to parse message: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
debug!("State of device [{device_name}] has changed: {}", present);
|
||||
self.state_mut().await.devices.insert(device_name, present);
|
||||
}
|
||||
|
||||
let overall_presence = self.state().await.devices.iter().any(|(_, v)| *v);
|
||||
if overall_presence != self.state().await.current_overall_presence {
|
||||
debug!("Overall presence updated: {overall_presence}");
|
||||
self.state_mut().await.current_overall_presence = overall_presence;
|
||||
|
||||
if self
|
||||
.config
|
||||
.tx
|
||||
.send(Event::Presence(overall_presence))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!("There are no receivers on the event channel");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
[package]
|
||||
name = "automation_macro"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
itertools = { workspace = true }
|
||||
proc-macro2 = { workspace = true }
|
||||
quote = { workspace = true }
|
||||
syn = { workspace = true }
|
|
@ -1,13 +0,0 @@
|
|||
#![feature(let_chains)]
|
||||
#![feature(iter_intersperse)]
|
||||
mod lua_device_config;
|
||||
|
||||
use lua_device_config::impl_lua_device_config_macro;
|
||||
use syn::{parse_macro_input, DeriveInput};
|
||||
|
||||
#[proc_macro_derive(LuaDeviceConfig, attributes(device_config))]
|
||||
pub fn lua_device_config_derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||
let ast = parse_macro_input!(input as DeriveInput);
|
||||
|
||||
impl_lua_device_config_macro(&ast).into()
|
||||
}
|
|
@ -1,281 +0,0 @@
|
|||
use itertools::Itertools;
|
||||
use proc_macro2::TokenStream;
|
||||
use quote::{quote, quote_spanned};
|
||||
use syn::parse::{Parse, ParseStream};
|
||||
use syn::punctuated::Punctuated;
|
||||
use syn::spanned::Spanned;
|
||||
use syn::token::Paren;
|
||||
use syn::{
|
||||
parenthesized, Data, DataStruct, DeriveInput, Expr, Field, Fields, FieldsNamed, LitStr, Result,
|
||||
Token, Type,
|
||||
};
|
||||
|
||||
mod kw {
|
||||
use syn::custom_keyword;
|
||||
|
||||
custom_keyword!(device_config);
|
||||
custom_keyword!(flatten);
|
||||
custom_keyword!(from_lua);
|
||||
custom_keyword!(rename);
|
||||
custom_keyword!(with);
|
||||
custom_keyword!(from);
|
||||
custom_keyword!(default);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Argument {
|
||||
Flatten {
|
||||
_keyword: kw::flatten,
|
||||
},
|
||||
FromLua {
|
||||
_keyword: kw::from_lua,
|
||||
},
|
||||
Rename {
|
||||
_keyword: kw::rename,
|
||||
_paren: Paren,
|
||||
ident: LitStr,
|
||||
},
|
||||
With {
|
||||
_keyword: kw::with,
|
||||
_paren: Paren,
|
||||
// TODO: Ideally we capture this better
|
||||
expr: Expr,
|
||||
},
|
||||
From {
|
||||
_keyword: kw::from,
|
||||
_paren: Paren,
|
||||
ty: Type,
|
||||
},
|
||||
Default {
|
||||
_keyword: kw::default,
|
||||
},
|
||||
DefaultExpr {
|
||||
_keyword: kw::default,
|
||||
_paren: Paren,
|
||||
expr: Expr,
|
||||
},
|
||||
}
|
||||
|
||||
impl Parse for Argument {
|
||||
fn parse(input: ParseStream) -> Result<Self> {
|
||||
let lookahead = input.lookahead1();
|
||||
if lookahead.peek(kw::flatten) {
|
||||
Ok(Self::Flatten {
|
||||
_keyword: input.parse()?,
|
||||
})
|
||||
} else if lookahead.peek(kw::from_lua) {
|
||||
Ok(Self::FromLua {
|
||||
_keyword: input.parse()?,
|
||||
})
|
||||
} else if lookahead.peek(kw::rename) {
|
||||
let content;
|
||||
Ok(Self::Rename {
|
||||
_keyword: input.parse()?,
|
||||
_paren: parenthesized!(content in input),
|
||||
ident: content.parse()?,
|
||||
})
|
||||
} else if lookahead.peek(kw::with) {
|
||||
let content;
|
||||
Ok(Self::With {
|
||||
_keyword: input.parse()?,
|
||||
_paren: parenthesized!(content in input),
|
||||
expr: content.parse()?,
|
||||
})
|
||||
} else if lookahead.peek(kw::from) {
|
||||
let content;
|
||||
Ok(Self::From {
|
||||
_keyword: input.parse()?,
|
||||
_paren: parenthesized!(content in input),
|
||||
ty: content.parse()?,
|
||||
})
|
||||
} else if lookahead.peek(kw::default) {
|
||||
let keyword = input.parse()?;
|
||||
if input.peek(Paren) {
|
||||
let content;
|
||||
Ok(Self::DefaultExpr {
|
||||
_keyword: keyword,
|
||||
_paren: parenthesized!(content in input),
|
||||
expr: content.parse()?,
|
||||
})
|
||||
} else {
|
||||
Ok(Self::Default { _keyword: keyword })
|
||||
}
|
||||
} else {
|
||||
Err(lookahead.error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Args {
|
||||
args: Punctuated<Argument, Token![,]>,
|
||||
}
|
||||
|
||||
impl Parse for Args {
|
||||
fn parse(input: ParseStream) -> Result<Self> {
|
||||
Ok(Self {
|
||||
args: input.parse_terminated(Argument::parse, Token![,])?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn field_from_lua(field: &Field) -> TokenStream {
|
||||
let (args, errors): (Vec<_>, Vec<_>) = field
|
||||
.attrs
|
||||
.iter()
|
||||
.filter_map(|attr| {
|
||||
if attr.path().is_ident("device_config") {
|
||||
Some(attr.parse_args::<Args>().map(|args| args.args))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.partition_result();
|
||||
|
||||
let errors: Vec<_> = errors
|
||||
.iter()
|
||||
.map(|error| error.to_compile_error())
|
||||
.collect();
|
||||
|
||||
if !errors.is_empty() {
|
||||
return quote! { #(#errors)* };
|
||||
}
|
||||
|
||||
let args: Vec<_> = args.into_iter().flatten().collect();
|
||||
|
||||
let table_name = match args
|
||||
.iter()
|
||||
.filter_map(|arg| match arg {
|
||||
Argument::Rename { ident, .. } => Some(ident.value()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice()
|
||||
{
|
||||
[] => field.ident.clone().unwrap().to_string(),
|
||||
[rename] => rename.to_owned(),
|
||||
_ => {
|
||||
return quote_spanned! {field.span() => compile_error!("Field contains duplicate 'rename'")}
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: Detect Option<_> properly and use Default::default() as fallback automatically
|
||||
let missing = format!("Missing field '{table_name}'");
|
||||
let default = match args
|
||||
.iter()
|
||||
.filter_map(|arg| match arg {
|
||||
Argument::Default { .. } => Some(quote! { Default::default() }),
|
||||
Argument::DefaultExpr { expr, .. } => Some(quote! { (#expr) }),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice()
|
||||
{
|
||||
[] => quote! {panic!(#missing)},
|
||||
[default] => default.to_owned(),
|
||||
_ => {
|
||||
return quote_spanned! {field.span() => compile_error!("Field contains duplicate 'default'")}
|
||||
}
|
||||
};
|
||||
|
||||
let value = match args
|
||||
.iter()
|
||||
.filter_map(|arg| match arg {
|
||||
Argument::Flatten { .. } => Some(quote! {
|
||||
mlua::LuaSerdeExt::from_value_with(lua, value.clone(), mlua::DeserializeOptions::new().deny_unsupported_types(false))?
|
||||
}),
|
||||
Argument::FromLua { .. } => Some(quote! {
|
||||
if table.contains_key(#table_name)? {
|
||||
table.get(#table_name)?
|
||||
} else {
|
||||
#default
|
||||
}
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice() {
|
||||
[] => quote! {
|
||||
{
|
||||
let value: mlua::Value = table.get(#table_name)?;
|
||||
if !value.is_nil() {
|
||||
mlua::LuaSerdeExt::from_value(lua, value)?
|
||||
} else {
|
||||
#default
|
||||
}
|
||||
}
|
||||
},
|
||||
[value] => value.to_owned(),
|
||||
_ => return quote_spanned! {field.span() => compile_error!("Only one of either 'flatten' or 'from_lua' is allowed")},
|
||||
};
|
||||
|
||||
let value = match args
|
||||
.iter()
|
||||
.filter_map(|arg| match arg {
|
||||
Argument::From { ty, .. } => Some(quote! {
|
||||
{
|
||||
let temp: #ty = #value;
|
||||
temp.into()
|
||||
}
|
||||
}),
|
||||
Argument::With { expr, .. } => Some(quote! {
|
||||
{
|
||||
let temp = #value;
|
||||
(#expr)(temp)
|
||||
}
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice()
|
||||
{
|
||||
[] => value,
|
||||
[value] => value.to_owned(),
|
||||
_ => {
|
||||
return quote_spanned! {field.span() => compile_error!("Only one of either 'from' or 'with' is allowed")}
|
||||
}
|
||||
};
|
||||
|
||||
quote! { #value }
|
||||
}
|
||||
|
||||
pub fn impl_lua_device_config_macro(ast: &DeriveInput) -> TokenStream {
|
||||
let name = &ast.ident;
|
||||
let fields = if let Data::Struct(DataStruct {
|
||||
fields: Fields::Named(FieldsNamed { ref named, .. }),
|
||||
..
|
||||
}) = ast.data
|
||||
{
|
||||
named
|
||||
} else {
|
||||
return quote_spanned! {ast.span() => compile_error!("This macro only works on named structs")};
|
||||
};
|
||||
|
||||
let lua_fields: Vec<_> = fields
|
||||
.iter()
|
||||
.map(|field| {
|
||||
let name = field.ident.clone().unwrap();
|
||||
let value = field_from_lua(field);
|
||||
quote! { #name: #value }
|
||||
})
|
||||
.collect();
|
||||
|
||||
let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
|
||||
let impl_from_lua = quote! {
|
||||
impl #impl_generics mlua::FromLua for #name #type_generics #where_clause {
|
||||
fn from_lua(value: mlua::Value, lua: &mlua::Lua) -> mlua::Result<Self> {
|
||||
if !value.is_table() {
|
||||
panic!("Expected table");
|
||||
}
|
||||
let table = value.as_table().unwrap();
|
||||
|
||||
Ok(#name {
|
||||
#(#lua_fields,)*
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
impl_from_lua
|
||||
}
|
507
config.lua
507
config.lua
|
@ -1,507 +0,0 @@
|
|||
print("Hello from lua")
|
||||
|
||||
local host = automation.util.get_hostname()
|
||||
print("Running @" .. host)
|
||||
|
||||
local debug, value = pcall(automation.util.get_env, "DEBUG")
|
||||
if debug and value ~= "true" then
|
||||
debug = false
|
||||
end
|
||||
|
||||
local function mqtt_z2m(topic)
|
||||
return "zigbee2mqtt/" .. topic
|
||||
end
|
||||
|
||||
local function mqtt_automation(topic)
|
||||
return "automation/" .. topic
|
||||
end
|
||||
|
||||
automation.fulfillment = {
|
||||
openid_url = "https://login.huizinga.dev/api/oidc",
|
||||
}
|
||||
|
||||
local mqtt_client = automation.new_mqtt_client({
|
||||
host = ((host == "zeus" or host == "hephaestus") and "olympus.lan.huizinga.dev") or "mosquitto",
|
||||
port = 8883,
|
||||
client_name = "automation-" .. host,
|
||||
username = "mqtt",
|
||||
password = automation.util.get_env("MQTT_PASSWORD"),
|
||||
tls = host == "zeus" or host == "hephaestus",
|
||||
})
|
||||
|
||||
automation.device_manager:add(Ntfy.new({
|
||||
topic = automation.util.get_env("NTFY_TOPIC"),
|
||||
event_channel = automation.device_manager:event_channel(),
|
||||
}))
|
||||
|
||||
automation.device_manager:add(Presence.new({
|
||||
topic = mqtt_automation("presence/+/#"),
|
||||
client = mqtt_client,
|
||||
event_channel = automation.device_manager:event_channel(),
|
||||
}))
|
||||
|
||||
automation.device_manager:add(DebugBridge.new({
|
||||
identifier = "debug_bridge",
|
||||
topic = mqtt_automation("debug"),
|
||||
client = mqtt_client,
|
||||
}))
|
||||
|
||||
local hue_ip = "10.0.0.102"
|
||||
local hue_token = automation.util.get_env("HUE_TOKEN")
|
||||
|
||||
automation.device_manager:add(HueBridge.new({
|
||||
identifier = "hue_bridge",
|
||||
ip = hue_ip,
|
||||
login = hue_token,
|
||||
flags = {
|
||||
presence = 41,
|
||||
darkness = 43,
|
||||
},
|
||||
}))
|
||||
|
||||
local kitchen_lights = HueGroup.new({
|
||||
identifier = "kitchen_lights",
|
||||
ip = hue_ip,
|
||||
login = hue_token,
|
||||
group_id = 7,
|
||||
scene_id = "7MJLG27RzeRAEVJ",
|
||||
})
|
||||
automation.device_manager:add(kitchen_lights)
|
||||
local living_lights = HueGroup.new({
|
||||
identifier = "living_lights",
|
||||
ip = hue_ip,
|
||||
login = hue_token,
|
||||
group_id = 1,
|
||||
scene_id = "SNZw7jUhQ3cXSjkj",
|
||||
})
|
||||
automation.device_manager:add(living_lights)
|
||||
local living_lights_relax = HueGroup.new({
|
||||
identifier = "living_lights",
|
||||
ip = hue_ip,
|
||||
login = hue_token,
|
||||
group_id = 1,
|
||||
scene_id = "eRJ3fvGHCcb6yNw",
|
||||
})
|
||||
automation.device_manager:add(living_lights_relax)
|
||||
|
||||
automation.device_manager:add(HueSwitch.new({
|
||||
name = "Switch",
|
||||
room = "Living",
|
||||
client = mqtt_client,
|
||||
topic = mqtt_z2m("living/switch"),
|
||||
left_callback = function()
|
||||
kitchen_lights:set_on(not kitchen_lights:on())
|
||||
end,
|
||||
right_callback = function()
|
||||
living_lights:set_on(not living_lights:on())
|
||||
end,
|
||||
right_hold_callback = function()
|
||||
living_lights_relax:set_on(true)
|
||||
end,
|
||||
}))
|
||||
|
||||
automation.device_manager:add(LightSensor.new({
|
||||
identifier = "living_light_sensor",
|
||||
topic = mqtt_z2m("living/light"),
|
||||
client = mqtt_client,
|
||||
min = 22000,
|
||||
max = 23500,
|
||||
event_channel = automation.device_manager:event_channel(),
|
||||
}))
|
||||
|
||||
automation.device_manager:add(WakeOnLAN.new({
|
||||
name = "Zeus",
|
||||
room = "Living Room",
|
||||
topic = mqtt_automation("appliance/living_room/zeus"),
|
||||
client = mqtt_client,
|
||||
mac_address = "30:9c:23:60:9c:13",
|
||||
broadcast_ip = "10.0.3.255",
|
||||
}))
|
||||
|
||||
local living_mixer = OutletOnOff.new({
|
||||
name = "Mixer",
|
||||
room = "Living Room",
|
||||
topic = mqtt_z2m("living/mixer"),
|
||||
client = mqtt_client,
|
||||
})
|
||||
automation.device_manager:add(living_mixer)
|
||||
local living_speakers = OutletOnOff.new({
|
||||
name = "Speakers",
|
||||
room = "Living Room",
|
||||
topic = mqtt_z2m("living/speakers"),
|
||||
client = mqtt_client,
|
||||
})
|
||||
automation.device_manager:add(living_speakers)
|
||||
|
||||
automation.device_manager:add(IkeaRemote.new({
|
||||
name = "Remote",
|
||||
room = "Living Room",
|
||||
client = mqtt_client,
|
||||
topic = mqtt_z2m("living/remote"),
|
||||
single_button = true,
|
||||
callback = function(_, on)
|
||||
if on then
|
||||
if living_mixer:on() then
|
||||
living_mixer:set_on(false)
|
||||
living_speakers:set_on(false)
|
||||
else
|
||||
living_mixer:set_on(true)
|
||||
living_speakers:set_on(true)
|
||||
end
|
||||
else
|
||||
if not living_mixer:on() then
|
||||
living_mixer:set_on(true)
|
||||
else
|
||||
living_speakers:set_on(not living_speakers:on())
|
||||
end
|
||||
end
|
||||
end,
|
||||
}))
|
||||
|
||||
local function kettle_timeout()
|
||||
local timeout = Timeout.new()
|
||||
|
||||
return function(self, state)
|
||||
if state.state and state.power < 100 then
|
||||
timeout:start(3, function()
|
||||
self:set_on(false)
|
||||
end)
|
||||
else
|
||||
timeout:cancel()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local kettle = OutletPower.new({
|
||||
outlet_type = "Kettle",
|
||||
name = "Kettle",
|
||||
room = "Kitchen",
|
||||
topic = mqtt_z2m("kitchen/kettle"),
|
||||
client = mqtt_client,
|
||||
callback = kettle_timeout(),
|
||||
})
|
||||
automation.device_manager:add(kettle)
|
||||
|
||||
local function set_kettle(_, on)
|
||||
kettle:set_on(on)
|
||||
end
|
||||
|
||||
automation.device_manager:add(IkeaRemote.new({
|
||||
name = "Remote",
|
||||
room = "Bedroom",
|
||||
client = mqtt_client,
|
||||
topic = mqtt_z2m("bedroom/remote"),
|
||||
single_button = true,
|
||||
callback = set_kettle,
|
||||
}))
|
||||
|
||||
automation.device_manager:add(IkeaRemote.new({
|
||||
name = "Remote",
|
||||
room = "Kitchen",
|
||||
client = mqtt_client,
|
||||
topic = mqtt_z2m("kitchen/remote"),
|
||||
single_button = true,
|
||||
callback = set_kettle,
|
||||
}))
|
||||
|
||||
local function off_timeout(duration)
|
||||
local timeout = Timeout.new()
|
||||
|
||||
return function(self, state)
|
||||
if state.state then
|
||||
timeout:start(duration, function()
|
||||
self:set_on(false)
|
||||
end)
|
||||
else
|
||||
timeout:cancel()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
automation.device_manager:add(LightOnOff.new({
|
||||
name = "Light",
|
||||
room = "Bathroom",
|
||||
topic = mqtt_z2m("bathroom/light"),
|
||||
client = mqtt_client,
|
||||
callback = off_timeout(debug and 60 or 45 * 60),
|
||||
}))
|
||||
|
||||
automation.device_manager:add(Washer.new({
|
||||
identifier = "bathroom_washer",
|
||||
topic = mqtt_z2m("bathroom/washer"),
|
||||
client = mqtt_client,
|
||||
threshold = 1,
|
||||
event_channel = automation.device_manager:event_channel(),
|
||||
}))
|
||||
|
||||
automation.device_manager:add(OutletOnOff.new({
|
||||
presence_auto_off = false,
|
||||
name = "Charger",
|
||||
room = "Workbench",
|
||||
topic = mqtt_z2m("workbench/charger"),
|
||||
client = mqtt_client,
|
||||
callback = off_timeout(debug and 5 or 20 * 3600),
|
||||
}))
|
||||
|
||||
automation.device_manager:add(OutletOnOff.new({
|
||||
name = "Outlet",
|
||||
room = "Workbench",
|
||||
topic = mqtt_z2m("workbench/outlet"),
|
||||
client = mqtt_client,
|
||||
}))
|
||||
|
||||
local workbench_light = LightBrightness.new({
|
||||
name = "Light",
|
||||
room = "Workbench",
|
||||
topic = mqtt_z2m("workbench/light"),
|
||||
client = mqtt_client,
|
||||
})
|
||||
automation.device_manager:add(workbench_light)
|
||||
|
||||
automation.device_manager:add(IkeaRemote.new({
|
||||
name = "Remote",
|
||||
room = "Workbench",
|
||||
client = mqtt_client,
|
||||
topic = mqtt_z2m("workbench/remote"),
|
||||
callback = function(_, on)
|
||||
workbench_light:set_on(on)
|
||||
end,
|
||||
}))
|
||||
|
||||
local hallway_top_light = HueGroup.new({
|
||||
identifier = "hallway_top_light",
|
||||
ip = hue_ip,
|
||||
login = hue_token,
|
||||
group_id = 83,
|
||||
scene_id = "QeufkFDICEHWeKJ7",
|
||||
})
|
||||
automation.device_manager:add(HueSwitch.new({
|
||||
name = "SwitchBottom",
|
||||
room = "Hallway",
|
||||
client = mqtt_client,
|
||||
topic = mqtt_z2m("hallway/switchbottom"),
|
||||
left_callback = function()
|
||||
hallway_top_light:set_on(not hallway_top_light:on())
|
||||
end,
|
||||
}))
|
||||
automation.device_manager:add(HueSwitch.new({
|
||||
name = "SwitchTop",
|
||||
room = "Hallway",
|
||||
client = mqtt_client,
|
||||
topic = mqtt_z2m("hallway/switchtop"),
|
||||
left_callback = function()
|
||||
hallway_top_light:set_on(not hallway_top_light:on())
|
||||
end,
|
||||
}))
|
||||
|
||||
local hallway_light_automation = {
|
||||
timeout = Timeout.new(),
|
||||
forced = false,
|
||||
switch_callback = function(self, on)
|
||||
self.timeout:cancel()
|
||||
self.group.set_on(on)
|
||||
self.forced = on
|
||||
end,
|
||||
door_callback = function(self, open)
|
||||
if open then
|
||||
self.timeout:cancel()
|
||||
|
||||
self.group.set_on(true)
|
||||
elseif not self.forced then
|
||||
self.timeout:start(debug and 10 or 2 * 60, function()
|
||||
if self.trash:open_percent() == 0 then
|
||||
self.group.set_on(false)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end,
|
||||
trash_callback = function(self, open)
|
||||
if open then
|
||||
self.group.set_on(true)
|
||||
else
|
||||
if not self.timeout:is_waiting() and self.door:open_percent() == 0 and not self.forced then
|
||||
self.group.set_on(false)
|
||||
end
|
||||
end
|
||||
end,
|
||||
light_callback = function(self, on)
|
||||
if on and self.trash:open_percent() == 0 and self.door:open_percent() == 0 then
|
||||
-- If the door and trash are not open, that means the light got turned on manually
|
||||
self.timeout:cancel()
|
||||
self.forced = true
|
||||
elseif not on then
|
||||
-- The light is never forced when it is off
|
||||
self.forced = false
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
local hallway_storage = LightBrightness.new({
|
||||
name = "Storage",
|
||||
room = "Hallway",
|
||||
topic = mqtt_z2m("hallway/storage"),
|
||||
client = mqtt_client,
|
||||
callback = function(_, state)
|
||||
hallway_light_automation:light_callback(state.state)
|
||||
end,
|
||||
})
|
||||
automation.device_manager:add(hallway_storage)
|
||||
|
||||
local hallway_bottom_lights = HueGroup.new({
|
||||
identifier = "hallway_bottom_lights",
|
||||
ip = hue_ip,
|
||||
login = hue_token,
|
||||
group_id = 81,
|
||||
scene_id = "3qWKxGVadXFFG4o",
|
||||
})
|
||||
automation.device_manager:add(hallway_bottom_lights)
|
||||
|
||||
hallway_light_automation.group = {
|
||||
set_on = function(on)
|
||||
if on then
|
||||
hallway_storage:set_brightness(80)
|
||||
else
|
||||
hallway_storage:set_on(false)
|
||||
end
|
||||
hallway_bottom_lights:set_on(on)
|
||||
end,
|
||||
}
|
||||
|
||||
automation.device_manager:add(IkeaRemote.new({
|
||||
name = "Remote",
|
||||
room = "Hallway",
|
||||
client = mqtt_client,
|
||||
topic = mqtt_z2m("hallway/remote"),
|
||||
callback = function(_, on)
|
||||
hallway_light_automation:switch_callback(on)
|
||||
end,
|
||||
}))
|
||||
local hallway_frontdoor = ContactSensor.new({
|
||||
name = "Frontdoor",
|
||||
room = "Hallway",
|
||||
sensor_type = "Door",
|
||||
topic = mqtt_z2m("hallway/frontdoor"),
|
||||
client = mqtt_client,
|
||||
presence = {
|
||||
topic = mqtt_automation("presence/contact/frontdoor"),
|
||||
timeout = debug and 10 or 15 * 60,
|
||||
},
|
||||
callback = function(_, open)
|
||||
hallway_light_automation:door_callback(open)
|
||||
end,
|
||||
})
|
||||
automation.device_manager:add(hallway_frontdoor)
|
||||
hallway_light_automation.door = hallway_frontdoor
|
||||
|
||||
local hallway_trash = ContactSensor.new({
|
||||
name = "Trash",
|
||||
room = "Hallway",
|
||||
sensor_type = "Drawer",
|
||||
topic = mqtt_z2m("hallway/trash"),
|
||||
client = mqtt_client,
|
||||
callback = function(_, open)
|
||||
hallway_light_automation:trash_callback(open)
|
||||
end,
|
||||
})
|
||||
automation.device_manager:add(hallway_trash)
|
||||
hallway_light_automation.trash = hallway_trash
|
||||
|
||||
automation.device_manager:add(LightOnOff.new({
|
||||
name = "Light",
|
||||
room = "Guest Room",
|
||||
topic = mqtt_z2m("guest/light"),
|
||||
client = mqtt_client,
|
||||
}))
|
||||
|
||||
local bedroom_air_filter = AirFilter.new({
|
||||
name = "Air Filter",
|
||||
room = "Bedroom",
|
||||
url = "http://10.0.0.103",
|
||||
})
|
||||
automation.device_manager:add(bedroom_air_filter)
|
||||
|
||||
local bedroom_lights = HueGroup.new({
|
||||
identifier = "bedroom_lights",
|
||||
ip = hue_ip,
|
||||
login = hue_token,
|
||||
group_id = 3,
|
||||
scene_id = "PvRs-lGD4VRytL9",
|
||||
})
|
||||
automation.device_manager:add(bedroom_lights)
|
||||
local bedroom_lights_relax = HueGroup.new({
|
||||
identifier = "bedroom_lights",
|
||||
ip = hue_ip,
|
||||
login = hue_token,
|
||||
group_id = 3,
|
||||
scene_id = "60tfTyR168v2csz",
|
||||
})
|
||||
automation.device_manager:add(bedroom_lights_relax)
|
||||
|
||||
automation.device_manager:add(HueSwitch.new({
|
||||
name = "Switch",
|
||||
room = "Bedroom",
|
||||
client = mqtt_client,
|
||||
topic = mqtt_z2m("bedroom/switch"),
|
||||
left_callback = function()
|
||||
bedroom_lights:set_on(not bedroom_lights:on())
|
||||
end,
|
||||
left_hold_callback = function()
|
||||
bedroom_lights_relax:set_on(true)
|
||||
end,
|
||||
}))
|
||||
|
||||
automation.device_manager:add(ContactSensor.new({
|
||||
name = "Balcony",
|
||||
room = "Living Room",
|
||||
sensor_type = "Door",
|
||||
topic = mqtt_z2m("living/balcony"),
|
||||
client = mqtt_client,
|
||||
}))
|
||||
automation.device_manager:add(ContactSensor.new({
|
||||
name = "Window",
|
||||
room = "Living Room",
|
||||
topic = mqtt_z2m("living/window"),
|
||||
client = mqtt_client,
|
||||
}))
|
||||
automation.device_manager:add(ContactSensor.new({
|
||||
name = "Window",
|
||||
room = "Bedroom",
|
||||
topic = mqtt_z2m("bedroom/window"),
|
||||
client = mqtt_client,
|
||||
}))
|
||||
automation.device_manager:add(ContactSensor.new({
|
||||
name = "Window",
|
||||
room = "Guest Room",
|
||||
topic = mqtt_z2m("guest/window"),
|
||||
client = mqtt_client,
|
||||
}))
|
||||
|
||||
local storage_light = LightBrightness.new({
|
||||
name = "Light",
|
||||
room = "Storage",
|
||||
topic = mqtt_z2m("storage/light"),
|
||||
client = mqtt_client,
|
||||
})
|
||||
automation.device_manager:add(storage_light)
|
||||
|
||||
automation.device_manager:add(ContactSensor.new({
|
||||
name = "Door",
|
||||
room = "Storage",
|
||||
sensor_type = "Door",
|
||||
topic = mqtt_z2m("storage/door"),
|
||||
client = mqtt_client,
|
||||
callback = function(_, open)
|
||||
if open then
|
||||
storage_light:set_brightness(100)
|
||||
else
|
||||
storage_light:set_on(false)
|
||||
end
|
||||
end,
|
||||
}))
|
||||
|
||||
automation.device_manager:schedule("0 0 19 * * *", function()
|
||||
bedroom_air_filter:set_on(true)
|
||||
end)
|
||||
automation.device_manager:schedule("0 0 20 * * *", function()
|
||||
bedroom_air_filter:set_on(false)
|
||||
end)
|
66
config/ares.dev.yml
Normal file
66
config/ares.dev.yml
Normal file
|
@ -0,0 +1,66 @@
|
|||
openid:
|
||||
base_url: "https://login.huizinga.dev/api/oidc"
|
||||
|
||||
mqtt:
|
||||
host: "olympus.vpn.huizinga.dev"
|
||||
port: 8883
|
||||
client_name: "automation-ares"
|
||||
username: "mqtt"
|
||||
password: "${MQTT_PASSWORD}"
|
||||
tls: true
|
||||
|
||||
ntfy:
|
||||
topic: "${NTFY_TOPIC}"
|
||||
|
||||
presence:
|
||||
topic: "automation_dev/presence/+/#"
|
||||
|
||||
devices:
|
||||
debug_bridge:
|
||||
!DebugBridge
|
||||
topic: "automation_dev/debug"
|
||||
|
||||
living_light_sensor:
|
||||
!LightSensor
|
||||
topic: "zigbee2mqtt_dev/living/light"
|
||||
min: 23000
|
||||
max: 25000
|
||||
|
||||
kitchen_kettle:
|
||||
!IkeaOutlet
|
||||
outlet_type: "Kettle"
|
||||
name: "Kettle"
|
||||
room: "Kitchen"
|
||||
topic: "zigbee2mqtt/kitchen/kettle"
|
||||
timeout: 5
|
||||
remotes:
|
||||
- topic: "zigbee2mqtt/bedroom/remote"
|
||||
- topic: "zigbee2mqtt/kitchen/remote"
|
||||
|
||||
workbench_charger:
|
||||
!IkeaOutlet
|
||||
outlet_type: "Charger"
|
||||
name: "Charger"
|
||||
room: "Workbench"
|
||||
topic: "zigbee2mqtt/workbench/charger"
|
||||
timeout: 5
|
||||
|
||||
workbench_outlet:
|
||||
!IkeaOutlet
|
||||
name: "Outlet"
|
||||
room: "Workbench"
|
||||
topic: "zigbee2mqtt/workbench/outlet"
|
||||
|
||||
living_zeus:
|
||||
!WakeOnLAN
|
||||
name: "Zeus"
|
||||
room: "Living Room"
|
||||
topic: "automation/appliance/living_room/zeus"
|
||||
mac_address: "30:9c:23:60:9c:13"
|
||||
|
||||
hallway_frontdoor:
|
||||
!ContactSensor
|
||||
topic: "zigbee2mqtt/hallway/frontdoor"
|
||||
presence:
|
||||
topic: "automation_dev/presence/contact/frontdoor"
|
||||
timeout: 10
|
134
config/config.yml
Normal file
134
config/config.yml
Normal file
|
@ -0,0 +1,134 @@
|
|||
openid:
|
||||
base_url: "https://login.huizinga.dev/api/oidc"
|
||||
|
||||
mqtt:
|
||||
host: "mosquitto"
|
||||
port: 8883
|
||||
client_name: "automation_rs"
|
||||
username: "mqtt"
|
||||
password: "${MQTT_PASSWORD}"
|
||||
|
||||
ntfy:
|
||||
topic: "${NTFY_TOPIC}"
|
||||
|
||||
presence:
|
||||
topic: "automation/presence/+/#"
|
||||
|
||||
devices:
|
||||
debug_bridge:
|
||||
!DebugBridge
|
||||
topic: "automation/debug"
|
||||
|
||||
hue_bridge:
|
||||
!HueBridge
|
||||
ip: &hue_ip "10.0.0.146"
|
||||
login: &hue_token "${HUE_TOKEN}"
|
||||
flags: { presence: 41, darkness: 43 }
|
||||
|
||||
|
||||
living_light_sensor:
|
||||
!LightSensor
|
||||
topic: "zigbee2mqtt/living/light"
|
||||
min: 22000
|
||||
max: 23500
|
||||
|
||||
living_zeus:
|
||||
!WakeOnLAN
|
||||
name: "Zeus"
|
||||
room: "Living Room"
|
||||
topic: "automation/appliance/living_room/zeus"
|
||||
mac_address: "30:9c:23:60:9c:13"
|
||||
broadcast_ip: "10.0.0.255"
|
||||
|
||||
&mixer living_mixer:
|
||||
!KasaOutlet
|
||||
ip: "10.0.0.49"
|
||||
|
||||
&speakers living_speakers:
|
||||
!KasaOutlet
|
||||
ip: "10.0.0.182"
|
||||
|
||||
living_audio:
|
||||
!AudioSetup
|
||||
topic: "zigbee2mqtt/living/remote"
|
||||
mixer: *mixer
|
||||
speakers: *speakers
|
||||
|
||||
|
||||
kitchen_kettle:
|
||||
!IkeaOutlet
|
||||
outlet_type: "Kettle"
|
||||
name: "Kettle"
|
||||
room: "Kitchen"
|
||||
topic: "zigbee2mqtt/kitchen/kettle"
|
||||
timeout: 300
|
||||
remotes:
|
||||
- topic: "zigbee2mqtt/bedroom/remote"
|
||||
- topic: "zigbee2mqtt/kitchen/remote"
|
||||
|
||||
|
||||
bathroom_light:
|
||||
!IkeaOutlet
|
||||
type: "IkeaOutlet"
|
||||
outlet_type: "Light"
|
||||
name: "Light"
|
||||
room: "Bathroom"
|
||||
topic: "zigbee2mqtt/bathroom/light"
|
||||
timeout: 2700
|
||||
|
||||
bathroom_washer:
|
||||
!Washer
|
||||
topic: "zigbee2mqtt/bathroom/washer"
|
||||
threshold: 1
|
||||
|
||||
workbench_charger:
|
||||
!IkeaOutlet
|
||||
outlet_type: "Charger"
|
||||
name: "Charger"
|
||||
room: "Workbench"
|
||||
topic: "zigbee2mqtt/workbench/charger"
|
||||
timeout: 72000
|
||||
|
||||
workbench_outlet:
|
||||
!IkeaOutlet
|
||||
name: "Outlet"
|
||||
room: "Workbench"
|
||||
topic: "zigbee2mqtt/workbench/outlet"
|
||||
|
||||
|
||||
hallway_lights:
|
||||
!HueGroup
|
||||
ip: *hue_ip
|
||||
login: *hue_token
|
||||
group_id: 81
|
||||
scene_id: "3qWKxGVadXFFG4o"
|
||||
timer_id: 1
|
||||
remotes:
|
||||
- topic: "zigbee2mqtt/hallway/remote"
|
||||
|
||||
hallway_frontdoor:
|
||||
!ContactSensor
|
||||
topic: "zigbee2mqtt/hallway/frontdoor"
|
||||
presence:
|
||||
topic: "automation/presence/contact/frontdoor"
|
||||
timeout: 900
|
||||
trigger:
|
||||
devices: ["hallway_lights"]
|
||||
timeout: 60
|
||||
|
||||
|
||||
&air_filter bedroom_air_filter:
|
||||
!AirFilter
|
||||
name: "Air Filter"
|
||||
room: "Bedroom"
|
||||
topic: "pico/filter/test"
|
||||
|
||||
# Run the air filter everyday for 19:00 to 20:00
|
||||
schedule:
|
||||
0 0 19 * * *:
|
||||
on:
|
||||
- *air_filter
|
||||
|
||||
0 0 20 * * *:
|
||||
off:
|
||||
- *air_filter
|
133
config/zeus.dev.yml
Normal file
133
config/zeus.dev.yml
Normal file
|
@ -0,0 +1,133 @@
|
|||
openid:
|
||||
base_url: "https://login.huizinga.dev/api/oidc"
|
||||
|
||||
mqtt:
|
||||
host: "olympus.lan.huizinga.dev"
|
||||
port: 8883
|
||||
client_name: "automation-zeus"
|
||||
username: "mqtt"
|
||||
password: "${MQTT_PASSWORD}"
|
||||
tls: true
|
||||
|
||||
ntfy:
|
||||
topic: "${NTFY_TOPIC}"
|
||||
|
||||
presence:
|
||||
topic: "automation_dev/presence/+/#"
|
||||
|
||||
devices:
|
||||
debug_bridge:
|
||||
!DebugBridge
|
||||
topic: "automation_dev/debug"
|
||||
|
||||
hue_bridge:
|
||||
!HueBridge
|
||||
ip: &hue_ip "10.0.0.146"
|
||||
login: &hue_token "${HUE_TOKEN}"
|
||||
flags: { presence: 41, darkness: 43 }
|
||||
|
||||
|
||||
living_light_sensor:
|
||||
!LightSensor
|
||||
topic: "zigbee2mqtt_dev/living/light"
|
||||
min: 23000
|
||||
max: 25000
|
||||
|
||||
living_zeus:
|
||||
!WakeOnLAN
|
||||
name: "Zeus"
|
||||
room: "Living Room"
|
||||
topic: "automation/appliance/living_room/zeus"
|
||||
mac_address: "30:9c:23:60:9c:13"
|
||||
|
||||
&mixer living_mixer:
|
||||
!KasaOutlet
|
||||
ip: "10.0.0.49"
|
||||
|
||||
&speakers living_speakers:
|
||||
!KasaOutlet
|
||||
ip: "10.0.0.182"
|
||||
|
||||
living_audio:
|
||||
!AudioSetup
|
||||
topic: "zigbee2mqtt/living/remote"
|
||||
mixer: *mixer
|
||||
speakers: *speakers
|
||||
|
||||
|
||||
kitchen_kettle:
|
||||
!IkeaOutlet
|
||||
outlet_type: "Kettle"
|
||||
name: "Kettle"
|
||||
room: "Kitchen"
|
||||
topic: "zigbee2mqtt/kitchen/kettle"
|
||||
timeout: 5
|
||||
remotes:
|
||||
- topic: "zigbee2mqtt/bedroom/remote"
|
||||
- topic: "zigbee2mqtt/kitchen/remote"
|
||||
|
||||
|
||||
bathroom_light:
|
||||
!IkeaOutlet
|
||||
type: "IkeaOutlet"
|
||||
outlet_type: "Light"
|
||||
name: "Light"
|
||||
room: "Bathroom"
|
||||
topic: "zigbee2mqtt/bathroom/light"
|
||||
timeout: 60
|
||||
|
||||
bathroom_washer:
|
||||
!Washer
|
||||
topic: "zigbee2mqtt/bathroom/washer"
|
||||
threshold: 1
|
||||
|
||||
workbench_charger:
|
||||
!IkeaOutlet
|
||||
outlet_type: "Charger"
|
||||
name: "Charger"
|
||||
room: "Workbench"
|
||||
topic: "zigbee2mqtt/workbench/charger"
|
||||
timeout: 5
|
||||
|
||||
&outlet workbench_outlet:
|
||||
!IkeaOutlet
|
||||
name: "Outlet"
|
||||
room: "Workbench"
|
||||
topic: "zigbee2mqtt/workbench/outlet"
|
||||
|
||||
|
||||
hallway_lights:
|
||||
!HueGroup
|
||||
ip: *hue_ip
|
||||
login: *hue_token
|
||||
group_id: 81
|
||||
scene_id: "3qWKxGVadXFFG4o"
|
||||
timer_id: 1
|
||||
remotes:
|
||||
- topic: "zigbee2mqtt/hallway/remote"
|
||||
|
||||
hallway_frontdoor:
|
||||
!ContactSensor
|
||||
topic: "zigbee2mqtt/hallway/frontdoor"
|
||||
presence:
|
||||
topic: "automation_dev/presence/contact/frontdoor"
|
||||
timeout: 10
|
||||
trigger:
|
||||
devices: ["hallway_lights"]
|
||||
timeout: 10
|
||||
|
||||
|
||||
bedroom_air_filter:
|
||||
!AirFilter
|
||||
name: "Air Filter"
|
||||
room: "Bedroom"
|
||||
topic: "pico/filter/test"
|
||||
|
||||
# schedule:
|
||||
# 0/30 * * * * *:
|
||||
# on:
|
||||
# - *outlet
|
||||
#
|
||||
# 15/30 * * * * *:
|
||||
# off:
|
||||
# - *outlet
|
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]
|
||||
impl_cast = { path = "../impl_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"
|
20
google-home/src/attributes.rs
Normal file
20
google-home/src/attributes.rs
Normal file
|
@ -0,0 +1,20 @@
|
|||
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>,
|
||||
}
|
195
google-home/src/device.rs
Normal file
195
google-home/src/device.rs
Normal file
|
@ -0,0 +1,195 @@
|
|||
use async_trait::async_trait;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::errors::{DeviceError, ErrorCode};
|
||||
use crate::request::execute::CommandType;
|
||||
use crate::response;
|
||||
use crate::traits::{FanSpeed, OnOff, Scene, Trait};
|
||||
use crate::types::Type;
|
||||
|
||||
// TODO: Find a more elegant way to do this
|
||||
pub trait AsGoogleHomeDevice {
|
||||
fn cast(&self) -> Option<&dyn GoogleHomeDevice>;
|
||||
fn cast_mut(&mut self) -> Option<&mut dyn GoogleHomeDevice>;
|
||||
}
|
||||
|
||||
// Default impl
|
||||
impl<T> AsGoogleHomeDevice for T
|
||||
where
|
||||
T: 'static,
|
||||
{
|
||||
default fn cast(&self) -> Option<&(dyn GoogleHomeDevice + 'static)> {
|
||||
None
|
||||
}
|
||||
|
||||
default fn cast_mut(&mut self) -> Option<&mut (dyn GoogleHomeDevice + 'static)> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Specialization
|
||||
impl<T> AsGoogleHomeDevice for T
|
||||
where
|
||||
T: GoogleHomeDevice + 'static,
|
||||
{
|
||||
fn cast(&self) -> Option<&(dyn GoogleHomeDevice + 'static)> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn cast_mut(&mut self) -> Option<&mut (dyn GoogleHomeDevice + 'static)> {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
#[impl_cast::device(As: OnOff + Scene + FanSpeed)]
|
||||
pub trait GoogleHomeDevice: AsGoogleHomeDevice + Sync + Send + 'static {
|
||||
fn get_device_type(&self) -> Type;
|
||||
fn get_device_name(&self) -> Name;
|
||||
fn get_id(&self) -> &str;
|
||||
fn is_online(&self) -> bool;
|
||||
|
||||
// Default values that can optionally be overriden
|
||||
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) = As::<dyn OnOff>::cast(self) {
|
||||
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) = As::<dyn Scene>::cast(self) {
|
||||
traits.push(Trait::Scene);
|
||||
device.attributes.scene_reversible = scene.is_scene_reversible();
|
||||
}
|
||||
|
||||
// FanSpeed
|
||||
if let Some(fan_speed) = As::<dyn FanSpeed>::cast(self) {
|
||||
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());
|
||||
}
|
||||
|
||||
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) = As::<dyn OnOff>::cast(self) {
|
||||
device.state.on = on_off
|
||||
.is_on()
|
||||
.await
|
||||
.map_err(|err| device.set_error(err))
|
||||
.ok();
|
||||
}
|
||||
|
||||
// FanSpeed
|
||||
if let Some(fan_speed) = As::<dyn FanSpeed>::cast(self) {
|
||||
device.state.current_fan_speed_setting = Some(fan_speed.current_speed().await);
|
||||
}
|
||||
|
||||
device
|
||||
}
|
||||
|
||||
async fn execute(&mut self, command: &CommandType) -> Result<(), ErrorCode> {
|
||||
match command {
|
||||
CommandType::OnOff { on } => {
|
||||
if let Some(t) = As::<dyn OnOff>::cast_mut(self) {
|
||||
t.set_on(*on).await?;
|
||||
} else {
|
||||
return Err(DeviceError::ActionNotAvailable.into());
|
||||
}
|
||||
}
|
||||
CommandType::ActivateScene { deactivate } => {
|
||||
if let Some(t) = As::<dyn Scene>::cast(self) {
|
||||
t.set_active(!deactivate).await?;
|
||||
} else {
|
||||
return Err(DeviceError::ActionNotAvailable.into());
|
||||
}
|
||||
}
|
||||
CommandType::SetFanSpeed { fan_speed } => {
|
||||
if let Some(t) = As::<dyn FanSpeed>::cast(self) {
|
||||
t.set_speed(fan_speed).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Name {
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
default_names: Vec<String>,
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
nicknames: Vec<String>,
|
||||
}
|
||||
|
||||
impl Name {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self {
|
||||
default_names: Vec::new(),
|
||||
name: name.into(),
|
||||
nicknames: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_default_name(&mut self, name: &str) {
|
||||
self.default_names.push(name.into());
|
||||
}
|
||||
|
||||
pub fn add_nickname(&mut self, name: &str) {
|
||||
self.nicknames.push(name.into());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Info {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub manufacturer: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hw_version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sw_version: Option<String>,
|
||||
// attributes
|
||||
// customData
|
||||
// otherDeviceIds
|
||||
}
|
|
@ -1,15 +1,14 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use automation_cast::Cast;
|
||||
use futures::future::{join_all, OptionFuture};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
use crate::device::AsGoogleHomeDevice;
|
||||
use crate::errors::{DeviceError, ErrorCode};
|
||||
use crate::request::{self, Intent, Request};
|
||||
use crate::response::{self, execute, query, sync, Response, ResponsePayload};
|
||||
use crate::Device;
|
||||
use crate::response::{self, execute, query, sync, Response, ResponsePayload, State};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct GoogleHome {
|
||||
|
@ -18,7 +17,7 @@ pub struct GoogleHome {
|
|||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum FulfillmentError {
|
||||
pub enum FullfillmentError {
|
||||
#[error("Expected at least one ResponsePayload")]
|
||||
ExpectedOnePayload,
|
||||
}
|
||||
|
@ -30,11 +29,11 @@ impl GoogleHome {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn handle_request<T: Cast<dyn Device> + ?Sized + 'static>(
|
||||
pub async fn handle_request<T: AsGoogleHomeDevice + ?Sized + 'static>(
|
||||
&self,
|
||||
request: Request,
|
||||
devices: &HashMap<String, Box<T>>,
|
||||
) -> Result<Response, FulfillmentError> {
|
||||
devices: &HashMap<String, Arc<RwLock<Box<T>>>>,
|
||||
) -> Result<Response, FullfillmentError> {
|
||||
// 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
|
||||
let intent = request.inputs.into_iter().next();
|
||||
|
@ -55,18 +54,18 @@ impl GoogleHome {
|
|||
|
||||
payload
|
||||
.await
|
||||
.ok_or(FulfillmentError::ExpectedOnePayload)
|
||||
.ok_or(FullfillmentError::ExpectedOnePayload)
|
||||
.map(|payload| Response::new(&request.request_id, payload))
|
||||
}
|
||||
|
||||
async fn sync<T: Cast<dyn Device> + ?Sized + 'static>(
|
||||
async fn sync<T: AsGoogleHomeDevice + ?Sized + 'static>(
|
||||
&self,
|
||||
devices: &HashMap<String, Box<T>>,
|
||||
devices: &HashMap<String, Arc<RwLock<Box<T>>>>,
|
||||
) -> sync::Payload {
|
||||
let mut resp_payload = sync::Payload::new(&self.user_id);
|
||||
let f = devices.iter().map(|(_, device)| async move {
|
||||
if let Some(device) = device.as_ref().cast() {
|
||||
Some(Device::sync(device).await)
|
||||
if let Some(device) = device.read().await.as_ref().cast() {
|
||||
Some(device.sync().await)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
@ -76,10 +75,10 @@ impl GoogleHome {
|
|||
resp_payload
|
||||
}
|
||||
|
||||
async fn query<T: Cast<dyn Device> + ?Sized + 'static>(
|
||||
async fn query<T: AsGoogleHomeDevice + ?Sized + 'static>(
|
||||
&self,
|
||||
payload: request::query::Payload,
|
||||
devices: &HashMap<String, Box<T>>,
|
||||
devices: &HashMap<String, Arc<RwLock<Box<T>>>>,
|
||||
) -> query::Payload {
|
||||
let mut resp_payload = query::Payload::new();
|
||||
let f = payload
|
||||
|
@ -89,9 +88,9 @@ impl GoogleHome {
|
|||
.map(|id| async move {
|
||||
// NOTE: Requires let_chains feature
|
||||
let device = if let Some(device) = devices.get(id.as_str())
|
||||
&& let Some(device) = device.as_ref().cast()
|
||||
&& let Some(device) = device.read().await.as_ref().cast()
|
||||
{
|
||||
Device::query(device).await
|
||||
device.query().await
|
||||
} else {
|
||||
let mut device = query::Device::new();
|
||||
device.set_offline();
|
||||
|
@ -108,10 +107,10 @@ impl GoogleHome {
|
|||
resp_payload
|
||||
}
|
||||
|
||||
async fn execute<T: Cast<dyn Device> + ?Sized + 'static>(
|
||||
async fn execute<T: AsGoogleHomeDevice + ?Sized + 'static>(
|
||||
&self,
|
||||
payload: request::execute::Payload,
|
||||
devices: &HashMap<String, Box<T>>,
|
||||
devices: &HashMap<String, Arc<RwLock<Box<T>>>>,
|
||||
) -> execute::Payload {
|
||||
let resp_payload = Arc::new(Mutex::new(response::execute::Payload::new()));
|
||||
|
||||
|
@ -121,12 +120,12 @@ impl GoogleHome {
|
|||
let mut success = response::execute::Command::new(execute::Status::Success);
|
||||
success.states = Some(execute::States {
|
||||
online: true,
|
||||
state: Default::default(),
|
||||
state: State::default(),
|
||||
});
|
||||
let mut offline = response::execute::Command::new(execute::Status::Offline);
|
||||
offline.states = Some(execute::States {
|
||||
online: false,
|
||||
state: Default::default(),
|
||||
state: State::default(),
|
||||
});
|
||||
let mut errors: HashMap<ErrorCode, response::execute::Command> = HashMap::new();
|
||||
|
||||
|
@ -138,16 +137,16 @@ impl GoogleHome {
|
|||
let execution = command.execution.clone();
|
||||
async move {
|
||||
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));
|
||||
}
|
||||
|
||||
// NOTE: We can not use .map here because async =(
|
||||
let mut results = Vec::new();
|
||||
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
|
|
@ -2,16 +2,17 @@
|
|||
#![feature(specialization)]
|
||||
#![feature(let_chains)]
|
||||
pub mod device;
|
||||
mod fulfillment;
|
||||
mod fullfillment;
|
||||
|
||||
mod request;
|
||||
mod response;
|
||||
|
||||
mod attributes;
|
||||
pub mod errors;
|
||||
pub mod traits;
|
||||
pub mod types;
|
||||
|
||||
pub use device::Device;
|
||||
pub use fulfillment::{FulfillmentError, GoogleHome};
|
||||
pub use device::GoogleHomeDevice;
|
||||
pub use fullfillment::{FullfillmentError, GoogleHome};
|
||||
pub use request::Request;
|
||||
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)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::request::{Intent, Request};
|
||||
|
||||
#[test]
|
||||
fn deserialize() {
|
||||
let req = json!({
|
||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
|
||||
"inputs": [
|
||||
{
|
||||
"intent": "action.devices.QUERY",
|
||||
"payload": {
|
||||
"devices": [
|
||||
{
|
||||
"id": "123",
|
||||
"customData": {
|
||||
"fooValue": 74,
|
||||
"barValue": true,
|
||||
"bazValue": "foo"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "456",
|
||||
"customData": {
|
||||
"fooValue": 12,
|
||||
"barValue": false,
|
||||
"bazValue": "bar"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
let json = r#"{
|
||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
|
||||
"inputs": [
|
||||
{
|
||||
"intent": "action.devices.QUERY",
|
||||
"payload": {
|
||||
"devices": [
|
||||
{
|
||||
"id": "123",
|
||||
"customData": {
|
||||
"fooValue": 74,
|
||||
"barValue": true,
|
||||
"bazValue": "foo"
|
||||
}
|
||||
]
|
||||
});
|
||||
},
|
||||
{
|
||||
"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);
|
||||
|
|
@ -1,21 +1,19 @@
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::request::{Intent, Request};
|
||||
|
||||
#[test]
|
||||
fn deserialize() {
|
||||
let req = json!({
|
||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
|
||||
"inputs": [
|
||||
{
|
||||
"intent": "action.devices.SYNC"
|
||||
}
|
||||
]
|
||||
});
|
||||
let json = r#"{
|
||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
|
||||
"inputs": [
|
||||
{
|
||||
"intent": "action.devices.SYNC"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let req: Request = serde_json::from_value(req).unwrap();
|
||||
let req: Request = serde_json::from_str(json).unwrap();
|
||||
|
||||
println!("{:?}", req);
|
||||
|
|
@ -27,3 +27,13 @@ pub enum ResponsePayload {
|
|||
Query(query::Payload),
|
||||
Execute(execute::Payload),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct State {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub on: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub current_fan_speed_setting: Option<String>,
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
use serde::Serialize;
|
||||
|
||||
use crate::errors::ErrorCode;
|
||||
use crate::response::State;
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
@ -71,7 +72,7 @@ pub struct States {
|
|||
pub online: bool,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub state: serde_json::Value,
|
||||
pub state: State,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
|
@ -86,19 +87,18 @@ pub enum Status {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::errors::DeviceError;
|
||||
use crate::response::{Response, ResponsePayload};
|
||||
use crate::response::{Response, ResponsePayload, State};
|
||||
|
||||
#[test]
|
||||
fn serialize() {
|
||||
let mut execute_resp = Payload::new();
|
||||
|
||||
let state = json!({
|
||||
"on": true,
|
||||
});
|
||||
let state = State {
|
||||
on: Some(true),
|
||||
current_fan_speed_setting: None,
|
||||
};
|
||||
let mut command = Command::new(Status::Success);
|
||||
command.states = Some(States {
|
||||
online: true,
|
||||
|
@ -117,28 +117,10 @@ mod tests {
|
|||
ResponsePayload::Execute(execute_resp),
|
||||
);
|
||||
|
||||
let resp = serde_json::to_value(resp).unwrap();
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
|
||||
let resp_expected = json!({
|
||||
"payload": {
|
||||
"commands": [
|
||||
{
|
||||
"states": {
|
||||
"on": true,
|
||||
"online": true
|
||||
},
|
||||
"ids": ["123"],
|
||||
"status": "SUCCESS"
|
||||
}, {
|
||||
"errorCode": "deviceNotFound",
|
||||
"ids": ["456"],
|
||||
"status":"ERROR"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf"
|
||||
});
|
||||
println!("{}", json);
|
||||
|
||||
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 crate::errors::ErrorCode;
|
||||
use crate::response::State;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
@ -52,7 +53,7 @@ pub struct Device {
|
|||
error_code: Option<ErrorCode>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub state: serde_json::Value,
|
||||
pub state: State,
|
||||
}
|
||||
|
||||
impl Device {
|
||||
|
@ -61,7 +62,7 @@ impl Device {
|
|||
online: true,
|
||||
status: Status::Success,
|
||||
error_code: None,
|
||||
state: Default::default(),
|
||||
state: State::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -87,8 +88,6 @@ impl Default for Device {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::response::{Response, ResponsePayload};
|
||||
|
||||
|
@ -97,15 +96,11 @@ mod tests {
|
|||
let mut query_resp = Payload::new();
|
||||
|
||||
let mut device = Device::new();
|
||||
device.state = json!({
|
||||
"on": true,
|
||||
});
|
||||
device.state.on = Some(true);
|
||||
query_resp.add_device("123", device);
|
||||
|
||||
let mut device = Device::new();
|
||||
device.state = json!({
|
||||
"on": true,
|
||||
});
|
||||
device.state.on = Some(false);
|
||||
query_resp.add_device("456", device);
|
||||
|
||||
let resp = Response::new(
|
||||
|
@ -113,26 +108,10 @@ mod tests {
|
|||
ResponsePayload::Query(query_resp),
|
||||
);
|
||||
|
||||
let resp = serde_json::to_value(resp).unwrap();
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
|
||||
let resp_expected = json!({
|
||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
|
||||
"payload": {
|
||||
"devices": {
|
||||
"123": {
|
||||
"online": true,
|
||||
"status": "SUCCESS",
|
||||
"on": true
|
||||
},
|
||||
"456": {
|
||||
"online": true,
|
||||
"status": "SUCCESS",
|
||||
"on":true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
println!("{}", json);
|
||||
|
||||
assert_eq!(resp, resp_expected);
|
||||
// TODO: Add a known correct output to test against
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
use serde::Serialize;
|
||||
|
||||
use crate::attributes::Attributes;
|
||||
use crate::device;
|
||||
use crate::errors::ErrorCode;
|
||||
use crate::traits::Trait;
|
||||
|
@ -46,8 +47,7 @@ pub struct Device {
|
|||
pub room_hint: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub device_info: Option<device::Info>,
|
||||
#[serde(skip_serializing_if = "serde_json::Value::is_null")]
|
||||
pub attributes: serde_json::Value,
|
||||
pub attributes: Attributes,
|
||||
}
|
||||
|
||||
impl Device {
|
||||
|
@ -61,15 +61,13 @@ impl Device {
|
|||
notification_supported_by_agent: None,
|
||||
room_hint: None,
|
||||
device_info: None,
|
||||
attributes: Default::default(),
|
||||
attributes: Attributes::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::response::{Response, ResponsePayload};
|
||||
use crate::traits::Trait;
|
||||
|
@ -99,35 +97,10 @@ mod tests {
|
|||
ResponsePayload::Sync(sync_resp),
|
||||
);
|
||||
|
||||
let resp = serde_json::to_value(resp).unwrap();
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
|
||||
let resp_expected = json!({
|
||||
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
|
||||
"payload": {
|
||||
"agentUserId": "1836.15267389",
|
||||
"devices": [
|
||||
{
|
||||
"id": "123",
|
||||
"type": "action.devices.types.KETTLE",
|
||||
"traits": ["action.devices.traits.OnOff"],
|
||||
"name": {
|
||||
"defaultNames": ["My Outlet 1234"],
|
||||
"name": "Night light",
|
||||
"nicknames": ["wall plug"]
|
||||
},
|
||||
"willReportState": false,
|
||||
"roomHint": "kitchen",
|
||||
"deviceInfo": {
|
||||
"manufacturer": "lights-out-inc",
|
||||
"model": "hs1234",
|
||||
"hwVersion": "3.2",
|
||||
"swVersion": "11.4"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
println!("{}", json);
|
||||
|
||||
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"}}]}}"#)
|
||||
}
|
||||
}
|
74
google-home/src/traits.rs
Normal file
74
google-home/src/traits.rs
Normal file
|
@ -0,0 +1,74 @@
|
|||
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,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
#[impl_cast::device_trait]
|
||||
pub trait OnOff {
|
||||
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]
|
||||
#[impl_cast::device_trait]
|
||||
pub trait Scene {
|
||||
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]
|
||||
#[impl_cast::device_trait]
|
||||
pub trait FanSpeed {
|
||||
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>;
|
||||
}
|
|
@ -12,10 +12,4 @@ pub enum Type {
|
|||
Scene,
|
||||
#[serde(rename = "action.devices.types.AIRPURIFIER")]
|
||||
AirPurifier,
|
||||
#[serde(rename = "action.devices.types.DOOR")]
|
||||
Door,
|
||||
#[serde(rename = "action.devices.types.WINDOW")]
|
||||
Window,
|
||||
#[serde(rename = "action.devices.types.DRAWER")]
|
||||
Drawer,
|
||||
}
|
|
@ -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()
|
||||
}
|
15
impl_cast/Cargo.toml
Normal file
15
impl_cast/Cargo.toml
Normal file
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "impl_cast"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
syn = { version = "2.0", features = ["extra-traits", "full"] }
|
||||
quote = "1.0"
|
||||
|
||||
[features]
|
||||
debug = [
|
||||
] # If enabled it will add std::fmt::Debug as a trait bound to device_traits
|
165
impl_cast/src/lib.rs
Normal file
165
impl_cast/src/lib.rs
Normal file
|
@ -0,0 +1,165 @@
|
|||
use proc_macro::TokenStream;
|
||||
use quote::{format_ident, quote, ToTokens};
|
||||
use syn::parse::Parse;
|
||||
use syn::{parse_macro_input, Ident, ItemTrait, Path, Token, TypeParamBound};
|
||||
|
||||
struct Attr {
|
||||
name: Ident,
|
||||
traits: Vec<Path>,
|
||||
}
|
||||
|
||||
impl Parse for Attr {
|
||||
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
|
||||
let mut traits = Vec::new();
|
||||
|
||||
let name = input.parse::<Ident>()?;
|
||||
input.parse::<Token![:]>()?;
|
||||
|
||||
loop {
|
||||
let ty = input.parse()?;
|
||||
traits.push(ty);
|
||||
|
||||
if input.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
input.parse::<Token![+]>()?;
|
||||
}
|
||||
|
||||
Ok(Attr { name, traits })
|
||||
}
|
||||
}
|
||||
|
||||
/// This macro enables optional trait bounds on a trait with an appropriate cast trait to convert
|
||||
/// to the optional traits
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// #![feature(specialization)]
|
||||
///
|
||||
/// // Create some traits
|
||||
/// #[impl_cast::device_trait]
|
||||
/// trait OnOff {}
|
||||
/// #[impl_cast::device_trait]
|
||||
/// trait Brightness {}
|
||||
///
|
||||
/// // Create the main device trait
|
||||
/// #[impl_cast::device(As: OnOff + Brightness)]
|
||||
/// trait Device {}
|
||||
///
|
||||
/// // Create an implementation
|
||||
/// struct ExampleDevice {}
|
||||
/// impl Device for ExampleDevice {}
|
||||
/// impl OnOff for ExampleDevice {}
|
||||
///
|
||||
/// // Creates a boxed instance of the example device
|
||||
/// let example_device: Box<dyn Device> = Box::new(ExampleDevice {});
|
||||
///
|
||||
/// // Cast to the OnOff trait, which is implemented
|
||||
/// let as_on_off = As::<dyn OnOff>::cast(example_device.as_ref());
|
||||
/// assert!(as_on_off.is_some());
|
||||
///
|
||||
/// // Cast to the Brightness trait, which is not implemented
|
||||
/// let as_on_off = As::<dyn Brightness>::cast(example_device.as_ref());
|
||||
/// assert!(as_on_off.is_none());
|
||||
///
|
||||
/// // Finally we are going to consume the example device into an instance of the OnOff trait
|
||||
/// let consumed = As::<dyn OnOff>::consume(example_device);
|
||||
/// assert!(consumed.is_some())
|
||||
/// ```
|
||||
#[proc_macro_attribute]
|
||||
pub fn device(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let Attr { name, traits } = parse_macro_input!(attr);
|
||||
let mut interface: ItemTrait = parse_macro_input!(item);
|
||||
|
||||
let prefix = quote! {
|
||||
pub trait #name<T: ?Sized + 'static> {
|
||||
fn is(&self) -> bool;
|
||||
fn cast(&self) -> Option<&T>;
|
||||
fn cast_mut(&mut self) -> Option<&mut T>;
|
||||
}
|
||||
};
|
||||
|
||||
traits.iter().for_each(|device_trait| {
|
||||
interface.supertraits.push(TypeParamBound::Verbatim(quote! {
|
||||
#name<dyn #device_trait>
|
||||
}));
|
||||
});
|
||||
|
||||
let interface_ident = format_ident!("{}", interface.ident);
|
||||
let impls = traits
|
||||
.iter()
|
||||
.map(|device_trait| {
|
||||
quote! {
|
||||
// Default impl
|
||||
impl<T> #name<dyn #device_trait> for T
|
||||
where
|
||||
T: #interface_ident + 'static,
|
||||
{
|
||||
default fn is(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
default fn cast(&self) -> Option<&(dyn #device_trait + 'static)> {
|
||||
None
|
||||
}
|
||||
|
||||
default fn cast_mut(&mut self) -> Option<&mut (dyn #device_trait + 'static)> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Specialization, should not cause any unsoundness as we dispatch based on
|
||||
// #device_trait
|
||||
impl<T> #name<dyn #device_trait> for T
|
||||
where
|
||||
T: #interface_ident + #device_trait + 'static,
|
||||
{
|
||||
fn is(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn cast(&self) -> Option<&(dyn #device_trait + 'static)> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn cast_mut(&mut self) -> Option<&mut (dyn #device_trait + 'static)> {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.fold(quote! {}, |acc, x| {
|
||||
quote! {
|
||||
// Not sure if this is the right way to do this
|
||||
#acc
|
||||
#x
|
||||
}
|
||||
});
|
||||
|
||||
let tokens = quote! {
|
||||
#interface
|
||||
#prefix
|
||||
#impls
|
||||
};
|
||||
|
||||
tokens.into()
|
||||
}
|
||||
|
||||
// TODO: Not sure if this makes sense to have?
|
||||
/// This macro ensures that the device traits have the correct trait bounds
|
||||
#[proc_macro_attribute]
|
||||
pub fn device_trait(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let mut interface: ItemTrait = parse_macro_input!(item);
|
||||
|
||||
interface.supertraits.push(TypeParamBound::Verbatim(quote! {
|
||||
::core::marker::Sync + ::core::marker::Send
|
||||
}));
|
||||
|
||||
#[cfg(feature = "debug")]
|
||||
interface.supertraits.push(TypeParamBound::Verbatim(quote! {
|
||||
::std::fmt::Debug
|
||||
}));
|
||||
|
||||
interface.into_token_stream().into()
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
[toolchain]
|
||||
channel = "nightly-2024-12-06"
|
||||
components = ["rustfmt", "clippy", "rust-analyzer"]
|
||||
channel = "nightly-2023-11-15"
|
||||
components = ["rustfmt", "clippy"]
|
||||
profile = "minimal"
|
||||
|
|
67
src/auth.rs
Normal file
67
src/auth.rs
Normal file
|
@ -0,0 +1,67 @@
|
|||
use axum::async_trait;
|
||||
use axum::extract::{FromRef, FromRequestParts};
|
||||
use axum::http::request::Parts;
|
||||
use axum::http::StatusCode;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::{ApiError, ApiErrorJson};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct OpenIDConfig {
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct User {
|
||||
pub preferred_username: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for User
|
||||
where
|
||||
OpenIDConfig: FromRef<S>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
// Get the state
|
||||
let openid = OpenIDConfig::from_ref(state);
|
||||
|
||||
// Create a request to the auth server
|
||||
// TODO: Do some discovery to find the correct url for this instead of assuming
|
||||
let mut req = reqwest::Client::new().get(format!("{}/userinfo", openid.base_url));
|
||||
|
||||
// Add auth header to the request if it exists
|
||||
if let Some(auth) = parts.headers.get(axum::http::header::AUTHORIZATION) {
|
||||
req = req.header(reqwest::header::AUTHORIZATION, auth);
|
||||
}
|
||||
|
||||
// Send the request
|
||||
let res = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.into()))?;
|
||||
|
||||
// If the request is success full the auth token is valid and we are given userinfo
|
||||
let status = res.status();
|
||||
if status.is_success() {
|
||||
let user = res
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.into()))?;
|
||||
|
||||
return Ok(user);
|
||||
} else {
|
||||
let err: ApiErrorJson = res
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.into()))?;
|
||||
|
||||
let err = ApiError::try_from(err)
|
||||
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.into()))?;
|
||||
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
141
src/config.rs
Normal file
141
src/config.rs
Normal file
|
@ -0,0 +1,141 @@
|
|||
use std::fs;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use indexmap::IndexMap;
|
||||
use regex::{Captures, Regex};
|
||||
use rumqttc::{MqttOptions, Transport};
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::auth::OpenIDConfig;
|
||||
use crate::device_manager::DeviceConfigs;
|
||||
use crate::devices::PresenceConfig;
|
||||
use crate::error::{ConfigParseError, MissingEnv};
|
||||
use crate::schedule::Schedule;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
pub openid: OpenIDConfig,
|
||||
#[serde(deserialize_with = "deserialize_mqtt_options")]
|
||||
pub mqtt: MqttOptions,
|
||||
#[serde(default)]
|
||||
pub fullfillment: FullfillmentConfig,
|
||||
pub ntfy: Option<NtfyConfig>,
|
||||
pub presence: PresenceConfig,
|
||||
pub devices: IndexMap<String, DeviceConfigs>,
|
||||
pub schedule: Schedule,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MqttConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub client_name: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
pub tls: bool,
|
||||
}
|
||||
|
||||
impl From<MqttConfig> for MqttOptions {
|
||||
fn from(value: MqttConfig) -> Self {
|
||||
let mut mqtt_options = MqttOptions::new(value.client_name, value.host, value.port);
|
||||
mqtt_options.set_credentials(value.username, value.password);
|
||||
mqtt_options.set_keep_alive(Duration::from_secs(5));
|
||||
|
||||
if value.tls {
|
||||
mqtt_options.set_transport(Transport::tls_with_default_config());
|
||||
}
|
||||
|
||||
mqtt_options
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_mqtt_options<'de, D>(deserializer: D) -> Result<MqttOptions, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Ok(MqttOptions::from(MqttConfig::deserialize(deserializer)?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FullfillmentConfig {
|
||||
#[serde(default = "default_fullfillment_ip")]
|
||||
pub ip: Ipv4Addr,
|
||||
#[serde(default = "default_fullfillment_port")]
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl From<FullfillmentConfig> for SocketAddr {
|
||||
fn from(fullfillment: FullfillmentConfig) -> Self {
|
||||
(fullfillment.ip, fullfillment.port).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FullfillmentConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ip: default_fullfillment_ip(),
|
||||
port: default_fullfillment_port(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_fullfillment_ip() -> Ipv4Addr {
|
||||
[0, 0, 0, 0].into()
|
||||
}
|
||||
|
||||
fn default_fullfillment_port() -> u16 {
|
||||
7878
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NtfyConfig {
|
||||
#[serde(default = "default_ntfy_url")]
|
||||
pub url: String,
|
||||
pub topic: String,
|
||||
}
|
||||
|
||||
fn default_ntfy_url() -> String {
|
||||
"https://ntfy.sh".into()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct InfoConfig {
|
||||
pub name: String,
|
||||
pub room: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MqttDeviceConfig {
|
||||
pub topic: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn parse_file(filename: &str) -> Result<Self, ConfigParseError> {
|
||||
debug!("Loading config: {filename}");
|
||||
let file = fs::read_to_string(filename)?;
|
||||
|
||||
// Substitute in environment variables
|
||||
let re = Regex::new(r"\$\{(.*)\}").expect("Regex should be valid");
|
||||
let mut missing = MissingEnv::new();
|
||||
let file = re.replace_all(&file, |caps: &Captures| {
|
||||
let key = caps.get(1).expect("Capture group should exist").as_str();
|
||||
debug!("Substituting '{key}' in config");
|
||||
match std::env::var(key) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
missing.add_missing(key);
|
||||
"".into()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
missing.has_missing()?;
|
||||
|
||||
let config: Config = serde_yaml::from_str(&file)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
}
|
263
src/device_manager.rs
Normal file
263
src/device_manager.rs
Normal file
|
@ -0,0 +1,263 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use enum_dispatch::enum_dispatch;
|
||||
use futures::future::join_all;
|
||||
use google_home::traits::OnOff;
|
||||
use rumqttc::{matches, AsyncClient, QoS};
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::{RwLock, RwLockReadGuard};
|
||||
use tokio_cron_scheduler::{Job, JobScheduler};
|
||||
use tracing::{debug, error, instrument, trace};
|
||||
|
||||
use crate::devices::{
|
||||
AirFilterConfig, As, AudioSetupConfig, ContactSensorConfig, DebugBridgeConfig, Device,
|
||||
HueBridgeConfig, HueGroupConfig, IkeaOutletConfig, KasaOutletConfig, LightSensorConfig,
|
||||
WakeOnLANConfig, WasherConfig,
|
||||
};
|
||||
use crate::error::DeviceConfigError;
|
||||
use crate::event::{Event, EventChannel, OnDarkness, OnMqtt, OnNotification, OnPresence};
|
||||
use crate::schedule::{Action, Schedule};
|
||||
|
||||
pub struct ConfigExternal<'a> {
|
||||
pub client: &'a AsyncClient,
|
||||
pub device_manager: &'a DeviceManager,
|
||||
pub event_channel: &'a EventChannel,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
#[enum_dispatch]
|
||||
pub trait DeviceConfig {
|
||||
async fn create(
|
||||
self,
|
||||
identifier: &str,
|
||||
ext: &ConfigExternal,
|
||||
) -> Result<Box<dyn Device>, DeviceConfigError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[enum_dispatch(DeviceConfig)]
|
||||
pub enum DeviceConfigs {
|
||||
AirFilter(AirFilterConfig),
|
||||
AudioSetup(AudioSetupConfig),
|
||||
ContactSensor(ContactSensorConfig),
|
||||
DebugBridge(DebugBridgeConfig),
|
||||
IkeaOutlet(IkeaOutletConfig),
|
||||
KasaOutlet(KasaOutletConfig),
|
||||
WakeOnLAN(WakeOnLANConfig),
|
||||
Washer(WasherConfig),
|
||||
HueBridge(HueBridgeConfig),
|
||||
HueGroup(HueGroupConfig),
|
||||
LightSensor(LightSensorConfig),
|
||||
}
|
||||
|
||||
pub type WrappedDevice = Arc<RwLock<Box<dyn Device>>>;
|
||||
pub type DeviceMap = HashMap<String, WrappedDevice>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeviceManager {
|
||||
devices: Arc<RwLock<DeviceMap>>,
|
||||
client: AsyncClient,
|
||||
event_channel: EventChannel,
|
||||
}
|
||||
|
||||
impl DeviceManager {
|
||||
pub fn new(client: AsyncClient) -> Self {
|
||||
let (event_channel, mut event_rx) = EventChannel::new();
|
||||
|
||||
let device_manager = Self {
|
||||
devices: Arc::new(RwLock::new(HashMap::new())),
|
||||
client,
|
||||
event_channel,
|
||||
};
|
||||
|
||||
tokio::spawn({
|
||||
let device_manager = device_manager.clone();
|
||||
async move {
|
||||
loop {
|
||||
if let Some(event) = event_rx.recv().await {
|
||||
device_manager.handle_event(event).await;
|
||||
} else {
|
||||
todo!("Handle errors with the event channel properly")
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
device_manager
|
||||
}
|
||||
|
||||
// TODO: This function is currently extremely cursed...
|
||||
pub async fn add_schedule(&self, schedule: Schedule) {
|
||||
let sched = JobScheduler::new().await.unwrap();
|
||||
|
||||
for (when, actions) in schedule {
|
||||
let manager = self.clone();
|
||||
sched
|
||||
.add(
|
||||
Job::new_async(when.as_str(), move |_uuid, _l| {
|
||||
let actions = actions.clone();
|
||||
let manager = manager.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
for (action, targets) in actions {
|
||||
for target in targets {
|
||||
let device = manager.get(&target).await.unwrap();
|
||||
match action {
|
||||
Action::On => {
|
||||
As::<dyn OnOff>::cast_mut(
|
||||
device.write().await.as_mut(),
|
||||
)
|
||||
.unwrap()
|
||||
.set_on(true)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
Action::Off => {
|
||||
As::<dyn OnOff>::cast_mut(
|
||||
device.write().await.as_mut(),
|
||||
)
|
||||
.unwrap()
|
||||
.set_on(false)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
sched.start().await.unwrap();
|
||||
}
|
||||
|
||||
pub async fn add(&self, device: Box<dyn Device>) {
|
||||
let id = device.get_id().into();
|
||||
|
||||
debug!(id, "Adding device");
|
||||
|
||||
// If the device listens to mqtt, subscribe to the topics
|
||||
if let Some(device) = As::<dyn OnMqtt>::cast(device.as_ref()) {
|
||||
for topic in device.topics() {
|
||||
trace!(id, topic, "Subscribing to topic");
|
||||
if let Err(err) = self.client.subscribe(topic, QoS::AtLeastOnce).await {
|
||||
// NOTE: Pretty sure that this can only happen if the mqtt client if no longer
|
||||
// running
|
||||
error!(id, topic, "Failed to subscribe to topic: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap the device
|
||||
let device = Arc::new(RwLock::new(device));
|
||||
|
||||
self.devices.write().await.insert(id, device);
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
&self,
|
||||
identifier: &str,
|
||||
device_config: DeviceConfigs,
|
||||
) -> Result<(), DeviceConfigError> {
|
||||
let ext = ConfigExternal {
|
||||
client: &self.client,
|
||||
device_manager: self,
|
||||
event_channel: &self.event_channel,
|
||||
};
|
||||
|
||||
let device = device_config.create(identifier, &ext).await?;
|
||||
|
||||
self.add(device).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn event_channel(&self) -> EventChannel {
|
||||
self.event_channel.clone()
|
||||
}
|
||||
|
||||
pub async fn get(&self, name: &str) -> Option<WrappedDevice> {
|
||||
self.devices.read().await.get(name).cloned()
|
||||
}
|
||||
|
||||
pub async fn devices(&self) -> RwLockReadGuard<DeviceMap> {
|
||||
self.devices.read().await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn handle_event(&self, event: Event) {
|
||||
match event {
|
||||
Event::MqttMessage(message) => {
|
||||
let devices = self.devices.read().await;
|
||||
let iter = devices.iter().map(|(id, device)| {
|
||||
let message = message.clone();
|
||||
async move {
|
||||
let mut device = device.write().await;
|
||||
let device = device.as_mut();
|
||||
if let Some(device) = As::<dyn OnMqtt>::cast_mut(device) {
|
||||
let subscribed = device
|
||||
.topics()
|
||||
.iter()
|
||||
.any(|topic| matches(&message.topic, topic));
|
||||
|
||||
if subscribed {
|
||||
trace!(id, "Handling");
|
||||
device.on_mqtt(message).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
join_all(iter).await;
|
||||
}
|
||||
Event::Darkness(dark) => {
|
||||
let devices = self.devices.read().await;
|
||||
let iter = devices.iter().map(|(id, device)| async move {
|
||||
let mut device = device.write().await;
|
||||
let device = device.as_mut();
|
||||
if let Some(device) = As::<dyn OnDarkness>::cast_mut(device) {
|
||||
trace!(id, "Handling");
|
||||
device.on_darkness(dark).await;
|
||||
}
|
||||
});
|
||||
|
||||
join_all(iter).await;
|
||||
}
|
||||
Event::Presence(presence) => {
|
||||
let devices = self.devices.read().await;
|
||||
let iter = devices.iter().map(|(id, device)| async move {
|
||||
let mut device = device.write().await;
|
||||
let device = device.as_mut();
|
||||
if let Some(device) = As::<dyn OnPresence>::cast_mut(device) {
|
||||
trace!(id, "Handling");
|
||||
device.on_presence(presence).await;
|
||||
}
|
||||
});
|
||||
|
||||
join_all(iter).await;
|
||||
}
|
||||
Event::Ntfy(notification) => {
|
||||
let devices = self.devices.read().await;
|
||||
let iter = devices.iter().map(|(id, device)| {
|
||||
let notification = notification.clone();
|
||||
async move {
|
||||
let mut device = device.write().await;
|
||||
let device = device.as_mut();
|
||||
if let Some(device) = As::<dyn OnNotification>::cast_mut(device) {
|
||||
trace!(id, "Handling");
|
||||
device.on_notification(notification).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
join_all(iter).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
216
src/devices/air_filter.rs
Normal file
216
src/devices/air_filter.rs
Normal file
|
@ -0,0 +1,216 @@
|
|||
use async_trait::async_trait;
|
||||
use google_home::device::Name;
|
||||
use google_home::errors::ErrorCode;
|
||||
use google_home::traits::{AvailableSpeeds, FanSpeed, OnOff, Speed, SpeedValues};
|
||||
use google_home::types::Type;
|
||||
use google_home::GoogleHomeDevice;
|
||||
use rumqttc::{AsyncClient, Publish};
|
||||
use serde::Deserialize;
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use crate::config::{InfoConfig, MqttDeviceConfig};
|
||||
use crate::device_manager::{ConfigExternal, DeviceConfig};
|
||||
use crate::devices::Device;
|
||||
use crate::error::DeviceConfigError;
|
||||
use crate::event::OnMqtt;
|
||||
use crate::messages::{AirFilterMessage, AirFilterState};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AirFilterConfig {
|
||||
#[serde(flatten)]
|
||||
info: InfoConfig,
|
||||
#[serde(flatten)]
|
||||
mqtt: MqttDeviceConfig,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeviceConfig for AirFilterConfig {
|
||||
async fn create(
|
||||
self,
|
||||
identifier: &str,
|
||||
ext: &ConfigExternal,
|
||||
) -> Result<Box<dyn Device>, DeviceConfigError> {
|
||||
let device = AirFilter {
|
||||
identifier: identifier.into(),
|
||||
info: self.info,
|
||||
mqtt: self.mqtt,
|
||||
client: ext.client.clone(),
|
||||
last_known_state: AirFilterState::Off,
|
||||
};
|
||||
|
||||
Ok(Box::new(device))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AirFilter {
|
||||
identifier: String,
|
||||
info: InfoConfig,
|
||||
mqtt: MqttDeviceConfig,
|
||||
|
||||
client: AsyncClient,
|
||||
last_known_state: AirFilterState,
|
||||
}
|
||||
|
||||
impl AirFilter {
|
||||
async fn set_speed(&self, state: AirFilterState) {
|
||||
let message = AirFilterMessage::new(state);
|
||||
|
||||
let topic = format!("{}/set", self.mqtt.topic);
|
||||
// TODO: Handle potential errors here
|
||||
self.client
|
||||
.publish(
|
||||
topic.clone(),
|
||||
rumqttc::QoS::AtLeastOnce,
|
||||
false,
|
||||
serde_json::to_string(&message).unwrap(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| warn!("Failed to update state on {topic}: {err}"))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for AirFilter {
|
||||
fn get_id(&self) -> &str {
|
||||
&self.identifier
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnMqtt for AirFilter {
|
||||
fn topics(&self) -> Vec<&str> {
|
||||
vec![&self.mqtt.topic]
|
||||
}
|
||||
|
||||
async fn on_mqtt(&mut self, message: Publish) {
|
||||
let state = match AirFilterMessage::try_from(message) {
|
||||
Ok(state) => state.state(),
|
||||
Err(err) => {
|
||||
error!(id = self.identifier, "Failed to parse message: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if state == self.last_known_state {
|
||||
return;
|
||||
}
|
||||
|
||||
debug!(id = self.identifier, "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.info.name)
|
||||
}
|
||||
|
||||
fn get_id(&self) -> &str {
|
||||
Device::get_id(self)
|
||||
}
|
||||
|
||||
fn is_online(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn get_room_hint(&self) -> Option<&str> {
|
||||
self.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 != AirFilterState::Off)
|
||||
}
|
||||
|
||||
async fn set_on(&mut self, on: bool) -> Result<(), ErrorCode> {
|
||||
debug!("Turning on air filter: {on}");
|
||||
|
||||
if on {
|
||||
self.set_speed(AirFilterState::High).await;
|
||||
} else {
|
||||
self.set_speed(AirFilterState::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 {
|
||||
AirFilterState::Off => "off",
|
||||
AirFilterState::Low => "low",
|
||||
AirFilterState::Medium => "medium",
|
||||
AirFilterState::High => "high",
|
||||
};
|
||||
|
||||
speed.into()
|
||||
}
|
||||
|
||||
async fn set_speed(&self, speed: &str) -> Result<(), ErrorCode> {
|
||||
let state = if speed == "off" {
|
||||
AirFilterState::Off
|
||||
} else if speed == "low" {
|
||||
AirFilterState::Low
|
||||
} else if speed == "medium" {
|
||||
AirFilterState::Medium
|
||||
} else if speed == "high" {
|
||||
AirFilterState::High
|
||||
} else {
|
||||
return Err(google_home::errors::DeviceError::TransientError.into());
|
||||
};
|
||||
|
||||
self.set_speed(state).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
153
src/devices/audio_setup.rs
Normal file
153
src/devices/audio_setup.rs
Normal file
|
@ -0,0 +1,153 @@
|
|||
use async_trait::async_trait;
|
||||
use google_home::traits::OnOff;
|
||||
use serde::Deserialize;
|
||||
use tracing::{debug, error, trace, warn};
|
||||
|
||||
use super::Device;
|
||||
use crate::config::MqttDeviceConfig;
|
||||
use crate::device_manager::{ConfigExternal, DeviceConfig, WrappedDevice};
|
||||
use crate::devices::As;
|
||||
use crate::error::DeviceConfigError;
|
||||
use crate::event::{OnMqtt, OnPresence};
|
||||
use crate::messages::{RemoteAction, RemoteMessage};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AudioSetupConfig {
|
||||
#[serde(flatten)]
|
||||
mqtt: MqttDeviceConfig,
|
||||
mixer: String,
|
||||
speakers: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeviceConfig for AudioSetupConfig {
|
||||
async fn create(
|
||||
self,
|
||||
identifier: &str,
|
||||
ext: &ConfigExternal,
|
||||
) -> Result<Box<dyn Device>, DeviceConfigError> {
|
||||
trace!(id = identifier, "Setting up AudioSetup");
|
||||
|
||||
// TODO: Make sure they implement OnOff?
|
||||
let mixer = ext
|
||||
.device_manager
|
||||
.get(&self.mixer)
|
||||
.await
|
||||
// NOTE: We need to clone to make the compiler happy, how ever if this clone happens the next one can never happen...
|
||||
.ok_or(DeviceConfigError::MissingChild(
|
||||
identifier.into(),
|
||||
self.mixer.clone(),
|
||||
))?;
|
||||
|
||||
if !As::<dyn OnOff>::is(mixer.read().await.as_ref()) {
|
||||
return Err(DeviceConfigError::MissingTrait(self.mixer, "OnOff".into()));
|
||||
}
|
||||
|
||||
let speakers =
|
||||
ext.device_manager
|
||||
.get(&self.speakers)
|
||||
.await
|
||||
.ok_or(DeviceConfigError::MissingChild(
|
||||
identifier.into(),
|
||||
self.speakers.clone(),
|
||||
))?;
|
||||
|
||||
if !As::<dyn OnOff>::is(speakers.read().await.as_ref()) {
|
||||
return Err(DeviceConfigError::MissingTrait(
|
||||
self.speakers,
|
||||
"OnOff".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let device = AudioSetup {
|
||||
identifier: identifier.into(),
|
||||
mqtt: self.mqtt,
|
||||
mixer,
|
||||
speakers,
|
||||
};
|
||||
|
||||
Ok(Box::new(device))
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: We need a better way to store the children devices
|
||||
#[derive(Debug)]
|
||||
struct AudioSetup {
|
||||
identifier: String,
|
||||
mqtt: MqttDeviceConfig,
|
||||
mixer: WrappedDevice,
|
||||
speakers: WrappedDevice,
|
||||
}
|
||||
|
||||
impl Device for AudioSetup {
|
||||
fn get_id(&self) -> &str {
|
||||
&self.identifier
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnMqtt for AudioSetup {
|
||||
fn topics(&self) -> Vec<&str> {
|
||||
vec![&self.mqtt.topic]
|
||||
}
|
||||
|
||||
async fn on_mqtt(&mut self, message: rumqttc::Publish) {
|
||||
let action = match RemoteMessage::try_from(message) {
|
||||
Ok(message) => message.action(),
|
||||
Err(err) => {
|
||||
error!(id = self.identifier, "Failed to parse message: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut mixer = self.mixer.write().await;
|
||||
let mut speakers = self.speakers.write().await;
|
||||
if let (Some(mixer), Some(speakers)) = (
|
||||
As::<dyn OnOff>::cast_mut(mixer.as_mut()),
|
||||
As::<dyn OnOff>::cast_mut(speakers.as_mut()),
|
||||
) {
|
||||
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.mixer.write().await;
|
||||
let mut speakers = self.speakers.write().await;
|
||||
|
||||
if let (Some(mixer), Some(speakers)) = (
|
||||
As::<dyn OnOff>::cast_mut(mixer.as_mut()),
|
||||
As::<dyn OnOff>::cast_mut(speakers.as_mut()),
|
||||
) {
|
||||
// Turn off the audio setup when we leave the house
|
||||
if !presence {
|
||||
debug!(id = self.identifier, "Turning devices off");
|
||||
speakers.set_on(false).await.unwrap();
|
||||
mixer.set_on(false).await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
244
src/devices/contact_sensor.rs
Normal file
244
src/devices/contact_sensor.rs
Normal file
|
@ -0,0 +1,244 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use google_home::traits::OnOff;
|
||||
use rumqttc::AsyncClient;
|
||||
use serde::Deserialize;
|
||||
use serde_with::{serde_as, DurationSeconds};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{debug, error, trace, warn};
|
||||
|
||||
use super::Device;
|
||||
use crate::config::MqttDeviceConfig;
|
||||
use crate::device_manager::{ConfigExternal, DeviceConfig, WrappedDevice};
|
||||
use crate::devices::{As, DEFAULT_PRESENCE};
|
||||
use crate::error::DeviceConfigError;
|
||||
use crate::event::{OnMqtt, OnPresence};
|
||||
use crate::messages::{ContactMessage, PresenceMessage};
|
||||
use crate::traits::Timeout;
|
||||
|
||||
// NOTE: If we add more presence devices we might need to move this out of here
|
||||
#[serde_as]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct PresenceDeviceConfig {
|
||||
#[serde(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
#[serde_as(as = "DurationSeconds")]
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TriggerConfig {
|
||||
devices: Vec<String>,
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "DurationSeconds")]
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ContactSensorConfig {
|
||||
#[serde(flatten)]
|
||||
mqtt: MqttDeviceConfig,
|
||||
presence: Option<PresenceDeviceConfig>,
|
||||
trigger: Option<TriggerConfig>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeviceConfig for ContactSensorConfig {
|
||||
async fn create(
|
||||
self,
|
||||
identifier: &str,
|
||||
ext: &ConfigExternal,
|
||||
) -> Result<Box<dyn Device>, DeviceConfigError> {
|
||||
trace!(id = identifier, "Setting up ContactSensor");
|
||||
|
||||
let trigger = if let Some(trigger_config) = &self.trigger {
|
||||
let mut devices = Vec::new();
|
||||
for device_name in &trigger_config.devices {
|
||||
let device = ext.device_manager.get(device_name).await.ok_or(
|
||||
DeviceConfigError::MissingChild(device_name.into(), "OnOff".into()),
|
||||
)?;
|
||||
|
||||
if !As::<dyn OnOff>::is(device.read().await.as_ref()) {
|
||||
return Err(DeviceConfigError::MissingTrait(
|
||||
device_name.into(),
|
||||
"OnOff".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if !trigger_config.timeout.is_zero()
|
||||
&& !As::<dyn Timeout>::is(device.read().await.as_ref())
|
||||
{
|
||||
return Err(DeviceConfigError::MissingTrait(
|
||||
device_name.into(),
|
||||
"Timeout".into(),
|
||||
));
|
||||
}
|
||||
|
||||
devices.push((device, false));
|
||||
}
|
||||
|
||||
Some(Trigger {
|
||||
devices,
|
||||
timeout: trigger_config.timeout,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let device = ContactSensor {
|
||||
identifier: identifier.into(),
|
||||
mqtt: self.mqtt,
|
||||
presence: self.presence,
|
||||
client: ext.client.clone(),
|
||||
overall_presence: DEFAULT_PRESENCE,
|
||||
is_closed: true,
|
||||
handle: None,
|
||||
trigger,
|
||||
};
|
||||
|
||||
Ok(Box::new(device))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Trigger {
|
||||
devices: Vec<(WrappedDevice, bool)>,
|
||||
timeout: Duration, // Timeout in seconds
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ContactSensor {
|
||||
identifier: String,
|
||||
mqtt: MqttDeviceConfig,
|
||||
presence: Option<PresenceDeviceConfig>,
|
||||
|
||||
client: AsyncClient,
|
||||
overall_presence: bool,
|
||||
is_closed: bool,
|
||||
handle: Option<JoinHandle<()>>,
|
||||
|
||||
trigger: Option<Trigger>,
|
||||
}
|
||||
|
||||
impl Device for ContactSensor {
|
||||
fn get_id(&self) -> &str {
|
||||
&self.identifier
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnPresence for ContactSensor {
|
||||
async fn on_presence(&mut self, presence: bool) {
|
||||
self.overall_presence = presence;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnMqtt for ContactSensor {
|
||||
fn topics(&self) -> Vec<&str> {
|
||||
vec![&self.mqtt.topic]
|
||||
}
|
||||
|
||||
async fn on_mqtt(&mut self, message: rumqttc::Publish) {
|
||||
let is_closed = match ContactMessage::try_from(message) {
|
||||
Ok(state) => state.is_closed(),
|
||||
Err(err) => {
|
||||
error!(id = self.identifier, "Failed to parse message: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if is_closed == self.is_closed {
|
||||
return;
|
||||
}
|
||||
|
||||
debug!(id = self.identifier, "Updating state to {is_closed}");
|
||||
self.is_closed = is_closed;
|
||||
|
||||
if let Some(trigger) = &mut self.trigger {
|
||||
if !self.is_closed {
|
||||
for (light, previous) in &mut trigger.devices {
|
||||
let mut light = light.write().await;
|
||||
if let Some(light) = As::<dyn OnOff>::cast_mut(light.as_mut()) {
|
||||
*previous = light.is_on().await.unwrap();
|
||||
// Only turn the light on when it is currently off
|
||||
// This is done such that if the light is on but dimmed for example it
|
||||
// won't suddenly blast at full brightness but instead retain the current
|
||||
// state
|
||||
if !*previous {
|
||||
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_zero()
|
||||
&& let Some(light) = As::<dyn OnOff>::cast_mut(light.as_mut())
|
||||
{
|
||||
light.set_on(false).await.ok();
|
||||
} else if let Some(light) = As::<dyn Timeout>::cast_mut(light.as_mut()) {
|
||||
light.start_timeout(trigger.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.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.client
|
||||
.publish(
|
||||
presence.mqtt.topic.clone(),
|
||||
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.client.clone();
|
||||
let id = self.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.clone(), rumqttc::QoS::AtLeastOnce, false, "")
|
||||
.await
|
||||
.map_err(|err| warn!("Failed to publish presence on {topic}: {err}"))
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
93
src/devices/debug_bridge.rs
Normal file
93
src/devices/debug_bridge.rs
Normal file
|
@ -0,0 +1,93 @@
|
|||
use async_trait::async_trait;
|
||||
use rumqttc::AsyncClient;
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::MqttDeviceConfig;
|
||||
use crate::device_manager::{ConfigExternal, DeviceConfig};
|
||||
use crate::devices::Device;
|
||||
use crate::error::DeviceConfigError;
|
||||
use crate::event::{OnDarkness, OnPresence};
|
||||
use crate::messages::{DarknessMessage, PresenceMessage};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DebugBridgeConfig {
|
||||
#[serde(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeviceConfig for DebugBridgeConfig {
|
||||
async fn create(
|
||||
self,
|
||||
identifier: &str,
|
||||
ext: &ConfigExternal,
|
||||
) -> Result<Box<dyn Device>, DeviceConfigError> {
|
||||
let device = DebugBridge {
|
||||
identifier: identifier.into(),
|
||||
mqtt: self.mqtt,
|
||||
client: ext.client.clone(),
|
||||
};
|
||||
|
||||
Ok(Box::new(device))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DebugBridge {
|
||||
identifier: String,
|
||||
mqtt: MqttDeviceConfig,
|
||||
client: AsyncClient,
|
||||
}
|
||||
|
||||
impl Device for DebugBridge {
|
||||
fn get_id(&self) -> &str {
|
||||
&self.identifier
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnPresence for DebugBridge {
|
||||
async fn on_presence(&mut self, presence: bool) {
|
||||
let message = PresenceMessage::new(presence);
|
||||
let topic = format!("{}/presence", self.mqtt.topic);
|
||||
self.client
|
||||
.publish(
|
||||
topic,
|
||||
rumqttc::QoS::AtLeastOnce,
|
||||
true,
|
||||
serde_json::to_string(&message).expect("Serialization should not fail"),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(
|
||||
"Failed to update presence on {}/presence: {err}",
|
||||
self.mqtt.topic
|
||||
)
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnDarkness for DebugBridge {
|
||||
async fn on_darkness(&mut self, dark: bool) {
|
||||
let message = DarknessMessage::new(dark);
|
||||
let topic = format!("{}/darkness", self.mqtt.topic);
|
||||
self.client
|
||||
.publish(
|
||||
topic,
|
||||
rumqttc::QoS::AtLeastOnce,
|
||||
true,
|
||||
serde_json::to_string(&message).unwrap(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(
|
||||
"Failed to update presence on {}/presence: {err}",
|
||||
self.mqtt.topic
|
||||
)
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
|
@ -1,13 +1,14 @@
|
|||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||
use automation_lib::event::{OnDarkness, OnPresence};
|
||||
use automation_macro::LuaDeviceConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, trace, warn};
|
||||
|
||||
use crate::device_manager::{ConfigExternal, DeviceConfig};
|
||||
use crate::devices::Device;
|
||||
use crate::error::DeviceConfigError;
|
||||
use crate::event::{OnDarkness, OnPresence};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Flag {
|
||||
Presence,
|
||||
|
@ -16,22 +17,41 @@ pub enum Flag {
|
|||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct FlagIDs {
|
||||
presence: isize,
|
||||
darkness: isize,
|
||||
pub presence: isize,
|
||||
pub darkness: isize,
|
||||
}
|
||||
|
||||
#[derive(Debug, LuaDeviceConfig, Clone)]
|
||||
pub struct Config {
|
||||
pub identifier: String,
|
||||
#[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 80)))]
|
||||
pub addr: SocketAddr,
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct HueBridgeConfig {
|
||||
pub ip: Ipv4Addr,
|
||||
pub login: String,
|
||||
pub flags: FlagIDs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HueBridge {
|
||||
config: Config,
|
||||
#[async_trait]
|
||||
impl DeviceConfig for HueBridgeConfig {
|
||||
async fn create(
|
||||
self,
|
||||
identifier: &str,
|
||||
_ext: &ConfigExternal,
|
||||
) -> Result<Box<dyn Device>, DeviceConfigError> {
|
||||
let device = HueBridge {
|
||||
identifier: identifier.into(),
|
||||
addr: (self.ip, 80).into(),
|
||||
login: self.login,
|
||||
flag_ids: self.flags,
|
||||
};
|
||||
|
||||
Ok(Box::new(device))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HueBridge {
|
||||
identifier: String,
|
||||
addr: SocketAddr,
|
||||
login: String,
|
||||
flag_ids: FlagIDs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
@ -39,27 +59,16 @@ struct FlagMessage {
|
|||
flag: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for HueBridge {
|
||||
type Config = Config;
|
||||
type Error = Infallible;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Infallible> {
|
||||
trace!(id = config.identifier, "Setting up HueBridge");
|
||||
Ok(Self { config })
|
||||
}
|
||||
}
|
||||
|
||||
impl HueBridge {
|
||||
pub async fn set_flag(&self, flag: Flag, value: bool) {
|
||||
let flag_id = match flag {
|
||||
Flag::Presence => self.config.flags.presence,
|
||||
Flag::Darkness => self.config.flags.darkness,
|
||||
Flag::Presence => self.flag_ids.presence,
|
||||
Flag::Darkness => self.flag_ids.darkness,
|
||||
};
|
||||
|
||||
let url = format!(
|
||||
"http://{}/api/{}/sensors/{flag_id}/state",
|
||||
self.config.addr, self.config.login
|
||||
self.addr, self.login
|
||||
);
|
||||
|
||||
trace!(?flag, flag_id, value, "Sending request to change flag");
|
||||
|
@ -84,14 +93,14 @@ impl HueBridge {
|
|||
}
|
||||
|
||||
impl Device for HueBridge {
|
||||
fn get_id(&self) -> String {
|
||||
self.config.identifier.clone()
|
||||
fn get_id(&self) -> &str {
|
||||
&self.identifier
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnPresence for HueBridge {
|
||||
async fn on_presence(&self, presence: bool) {
|
||||
async fn on_presence(&mut self, presence: bool) {
|
||||
trace!("Bridging presence to hue");
|
||||
self.set_flag(Flag::Presence, presence).await;
|
||||
}
|
||||
|
@ -99,7 +108,7 @@ impl OnPresence for HueBridge {
|
|||
|
||||
#[async_trait]
|
||||
impl OnDarkness for HueBridge {
|
||||
async fn on_darkness(&self, dark: bool) {
|
||||
async fn on_darkness(&mut self, dark: bool) {
|
||||
trace!("Bridging darkness to hue");
|
||||
self.set_flag(Flag::Darkness, dark).await;
|
||||
}
|
312
src/devices/hue_light.rs
Normal file
312
src/devices/hue_light.rs
Normal file
|
@ -0,0 +1,312 @@
|
|||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use google_home::errors::ErrorCode;
|
||||
use google_home::traits::OnOff;
|
||||
use rumqttc::Publish;
|
||||
use serde::Deserialize;
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use super::Device;
|
||||
use crate::config::MqttDeviceConfig;
|
||||
use crate::device_manager::{ConfigExternal, DeviceConfig};
|
||||
use crate::error::DeviceConfigError;
|
||||
use crate::event::OnMqtt;
|
||||
use crate::messages::{RemoteAction, RemoteMessage};
|
||||
use crate::traits::Timeout;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct HueGroupConfig {
|
||||
pub ip: Ipv4Addr,
|
||||
pub login: String,
|
||||
pub group_id: isize,
|
||||
pub timer_id: isize,
|
||||
pub scene_id: String,
|
||||
#[serde(default)]
|
||||
pub remotes: Vec<MqttDeviceConfig>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeviceConfig for HueGroupConfig {
|
||||
async fn create(
|
||||
self,
|
||||
identifier: &str,
|
||||
_ext: &ConfigExternal,
|
||||
) -> Result<Box<dyn Device>, DeviceConfigError> {
|
||||
let device = HueGroup {
|
||||
identifier: identifier.into(),
|
||||
addr: (self.ip, 80).into(),
|
||||
login: self.login,
|
||||
group_id: self.group_id,
|
||||
scene_id: self.scene_id,
|
||||
timer_id: self.timer_id,
|
||||
remotes: self.remotes,
|
||||
};
|
||||
|
||||
Ok(Box::new(device))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HueGroup {
|
||||
identifier: String,
|
||||
addr: SocketAddr,
|
||||
login: String,
|
||||
group_id: isize,
|
||||
timer_id: isize,
|
||||
scene_id: String,
|
||||
remotes: Vec<MqttDeviceConfig>,
|
||||
}
|
||||
|
||||
// Couple of helper function to get the correct urls
|
||||
impl HueGroup {
|
||||
fn url_base(&self) -> String {
|
||||
format!("http://{}/api/{}", self.addr, self.login)
|
||||
}
|
||||
|
||||
fn url_set_schedule(&self) -> String {
|
||||
format!("{}/schedules/{}", self.url_base(), self.timer_id)
|
||||
}
|
||||
|
||||
fn url_set_action(&self) -> String {
|
||||
format!("{}/groups/{}/action", self.url_base(), self.group_id)
|
||||
}
|
||||
|
||||
fn url_get_state(&self) -> String {
|
||||
format!("{}/groups/{}", self.url_base(), self.group_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for HueGroup {
|
||||
fn get_id(&self) -> &str {
|
||||
&self.identifier
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnMqtt for HueGroup {
|
||||
fn topics(&self) -> Vec<&str> {
|
||||
self.remotes
|
||||
.iter()
|
||||
.map(|mqtt| mqtt.topic.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn on_mqtt(&mut self, message: Publish) {
|
||||
let action = match RemoteMessage::try_from(message) {
|
||||
Ok(message) => message.action(),
|
||||
Err(err) => {
|
||||
error!(id = self.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.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.identifier, "Status code is not success: {status}");
|
||||
}
|
||||
}
|
||||
Err(err) => error!(id = self.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.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.identifier, "Failed to parse message: {err}");
|
||||
// TODO: Error code
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
return Ok(on);
|
||||
}
|
||||
Err(err) => error!(id = self.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()
|
||||
}
|
||||
}
|
||||
}
|
265
src/devices/ikea_outlet.rs
Normal file
265
src/devices/ikea_outlet.rs
Normal file
|
@ -0,0 +1,265 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use google_home::errors::ErrorCode;
|
||||
use google_home::traits::{self, OnOff};
|
||||
use google_home::types::Type;
|
||||
use google_home::{device, GoogleHomeDevice};
|
||||
use rumqttc::{matches, AsyncClient, Publish};
|
||||
use serde::Deserialize;
|
||||
use serde_with::{serde_as, DurationSeconds};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{debug, error, trace, warn};
|
||||
|
||||
use crate::config::{InfoConfig, MqttDeviceConfig};
|
||||
use crate::device_manager::{ConfigExternal, DeviceConfig};
|
||||
use crate::devices::Device;
|
||||
use crate::error::DeviceConfigError;
|
||||
use crate::event::{OnMqtt, OnPresence};
|
||||
use crate::messages::{OnOffMessage, RemoteAction, RemoteMessage};
|
||||
use crate::traits::Timeout;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Copy)]
|
||||
pub enum OutletType {
|
||||
Outlet,
|
||||
Kettle,
|
||||
Charger,
|
||||
Light,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct IkeaOutletConfig {
|
||||
#[serde(flatten)]
|
||||
info: InfoConfig,
|
||||
#[serde(flatten)]
|
||||
mqtt: MqttDeviceConfig,
|
||||
#[serde(default = "default_outlet_type")]
|
||||
outlet_type: OutletType,
|
||||
#[serde_as(as = "Option<DurationSeconds>")]
|
||||
timeout: Option<Duration>, // Timeout in seconds
|
||||
#[serde(default)]
|
||||
pub remotes: Vec<MqttDeviceConfig>,
|
||||
}
|
||||
|
||||
fn default_outlet_type() -> OutletType {
|
||||
OutletType::Outlet
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeviceConfig for IkeaOutletConfig {
|
||||
async fn create(
|
||||
self,
|
||||
identifier: &str,
|
||||
ext: &ConfigExternal,
|
||||
) -> Result<Box<dyn Device>, DeviceConfigError> {
|
||||
trace!(
|
||||
id = identifier,
|
||||
name = self.info.name,
|
||||
room = self.info.room,
|
||||
"Setting up IkeaOutlet"
|
||||
);
|
||||
|
||||
let device = IkeaOutlet {
|
||||
identifier: identifier.into(),
|
||||
info: self.info,
|
||||
mqtt: self.mqtt,
|
||||
outlet_type: self.outlet_type,
|
||||
timeout: self.timeout,
|
||||
remotes: self.remotes,
|
||||
client: ext.client.clone(),
|
||||
last_known_state: false,
|
||||
handle: None,
|
||||
};
|
||||
|
||||
Ok(Box::new(device))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IkeaOutlet {
|
||||
identifier: String,
|
||||
info: InfoConfig,
|
||||
mqtt: MqttDeviceConfig,
|
||||
outlet_type: OutletType,
|
||||
timeout: Option<Duration>,
|
||||
remotes: Vec<MqttDeviceConfig>,
|
||||
|
||||
client: AsyncClient,
|
||||
last_known_state: bool,
|
||||
handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
async fn set_on(client: AsyncClient, topic: &str, on: bool) {
|
||||
let message = OnOffMessage::new(on);
|
||||
|
||||
let topic = format!("{}/set", topic);
|
||||
// TODO: Handle potential errors here
|
||||
client
|
||||
.publish(
|
||||
topic.clone(),
|
||||
rumqttc::QoS::AtLeastOnce,
|
||||
false,
|
||||
serde_json::to_string(&message).unwrap(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| warn!("Failed to update state on {topic}: {err}"))
|
||||
.ok();
|
||||
}
|
||||
|
||||
impl Device for IkeaOutlet {
|
||||
fn get_id(&self) -> &str {
|
||||
&self.identifier
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnMqtt for IkeaOutlet {
|
||||
fn topics(&self) -> Vec<&str> {
|
||||
let mut topics: Vec<_> = self
|
||||
.remotes
|
||||
.iter()
|
||||
.map(|mqtt| mqtt.topic.as_str())
|
||||
.collect();
|
||||
|
||||
topics.push(&self.mqtt.topic);
|
||||
|
||||
topics
|
||||
}
|
||||
|
||||
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.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 = self.identifier, "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 = self.identifier, "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.timeout {
|
||||
self.start_timeout(timeout).await.unwrap();
|
||||
}
|
||||
} else {
|
||||
let action = match RemoteMessage::try_from(message) {
|
||||
Ok(message) => message.action(),
|
||||
Err(err) => {
|
||||
error!(id = self.identifier, "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.outlet_type != OutletType::Charger {
|
||||
debug!(id = self.identifier, "Turning device off");
|
||||
self.set_on(false).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GoogleHomeDevice for IkeaOutlet {
|
||||
fn get_device_type(&self) -> Type {
|
||||
match self.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.info.name)
|
||||
}
|
||||
|
||||
fn get_id(&self) -> &str {
|
||||
Device::get_id(self)
|
||||
}
|
||||
|
||||
fn is_online(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn get_room_hint(&self) -> Option<&str> {
|
||||
self.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.client.clone(), &self.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.client.clone();
|
||||
let topic = self.mqtt.topic.clone();
|
||||
let id = self.identifier.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 seperate task.
|
||||
// I don't think we can really get around calling outside function
|
||||
set_on(client, &topic, false).await;
|
||||
}));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop_timeout(&mut self) -> Result<()> {
|
||||
if let Some(handle) = self.handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -1,46 +1,52 @@
|
|||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::str::Utf8Error;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use automation_lib::device::{Device, LuaDeviceCreate};
|
||||
use automation_lib::event::OnPresence;
|
||||
use automation_macro::LuaDeviceConfig;
|
||||
use bytes::{Buf, BufMut};
|
||||
use google_home::errors::{self, DeviceError};
|
||||
use google_home::traits::OnOff;
|
||||
use google_home::traits;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tracing::{debug, trace};
|
||||
use tracing::trace;
|
||||
|
||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||
pub struct Config {
|
||||
pub identifier: String,
|
||||
#[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 9999)))]
|
||||
pub addr: SocketAddr,
|
||||
}
|
||||
use super::Device;
|
||||
use crate::device_manager::{ConfigExternal, DeviceConfig};
|
||||
use crate::error::DeviceConfigError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KasaOutlet {
|
||||
config: Config,
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct KasaOutletConfig {
|
||||
ip: Ipv4Addr,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for KasaOutlet {
|
||||
type Config = Config;
|
||||
type Error = Infallible;
|
||||
impl DeviceConfig for KasaOutletConfig {
|
||||
async fn create(
|
||||
self,
|
||||
identifier: &str,
|
||||
_ext: &ConfigExternal,
|
||||
) -> Result<Box<dyn Device>, DeviceConfigError> {
|
||||
trace!(id = identifier, "Setting up KasaOutlet");
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
trace!(id = config.identifier, "Setting up KasaOutlet");
|
||||
Ok(Self { config })
|
||||
let device = KasaOutlet {
|
||||
identifier: identifier.into(),
|
||||
addr: (self.ip, 9999).into(),
|
||||
};
|
||||
|
||||
Ok(Box::new(device))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct KasaOutlet {
|
||||
identifier: String,
|
||||
addr: SocketAddr,
|
||||
}
|
||||
|
||||
impl Device for KasaOutlet {
|
||||
fn get_id(&self) -> String {
|
||||
self.config.identifier.clone()
|
||||
fn get_id(&self) -> &str {
|
||||
&self.identifier
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -206,9 +212,9 @@ impl Response {
|
|||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnOff for KasaOutlet {
|
||||
async fn on(&self) -> Result<bool, errors::ErrorCode> {
|
||||
let mut stream = TcpStream::connect(self.config.addr)
|
||||
impl traits::OnOff for KasaOutlet {
|
||||
async fn is_on(&self) -> Result<bool, errors::ErrorCode> {
|
||||
let mut stream = TcpStream::connect(self.addr)
|
||||
.await
|
||||
.or::<DeviceError>(Err(DeviceError::DeviceOffline))?;
|
||||
|
||||
|
@ -241,8 +247,8 @@ impl OnOff for KasaOutlet {
|
|||
.or(Err(DeviceError::TransientError.into()))
|
||||
}
|
||||
|
||||
async fn set_on(&self, on: bool) -> Result<(), errors::ErrorCode> {
|
||||
let mut stream = TcpStream::connect(self.config.addr)
|
||||
async fn set_on(&mut self, on: bool) -> Result<(), errors::ErrorCode> {
|
||||
let mut stream = TcpStream::connect(self.addr)
|
||||
.await
|
||||
.or::<DeviceError>(Err(DeviceError::DeviceOffline))?;
|
||||
|
||||
|
@ -275,13 +281,3 @@ impl OnOff for KasaOutlet {
|
|||
.or(Err(DeviceError::TransientError.into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnPresence for KasaOutlet {
|
||||
async fn on_presence(&self, presence: bool) {
|
||||
if !presence {
|
||||
debug!(id = Device::get_id(self), "Turning device off");
|
||||
self.set_on(false).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
102
src/devices/light_sensor.rs
Normal file
102
src/devices/light_sensor.rs
Normal file
|
@ -0,0 +1,102 @@
|
|||
use async_trait::async_trait;
|
||||
use rumqttc::Publish;
|
||||
use serde::Deserialize;
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
use crate::config::MqttDeviceConfig;
|
||||
use crate::device_manager::{ConfigExternal, DeviceConfig};
|
||||
use crate::devices::Device;
|
||||
use crate::error::DeviceConfigError;
|
||||
use crate::event::{self, Event, OnMqtt};
|
||||
use crate::messages::BrightnessMessage;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LightSensorConfig {
|
||||
#[serde(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
pub min: isize,
|
||||
pub max: isize,
|
||||
}
|
||||
|
||||
pub const DEFAULT: bool = false;
|
||||
|
||||
// TODO: The light sensor should get a list of devices that it should inform
|
||||
|
||||
#[async_trait]
|
||||
impl DeviceConfig for LightSensorConfig {
|
||||
async fn create(
|
||||
self,
|
||||
identifier: &str,
|
||||
ext: &ConfigExternal,
|
||||
) -> Result<Box<dyn Device>, DeviceConfigError> {
|
||||
let device = LightSensor {
|
||||
identifier: identifier.into(),
|
||||
tx: ext.event_channel.get_tx(),
|
||||
mqtt: self.mqtt,
|
||||
min: self.min,
|
||||
max: self.max,
|
||||
is_dark: DEFAULT,
|
||||
};
|
||||
|
||||
Ok(Box::new(device))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LightSensor {
|
||||
identifier: String,
|
||||
tx: event::Sender,
|
||||
mqtt: MqttDeviceConfig,
|
||||
min: isize,
|
||||
max: isize,
|
||||
is_dark: bool,
|
||||
}
|
||||
|
||||
impl Device for LightSensor {
|
||||
fn get_id(&self) -> &str {
|
||||
&self.identifier
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnMqtt for LightSensor {
|
||||
fn topics(&self) -> Vec<&str> {
|
||||
vec![&self.mqtt.topic]
|
||||
}
|
||||
|
||||
async fn on_mqtt(&mut self, message: Publish) {
|
||||
let illuminance = match BrightnessMessage::try_from(message) {
|
||||
Ok(state) => state.illuminance(),
|
||||
Err(err) => {
|
||||
warn!("Failed to parse message: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
debug!("Illuminance: {illuminance}");
|
||||
let is_dark = if illuminance <= self.min {
|
||||
trace!("It is dark");
|
||||
true
|
||||
} else if illuminance >= self.max {
|
||||
trace!("It is light");
|
||||
false
|
||||
} else {
|
||||
trace!(
|
||||
"In between min ({}) and max ({}) value, keeping current state: {}",
|
||||
self.min,
|
||||
self.max,
|
||||
self.is_dark
|
||||
);
|
||||
self.is_dark
|
||||
};
|
||||
|
||||
if is_dark != self.is_dark {
|
||||
debug!("Dark state has changed: {is_dark}");
|
||||
self.is_dark = is_dark;
|
||||
|
||||
if self.tx.send(Event::Darkness(is_dark)).await.is_err() {
|
||||
warn!("There are no receivers on the event channel");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
37
src/devices/mod.rs
Normal file
37
src/devices/mod.rs
Normal file
|
@ -0,0 +1,37 @@
|
|||
mod air_filter;
|
||||
mod audio_setup;
|
||||
mod contact_sensor;
|
||||
mod debug_bridge;
|
||||
mod hue_bridge;
|
||||
mod hue_light;
|
||||
mod ikea_outlet;
|
||||
mod kasa_outlet;
|
||||
mod light_sensor;
|
||||
mod ntfy;
|
||||
mod presence;
|
||||
mod wake_on_lan;
|
||||
mod washer;
|
||||
|
||||
use google_home::device::AsGoogleHomeDevice;
|
||||
use google_home::traits::OnOff;
|
||||
|
||||
pub use self::air_filter::AirFilterConfig;
|
||||
pub use self::audio_setup::AudioSetupConfig;
|
||||
pub use self::contact_sensor::ContactSensorConfig;
|
||||
pub use self::debug_bridge::DebugBridgeConfig;
|
||||
pub use self::hue_bridge::HueBridgeConfig;
|
||||
pub use self::hue_light::HueGroupConfig;
|
||||
pub use self::ikea_outlet::IkeaOutletConfig;
|
||||
pub use self::kasa_outlet::KasaOutletConfig;
|
||||
pub use self::light_sensor::{LightSensor, LightSensorConfig};
|
||||
pub use self::ntfy::{Notification, Ntfy};
|
||||
pub use self::presence::{Presence, PresenceConfig, DEFAULT_PRESENCE};
|
||||
pub use self::wake_on_lan::WakeOnLANConfig;
|
||||
pub use self::washer::WasherConfig;
|
||||
use crate::event::{OnDarkness, OnMqtt, OnNotification, OnPresence};
|
||||
use crate::traits::Timeout;
|
||||
|
||||
#[impl_cast::device(As: OnMqtt + OnPresence + OnDarkness + OnNotification + OnOff + Timeout)]
|
||||
pub trait Device: AsGoogleHomeDevice + std::fmt::Debug + Sync + Send {
|
||||
fn get_id(&self) -> &str;
|
||||
}
|
|
@ -1,17 +1,21 @@
|
|||
use std::collections::HashMap;
|
||||
use std::convert::Infallible;
|
||||
use std::ops::Deref;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use automation_cast::Cast;
|
||||
use automation_macro::LuaDeviceConfig;
|
||||
use serde::Serialize;
|
||||
use serde_repr::*;
|
||||
use tracing::{error, trace, warn};
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use crate::device::{impl_device, Device, LuaDeviceCreate};
|
||||
use crate::config::NtfyConfig;
|
||||
use crate::devices::Device;
|
||||
use crate::event::{self, Event, EventChannel, OnNotification, OnPresence};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Ntfy {
|
||||
base_url: String,
|
||||
topic: String,
|
||||
tx: event::Sender,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize_repr, Clone, Copy)]
|
||||
#[repr(u8)]
|
||||
pub enum Priority {
|
||||
|
@ -36,9 +40,9 @@ pub enum ActionType {
|
|||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct Action {
|
||||
#[serde(flatten)]
|
||||
pub action: ActionType,
|
||||
pub label: String,
|
||||
pub clear: Option<bool>,
|
||||
action: ActionType,
|
||||
label: String,
|
||||
clear: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
@ -112,52 +116,28 @@ impl Default for Notification {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, LuaDeviceConfig)]
|
||||
pub struct Config {
|
||||
#[device_config(default("https://ntfy.sh".into()))]
|
||||
pub url: String,
|
||||
pub topic: String,
|
||||
#[device_config(rename("event_channel"), from_lua, with(|ec: EventChannel| ec.get_tx()))]
|
||||
pub tx: event::Sender,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Ntfy {
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl_device!(Ntfy);
|
||||
|
||||
#[async_trait]
|
||||
impl LuaDeviceCreate for Ntfy {
|
||||
type Config = Config;
|
||||
type Error = Infallible;
|
||||
|
||||
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
|
||||
trace!(id = "ntfy", "Setting up Ntfy");
|
||||
Ok(Self { config })
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for Ntfy {
|
||||
fn get_id(&self) -> String {
|
||||
"ntfy".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Ntfy {
|
||||
pub fn new(config: NtfyConfig, event_channel: &EventChannel) -> Self {
|
||||
Self {
|
||||
base_url: config.url,
|
||||
topic: config.topic,
|
||||
tx: event_channel.get_tx(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send(&self, notification: Notification) {
|
||||
let notification = notification.finalize(&self.config.topic);
|
||||
let notification = notification.finalize(&self.topic);
|
||||
debug!("Sending notfication");
|
||||
|
||||
// Create the request
|
||||
let res = reqwest::Client::new()
|
||||
.post(self.config.url.clone())
|
||||
.post(self.base_url.clone())
|
||||
.json(¬ification)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
if let Err(err) = res {
|
||||
error!("Something went wrong while sending the notification: {err}");
|
||||
error!("Something went wrong while sending the notifcation: {err}");
|
||||
} else if let Ok(res) = res {
|
||||
let status = res.status();
|
||||
if !status.is_success() {
|
||||
|
@ -167,9 +147,15 @@ impl Ntfy {
|
|||
}
|
||||
}
|
||||
|
||||
impl Device for Ntfy {
|
||||
fn get_id(&self) -> &str {
|
||||
"ntfy"
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnPresence for Ntfy {
|
||||
async fn on_presence(&self, presence: bool) {
|
||||
async fn on_presence(&mut self, presence: bool) {
|
||||
// Setup extras for the broadcast
|
||||
let extras = HashMap::from([
|
||||
("cmd".into(), "presence".into()),
|
||||
|
@ -191,13 +177,7 @@ impl OnPresence for Ntfy {
|
|||
.add_action(action)
|
||||
.set_priority(Priority::Low);
|
||||
|
||||
if self
|
||||
.config
|
||||
.tx
|
||||
.send(Event::Ntfy(notification))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
if self.tx.send(Event::Ntfy(notification)).await.is_err() {
|
||||
warn!("There are no receivers on the event channel");
|
||||
}
|
||||
}
|
||||
|
@ -205,7 +185,7 @@ impl OnPresence for Ntfy {
|
|||
|
||||
#[async_trait]
|
||||
impl OnNotification for Ntfy {
|
||||
async fn on_notification(&self, notification: Notification) {
|
||||
async fn on_notification(&mut self, notification: Notification) {
|
||||
self.send(notification).await;
|
||||
}
|
||||
}
|
93
src/devices/presence.rs
Normal file
93
src/devices/presence.rs
Normal file
|
@ -0,0 +1,93 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use rumqttc::Publish;
|
||||
use serde::Deserialize;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::config::MqttDeviceConfig;
|
||||
use crate::devices::Device;
|
||||
use crate::event::{self, Event, EventChannel, OnMqtt};
|
||||
use crate::messages::PresenceMessage;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PresenceConfig {
|
||||
#[serde(flatten)]
|
||||
pub mqtt: MqttDeviceConfig,
|
||||
}
|
||||
|
||||
pub const DEFAULT_PRESENCE: bool = false;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Presence {
|
||||
tx: event::Sender,
|
||||
mqtt: MqttDeviceConfig,
|
||||
devices: HashMap<String, bool>,
|
||||
current_overall_presence: bool,
|
||||
}
|
||||
|
||||
impl Presence {
|
||||
pub fn new(config: PresenceConfig, event_channel: &EventChannel) -> Self {
|
||||
Self {
|
||||
tx: event_channel.get_tx(),
|
||||
mqtt: config.mqtt,
|
||||
devices: HashMap::new(),
|
||||
current_overall_presence: DEFAULT_PRESENCE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for Presence {
|
||||
fn get_id(&self) -> &str {
|
||||
"presence"
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnMqtt for Presence {
|
||||
fn topics(&self) -> Vec<&str> {
|
||||
vec![&self.mqtt.topic]
|
||||
}
|
||||
|
||||
async fn on_mqtt(&mut self, message: Publish) {
|
||||
let offset = self
|
||||
.mqtt
|
||||
.topic
|
||||
.find('+')
|
||||
.or(self.mqtt.topic.find('#'))
|
||||
.expect("Presence::create fails if it does not contain wildcards");
|
||||
let device_name = message.topic[offset..].into();
|
||||
|
||||
if message.payload.is_empty() {
|
||||
// Remove the device from the map
|
||||
debug!("State of device [{device_name}] has been removed");
|
||||
self.devices.remove(&device_name);
|
||||
} else {
|
||||
let present = match PresenceMessage::try_from(message) {
|
||||
Ok(state) => state.presence(),
|
||||
Err(err) => {
|
||||
warn!("Failed to parse message: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
debug!("State of device [{device_name}] has changed: {}", present);
|
||||
self.devices.insert(device_name, present);
|
||||
}
|
||||
|
||||
let overall_presence = self.devices.iter().any(|(_, v)| *v);
|
||||
if overall_presence != self.current_overall_presence {
|
||||
debug!("Overall presence updated: {overall_presence}");
|
||||
self.current_overall_presence = overall_presence;
|
||||
|
||||
if self
|
||||
.tx
|
||||
.send(Event::Presence(overall_presence))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!("There are no receivers on the event channel");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
152
src/devices/wake_on_lan.rs
Normal file
152
src/devices/wake_on_lan.rs
Normal file
|
@ -0,0 +1,152 @@
|
|||
use std::net::Ipv4Addr;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eui48::MacAddress;
|
||||
use google_home::errors::ErrorCode;
|
||||
use google_home::traits::{self, Scene};
|
||||
use google_home::types::Type;
|
||||
use google_home::{device, GoogleHomeDevice};
|
||||
use rumqttc::Publish;
|
||||
use serde::Deserialize;
|
||||
use tracing::{debug, error, trace};
|
||||
|
||||
use super::Device;
|
||||
use crate::config::{InfoConfig, MqttDeviceConfig};
|
||||
use crate::device_manager::{ConfigExternal, DeviceConfig};
|
||||
use crate::error::DeviceConfigError;
|
||||
use crate::event::OnMqtt;
|
||||
use crate::messages::ActivateMessage;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct WakeOnLANConfig {
|
||||
#[serde(flatten)]
|
||||
info: InfoConfig,
|
||||
#[serde(flatten)]
|
||||
mqtt: MqttDeviceConfig,
|
||||
mac_address: MacAddress,
|
||||
#[serde(default = "default_broadcast_ip")]
|
||||
broadcast_ip: Ipv4Addr,
|
||||
}
|
||||
|
||||
fn default_broadcast_ip() -> Ipv4Addr {
|
||||
Ipv4Addr::new(255, 255, 255, 255)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeviceConfig for WakeOnLANConfig {
|
||||
async fn create(
|
||||
self,
|
||||
identifier: &str,
|
||||
_ext: &ConfigExternal,
|
||||
) -> Result<Box<dyn Device>, DeviceConfigError> {
|
||||
trace!(
|
||||
id = identifier,
|
||||
name = self.info.name,
|
||||
room = self.info.room,
|
||||
"Setting up WakeOnLAN"
|
||||
);
|
||||
|
||||
let device = WakeOnLAN {
|
||||
identifier: identifier.into(),
|
||||
info: self.info,
|
||||
mqtt: self.mqtt,
|
||||
mac_address: self.mac_address,
|
||||
broadcast_ip: self.broadcast_ip,
|
||||
};
|
||||
|
||||
Ok(Box::new(device))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct WakeOnLAN {
|
||||
identifier: String,
|
||||
info: InfoConfig,
|
||||
mqtt: MqttDeviceConfig,
|
||||
mac_address: MacAddress,
|
||||
broadcast_ip: Ipv4Addr,
|
||||
}
|
||||
|
||||
impl Device for WakeOnLAN {
|
||||
fn get_id(&self) -> &str {
|
||||
&self.identifier
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OnMqtt for WakeOnLAN {
|
||||
fn topics(&self) -> Vec<&str> {
|
||||
vec![&self.mqtt.topic]
|
||||
}
|
||||
|
||||
async fn on_mqtt(&mut self, message: Publish) {
|
||||
let activate = match ActivateMessage::try_from(message) {
|
||||
Ok(message) => message.activate(),
|
||||
Err(err) => {
|
||||
error!(id = self.identifier, "Failed to parse message: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
self.set_active(activate).await.ok();
|
||||
}
|
||||
}
|
||||
|
||||
impl GoogleHomeDevice for WakeOnLAN {
|
||||
fn get_device_type(&self) -> Type {
|
||||
Type::Scene
|
||||
}
|
||||
|
||||
fn get_device_name(&self) -> device::Name {
|
||||
let mut name = device::Name::new(&self.info.name);
|
||||
name.add_default_name("Computer");
|
||||
|
||||
name
|
||||
}
|
||||
|
||||
fn get_id(&self) -> &str {
|
||||
Device::get_id(self)
|
||||
}
|
||||
|
||||
fn is_online(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn get_room_hint(&self) -> Option<&str> {
|
||||
self.info.room.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl traits::Scene for WakeOnLAN {
|
||||
async fn set_active(&self, activate: bool) -> Result<(), ErrorCode> {
|
||||
if activate {
|
||||
debug!(
|
||||
id = self.identifier,
|
||||
"Activating Computer: {} (Sending to {})", self.mac_address, self.broadcast_ip
|
||||
);
|
||||
let wol =
|
||||
wakey::WolPacket::from_bytes(&self.mac_address.to_array()).map_err(|err| {
|
||||
error!(id = self.identifier, "invalid mac address: {err}");
|
||||
google_home::errors::DeviceError::TransientError
|
||||
})?;
|
||||
|
||||
wol.send_magic_to((Ipv4Addr::new(0, 0, 0, 0), 0), (self.broadcast_ip, 9))
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!(id = self.identifier, "Failed to activate computer: {err}");
|
||||
google_home::errors::DeviceError::TransientError.into()
|
||||
})
|
||||
.map(|_| debug!(id = self.identifier, "Success!"))
|
||||
} else {
|
||||
debug!(
|
||||
id = self.identifier,
|
||||
"Trying to deactive computer, this is not currently supported"
|
||||
);
|
||||
// We do not support deactivating this scene
|
||||
Err(ErrorCode::DeviceError(
|
||||
google_home::errors::DeviceError::ActionNotAvailable,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
120
src/devices/washer.rs
Normal file
120
src/devices/washer.rs
Normal file
|
@ -0,0 +1,120 @@
|
|||
use async_trait::async_trait;
|
||||
use rumqttc::Publish;
|
||||
use serde::Deserialize;
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use super::ntfy::Priority;
|
||||
use super::{Device, Notification};
|
||||
use crate::config::MqttDeviceConfig;
|
||||
use crate::device_manager::{ConfigExternal, DeviceConfig};
|
||||
use crate::error::DeviceConfigError;
|
||||
use crate::event::{Event, EventChannel, OnMqtt};
|
||||
use crate::messages::PowerMessage;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct WasherConfig {
|
||||
#[serde(flatten)]
|
||||
mqtt: MqttDeviceConfig,
|
||||
threshold: f32, // Power in Watt
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeviceConfig for WasherConfig {
|
||||
async fn create(
|
||||
self,
|
||||
identifier: &str,
|
||||
ext: &ConfigExternal,
|
||||
) -> Result<Box<dyn Device>, DeviceConfigError> {
|
||||
let device = Washer {
|
||||
identifier: identifier.into(),
|
||||
mqtt: self.mqtt,
|
||||
event_channel: ext.event_channel.clone(),
|
||||
threshold: self.threshold,
|
||||
running: 0,
|
||||
};
|
||||
|
||||
Ok(Box::new(device))
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add google home integration
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Washer {
|
||||
identifier: String,
|
||||
mqtt: MqttDeviceConfig,
|
||||
|
||||
event_channel: EventChannel,
|
||||
threshold: f32,
|
||||
running: isize,
|
||||
}
|
||||
|
||||
impl Device for Washer {
|
||||
fn get_id(&self) -> &str {
|
||||
&self.identifier
|
||||
}
|
||||
}
|
||||
|
||||
// The washer needs to have a power draw above the theshold multiple times before the washer is
|
||||
// actually marked as running
|
||||
// This helps prevent false positives
|
||||
const HYSTERESIS: isize = 10;
|
||||
|
||||
#[async_trait]
|
||||
impl OnMqtt for Washer {
|
||||
fn topics(&self) -> Vec<&str> {
|
||||
vec![&self.mqtt.topic]
|
||||
}
|
||||
|
||||
async fn on_mqtt(&mut self, message: Publish) {
|
||||
let power = match PowerMessage::try_from(message) {
|
||||
Ok(state) => state.power(),
|
||||
Err(err) => {
|
||||
error!(id = self.identifier, "Failed to parse message: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// debug!(id = self.identifier, power, "Washer state update");
|
||||
|
||||
if power < self.threshold && self.running >= HYSTERESIS {
|
||||
// The washer is done running
|
||||
debug!(
|
||||
id = self.identifier,
|
||||
power,
|
||||
threshold = self.threshold,
|
||||
"Washer is done"
|
||||
);
|
||||
|
||||
self.running = 0;
|
||||
let notification = Notification::new()
|
||||
.set_title("Laundy is done")
|
||||
.set_message("Don't forget to hang it!")
|
||||
.add_tag("womans_clothes")
|
||||
.set_priority(Priority::High);
|
||||
|
||||
if self
|
||||
.event_channel
|
||||
.get_tx()
|
||||
.send(Event::Ntfy(notification))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!("There are no receivers on the event channel");
|
||||
}
|
||||
} else if power < self.threshold {
|
||||
// Prevent false positives
|
||||
self.running = 0;
|
||||
} else if power >= self.threshold && self.running < HYSTERESIS {
|
||||
// Washer could be starting
|
||||
debug!(
|
||||
id = self.identifier,
|
||||
power,
|
||||
threshold = self.threshold,
|
||||
"Washer is starting"
|
||||
);
|
||||
|
||||
self.running += 1;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +1,10 @@
|
|||
use std::{error, fmt, result};
|
||||
|
||||
use axum::http::status::InvalidStatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use bytes::Bytes;
|
||||
use rumqttc::ClientError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
@ -62,6 +65,16 @@ pub enum ParseError {
|
|||
InvalidPayload(Bytes),
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigParseError {
|
||||
#[error(transparent)]
|
||||
MissingEnv(#[from] MissingEnv),
|
||||
#[error(transparent)]
|
||||
IoError(#[from] std::io::Error),
|
||||
#[error(transparent)]
|
||||
YamlError(#[from] serde_yaml::Error),
|
||||
}
|
||||
|
||||
// TODO: Would be nice to somehow get the line number of the expected wildcard topic
|
||||
#[derive(Debug, Error)]
|
||||
#[error("Topic '{topic}' is expected to be a wildcard topic")]
|
||||
|
@ -79,10 +92,12 @@ impl MissingWildcard {
|
|||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DeviceConfigError {
|
||||
#[error("Child '{1}' of device '{0}' does not exist")]
|
||||
MissingChild(String, String),
|
||||
#[error("Device '{0}' does not implement expected trait '{1}'")]
|
||||
MissingTrait(String, String),
|
||||
#[error(transparent)]
|
||||
MqttClientError(#[from] rumqttc::ClientError),
|
||||
MissingWildcard(#[from] MissingWildcard),
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
|
@ -98,3 +113,68 @@ pub enum LightSensorError {
|
|||
#[error(transparent)]
|
||||
SubscribeError(#[from] ClientError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("{source}")]
|
||||
pub struct ApiError {
|
||||
status_code: axum::http::StatusCode,
|
||||
source: Box<dyn std::error::Error>,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn new(status_code: axum::http::StatusCode, source: Box<dyn std::error::Error>) -> Self {
|
||||
Self {
|
||||
status_code,
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiError> for ApiErrorJson {
|
||||
fn from(value: ApiError) -> Self {
|
||||
let error = ApiErrorJsonError {
|
||||
code: value.status_code.as_u16(),
|
||||
status: value.status_code.to_string(),
|
||||
reason: value.source.to_string(),
|
||||
};
|
||||
|
||||
Self { error }
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
(
|
||||
self.status_code,
|
||||
serde_json::to_string::<ApiErrorJson>(&self.into())
|
||||
.expect("Serialization should not fail"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ApiErrorJsonError {
|
||||
code: u16,
|
||||
status: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ApiErrorJson {
|
||||
error: ApiErrorJsonError,
|
||||
}
|
||||
|
||||
impl TryFrom<ApiErrorJson> for ApiError {
|
||||
type Error = InvalidStatusCode;
|
||||
|
||||
fn try_from(value: ApiErrorJson) -> result::Result<Self, Self::Error> {
|
||||
let status_code = axum::http::StatusCode::from_u16(value.error.code)?;
|
||||
let source = value.error.reason.into();
|
||||
|
||||
Ok(Self {
|
||||
status_code,
|
||||
source,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
use async_trait::async_trait;
|
||||
use mlua::FromLua;
|
||||
use impl_cast::device_trait;
|
||||
use rumqttc::Publish;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::ntfy::Notification;
|
||||
use crate::devices::Notification;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
|
@ -16,7 +16,7 @@ pub enum Event {
|
|||
pub type Sender = mpsc::Sender<Event>;
|
||||
pub type Receiver = mpsc::Receiver<Event>;
|
||||
|
||||
#[derive(Clone, Debug, FromLua)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EventChannel(Sender);
|
||||
|
||||
impl EventChannel {
|
||||
|
@ -31,25 +31,27 @@ impl EventChannel {
|
|||
}
|
||||
}
|
||||
|
||||
impl mlua::UserData for EventChannel {}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnMqtt: Sync + Send {
|
||||
// fn topics(&self) -> Vec<&str>;
|
||||
async fn on_mqtt(&self, message: Publish);
|
||||
#[device_trait]
|
||||
pub trait OnMqtt {
|
||||
fn topics(&self) -> Vec<&str>;
|
||||
async fn on_mqtt(&mut self, message: Publish);
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnPresence: Sync + Send {
|
||||
async fn on_presence(&self, presence: bool);
|
||||
#[device_trait]
|
||||
pub trait OnPresence {
|
||||
async fn on_presence(&mut self, presence: bool);
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnDarkness: Sync + Send {
|
||||
async fn on_darkness(&self, dark: bool);
|
||||
#[device_trait]
|
||||
pub trait OnDarkness {
|
||||
async fn on_darkness(&mut self, dark: bool);
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnNotification: Sync + Send {
|
||||
async fn on_notification(&self, notification: Notification);
|
||||
#[device_trait]
|
||||
pub trait OnNotification {
|
||||
async fn on_notification(&mut self, notification: Notification);
|
||||
}
|
|
@ -1,16 +1,13 @@
|
|||
#![allow(incomplete_features)]
|
||||
#![feature(specialization)]
|
||||
#![feature(let_chains)]
|
||||
|
||||
pub mod action_callback;
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod device;
|
||||
pub mod device_manager;
|
||||
pub mod devices;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod helpers;
|
||||
pub mod messages;
|
||||
pub mod mqtt;
|
||||
pub mod ntfy;
|
||||
pub mod presence;
|
||||
pub mod schedule;
|
||||
pub mod traits;
|
175
src/main.rs
175
src/main.rs
|
@ -1,37 +1,30 @@
|
|||
mod web;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
#![feature(async_closure)]
|
||||
use std::process;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use automation_lib::config::{FulfillmentConfig, MqttConfig};
|
||||
use automation_lib::device_manager::DeviceManager;
|
||||
use automation_lib::helpers;
|
||||
use automation_lib::mqtt::{self, WrappedAsyncClient};
|
||||
use automation_lib::ntfy::Ntfy;
|
||||
use automation_lib::presence::Presence;
|
||||
use axum::extract::{FromRef, State};
|
||||
use automation::auth::{OpenIDConfig, User};
|
||||
use automation::config::Config;
|
||||
use automation::device_manager::DeviceManager;
|
||||
use automation::devices::{Ntfy, Presence};
|
||||
use automation::error::ApiError;
|
||||
use automation::mqtt;
|
||||
use axum::extract::FromRef;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use dotenvy::dotenv;
|
||||
use google_home::{GoogleHome, Request, Response};
|
||||
use mlua::LuaSerdeExt;
|
||||
use google_home::{GoogleHome, Request};
|
||||
use rumqttc::AsyncClient;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use web::{ApiError, User};
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
pub openid_url: String,
|
||||
pub device_manager: DeviceManager,
|
||||
pub openid: OpenIDConfig,
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for String {
|
||||
impl FromRef<AppState> for OpenIDConfig {
|
||||
fn from_ref(input: &AppState) -> Self {
|
||||
input.openid_url.clone()
|
||||
input.openid.clone()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -48,118 +41,82 @@ async fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
async fn fulfillment(
|
||||
State(state): State<AppState>,
|
||||
user: User,
|
||||
Json(payload): Json<Request>,
|
||||
) -> Result<Json<Response>, ApiError> {
|
||||
debug!(username = user.preferred_username, "{payload:#?}");
|
||||
let gc = GoogleHome::new(&user.preferred_username);
|
||||
let devices = state.device_manager.devices().await;
|
||||
let result = gc
|
||||
.handle_request(payload, &devices)
|
||||
.await
|
||||
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.into()))?;
|
||||
|
||||
debug!(username = user.preferred_username, "{result:#?}");
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
async fn app() -> anyhow::Result<()> {
|
||||
dotenv().ok();
|
||||
|
||||
tracing_subscriber::fmt::init();
|
||||
// console_subscriber::init();
|
||||
console_subscriber::init();
|
||||
|
||||
info!("Starting automation_rs...");
|
||||
|
||||
let config_filename =
|
||||
std::env::var("AUTOMATION_CONFIG").unwrap_or("./config/config.yml".into());
|
||||
let config = Config::parse_file(&config_filename)?;
|
||||
|
||||
// Create a mqtt client
|
||||
// TODO: Since we wait with starting the eventloop we might fill the queue while setting up devices
|
||||
let (client, eventloop) = AsyncClient::new(config.mqtt.clone(), 100);
|
||||
|
||||
// Setup the device handler
|
||||
let device_manager = DeviceManager::new().await;
|
||||
let device_manager = DeviceManager::new(client.clone());
|
||||
|
||||
let fulfillment_config = {
|
||||
let lua = mlua::Lua::new();
|
||||
for (id, device_config) in config.devices {
|
||||
device_manager.create(&id, device_config).await?;
|
||||
}
|
||||
|
||||
lua.set_warning_function(|_lua, text, _cont| {
|
||||
warn!("{text}");
|
||||
Ok(())
|
||||
});
|
||||
device_manager.add_schedule(config.schedule).await;
|
||||
|
||||
let automation = lua.create_table()?;
|
||||
let event_channel = device_manager.event_channel();
|
||||
let new_mqtt_client = lua.create_function(move |lua, config: mlua::Value| {
|
||||
let config: MqttConfig = lua.from_value(config)?;
|
||||
let event_channel = device_manager.event_channel();
|
||||
|
||||
// Create a mqtt client
|
||||
// TODO: When starting up, the devices are not yet created, this could lead to a device being out of sync
|
||||
let (client, eventloop) = AsyncClient::new(config.into(), 100);
|
||||
mqtt::start(eventloop, &event_channel);
|
||||
// Create and add the presence system
|
||||
{
|
||||
let presence = Presence::new(config.presence, &event_channel);
|
||||
device_manager.add(Box::new(presence)).await;
|
||||
}
|
||||
|
||||
Ok(WrappedAsyncClient(client))
|
||||
})?;
|
||||
// Start the ntfy service if it is configured
|
||||
if let Some(config) = config.ntfy {
|
||||
let ntfy = Ntfy::new(config, &event_channel);
|
||||
device_manager.add(Box::new(ntfy)).await;
|
||||
}
|
||||
|
||||
automation.set("new_mqtt_client", new_mqtt_client)?;
|
||||
automation.set("device_manager", device_manager.clone())?;
|
||||
// Wrap the mqtt eventloop and start listening for message
|
||||
// NOTE: We wait until all the setup is done, as otherwise we might miss some messages
|
||||
mqtt::start(eventloop, &event_channel);
|
||||
|
||||
let util = lua.create_table()?;
|
||||
let get_env = lua.create_function(|_lua, name: String| {
|
||||
std::env::var(name).map_err(mlua::ExternalError::into_lua_err)
|
||||
})?;
|
||||
util.set("get_env", get_env)?;
|
||||
let get_hostname = lua.create_function(|_lua, ()| {
|
||||
hostname::get()
|
||||
.map(|name| name.to_str().unwrap_or("unknown").to_owned())
|
||||
.map_err(mlua::ExternalError::into_lua_err)
|
||||
})?;
|
||||
util.set("get_hostname", get_hostname)?;
|
||||
automation.set("util", util)?;
|
||||
// Create google home fullfillment route
|
||||
let fullfillment = 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()
|
||||
}
|
||||
};
|
||||
|
||||
lua.globals().set("automation", automation)?;
|
||||
debug!(username = user.preferred_username, "{result:#?}");
|
||||
|
||||
automation_devices::register_with_lua(&lua)?;
|
||||
helpers::register_with_lua(&lua)?;
|
||||
lua.globals().set("Ntfy", lua.create_proxy::<Ntfy>()?)?;
|
||||
lua.globals()
|
||||
.set("Presence", lua.create_proxy::<Presence>()?)?;
|
||||
|
||||
// TODO: Make this not hardcoded
|
||||
let config_filename = std::env::var("AUTOMATION_CONFIG").unwrap_or("./config.lua".into());
|
||||
let config_path = Path::new(&config_filename);
|
||||
match lua.load(config_path).exec_async().await {
|
||||
Err(error) => {
|
||||
println!("{error}");
|
||||
Err(error)
|
||||
}
|
||||
result => result,
|
||||
}?;
|
||||
|
||||
let automation: mlua::Table = lua.globals().get("automation")?;
|
||||
let fulfillment_config: Option<mlua::Value> = automation.get("fulfillment")?;
|
||||
if let Some(fulfillment_config) = fulfillment_config {
|
||||
let fulfillment_config: FulfillmentConfig = lua.from_value(fulfillment_config)?;
|
||||
debug!("automation.fulfillment = {fulfillment_config:?}");
|
||||
fulfillment_config
|
||||
} else {
|
||||
return Err(anyhow!("Fulfillment is not configured"));
|
||||
}
|
||||
};
|
||||
|
||||
// Create google home fulfillment route
|
||||
let fulfillment = Router::new().route("/google_home", post(fulfillment));
|
||||
(StatusCode::OK, Json(result)).into_response()
|
||||
}),
|
||||
);
|
||||
|
||||
// Combine together all the routes
|
||||
let app = Router::new()
|
||||
.nest("/fulfillment", fulfillment)
|
||||
.nest("/fullfillment", fullfillment)
|
||||
.with_state(AppState {
|
||||
openid_url: fulfillment_config.openid_url.clone(),
|
||||
device_manager,
|
||||
openid: config.openid,
|
||||
});
|
||||
|
||||
// Start the web server
|
||||
let addr: SocketAddr = fulfillment_config.into();
|
||||
let addr = config.fullfillment.into();
|
||||
info!("Server started on http://{addr}");
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
axum::Server::try_bind(&addr)?
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -241,3 +241,37 @@ impl TryFrom<Bytes> for HueMessage {
|
|||
serde_json::from_slice(&bytes).or(Err(ParseError::InvalidPayload(bytes.clone())))
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Import this from the air_filter code itself instead of copying
|
||||
#[derive(PartialEq, Eq, Debug, Clone, Copy, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AirFilterState {
|
||||
Off,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
|
||||
pub struct AirFilterMessage {
|
||||
state: AirFilterState,
|
||||
}
|
||||
|
||||
impl AirFilterMessage {
|
||||
pub fn state(&self) -> AirFilterState {
|
||||
self.state
|
||||
}
|
||||
|
||||
pub fn new(state: AirFilterState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Publish> for AirFilterMessage {
|
||||
type Error = ParseError;
|
||||
|
||||
fn try_from(message: Publish) -> Result<Self, Self::Error> {
|
||||
serde_json::from_slice(&message.payload)
|
||||
.or(Err(ParseError::InvalidPayload(message.payload.clone())))
|
||||
}
|
||||
}
|
|
@ -1,30 +1,8 @@
|
|||
use std::ops::{Deref, DerefMut};
|
||||
|
||||
use mlua::FromLua;
|
||||
use rumqttc::{AsyncClient, Event, EventLoop, Incoming};
|
||||
use rumqttc::{Event, EventLoop, Incoming};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::event::{self, EventChannel};
|
||||
|
||||
#[derive(Debug, Clone, FromLua)]
|
||||
pub struct WrappedAsyncClient(pub AsyncClient);
|
||||
|
||||
impl Deref for WrappedAsyncClient {
|
||||
type Target = AsyncClient;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for WrappedAsyncClient {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl mlua::UserData for WrappedAsyncClient {}
|
||||
|
||||
pub fn start(mut eventloop: EventLoop, event_channel: &EventChannel) {
|
||||
let tx = event_channel.get_tx();
|
||||
|
12
src/traits.rs
Normal file
12
src/traits.rs
Normal file
|
@ -0,0 +1,12 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use impl_cast::device_trait;
|
||||
|
||||
#[async_trait]
|
||||
#[device_trait]
|
||||
pub trait Timeout {
|
||||
async fn start_timeout(&mut self, _timeout: Duration) -> Result<()>;
|
||||
async fn stop_timeout(&mut self) -> Result<()>;
|
||||
}
|
132
src/web.rs
132
src/web.rs
|
@ -1,132 +0,0 @@
|
|||
use std::result;
|
||||
|
||||
use axum::async_trait;
|
||||
use axum::extract::{FromRef, FromRequestParts};
|
||||
use axum::http::request::Parts;
|
||||
use axum::http::status::InvalidStatusCode;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("{source}")]
|
||||
pub struct ApiError {
|
||||
status_code: axum::http::StatusCode,
|
||||
source: Box<dyn std::error::Error>,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn new(status_code: axum::http::StatusCode, source: Box<dyn std::error::Error>) -> Self {
|
||||
Self {
|
||||
status_code,
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiError> for ApiErrorJson {
|
||||
fn from(value: ApiError) -> Self {
|
||||
let error = ApiErrorJsonError {
|
||||
code: value.status_code.as_u16(),
|
||||
status: value.status_code.to_string(),
|
||||
reason: value.source.to_string(),
|
||||
};
|
||||
|
||||
Self { error }
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
(
|
||||
self.status_code,
|
||||
serde_json::to_string::<ApiErrorJson>(&self.into())
|
||||
.expect("Serialization should not fail"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ApiErrorJsonError {
|
||||
code: u16,
|
||||
status: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ApiErrorJson {
|
||||
error: ApiErrorJsonError,
|
||||
}
|
||||
|
||||
impl TryFrom<ApiErrorJson> for ApiError {
|
||||
type Error = InvalidStatusCode;
|
||||
|
||||
fn try_from(value: ApiErrorJson) -> result::Result<Self, Self::Error> {
|
||||
let status_code = axum::http::StatusCode::from_u16(value.error.code)?;
|
||||
let source = value.error.reason.into();
|
||||
|
||||
Ok(Self {
|
||||
status_code,
|
||||
source,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct User {
|
||||
pub preferred_username: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for User
|
||||
where
|
||||
String: FromRef<S>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
// Get the state
|
||||
let openid_url = String::from_ref(state);
|
||||
|
||||
// Create a request to the auth server
|
||||
// TODO: Do some discovery to find the correct url for this instead of assuming
|
||||
// TODO: I think we can also just run Authlia in front of the endpoint instead
|
||||
// This would then give us a header containing the logged in user info?
|
||||
let mut req = reqwest::Client::new().get(format!("{}/userinfo", openid_url));
|
||||
|
||||
// Add auth header to the request if it exists
|
||||
if let Some(auth) = parts.headers.get(axum::http::header::AUTHORIZATION) {
|
||||
req = req.header(reqwest::header::AUTHORIZATION, auth);
|
||||
}
|
||||
|
||||
// Send the request
|
||||
let res = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.into()))?;
|
||||
|
||||
// If the request is success full the auth token is valid and we are given userinfo
|
||||
let status = res.status();
|
||||
if status.is_success() {
|
||||
let user = res
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.into()))?;
|
||||
|
||||
return Ok(user);
|
||||
} else {
|
||||
let err: ApiErrorJson = res
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.into()))?;
|
||||
|
||||
let err = ApiError::try_from(err)
|
||||
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.into()))?;
|
||||
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user