Compare commits

...

103 Commits

Author SHA1 Message Date
d9e83a49a1
Improved long press behaviour when there is no long press callback
All checks were successful
Build and deploy / Build application (push) Successful in 3m23s
Build and deploy / Build container (push) Successful in 52s
Build and deploy / Deploy container (push) Successful in 47s
2025-01-29 00:55:00 +01:00
00cd0366fd
Added hue groups for bedroom lights controlled by hue switch
All checks were successful
Build and deploy / Build application (push) Successful in 3m34s
Build and deploy / Build container (push) Successful in 57s
Build and deploy / Deploy container (push) Successful in 32s
2025-01-28 23:33:30 +01:00
68684d9410
Added hue groups for kitchen and living room lights controlled by hue switch
All checks were successful
Build and deploy / Build application (push) Successful in 3m50s
Build and deploy / Build container (push) Successful in 1m21s
Build and deploy / Deploy container (push) Successful in 35s
2025-01-28 22:49:37 +01:00
746e19eb8c
Use own struct to deserialize hue switch state and added hold actions 2025-01-28 22:48:02 +01:00
47d509cec1
Unneeded mqtt client in huegroup
Some checks failed
Build and deploy / Build application (push) Failing after 2m57s
Build and deploy / Build container (push) Has been skipped
Build and deploy / Deploy container (push) Has been skipped
2025-01-28 22:43:50 +01:00
856bc3cc96
Updated airfilter ip
All checks were successful
Build and deploy / Build application (push) Successful in 4m16s
Build and deploy / Build container (push) Successful in 1m25s
Build and deploy / Deploy container (push) Successful in 35s
2025-01-27 02:21:13 +01:00
fbabc978b1
Reworked IkeaOutlet into more generic outlet that also (optionally) supports power measurement
All checks were successful
Build and deploy / Build application (push) Successful in 4m15s
Build and deploy / Build container (push) Successful in 1m16s
Build and deploy / Deploy container (push) Successful in 19s
This new power measurement feature is used to turn the kettle off
automatically once it is done boiling
2025-01-26 04:48:59 +01:00
48c600b9cb
Use ip instead of dns name for airfilter
All checks were successful
Build and deploy / Build application (push) Successful in 4m13s
Build and deploy / Build container (push) Successful in 1m0s
Build and deploy / Deploy container (push) Successful in 33s
The dns name does not resolve properly in the container
2025-01-22 03:55:28 +01:00
3905df690b
Reworked air filter integration
All checks were successful
Build and deploy / Build application (push) Successful in 5m8s
Build and deploy / Build container (push) Successful in 2m19s
Build and deploy / Deploy container (push) Successful in 35s
2025-01-22 03:12:13 +01:00
5af713cf8f
Switched speaker and mixer from KasaOutlet to IkeaOutlet
All checks were successful
Build and deploy / Build application (push) Successful in 4m47s
Build and deploy / Build container (push) Successful in 1m21s
Build and deploy / Deploy container (push) Successful in 33s
2025-01-11 17:55:20 +01:00
ae61cf5dd2
Updated ips
All checks were successful
Build and deploy / Build application (push) Successful in 3m35s
Build and deploy / Build container (push) Successful in 1m22s
Build and deploy / Deploy container (push) Successful in 33s
2024-12-27 22:24:31 +01:00
8ad75a1148
Added workbench light (no color temp control for now)
All checks were successful
Build and deploy / Build application (push) Successful in 3m30s
Build and deploy / Build container (push) Successful in 1m6s
Build and deploy / Deploy container (push) Successful in 33s
2024-12-17 19:59:08 +01:00
ef180f6261
Added automatic storage room light
All checks were successful
Build and deploy / Build application (push) Successful in 3m30s
Build and deploy / Build container (push) Successful in 1m18s
Build and deploy / Deploy container (push) Successful in 31s
2024-12-16 23:15:45 +01:00
1462755f36
Added window sensors, updated room names, and improved hallway automation
All checks were successful
Build and deploy / Build application (push) Successful in 3m16s
Build and deploy / Build container (push) Successful in 52s
Build and deploy / Deploy container (push) Successful in 31s
2024-12-12 17:17:50 +01:00
90a94934fb
Added open close trait and google home support for contact sensor 2024-12-11 22:19:31 +01:00
24815edd34
Increased hallway light timeout back to two minutes
All checks were successful
Build and deploy / Build application (push) Successful in 3m59s
Build and deploy / Build container (push) Successful in 1m18s
Build and deploy / Deploy container (push) Successful in 34s
2024-12-10 22:23:07 +01:00
bf6d80ded9
Added logo
All checks were successful
Build and deploy / Build application (push) Successful in 3m7s
Build and deploy / Build container (push) Successful in 44s
Build and deploy / Deploy container (push) Successful in 32s
2024-12-08 05:47:21 +01:00
175056416e
Updated is_on -> on to be consistent with rust
All checks were successful
Build and deploy / Build application (push) Successful in 3m23s
Build and deploy / Build container (push) Successful in 1m2s
Build and deploy / Deploy container (push) Successful in 18s
2024-12-08 05:35:48 +01:00
e4c211a278
Added dedicated light device and updated hallway logic 2024-12-08 05:34:51 +01:00
8c9e93dcc4
Added brightness trait 2024-12-08 05:19:27 +01:00
41d2af655b
ActionCallback now always returns self and state can be anything serializable 2024-12-08 02:50:52 +01:00
eefb476d7f
Added support for generic structs in LuaDeviceConfig 2024-12-08 01:53:04 +01:00
14aabe202d
Updated rust toolchain
All checks were successful
Build and deploy / Build application (push) Successful in 4m7s
Build and deploy / Build container (push) Successful in 1m2s
Build and deploy / Deploy container (push) Successful in 35s
2024-12-08 00:57:57 +01:00
e8d5698835
Updated dependencies 2024-12-08 00:53:31 +01:00
8877b24e84
Reorganized project 2024-12-08 00:15:03 +01:00
42f391cde6
Removed duplicate OnMqtt entry 2024-12-07 22:33:52 +01:00
e9f080ef19
Moved and improved hallways logic with lua
All checks were successful
Build and deploy / Build application (push) Successful in 4m7s
Build and deploy / Build container (push) Successful in 1m18s
Build and deploy / Deploy container (push) Successful in 21s
2024-12-06 01:27:35 +01:00
9d4b52b511
Implemented new timeout mechanism for ikea_outlet
All checks were successful
Build and deploy / Build application (push) Successful in 5m24s
Build and deploy / Build container (push) Successful in 1m8s
Build and deploy / Deploy container (push) Successful in 19s
2024-12-04 03:03:53 +01:00
03f1790627
Removed spammy debug message 2024-12-04 01:34:46 +01:00
d39432fa22
ActionCallback can now handle tuples 2024-12-04 01:29:28 +01:00
6b8d0b7d56
Added hue wall switches
All checks were successful
Build and deploy / Build application (push) Successful in 4m9s
Build and deploy / Build container (push) Successful in 53s
Build and deploy / Deploy container (push) Successful in 32s
2024-11-30 22:17:16 +01:00
5185b0d3ba
Added guest room light
All checks were successful
Build and deploy / Build application (push) Successful in 3m23s
Build and deploy / Build container (push) Successful in 55s
Build and deploy / Deploy container (push) Successful in 31s
2024-11-30 18:44:48 +01:00
4bb49a381b
Use IkeaRemote to control devices and completely replace AudioSetup
All checks were successful
Build and deploy / Build application (push) Successful in 3m24s
Build and deploy / Build container (push) Successful in 43s
Build and deploy / Deploy container (push) Successful in 18s
2024-11-30 06:06:30 +01:00
a353ba3d08
Added IkeaRemote 2024-11-30 05:45:03 +01:00
157bbf923f
Added generic action callback 2024-11-30 05:44:23 +01:00
9719c46136
Added deref to impl_device to account for changes in mlua 0.10
All checks were successful
Build and deploy / Build application (push) Successful in 3m26s
Build and deploy / Build container (push) Successful in 52s
Build and deploy / Deploy container (push) Successful in 32s
2024-11-30 05:31:38 +01:00
8b04435537
No more global LUA
All checks were successful
Build and deploy / Build application (push) Successful in 3m45s
Build and deploy / Build container (push) Successful in 54s
Build and deploy / Deploy container (push) Successful in 29s
2024-11-30 05:10:40 +01:00
ae2c27551f
Initial upgrade to mlua 0.10
All checks were successful
Build and deploy / Build application (push) Successful in 7m59s
Build and deploy / Build container (push) Successful in 2m54s
Build and deploy / Deploy container (push) Successful in 19s
2024-11-30 04:47:52 +01:00
d11e79cdfa
Devices now keep type in lua
All checks were successful
Build and deploy / Build application (push) Successful in 4m5s
Build and deploy / Build container (push) Successful in 1m9s
Build and deploy / Deploy container (push) Successful in 37s
2024-08-08 01:36:11 +02:00
b0467b8012
Fixed kasa ip addresses
All checks were successful
Build and deploy / Build application (push) Successful in 3m39s
Build and deploy / Build container (push) Successful in 1m5s
Build and deploy / Deploy container (push) Successful in 37s
2024-08-08 00:24:14 +02:00
3105c266b0
Updated hue ip
All checks were successful
Build and deploy / Build application (push) Successful in 3m59s
Build and deploy / Build container (push) Successful in 1m24s
Build and deploy / Deploy container (push) Successful in 38s
2024-08-07 23:22:11 +02:00
88e31699ad
Removed pre-commit action
All checks were successful
Build and deploy / Build application (push) Successful in 3m36s
Build and deploy / Build container (push) Successful in 40s
Build and deploy / Deploy container (push) Successful in 32s
I should always run pre-commit locally and currently this just takes to
long to run.
2024-07-30 00:08:10 +02:00
23e78fe5a7
Small cleanup
All checks were successful
Build and deploy / Build application (push) Successful in 4m43s
Check / Run checks (push) Successful in 2m24s
Build and deploy / Build container (push) Successful in 58s
Build and deploy / Deploy container (push) Has been skipped
2024-07-30 00:06:49 +02:00
14e14ca479
No need for Arc<RwLock<_>> inside the device wrapper anymore
All checks were successful
Build and deploy / Build application (push) Successful in 4m27s
Check / Run checks (push) Successful in 2m14s
Build and deploy / Build container (push) Successful in 55s
Build and deploy / Deploy container (push) Has been skipped
2024-07-26 01:17:12 +02:00
3fd8dddeb2
No more cast_mut() 2024-07-26 00:37:53 +02:00
6c797820dc
Updated to newest rust nightly 2024-07-26 00:25:49 +02:00
2cf4e40ad5
Devices are now clonable 2024-07-26 00:25:30 +02:00
98ab265fed
Improved Lua macro situation
All checks were successful
Build and deploy / Build application (push) Successful in 6m20s
Check / Run checks (push) Successful in 2m19s
Build and deploy / Build container (push) Successful in 1m16s
Build and deploy / Deploy container (push) Has been skipped
2024-07-25 00:49:10 +02:00
006320be18
Added trash light automation
All checks were successful
Build and deploy / Build application (push) Successful in 3m49s
Check / Run checks (push) Successful in 2m16s
Build and deploy / Build container (push) Successful in 49s
Build and deploy / Deploy container (push) Successful in 32s
2024-07-15 00:37:24 +02:00
3b8f15eb88
Fixed activating scene
All checks were successful
Build and deploy / Build application (push) Successful in 3m51s
Check / Run checks (push) Successful in 2m37s
Build and deploy / Build container (push) Successful in 1m8s
Build and deploy / Deploy container (push) Successful in 36s
2024-07-10 02:02:43 +02:00
f7b709a2c7
Added temperature to air_filter
All checks were successful
Build and deploy / Build application (push) Successful in 3m49s
Check / Run checks (push) Successful in 2m11s
Build and deploy / Build container (push) Successful in 56s
Build and deploy / Deploy container (push) Successful in 37s
2024-07-09 02:37:33 +02:00
bab85a092e
SpeedValues -> SpeedValue 2024-07-09 02:36:39 +02:00
01dfc6b81e
Improved google_home tests
All checks were successful
Build and deploy / Build application (push) Successful in 3m45s
Check / Run checks (push) Successful in 2m16s
Build and deploy / Build container (push) Successful in 48s
Build and deploy / Deploy container (push) Successful in 36s
2024-07-09 00:00:00 +02:00
758500a071
Cleanup 2024-07-09 00:00:00 +02:00
9aa16e3ef8
Started actually using the google home trait macro 2024-07-09 00:00:00 +02:00
d84ff8ec8e
Initial google home trait macro 2024-07-08 23:59:59 +02:00
fb7af4a8b1
Added caching to pre-commit checks
All checks were successful
Build and deploy / Build application (push) Successful in 3m46s
Check / Run checks (push) Successful in 3m53s
Build and deploy / Build container (push) Successful in 46s
Build and deploy / Deploy container (push) Successful in 34s
2024-07-08 23:34:50 +02:00
c6e63750d0
Fixed bathroom light
All checks were successful
Build and deploy / Build application (push) Successful in 3m48s
Check / Run checks (push) Successful in 3m20s
Build and deploy / Build container (push) Successful in 1m49s
Build and deploy / Deploy container (push) Successful in 38s
2024-07-08 23:25:24 +02:00
5bf6e6bc3c
Fixed build after gitea update 2024-07-08 23:25:20 +02:00
526c82096c
Improved workflow
All checks were successful
Build and deploy / Build application (push) Successful in 4m23s
Build and deploy / Build container (push) Successful in 1m0s
Check / Run checks (push) Successful in 3m27s
Build and deploy / Deploy container (push) Successful in 36s
2024-06-15 04:31:27 +02:00
32aa981e31
Fixed fan speed control in google home
All checks were successful
Build and deploy automation_rs / Run pre-commit checks (push) Successful in 4m28s
Build and deploy automation_rs / Build automation_rs (push) Successful in 4m43s
Build and deploy automation_rs / Build Docker image (push) Successful in 54s
Build and deploy automation_rs / Deploy Docker container (push) Successful in 30s
2024-05-28 22:51:22 +02:00
d8d348d906
Fixed presence topic
All checks were successful
Build and deploy automation_rs / Run pre-commit checks (push) Successful in 6m50s
Build and deploy automation_rs / Build automation_rs (push) Successful in 4m47s
Build and deploy automation_rs / Build Docker image (push) Successful in 1m11s
Build and deploy automation_rs / Deploy Docker container (push) Successful in 33s
2024-05-24 22:43:03 +02:00
6ed1ee6ebc
Fixed typo with washer
All checks were successful
Build and deploy automation_rs / Run pre-commit checks (push) Successful in 5m39s
Build and deploy automation_rs / Build automation_rs (push) Successful in 5m3s
Build and deploy automation_rs / Build Docker image (push) Successful in 1m9s
Build and deploy automation_rs / Deploy Docker container (push) Successful in 29s
2024-05-15 00:35:23 +02:00
113f9f926c
Switched from custom pre-commit script to using the pre-commit tool
All checks were successful
Build and deploy automation_rs / Run pre-commit checks (push) Successful in 4m1s
Build and deploy automation_rs / Build automation_rs (push) Successful in 4m31s
Build and deploy automation_rs / Build Docker image (push) Successful in 50s
Build and deploy automation_rs / Deploy Docker container (push) Successful in 32s
2024-05-10 01:28:50 +02:00
794b8eef19
Quickly hacked in is_on function on devices in lua
All checks were successful
Build and deploy automation_rs / Build automation_rs (push) Successful in 5m48s
Build and deploy automation_rs / Build Docker image (push) Successful in 47s
Build and deploy automation_rs / Deploy Docker container (push) Successful in 30s
In order to get feature parity with pre-lua the is_on function is
manually implemented on all wrapped devices in lua
This implementation will need to be improved in the future.
2024-05-07 00:05:38 +02:00
c7fc25d239
Fix: Scheduled function can not run async functions
Since Lua is not Send, this turned out to be a bit more complicated.
In order to make it work the async function needs to be pinned to a
single thread.
It works now, but the implementation looks a bit messy. Not sure it can
be improved through.
2024-05-07 00:05:38 +02:00
bf3d757710
Added lua function to get the current hostname
This makes it possible to set options depending on what machine we are
running
2024-05-07 00:05:38 +02:00
bb15558ab2
Fixed typo in README.md and added mosquitto as word 2024-05-07 00:05:37 +02:00
02d6630ac6
Started work on reimplementing schedules 2024-05-07 00:05:37 +02:00
456d7a359b
Fixed spelling mistakes 2024-05-07 00:05:37 +02:00
2ff59872b2
Moved last config items to lua + small cleanup 2024-05-07 00:05:37 +02:00
2a3b14267b
Fixed visibility of device configs 2024-05-07 00:05:37 +02:00
44a40d4dfa
LuaDevice macro now uses LuaDeviceCreate trait to create devices from configs 2024-05-07 00:05:37 +02:00
9f636a2572
mqtt client is now created in lua 2024-05-07 00:05:37 +02:00
fcd0b370d6
DeviceManager no longer handles subscribing and filtering topics, each device has to do this themselves now 2024-05-07 00:05:37 +02:00
3e4ea8952a
Improved how devices are created, ntfy and presence are now treated like any other device 2024-05-07 00:05:36 +02:00
5069d1b0e7
Moved schedule config from yml to lua 2024-05-07 00:05:36 +02:00
3225dbdda9
Set lua warning function 2024-05-07 00:05:36 +02:00
20feaa6308
Slight macro cleanup 2024-05-07 00:05:36 +02:00
55237a2ba2
Improved the internals of the LuaDeviceConfig macro and improve the
usability of the macro
2024-05-07 00:05:36 +02:00
024b9c9dbc
Use helper types to process config input into the right type 2024-05-07 00:05:36 +02:00
51f689b199
Added helper type to convert from ip addr to socketaddr with the correct port 2024-05-07 00:05:36 +02:00
a2ee2ad71d
Added rename option to macro 2024-05-07 00:05:36 +02:00
f4a1b507e5
Everything needed to construct a new device is passed in through lua 2024-05-07 00:05:36 +02:00
bfc73c7bd3
Device config is now done through lua 2024-05-07 00:05:36 +02:00
f50bc4bd0c
Replaced impl_cast with a new and improved trait
With this trait the impl_cast macros are no longer needed, simplifying
everything.
This commit also improved how the actual casting itself is handled.
2024-05-07 00:05:32 +02:00
3689a52afd
Replaced impl_cast with a new and improved trait
All checks were successful
Build and deploy automation_rs / Build automation_rs (push) Successful in 4m0s
Build and deploy automation_rs / Build Docker image (push) Successful in 52s
Build and deploy automation_rs / Deploy Docker container (push) Successful in 28s
With this trait the impl_cast macros are no longer needed, simplifying
everything.
This commit also improved how the actual casting itself is handled.
2024-05-05 00:33:21 +02:00
cde9654a78
Fix: Memory leak
All checks were successful
Build and deploy automation_rs / Build automation_rs (push) Successful in 4m19s
Build and deploy automation_rs / Build Docker image (push) Successful in 1m3s
Build and deploy automation_rs / Deploy Docker container (push) Successful in 31s
It turns out that console-subscriber has a memory leak, this is fixed in
main, but there has not been a new release yet. So for now we go back
to tracing subscriber.
2024-05-03 01:07:24 +02:00
40ba4c47cf
Fix: contact sensor turns off lights even if they were already on
All checks were successful
Build and deploy automation_rs / Build automation_rs (push) Successful in 3m57s
Build and deploy automation_rs / Build Docker image (push) Successful in 45s
Build and deploy automation_rs / Deploy Docker container (push) Successful in 29s
2024-04-26 06:00:53 +02:00
8b0c1ae352
Report AirFilter humidity
All checks were successful
Build and deploy automation_rs / Build automation_rs (push) Successful in 3m57s
Build and deploy automation_rs / Build Docker image (push) Successful in 44s
Build and deploy automation_rs / Deploy Docker container (push) Successful in 29s
2024-04-23 02:47:10 +02:00
8b191f6013
Updated airfilter mqtt topic
All checks were successful
Build and deploy automation_rs / Build automation_rs (push) Successful in 7m4s
Build and deploy automation_rs / Build Docker image (push) Successful in 1m28s
Build and deploy automation_rs / Deploy Docker container (push) Successful in 29s
2024-03-27 04:46:06 +01:00
476688e3cb
Always turn all the lights on when a contact sensor is activated, not matter the previous state
All checks were successful
Build and deploy automation_rs / Build automation_rs (push) Successful in 7m25s
Build and deploy automation_rs / Build Docker image (push) Successful in 2m45s
Build and deploy automation_rs / Deploy Docker container (push) Successful in 37s
2024-03-05 20:06:00 +01:00
6e4a63e9d7
Improvement: Job names could be better
All checks were successful
Build and deploy automation_rs / Build automation_rs (push) Successful in 4m27s
Build and deploy automation_rs / Build Docker image (push) Successful in 47s
Build and deploy automation_rs / Deploy Docker container (push) Successful in 21s
2023-11-24 00:09:45 +01:00
b4427f2140
Fix: Wake On LAN is not working
The docker container needs to be created with the network option set to
one of the networks otherwise it will not work.
2023-11-23 23:04:26 +01:00
234e891418
Fix: main is used instead of master, only builds for feature/action
All checks were successful
Build and deploy automation_rs / Build (push) Successful in 4m38s
Build and deploy automation_rs / Create container (push) Successful in 43s
Build and deploy automation_rs / Deploy Docker container (push) Successful in 36s
Accidentally used main instead of master in the workflow.
Also hardcoded feature/action as the only feature branch that triggers a
build, instead any feature branch will now trigger a build.
2023-11-23 00:47:19 +01:00
39f9b997ed
Fix: Only master branch should push the docker image
All checks were successful
Build and deploy automation_rs / Build (push) Successful in 4m57s
Build and deploy automation_rs / Create container (push) Successful in 57s
Build and deploy automation_rs / Deploy Docker container (push) Has been skipped
2023-11-23 00:26:24 +01:00
cdb02eb5dd
Feature: Deploy Docker container after it is created
All checks were successful
Build and deploy automation_rs / Build (push) Successful in 4m47s
Build and deploy automation_rs / Create container (push) Successful in 1m2s
Build and deploy automation_rs / Deploy Docker container (push) Has been skipped
2023-11-22 01:17:30 +01:00
c77064b5b9
Feature: Use Gitea Actions to build automation_rs
All checks were successful
Build and deploy automation_rs / Build (push) Successful in 6m39s
Build and deploy automation_rs / Create Docker container (push) Successful in 1m1s
Builds automation_rs and the corresponding docker image.
The binary is uploaded as an artifact and the image is uploaded to the
registry.

In order to improve caching the nightly version is locked using
rust-toolchain.toml
2023-11-22 00:40:05 +01:00
73a2b077ed
Fmt: Added cargofmt config and reformatted files
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-20 23:27:48 +01:00
78bb80d510
Fixed: Frontdoor uses the wrong presence topic
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-20 22:55:04 +01:00
5333d8042f
Fixed formatting
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-17 01:10:45 +01:00
db17b68e90
Feature: Schedule devices turning on/off
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-17 00:01:13 +01:00
0154d19b71
Fixed typo in topic name for air_filter
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-16 00:57:51 +01:00
96 changed files with 6484 additions and 4490 deletions

View File

@ -1,49 +0,0 @@
kind: pipeline
type: docker
name: default
steps:
- name: build
image: docker
volumes:
- name: socket
path: /var/run/docker.sock
commands:
- DOCKER_BUILDKIT=1 docker build -t automation_rs .
- name: deploy
image: docker
volumes:
- name: socket
path: /var/run/docker.sock
environment:
MQTT_PASSWORD:
from_secret: MQTT_PASSWORD
HUE_TOKEN:
from_secret: HUE_TOKEN
NTFY_TOPIC:
from_secret: NTFY_TOPIC
RUST_LOG:
from_secret: RUST_LOG
commands:
- docker stop automation_rs || true
- docker rm automation_rs || true
# Networks need to be setup to to allow broadcasts: https://www.devwithimagination.com/2020/06/15/homebridge-docker-and-wake-on-lan/ https://github.com/dhutchison/container-images/blob/0c2d7d96bab751fb0a008cc91ba2990724bbd11f/homebridge/configure_docker_networks_for_wol.sh
# Needs to be done for ALL networks, because we can't seem to control which interface gets used to send the broadcast
- docker create -e RUST_LOG=$RUST_LOG -e MQTT_PASSWORD=$MQTT_PASSWORD -e HUE_TOKEN=$HUE_TOKEN -e NTFY_TOPIC=$NTFY_TOPIC --network mqtt --restart unless-stopped --name automation_rs automation_rs
- docker network connect web automation_rs
- docker start automation_rs
when:
branch:
- master
event:
exclude:
- pull_request
volumes:
- name: socket
host:
path: /var/run/docker.sock

View File

@ -1,15 +0,0 @@
#!/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 Normal file
View File

@ -0,0 +1 @@
*.xcf filter=lfs diff=lfs merge=lfs -text

107
.gitea/workflows/build.yml Normal file
View File

@ -0,0 +1,107 @@
# Based on: https://pastebin.com/99Fq2b2w
name: Build and deploy
on:
push:
branches:
- master
- feature/**
jobs:
build:
name: Build application
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
rustflags: ""
- name: Build
run: cargo build --release
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: automation
path: target/x86_64-unknown-linux-gnu/release/automation
container:
name: Build container
runs-on: ubuntu-latest
needs: [build]
container: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download artifact
uses: actions/download-artifact@v3
with:
name: automation
- 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}}
- name: Login to registry
uses: https://github.com/docker/login-action@v3
with:
registry: git.huizinga.dev
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and 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 }}
deploy:
name: Deploy container
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-latest
needs: [container]
if: gitea.ref == 'refs/heads/master'
steps:
- name: Stop and remove current container
run: |
docker stop automation_rs || true
docker rm automation_rs || true
- name: Create container
run: |
docker create \
--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
docker network connect web automation_rs
- name: Start container
run: docker start automation_rs
# TODO: Perform a healthcheck

32
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,32 @@
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

2
.rustfmt.toml Normal file
View File

@ -0,0 +1,2 @@
imports_granularity = "Module"
group_imports = "StdExternalCrate"

2
.typos.toml Normal file
View File

@ -0,0 +1,2 @@
[default.extend-words]
mosquitto = "mosquitto"

1613
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,42 +4,85 @@ version = "0.1.0"
edition = "2021"
[workspace]
members = ["impl_cast", "google-home"]
members = [
"automation_macro",
"automation_cast",
"google_home/google_home",
"google_home/google_home_macro",
"automation_devices",
"automation_lib",
]
[dependencies]
rumqttc = "0.18"
[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"
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"
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"
serde_with = "3.2.0"
enum_dispatch = "0.3.12"
indexmap = { version = "2.0.0", features = ["serde"] }
serde_yaml = "0.9.27"
uuid = "1.8.0"
wakey = "0.3.0"
air_filter_types = { git = "https://git.huizinga.dev/Dreaded_X/airfilter", tag = "v0.4.4" }
[dependencies]
automation_lib = { workspace = true }
automation_devices = { workspace = true }
google_home = { workspace = true }
mlua = { workspace = true }
tokio = { workspace = true }
hostname = { workspace = true }
rumqttc = { workspace = true }
axum = { workspace = true }
tracing = { workspace = true }
anyhow = { workspace = true }
dotenvy = { workspace = true }
tracing-subscriber = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
serde_json = { workspace = true }
reqwest = { workspace = true }
[patch.crates-io]
wakey = { git = "https://git.huizinga.dev/Dreaded_X/wakey" }

View File

@ -1,64 +1,8 @@
FROM rust:bookworm AS build
FROM gcr.io/distroless/cc-debian12:nonroot
# Create user
ENV USER=automation
ENV UID=10001
RUN adduser \
--disabled-password \
--gecos "" \
--home "/nonexistent" \
--shell "/sbin/nologin" \
--no-create-home \
--uid "${UID}" \
"${USER}"
ENV AUTOMATION_CONFIG=/app/config.lua
COPY ./config.lua /app/config.lua
# Create basic project structure
RUN cargo new --bin /app
RUN cargo new --lib /app/impl_cast && truncate -s 0 /app/impl_cast/src/lib.rs
RUN cargo new --lib /app/google-home
# Get the correct version of the compiler
RUN rustup default nightly
# Copy cargo config
COPY .cargo/config.toml /app/.cargo/config.toml
# Copy the Cargo.toml files
COPY impl_cast/Cargo.toml /app/impl_cast
COPY google-home/Cargo.toml /app/google-home
COPY Cargo.toml Cargo.lock /app/
# Download and build all the dependencies
WORKDIR /app
RUN --mount=type=cache,target=/usr/local/cargo/registry cargo build --release
# Build impl_cast
COPY impl_cast/src/ /app/impl_cast/src/
RUN --mount=type=cache,target=/usr/local/cargo/registry set -e; touch /app/impl_cast/src/lib.rs; cargo build --release --package impl_cast
# Build google-home
COPY google-home/src/ /app/google-home/src/
RUN --mount=type=cache,target=/usr/local/cargo/registry set -e; touch /app/google-home/src/lib.rs; cargo build --release --package google-home
# Build automation
COPY src/ /app/src/
RUN --mount=type=cache,target=/usr/local/cargo/registry set -e; touch /app/src/main.rs /app/src/lib.rs /app/google-home/src/lib.rs /app/impl_cast/src/lib.rs; cargo build --release
CMD ["/app/target/release/automation"]
# FINAL IMAGE
FROM gcr.io/distroless/cc-debian12:latest
COPY --from=build /etc/passwd /etc/passwd
COPY --from=build /etc/group /etc/group
ENV AUTOMATION_CONFIG=/app/config.yml
COPY config/config.yml /app/config.yml
WORKDIR /app
COPY --from=build /app/target/x86_64-unknown-linux-gnu/release/automation ./
USER automation:automation
COPY ./automation /app/automation
CMD ["/app/automation"]

View File

@ -1,8 +1,12 @@
# automation_rs
Custom home automation solution with google-home intergration
Custom home automation solution with Google Home integration and lua scripting.
## Development
Make sure to setup git hooks by running
```sh
git config --local core.hooksPath .git-hooks/
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
```

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

BIN
assets/logo.xcf (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,8 @@
[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]

View File

@ -0,0 +1,28 @@
#![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)
}
}

View File

@ -0,0 +1,27 @@
[package]
name = "automation_devices"
version = "0.1.0"
edition = "2021"
[dependencies]
automation_lib = { workspace = true }
automation_macro = { workspace = true }
automation_cast = { workspace = true }
google_home = { workspace = true }
mlua = { workspace = true }
async-trait = { workspace = true }
dyn-clone = { workspace = true }
rumqttc = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
serde_json = { workspace = true }
impls = { workspace = true }
serde = { workspace = true }
reqwest = { workspace = true } # Use rustls, since the other packages also use rustls
anyhow = { workspace = true }
axum = { workspace = true }
bytes = { workspace = true }
thiserror = { workspace = true }
eui48 = { workspace = true }
wakey = { workspace = true }
air_filter_types = { workspace = true }

View File

@ -0,0 +1,226 @@
use async_trait::async_trait;
use automation_lib::config::InfoConfig;
use automation_lib::device::{Device, LuaDeviceCreate};
use automation_macro::LuaDeviceConfig;
use google_home::device::Name;
use google_home::errors::ErrorCode;
use google_home::traits::{
AvailableSpeeds, FanSpeed, HumiditySetting, OnOff, Speed, SpeedValue, TemperatureSetting,
TemperatureUnit,
};
use google_home::types::Type;
use thiserror::Error;
use tracing::{debug, trace};
#[derive(Debug, Clone, LuaDeviceConfig)]
pub struct Config {
#[device_config(flatten)]
pub info: InfoConfig,
pub url: String,
}
#[derive(Debug, Clone)]
pub struct AirFilter {
config: Config,
}
#[derive(Debug, Error)]
pub enum Error {
#[error("Connection error")]
ReqwestError(#[from] reqwest::Error),
}
impl From<Error> for google_home::errors::ErrorCode {
fn from(value: Error) -> Self {
match value {
// Assume that if we encounter a ReqwestError the device is offline
Error::ReqwestError(_) => {
Self::DeviceError(google_home::errors::DeviceError::DeviceOffline)
}
}
}
}
// TODO: Handle error properly
impl AirFilter {
async fn set_fan_speed(&self, speed: air_filter_types::FanSpeed) -> Result<(), Error> {
let message = air_filter_types::SetFanSpeed::new(speed);
let url = format!("{}/state/fan", self.config.url);
let client = reqwest::Client::new();
client.put(url).json(&message).send().await?;
Ok(())
}
async fn get_fan_state(&self) -> Result<air_filter_types::FanState, Error> {
let url = format!("{}/state/fan", self.config.url);
Ok(reqwest::get(url).await?.json().await?)
}
async fn get_sensor_data(&self) -> Result<air_filter_types::SensorData, Error> {
let url = format!("{}/state/sensor", self.config.url);
Ok(reqwest::get(url).await?.json().await?)
}
}
#[async_trait]
impl LuaDeviceCreate for AirFilter {
type Config = Config;
type Error = rumqttc::ClientError;
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
trace!(id = config.info.identifier(), "Setting up AirFilter");
Ok(Self { config })
}
}
impl Device for AirFilter {
fn get_id(&self) -> String {
self.config.info.identifier()
}
}
#[async_trait]
impl google_home::Device for AirFilter {
fn get_device_type(&self) -> Type {
Type::AirPurifier
}
fn get_device_name(&self) -> Name {
Name::new(&self.config.info.name)
}
fn get_id(&self) -> String {
Device::get_id(self)
}
async fn is_online(&self) -> bool {
self.get_sensor_data().await.is_ok()
}
fn get_room_hint(&self) -> Option<&str> {
self.config.info.room.as_deref()
}
fn will_report_state(&self) -> bool {
false
}
}
#[async_trait]
impl OnOff for AirFilter {
async fn on(&self) -> Result<bool, ErrorCode> {
Ok(self.get_fan_state().await?.speed != air_filter_types::FanSpeed::Off)
}
async fn set_on(&self, on: bool) -> Result<(), ErrorCode> {
debug!("Turning on air filter: {on}");
if on {
self.set_fan_speed(air_filter_types::FanSpeed::High).await?;
} else {
self.set_fan_speed(air_filter_types::FanSpeed::Off).await?;
}
Ok(())
}
}
#[async_trait]
impl FanSpeed for AirFilter {
fn available_fan_speeds(&self) -> AvailableSpeeds {
AvailableSpeeds {
speeds: vec![
Speed {
speed_name: "off".into(),
speed_values: vec![SpeedValue {
speed_synonym: vec!["Off".into()],
lang: "en".into(),
}],
},
Speed {
speed_name: "low".into(),
speed_values: vec![SpeedValue {
speed_synonym: vec!["Low".into()],
lang: "en".into(),
}],
},
Speed {
speed_name: "medium".into(),
speed_values: vec![SpeedValue {
speed_synonym: vec!["Medium".into()],
lang: "en".into(),
}],
},
Speed {
speed_name: "high".into(),
speed_values: vec![SpeedValue {
speed_synonym: vec!["High".into()],
lang: "en".into(),
}],
},
],
ordered: true,
}
}
async fn current_fan_speed_setting(&self) -> Result<String, ErrorCode> {
let speed = self.get_fan_state().await?.speed;
let speed = match speed {
air_filter_types::FanSpeed::Off => "off",
air_filter_types::FanSpeed::Low => "low",
air_filter_types::FanSpeed::Medium => "medium",
air_filter_types::FanSpeed::High => "high",
};
Ok(speed.into())
}
async fn set_fan_speed(&self, fan_speed: String) -> Result<(), ErrorCode> {
let fan_speed = fan_speed.as_str();
let speed = if fan_speed == "off" {
air_filter_types::FanSpeed::Off
} else if fan_speed == "low" {
air_filter_types::FanSpeed::Low
} else if fan_speed == "medium" {
air_filter_types::FanSpeed::Medium
} else if fan_speed == "high" {
air_filter_types::FanSpeed::High
} else {
return Err(google_home::errors::DeviceError::TransientError.into());
};
self.set_fan_speed(speed).await?;
Ok(())
}
}
#[async_trait]
impl HumiditySetting for AirFilter {
fn query_only_humidity_setting(&self) -> Option<bool> {
Some(true)
}
async fn humidity_ambient_percent(&self) -> Result<isize, ErrorCode> {
Ok(self.get_sensor_data().await?.humidity().round() as isize)
}
}
#[async_trait]
impl TemperatureSetting for AirFilter {
fn query_only_temperature_control(&self) -> Option<bool> {
Some(true)
}
#[allow(non_snake_case)]
fn temperatureUnitForUX(&self) -> TemperatureUnit {
TemperatureUnit::Celsius
}
async fn temperature_ambient_celsius(&self) -> Result<f32, ErrorCode> {
// HACK: Round to one decimal place
Ok((10.0 * self.get_sensor_data().await?.temperature()).round() / 10.0)
}
}

View File

@ -0,0 +1,254 @@
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use automation_lib::action_callback::ActionCallback;
use automation_lib::config::{InfoConfig, MqttDeviceConfig};
use automation_lib::device::{Device, LuaDeviceCreate};
use automation_lib::error::DeviceConfigError;
use automation_lib::event::{OnMqtt, OnPresence};
use automation_lib::messages::{ContactMessage, PresenceMessage};
use automation_lib::mqtt::WrappedAsyncClient;
use automation_lib::presence::DEFAULT_PRESENCE;
use automation_macro::LuaDeviceConfig;
use google_home::device;
use google_home::errors::{DeviceError, ErrorCode};
use google_home::traits::OpenClose;
use google_home::types::Type;
use serde::Deserialize;
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use tokio::task::JoinHandle;
use tracing::{debug, error, trace, warn};
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Copy)]
pub enum SensorType {
Door,
Drawer,
Window,
}
// NOTE: If we add more presence devices we might need to move this out of here
#[derive(Debug, Clone, LuaDeviceConfig)]
pub struct PresenceDeviceConfig {
#[device_config(flatten)]
pub mqtt: MqttDeviceConfig,
#[device_config(with(Duration::from_secs))]
pub timeout: Duration,
}
#[derive(Debug, Clone, LuaDeviceConfig)]
pub struct Config {
#[device_config(flatten)]
pub info: InfoConfig,
#[device_config(flatten)]
pub mqtt: MqttDeviceConfig,
#[device_config(from_lua, default)]
pub presence: Option<PresenceDeviceConfig>,
#[device_config(default(SensorType::Window))]
pub sensor_type: SensorType,
#[device_config(from_lua, default)]
pub callback: ActionCallback<ContactSensor, bool>,
#[device_config(from_lua)]
pub client: WrappedAsyncClient,
}
#[derive(Debug)]
struct State {
overall_presence: bool,
is_closed: bool,
handle: Option<JoinHandle<()>>,
}
#[derive(Debug, Clone)]
pub struct ContactSensor {
config: Config,
state: Arc<RwLock<State>>,
}
impl ContactSensor {
async fn state(&self) -> RwLockReadGuard<State> {
self.state.read().await
}
async fn state_mut(&self) -> RwLockWriteGuard<State> {
self.state.write().await
}
}
#[async_trait]
impl LuaDeviceCreate for ContactSensor {
type Config = Config;
type Error = DeviceConfigError;
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
trace!(id = config.info.identifier(), "Setting up ContactSensor");
config
.client
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
.await?;
let state = State {
overall_presence: DEFAULT_PRESENCE,
is_closed: true,
handle: None,
};
let state = Arc::new(RwLock::new(state));
Ok(Self { config, state })
}
}
impl Device for ContactSensor {
fn get_id(&self) -> String {
self.config.info.identifier()
}
}
#[async_trait]
impl google_home::Device for ContactSensor {
fn get_device_type(&self) -> google_home::types::Type {
match self.config.sensor_type {
SensorType::Door => Type::Door,
SensorType::Drawer => Type::Drawer,
SensorType::Window => Type::Window,
}
}
fn get_id(&self) -> String {
Device::get_id(self)
}
fn get_device_name(&self) -> google_home::device::Name {
device::Name::new(&self.config.info.name)
}
fn get_room_hint(&self) -> Option<&str> {
self.config.info.room.as_deref()
}
fn will_report_state(&self) -> bool {
false
}
async fn is_online(&self) -> bool {
true
}
}
#[async_trait]
impl OpenClose for ContactSensor {
fn discrete_only_open_close(&self) -> Option<bool> {
Some(true)
}
fn query_only_open_close(&self) -> Option<bool> {
Some(true)
}
async fn open_percent(&self) -> Result<u8, ErrorCode> {
if self.state().await.is_closed {
Ok(0)
} else {
Ok(100)
}
}
async fn set_open_percent(&self, _open_percent: u8) -> Result<(), ErrorCode> {
Err(DeviceError::ActionNotAvailable.into())
}
}
#[async_trait]
impl OnPresence for ContactSensor {
async fn on_presence(&self, presence: bool) {
self.state_mut().await.overall_presence = presence;
}
}
#[async_trait]
impl OnMqtt for ContactSensor {
async fn on_mqtt(&self, message: rumqttc::Publish) {
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
return;
}
let is_closed = match ContactMessage::try_from(message) {
Ok(state) => state.is_closed(),
Err(err) => {
error!(id = self.get_id(), "Failed to parse message: {err}");
return;
}
};
if is_closed == self.state().await.is_closed {
return;
}
self.config.callback.call(self, &!is_closed).await;
debug!(id = self.get_id(), "Updating state to {is_closed}");
self.state_mut().await.is_closed = is_closed;
// Check if this contact sensor works as a presence device
// If not we are done here
let presence = match &self.config.presence {
Some(presence) => presence.clone(),
None => return,
};
if !is_closed {
// Activate presence and stop any timeout once we open the door
if let Some(handle) = self.state_mut().await.handle.take() {
handle.abort();
}
// Only use the door as an presence sensor if there the current presence is set false
// This is to prevent the house from being marked as present for however long the
// timeout is set when leaving the house
if !self.state().await.overall_presence {
self.config
.client
.publish(
&presence.mqtt.topic,
rumqttc::QoS::AtLeastOnce,
false,
serde_json::to_string(&PresenceMessage::new(true)).unwrap(),
)
.await
.map_err(|err| {
warn!(
"Failed to publish presence on {}: {err}",
presence.mqtt.topic
)
})
.ok();
}
} else {
// Once the door is closed again we start a timeout for removing the presence
let device = self.clone();
self.state_mut().await.handle = Some(tokio::spawn(async move {
debug!(
id = device.get_id(),
"Starting timeout ({:?}) for contact sensor...", presence.timeout
);
tokio::time::sleep(presence.timeout).await;
debug!(id = device.get_id(), "Removing door device!");
device
.config
.client
.publish(&presence.mqtt.topic, rumqttc::QoS::AtLeastOnce, false, "")
.await
.map_err(|err| {
warn!(
"Failed to publish presence on {}: {err}",
presence.mqtt.topic
)
})
.ok();
}));
}
}
}

View File

@ -0,0 +1,89 @@
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();
}
}

View File

@ -1,17 +1,13 @@
use std::net::{Ipv4Addr, SocketAddr};
use std::convert::Infallible;
use std::net::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},
devices::Device,
error::DeviceConfigError,
event::OnDarkness,
event::OnPresence,
};
#[derive(Debug)]
pub enum Flag {
Presence,
@ -20,41 +16,22 @@ pub enum Flag {
#[derive(Debug, Clone, Deserialize)]
pub struct FlagIDs {
pub presence: isize,
pub darkness: isize,
presence: isize,
darkness: isize,
}
#[derive(Debug, Deserialize)]
pub struct HueBridgeConfig {
pub ip: Ipv4Addr,
#[derive(Debug, LuaDeviceConfig, Clone)]
pub struct Config {
pub identifier: String,
#[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 80)))]
pub addr: SocketAddr,
pub login: String,
pub flags: FlagIDs,
}
#[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, Clone)]
pub struct HueBridge {
config: Config,
}
#[derive(Debug, Serialize)]
@ -62,16 +39,27 @@ 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.flag_ids.presence,
Flag::Darkness => self.flag_ids.darkness,
Flag::Presence => self.config.flags.presence,
Flag::Darkness => self.config.flags.darkness,
};
let url = format!(
"http://{}/api/{}/sensors/{flag_id}/state",
self.addr, self.login
self.config.addr, self.config.login
);
trace!(?flag, flag_id, value, "Sending request to change flag");
@ -96,14 +84,14 @@ impl HueBridge {
}
impl Device for HueBridge {
fn get_id(&self) -> &str {
&self.identifier
fn get_id(&self) -> String {
self.config.identifier.clone()
}
}
#[async_trait]
impl OnPresence for HueBridge {
async fn on_presence(&mut self, presence: bool) {
async fn on_presence(&self, presence: bool) {
trace!("Bridging presence to hue");
self.set_flag(Flag::Presence, presence).await;
}
@ -111,7 +99,7 @@ impl OnPresence for HueBridge {
#[async_trait]
impl OnDarkness for HueBridge {
async fn on_darkness(&mut self, dark: bool) {
async fn on_darkness(&self, dark: bool) {
trace!("Bridging darkness to hue");
self.set_flag(Flag::Darkness, dark).await;
}

View File

@ -0,0 +1,162 @@
use std::net::SocketAddr;
use anyhow::Result;
use async_trait::async_trait;
use automation_macro::LuaDeviceConfig;
use google_home::errors::ErrorCode;
use google_home::traits::OnOff;
use tracing::{error, trace, warn};
use super::{Device, LuaDeviceCreate};
#[derive(Debug, Clone, LuaDeviceConfig)]
pub struct Config {
pub identifier: String,
#[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 80)))]
pub addr: SocketAddr,
pub login: String,
pub group_id: isize,
pub scene_id: String,
}
#[derive(Debug, Clone)]
pub struct HueGroup {
config: Config,
}
// Couple of helper function to get the correct urls
#[async_trait]
impl LuaDeviceCreate for HueGroup {
type Config = Config;
type Error = rumqttc::ClientError;
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
trace!(id = config.identifier, "Setting up AudioSetup");
Ok(Self { config })
}
}
impl HueGroup {
fn url_base(&self) -> String {
format!("http://{}/api/{}", self.config.addr, self.config.login)
}
fn url_set_action(&self) -> String {
format!("{}/groups/{}/action", self.url_base(), self.config.group_id)
}
fn url_get_state(&self) -> String {
format!("{}/groups/{}", self.url_base(), self.config.group_id)
}
}
impl Device for HueGroup {
fn get_id(&self) -> String {
self.config.identifier.clone()
}
}
#[async_trait]
impl OnOff for HueGroup {
async fn set_on(&self, on: bool) -> Result<(), ErrorCode> {
let message = if on {
message::Action::scene(self.config.scene_id.clone())
} else {
message::Action::on(false)
};
let res = reqwest::Client::new()
.put(self.url_set_action())
.json(&message)
.send()
.await;
match res {
Ok(res) => {
let status = res.status();
if !status.is_success() {
warn!(id = self.get_id(), "Status code is not success: {status}");
}
}
Err(err) => error!(id = self.get_id(), "Error: {err}"),
}
Ok(())
}
async fn on(&self) -> Result<bool, ErrorCode> {
let res = reqwest::Client::new()
.get(self.url_get_state())
.send()
.await;
match res {
Ok(res) => {
let status = res.status();
if !status.is_success() {
warn!(id = self.get_id(), "Status code is not success: {status}");
}
let on = match res.json::<message::Info>().await {
Ok(info) => info.any_on(),
Err(err) => {
error!(id = self.get_id(), "Failed to parse message: {err}");
// TODO: Error code
return Ok(false);
}
};
return Ok(on);
}
Err(err) => error!(id = self.get_id(), "Error: {err}"),
}
Ok(false)
}
}
mod message {
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct Action {
#[serde(skip_serializing_if = "Option::is_none")]
on: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
scene: Option<String>,
}
impl Action {
pub fn on(on: bool) -> Self {
Self {
on: Some(on),
scene: None,
}
}
pub fn scene(scene: String) -> Self {
Self {
on: None,
scene: Some(scene),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
struct State {
all_on: bool,
any_on: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Info {
state: State,
}
impl Info {
pub fn any_on(&self) -> bool {
self.state.any_on
}
}
}

View File

@ -0,0 +1,116 @@
use async_trait::async_trait;
use automation_lib::action_callback::ActionCallback;
use automation_lib::config::{InfoConfig, MqttDeviceConfig};
use automation_lib::device::{Device, LuaDeviceCreate};
use automation_lib::event::OnMqtt;
use automation_lib::mqtt::WrappedAsyncClient;
use automation_macro::LuaDeviceConfig;
use rumqttc::{matches, Publish};
use serde::Deserialize;
use tracing::{debug, trace, warn};
#[derive(Debug, Clone, LuaDeviceConfig)]
pub struct Config {
#[device_config(flatten)]
pub info: InfoConfig,
#[device_config(flatten)]
pub mqtt: MqttDeviceConfig,
#[device_config(from_lua)]
pub client: WrappedAsyncClient,
#[device_config(from_lua, default)]
pub left_callback: ActionCallback<HueSwitch, ()>,
#[device_config(from_lua, default)]
pub right_callback: ActionCallback<HueSwitch, ()>,
#[device_config(from_lua, default)]
pub left_hold_callback: ActionCallback<HueSwitch, ()>,
#[device_config(from_lua, default)]
pub right_hold_callback: ActionCallback<HueSwitch, ()>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Action {
LeftPress,
LeftPressRelease,
LeftHold,
LeftHoldRelease,
RightPress,
RightPressRelease,
RightHold,
RightHoldRelease,
}
#[derive(Debug, Clone, Deserialize)]
struct State {
action: Action,
}
#[derive(Debug, Clone)]
pub struct HueSwitch {
config: Config,
}
impl Device for HueSwitch {
fn get_id(&self) -> String {
self.config.info.identifier()
}
}
#[async_trait]
impl LuaDeviceCreate for HueSwitch {
type Config = Config;
type Error = rumqttc::ClientError;
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
trace!(id = config.info.identifier(), "Setting up HueSwitch");
config
.client
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
.await?;
Ok(Self { config })
}
}
#[async_trait]
impl OnMqtt for HueSwitch {
async fn on_mqtt(&self, message: Publish) {
// Check if the message is from the device itself or from a remote
if matches(&message.topic, &self.config.mqtt.topic) {
let action = match serde_json::from_slice::<State>(&message.payload) {
Ok(message) => message.action,
Err(err) => {
warn!(id = Device::get_id(self), "Failed to parse message: {err}");
return;
}
};
debug!(id = Device::get_id(self), "Remote action = {:?}", action);
match action {
Action::LeftPressRelease => self.config.left_callback.call(self, &()).await,
Action::RightPressRelease => self.config.right_callback.call(self, &()).await,
Action::LeftHold => self.config.left_hold_callback.call(self, &()).await,
Action::RightHold => self.config.right_hold_callback.call(self, &()).await,
// If there is no hold action, the switch will act like a normal release
Action::RightHoldRelease => {
if !self.config.right_hold_callback.is_set() {
self.config.right_callback.call(self, &()).await
}
}
Action::LeftHoldRelease => {
if !self.config.left_hold_callback.is_set() {
self.config.left_callback.call(self, &()).await
}
}
_ => {}
}
}
}
}

View File

@ -0,0 +1,91 @@
use automation_lib::action_callback::ActionCallback;
use automation_lib::config::{InfoConfig, MqttDeviceConfig};
use automation_lib::device::{Device, LuaDeviceCreate};
use automation_lib::event::OnMqtt;
use automation_lib::messages::{RemoteAction, RemoteMessage};
use automation_lib::mqtt::WrappedAsyncClient;
use automation_macro::LuaDeviceConfig;
use axum::async_trait;
use rumqttc::{matches, Publish};
use tracing::{debug, error, trace};
#[derive(Debug, Clone, LuaDeviceConfig)]
pub struct Config {
#[device_config(flatten)]
pub info: InfoConfig,
#[device_config(default)]
pub single_button: bool,
#[device_config(flatten)]
pub mqtt: MqttDeviceConfig,
#[device_config(from_lua)]
pub client: WrappedAsyncClient,
#[device_config(from_lua)]
pub callback: ActionCallback<IkeaRemote, bool>,
}
#[derive(Debug, Clone)]
pub struct IkeaRemote {
config: Config,
}
impl Device for IkeaRemote {
fn get_id(&self) -> String {
self.config.info.identifier()
}
}
#[async_trait]
impl LuaDeviceCreate for IkeaRemote {
type Config = Config;
type Error = rumqttc::ClientError;
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
trace!(id = config.info.identifier(), "Setting up IkeaRemote");
config
.client
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
.await?;
Ok(Self { config })
}
}
#[async_trait]
impl OnMqtt for IkeaRemote {
async fn on_mqtt(&self, message: Publish) {
// Check if the message is from the deviec itself or from a remote
if matches(&message.topic, &self.config.mqtt.topic) {
let action = match RemoteMessage::try_from(message) {
Ok(message) => message.action(),
Err(err) => {
error!(id = Device::get_id(self), "Failed to parse message: {err}");
return;
}
};
debug!(id = Device::get_id(self), "Remote action = {:?}", action);
let on = if self.config.single_button {
match action {
RemoteAction::On => Some(true),
RemoteAction::BrightnessMoveUp => Some(false),
_ => None,
}
} else {
match action {
RemoteAction::On => Some(true),
RemoteAction::Off => Some(false),
_ => None,
}
};
if let Some(on) = on {
self.config.callback.call(self, &on).await;
}
}
}
}

View File

@ -1,61 +1,46 @@
use std::{
net::{Ipv4Addr, SocketAddr},
str::Utf8Error,
};
use std::convert::Infallible;
use std::net::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},
traits,
};
use google_home::errors::{self, DeviceError};
use google_home::traits::OnOff;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpStream,
};
use tracing::trace;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tracing::{debug, trace};
use crate::{
device_manager::{ConfigExternal, DeviceConfig},
error::DeviceConfigError,
};
#[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;
#[derive(Debug, Clone, Deserialize)]
pub struct KasaOutletConfig {
ip: Ipv4Addr,
#[derive(Debug, Clone)]
pub struct KasaOutlet {
config: Config,
}
#[async_trait]
impl DeviceConfig for KasaOutletConfig {
async fn create(
self,
identifier: &str,
_ext: &ConfigExternal,
) -> Result<Box<dyn Device>, DeviceConfigError> {
trace!(id = identifier, "Setting up KasaOutlet");
impl LuaDeviceCreate for KasaOutlet {
type Config = Config;
type Error = Infallible;
let device = KasaOutlet {
identifier: identifier.into(),
addr: (self.ip, 9999).into(),
};
Ok(Box::new(device))
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
trace!(id = config.identifier, "Setting up KasaOutlet");
Ok(Self { config })
}
}
#[derive(Debug)]
struct KasaOutlet {
identifier: String,
addr: SocketAddr,
}
impl Device for KasaOutlet {
fn get_id(&self) -> &str {
&self.identifier
fn get_id(&self) -> String {
self.config.identifier.clone()
}
}
@ -221,9 +206,9 @@ impl Response {
}
#[async_trait]
impl traits::OnOff for KasaOutlet {
async fn is_on(&self) -> Result<bool, errors::ErrorCode> {
let mut stream = TcpStream::connect(self.addr)
impl OnOff for KasaOutlet {
async fn on(&self) -> Result<bool, errors::ErrorCode> {
let mut stream = TcpStream::connect(self.config.addr)
.await
.or::<DeviceError>(Err(DeviceError::DeviceOffline))?;
@ -256,8 +241,8 @@ impl traits::OnOff for KasaOutlet {
.or(Err(DeviceError::TransientError.into()))
}
async fn set_on(&mut self, on: bool) -> Result<(), errors::ErrorCode> {
let mut stream = TcpStream::connect(self.addr)
async fn set_on(&self, on: bool) -> Result<(), errors::ErrorCode> {
let mut stream = TcpStream::connect(self.config.addr)
.await
.or::<DeviceError>(Err(DeviceError::DeviceOffline))?;
@ -290,3 +275,13 @@ impl traits::OnOff for KasaOutlet {
.or(Err(DeviceError::TransientError.into()))
}
}
#[async_trait]
impl OnPresence for KasaOutlet {
async fn on_presence(&self, presence: bool) {
if !presence {
debug!(id = Device::get_id(self), "Turning device off");
self.set_on(false).await.ok();
}
}
}

View File

@ -0,0 +1,159 @@
mod air_filter;
mod contact_sensor;
mod debug_bridge;
mod hue_bridge;
mod hue_group;
mod hue_switch;
mod ikea_remote;
mod kasa_outlet;
mod light_sensor;
mod wake_on_lan;
mod washer;
mod zigbee;
use std::ops::Deref;
use automation_cast::Cast;
use automation_lib::device::{Device, LuaDeviceCreate};
use zigbee::light::{LightBrightness, LightOnOff};
use zigbee::outlet::{OutletOnOff, OutletPower};
pub use self::air_filter::AirFilter;
pub use self::contact_sensor::ContactSensor;
pub use self::debug_bridge::DebugBridge;
pub use self::hue_bridge::HueBridge;
pub use self::hue_group::HueGroup;
pub use self::hue_switch::HueSwitch;
pub use self::ikea_remote::IkeaRemote;
pub use self::kasa_outlet::KasaOutlet;
pub use self::light_sensor::LightSensor;
pub use self::wake_on_lan::WakeOnLAN;
pub use self::washer::Washer;
macro_rules! register_device {
($lua:expr, $device:ty) => {
$lua.globals()
.set(stringify!($device), $lua.create_proxy::<$device>()?)?;
};
}
macro_rules! impl_device {
($device:ty) => {
impl mlua::UserData for $device {
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
methods.add_async_function("new", |_lua, config| async {
let device: $device = LuaDeviceCreate::create(config)
.await
.map_err(mlua::ExternalError::into_lua_err)?;
Ok(device)
});
methods.add_method("__box", |_lua, this, _: ()| {
let b: Box<dyn Device> = Box::new(this.clone());
Ok(b)
});
methods.add_async_method("get_id", |_lua, this, _: ()| async move { Ok(this.get_id()) });
if impls::impls!($device: google_home::traits::OnOff) {
methods.add_async_method("set_on", |_lua, this, on: bool| async move {
(this.deref().cast() as Option<&dyn google_home::traits::OnOff>)
.expect("Cast should be valid")
.set_on(on)
.await
.unwrap();
Ok(())
});
methods.add_async_method("on", |_lua, this, _: ()| async move {
Ok((this.deref().cast() as Option<&dyn google_home::traits::OnOff>)
.expect("Cast should be valid")
.on()
.await
.unwrap())
});
}
if impls::impls!($device: google_home::traits::Brightness) {
methods.add_async_method("set_brightness", |_lua, this, brightness: u8| async move {
(this.deref().cast() as Option<&dyn google_home::traits::Brightness>)
.expect("Cast should be valid")
.set_brightness(brightness)
.await
.unwrap();
Ok(())
});
methods.add_async_method("brightness", |_lua, this, _: ()| async move {
Ok((this.deref().cast() as Option<&dyn google_home::traits::Brightness>)
.expect("Cast should be valid")
.brightness()
.await
.unwrap())
});
}
if impls::impls!($device: google_home::traits::OpenClose) {
// TODO: Make discrete_only_open_close and query_only_open_close static, that way we can
// add only the supported functions and drop _percet if discrete is true
methods.add_async_method("set_open_percent", |_lua, this, open_percent: u8| async move {
(this.deref().cast() as Option<&dyn google_home::traits::OpenClose>)
.expect("Cast should be valid")
.set_open_percent(open_percent)
.await
.unwrap();
Ok(())
});
methods.add_async_method("open_percent", |_lua, this, _: ()| async move {
Ok((this.deref().cast() as Option<&dyn google_home::traits::OpenClose>)
.expect("Cast should be valid")
.open_percent()
.await
.unwrap())
});
}
}
}
};
}
impl_device!(LightOnOff);
impl_device!(LightBrightness);
impl_device!(OutletOnOff);
impl_device!(OutletPower);
impl_device!(AirFilter);
impl_device!(ContactSensor);
impl_device!(DebugBridge);
impl_device!(HueBridge);
impl_device!(HueGroup);
impl_device!(HueSwitch);
impl_device!(IkeaRemote);
impl_device!(KasaOutlet);
impl_device!(LightSensor);
impl_device!(WakeOnLAN);
impl_device!(Washer);
pub fn register_with_lua(lua: &mlua::Lua) -> mlua::Result<()> {
register_device!(lua, LightOnOff);
register_device!(lua, LightBrightness);
register_device!(lua, OutletOnOff);
register_device!(lua, OutletPower);
register_device!(lua, AirFilter);
register_device!(lua, ContactSensor);
register_device!(lua, DebugBridge);
register_device!(lua, HueBridge);
register_device!(lua, HueGroup);
register_device!(lua, HueSwitch);
register_device!(lua, IkeaRemote);
register_device!(lua, KasaOutlet);
register_device!(lua, LightSensor);
register_device!(lua, WakeOnLAN);
register_device!(lua, Washer);
Ok(())
}

View File

@ -0,0 +1,118 @@
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");
}
}
}
}

View File

@ -0,0 +1,145 @@
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!"))
}
}
}

View File

@ -0,0 +1,141 @@
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;
}
}
}

View File

@ -0,0 +1,299 @@
use std::fmt::Debug;
use std::ops::Deref;
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use automation_lib::action_callback::ActionCallback;
use automation_lib::config::{InfoConfig, MqttDeviceConfig};
use automation_lib::device::{Device, LuaDeviceCreate};
use automation_lib::event::{OnMqtt, OnPresence};
use automation_lib::helpers::serialization::state_deserializer;
use automation_lib::mqtt::WrappedAsyncClient;
use automation_macro::LuaDeviceConfig;
use google_home::device;
use google_home::errors::ErrorCode;
use google_home::traits::{Brightness, OnOff};
use google_home::types::Type;
use rumqttc::{matches, Publish};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use tracing::{debug, trace, warn};
pub trait LightState:
Debug + Clone + Default + Sync + Send + Serialize + Into<StateOnOff> + 'static
{
}
#[derive(Debug, Clone, LuaDeviceConfig)]
pub struct Config<T: LightState> {
#[device_config(flatten)]
pub info: InfoConfig,
#[device_config(flatten)]
pub mqtt: MqttDeviceConfig,
#[device_config(from_lua, default)]
pub callback: ActionCallback<Light<T>, T>,
#[device_config(from_lua)]
pub client: WrappedAsyncClient,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StateOnOff {
#[serde(deserialize_with = "state_deserializer")]
state: bool,
}
impl LightState for StateOnOff {}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StateBrightness {
#[serde(deserialize_with = "state_deserializer")]
state: bool,
brightness: f64,
}
impl LightState for StateBrightness {}
impl From<StateBrightness> for StateOnOff {
fn from(state: StateBrightness) -> Self {
StateOnOff { state: state.state }
}
}
#[derive(Debug, Clone)]
pub struct Light<T: LightState> {
config: Config<T>,
state: Arc<RwLock<T>>,
}
pub type LightOnOff = Light<StateOnOff>;
pub type LightBrightness = Light<StateBrightness>;
impl<T: LightState> Light<T> {
async fn state(&self) -> RwLockReadGuard<T> {
self.state.read().await
}
async fn state_mut(&self) -> RwLockWriteGuard<T> {
self.state.write().await
}
}
#[async_trait]
impl<T: LightState> LuaDeviceCreate for Light<T> {
type Config = Config<T>;
type Error = rumqttc::ClientError;
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
trace!(id = config.info.identifier(), "Setting up IkeaOutlet");
config
.client
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
.await?;
Ok(Self {
config,
state: Default::default(),
})
}
}
impl<T: LightState> Device for Light<T> {
fn get_id(&self) -> String {
self.config.info.identifier()
}
}
#[async_trait]
impl OnMqtt for Light<StateOnOff> {
async fn on_mqtt(&self, message: Publish) {
// Check if the message is from the device itself or from a remote
if matches(&message.topic, &self.config.mqtt.topic) {
let state = match serde_json::from_slice::<StateOnOff>(&message.payload) {
Ok(state) => state,
Err(err) => {
warn!(id = Device::get_id(self), "Failed to parse message: {err}");
return;
}
};
// No need to do anything if the state has not changed
if state.state == self.state().await.state {
return;
}
self.state_mut().await.state = state.state;
debug!(
id = Device::get_id(self),
"Updating state to {:?}",
self.state().await
);
self.config
.callback
.call(self, self.state().await.deref())
.await;
}
}
}
#[async_trait]
impl OnMqtt for Light<StateBrightness> {
async fn on_mqtt(&self, message: Publish) {
// Check if the message is from the deviec itself or from a remote
if matches(&message.topic, &self.config.mqtt.topic) {
let state = match serde_json::from_slice::<StateBrightness>(&message.payload) {
Ok(state) => state,
Err(err) => {
warn!(id = Device::get_id(self), "Failed to parse message: {err}");
return;
}
};
{
let current_state = self.state().await;
// No need to do anything if the state has not changed
if state.state == current_state.state
&& state.brightness == current_state.brightness
{
return;
}
}
self.state_mut().await.state = state.state;
self.state_mut().await.brightness = state.brightness;
debug!(
id = Device::get_id(self),
"Updating state to {:?}",
self.state().await
);
self.config
.callback
.call(self, self.state().await.deref())
.await;
}
}
}
#[async_trait]
impl<T: LightState> OnPresence for Light<T> {
async fn on_presence(&self, presence: bool) {
if !presence {
debug!(id = Device::get_id(self), "Turning device off");
self.set_on(false).await.ok();
}
}
}
#[async_trait]
impl<T: LightState> google_home::Device for Light<T> {
fn get_device_type(&self) -> Type {
Type::Light
}
fn get_device_name(&self) -> device::Name {
device::Name::new(&self.config.info.name)
}
fn get_id(&self) -> String {
Device::get_id(self)
}
async fn is_online(&self) -> bool {
true
}
fn get_room_hint(&self) -> Option<&str> {
self.config.info.room.as_deref()
}
fn will_report_state(&self) -> bool {
// TODO: Implement state reporting
false
}
}
#[async_trait]
impl<T> OnOff for Light<T>
where
T: LightState,
{
async fn on(&self) -> Result<bool, ErrorCode> {
let state = self.state().await;
let state: StateOnOff = state.deref().clone().into();
Ok(state.state)
}
async fn set_on(&self, on: bool) -> Result<(), ErrorCode> {
let message = json!({
"state": if on { "ON" } else { "OFF"}
});
debug!(id = Device::get_id(self), "{message}");
let topic = format!("{}/set", self.config.mqtt.topic);
// TODO: Handle potential errors here
self.config
.client
.publish(
&topic,
rumqttc::QoS::AtLeastOnce,
false,
serde_json::to_string(&message).unwrap(),
)
.await
.map_err(|err| warn!("Failed to update state on {topic}: {err}"))
.ok();
Ok(())
}
}
const FACTOR: f64 = 30.0;
#[async_trait]
impl<T> Brightness for Light<T>
where
T: LightState,
T: Into<StateBrightness>,
{
async fn brightness(&self) -> Result<u8, ErrorCode> {
let state = self.state().await;
let state: StateBrightness = state.deref().clone().into();
let brightness =
100.0 * f64::log10(state.brightness / FACTOR + 1.0) / f64::log10(254.0 / FACTOR + 1.0);
Ok(brightness.clamp(0.0, 100.0).round() as u8)
}
async fn set_brightness(&self, brightness: u8) -> Result<(), ErrorCode> {
let brightness =
FACTOR * ((FACTOR / (FACTOR + 254.0)).powf(-(brightness as f64) / 100.0) - 1.0);
let message = json!({
"brightness": brightness.clamp(0.0, 254.0).round() as u8
});
let topic = format!("{}/set", self.config.mqtt.topic);
// TODO: Handle potential errors here
self.config
.client
.publish(
&topic,
rumqttc::QoS::AtLeastOnce,
false,
serde_json::to_string(&message).unwrap(),
)
.await
.map_err(|err| warn!("Failed to update state on {topic}: {err}"))
.ok();
Ok(())
}
}

View File

@ -0,0 +1,2 @@
pub mod light;
pub mod outlet;

View File

@ -0,0 +1,275 @@
use std::fmt::Debug;
use std::ops::Deref;
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use automation_lib::action_callback::ActionCallback;
use automation_lib::config::{InfoConfig, MqttDeviceConfig};
use automation_lib::device::{Device, LuaDeviceCreate};
use automation_lib::event::{OnMqtt, OnPresence};
use automation_lib::helpers::serialization::state_deserializer;
use automation_lib::mqtt::WrappedAsyncClient;
use automation_macro::LuaDeviceConfig;
use google_home::device;
use google_home::errors::ErrorCode;
use google_home::traits::OnOff;
use google_home::types::Type;
use rumqttc::{matches, Publish};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use tracing::{debug, trace, warn};
pub trait OutletState:
Debug + Clone + Default + Sync + Send + Serialize + Into<StateOnOff> + 'static
{
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Copy)]
pub enum OutletType {
Outlet,
Kettle,
}
impl From<OutletType> for Type {
fn from(outlet: OutletType) -> Self {
match outlet {
OutletType::Outlet => Type::Outlet,
OutletType::Kettle => Type::Kettle,
}
}
}
#[derive(Debug, Clone, LuaDeviceConfig)]
pub struct Config<T: OutletState> {
#[device_config(flatten)]
pub info: InfoConfig,
#[device_config(flatten)]
pub mqtt: MqttDeviceConfig,
#[device_config(default(OutletType::Outlet))]
pub outlet_type: OutletType,
// TODO: One presence is reworked, this should be removed!
#[device_config(default(true))]
pub presence_auto_off: bool,
#[device_config(from_lua, default)]
pub callback: ActionCallback<Outlet<T>, T>,
#[device_config(from_lua)]
pub client: WrappedAsyncClient,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StateOnOff {
#[serde(deserialize_with = "state_deserializer")]
state: bool,
}
impl OutletState for StateOnOff {}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StatePower {
#[serde(deserialize_with = "state_deserializer")]
state: bool,
power: f64,
}
impl OutletState for StatePower {}
impl From<StatePower> for StateOnOff {
fn from(state: StatePower) -> Self {
StateOnOff { state: state.state }
}
}
#[derive(Debug, Clone)]
pub struct Outlet<T: OutletState> {
config: Config<T>,
state: Arc<RwLock<T>>,
}
pub type OutletOnOff = Outlet<StateOnOff>;
pub type OutletPower = Outlet<StatePower>;
impl<T: OutletState> Outlet<T> {
async fn state(&self) -> RwLockReadGuard<T> {
self.state.read().await
}
async fn state_mut(&self) -> RwLockWriteGuard<T> {
self.state.write().await
}
}
#[async_trait]
impl<T: OutletState> LuaDeviceCreate for Outlet<T> {
type Config = Config<T>;
type Error = rumqttc::ClientError;
async fn create(config: Self::Config) -> Result<Self, Self::Error> {
trace!(id = config.info.identifier(), "Setting up IkeaOutlet");
config
.client
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
.await?;
Ok(Self {
config,
state: Default::default(),
})
}
}
impl<T: OutletState> Device for Outlet<T> {
fn get_id(&self) -> String {
self.config.info.identifier()
}
}
#[async_trait]
impl OnMqtt for Outlet<StateOnOff> {
async fn on_mqtt(&self, message: Publish) {
// Check if the message is from the device itself or from a remote
if matches(&message.topic, &self.config.mqtt.topic) {
let state = match serde_json::from_slice::<StateOnOff>(&message.payload) {
Ok(state) => state,
Err(err) => {
warn!(id = Device::get_id(self), "Failed to parse message: {err}");
return;
}
};
// No need to do anything if the state has not changed
if state.state == self.state().await.state {
return;
}
self.state_mut().await.state = state.state;
debug!(
id = Device::get_id(self),
"Updating state to {:?}",
self.state().await
);
self.config
.callback
.call(self, self.state().await.deref())
.await;
}
}
}
#[async_trait]
impl OnMqtt for Outlet<StatePower> {
async fn on_mqtt(&self, message: Publish) {
// Check if the message is from the deviec itself or from a remote
if matches(&message.topic, &self.config.mqtt.topic) {
let state = match serde_json::from_slice::<StatePower>(&message.payload) {
Ok(state) => state,
Err(err) => {
warn!(id = Device::get_id(self), "Failed to parse message: {err}");
return;
}
};
{
let current_state = self.state().await;
// No need to do anything if the state has not changed
if state.state == current_state.state && state.power == current_state.power {
return;
}
}
self.state_mut().await.state = state.state;
self.state_mut().await.power = state.power;
debug!(
id = Device::get_id(self),
"Updating state to {:?}",
self.state().await
);
self.config
.callback
.call(self, self.state().await.deref())
.await;
}
}
}
#[async_trait]
impl<T: OutletState> OnPresence for Outlet<T> {
async fn on_presence(&self, presence: bool) {
if self.config.presence_auto_off && !presence {
debug!(id = Device::get_id(self), "Turning device off");
self.set_on(false).await.ok();
}
}
}
#[async_trait]
impl<T: OutletState> google_home::Device for Outlet<T> {
fn get_device_type(&self) -> Type {
self.config.outlet_type.into()
}
fn get_device_name(&self) -> device::Name {
device::Name::new(&self.config.info.name)
}
fn get_id(&self) -> String {
Device::get_id(self)
}
async fn is_online(&self) -> bool {
true
}
fn get_room_hint(&self) -> Option<&str> {
self.config.info.room.as_deref()
}
fn will_report_state(&self) -> bool {
// TODO: Implement state reporting
false
}
}
#[async_trait]
impl<T> OnOff for Outlet<T>
where
T: OutletState,
{
async fn on(&self) -> Result<bool, ErrorCode> {
let state = self.state().await;
let state: StateOnOff = state.deref().clone().into();
Ok(state.state)
}
async fn set_on(&self, on: bool) -> Result<(), ErrorCode> {
let message = json!({
"state": if on { "ON" } else { "OFF"}
});
debug!(id = Device::get_id(self), "{message}");
let topic = format!("{}/set", self.config.mqtt.topic);
// TODO: Handle potential errors here
self.config
.client
.publish(
&topic,
rumqttc::QoS::AtLeastOnce,
false,
serde_json::to_string(&message).unwrap(),
)
.await
.map_err(|err| warn!("Failed to update state on {topic}: {err}"))
.ok();
Ok(())
}
}

28
automation_lib/Cargo.toml Normal file
View File

@ -0,0 +1,28 @@
[package]
name = "automation_lib"
version = "0.1.0"
edition = "2021"
[dependencies]
automation_macro = { workspace = true }
automation_cast = { workspace = true }
google_home = { workspace = true }
rumqttc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
reqwest = { workspace = true }
serde_repr = { workspace = true }
tracing = { workspace = true }
bytes = { workspace = true }
pollster = { workspace = true }
async-trait = { workspace = true }
futures = { workspace = true }
thiserror = { workspace = true }
indexmap = { workspace = true }
tokio-cron-scheduler = { workspace = true }
mlua = { workspace = true }
tokio-util = { workspace = true }
uuid = { workspace = true }
dyn-clone = { workspace = true }
impls = { workspace = true }

View File

@ -0,0 +1,71 @@
use std::marker::PhantomData;
use mlua::{FromLua, IntoLua, LuaSerdeExt};
use serde::Serialize;
#[derive(Debug, Clone)]
struct Internal {
uuid: uuid::Uuid,
lua: mlua::Lua,
}
#[derive(Debug, Clone)]
pub struct ActionCallback<T, S> {
internal: Option<Internal>,
_this: PhantomData<T>,
_state: PhantomData<S>,
}
impl<T, S> Default for ActionCallback<T, S> {
fn default() -> Self {
Self {
internal: None,
_this: PhantomData::<T>,
_state: PhantomData::<S>,
}
}
}
impl<T, S> FromLua for ActionCallback<T, S> {
fn from_lua(value: mlua::Value, lua: &mlua::Lua) -> mlua::Result<Self> {
let uuid = uuid::Uuid::new_v4();
lua.set_named_registry_value(&uuid.to_string(), value)?;
Ok(ActionCallback {
internal: Some(Internal {
uuid,
lua: lua.clone(),
}),
_this: PhantomData::<T>,
_state: PhantomData::<S>,
})
}
}
// TODO: Return proper error here
impl<T, S> ActionCallback<T, S>
where
T: IntoLua + Sync + Send + Clone + 'static,
S: Serialize,
{
pub async fn call(&self, this: &T, state: &S) {
let Some(internal) = self.internal.as_ref() else {
return;
};
let state = internal.lua.to_value(state).unwrap();
let callback: mlua::Value = internal
.lua
.named_registry_value(&internal.uuid.to_string())
.unwrap();
match callback {
mlua::Value::Function(f) => f.call_async::<()>((this.clone(), state)).await.unwrap(),
_ => todo!("Only functions are currently supported"),
}
}
pub fn is_set(&self) -> bool {
self.internal.is_some()
}
}

View File

@ -0,0 +1,74 @@
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,
}

View File

@ -0,0 +1,99 @@
use std::fmt::Debug;
use automation_cast::Cast;
use dyn_clone::DynClone;
use google_home::traits::OnOff;
use mlua::ObjectLike;
use crate::event::{OnDarkness, OnMqtt, OnNotification, OnPresence};
// TODO: Make this a proper macro
macro_rules! impl_device {
($device:ty) => {
impl mlua::UserData for $device {
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
methods.add_async_function("new", |_lua, config| async {
let device: $device = LuaDeviceCreate::create(config)
.await
.map_err(mlua::ExternalError::into_lua_err)?;
Ok(device)
});
methods.add_method("__box", |_lua, this, _: ()| {
let b: Box<dyn Device> = Box::new(this.clone());
Ok(b)
});
methods.add_async_method("get_id", |_lua, this, _: ()| async move { Ok(this.get_id()) });
if impls::impls!($device: google_home::traits::OnOff) {
methods.add_async_method("set_on", |_lua, this, on: bool| async move {
(this.deref().cast() as Option<&dyn google_home::traits::OnOff>)
.expect("Cast should be valid")
.set_on(on)
.await
.unwrap();
Ok(())
});
methods.add_async_method("is_on", |_lua, this, _: ()| async move {
Ok((this.deref().cast() as Option<&dyn google_home::traits::OnOff>)
.expect("Cast should be valid")
.on()
.await
.unwrap())
});
}
}
}
};
}
pub(crate) use impl_device;
#[async_trait::async_trait]
pub trait LuaDeviceCreate {
type Config;
type Error;
async fn create(config: Self::Config) -> Result<Self, Self::Error>
where
Self: Sized;
}
pub trait Device:
Debug
+ DynClone
+ Sync
+ Send
+ Cast<dyn google_home::Device>
+ Cast<dyn OnMqtt>
+ Cast<dyn OnPresence>
+ Cast<dyn OnDarkness>
+ Cast<dyn OnNotification>
+ Cast<dyn OnOff>
{
fn get_id(&self) -> String;
}
impl mlua::FromLua for Box<dyn Device> {
fn from_lua(value: mlua::Value, _lua: &mlua::Lua) -> mlua::Result<Self> {
match value {
mlua::Value::UserData(ud) => {
let ud = if ud.is::<Box<dyn Device>>() {
ud
} else {
ud.call_method::<_>("__box", ())?
};
let b = ud.borrow::<Self>()?.clone();
Ok(b)
}
_ => Err(mlua::Error::RuntimeError("Expected user data".into())),
}
}
}
impl mlua::UserData for Box<dyn Device> {}
dyn_clone::clone_trait_object!(Device);

View File

@ -0,0 +1,189 @@
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()))
}
}

View File

@ -1,9 +1,7 @@
use std::{error, fmt, result};
use axum::{http::status::InvalidStatusCode, response::IntoResponse};
use bytes::Bytes;
use rumqttc::ClientError;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone)]
@ -64,16 +62,6 @@ 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")]
@ -91,12 +79,10 @@ 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)]
MissingWildcard(#[from] MissingWildcard),
MqttClientError(#[from] rumqttc::ClientError),
}
#[derive(Debug, Error)]
@ -112,68 +98,3 @@ pub enum LightSensorError {
#[error(transparent)]
SubscribeError(#[from] ClientError),
}
#[derive(Debug, Error)]
#[error("{source}")]
pub struct ApiError {
status_code: axum::http::StatusCode,
source: Box<dyn std::error::Error>,
}
impl ApiError {
pub fn new(status_code: axum::http::StatusCode, source: Box<dyn std::error::Error>) -> Self {
Self {
status_code,
source,
}
}
}
impl From<ApiError> for ApiErrorJson {
fn from(value: ApiError) -> Self {
let error = ApiErrorJsonError {
code: value.status_code.as_u16(),
status: value.status_code.to_string(),
reason: value.source.to_string(),
};
Self { error }
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
(
self.status_code,
serde_json::to_string::<ApiErrorJson>(&self.into())
.expect("Serialization should not fail"),
)
.into_response()
}
}
#[derive(Debug, Serialize, Deserialize)]
struct ApiErrorJsonError {
code: u16,
status: String,
reason: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ApiErrorJson {
error: ApiErrorJsonError,
}
impl TryFrom<ApiErrorJson> for ApiError {
type Error = InvalidStatusCode;
fn try_from(value: ApiErrorJson) -> result::Result<Self, Self::Error> {
let status_code = axum::http::StatusCode::from_u16(value.error.code)?;
let source = value.error.reason.into();
Ok(Self {
status_code,
source,
})
}
}

View File

@ -1,10 +1,9 @@
use async_trait::async_trait;
use mlua::FromLua;
use rumqttc::Publish;
use tokio::sync::mpsc;
use impl_cast::device_trait;
use crate::devices::Notification;
use crate::ntfy::Notification;
#[derive(Debug, Clone)]
pub enum Event {
@ -17,7 +16,7 @@ pub enum Event {
pub type Sender = mpsc::Sender<Event>;
pub type Receiver = mpsc::Receiver<Event>;
#[derive(Clone, Debug)]
#[derive(Clone, Debug, FromLua)]
pub struct EventChannel(Sender);
impl EventChannel {
@ -32,27 +31,25 @@ impl EventChannel {
}
}
impl mlua::UserData for EventChannel {}
#[async_trait]
#[device_trait]
pub trait OnMqtt {
fn topics(&self) -> Vec<&str>;
async fn on_mqtt(&mut self, message: Publish);
pub trait OnMqtt: Sync + Send {
// fn topics(&self) -> Vec<&str>;
async fn on_mqtt(&self, message: Publish);
}
#[async_trait]
#[device_trait]
pub trait OnPresence {
async fn on_presence(&mut self, presence: bool);
pub trait OnPresence: Sync + Send {
async fn on_presence(&self, presence: bool);
}
#[async_trait]
#[device_trait]
pub trait OnDarkness {
async fn on_darkness(&mut self, dark: bool);
pub trait OnDarkness: Sync + Send {
async fn on_darkness(&self, dark: bool);
}
#[async_trait]
#[device_trait]
pub trait OnNotification {
async fn on_notification(&mut self, notification: Notification);
pub trait OnNotification: Sync + Send {
async fn on_notification(&self, notification: Notification);
}

View File

@ -0,0 +1,11 @@
pub mod serialization;
mod timeout;
pub use timeout::Timeout;
pub fn register_with_lua(lua: &mlua::Lua) -> mlua::Result<()> {
lua.globals()
.set("Timeout", lua.create_proxy::<Timeout>()?)?;
Ok(())
}

View File

@ -0,0 +1,16 @@
use serde::de::{self, Unexpected};
use serde::{Deserialize, Deserializer};
pub fn state_deserializer<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
match String::deserialize(deserializer)?.as_ref() {
"ON" => Ok(true),
"OFF" => Ok(false),
other => Err(de::Error::invalid_value(
Unexpected::Str(other),
&"Value expected was either ON or OFF",
)),
}
}

View File

@ -0,0 +1,76 @@
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tokio::task::JoinHandle;
use tracing::debug;
use crate::action_callback::ActionCallback;
#[derive(Debug, Default)]
pub struct State {
handle: Option<JoinHandle<()>>,
}
#[derive(Debug, Clone)]
pub struct Timeout {
state: Arc<RwLock<State>>,
}
impl mlua::UserData for Timeout {
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
methods.add_function("new", |_lua, ()| {
let device = Self {
state: Default::default(),
};
Ok(device)
});
methods.add_async_method(
"start",
|_lua, this, (timeout, callback): (u64, ActionCallback<mlua::Value, bool>)| async move {
if let Some(handle) = this.state.write().await.handle.take() {
handle.abort();
}
debug!("Running timeout callback after {timeout}s");
let timeout = Duration::from_secs(timeout);
this.state.write().await.handle = Some(tokio::spawn({
async move {
tokio::time::sleep(timeout).await;
callback.call(&mlua::Nil, &false).await;
}
}));
Ok(())
},
);
methods.add_async_method("cancel", |_lua, this, ()| async move {
debug!("Canceling timeout callback");
if let Some(handle) = this.state.write().await.handle.take() {
handle.abort();
}
Ok(())
});
methods.add_async_method("is_waiting", |_lua, this, ()| async move {
debug!("Canceling timeout callback");
if let Some(handle) = this.state.read().await.handle.as_ref() {
debug!("Join handle: {}", handle.is_finished());
return Ok(!handle.is_finished());
}
debug!("Join handle: None");
Ok(false)
});
}
}

View File

@ -1,12 +1,16 @@
#![allow(incomplete_features)]
#![feature(specialization)]
#![feature(let_chains)]
pub mod auth;
pub mod action_callback;
pub mod config;
pub mod device;
pub mod device_manager;
pub mod devices;
pub mod error;
pub mod event;
pub mod helpers;
pub mod messages;
pub mod mqtt;
pub mod traits;
pub mod ntfy;
pub mod presence;
pub mod schedule;

View File

@ -241,37 +241,3 @@ impl TryFrom<Bytes> for HueMessage {
serde_json::from_slice(&bytes).or(Err(ParseError::InvalidPayload(bytes.clone())))
}
}
// TODO: Import this from the air_filter code itself instead of copying
#[derive(PartialEq, Eq, Debug, Clone, Copy, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum 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())))
}
}

View File

@ -1,9 +1,30 @@
use std::ops::{Deref, DerefMut};
use mlua::FromLua;
use rumqttc::{AsyncClient, Event, EventLoop, Incoming};
use tracing::{debug, warn};
use rumqttc::{Event, EventLoop, Incoming};
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();

View File

@ -1,23 +1,16 @@
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::{debug, error, warn};
use tracing::{error, trace, warn};
use crate::{
config::NtfyConfig,
devices::Device,
event::{self, Event, EventChannel},
event::{OnNotification, OnPresence},
};
#[derive(Debug)]
pub struct Ntfy {
base_url: String,
topic: String,
tx: event::Sender,
}
use crate::device::{impl_device, Device, LuaDeviceCreate};
use crate::event::{self, Event, EventChannel, OnNotification, OnPresence};
#[derive(Debug, Serialize_repr, Clone, Copy)]
#[repr(u8)]
@ -43,9 +36,9 @@ pub enum ActionType {
#[derive(Debug, Serialize, Clone)]
pub struct Action {
#[serde(flatten)]
action: ActionType,
label: String,
clear: Option<bool>,
pub action: ActionType,
pub label: String,
pub clear: Option<bool>,
}
#[derive(Serialize)]
@ -119,28 +112,52 @@ impl Default for Notification {
}
}
impl Ntfy {
pub fn new(config: NtfyConfig, event_channel: &EventChannel) -> Self {
Self {
base_url: config.url,
topic: config.topic,
tx: event_channel.get_tx(),
}
}
#[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 {
async fn send(&self, notification: Notification) {
let notification = notification.finalize(&self.topic);
debug!("Sending notfication");
let notification = notification.finalize(&self.config.topic);
// Create the request
let res = reqwest::Client::new()
.post(self.base_url.clone())
.post(self.config.url.clone())
.json(&notification)
.send()
.await;
if let Err(err) = res {
error!("Something went wrong while sending the notifcation: {err}");
error!("Something went wrong while sending the notification: {err}");
} else if let Ok(res) = res {
let status = res.status();
if !status.is_success() {
@ -150,15 +167,9 @@ impl Ntfy {
}
}
impl Device for Ntfy {
fn get_id(&self) -> &str {
"ntfy"
}
}
#[async_trait]
impl OnPresence for Ntfy {
async fn on_presence(&mut self, presence: bool) {
async fn on_presence(&self, presence: bool) {
// Setup extras for the broadcast
let extras = HashMap::from([
("cmd".into(), "presence".into()),
@ -180,7 +191,13 @@ impl OnPresence for Ntfy {
.add_action(action)
.set_priority(Priority::Low);
if self.tx.send(Event::Ntfy(notification)).await.is_err() {
if self
.config
.tx
.send(Event::Ntfy(notification))
.await
.is_err()
{
warn!("There are no receivers on the event channel");
}
}
@ -188,7 +205,7 @@ impl OnPresence for Ntfy {
#[async_trait]
impl OnNotification for Ntfy {
async fn on_notification(&mut self, notification: Notification) {
async fn on_notification(&self, notification: Notification) {
self.send(notification).await;
}
}

View File

@ -0,0 +1,132 @@
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");
}
}
}
}

View File

@ -0,0 +1,17 @@
use indexmap::IndexMap;
use serde::Deserialize;
#[derive(Debug, Deserialize, Hash, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum Action {
On,
Off,
}
pub type Schedule = IndexMap<String, IndexMap<Action, Vec<String>>>;
// #[derive(Debug, Deserialize)]
// pub struct Schedule {
// pub when: String,
// pub actions: IndexMap<Action, Vec<String>>,
// }

View File

@ -0,0 +1,13 @@
[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 }

View File

@ -0,0 +1,13 @@
#![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()
}

View File

@ -0,0 +1,281 @@
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 Normal file
View File

@ -0,0 +1,507 @@
print("Hello from lua")
local host = automation.util.get_hostname()
print("Running @" .. host)
local debug, value = pcall(automation.util.get_env, "DEBUG")
if debug and value ~= "true" then
debug = false
end
local function mqtt_z2m(topic)
return "zigbee2mqtt/" .. topic
end
local function mqtt_automation(topic)
return "automation/" .. topic
end
automation.fulfillment = {
openid_url = "https://login.huizinga.dev/api/oidc",
}
local mqtt_client = automation.new_mqtt_client({
host = ((host == "zeus" or host == "hephaestus") and "olympus.lan.huizinga.dev") or "mosquitto",
port = 8883,
client_name = "automation-" .. host,
username = "mqtt",
password = automation.util.get_env("MQTT_PASSWORD"),
tls = host == "zeus" or host == "hephaestus",
})
automation.device_manager:add(Ntfy.new({
topic = automation.util.get_env("NTFY_TOPIC"),
event_channel = automation.device_manager:event_channel(),
}))
automation.device_manager:add(Presence.new({
topic = mqtt_automation("presence/+/#"),
client = mqtt_client,
event_channel = automation.device_manager:event_channel(),
}))
automation.device_manager:add(DebugBridge.new({
identifier = "debug_bridge",
topic = mqtt_automation("debug"),
client = mqtt_client,
}))
local hue_ip = "10.0.0.102"
local hue_token = automation.util.get_env("HUE_TOKEN")
automation.device_manager:add(HueBridge.new({
identifier = "hue_bridge",
ip = hue_ip,
login = hue_token,
flags = {
presence = 41,
darkness = 43,
},
}))
local kitchen_lights = HueGroup.new({
identifier = "kitchen_lights",
ip = hue_ip,
login = hue_token,
group_id = 7,
scene_id = "7MJLG27RzeRAEVJ",
})
automation.device_manager:add(kitchen_lights)
local living_lights = HueGroup.new({
identifier = "living_lights",
ip = hue_ip,
login = hue_token,
group_id = 1,
scene_id = "SNZw7jUhQ3cXSjkj",
})
automation.device_manager:add(living_lights)
local living_lights_relax = HueGroup.new({
identifier = "living_lights",
ip = hue_ip,
login = hue_token,
group_id = 1,
scene_id = "eRJ3fvGHCcb6yNw",
})
automation.device_manager:add(living_lights_relax)
automation.device_manager:add(HueSwitch.new({
name = "Switch",
room = "Living",
client = mqtt_client,
topic = mqtt_z2m("living/switch"),
left_callback = function()
kitchen_lights:set_on(not kitchen_lights:on())
end,
right_callback = function()
living_lights:set_on(not living_lights:on())
end,
right_hold_callback = function()
living_lights_relax:set_on(true)
end,
}))
automation.device_manager:add(LightSensor.new({
identifier = "living_light_sensor",
topic = mqtt_z2m("living/light"),
client = mqtt_client,
min = 22000,
max = 23500,
event_channel = automation.device_manager:event_channel(),
}))
automation.device_manager:add(WakeOnLAN.new({
name = "Zeus",
room = "Living Room",
topic = mqtt_automation("appliance/living_room/zeus"),
client = mqtt_client,
mac_address = "30:9c:23:60:9c:13",
broadcast_ip = "10.0.3.255",
}))
local living_mixer = OutletOnOff.new({
name = "Mixer",
room = "Living Room",
topic = mqtt_z2m("living/mixer"),
client = mqtt_client,
})
automation.device_manager:add(living_mixer)
local living_speakers = OutletOnOff.new({
name = "Speakers",
room = "Living Room",
topic = mqtt_z2m("living/speakers"),
client = mqtt_client,
})
automation.device_manager:add(living_speakers)
automation.device_manager:add(IkeaRemote.new({
name = "Remote",
room = "Living Room",
client = mqtt_client,
topic = mqtt_z2m("living/remote"),
single_button = true,
callback = function(_, on)
if on then
if living_mixer:on() then
living_mixer:set_on(false)
living_speakers:set_on(false)
else
living_mixer:set_on(true)
living_speakers:set_on(true)
end
else
if not living_mixer:on() then
living_mixer:set_on(true)
else
living_speakers:set_on(not living_speakers:on())
end
end
end,
}))
local function kettle_timeout()
local timeout = Timeout.new()
return function(self, state)
if state.state and state.power < 100 then
timeout:start(3, function()
self:set_on(false)
end)
else
timeout:cancel()
end
end
end
local kettle = OutletPower.new({
outlet_type = "Kettle",
name = "Kettle",
room = "Kitchen",
topic = mqtt_z2m("kitchen/kettle"),
client = mqtt_client,
callback = kettle_timeout(),
})
automation.device_manager:add(kettle)
local function set_kettle(_, on)
kettle:set_on(on)
end
automation.device_manager:add(IkeaRemote.new({
name = "Remote",
room = "Bedroom",
client = mqtt_client,
topic = mqtt_z2m("bedroom/remote"),
single_button = true,
callback = set_kettle,
}))
automation.device_manager:add(IkeaRemote.new({
name = "Remote",
room = "Kitchen",
client = mqtt_client,
topic = mqtt_z2m("kitchen/remote"),
single_button = true,
callback = set_kettle,
}))
local function off_timeout(duration)
local timeout = Timeout.new()
return function(self, state)
if state.state then
timeout:start(duration, function()
self:set_on(false)
end)
else
timeout:cancel()
end
end
end
automation.device_manager:add(LightOnOff.new({
name = "Light",
room = "Bathroom",
topic = mqtt_z2m("bathroom/light"),
client = mqtt_client,
callback = off_timeout(debug and 60 or 45 * 60),
}))
automation.device_manager:add(Washer.new({
identifier = "bathroom_washer",
topic = mqtt_z2m("bathroom/washer"),
client = mqtt_client,
threshold = 1,
event_channel = automation.device_manager:event_channel(),
}))
automation.device_manager:add(OutletOnOff.new({
presence_auto_off = false,
name = "Charger",
room = "Workbench",
topic = mqtt_z2m("workbench/charger"),
client = mqtt_client,
callback = off_timeout(debug and 5 or 20 * 3600),
}))
automation.device_manager:add(OutletOnOff.new({
name = "Outlet",
room = "Workbench",
topic = mqtt_z2m("workbench/outlet"),
client = mqtt_client,
}))
local workbench_light = LightBrightness.new({
name = "Light",
room = "Workbench",
topic = mqtt_z2m("workbench/light"),
client = mqtt_client,
})
automation.device_manager:add(workbench_light)
automation.device_manager:add(IkeaRemote.new({
name = "Remote",
room = "Workbench",
client = mqtt_client,
topic = mqtt_z2m("workbench/remote"),
callback = function(_, on)
workbench_light:set_on(on)
end,
}))
local hallway_top_light = HueGroup.new({
identifier = "hallway_top_light",
ip = hue_ip,
login = hue_token,
group_id = 83,
scene_id = "QeufkFDICEHWeKJ7",
})
automation.device_manager:add(HueSwitch.new({
name = "SwitchBottom",
room = "Hallway",
client = mqtt_client,
topic = mqtt_z2m("hallway/switchbottom"),
left_callback = function()
hallway_top_light:set_on(not hallway_top_light:on())
end,
}))
automation.device_manager:add(HueSwitch.new({
name = "SwitchTop",
room = "Hallway",
client = mqtt_client,
topic = mqtt_z2m("hallway/switchtop"),
left_callback = function()
hallway_top_light:set_on(not hallway_top_light:on())
end,
}))
local hallway_light_automation = {
timeout = Timeout.new(),
forced = false,
switch_callback = function(self, on)
self.timeout:cancel()
self.group.set_on(on)
self.forced = on
end,
door_callback = function(self, open)
if open then
self.timeout:cancel()
self.group.set_on(true)
elseif not self.forced then
self.timeout:start(debug and 10 or 2 * 60, function()
if self.trash:open_percent() == 0 then
self.group.set_on(false)
end
end)
end
end,
trash_callback = function(self, open)
if open then
self.group.set_on(true)
else
if not self.timeout:is_waiting() and self.door:open_percent() == 0 and not self.forced then
self.group.set_on(false)
end
end
end,
light_callback = function(self, on)
if on and self.trash:open_percent() == 0 and self.door:open_percent() == 0 then
-- If the door and trash are not open, that means the light got turned on manually
self.timeout:cancel()
self.forced = true
elseif not on then
-- The light is never forced when it is off
self.forced = false
end
end,
}
local hallway_storage = LightBrightness.new({
name = "Storage",
room = "Hallway",
topic = mqtt_z2m("hallway/storage"),
client = mqtt_client,
callback = function(_, state)
hallway_light_automation:light_callback(state.state)
end,
})
automation.device_manager:add(hallway_storage)
local hallway_bottom_lights = HueGroup.new({
identifier = "hallway_bottom_lights",
ip = hue_ip,
login = hue_token,
group_id = 81,
scene_id = "3qWKxGVadXFFG4o",
})
automation.device_manager:add(hallway_bottom_lights)
hallway_light_automation.group = {
set_on = function(on)
if on then
hallway_storage:set_brightness(80)
else
hallway_storage:set_on(false)
end
hallway_bottom_lights:set_on(on)
end,
}
automation.device_manager:add(IkeaRemote.new({
name = "Remote",
room = "Hallway",
client = mqtt_client,
topic = mqtt_z2m("hallway/remote"),
callback = function(_, on)
hallway_light_automation:switch_callback(on)
end,
}))
local hallway_frontdoor = ContactSensor.new({
name = "Frontdoor",
room = "Hallway",
sensor_type = "Door",
topic = mqtt_z2m("hallway/frontdoor"),
client = mqtt_client,
presence = {
topic = mqtt_automation("presence/contact/frontdoor"),
timeout = debug and 10 or 15 * 60,
},
callback = function(_, open)
hallway_light_automation:door_callback(open)
end,
})
automation.device_manager:add(hallway_frontdoor)
hallway_light_automation.door = hallway_frontdoor
local hallway_trash = ContactSensor.new({
name = "Trash",
room = "Hallway",
sensor_type = "Drawer",
topic = mqtt_z2m("hallway/trash"),
client = mqtt_client,
callback = function(_, open)
hallway_light_automation:trash_callback(open)
end,
})
automation.device_manager:add(hallway_trash)
hallway_light_automation.trash = hallway_trash
automation.device_manager:add(LightOnOff.new({
name = "Light",
room = "Guest Room",
topic = mqtt_z2m("guest/light"),
client = mqtt_client,
}))
local bedroom_air_filter = AirFilter.new({
name = "Air Filter",
room = "Bedroom",
url = "http://10.0.0.103",
})
automation.device_manager:add(bedroom_air_filter)
local bedroom_lights = HueGroup.new({
identifier = "bedroom_lights",
ip = hue_ip,
login = hue_token,
group_id = 3,
scene_id = "PvRs-lGD4VRytL9",
})
automation.device_manager:add(bedroom_lights)
local bedroom_lights_relax = HueGroup.new({
identifier = "bedroom_lights",
ip = hue_ip,
login = hue_token,
group_id = 3,
scene_id = "60tfTyR168v2csz",
})
automation.device_manager:add(bedroom_lights_relax)
automation.device_manager:add(HueSwitch.new({
name = "Switch",
room = "Bedroom",
client = mqtt_client,
topic = mqtt_z2m("bedroom/switch"),
left_callback = function()
bedroom_lights:set_on(not bedroom_lights:on())
end,
left_hold_callback = function()
bedroom_lights_relax:set_on(true)
end,
}))
automation.device_manager:add(ContactSensor.new({
name = "Balcony",
room = "Living Room",
sensor_type = "Door",
topic = mqtt_z2m("living/balcony"),
client = mqtt_client,
}))
automation.device_manager:add(ContactSensor.new({
name = "Window",
room = "Living Room",
topic = mqtt_z2m("living/window"),
client = mqtt_client,
}))
automation.device_manager:add(ContactSensor.new({
name = "Window",
room = "Bedroom",
topic = mqtt_z2m("bedroom/window"),
client = mqtt_client,
}))
automation.device_manager:add(ContactSensor.new({
name = "Window",
room = "Guest Room",
topic = mqtt_z2m("guest/window"),
client = mqtt_client,
}))
local storage_light = LightBrightness.new({
name = "Light",
room = "Storage",
topic = mqtt_z2m("storage/light"),
client = mqtt_client,
})
automation.device_manager:add(storage_light)
automation.device_manager:add(ContactSensor.new({
name = "Door",
room = "Storage",
sensor_type = "Door",
topic = mqtt_z2m("storage/door"),
client = mqtt_client,
callback = function(_, open)
if open then
storage_light:set_brightness(100)
else
storage_light:set_on(false)
end
end,
}))
automation.device_manager:schedule("0 0 19 * * *", function()
bedroom_air_filter:set_on(true)
end)
automation.device_manager:schedule("0 0 20 * * *", function()
bedroom_air_filter:set_on(false)
end)

View File

@ -1,66 +0,0 @@
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

View File

@ -1,124 +0,0 @@
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_dev/presence/contact/frontdoor"
timeout: 900
trigger:
devices: ["hallway_lights"]
timeout: 60
bedroom_air_filter:
!AirFilter
name: "Air Filter"
room: "Bedroom"
topic: "pio/filter/test"

View File

@ -1,124 +0,0 @@
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
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: "pio/filter/test"

View File

@ -1,16 +0,0 @@
[package]
name = "google-home"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
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"

View File

@ -1,20 +0,0 @@
use serde::Serialize;
use crate::traits::AvailableSpeeds;
#[derive(Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Attributes {
#[serde(skip_serializing_if = "Option::is_none")]
pub command_only_on_off: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub query_only_on_off: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scene_reversible: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reversible: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command_only_fan_speed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub available_fan_speeds: Option<AvailableSpeeds>,
}

View File

@ -1,197 +0,0 @@
use async_trait::async_trait;
use serde::Serialize;
use crate::{
errors::{DeviceError, ErrorCode},
request::execute::CommandType,
response,
traits::{FanSpeed, OnOff, Scene, Trait},
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
}

View File

@ -1,106 +0,0 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Payload {
pub commands: Vec<Command>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Command {
pub devices: Vec<Device>,
pub execution: Vec<CommandType>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Device {
pub id: String,
// customData
}
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "command", content = "params")]
pub enum CommandType {
#[serde(rename = "action.devices.commands.OnOff")]
OnOff { on: bool },
#[serde(rename = "action.devices.commands.ActivateScene")]
ActivateScene { deactivate: bool },
#[serde(rename = "action.devices.commands.SetFanSpeed")]
SetFanSpeed { fan_speed: String },
}
#[cfg(test)]
mod tests {
use super::*;
use crate::request::{Intent, Request};
#[test]
fn deserialize() {
let json = r#"{
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
"inputs": [
{
"intent": "action.devices.EXECUTE",
"payload": {
"commands": [
{
"devices": [
{
"id": "123",
"customData": {
"fooValue": 74,
"barValue": true,
"bazValue": "sheepdip"
}
},
{
"id": "456",
"customData": {
"fooValue": 36,
"barValue": false,
"bazValue": "moarsheep"
}
}
],
"execution": [
{
"command": "action.devices.commands.OnOff",
"params": {
"on": true
}
}
]
}
]
}
}
]
}"#;
let req: Request = serde_json::from_str(json).unwrap();
println!("{:?}", req);
assert_eq!(
req.request_id,
"ff36a3cc-ec34-11e6-b1a0-64510650abcf".to_string()
);
assert_eq!(req.inputs.len(), 1);
match &req.inputs[0] {
Intent::Execute(payload) => {
assert_eq!(payload.commands.len(), 1);
assert_eq!(payload.commands[0].devices.len(), 2);
assert_eq!(payload.commands[0].devices[0].id, "123");
assert_eq!(payload.commands[0].devices[1].id, "456");
assert_eq!(payload.commands[0].execution.len(), 1);
match payload.commands[0].execution[0] {
CommandType::OnOff { on } => assert!(on),
_ => panic!("Expected OnOff"),
}
}
_ => panic!("Expected Execute intent"),
};
}
}

View File

@ -1,74 +0,0 @@
use async_trait::async_trait;
use serde::Serialize;
use crate::errors::ErrorCode;
#[derive(Debug, Serialize)]
pub enum Trait {
#[serde(rename = "action.devices.traits.OnOff")]
OnOff,
#[serde(rename = "action.devices.traits.Scene")]
Scene,
#[serde(rename = "action.devices.traits.FanSpeed")]
FanSpeed,
}
#[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>;
}

View File

@ -0,0 +1,17 @@
[package]
name = "google_home"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
automation_cast = { workspace = true }
google_home_macro = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
futures = { workspace = true }
json_value_merge = { workspace = true }

View File

@ -0,0 +1,120 @@
use async_trait::async_trait;
use serde::Serialize;
use crate::errors::ErrorCode;
use crate::response;
use crate::traits::{Command, DeviceFulfillment};
use crate::types::Type;
#[async_trait]
pub trait Device: DeviceFulfillment {
fn get_device_type(&self) -> Type;
fn get_device_name(&self) -> Name;
fn get_id(&self) -> String;
async fn is_online(&self) -> bool;
// Default values that can optionally be overridden
fn will_report_state(&self) -> bool {
false
}
fn get_room_hint(&self) -> Option<&str> {
None
}
fn get_device_info(&self) -> Option<Info> {
None
}
async fn sync(&self) -> response::sync::Device {
let name = self.get_device_name();
let mut device =
response::sync::Device::new(&self.get_id(), &name.name, self.get_device_type());
device.name = name;
device.will_report_state = self.will_report_state();
// notification_supported_by_agent
if let Some(room) = self.get_room_hint() {
device.room_hint = Some(room.into());
}
device.device_info = self.get_device_info();
// TODO: Return the appropriate error
if let Ok((traits, attributes)) = DeviceFulfillment::sync(self).await {
device.traits = traits;
device.attributes = attributes;
}
device
}
async fn query(&self) -> response::query::Device {
let mut device = response::query::Device::new();
if !self.is_online().await {
device.set_offline();
}
// TODO: Return the appropriate error
if let Ok(state) = DeviceFulfillment::query(self).await {
device.state = state;
}
device
}
async fn execute(&self, command: Command) -> Result<(), ErrorCode> {
// TODO: Do something with the return value, or just get rut of the return value?
if DeviceFulfillment::execute(self, command.clone())
.await
.is_err()
{
return Err(ErrorCode::DeviceError(
crate::errors::DeviceError::TransientError,
));
}
Ok(())
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Name {
#[serde(skip_serializing_if = "Vec::is_empty")]
default_names: Vec<String>,
name: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
nicknames: Vec<String>,
}
impl Name {
pub fn new(name: &str) -> Self {
Self {
default_names: Vec::new(),
name: name.into(),
nicknames: Vec::new(),
}
}
pub fn add_default_name(&mut self, name: &str) {
self.default_names.push(name.into());
}
pub fn add_nickname(&mut self, name: &str) {
self.nicknames.push(name.into());
}
}
#[derive(Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Info {
#[serde(skip_serializing_if = "Option::is_none")]
pub manufacturer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hw_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sw_version: Option<String>,
// attributes
// customData
// otherDeviceIds
}

View File

@ -1,15 +1,15 @@
use std::{collections::HashMap, sync::Arc};
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, RwLock};
use tokio::sync::Mutex;
use crate::{
device::AsGoogleHomeDevice,
errors::{DeviceError, ErrorCode},
request::{self, Intent, Request},
response::{self, execute, query, sync, Response, ResponsePayload, State},
};
use crate::errors::{DeviceError, ErrorCode};
use crate::request::{self, Intent, Request};
use crate::response::{self, execute, query, sync, Response, ResponsePayload};
use crate::Device;
#[derive(Debug)]
pub struct GoogleHome {
@ -18,7 +18,7 @@ pub struct GoogleHome {
}
#[derive(Debug, Error)]
pub enum FullfillmentError {
pub enum FulfillmentError {
#[error("Expected at least one ResponsePayload")]
ExpectedOnePayload,
}
@ -30,11 +30,11 @@ impl GoogleHome {
}
}
pub async fn handle_request<T: AsGoogleHomeDevice + ?Sized + 'static>(
pub async fn handle_request<T: Cast<dyn Device> + ?Sized + 'static>(
&self,
request: Request,
devices: &HashMap<String, Arc<RwLock<Box<T>>>>,
) -> Result<Response, FullfillmentError> {
devices: &HashMap<String, Box<T>>,
) -> Result<Response, FulfillmentError> {
// TODO: What do we do if we actually get more then one thing in the input array, right now
// we only respond to the first thing
let intent = request.inputs.into_iter().next();
@ -55,18 +55,18 @@ impl GoogleHome {
payload
.await
.ok_or(FullfillmentError::ExpectedOnePayload)
.ok_or(FulfillmentError::ExpectedOnePayload)
.map(|payload| Response::new(&request.request_id, payload))
}
async fn sync<T: AsGoogleHomeDevice + ?Sized + 'static>(
async fn sync<T: Cast<dyn Device> + ?Sized + 'static>(
&self,
devices: &HashMap<String, Arc<RwLock<Box<T>>>>,
devices: &HashMap<String, Box<T>>,
) -> sync::Payload {
let mut resp_payload = sync::Payload::new(&self.user_id);
let f = devices.iter().map(|(_, device)| async move {
if let Some(device) = device.read().await.as_ref().cast() {
Some(device.sync().await)
if let Some(device) = device.as_ref().cast() {
Some(Device::sync(device).await)
} else {
None
}
@ -76,10 +76,10 @@ impl GoogleHome {
resp_payload
}
async fn query<T: AsGoogleHomeDevice + ?Sized + 'static>(
async fn query<T: Cast<dyn Device> + ?Sized + 'static>(
&self,
payload: request::query::Payload,
devices: &HashMap<String, Arc<RwLock<Box<T>>>>,
devices: &HashMap<String, Box<T>>,
) -> query::Payload {
let mut resp_payload = query::Payload::new();
let f = payload
@ -87,16 +87,18 @@ impl GoogleHome {
.into_iter()
.map(|device| device.id)
.map(|id| async move {
// NOTE: Requires let_chains feature
let device = if let Some(device) = devices.get(id.as_str()) && let Some(device) = device.read().await.as_ref().cast() {
device.query().await
} else {
let mut device = query::Device::new();
device.set_offline();
device.set_error(DeviceError::DeviceNotFound.into());
// NOTE: Requires let_chains feature
let device = if let Some(device) = devices.get(id.as_str())
&& let Some(device) = device.as_ref().cast()
{
Device::query(device).await
} else {
let mut device = query::Device::new();
device.set_offline();
device.set_error(DeviceError::DeviceNotFound.into());
device
};
device
};
(id, device)
});
@ -106,91 +108,92 @@ impl GoogleHome {
resp_payload
}
async fn execute<T: AsGoogleHomeDevice + ?Sized + 'static>(
async fn execute<T: Cast<dyn Device> + ?Sized + 'static>(
&self,
payload: request::execute::Payload,
devices: &HashMap<String, Arc<RwLock<Box<T>>>>,
devices: &HashMap<String, Box<T>>,
) -> execute::Payload {
let resp_payload = Arc::new(Mutex::new(response::execute::Payload::new()));
let f = payload.commands.into_iter().map(|command| {
let resp_payload = resp_payload.clone();
async move {
let mut success = response::execute::Command::new(execute::Status::Success);
success.states = Some(execute::States {
online: true,
state: State::default(),
});
let mut offline = response::execute::Command::new(execute::Status::Offline);
offline.states = Some(execute::States {
online: false,
state: State::default(),
});
let mut errors: HashMap<ErrorCode, response::execute::Command> = HashMap::new();
let resp_payload = resp_payload.clone();
async move {
let mut success = response::execute::Command::new(execute::Status::Success);
success.states = Some(execute::States {
online: true,
state: Default::default(),
});
let mut offline = response::execute::Command::new(execute::Status::Offline);
offline.states = Some(execute::States {
online: false,
state: Default::default(),
});
let mut errors: HashMap<ErrorCode, response::execute::Command> = HashMap::new();
let f = command
.devices
.into_iter()
.map(|device| device.id)
.map(|id| {
let execution = command.execution.clone();
async move {
if let Some(device) = devices.get(id.as_str()) && let Some(device) = device.write().await.as_mut().cast_mut() {
if !device.is_online() {
return (id, Ok(false));
}
let f = command
.devices
.into_iter()
.map(|device| device.id)
.map(|id| {
let execution = command.execution.clone();
async move {
if let Some(device) = devices.get(id.as_str())
&& let Some(device) = device.as_ref().cast()
{
if !device.is_online().await {
return (id, Ok(false));
}
// NOTE: We can not use .map here because async =(
let mut results = Vec::new();
for cmd in &execution {
results.push(device.execute(cmd).await);
}
// 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);
}
// Convert vec of results to a result with a vec and the first
// encountered error
let results = results
.into_iter()
.collect::<Result<Vec<_>, ErrorCode>>();
// Convert vec of results to a result with a vec and the first
// encountered error
let results =
results.into_iter().collect::<Result<Vec<_>, ErrorCode>>();
// TODO: We only get one error not all errors
if let Err(err) = results {
(id, Err(err))
} else {
(id, Ok(true))
}
} else {
(id.clone(), Err(DeviceError::DeviceNotFound.into()))
}
}
});
// TODO: We only get one error not all errors
if let Err(err) = results {
(id, Err(err))
} else {
(id, Ok(true))
}
} else {
(id.clone(), Err(DeviceError::DeviceNotFound.into()))
}
}
});
let a = join_all(f).await;
a.into_iter().for_each(|(id, state)| {
match state {
Ok(true) => success.add_id(&id),
Ok(false) => offline.add_id(&id),
Err(err) => errors
.entry(err)
.or_insert_with(|| match &err {
ErrorCode::DeviceError(_) => {
response::execute::Command::new(execute::Status::Error)
}
ErrorCode::DeviceException(_) => {
response::execute::Command::new(execute::Status::Exceptions)
}
})
.add_id(&id),
};
});
let a = join_all(f).await;
a.into_iter().for_each(|(id, state)| {
match state {
Ok(true) => success.add_id(&id),
Ok(false) => offline.add_id(&id),
Err(err) => errors
.entry(err)
.or_insert_with(|| match &err {
ErrorCode::DeviceError(_) => {
response::execute::Command::new(execute::Status::Error)
}
ErrorCode::DeviceException(_) => {
response::execute::Command::new(execute::Status::Exceptions)
}
})
.add_id(&id),
};
});
let mut resp_payload = resp_payload.lock().await;
resp_payload.add_command(success);
resp_payload.add_command(offline);
for (error, mut cmd) in errors {
cmd.error_code = Some(error);
resp_payload.add_command(cmd);
}
}
let mut resp_payload = resp_payload.lock().await;
resp_payload.add_command(success);
resp_payload.add_command(offline);
for (error, mut cmd) in errors {
cmd.error_code = Some(error);
resp_payload.add_command(cmd);
}
}
});
join_all(f).await;

View File

@ -2,18 +2,16 @@
#![feature(specialization)]
#![feature(let_chains)]
pub mod device;
mod fullfillment;
mod fulfillment;
mod request;
mod response;
mod attributes;
pub mod errors;
pub mod traits;
pub mod types;
pub use device::GoogleHomeDevice;
pub use fullfillment::FullfillmentError;
pub use fullfillment::GoogleHome;
pub use device::Device;
pub use fulfillment::{FulfillmentError, GoogleHome};
pub use request::Request;
pub use response::Response;

View File

@ -0,0 +1,142 @@
use serde::Deserialize;
use crate::traits;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Payload {
pub commands: Vec<Command>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Command {
pub devices: Vec<Device>,
pub execution: Vec<traits::Command>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Device {
pub id: String,
// customData
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::request::{Intent, Request};
#[test]
fn deserialize_set_fan_speed() {
let req = json!({
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
"inputs": [
{
"intent": "action.devices.EXECUTE",
"payload": {
"commands": [
{
"devices": [],
"execution": [
{
"command": "action.devices.commands.SetFanSpeed",
"params": {
"fanSpeed": "Test"
}
}
]
}
]
}
}
]
});
let req: Request = serde_json::from_value(req).unwrap();
assert_eq!(req.inputs.len(), 1);
match &req.inputs[0] {
Intent::Execute(payload) => {
assert_eq!(payload.commands.len(), 1);
assert_eq!(payload.commands[0].devices.len(), 0);
assert_eq!(payload.commands[0].execution.len(), 1);
match &payload.commands[0].execution[0] {
traits::Command::SetFanSpeed { fan_speed } => assert_eq!(fan_speed, "Test"),
_ => panic!("Expected SetFanSpeed"),
}
}
_ => panic!("Expected Execute intent"),
};
}
#[test]
fn deserialize() {
let req = json!({
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
"inputs": [
{
"intent": "action.devices.EXECUTE",
"payload": {
"commands": [
{
"devices": [
{
"id": "123",
"customData": {
"fooValue": 74,
"barValue": true,
"bazValue": "sheepdip"
}
},
{
"id": "456",
"customData": {
"fooValue": 36,
"barValue": false,
"bazValue": "moarsheep"
}
}
],
"execution": [
{
"command": "action.devices.commands.OnOff",
"params": {
"on": true
}
}
]
}
]
}
}
]
});
let req: Request = serde_json::from_value(req).unwrap();
println!("{:?}", req);
assert_eq!(
req.request_id,
"ff36a3cc-ec34-11e6-b1a0-64510650abcf".to_string()
);
assert_eq!(req.inputs.len(), 1);
match &req.inputs[0] {
Intent::Execute(payload) => {
assert_eq!(payload.commands.len(), 1);
assert_eq!(payload.commands[0].devices.len(), 2);
assert_eq!(payload.commands[0].devices[0].id, "123");
assert_eq!(payload.commands[0].devices[1].id, "456");
assert_eq!(payload.commands[0].execution.len(), 1);
match payload.commands[0].execution[0] {
traits::Command::OnOff { on } => assert!(on),
_ => panic!("Expected OnOff"),
}
}
_ => panic!("Expected Execute intent"),
};
}
}

View File

@ -15,40 +15,42 @@ pub struct Device {
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::request::{Intent, Request};
#[test]
fn deserialize() {
let json = r#"{
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
"inputs": [
{
"intent": "action.devices.QUERY",
"payload": {
"devices": [
{
"id": "123",
"customData": {
"fooValue": 74,
"barValue": true,
"bazValue": "foo"
let req = json!({
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
"inputs": [
{
"intent": "action.devices.QUERY",
"payload": {
"devices": [
{
"id": "123",
"customData": {
"fooValue": 74,
"barValue": true,
"bazValue": "foo"
}
},
{
"id": "456",
"customData": {
"fooValue": 12,
"barValue": false,
"bazValue": "bar"
}
}
]
}
}
},
{
"id": "456",
"customData": {
"fooValue": 12,
"barValue": false,
"bazValue": "bar"
}
}
]
}
}
]
}"#;
]
});
let req: Request = serde_json::from_str(json).unwrap();
let req: Request = serde_json::from_value(req).unwrap();
println!("{:?}", req);

View File

@ -1,19 +1,21 @@
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::request::{Intent, Request};
#[test]
fn deserialize() {
let json = r#"{
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
"inputs": [
{
"intent": "action.devices.SYNC"
}
]
}"#;
let req = json!({
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
"inputs": [
{
"intent": "action.devices.SYNC"
}
]
});
let req: Request = serde_json::from_str(json).unwrap();
let req: Request = serde_json::from_value(req).unwrap();
println!("{:?}", req);

View File

@ -27,13 +27,3 @@ pub enum ResponsePayload {
Query(query::Payload),
Execute(execute::Payload),
}
#[derive(Debug, Default, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct State {
#[serde(skip_serializing_if = "Option::is_none")]
pub on: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_fan_speed_setting: Option<String>,
}

View File

@ -1,6 +1,6 @@
use serde::Serialize;
use crate::{errors::ErrorCode, response::State};
use crate::errors::ErrorCode;
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
@ -71,7 +71,7 @@ pub struct States {
pub online: bool,
#[serde(flatten)]
pub state: State,
pub state: serde_json::Value,
}
#[derive(Debug, Serialize, Clone)]
@ -86,20 +86,19 @@ pub enum Status {
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::{
errors::DeviceError,
response::{Response, ResponsePayload, State},
};
use crate::errors::DeviceError;
use crate::response::{Response, ResponsePayload};
#[test]
fn serialize() {
let mut execute_resp = Payload::new();
let state = State {
on: Some(true),
current_fan_speed_setting: None,
};
let state = json!({
"on": true,
});
let mut command = Command::new(Status::Success);
command.states = Some(States {
online: true,
@ -118,10 +117,28 @@ mod tests {
ResponsePayload::Execute(execute_resp),
);
let json = serde_json::to_string(&resp).unwrap();
let resp = serde_json::to_value(resp).unwrap();
println!("{}", json);
let resp_expected = json!({
"payload": {
"commands": [
{
"states": {
"on": true,
"online": true
},
"ids": ["123"],
"status": "SUCCESS"
}, {
"errorCode": "deviceNotFound",
"ids": ["456"],
"status":"ERROR"
}
]
},
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf"
});
// TODO: Add a known correct output to test against
assert_eq!(resp, resp_expected);
}
}

View File

@ -2,7 +2,7 @@ use std::collections::HashMap;
use serde::Serialize;
use crate::{errors::ErrorCode, response::State};
use crate::errors::ErrorCode;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@ -52,7 +52,7 @@ pub struct Device {
error_code: Option<ErrorCode>,
#[serde(flatten)]
pub state: State,
pub state: serde_json::Value,
}
impl Device {
@ -61,7 +61,7 @@ impl Device {
online: true,
status: Status::Success,
error_code: None,
state: State::default(),
state: Default::default(),
}
}
@ -87,6 +87,8 @@ impl Default for Device {
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::response::{Response, ResponsePayload};
@ -95,11 +97,15 @@ mod tests {
let mut query_resp = Payload::new();
let mut device = Device::new();
device.state.on = Some(true);
device.state = json!({
"on": true,
});
query_resp.add_device("123", device);
let mut device = Device::new();
device.state.on = Some(false);
device.state = json!({
"on": true,
});
query_resp.add_device("456", device);
let resp = Response::new(
@ -107,10 +113,26 @@ mod tests {
ResponsePayload::Query(query_resp),
);
let json = serde_json::to_string(&resp).unwrap();
let resp = serde_json::to_value(resp).unwrap();
println!("{}", json);
let resp_expected = json!({
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
"payload": {
"devices": {
"123": {
"online": true,
"status": "SUCCESS",
"on": true
},
"456": {
"online": true,
"status": "SUCCESS",
"on":true
}
}
}
});
// TODO: Add a known correct output to test against
assert_eq!(resp, resp_expected);
}
}

View File

@ -1,6 +1,5 @@
use serde::Serialize;
use crate::attributes::Attributes;
use crate::device;
use crate::errors::ErrorCode;
use crate::traits::Trait;
@ -47,7 +46,8 @@ pub struct Device {
pub room_hint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_info: Option<device::Info>,
pub attributes: Attributes,
#[serde(skip_serializing_if = "serde_json::Value::is_null")]
pub attributes: serde_json::Value,
}
impl Device {
@ -61,19 +61,19 @@ impl Device {
notification_supported_by_agent: None,
room_hint: None,
device_info: None,
attributes: Attributes::default(),
attributes: Default::default(),
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::{
response::{Response, ResponsePayload},
traits::Trait,
types::Type,
};
use crate::response::{Response, ResponsePayload};
use crate::traits::Trait;
use crate::types::Type;
#[test]
fn serialize() {
@ -99,10 +99,35 @@ mod tests {
ResponsePayload::Sync(sync_resp),
);
let json = serde_json::to_string(&resp).unwrap();
let resp = serde_json::to_value(resp).unwrap();
println!("{}", json);
let resp_expected = json!({
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
"payload": {
"agentUserId": "1836.15267389",
"devices": [
{
"id": "123",
"type": "action.devices.types.KETTLE",
"traits": ["action.devices.traits.OnOff"],
"name": {
"defaultNames": ["My Outlet 1234"],
"name": "Night light",
"nicknames": ["wall plug"]
},
"willReportState": false,
"roomHint": "kitchen",
"deviceInfo": {
"manufacturer": "lights-out-inc",
"model": "hs1234",
"hwVersion": "3.2",
"swVersion": "11.4"
}
}
]
}
});
// assert_eq!(json, r#"{"requestId":"ff36a3cc-ec34-11e6-b1a0-64510650abcf","payload":{"agentUserId":"1836.15267389","devices":[{"id":"123","type":"action.devices.types.KETTLE","traits":["action.devices.traits.OnOff"],"name":{"defaultNames":["My Outlet 1234"],"name":"Night light","nicknames":["wall plug"]},"willReportState":false,"roomHint":"kitchen","deviceInfo":{"manufacturer":"lights-out-inc","model":"hs1234","hwVersion":"3.2","swVersion":"11.4"}}]}}"#)
assert_eq!(resp, resp_expected);
}
}

View File

@ -0,0 +1,83 @@
#![allow(non_snake_case)]
use automation_cast::Cast;
use google_home_macro::traits;
use serde::Serialize;
use crate::errors::ErrorCode;
use crate::Device;
traits! {
Device,
"action.devices.traits.OnOff" => trait OnOff {
command_only_on_off: Option<bool>,
query_only_on_off: Option<bool>,
async fn on(&self) -> Result<bool, ErrorCode>,
"action.devices.commands.OnOff" => async fn set_on(&self, on: bool) -> Result<(), ErrorCode>,
},
"action.devices.traits.OpenClose" => trait OpenClose {
discrete_only_open_close: Option<bool>,
command_only_open_close: Option<bool>,
query_only_open_close: Option<bool>,
async fn open_percent(&self) -> Result<u8, ErrorCode>,
"action.devices.commands.OpenClose" => async fn set_open_percent(&self, open_percent: u8) -> Result<(), ErrorCode>,
},
"action.devices.traits.Brightness" => trait Brightness {
command_only_brightness: Option<bool>,
async fn brightness(&self) -> Result<u8, ErrorCode>,
"action.devices.commands.BrightnessAbsolute" => async fn set_brightness(&self, brightness: u8) -> Result<(), ErrorCode>,
},
"action.devices.traits.Scene" => trait Scene {
scene_reversible: Option<bool>,
"action.devices.commands.ActivateScene" => async fn set_active(&self, deactivate: bool) -> Result<(), ErrorCode>,
},
"action.devices.traits.FanSpeed" => trait FanSpeed {
reversible: Option<bool>,
command_only_fan_speed: Option<bool>,
available_fan_speeds: AvailableSpeeds,
async fn current_fan_speed_setting(&self) -> Result<String, ErrorCode>,
// TODO: Figure out some syntax for optional command?
// Probably better to just force the user to always implement commands?
"action.devices.commands.SetFanSpeed" => async fn set_fan_speed(&self, fan_speed: String) -> Result<(), ErrorCode>,
},
"action.devices.traits.HumiditySetting" => trait HumiditySetting {
query_only_humidity_setting: Option<bool>,
async fn humidity_ambient_percent(&self) -> Result<isize, ErrorCode>,
},
"action.devices.traits.TemperatureControl" => trait TemperatureSetting {
query_only_temperature_control: Option<bool>,
// TODO: Add rename
temperatureUnitForUX: TemperatureUnit,
async fn temperature_ambient_celsius(&self) -> Result<f32, ErrorCode>,
}
}
#[derive(Debug, Serialize)]
pub struct SpeedValue {
pub speed_synonym: Vec<String>,
pub lang: String,
}
#[derive(Debug, Serialize)]
pub struct Speed {
pub speed_name: String,
pub speed_values: Vec<SpeedValue>,
}
#[derive(Debug, Serialize)]
pub struct AvailableSpeeds {
pub speeds: Vec<Speed>,
pub ordered: bool,
}
#[derive(Debug, Serialize)]
pub enum TemperatureUnit {
#[serde(rename = "C")]
Celsius,
#[serde(rename = "F")]
Fahrenheit,
}

View File

@ -12,4 +12,10 @@ pub enum Type {
Scene,
#[serde(rename = "action.devices.types.AIRPURIFIER")]
AirPurifier,
#[serde(rename = "action.devices.types.DOOR")]
Door,
#[serde(rename = "action.devices.types.WINDOW")]
Window,
#[serde(rename = "action.devices.types.DRAWER")]
Drawer,
}

View File

@ -0,0 +1,12 @@
[package]
name = "google_home_macro"
version = "0.1.0"
edition = "2021"
[lib]
proc-macro = true
[dependencies]
proc-macro2 = { workspace = true }
quote = { workspace = true }
syn = { workspace = true }

View File

@ -0,0 +1,569 @@
#![feature(let_chains)]
#![feature(iter_intersperse)]
use proc_macro::TokenStream;
use quote::quote;
use syn::parse::Parse;
use syn::punctuated::Punctuated;
use syn::token::Brace;
use syn::{
braced, parse_macro_input, GenericArgument, Ident, LitStr, Path, PathArguments, PathSegment,
ReturnType, Signature, Token, Type, TypePath,
};
mod kw {
use syn::custom_keyword;
custom_keyword!(required);
}
#[derive(Debug)]
struct FieldAttribute {
ident: Ident,
_colon_token: Token![:],
ty: Type,
}
impl Parse for FieldAttribute {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
Ok(Self {
ident: input.parse()?,
_colon_token: input.parse()?,
ty: input.parse()?,
})
}
}
#[derive(Debug)]
struct FieldState {
sign: Signature,
}
impl Parse for FieldState {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
Ok(Self {
sign: input.parse()?,
})
}
}
#[derive(Debug)]
struct FieldExecute {
name: LitStr,
_fat_arrow_token: Token![=>],
sign: Signature,
}
impl Parse for FieldExecute {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
Ok(Self {
name: input.parse()?,
_fat_arrow_token: input.parse()?,
sign: input.parse()?,
})
}
}
#[derive(Debug)]
enum Field {
Attribute(FieldAttribute),
State(FieldState),
Execute(FieldExecute),
}
impl Parse for Field {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
if input.peek(Ident) {
Ok(Field::Attribute(input.parse()?))
} else if input.peek(LitStr) {
Ok(Field::Execute(input.parse()?))
} else {
Ok(Field::State(input.parse()?))
}
}
}
#[derive(Debug)]
struct Trait {
name: LitStr,
_fat_arrow_token: Token![=>],
_trait_token: Token![trait],
ident: Ident,
_brace_token: Brace,
fields: Punctuated<Field, Token![,]>,
}
impl Parse for Trait {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let content;
Ok(Self {
name: input.parse()?,
_fat_arrow_token: input.parse()?,
_trait_token: input.parse()?,
ident: input.parse()?,
_brace_token: braced!(content in input),
fields: content.parse_terminated(Field::parse, Token![,])?,
})
}
}
#[derive(Debug)]
struct Input {
ty: TypePath,
_comma: Token![,],
traits: Punctuated<Trait, Token![,]>,
}
// TODO: Error on duplicate name?
impl Parse for Input {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
Ok(Self {
ty: input.parse()?,
_comma: input.parse()?,
traits: input.parse_terminated(Trait::parse, Token![,])?,
})
}
}
fn extract_type_path(ty: &syn::Type) -> Option<&Path> {
match *ty {
Type::Path(ref typepath) if typepath.qself.is_none() => Some(&typepath.path),
_ => None,
}
}
fn extract_segment<'a>(path: &'a Path, options: &[&str]) -> Option<&'a PathSegment> {
let idents_of_path = path
.segments
.iter()
.map(|segment| segment.ident.to_string())
.intersperse('|'.into())
.collect::<String>();
options
.iter()
.find(|s| &idents_of_path == *s)
.and_then(|_| path.segments.last())
}
// Based on: https://stackoverflow.com/a/56264023
fn extract_type_from_option(ty: &syn::Type) -> Option<&syn::Type> {
extract_type_path(ty)
.and_then(|path| {
extract_segment(path, &["Option", "std|option|Option", "core|option|Option"])
})
.and_then(|path_seg| {
let type_params = &path_seg.arguments;
// It should have only on angle-bracketed param ("<String>"):
match *type_params {
PathArguments::AngleBracketed(ref params) => params.args.first(),
_ => None,
}
})
.and_then(|generic_arg| match *generic_arg {
GenericArgument::Type(ref ty) => Some(ty),
_ => None,
})
}
fn extract_type_from_result(ty: &syn::Type) -> Option<&syn::Type> {
extract_type_path(ty)
.and_then(|path| {
extract_segment(path, &["Result", "std|result|Result", "core|result|Result"])
})
.and_then(|path_seg| {
let type_params = &path_seg.arguments;
// It should have only on angle-bracketed param ("<String>"):
match *type_params {
PathArguments::AngleBracketed(ref params) => params.args.first(),
_ => None,
}
})
.and_then(|generic_arg| match *generic_arg {
GenericArgument::Type(ref ty) => Some(ty),
_ => None,
})
}
fn get_attributes_struct_ident(t: &Trait) -> Ident {
syn::Ident::new(&format!("{}Attributes", t.ident), t.ident.span())
}
fn get_attributes_struct(t: &Trait) -> proc_macro2::TokenStream {
let fields = t.fields.iter().filter_map(|f| match f {
Field::Attribute(attr) => {
let ident = &attr.ident;
let ty = &attr.ty;
// TODO: Extract into function
if let Some(ty) = extract_type_from_option(ty) {
Some(quote! {
#[serde(skip_serializing_if = "core::option::Option::is_none")]
#ident: ::core::option::Option<#ty>
})
} else {
Some(quote! {
#ident: #ty
})
}
}
_ => None,
});
let name = get_attributes_struct_ident(t);
quote! {
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct #name {
#(#fields,)*
}
}
}
fn get_state_struct_ident(t: &Trait) -> Ident {
syn::Ident::new(&format!("{}State", t.ident), t.ident.span())
}
fn get_state_struct(t: &Trait) -> proc_macro2::TokenStream {
let fields = t.fields.iter().filter_map(|f| match f {
Field::State(state) => {
let ident = &state.sign.ident;
let ReturnType::Type(_, ty) = &state.sign.output else {
return None;
};
let ty = extract_type_from_result(ty).unwrap_or(ty);
if let Some(ty) = extract_type_from_option(ty) {
Some(quote! {
#[serde(skip_serializing_if = "core::option::Option::is_none")]
#ident: ::core::option::Option<#ty>
})
} else {
Some(quote! {#ident: #ty})
}
}
_ => None,
});
let name = get_state_struct_ident(t);
quote! {
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct #name {
#(#fields,)*
}
}
}
fn get_command_enum(traits: &Punctuated<Trait, Token![,]>) -> proc_macro2::TokenStream {
let items = traits.iter().flat_map(|t| {
t.fields.iter().filter_map(|f| match f {
Field::Execute(execute) => {
let name = execute.name.value();
let ident = Ident::new(
name.split_at(name.rfind('.').map(|v| v + 1).unwrap_or(0)).1,
execute.name.span(),
);
let parameters = execute.sign.inputs.iter().skip(1);
Some(quote! {
#[serde(rename = #name, rename_all = "camelCase")]
#ident {
#(#parameters,)*
}
})
}
_ => None,
})
});
quote! {
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(tag = "command", content = "params", rename_all = "camelCase")]
pub enum Command {
#(#items,)*
}
}
}
fn get_trait_enum(traits: &Punctuated<Trait, Token![,]>) -> proc_macro2::TokenStream {
let items = traits.iter().map(|t| {
let name = &t.name;
let ident = &t.ident;
quote! {
#[serde(rename = #name)]
#ident
}
});
quote! {
#[derive(Debug, serde::Serialize)]
pub enum Trait {
#(#items,)*
}
}
}
fn get_trait(t: &Trait) -> proc_macro2::TokenStream {
let fields = t.fields.iter().map(|f| match f {
Field::Attribute(attr) => {
let name = &attr.ident;
let ty = &attr.ty;
// If the default type is marked as optional, respond None by default
if let Some(ty) = extract_type_from_option(ty) {
quote! {
fn #name(&self) -> Option<#ty> {
None
}
}
} else {
quote! {
fn #name(&self) -> #ty;
}
}
}
Field::State(state) => {
let sign = &state.sign;
let ReturnType::Type(_, ty) = &state.sign.output else {
todo!("Handle weird function return types");
};
let inner = extract_type_from_result(ty);
// If the default type is marked as optional, respond None by default
if extract_type_from_option(inner.unwrap_or(ty)).is_some() {
if inner.is_some() {
quote! {
#sign {
Ok(None)
}
}
} else {
quote! {
#sign {
None
}
}
}
} else {
quote! {
#sign;
}
}
}
Field::Execute(execute) => {
let sign = &execute.sign;
quote! {
#sign;
}
}
});
let ident = &t.ident;
let attr_ident = get_attributes_struct_ident(t);
let attr = t.fields.iter().filter_map(|f| match f {
Field::Attribute(attr) => {
let name = &attr.ident;
Some(quote! {
#name: self.#name()
})
}
_ => None,
});
let state_ident = get_state_struct_ident(t);
let state = t.fields.iter().filter_map(|f| match f {
Field::State(state) => {
let ident = &state.sign.ident;
let f_ident = &state.sign.ident;
let asyncness = if state.sign.asyncness.is_some() {
quote! {.await}
} else {
quote! {}
};
let errors = if let ReturnType::Type(_, ty) = &state.sign.output
&& extract_type_from_result(ty).is_some()
{
quote! {?}
} else {
quote! {}
};
Some(quote! {
#ident: self.#f_ident() #asyncness #errors,
})
}
_ => None,
});
quote! {
#[async_trait::async_trait]
pub trait #ident: Sync + Send {
#(#fields)*
fn get_attributes(&self) -> #attr_ident {
#attr_ident { #(#attr,)* }
}
async fn get_state(&self) -> Result<#state_ident, Box<dyn ::std::error::Error>> {
Ok(#state_ident { #(#state)* })
}
}
}
}
#[proc_macro]
pub fn traits(item: TokenStream) -> TokenStream {
let input = parse_macro_input!(item as Input);
let traits = input.traits;
let structs = traits.iter().map(|t| {
let attr = get_attributes_struct(t);
let state = get_state_struct(t);
let tra = get_trait(t);
quote! {
#attr
#state
#tra
}
});
let command_enum = get_command_enum(&traits);
let trait_enum = get_trait_enum(&traits);
let sync = traits.iter().map(|t| {
let ident = &t.ident;
quote! {
if let Some(t) = self.cast() as Option<&dyn #ident> {
traits.push(Trait::#ident);
let value = serde_json::to_value(t.get_attributes())?;
json_value_merge::Merge::merge(&mut attrs, &value);
}
}
});
let query = traits.iter().map(|t| {
let ident = &t.ident;
quote! {
if let Some(t) = self.cast() as Option<&dyn #ident> {
let value = serde_json::to_value(t.get_state().await?)?;
json_value_merge::Merge::merge(&mut state, &value);
}
}
});
let execute = traits.iter().flat_map(|t| {
t.fields.iter().filter_map(|f| match f {
Field::Execute(execute) => {
let ident = &t.ident;
let name = execute.name.value();
let command_name = Ident::new(
name.split_at(name.rfind('.').map(|v| v + 1).unwrap_or(0)).1,
execute.name.span(),
);
let f_name = &&execute.sign.ident;
let parameters = execute
.sign
.inputs
.iter()
.filter_map(|p| {
if let syn::FnArg::Typed(p) = p {
Some(&p.pat)
} else {
None
}
})
.collect::<Vec<_>>();
let asyncness = if execute.sign.asyncness.is_some() {
quote! {.await}
} else {
quote! {}
};
let errors = if let ReturnType::Type(_, ty) = &execute.sign.output
&& extract_type_from_result(ty).is_some()
{
quote! {?}
} else {
quote! {}
};
Some(quote! {
Command::#command_name {#(#parameters,)*} => {
if let Some(t) = self.cast() as Option<&dyn #ident> {
t.#f_name(#(#parameters,)*) #asyncness #errors;
serde_json::to_value(t.get_state().await?)?
} else {
todo!("Device does not support action, return proper error");
}
}
})
}
_ => None,
})
});
let ty = input.ty;
let fulfillment = Ident::new(
&format!("{}Fulfillment", ty.path.segments.last().unwrap().ident),
ty.path.segments.last().unwrap().ident.span(),
);
quote! {
// TODO: This is always the same, so should not be part of the macro, but instead something
// else
#[async_trait::async_trait]
pub trait #fulfillment: Sync + Send {
async fn sync(&self) -> Result<(Vec<Trait>, serde_json::Value), Box<dyn ::std::error::Error>>;
async fn query(&self) -> Result<serde_json::Value, Box<dyn ::std::error::Error>>;
async fn execute(&self, command: Command) -> Result<serde_json::Value, Box<dyn std::error::Error>>;
}
#(#structs)*
#command_enum
#trait_enum
#[async_trait::async_trait]
impl<D> #fulfillment for D where D: #ty
{
async fn sync(&self) -> Result<(Vec<Trait>, serde_json::Value), Box<dyn ::std::error::Error>> {
let mut traits = Vec::new();
let mut attrs = serde_json::Value::Null;
#(#sync)*
Ok((traits, attrs))
}
async fn query(&self) -> Result<serde_json::Value, Box<dyn ::std::error::Error>> {
let mut state = serde_json::Value::Null;
#(#query)*
Ok(state)
}
async fn execute(&self, command: Command) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let value = match command {
#(#execute)*
};
Ok(value)
}
}
}
.into()
}

View File

@ -1,15 +0,0 @@
[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

View File

@ -1,164 +0,0 @@
use proc_macro::TokenStream;
use quote::{format_ident, quote, ToTokens};
use syn::{parse::Parse, 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()
}

4
rust-toolchain.toml Normal file
View File

@ -0,0 +1,4 @@
[toolchain]
channel = "nightly-2024-12-06"
components = ["rustfmt", "clippy", "rust-analyzer"]
profile = "minimal"

View File

@ -1,68 +0,0 @@
use axum::{
async_trait,
extract::{FromRef, FromRequestParts},
http::{request::Parts, 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)
}
}
}

View File

@ -1,143 +0,0 @@
use std::{
fs,
net::{Ipv4Addr, SocketAddr},
time::Duration,
};
use indexmap::IndexMap;
use regex::{Captures, Regex};
use rumqttc::{MqttOptions, Transport};
use serde::{Deserialize, Deserializer};
use tracing::debug;
use crate::{
auth::OpenIDConfig,
device_manager::DeviceConfigs,
devices::PresenceConfig,
error::{ConfigParseError, MissingEnv},
};
#[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>,
}
#[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)
}
}

View File

@ -1,216 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use enum_dispatch::enum_dispatch;
use futures::future::join_all;
use rumqttc::{matches, AsyncClient, QoS};
use serde::Deserialize;
use tokio::sync::{RwLock, RwLockReadGuard};
use tracing::{debug, error, instrument, trace};
use crate::{
devices::{
AirFilterConfig, As, AudioSetupConfig, ContactSensorConfig, DebugBridgeConfig, Device,
HueBridgeConfig, HueGroupConfig, IkeaOutletConfig, KasaOutletConfig, LightSensorConfig,
WakeOnLANConfig, WasherConfig,
},
error::DeviceConfigError,
event::OnDarkness,
event::OnNotification,
event::OnPresence,
event::{Event, EventChannel, OnMqtt},
};
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
}
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;
}
}
}
}

View File

@ -1,216 +0,0 @@
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(())
}
}

View File

@ -1,157 +0,0 @@
use async_trait::async_trait;
use google_home::traits::OnOff;
use serde::Deserialize;
use tracing::{debug, error, trace, warn};
use crate::{
config::MqttDeviceConfig,
device_manager::{ConfigExternal, DeviceConfig, WrappedDevice},
devices::As,
error::DeviceConfigError,
event::OnMqtt,
event::OnPresence,
messages::{RemoteAction, RemoteMessage},
};
use super::Device;
#[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();
}
}
}
}

View File

@ -1,246 +0,0 @@
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 crate::{
config::MqttDeviceConfig,
device_manager::{ConfigExternal, DeviceConfig, WrappedDevice},
devices::{As, DEFAULT_PRESENCE},
error::DeviceConfigError,
event::OnMqtt,
event::OnPresence,
messages::{ContactMessage, PresenceMessage},
traits::Timeout,
};
use super::Device;
// 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();
}));
}
}
}

View File

@ -1,97 +0,0 @@
use async_trait::async_trait;
use rumqttc::AsyncClient;
use serde::Deserialize;
use tracing::warn;
use crate::device_manager::ConfigExternal;
use crate::device_manager::DeviceConfig;
use crate::devices::Device;
use crate::error::DeviceConfigError;
use crate::event::OnDarkness;
use crate::event::OnPresence;
use crate::{
config::MqttDeviceConfig,
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();
}
}

View File

@ -1,315 +0,0 @@
use std::{
net::{Ipv4Addr, SocketAddr},
time::Duration,
};
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use google_home::{errors::ErrorCode, traits::OnOff};
use rumqttc::Publish;
use serde::Deserialize;
use tracing::{debug, error, warn};
use crate::{
config::MqttDeviceConfig,
device_manager::{ConfigExternal, DeviceConfig},
error::DeviceConfigError,
event::OnMqtt,
messages::{RemoteAction, RemoteMessage},
traits::Timeout,
};
use super::Device;
#[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, 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()
}
}
}

View File

@ -1,269 +0,0 @@
use anyhow::Result;
use async_trait::async_trait;
use google_home::errors::ErrorCode;
use google_home::{
device,
traits::{self, OnOff},
types::Type,
GoogleHomeDevice,
};
use rumqttc::{matches, AsyncClient, Publish};
use serde::Deserialize;
use serde_with::serde_as;
use serde_with::DurationSeconds;
use std::time::Duration;
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;
use crate::event::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(())
}
}

View File

@ -1,105 +0,0 @@
use async_trait::async_trait;
use rumqttc::Publish;
use serde::Deserialize;
use tracing::{debug, trace, warn};
use crate::{
config::MqttDeviceConfig,
device_manager::{ConfigExternal, DeviceConfig},
devices::Device,
error::DeviceConfigError,
event::OnMqtt,
event::{self, Event},
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");
}
}
}
}

View File

@ -1,37 +0,0 @@
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;
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 google_home::{device::AsGoogleHomeDevice, traits::OnOff};
use crate::traits::Timeout;
use crate::{event::OnDarkness, event::OnMqtt, event::OnNotification, event::OnPresence};
#[impl_cast::device(As: OnMqtt + OnPresence + OnDarkness + OnNotification + OnOff + Timeout)]
pub trait Device: AsGoogleHomeDevice + std::fmt::Debug + Sync + Send {
fn get_id(&self) -> &str;
}

View File

@ -1,96 +0,0 @@
use std::collections::HashMap;
use async_trait::async_trait;
use rumqttc::Publish;
use serde::Deserialize;
use tracing::{debug, warn};
use crate::{
config::MqttDeviceConfig,
devices::Device,
event::OnMqtt,
event::{self, Event, EventChannel},
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");
}
}
}
}

View File

@ -1,158 +0,0 @@
use std::net::Ipv4Addr;
use async_trait::async_trait;
use eui48::MacAddress;
use google_home::{
device,
errors::ErrorCode,
traits::{self, Scene},
types::Type,
GoogleHomeDevice,
};
use rumqttc::Publish;
use serde::Deserialize;
use tracing::{debug, error, trace};
use crate::{
config::{InfoConfig, MqttDeviceConfig},
device_manager::{ConfigExternal, DeviceConfig},
error::DeviceConfigError,
event::OnMqtt,
messages::ActivateMessage,
};
use super::Device;
#[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,
))
}
}
}

View File

@ -1,122 +0,0 @@
use async_trait::async_trait;
use rumqttc::Publish;
use serde::Deserialize;
use tracing::{debug, error, warn};
use crate::{
config::MqttDeviceConfig,
device_manager::{ConfigExternal, DeviceConfig},
error::DeviceConfigError,
event::{Event, EventChannel, OnMqtt},
messages::PowerMessage,
};
use super::{ntfy::Priority, Device, Notification};
#[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;
}
}
}

View File

@ -1,31 +1,37 @@
#![feature(async_closure)]
mod web;
use std::net::SocketAddr;
use std::path::Path;
use std::process;
use axum::{
extract::FromRef, http::StatusCode, response::IntoResponse, routing::post, Json, Router,
};
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 axum::http::StatusCode;
use axum::routing::post;
use axum::{Json, Router};
use dotenvy::dotenv;
use google_home::{GoogleHome, Request, Response};
use mlua::LuaSerdeExt;
use rumqttc::AsyncClient;
use tracing::{debug, error, info};
use automation::{
auth::{OpenIDConfig, User},
config::Config,
device_manager::DeviceManager,
devices::{Ntfy, Presence},
error::ApiError,
mqtt,
};
use google_home::{GoogleHome, Request};
use tokio::net::TcpListener;
use tracing::{debug, error, info, warn};
use web::{ApiError, User};
#[derive(Clone)]
struct AppState {
pub openid: OpenIDConfig,
pub openid_url: String,
pub device_manager: DeviceManager,
}
impl FromRef<AppState> for OpenIDConfig {
impl FromRef<AppState> for String {
fn from_ref(input: &AppState) -> Self {
input.openid.clone()
input.openid_url.clone()
}
}
@ -42,80 +48,118 @@ 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();
console_subscriber::init();
tracing_subscriber::fmt::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(client.clone());
let device_manager = DeviceManager::new().await;
for (id, device_config) in config.devices {
device_manager.create(&id, device_config).await?;
}
let fulfillment_config = {
let lua = mlua::Lua::new();
let event_channel = device_manager.event_channel();
lua.set_warning_function(|_lua, text, _cont| {
warn!("{text}");
Ok(())
});
// Create and add the presence system
{
let presence = Presence::new(config.presence, &event_channel);
device_manager.add(Box::new(presence)).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)?;
// 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;
}
// 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);
// 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);
Ok(WrappedAsyncClient(client))
})?;
// 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()
}
};
automation.set("new_mqtt_client", new_mqtt_client)?;
automation.set("device_manager", device_manager.clone())?;
debug!(username = user.preferred_username, "{result:#?}");
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)?;
(StatusCode::OK, Json(result)).into_response()
}),
);
lua.globals().set("automation", automation)?;
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));
// Combine together all the routes
let app = Router::new()
.nest("/fullfillment", fullfillment)
.nest("/fulfillment", fulfillment)
.with_state(AppState {
openid: config.openid,
openid_url: fulfillment_config.openid_url.clone(),
device_manager,
});
// Start the web server
let addr = config.fullfillment.into();
let addr: SocketAddr = fulfillment_config.into();
info!("Server started on http://{addr}");
axum::Server::try_bind(&addr)?
.serve(app.into_make_service())
.await?;
let listener = TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}

View File

@ -1,12 +0,0 @@
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 Normal file
View File

@ -0,0 +1,132 @@
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)
}
}
}