Compare commits

..

198 Commits

Author SHA1 Message Date
ad158f2c22 feat: Reduced visibility of config structs
All checks were successful
Build and deploy / build (push) Successful in 9m0s
Build and deploy / Deploy container (push) Successful in 49s
2025-10-22 04:13:54 +02:00
f36adf2f19 feat: Implement useful traits to simplify code 2025-10-22 04:09:01 +02:00
5947098bfb chore: Fix config type annotations
All checks were successful
Build and deploy / build (push) Successful in 12m30s
Build and deploy / Deploy container (push) Has been skipped
2025-10-22 03:59:59 +02:00
8a3143a3ea feat: Added type alias for setup and schedule types 2025-10-22 03:59:40 +02:00
9546585440 feat(config)!: Made schedule part of new modules
All checks were successful
Build and deploy / build (push) Successful in 11m57s
Build and deploy / Deploy container (push) Has been skipped
2025-10-22 03:24:34 +02:00
a938f3d71b feat(config)!: Improve config module resolution
All checks were successful
Build and deploy / build (push) Successful in 11m31s
Build and deploy / Deploy container (push) Has been skipped
The new system is slightly less flexible, but the code and lua
definitions is now a lot simpler and easier to understand.
In fact the old lua definition was not actually correct.

It is likely that existing configs require not/minimal tweaks to work
again.
2025-10-22 03:09:15 +02:00
a6c19eb9b4 fix: Fix issues with inner type definitions 2025-10-22 02:59:21 +02:00
7db628709a refactor: Split config
All checks were successful
Build and deploy / build (push) Successful in 10m56s
Build and deploy / Deploy container (push) Has been skipped
2025-10-20 05:02:19 +02:00
bc75f7005c feat(config)!: Device creation function is now named entry
It now has to be called 'setup', this makes it possible to just
include the table as a whole in devices and it will automatically call
the correct function.
2025-10-20 05:02:04 +02:00
2056c6c70d feat(config)!: Changed default config location 2025-10-20 04:48:33 +02:00
2fe9fbadfb feat(config)!: Remove device manager lua code
With the recent changes the device manager no longer needs to be
available in lua.
2025-10-20 04:48:33 +02:00
2db4af7427 feat(config)!: Config now returns the mqtt config instead of the client
Instead the client is now created on the rust side based on the config.
Devices that require the mqtt client will now instead need to be
constructor using a function. This function receives the mqtt client.
2025-10-20 04:48:32 +02:00
7b7279017f refactor: Restructured config to not rely on mqtt client being available
In preparation of changes to the mqtt client the config is rewritten to
use a device creation function for devices that need the mqtt client.

This also fixes a but where hallway_top_light was not actually added to
the device manager.
2025-10-20 04:48:29 +02:00
f05856cd0c feat(config)!: In config devices can now also be a (table of) function(s)
This function receives the mqtt client as an argument. In the future
this will be the only way to create devices that require the mqtt client.
2025-10-20 04:48:28 +02:00
02b6cf12a1 feat: Improved device conversion error message 2025-10-20 04:48:28 +02:00
02b87126e1 feat: Use ActionCallback for schedule
This has two advantages:
- Each schedule entry can take either a single function or table of
  functions.
- We get a better type definition.
2025-10-20 04:48:28 +02:00
1ffccd955c refactor(config)!: Move scheduler out of device_manager
Due to changes made in mlua the new scheduler is much simpler. It also
had no real business being part of the device manager, so it has now been
moved to be part of the returned config.
2025-10-20 04:48:28 +02:00
948380ea9b feat: Receive devices through config return 2025-10-20 04:48:28 +02:00
0c80cef5a1 feat: Ensure consistent ordering device definitions 2025-10-20 04:48:28 +02:00
84e8942fc9 feat: Generate definitions for config 2025-10-20 04:48:27 +02:00
b557afe2fc refactor: Move definition writing into separate function 2025-10-20 04:48:27 +02:00
5801421378 refactor: Move main.rs to bin/automation.rs 2025-10-20 04:48:22 +02:00
ba818c6b60 refactor(config)!: Setup for expanding lua config return
Moves the application config out of automation_lib and sets up the
config return type for further expansion.
2025-10-17 03:08:21 +02:00
a95574b731 feat: Added type annotations to config.lua
All checks were successful
Build and deploy / build (push) Successful in 9m16s
Build and deploy / Deploy container (push) Successful in 3m12s
In some instances this required some restructuring of the code to be
able to properly add the annotations.
2025-10-15 04:24:08 +02:00
810fae8da5 chore: Reordered pre-commit hooks 2025-10-15 04:23:12 +02:00
6fc3783d7a feat: Added lua definition files
Also added a pre-commit hook to ensure that the definitions files are
up-to-date.
2025-10-15 04:23:12 +02:00
df64804b00 feat: Add bin to automatically generate lua definitions 2025-10-15 04:01:15 +02:00
11b9787890 chore: Remove allow that is no longer required 2025-10-15 03:57:57 +02:00
8961101fdf chore: Run main application by default 2025-10-15 03:57:02 +02:00
17a68e8991 feat: Added optional definition function to module 2025-10-15 03:53:55 +02:00
be1602d0e2 feat(config)!: Move mqtt module to actual separate module
The automation:mqtt module now gets loaded in a similar way as the
automation:devices and automation:utils modules.
This leads to a breaking change where instantiating a new mqtt client
the device manager needs to be explicitly passed in.
2025-10-15 03:53:55 +02:00
9bddeae54e feat: Use Typed::type_name for Timeout proxy name 2025-10-15 03:53:06 +02:00
97b944874a feat: Added/expanded Typed impls 2025-10-15 03:50:50 +02:00
54164c517b feat: Remove automatic automation: module prefix
Instead the prefix should be manually specified if it is desired.
2025-10-15 03:50:36 +02:00
518abd169d chore: Removed dotenvy
Since secrets can now be set from automation.toml the .env file was no
longer used, so dotenvy can be removed.
2025-10-15 03:44:17 +02:00
30ea9b2737 feat: Use Typed type_name for registering proxy 2025-10-15 03:44:17 +02:00
cd470cadaf feat!: Expanded add_methods to extra_user_data
Instead of being a function it now expects a struct with the
PartialUserData trait implemented. This in part ensures the correct
function signature.

It also adds another optional function to PartialUserData that returns
definitions for the added methods.
2025-10-15 03:44:17 +02:00
4b76bde2a6 feat: Specify (optional) interface name in PartialUserData 2025-10-15 03:44:17 +02:00
006a561307 feat: Use PartialUserData on proxy type to add trait methods 2025-10-15 03:44:17 +02:00
745a1025bb feat!: Improved attribute parsing in device macro 2025-10-15 03:44:13 +02:00
45485fca37 feat: Add proper type definition for devices
Depending on the implemented traits the lua class will inherit from the
associated interface class.

It also specifies the constructor function for each of the devices.
2025-10-15 00:45:37 +02:00
1532958a86 feat: Added Typed impl for all automation devices
To accomplish this a basic implementation was also provided for some
types in automation_lib
2025-10-15 00:45:37 +02:00
76eb63cd97 feat: Use same add_methods mechanic for Device as for other traits
All checks were successful
Build and deploy / build (push) Successful in 9m57s
Build and deploy / Deploy container (push) Has been skipped
2025-10-10 03:33:28 +02:00
b784cfed4a feat: Notify when windows are left open when leaving
All checks were successful
Build and deploy / build (push) Successful in 15m2s
Build and deploy / Deploy container (push) Successful in 2m8s
2025-10-10 01:12:58 +02:00
06b3154733 feat!: Use type alias instead of generic parameters in device macro
All checks were successful
Build and deploy / build (push) Successful in 10m11s
Build and deploy / Deploy container (push) Successful in 2m8s
This enforced the idea that all generics must be specified for the type
when using the device macro. It will also come into play later when the
Typed macro gets introduced, as the name will be used when generating
definitions.
2025-09-17 00:35:30 +02:00
580a5187bd feat!: Made ntfy notification title required
All checks were successful
Build and deploy / build (push) Successful in 12m26s
Build and deploy / Deploy container (push) Successful in 43s
2025-09-13 04:04:51 +02:00
8982e9c165 feat(config)!: Put automation modules in namespace
All checks were successful
Build and deploy / build (push) Successful in 13m33s
Build and deploy / Deploy container (push) Successful in 39s
All lua modules that originate from automation_rs are now prefixed with
`automation:`.
2025-09-11 04:12:15 +02:00
4e28ad0f85 feat!: Improve device type registration
All checks were successful
Build and deploy / build (push) Successful in 10m37s
Build and deploy / Deploy container (push) Successful in 38s
Instead of one function that contains all the device types available in
`automation_devices` a global registry is used were each device can
register itself.
2025-09-10 03:02:05 +02:00
f0e4c9dd21 chore!: Remove unused notification setters
Since the creation of notifications has moved entirely to lua these
setters were not being used anymore.
2025-09-10 02:56:56 +02:00
5271e5ad81 refactor(config)!: Moved Timeout into utils module and moved module
All checks were successful
Build and deploy / build (push) Successful in 10m43s
Build and deploy / Deploy container (push) Successful in 39s
The module is now setup in automation_lib::lua::utils.
2025-09-10 02:11:11 +02:00
1d28b43264 refactor: Move module load code into separate function 2025-09-10 02:11:11 +02:00
da04fad520 refactor(config)!: Move device proxies into module
Instead of registering the device proxies in the global namespace they
are now registered in a module called `devices`.
2025-09-10 02:11:09 +02:00
84e4b30b6a feat!: Improve lua module registration
Instead of having to call all the module registration functions in one
place it is possible for each module to register itself in a global registry.
During startup all the all the modules will be registered
automatically.

This does currently have one weakness, to need to ensure that the crate
is linked.
2025-09-10 02:10:45 +02:00
95a8a377e8 feat!: Removed AddAdditionalMethods
It has been replaced with the add_methods device attribute.
2025-09-10 01:58:48 +02:00
23355190ca feat: Added attribute to easily register additional lua methods
Previously this could only be done by implementing a trait, like
`AddAdditionalMethods`, that that has an add_methods function where you
can put your custom methods. With this new attribute you can pass in a
register function directly!
2025-09-10 01:58:48 +02:00
2dbd491b81 refactor!: Rewrote device implementation macro once again
This time with a bit more though put into the design of the code, as a
result the macro should be a lot more robust.

This did result in the macro getting renamed from LuaDevice to Device as
this should be _the_ Device macro.
The attribute also got renamed from traits() to device(traits()) and the
syntax got overhauled to allow for a bit more expression.
2025-09-10 01:58:48 +02:00
aad089aa10 chore: Removed old leftover contact sensor presence config 2025-09-10 01:46:16 +02:00
18e40726fe refactor: Remove unneeded wrapper functions when specifying callbacks
These wrappers can be moved up to where the callback itself is defined
instead of having to wrap the call manually. This also works a lot nicer
now that it is possible to provide multiple callback functions.
2025-09-10 01:46:16 +02:00
1925bac73c fix: Front door presence does not get cleared properly 2025-09-10 01:46:16 +02:00
5383e7265d feat!: ActionCallback can now receive any amount of arguments
ActionCallback now only has one generics argument that has to implement
IntoLuaMulti, this makes ActionCallback much more flexible as it no
longer always requires two arguments.
2025-09-10 01:46:12 +02:00
352654107a feat: Added derive macro to implement IntoLua on structs that implement Serialize
This can be very useful if you want to convert a data struct to a lua
table without having to write the boilerplane (however small it may
be).

It also adds the macro on several state structs so they can be
converted to lua in the upcoming ActionCallback refactor.
2025-09-08 04:06:00 +02:00
3a7f2f9bd7 fix: IkeaRemote callback is missing default specifier 2025-09-08 04:05:43 +02:00
3be11b0c6a feat: Allow for multiple callbacks inside of an ActionCallback
This also results in the conversion being performed when the
ActionCallback is instantiated instead of when it is called, this should
make it easier to catch errors.
2025-09-08 04:02:47 +02:00
e880efe4cf refactor: Store callback function directly instead of in the registry 2025-09-08 04:02:47 +02:00
e2fb680cd6 feat: Log version string during startup 2025-09-08 04:02:40 +02:00
edee032b91 chore: Set RUST_LOG to something sensible by default when running with cargo
Some checks failed
Build and deploy / Deploy container (push) Blocked by required conditions
Build and deploy / build (push) Has been cancelled
2025-09-05 04:48:00 +02:00
8bb17e1440 feat(config)!: Reworked how configuration is loaded
The environment variable `AUTOMATION_CONFIG` has been renamed to
`AUTOMATION__ENTRYPOINT` and can now also be set in `automation.toml` by
specifying:
```
automation = "<path>"
```

Directly accessing the environment variables in lua in no longer
possible. To pass in configuration or secrets you can now instead make
use of the `variables` and `secrets` modules.

To set values in these modules you can either specify them in
`automation.toml`:
```
[variables]
<name> = <value>

[secrets]
<name> = <value>
```
Note that these values will get converted to a string.

You can also specify the environment variables
`AUTOMATION__VARIABLES__<name>` and `AUTOMATION__SECRETS__<name>` to
set variables and secrets respectively. By adding the suffix `__FILE` to
the environment variable name the contents of a file can be loaded into
the variable or secret.

Note that variables and secrets are identical in functionality and the
name difference exists purely to make it clear that secret values are
meant to be kept secret.
2025-09-05 04:48:00 +02:00
ba37de3939 feat(config)!: Move new_mqtt_client out of global automation table into separate module
The function `new_mqtt_client` was the last remaining entry in the
global `automation` table. The function was renamed to `new` and placed
in the new `mqtt` module. As `automation` is now empty, it has been
removed.
2025-09-05 03:55:04 +02:00
22fee0ed77 feat(config)!: Move device_manager out of global automation table into separate module
Moved `automation.device_manager` into a separate module called
`device_manager`
2025-09-05 03:55:03 +02:00
5aebab28ed feat(config)!: Move util out of global automation table into separate module
Move `automation.util` into a separate module called `utils`.
2025-09-05 03:55:03 +02:00
e626caad8a feat(config)!: Fulfillment config is now returned at the end of the config
Previously the fulfillment config was set by setting
`automation.fulfillment`, this will no longer work in the future when
the global automation gets split into modules.
2025-09-05 03:55:03 +02:00
0090a77dc1 style: Sort crates by name 2025-09-05 03:55:03 +02:00
4d356001c3 style: Enforce conventional commits formatting 2025-09-05 03:55:03 +02:00
aa22132dd6 chore: Put typos config in Cargo.toml 2025-09-04 04:28:02 +02:00
1db269f65e chore: Update pre-commit hooks 2025-09-04 04:28:02 +02:00
77d7881a57 chore: Update/upgrade dependencies
There was a potential vulnerability in tracing-subscriber, so I took
this as an opportunity to update/upgrade all dependencies
2025-09-04 04:28:02 +02:00
f3b1854beb fix: Crash if hallway automation is called before door/trash have been initialized
Resolves: #4
2025-09-04 04:27:49 +02:00
8109dcf2f5 feat: Added low battery notification and made mqtt message parsing more robust
Resolves: #1
2025-09-04 04:26:34 +02:00
1b8566e593 refactor: Switch to async closures 2025-09-04 04:15:08 +02:00
e21ea0f34e Implement custom lua print function that calls info
All checks were successful
Build and deploy / build (push) Successful in 11m54s
Build and deploy / Deploy container (push) Successful in 45s
2025-09-01 02:47:47 +02:00
fb7e1f1472 Small cleanup 2025-09-01 02:47:29 +02:00
45de83ef2f Removed old presence system 2025-08-31 23:57:59 +02:00
2a1f75f158 Move front door presence logic to lua 2025-08-31 23:57:59 +02:00
74568b4e1f Handle turning off devices when away through lua 2025-08-31 23:57:59 +02:00
fefccf03d7 Removed DebugBridge as it no longer served a purpose 2025-08-31 23:57:59 +02:00
b56a16d0d7 Moved presence debug mqtt message to lua 2025-08-31 23:57:59 +02:00
1530875045 Presence and light sensor call all function in array 2025-08-31 23:57:58 +02:00
9616017c8f Print lua version on startup 2025-08-31 23:57:56 +02:00
6db5831571 Removed old darkness system 2025-08-31 23:56:28 +02:00
aa730c9738 Moved darkness debug mqtt message to lua 2025-08-31 05:41:49 +02:00
c362952f7c Feature: Get current ms since unix epoch in lua 2025-08-31 05:41:49 +02:00
dd379e4077 Feature: Send mqtt messages from lua 2025-08-31 05:41:48 +02:00
549d821e3a Moved hue bridge on darkness to lua 2025-08-31 05:41:46 +02:00
4980f4888e Removed unused event code 2025-08-31 05:01:56 +02:00
eb36d41f17 Move ntfy and presence to automation_devices 2025-08-31 04:57:31 +02:00
03dcd44e0e Removed old notification system
All checks were successful
Build and deploy / build (push) Successful in 8m50s
Build and deploy / Deploy container (push) Successful in 39s
2025-08-31 03:55:08 +02:00
6c9d2c16c1 Converted presence notification into lua callback 2025-08-31 03:55:08 +02:00
2d9e3d26f2 Send laundy notification from lua 2025-08-31 03:55:08 +02:00
64c7d950c5 Make it possible to send notifications from lua 2025-08-31 03:55:07 +02:00
5d342afb1f Converted macro to derive macro 2025-08-31 03:54:20 +02:00
d2b01123b8 Made the impl_device macro more explicit about the implemented traits
This also converts impl_device into a procedural macro and get rid of a
lot of "magic" that was happening.
2025-08-31 00:38:58 +02:00
c5262dcf35 Update to rust 1.89 and edition 2024 2025-08-31 00:38:58 +02:00
01e88eeb3b Use new and improved rust workflow and Dockerfile 2025-08-31 00:38:58 +02:00
d6ab38f690 Improve pre-commit hooks 2025-08-31 00:38:58 +02:00
9f3b927cb6 Update dependencies and remove unused dependencies 2025-08-31 00:38:56 +02:00
7f41132965 Switch workbench light to new color temperature light
All checks were successful
Build and deploy / Build application (push) Successful in 5m55s
Build and deploy / Build container (push) Successful in 2m16s
Build and deploy / Deploy container (push) Successful in 34s
2025-08-22 23:27:05 +02:00
3c5bd9ffb8 Add color temperature light 2025-08-22 23:27:05 +02:00
73218bb9b9 Store brightness in f32 instead of f64 2025-08-22 23:27:05 +02:00
fe83568839 Added color temperature support with ColorSetting 2025-08-22 23:27:05 +02:00
e27412339c Allow timeout to be a fraction of a second instead of always whole seconds 2025-08-22 23:27:05 +02:00
8f858e9b42 Removed cargo config that is no longer necessary 2025-08-22 23:27:01 +02:00
5730d9db03 Fixed struct name for temperature control 2025-08-22 02:15:26 +02:00
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
127 changed files with 8586 additions and 4625 deletions

View File

@@ -1,3 +1,2 @@
[build] [env]
target = "x86_64-unknown-linux-gnu" RUST_LOG = "automation=debug"
rustflags = ["--cfg", "tokio_unstable"]

View File

@@ -1,2 +1,4 @@
/target /target
.env .env
# Use the rust environment provided by the container
rust-toolchain.toml

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

View File

@@ -1,79 +1,24 @@
# Based on: https://pastebin.com/99Fq2b2w name: Build and deploy
name: Build and deploy automation_rs
on: on:
push: push:
branches: branches:
- master - master
- feature/** - feature/**
tags:
- v*.*.*
jobs: jobs:
build: build:
name: Build automation_rs uses: dreaded_x/workflows/.gitea/workflows/rust-kubernetes.yaml@22ee0c1788a8d2157db87d6a6f8dbe520fe48592
runs-on: ubuntu-latest secrets: inherit
container: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with: with:
rustflags: "" upload_manifests: false
- name: Formatting
uses: actions-rust-lang/rustfmt@v1
- name: Clippy
run: cargo clippy --all-targets --all -- -D warnings
- name: Build
run: cargo build --release
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: build
path: target/x86_64-unknown-linux-gnu/release/automation
container:
name: Build Docker image
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: build
- name: Set permissions
run: |
chown 65532:65532 ./build/*
chmod 0755 ./build/*
- 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: ${{ gitea.ref == 'refs/heads/master' }}
# TODO: Automatically add the correct tags here
tags: git.huizinga.dev/dreaded_x/automation_rs:latest
deploy: deploy:
name: Deploy Docker container name: Deploy container
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-latest container: catthehacker/ubuntu:act-latest
needs: [container] needs: build
if: gitea.ref == 'refs/heads/master' if: gitea.ref == 'refs/heads/master'
steps: steps:
- name: Stop and remove current container - name: Stop and remove current container
@@ -89,14 +34,12 @@ jobs:
--name automation_rs \ --name automation_rs \
--network mqtt \ --network mqtt \
-e RUST_LOG=automation=debug \ -e RUST_LOG=automation=debug \
-e MQTT_PASSWORD=${{ secrets.MQTT_PASSWORD }} \ -e AUTOMATION__SECRETS__MQTT_PASSWORD=${{ secrets.MQTT_PASSWORD }} \
-e HUE_TOKEN=${{ secrets.HUE_TOKEN }} \ -e AUTOMATION__SECRETS__HUE_TOKEN=${{ secrets.HUE_TOKEN }} \
-e NTFY_TOPIC=${{ secrets.NTFY_TOPIC }} \ -e AUTOMATION__SECRETS__NTFY_TOPIC=${{ secrets.NTFY_TOPIC }} \
git.huizinga.dev/dreaded_x/automation_rs:latest git.huizinga.dev/dreaded_x/automation_rs@${{ needs.build.outputs.digest }}
docker network connect web automation_rs docker network connect web automation_rs
- name: Start container - name: Start container
run: docker start automation_rs run: docker start automation_rs
# TODO: Perform a healthcheck

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/target /target
.env .env
automation.toml

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

@@ -0,0 +1,101 @@
default_install_hook_types:
- pre-commit
- commit-msg
default_stages:
- pre-commit
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
args:
- --allow-multiple-documents
- id: check-toml
- id: check-added-large-files
- id: check-merge-conflict
- repo: https://github.com/compilerla/conventional-pre-commit
rev: v4.2.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
args: [--verbose]
- repo: https://github.com/JohnnyMorganz/StyLua
rev: v2.1.0
hooks:
- id: stylua
- repo: https://github.com/crate-ci/typos
rev: v1.36.1
hooks:
- id: typos
args: ["--force-exclude"]
- repo: local
hooks:
- id: fmt
name: fmt
description: Format files with cargo fmt.
entry: cargo +nightly fmt
language: system
types: [rust]
args: ["--", "--check"]
# For some reason some formatting is different depending on how you invoke?
pass_filenames: false
- id: clippy
name: clippy
description: Lint rust sources
entry: cargo clippy
language: system
args: ["--", "-D", "warnings"]
types: [file]
files: (\.rs|Cargo.lock)$
pass_filenames: false
- id: generate_definitions
name: generate definitions
description: Generate lua definitions
entry: cargo run --bin generate_definitions
language: system
types: [rust]
pass_filenames: false
- id: test
name: test
description: Rust test
entry: cargo test
language: system
args: ["--workspace"]
types: [file]
files: (\.rs|Cargo.lock)$
pass_filenames: false
- id: udeps
name: unused
description: Check for unused crates
entry: cargo udeps
args: ["--workspace"]
language: system
types: [file]
files: (\.rs|Cargo.lock)$
pass_filenames: false
- id: audit
name: audit
description: Audit packages
entry: cargo audit
args: ["--deny", "warnings"]
language: system
pass_filenames: false
verbose: true
always_run: true
- repo: https://github.com/hadolint/hadolint
rev: v2.13.1
hooks:
- id: hadolint

View File

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

2459
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,49 +1,43 @@
[package] [package]
name = "automation" name = "automation"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
default-run = "automation"
[workspace] [workspace]
members = ["google-home", "automation_macro", "automation_cast"] members = [
"automation_cast",
"automation_devices",
"automation_lib",
"automation_macro",
"google_home/google_home",
"google_home/google_home_macro",
]
[workspace.dependencies]
[dependencies] air_filter_types = { git = "https://git.huizinga.dev/Dreaded_X/airfilter", tag = "v0.4.4" }
anyhow = "1.0.99"
async-trait = "0.1.89"
automation_cast = { path = "./automation_cast" }
automation_devices = { path = "./automation_devices" }
automation_lib = { path = "./automation_lib" }
automation_macro = { path = "./automation_macro" } automation_macro = { path = "./automation_macro" }
automation_cast = { path = "./automation_cast/" } axum = "0.8.4"
rumqttc = "0.18" bytes = "1.10.1"
serde = { version = "1.0.149", features = ["derive"] } dyn-clone = "1.0.20"
serde_json = "1.0.89" eui48 = { version = "1.1.0", features = [
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", "disp_hexstring",
"serde", "serde",
] } ], default-features = false }
thiserror = "1.0.38" futures = "0.3.31"
anyhow = "1.0.68" google_home = { path = "./google_home/google_home" }
wakey = "0.3.0" google_home_macro = { path = "./google_home/google_home_macro" }
console-subscriber = "0.1.8" hostname = "0.4.1"
tracing-subscriber = "0.3.16" inventory = "0.3.21"
serde_with = "3.2.0" itertools = "0.14.0"
enum_dispatch = "0.3.12" json_value_merge = "2.0.1"
indexmap = { version = "2.0.0", features = ["serde"] } lua_typed = { git = "https://git.huizinga.dev/Dreaded_X/lua_typed" }
serde_yaml = "0.9.27" mlua = { version = "0.11.3", features = [
tokio-cron-scheduler = "0.9.4"
mlua = { version = "0.9.7", features = [
"lua54", "lua54",
"vendored", "vendored",
"macros", "macros",
@@ -51,11 +45,55 @@ mlua = { version = "0.9.7", features = [
"async", "async",
"send", "send",
] } ] }
once_cell = "1.19.0" proc-macro2 = "1.0.101"
hostname = "0.4.0" quote = "1.0.40"
reqwest = { version = "0.12.23", features = [
"json",
"rustls-tls",
], default-features = false } # Use rustls, since the other packages also use rustls
rumqttc = "0.24.0"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.143"
serde_repr = "0.1.20"
syn = { version = "2.0.106" }
thiserror = "2.0.16"
tokio = { version = "1", features = ["rt-multi-thread"] }
tokio-cron-scheduler = "0.15.0"
tracing = "0.1.41"
tracing-subscriber = "0.3.20"
wakey = "0.3.0"
[dependencies]
anyhow = { workspace = true }
async-trait = { workspace = true }
automation_devices = { workspace = true }
automation_lib = { workspace = true }
automation_macro = { path = "./automation_macro" }
axum = { workspace = true }
config = { version = "0.15.15", default-features = false, features = [
"async",
"toml",
] }
git-version = "0.3.9"
google_home = { workspace = true }
lua_typed = { workspace = true }
inventory = { workspace = true }
mlua = { workspace = true }
reqwest = { workspace = true }
rumqttc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio-cron-scheduler = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[patch.crates-io] [patch.crates-io]
wakey = { git = "https://git.huizinga.dev/Dreaded_X/wakey" } wakey = { git = "https://git.huizinga.dev/Dreaded_X/wakey" }
[profile.release] [profile.release]
lto = true lto = true
[package.metadata.typos.default.extend-words]
mosquitto = "mosquitto"

View File

@@ -1,8 +1,25 @@
FROM gcr.io/distroless/cc-debian12:nonroot FROM rust:1.89 AS base
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse
RUN cargo install cargo-chef --locked --version 0.1.71 && \
cargo install cargo-auditable --locked --version 0.6.6
WORKDIR /app
ENV AUTOMATION_CONFIG=/app/config.lua FROM base AS planner
COPY ./config.lua /app/config.lua COPY . .
RUN cargo chef prepare --recipe-path recipe.json
COPY ./build/automation /app/automation FROM base AS builder
# HACK: Now we can use unstable feature while on stable rust!
ENV RUSTC_BOOTSTRAP=1
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
ARG RELEASE_VERSION
ENV RELEASE_VERSION=${RELEASE_VERSION}
RUN cargo auditable build --release
FROM gcr.io/distroless/cc-debian12:nonroot AS runtime
COPY --from=builder /app/target/release/automation /app/automation
COPY ./config /app/config
CMD [ "/app/automation" ] CMD [ "/app/automation" ]

View File

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

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

BIN
assets/logo.xcf LFS Normal file

Binary file not shown.

View File

@@ -1,8 +1,6 @@
[package] [package]
name = "automation_cast" name = "automation_cast"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]

View File

@@ -6,7 +6,6 @@ use std::marker::Unsize;
pub trait Cast<P: ?Sized> { pub trait Cast<P: ?Sized> {
fn cast(&self) -> Option<&P>; fn cast(&self) -> Option<&P>;
fn cast_mut(&mut self) -> Option<&mut P>;
} }
impl<D, P> Cast<P> for D impl<D, P> Cast<P> for D
@@ -16,10 +15,6 @@ where
default fn cast(&self) -> Option<&P> { default fn cast(&self) -> Option<&P> {
None None
} }
default fn cast_mut(&mut self) -> Option<&mut P> {
None
}
} }
impl<D, P> Cast<P> for D impl<D, P> Cast<P> for D
@@ -30,8 +25,4 @@ where
fn cast(&self) -> Option<&P> { fn cast(&self) -> Option<&P> {
Some(self) Some(self)
} }
fn cast_mut(&mut self) -> Option<&mut P> {
Some(self)
}
} }

View File

@@ -0,0 +1,27 @@
[package]
name = "automation_devices"
version = "0.1.0"
edition = "2024"
[dependencies]
air_filter_types = { workspace = true }
anyhow = { workspace = true }
async-trait = { workspace = true }
automation_lib = { workspace = true }
automation_macro = { workspace = true }
bytes = { workspace = true }
dyn-clone = { workspace = true }
eui48 = { workspace = true }
google_home = { workspace = true }
inventory = { workspace = true }
lua_typed = { workspace = true }
mlua = { workspace = true }
reqwest = { workspace = true }
rumqttc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_repr = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
wakey = { workspace = true }

View File

@@ -0,0 +1,232 @@
use async_trait::async_trait;
use automation_lib::config::InfoConfig;
use automation_lib::device::{Device, LuaDeviceCreate};
use automation_macro::{Device, LuaDeviceConfig};
use google_home::device::Name;
use google_home::errors::ErrorCode;
use google_home::traits::{
AvailableSpeeds, FanSpeed, HumiditySetting, OnOff, Speed, SpeedValue, TemperatureControl,
TemperatureUnit,
};
use google_home::types::Type;
use lua_typed::Typed;
use thiserror::Error;
use tracing::{debug, trace};
#[derive(Debug, Clone, LuaDeviceConfig, Typed)]
#[typed(as = "AirFilterConfig")]
pub struct Config {
#[device_config(flatten)]
#[typed(flatten)]
pub info: InfoConfig,
pub url: String,
}
crate::register_type!(Config);
#[derive(Debug, Clone, Device)]
#[device(traits(OnOff))]
pub struct AirFilter {
config: Config,
}
crate::register_device!(AirFilter);
#[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 TemperatureControl 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,192 @@
use std::sync::Arc;
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;
use automation_lib::messages::ContactMessage;
use automation_lib::mqtt::WrappedAsyncClient;
use automation_macro::{Device, LuaDeviceConfig};
use google_home::device;
use google_home::errors::{DeviceError, ErrorCode};
use google_home::traits::OpenClose;
use google_home::types::Type;
use lua_typed::Typed;
use serde::Deserialize;
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use tracing::{debug, error, trace};
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Copy, Typed)]
pub enum SensorType {
Door,
Drawer,
Window,
}
crate::register_type!(SensorType);
#[derive(Debug, Clone, LuaDeviceConfig, Typed)]
#[typed(as = "ContactSensorConfig")]
pub struct Config {
#[device_config(flatten)]
#[typed(flatten)]
pub info: InfoConfig,
#[device_config(flatten)]
#[typed(flatten)]
pub mqtt: MqttDeviceConfig,
#[device_config(default(SensorType::Window))]
#[typed(default)]
pub sensor_type: SensorType,
#[device_config(from_lua, default)]
#[typed(default)]
pub callback: ActionCallback<(ContactSensor, bool)>,
#[device_config(from_lua, default)]
#[typed(default)]
pub battery_callback: ActionCallback<(ContactSensor, f32)>,
#[device_config(from_lua)]
#[typed(default)]
pub client: WrappedAsyncClient,
}
crate::register_type!(Config);
#[derive(Debug)]
struct State {
is_closed: bool,
}
#[derive(Debug, Clone, Device)]
#[device(traits(OpenClose))]
pub struct ContactSensor {
config: Config,
state: Arc<RwLock<State>>,
}
crate::register_device!(ContactSensor);
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 { is_closed: true };
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 OnMqtt for ContactSensor {
async fn on_mqtt(&self, message: rumqttc::Publish) {
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
return;
}
let message = match ContactMessage::try_from(message) {
Ok(message) => message,
Err(err) => {
error!(id = self.get_id(), "Failed to parse message: {err}");
return;
}
};
if let Some(is_closed) = message.contact {
if is_closed == self.state().await.is_closed {
return;
}
self.config.callback.call((self.clone(), !is_closed)).await;
debug!(id = self.get_id(), "Updating state to {is_closed}");
self.state_mut().await.is_closed = is_closed;
}
if let Some(battery) = message.battery {
self.config
.battery_callback
.call((self.clone(), battery))
.await;
}
}
}

View File

@@ -2,38 +2,71 @@ use std::convert::Infallible;
use std::net::SocketAddr; use std::net::SocketAddr;
use async_trait::async_trait; use async_trait::async_trait;
use automation_macro::{LuaDevice, LuaDeviceConfig}; use automation_lib::device::{Device, LuaDeviceCreate};
use automation_lib::lua::traits::PartialUserData;
use automation_macro::{Device, LuaDeviceConfig};
use lua_typed::Typed;
use mlua::LuaSerdeExt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{error, trace, warn}; use tracing::{error, trace, warn};
use super::LuaDeviceCreate; #[derive(Debug, Deserialize, Typed)]
use crate::devices::Device; #[serde(rename_all = "snake_case")]
use crate::event::{OnDarkness, OnPresence}; #[typed(rename_all = "snake_case")]
#[derive(Debug)]
pub enum Flag { pub enum Flag {
Presence, Presence,
Darkness, Darkness,
} }
crate::register_type!(Flag);
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize, Typed)]
pub struct FlagIDs { pub struct FlagIDs {
presence: isize, presence: isize,
darkness: isize, darkness: isize,
} }
crate::register_type!(FlagIDs);
#[derive(Debug, LuaDeviceConfig, Clone)] #[derive(Debug, LuaDeviceConfig, Clone, Typed)]
pub struct HueBridgeConfig { #[typed(as = "HueBridgeConfig")]
pub struct Config {
pub identifier: String, pub identifier: String,
#[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 80)))] #[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 80)))]
#[typed(as = "ip")]
pub addr: SocketAddr, pub addr: SocketAddr,
pub login: String, pub login: String,
pub flags: FlagIDs, pub flags: FlagIDs,
} }
crate::register_type!(Config);
#[derive(Debug, LuaDevice)] #[derive(Debug, Clone, Device)]
#[device(extra_user_data = SetFlag)]
pub struct HueBridge { pub struct HueBridge {
config: HueBridgeConfig, config: Config,
}
crate::register_device!(HueBridge);
struct SetFlag;
impl PartialUserData<HueBridge> for SetFlag {
fn add_methods<M: mlua::UserDataMethods<HueBridge>>(methods: &mut M) {
methods.add_async_method(
"set_flag",
async |lua, this, (flag, value): (mlua::Value, bool)| {
let flag: Flag = lua.from_value(flag)?;
this.set_flag(flag, value).await;
Ok(())
},
);
}
fn definitions() -> Option<String> {
Some(format!(
"---@async\n---@param flag {}\n---@param value boolean\nfunction {}:set_flag(flag, value) end\n",
<Flag as Typed>::type_name(),
<HueBridge as Typed>::type_name(),
))
}
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -43,7 +76,7 @@ struct FlagMessage {
#[async_trait] #[async_trait]
impl LuaDeviceCreate for HueBridge { impl LuaDeviceCreate for HueBridge {
type Config = HueBridgeConfig; type Config = Config;
type Error = Infallible; type Error = Infallible;
async fn create(config: Self::Config) -> Result<Self, Infallible> { async fn create(config: Self::Config) -> Result<Self, Infallible> {
@@ -90,19 +123,3 @@ impl Device for HueBridge {
self.config.identifier.clone() self.config.identifier.clone()
} }
} }
#[async_trait]
impl OnPresence for HueBridge {
async fn on_presence(&mut self, presence: bool) {
trace!("Bridging presence to hue");
self.set_flag(Flag::Presence, presence).await;
}
}
#[async_trait]
impl OnDarkness for HueBridge {
async fn on_darkness(&mut self, dark: bool) {
trace!("Bridging darkness to hue");
self.set_flag(Flag::Darkness, dark).await;
}
}

View File

@@ -0,0 +1,168 @@
use std::net::SocketAddr;
use anyhow::Result;
use async_trait::async_trait;
use automation_macro::{Device, LuaDeviceConfig};
use google_home::errors::ErrorCode;
use google_home::traits::OnOff;
use lua_typed::Typed;
use tracing::{error, trace, warn};
use super::{Device, LuaDeviceCreate};
#[derive(Debug, Clone, LuaDeviceConfig, Typed)]
#[typed(as = "HueGroupConfig")]
pub struct Config {
pub identifier: String,
#[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 80)))]
#[typed(as = "ip")]
pub addr: SocketAddr,
pub login: String,
pub group_id: isize,
pub scene_id: String,
}
crate::register_type!(Config);
#[derive(Debug, Clone, Device)]
#[device(traits(OnOff))]
pub struct HueGroup {
config: Config,
}
crate::register_device!(HueGroup);
// 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,147 @@
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::{Device, LuaDeviceConfig};
use lua_typed::Typed;
use rumqttc::{Publish, matches};
use serde::Deserialize;
use tracing::{debug, trace, warn};
#[derive(Debug, Clone, LuaDeviceConfig, Typed)]
#[typed(as = "HueSwitchConfig")]
pub struct Config {
#[device_config(flatten)]
#[typed(flatten)]
pub info: InfoConfig,
#[device_config(flatten)]
#[typed(flatten)]
pub mqtt: MqttDeviceConfig,
#[device_config(from_lua)]
pub client: WrappedAsyncClient,
#[device_config(from_lua, default)]
#[typed(default)]
pub left_callback: ActionCallback<HueSwitch>,
#[device_config(from_lua, default)]
#[typed(default)]
pub right_callback: ActionCallback<HueSwitch>,
#[device_config(from_lua, default)]
#[typed(default)]
pub left_hold_callback: ActionCallback<HueSwitch>,
#[device_config(from_lua, default)]
#[typed(default)]
pub right_hold_callback: ActionCallback<HueSwitch>,
#[device_config(from_lua, default)]
#[typed(default)]
pub battery_callback: ActionCallback<(HueSwitch, f32)>,
}
crate::register_type!(Config);
#[derive(Debug, Copy, Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Action {
LeftPress,
LeftPressRelease,
LeftHold,
LeftHoldRelease,
RightPress,
RightPressRelease,
RightHold,
RightHoldRelease,
}
#[derive(Debug, Clone, Deserialize)]
struct State {
action: Option<Action>,
battery: Option<f32>,
}
#[derive(Debug, Clone, Device)]
pub struct HueSwitch {
config: Config,
}
crate::register_device!(HueSwitch);
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 message = match serde_json::from_slice::<State>(&message.payload) {
Ok(message) => message,
Err(err) => {
warn!(id = Device::get_id(self), "Failed to parse message: {err}");
return;
}
};
if let Some(action) = message.action {
debug!(
id = Device::get_id(self),
?message.action,
"Action received",
);
match action {
Action::LeftPressRelease => self.config.left_callback.call(self.clone()).await,
Action::RightPressRelease => {
self.config.right_callback.call(self.clone()).await
}
Action::LeftHold => self.config.left_hold_callback.call(self.clone()).await,
Action::RightHold => self.config.right_hold_callback.call(self.clone()).await,
// If there is no hold action, the switch will act like a normal release
Action::RightHoldRelease => {
if self.config.right_hold_callback.is_empty() {
self.config.right_callback.call(self.clone()).await
}
}
Action::LeftHoldRelease => {
if self.config.left_hold_callback.is_empty() {
self.config.left_callback.call(self.clone()).await
}
}
_ => {}
}
}
if let Some(battery) = message.battery {
self.config
.battery_callback
.call((self.clone(), battery))
.await;
}
}
}
}

View File

@@ -0,0 +1,112 @@
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::messages::{RemoteAction, RemoteMessage};
use automation_lib::mqtt::WrappedAsyncClient;
use automation_macro::{Device, LuaDeviceConfig};
use lua_typed::Typed;
use rumqttc::{Publish, matches};
use tracing::{debug, error, trace};
#[derive(Debug, Clone, LuaDeviceConfig, Typed)]
#[typed(as = "IkeaRemoteConfig")]
pub struct Config {
#[device_config(flatten)]
#[typed(flatten)]
pub info: InfoConfig,
#[device_config(default)]
#[typed(default)]
pub single_button: bool,
#[device_config(flatten)]
#[typed(flatten)]
pub mqtt: MqttDeviceConfig,
#[device_config(from_lua)]
pub client: WrappedAsyncClient,
#[device_config(from_lua, default)]
#[typed(default)]
pub callback: ActionCallback<(IkeaRemote, bool)>,
#[device_config(from_lua, default)]
#[typed(default)]
pub battery_callback: ActionCallback<(IkeaRemote, f32)>,
}
crate::register_type!(Config);
#[derive(Debug, Clone, Device)]
pub struct IkeaRemote {
config: Config,
}
crate::register_device!(IkeaRemote);
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 message = match RemoteMessage::try_from(message) {
Ok(message) => message,
Err(err) => {
error!(id = Device::get_id(self), "Failed to parse message: {err}");
return;
}
};
if let Some(action) = message.action {
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.clone(), on)).await;
}
}
if let Some(battery) = message.battery {
self.config
.battery_callback
.call((self.clone(), battery))
.await;
}
}
}
}

View File

@@ -3,33 +3,38 @@ use std::net::SocketAddr;
use std::str::Utf8Error; use std::str::Utf8Error;
use async_trait::async_trait; use async_trait::async_trait;
use automation_macro::{LuaDevice, LuaDeviceConfig}; use automation_lib::device::{Device, LuaDeviceCreate};
use automation_macro::{Device, LuaDeviceConfig};
use bytes::{Buf, BufMut}; use bytes::{Buf, BufMut};
use google_home::errors::{self, DeviceError}; use google_home::errors::{self, DeviceError};
use google_home::traits; use google_home::traits::OnOff;
use lua_typed::Typed;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tracing::trace; use tracing::trace;
use super::{Device, LuaDeviceCreate}; #[derive(Debug, Clone, LuaDeviceConfig, Typed)]
#[typed(as = "KasaOutletConfig")]
#[derive(Debug, Clone, LuaDeviceConfig)] pub struct Config {
pub struct KasaOutletConfig {
pub identifier: String, pub identifier: String,
#[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 9999)))] #[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 9999)))]
#[typed(as = "ip")]
pub addr: SocketAddr, pub addr: SocketAddr,
} }
crate::register_type!(Config);
#[derive(Debug, LuaDevice)] #[derive(Debug, Clone, Device)]
#[device(traits(OnOff))]
pub struct KasaOutlet { pub struct KasaOutlet {
config: KasaOutletConfig, config: Config,
} }
crate::register_device!(KasaOutlet);
#[async_trait] #[async_trait]
impl LuaDeviceCreate for KasaOutlet { impl LuaDeviceCreate for KasaOutlet {
type Config = KasaOutletConfig; type Config = Config;
type Error = Infallible; type Error = Infallible;
async fn create(config: Self::Config) -> Result<Self, Self::Error> { async fn create(config: Self::Config) -> Result<Self, Self::Error> {
@@ -206,8 +211,8 @@ impl Response {
} }
#[async_trait] #[async_trait]
impl traits::OnOff for KasaOutlet { impl OnOff for KasaOutlet {
async fn is_on(&self) -> Result<bool, errors::ErrorCode> { async fn on(&self) -> Result<bool, errors::ErrorCode> {
let mut stream = TcpStream::connect(self.config.addr) let mut stream = TcpStream::connect(self.config.addr)
.await .await
.or::<DeviceError>(Err(DeviceError::DeviceOffline))?; .or::<DeviceError>(Err(DeviceError::DeviceOffline))?;
@@ -241,7 +246,7 @@ impl traits::OnOff for KasaOutlet {
.or(Err(DeviceError::TransientError.into())) .or(Err(DeviceError::TransientError.into()))
} }
async fn set_on(&mut self, on: bool) -> Result<(), errors::ErrorCode> { async fn set_on(&self, on: bool) -> Result<(), errors::ErrorCode> {
let mut stream = TcpStream::connect(self.config.addr) let mut stream = TcpStream::connect(self.config.addr)
.await .await
.or::<DeviceError>(Err(DeviceError::DeviceOffline))?; .or::<DeviceError>(Err(DeviceError::DeviceOffline))?;

View File

@@ -0,0 +1,130 @@
#![feature(iter_intersperse)]
mod air_filter;
mod contact_sensor;
mod hue_bridge;
mod hue_group;
mod hue_switch;
mod ikea_remote;
mod kasa_outlet;
mod light_sensor;
mod ntfy;
mod presence;
mod wake_on_lan;
mod washer;
mod zigbee;
use automation_lib::Module;
use automation_lib::device::{Device, LuaDeviceCreate};
use tracing::{debug, warn};
type DeviceNameFn = fn() -> String;
type RegisterDeviceFn = fn(lua: &mlua::Lua) -> mlua::Result<mlua::AnyUserData>;
pub struct RegisteredDevice {
name_fn: DeviceNameFn,
register_fn: RegisterDeviceFn,
}
impl RegisteredDevice {
pub const fn new(name_fn: DeviceNameFn, register_fn: RegisterDeviceFn) -> Self {
Self {
name_fn,
register_fn,
}
}
pub fn get_name(&self) -> String {
(self.name_fn)()
}
pub fn register(&self, lua: &mlua::Lua) -> mlua::Result<mlua::AnyUserData> {
(self.register_fn)(lua)
}
}
macro_rules! register_device {
($device:ty) => {
::inventory::submit!(crate::RegisteredDevice::new(
<$device as ::lua_typed::Typed>::type_name,
::mlua::Lua::create_proxy::<$device>
));
crate::register_type!($device);
};
}
pub(crate) use register_device;
inventory::collect!(RegisteredDevice);
pub fn create_module(lua: &mlua::Lua) -> mlua::Result<mlua::Table> {
let devices = lua.create_table()?;
debug!("Loading devices...");
for device in inventory::iter::<RegisteredDevice> {
let name = device.get_name();
debug!(name, "Registering device");
let proxy = device.register(lua)?;
devices.set(name, proxy)?;
}
Ok(devices)
}
type TypeNameFn = fn() -> String;
type TypeDefinitionFn = fn() -> Option<String>;
pub struct RegisteredType {
name_fn: TypeNameFn,
definition_fn: TypeDefinitionFn,
}
impl RegisteredType {
pub const fn new(name_fn: TypeNameFn, definition_fn: TypeDefinitionFn) -> Self {
Self {
name_fn,
definition_fn,
}
}
pub fn get_name(&self) -> String {
(self.name_fn)()
}
pub fn register(&self) -> Option<String> {
(self.definition_fn)()
}
}
macro_rules! register_type {
($ty:ty) => {
::inventory::submit!(crate::RegisteredType::new(
<$ty as ::lua_typed::Typed>::type_name,
<$ty as ::lua_typed::Typed>::generate_full
));
};
}
pub(crate) use register_type;
inventory::collect!(RegisteredType);
fn generate_definitions() -> String {
let mut output = String::new();
let mut types: Vec<_> = inventory::iter::<RegisteredType>.into_iter().collect();
types.sort_by_key(|ty| ty.get_name());
output += "---@meta\n\nlocal devices\n\n";
for ty in types {
if let Some(def) = (ty.definition_fn)() {
output += &(def + "\n");
} else {
// NOTE: Due to how this works the typed is erased, so we don't know the cause
warn!("Registered type is missing generate_full function");
}
}
output += "return devices";
output
}
inventory::submit! {Module::new("automation:devices", create_module, Some(generate_definitions))}

View File

@@ -0,0 +1,127 @@
use std::sync::Arc;
use async_trait::async_trait;
use automation_lib::action_callback::ActionCallback;
use automation_lib::config::MqttDeviceConfig;
use automation_lib::device::{Device, LuaDeviceCreate};
use automation_lib::event::OnMqtt;
use automation_lib::messages::BrightnessMessage;
use automation_lib::mqtt::WrappedAsyncClient;
use automation_macro::{Device, LuaDeviceConfig};
use lua_typed::Typed;
use rumqttc::Publish;
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use tracing::{debug, trace, warn};
#[derive(Debug, Clone, LuaDeviceConfig, Typed)]
#[typed(as = "LightSensorConfig")]
pub struct Config {
pub identifier: String,
#[device_config(flatten)]
#[typed(flatten)]
pub mqtt: MqttDeviceConfig,
pub min: isize,
pub max: isize,
#[device_config(from_lua, default)]
#[typed(default)]
pub callback: ActionCallback<(LightSensor, bool)>,
#[device_config(from_lua)]
pub client: WrappedAsyncClient,
}
crate::register_type!(Config);
const DEFAULT: bool = false;
#[derive(Debug)]
pub struct State {
is_dark: bool,
}
#[derive(Debug, Clone, Device)]
pub struct LightSensor {
config: Config,
state: Arc<RwLock<State>>,
}
crate::register_device!(LightSensor);
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;
}
};
// TODO: Move this logic to lua at some point
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;
self.config
.callback
.call((self.clone(), !self.state().await.is_dark))
.await;
}
}
}

View File

@@ -0,0 +1,161 @@
use std::collections::HashMap;
use std::convert::Infallible;
use async_trait::async_trait;
use automation_lib::device::{Device, LuaDeviceCreate};
use automation_lib::lua::traits::PartialUserData;
use automation_macro::{Device, LuaDeviceConfig};
use lua_typed::Typed;
use mlua::LuaSerdeExt;
use serde::{Deserialize, Serialize};
use serde_repr::*;
use tracing::{error, trace, warn};
#[derive(Debug, Serialize_repr, Deserialize, Clone, Copy, Typed)]
#[repr(u8)]
#[serde(rename_all = "snake_case")]
#[typed(rename_all = "snake_case")]
pub enum Priority {
Min = 1,
Low,
Default,
High,
Max,
}
crate::register_type!(Priority);
#[derive(Debug, Serialize, Deserialize, Clone, Typed)]
#[serde(rename_all = "snake_case", tag = "action")]
#[typed(rename_all = "snake_case", tag = "action")]
pub enum ActionType {
Broadcast {
#[serde(skip_serializing_if = "HashMap::is_empty")]
#[serde(default)]
#[typed(default)]
extras: HashMap<String, String>,
},
// View,
// Http
}
#[derive(Debug, Serialize, Deserialize, Clone, Typed)]
pub struct Action {
#[serde(flatten)]
#[typed(flatten)]
pub action: ActionType,
pub label: String,
pub clear: Option<bool>,
}
crate::register_type!(Action);
#[derive(Serialize, Deserialize, Typed)]
struct NotificationFinal {
topic: String,
#[serde(flatten)]
#[typed(flatten)]
inner: Notification,
}
#[derive(Debug, Serialize, Clone, Deserialize, Typed)]
pub struct Notification {
title: String,
message: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default = "Default::default")]
#[typed(default)]
tags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
priority: Option<Priority>,
#[serde(skip_serializing_if = "Vec::is_empty", default = "Default::default")]
#[typed(default)]
actions: Vec<Action>,
}
crate::register_type!(Notification);
impl Notification {
fn finalize(self, topic: &str) -> NotificationFinal {
NotificationFinal {
topic: topic.into(),
inner: self,
}
}
}
#[derive(Debug, Clone, LuaDeviceConfig, Typed)]
#[typed(as = "NtfyConfig")]
pub struct Config {
#[device_config(default("https://ntfy.sh".into()))]
#[typed(default)]
pub url: String,
pub topic: String,
}
crate::register_type!(Config);
#[derive(Debug, Clone, Device)]
#[device(extra_user_data = SendNotification)]
pub struct Ntfy {
config: Config,
}
crate::register_device!(Ntfy);
struct SendNotification;
impl PartialUserData<Ntfy> for SendNotification {
fn add_methods<M: mlua::UserDataMethods<Ntfy>>(methods: &mut M) {
methods.add_async_method(
"send_notification",
async |lua, this, notification: mlua::Value| {
let notification: Notification = lua.from_value(notification)?;
this.send(notification).await;
Ok(())
},
);
}
fn definitions() -> Option<String> {
Some(format!(
"---@async\n---@param notification {}\nfunction {}:send_notification(notification) end\n",
<Notification as Typed>::type_name(),
<Ntfy as Typed>::type_name(),
))
}
}
#[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.config.topic);
// Create the request
let res = reqwest::Client::new()
.post(self.config.url.clone())
.json(&notification)
.send()
.await;
if let Err(err) = res {
error!("Something went wrong while sending the notification: {err}");
} else if let Ok(res) = res {
let status = res.status();
if !status.is_success() {
warn!("Received status {status} when sending notification");
}
}
}
}

View File

@@ -0,0 +1,149 @@
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use automation_lib::action_callback::ActionCallback;
use automation_lib::config::MqttDeviceConfig;
use automation_lib::device::{Device, LuaDeviceCreate};
use automation_lib::event::OnMqtt;
use automation_lib::lua::traits::PartialUserData;
use automation_lib::messages::PresenceMessage;
use automation_lib::mqtt::WrappedAsyncClient;
use automation_macro::{Device, LuaDeviceConfig};
use lua_typed::Typed;
use rumqttc::Publish;
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use tracing::{debug, trace, warn};
#[derive(Debug, Clone, LuaDeviceConfig, Typed)]
#[typed(as = "PresenceConfig")]
pub struct Config {
#[device_config(flatten)]
#[typed(flatten)]
pub mqtt: MqttDeviceConfig,
#[device_config(from_lua, default)]
#[typed(default)]
pub callback: ActionCallback<(Presence, bool)>,
#[device_config(from_lua)]
pub client: WrappedAsyncClient,
}
crate::register_type!(Config);
pub const DEFAULT_PRESENCE: bool = false;
#[derive(Debug)]
pub struct State {
devices: HashMap<String, bool>,
current_overall_presence: bool,
}
#[derive(Debug, Clone, Device)]
#[device(extra_user_data = OverallPresence)]
pub struct Presence {
config: Config,
state: Arc<RwLock<State>>,
}
crate::register_device!(Presence);
struct OverallPresence;
impl PartialUserData<Presence> for OverallPresence {
fn add_methods<M: mlua::UserDataMethods<Presence>>(methods: &mut M) {
methods.add_async_method("overall_presence", async |_lua, this, ()| {
Ok(this.state().await.current_overall_presence)
});
}
fn definitions() -> Option<String> {
Some(format!(
"---@async\n---@return boolean\nfunction {}:overall_presence() end\n",
<Presence as Typed>::type_name(),
))
}
}
impl Presence {
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 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;
self.config
.callback
.call((self.clone(), overall_presence))
.await;
}
}
}

View File

@@ -1,42 +1,48 @@
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
use async_trait::async_trait; use async_trait::async_trait;
use automation_macro::{LuaDevice, LuaDeviceConfig}; 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::{Device, LuaDeviceConfig};
use eui48::MacAddress; use eui48::MacAddress;
use google_home::device;
use google_home::errors::ErrorCode; use google_home::errors::ErrorCode;
use google_home::traits::{self, Scene}; use google_home::traits::{self, Scene};
use google_home::types::Type; use google_home::types::Type;
use google_home::{device, GoogleHomeDevice}; use lua_typed::Typed;
use rumqttc::Publish; use rumqttc::Publish;
use tracing::{debug, error, trace}; use tracing::{debug, error, trace};
use super::{Device, LuaDeviceCreate}; #[derive(Debug, Clone, LuaDeviceConfig, Typed)]
use crate::config::{InfoConfig, MqttDeviceConfig}; #[typed(as = "WolConfig")]
use crate::event::OnMqtt; pub struct Config {
use crate::messages::ActivateMessage;
use crate::mqtt::WrappedAsyncClient;
#[derive(Debug, Clone, LuaDeviceConfig)]
pub struct WakeOnLANConfig {
#[device_config(flatten)] #[device_config(flatten)]
#[typed(flatten)]
pub info: InfoConfig, pub info: InfoConfig,
#[device_config(flatten)] #[device_config(flatten)]
#[typed(flatten)]
pub mqtt: MqttDeviceConfig, pub mqtt: MqttDeviceConfig,
pub mac_address: MacAddress, pub mac_address: MacAddress,
#[device_config(default(Ipv4Addr::new(255, 255, 255, 255)))] #[device_config(default(Ipv4Addr::new(255, 255, 255, 255)))]
#[typed(default)]
pub broadcast_ip: Ipv4Addr, pub broadcast_ip: Ipv4Addr,
#[device_config(from_lua)] #[device_config(from_lua)]
pub client: WrappedAsyncClient, pub client: WrappedAsyncClient,
} }
crate::register_type!(Config);
#[derive(Debug, LuaDevice)] #[derive(Debug, Clone, Device)]
pub struct WakeOnLAN { pub struct WakeOnLAN {
config: WakeOnLANConfig, config: Config,
} }
crate::register_device!(WakeOnLAN);
#[async_trait] #[async_trait]
impl LuaDeviceCreate for WakeOnLAN { impl LuaDeviceCreate for WakeOnLAN {
type Config = WakeOnLANConfig; type Config = Config;
type Error = rumqttc::ClientError; type Error = rumqttc::ClientError;
async fn create(config: Self::Config) -> Result<Self, Self::Error> { async fn create(config: Self::Config) -> Result<Self, Self::Error> {
@@ -59,7 +65,7 @@ impl Device for WakeOnLAN {
#[async_trait] #[async_trait]
impl OnMqtt for WakeOnLAN { impl OnMqtt for WakeOnLAN {
async fn on_mqtt(&mut self, message: Publish) { async fn on_mqtt(&self, message: Publish) {
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) { if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
return; return;
} }
@@ -76,7 +82,8 @@ impl OnMqtt for WakeOnLAN {
} }
} }
impl GoogleHomeDevice for WakeOnLAN { #[async_trait]
impl google_home::Device for WakeOnLAN {
fn get_device_type(&self) -> Type { fn get_device_type(&self) -> Type {
Type::Scene Type::Scene
} }
@@ -92,7 +99,7 @@ impl GoogleHomeDevice for WakeOnLAN {
Device::get_id(self) Device::get_id(self)
} }
fn is_online(&self) -> bool { async fn is_online(&self) -> bool {
true true
} }
@@ -103,8 +110,17 @@ impl GoogleHomeDevice for WakeOnLAN {
#[async_trait] #[async_trait]
impl traits::Scene for WakeOnLAN { impl traits::Scene for WakeOnLAN {
async fn set_active(&self, activate: bool) -> Result<(), ErrorCode> { async fn set_active(&self, deactivate: bool) -> Result<(), ErrorCode> {
if activate { if deactivate {
debug!(
id = Device::get_id(self),
"Trying to deactivate computer, this is not currently supported"
);
// We do not support deactivating this scene
Err(ErrorCode::DeviceError(
google_home::errors::DeviceError::ActionNotAvailable,
))
} else {
debug!( debug!(
id = Device::get_id(self), id = Device::get_id(self),
"Activating Computer: {} (Sending to {})", "Activating Computer: {} (Sending to {})",
@@ -131,15 +147,6 @@ impl traits::Scene for WakeOnLAN {
google_home::errors::DeviceError::TransientError.into() google_home::errors::DeviceError::TransientError.into()
}) })
.map(|_| debug!(id = Device::get_id(self), "Success!")) .map(|_| debug!(id = Device::get_id(self), "Success!"))
} else {
debug!(
id = Device::get_id(self),
"Trying to deactivate computer, this is not currently supported"
);
// We do not support deactivating this scene
Err(ErrorCode::DeviceError(
google_home::errors::DeviceError::ActionNotAvailable,
))
} }
} }
} }

View File

@@ -1,39 +1,63 @@
use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use automation_macro::{LuaDevice, LuaDeviceConfig}; use automation_lib::action_callback::ActionCallback;
use automation_lib::config::MqttDeviceConfig;
use automation_lib::device::{Device, LuaDeviceCreate};
use automation_lib::event::OnMqtt;
use automation_lib::messages::PowerMessage;
use automation_lib::mqtt::WrappedAsyncClient;
use automation_macro::{Device, LuaDeviceConfig};
use lua_typed::Typed;
use rumqttc::Publish; use rumqttc::Publish;
use tracing::{debug, error, trace, warn}; use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use tracing::{debug, error, trace};
use super::ntfy::Priority; #[derive(Debug, Clone, LuaDeviceConfig, Typed)]
use super::{Device, LuaDeviceCreate, Notification}; #[typed(as = "WasherConfig")]
use crate::config::MqttDeviceConfig; pub struct Config {
use crate::event::{self, Event, EventChannel, OnMqtt};
use crate::messages::PowerMessage;
use crate::mqtt::WrappedAsyncClient;
#[derive(Debug, Clone, LuaDeviceConfig)]
pub struct WasherConfig {
pub identifier: String, pub identifier: String,
#[device_config(flatten)] #[device_config(flatten)]
#[typed(flatten)]
pub mqtt: MqttDeviceConfig, pub mqtt: MqttDeviceConfig,
// Power in Watt // Power in Watt
pub threshold: f32, pub threshold: f32,
#[device_config(rename("event_channel"), from_lua, with(|ec: EventChannel| ec.get_tx()))]
pub tx: event::Sender, #[device_config(from_lua, default)]
#[typed(default)]
pub done_callback: ActionCallback<Washer>,
#[device_config(from_lua)] #[device_config(from_lua)]
pub client: WrappedAsyncClient, pub client: WrappedAsyncClient,
} }
crate::register_type!(Config);
#[derive(Debug)]
pub struct State {
running: isize,
}
// TODO: Add google home integration // TODO: Add google home integration
#[derive(Debug, LuaDevice)] #[derive(Debug, Clone, Device)]
pub struct Washer { pub struct Washer {
config: WasherConfig, config: Config,
state: Arc<RwLock<State>>,
}
crate::register_device!(Washer);
running: isize, impl Washer {
async fn state(&self) -> RwLockReadGuard<'_, State> {
self.state.read().await
}
async fn state_mut(&self) -> RwLockWriteGuard<'_, State> {
self.state.write().await
}
} }
#[async_trait] #[async_trait]
impl LuaDeviceCreate for Washer { impl LuaDeviceCreate for Washer {
type Config = WasherConfig; type Config = Config;
type Error = rumqttc::ClientError; type Error = rumqttc::ClientError;
async fn create(config: Self::Config) -> Result<Self, Self::Error> { async fn create(config: Self::Config) -> Result<Self, Self::Error> {
@@ -44,7 +68,10 @@ impl LuaDeviceCreate for Washer {
.subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce) .subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce)
.await?; .await?;
Ok(Self { config, running: 0 }) let state = State { running: 0 };
let state = Arc::new(RwLock::new(state));
Ok(Self { config, state })
} }
} }
@@ -61,7 +88,7 @@ const HYSTERESIS: isize = 10;
#[async_trait] #[async_trait]
impl OnMqtt for Washer { impl OnMqtt for Washer {
async fn on_mqtt(&mut self, message: Publish) { async fn on_mqtt(&self, message: Publish) {
if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) { if !rumqttc::matches(&message.topic, &self.config.mqtt.topic) {
return; return;
} }
@@ -77,9 +104,7 @@ impl OnMqtt for Washer {
} }
}; };
// debug!(id = self.identifier, power, "Washer state update"); if power < self.config.threshold && self.state().await.running >= HYSTERESIS {
if power < self.config.threshold && self.running >= HYSTERESIS {
// The washer is done running // The washer is done running
debug!( debug!(
id = self.config.identifier, id = self.config.identifier,
@@ -88,26 +113,13 @@ impl OnMqtt for Washer {
"Washer is done" "Washer is done"
); );
self.running = 0; self.state_mut().await.running = 0;
let notification = Notification::new()
.set_title("Laundy is done")
.set_message("Don't forget to hang it!")
.add_tag("womans_clothes")
.set_priority(Priority::High);
if self self.config.done_callback.call(self.clone()).await;
.config
.tx
.send(Event::Ntfy(notification))
.await
.is_err()
{
warn!("There are no receivers on the event channel");
}
} else if power < self.config.threshold { } else if power < self.config.threshold {
// Prevent false positives // Prevent false positives
self.running = 0; self.state_mut().await.running = 0;
} else if power >= self.config.threshold && self.running < HYSTERESIS { } else if power >= self.config.threshold && self.state().await.running < HYSTERESIS {
// Washer could be starting // Washer could be starting
debug!( debug!(
id = self.config.identifier, id = self.config.identifier,
@@ -116,7 +128,7 @@ impl OnMqtt for Washer {
"Washer is starting" "Washer is starting"
); );
self.running += 1; self.state_mut().await.running += 1;
} }
} }
} }

View File

@@ -0,0 +1,447 @@
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;
use automation_lib::helpers::serialization::state_deserializer;
use automation_lib::mqtt::WrappedAsyncClient;
use automation_macro::{Device, LuaDeviceConfig, LuaSerialize};
use google_home::device;
use google_home::errors::ErrorCode;
use google_home::traits::{Brightness, Color, ColorSetting, ColorTemperatureRange, OnOff};
use google_home::types::Type;
use lua_typed::Typed;
use rumqttc::{Publish, matches};
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> + Typed + 'static
{
}
#[derive(Debug, Clone, LuaDeviceConfig, Typed)]
#[typed(as = "ConfigLight")]
pub struct Config<T: LightState>
where
Light<T>: Typed,
{
#[device_config(flatten)]
#[typed(flatten)]
pub info: InfoConfig,
#[device_config(flatten)]
#[typed(flatten)]
pub mqtt: MqttDeviceConfig,
#[device_config(from_lua, default)]
#[typed(default)]
pub callback: ActionCallback<(Light<T>, T)>,
#[device_config(from_lua)]
#[typed(default)]
pub client: WrappedAsyncClient,
}
crate::register_type!(Config<StateOnOff>);
crate::register_type!(Config<StateBrightness>);
crate::register_type!(Config<StateColorTemperature>);
#[derive(Debug, Clone, Default, Serialize, Deserialize, LuaSerialize, Typed)]
#[typed(as = "LightStateOnOff")]
pub struct StateOnOff {
#[serde(deserialize_with = "state_deserializer")]
state: bool,
}
impl LightState for StateOnOff {}
crate::register_type!(StateOnOff);
#[derive(Debug, Clone, Default, Serialize, Deserialize, LuaSerialize, Typed)]
#[typed(as = "LightStateBrightness")]
pub struct StateBrightness {
#[serde(deserialize_with = "state_deserializer")]
state: bool,
brightness: f32,
}
impl LightState for StateBrightness {}
crate::register_type!(StateBrightness);
impl From<StateBrightness> for StateOnOff {
fn from(state: StateBrightness) -> Self {
StateOnOff { state: state.state }
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, LuaSerialize, Typed)]
#[typed(as = "LightStateColorTemperature")]
pub struct StateColorTemperature {
#[serde(deserialize_with = "state_deserializer")]
state: bool,
brightness: f32,
color_temp: u32,
}
crate::register_type!(StateColorTemperature);
impl LightState for StateColorTemperature {}
impl From<StateColorTemperature> for StateOnOff {
fn from(state: StateColorTemperature) -> Self {
StateOnOff { state: state.state }
}
}
impl From<StateColorTemperature> for StateBrightness {
fn from(state: StateColorTemperature) -> Self {
StateBrightness {
state: state.state,
brightness: state.brightness,
}
}
}
#[derive(Debug, Clone, Device)]
#[device(traits(OnOff for LightOnOff, LightBrightness, LightColorTemperature))]
#[device(traits(Brightness for LightBrightness, LightColorTemperature))]
#[device(traits(ColorSetting for LightColorTemperature))]
pub struct Light<T: LightState>
where
Light<T>: Typed,
{
config: Config<T>,
state: Arc<RwLock<T>>,
}
pub type LightOnOff = Light<StateOnOff>;
crate::register_device!(LightOnOff);
pub type LightBrightness = Light<StateBrightness>;
crate::register_device!(LightBrightness);
pub type LightColorTemperature = Light<StateColorTemperature>;
crate::register_device!(LightColorTemperature);
impl<T: LightState> Light<T>
where
Light<T>: Typed,
{
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>
where
Light<T>: Typed,
{
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>
where
Light<T>: Typed,
{
fn get_id(&self) -> String {
self.config.info.identifier()
}
}
#[async_trait]
impl OnMqtt for LightOnOff {
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.clone(), self.state().await.clone()))
.await;
}
}
}
#[async_trait]
impl OnMqtt for LightBrightness {
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.clone(), self.state().await.clone()))
.await;
}
}
}
#[async_trait]
impl OnMqtt for LightColorTemperature {
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::<StateColorTemperature>(&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
&& state.color_temp == current_state.color_temp
{
return;
}
}
self.state_mut().await.state = state.state;
self.state_mut().await.brightness = state.brightness;
self.state_mut().await.color_temp = state.color_temp;
debug!(
id = Device::get_id(self),
"Updating state to {:?}",
self.state().await
);
self.config
.callback
.call((self.clone(), self.state().await.clone()))
.await;
}
}
}
#[async_trait]
impl<T: LightState> google_home::Device for Light<T>
where
Light<T>: Typed,
{
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,
Light<T>: Typed,
{
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: f32 = 30.0;
#[async_trait]
impl<T> Brightness for Light<T>
where
T: LightState,
T: Into<StateBrightness>,
Light<T>: Typed,
{
async fn brightness(&self) -> Result<u8, ErrorCode> {
let state = self.state().await;
let state: StateBrightness = state.deref().clone().into();
let brightness =
100.0 * f32::log10(state.brightness / FACTOR + 1.0) / f32::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 f32) / 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(())
}
}
#[async_trait]
impl<T> ColorSetting for Light<T>
where
T: LightState,
T: Into<StateColorTemperature>,
Light<T>: Typed,
{
fn color_temperature_range(&self) -> ColorTemperatureRange {
ColorTemperatureRange {
temperature_min_k: 2200,
temperature_max_k: 4000,
}
}
async fn color(&self) -> Color {
let state = self.state().await;
let state: StateColorTemperature = state.deref().clone().into();
let temperature = 1_000_000 / state.color_temp;
Color { temperature }
}
async fn set_color(&self, color: Color) -> Result<(), ErrorCode> {
let temperature = 1_000_000 / color.temperature;
let message = json!({
"color_temp": temperature,
});
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,297 @@
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;
use automation_lib::helpers::serialization::state_deserializer;
use automation_lib::mqtt::WrappedAsyncClient;
use automation_macro::{Device, LuaDeviceConfig, LuaSerialize};
use google_home::device;
use google_home::errors::ErrorCode;
use google_home::traits::OnOff;
use google_home::types::Type;
use lua_typed::Typed;
use rumqttc::{Publish, matches};
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> + Typed + 'static
{
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Copy, Typed)]
pub enum OutletType {
Outlet,
Kettle,
}
crate::register_type!(OutletType);
impl From<OutletType> for Type {
fn from(outlet: OutletType) -> Self {
match outlet {
OutletType::Outlet => Type::Outlet,
OutletType::Kettle => Type::Kettle,
}
}
}
#[derive(Debug, Clone, LuaDeviceConfig, Typed)]
#[typed(as = "ConfigOutlet")]
pub struct Config<T: OutletState>
where
Outlet<T>: Typed,
{
#[device_config(flatten)]
#[typed(flatten)]
pub info: InfoConfig,
#[device_config(flatten)]
#[typed(flatten)]
pub mqtt: MqttDeviceConfig,
#[device_config(default(OutletType::Outlet))]
#[typed(default)]
pub outlet_type: OutletType,
#[device_config(from_lua, default)]
#[typed(default)]
pub callback: ActionCallback<(Outlet<T>, T)>,
#[device_config(from_lua)]
pub client: WrappedAsyncClient,
}
crate::register_type!(Config<StateOnOff>);
crate::register_type!(Config<StatePower>);
#[derive(Debug, Clone, Default, Serialize, Deserialize, LuaSerialize, Typed)]
#[typed(as = "OutletStateOnOff")]
pub struct StateOnOff {
#[serde(deserialize_with = "state_deserializer")]
state: bool,
}
crate::register_type!(StateOnOff);
impl OutletState for StateOnOff {}
#[derive(Debug, Clone, Default, Serialize, Deserialize, LuaSerialize, Typed)]
#[typed(as = "OutletStatePower")]
pub struct StatePower {
#[serde(deserialize_with = "state_deserializer")]
state: bool,
power: f64,
}
crate::register_type!(StatePower);
impl OutletState for StatePower {}
impl From<StatePower> for StateOnOff {
fn from(state: StatePower) -> Self {
StateOnOff { state: state.state }
}
}
#[derive(Debug, Clone, Device)]
#[device(traits(OnOff for OutletOnOff, OutletPower))]
pub struct Outlet<T: OutletState>
where
Outlet<T>: Typed,
{
config: Config<T>,
state: Arc<RwLock<T>>,
}
pub type OutletOnOff = Outlet<StateOnOff>;
crate::register_device!(OutletOnOff);
pub type OutletPower = Outlet<StatePower>;
crate::register_device!(OutletPower);
impl<T: OutletState> Outlet<T>
where
Outlet<T>: Typed,
{
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>
where
Outlet<T>: Typed,
{
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>
where
Outlet<T>: Typed,
{
fn get_id(&self) -> String {
self.config.info.identifier()
}
}
#[async_trait]
impl OnMqtt for OutletOnOff {
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.clone(), self.state().await.clone()))
.await;
}
}
}
#[async_trait]
impl OnMqtt for OutletPower {
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.clone(), self.state().await.clone()))
.await;
}
}
}
#[async_trait]
impl<T: OutletState> google_home::Device for Outlet<T>
where
Outlet<T>: Typed,
{
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,
Outlet<T>: Typed,
{
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(())
}
}

23
automation_lib/Cargo.toml Normal file
View File

@@ -0,0 +1,23 @@
[package]
name = "automation_lib"
version = "0.1.0"
edition = "2024"
[dependencies]
automation_macro = { workspace = true }
async-trait = { workspace = true }
automation_cast = { workspace = true }
bytes = { workspace = true }
dyn-clone = { workspace = true }
futures = { workspace = true }
google_home = { workspace = true }
hostname = { workspace = true }
inventory = { workspace = true }
lua_typed = { workspace = true }
mlua = { workspace = true }
rumqttc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }

View File

@@ -0,0 +1,94 @@
use std::marker::PhantomData;
use futures::future::try_join_all;
use lua_typed::Typed;
use mlua::{FromLua, IntoLuaMulti};
#[derive(Debug, Clone)]
pub struct ActionCallback<P> {
callbacks: Vec<mlua::Function>,
_parameters: PhantomData<P>,
}
impl Typed for ActionCallback<()> {
fn type_name() -> String {
"fun() | fun()[]".into()
}
}
impl<A: Typed> Typed for ActionCallback<A> {
fn type_name() -> String {
let type_name = A::type_name();
format!("fun(_: {type_name}) | fun(_: {type_name})[]")
}
}
impl<A: Typed, B: Typed> Typed for ActionCallback<(A, B)> {
fn type_name() -> String {
let type_name_a = A::type_name();
let type_name_b = B::type_name();
format!(
"fun(_: {type_name_a}, _: {type_name_b}) | fun(_: {type_name_a}, _: {type_name_b})[]"
)
}
}
// NOTE: For some reason the derive macro combined with PhantomData leads to issues where it
// requires all types part of P to implement default, even if they never actually get constructed.
// By manually implemented Default it works fine.
impl<P> Default for ActionCallback<P> {
fn default() -> Self {
Self {
callbacks: Default::default(),
_parameters: Default::default(),
}
}
}
impl<P> FromLua for ActionCallback<P> {
fn from_lua(value: mlua::Value, _lua: &mlua::Lua) -> mlua::Result<Self> {
let callbacks = match value {
mlua::Value::Function(f) => vec![f],
mlua::Value::Table(table) => table
.pairs::<mlua::Value, mlua::Function>()
.map(|pair| {
let (_, f) = pair?;
Ok::<_, mlua::Error>(f)
})
.try_collect()?,
_ => {
return Err(mlua::Error::FromLuaConversionError {
from: value.type_name(),
to: "ActionCallback".into(),
message: Some("expected function or table of functions".into()),
});
}
};
Ok(ActionCallback {
callbacks,
_parameters: PhantomData::<P>,
})
}
}
// TODO: Return proper error here
impl<P> ActionCallback<P>
where
P: IntoLuaMulti + Sync + Clone,
{
pub async fn call(&self, parameters: P) {
try_join_all(
self.callbacks
.iter()
.map(async |f| f.call_async::<()>(parameters.clone()).await),
)
.await
.unwrap();
}
pub fn is_empty(&self) -> bool {
self.callbacks.is_empty()
}
}

View File

@@ -0,0 +1,23 @@
use lua_typed::Typed;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize, Typed)]
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, Typed)]
pub struct MqttDeviceConfig {
pub topic: String,
}

View File

@@ -0,0 +1,54 @@
use std::fmt::Debug;
use automation_cast::Cast;
use dyn_clone::DynClone;
use lua_typed::Typed;
use mlua::ObjectLike;
use crate::event::OnMqtt;
#[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>
{
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::<Self>() {
ud
} else {
ud.call_method::<_>("__box", ())?
};
let b = ud.borrow::<Self>()?.clone();
Ok(b)
}
_ => Err(mlua::Error::runtime(format!(
"Expected user data, instead found: {}",
value.type_name()
))),
}
}
}
impl mlua::UserData for Box<dyn Device> {}
impl Typed for Box<dyn Device> {
fn type_name() -> String {
"DeviceInterface".into()
}
}
dyn_clone::clone_trait_object!(Device);

View File

@@ -0,0 +1,89 @@
use std::collections::HashMap;
use std::sync::Arc;
use futures::future::join_all;
use tokio::sync::{RwLock, RwLockReadGuard};
use tracing::{debug, instrument, trace};
use crate::device::Device;
use crate::event::{Event, EventChannel, OnMqtt};
pub type DeviceMap = HashMap<String, Box<dyn Device>>;
#[derive(Clone)]
pub struct DeviceManager {
devices: Arc<RwLock<DeviceMap>>,
event_channel: EventChannel,
}
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,
};
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();
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(async |(id, device)| {
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.clone()).await;
trace!(id, "Done");
// }
}
});
join_all(iter).await;
}
}
}
}

View File

@@ -1,10 +1,7 @@
use std::{error, fmt, result}; use std::{error, fmt, result};
use axum::http::status::InvalidStatusCode;
use axum::response::IntoResponse;
use bytes::Bytes; use bytes::Bytes;
use rumqttc::ClientError; use rumqttc::ClientError;
use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -41,7 +38,9 @@ impl fmt::Display for MissingEnv {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Missing environment variable")?; write!(f, "Missing environment variable")?;
if self.keys.is_empty() { if self.keys.is_empty() {
unreachable!("This error should only be returned if there are actually missing environment variables"); unreachable!(
"This error should only be returned if there are actually missing environment variables"
);
} }
if self.keys.len() == 1 { if self.keys.len() == 1 {
write!(f, " '{}'", self.keys[0])?; write!(f, " '{}'", self.keys[0])?;
@@ -101,68 +100,3 @@ pub enum LightSensorError {
#[error(transparent)] #[error(transparent)]
SubscribeError(#[from] ClientError), SubscribeError(#[from] ClientError),
} }
#[derive(Debug, Error)]
#[error("{source}")]
pub struct ApiError {
status_code: axum::http::StatusCode,
source: Box<dyn std::error::Error>,
}
impl ApiError {
pub fn new(status_code: axum::http::StatusCode, source: Box<dyn std::error::Error>) -> Self {
Self {
status_code,
source,
}
}
}
impl From<ApiError> for ApiErrorJson {
fn from(value: ApiError) -> Self {
let error = ApiErrorJsonError {
code: value.status_code.as_u16(),
status: value.status_code.to_string(),
reason: value.source.to_string(),
};
Self { error }
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
(
self.status_code,
serde_json::to_string::<ApiErrorJson>(&self.into())
.expect("Serialization should not fail"),
)
.into_response()
}
}
#[derive(Debug, Serialize, Deserialize)]
struct ApiErrorJsonError {
code: u16,
status: String,
reason: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ApiErrorJson {
error: ApiErrorJsonError,
}
impl TryFrom<ApiErrorJson> for ApiError {
type Error = InvalidStatusCode;
fn try_from(value: ApiErrorJson) -> result::Result<Self, Self::Error> {
let status_code = axum::http::StatusCode::from_u16(value.error.code)?;
let source = value.error.reason.into();
Ok(Self {
status_code,
source,
})
}
}

View File

@@ -3,14 +3,9 @@ use mlua::FromLua;
use rumqttc::Publish; use rumqttc::Publish;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::devices::Notification;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Event { pub enum Event {
MqttMessage(Publish), MqttMessage(Publish),
Darkness(bool),
Presence(bool),
Ntfy(Notification),
} }
pub type Sender = mpsc::Sender<Event>; pub type Sender = mpsc::Sender<Event>;
@@ -36,20 +31,5 @@ impl mlua::UserData for EventChannel {}
#[async_trait] #[async_trait]
pub trait OnMqtt: Sync + Send { pub trait OnMqtt: Sync + Send {
// fn topics(&self) -> Vec<&str>; // fn topics(&self) -> Vec<&str>;
async fn on_mqtt(&mut self, message: Publish); async fn on_mqtt(&self, message: Publish);
}
#[async_trait]
pub trait OnPresence: Sync + Send {
async fn on_presence(&mut self, presence: bool);
}
#[async_trait]
pub trait OnDarkness: Sync + Send {
async fn on_darkness(&mut self, dark: bool);
}
#[async_trait]
pub trait OnNotification: Sync + Send {
async fn on_notification(&mut self, notification: Notification);
} }

View File

@@ -0,0 +1 @@
pub mod serialization;

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",
)),
}
}

62
automation_lib/src/lib.rs Normal file
View File

@@ -0,0 +1,62 @@
#![feature(iterator_try_collect)]
#![feature(with_negative_coherence)]
use tracing::debug;
pub mod action_callback;
pub mod config;
pub mod device;
pub mod device_manager;
pub mod error;
pub mod event;
pub mod helpers;
pub mod lua;
pub mod messages;
pub mod mqtt;
type RegisterFn = fn(lua: &mlua::Lua) -> mlua::Result<mlua::Table>;
type DefinitionsFn = fn() -> String;
pub struct Module {
name: &'static str,
register_fn: RegisterFn,
definitions_fn: Option<DefinitionsFn>,
}
impl Module {
pub const fn new(
name: &'static str,
register_fn: RegisterFn,
definitions_fn: Option<DefinitionsFn>,
) -> Self {
Self {
name,
register_fn,
definitions_fn,
}
}
pub const fn get_name(&self) -> &'static str {
self.name
}
pub fn register(&self, lua: &mlua::Lua) -> mlua::Result<mlua::Table> {
(self.register_fn)(lua)
}
pub fn definitions(&self) -> Option<String> {
self.definitions_fn.map(|f| f())
}
}
pub fn load_modules(lua: &mlua::Lua) -> mlua::Result<()> {
for module in inventory::iter::<Module> {
debug!(name = module.get_name(), "Loading module");
let table = module.register(lua)?;
lua.register_module(module.get_name(), table)?;
}
Ok(())
}
inventory::collect!(Module);

View File

@@ -0,0 +1,3 @@
pub mod traits;
mod utils;

View File

@@ -0,0 +1,127 @@
use std::ops::Deref;
// TODO: Enable and disable functions based on query_only and command_only
pub trait PartialUserData<T> {
fn add_methods<M: mlua::UserDataMethods<T>>(methods: &mut M);
fn interface_name() -> Option<&'static str> {
None
}
fn definitions() -> Option<String> {
None
}
}
pub struct Device;
impl<T> PartialUserData<T> for Device
where
T: crate::device::Device + 'static,
{
fn add_methods<M: mlua::UserDataMethods<T>>(methods: &mut M) {
methods.add_async_method("get_id", async |_lua, this, _: ()| Ok(this.get_id()));
}
fn interface_name() -> Option<&'static str> {
Some("DeviceInterface")
}
}
pub struct OnOff;
impl<T> PartialUserData<T> for OnOff
where
T: google_home::traits::OnOff + 'static,
{
fn add_methods<M: mlua::UserDataMethods<T>>(methods: &mut M) {
methods.add_async_method("set_on", async |_lua, this, on: bool| {
this.deref().set_on(on).await.unwrap();
Ok(())
});
methods.add_async_method("on", async |_lua, this, ()| {
Ok(this.deref().on().await.unwrap())
});
}
fn interface_name() -> Option<&'static str> {
Some("OnOffInterface")
}
}
pub struct Brightness;
impl<T> PartialUserData<T> for Brightness
where
T: google_home::traits::Brightness + 'static,
{
fn add_methods<M: mlua::UserDataMethods<T>>(methods: &mut M) {
methods.add_async_method("set_brightness", async |_lua, this, brightness: u8| {
this.set_brightness(brightness).await.unwrap();
Ok(())
});
methods.add_async_method("brightness", async |_lua, this, _: ()| {
Ok(this.brightness().await.unwrap())
});
}
fn interface_name() -> Option<&'static str> {
Some("BrightnessInterface")
}
}
pub struct ColorSetting;
impl<T> PartialUserData<T> for ColorSetting
where
T: google_home::traits::ColorSetting + 'static,
{
fn add_methods<M: mlua::UserDataMethods<T>>(methods: &mut M) {
methods.add_async_method(
"set_color_temperature",
async |_lua, this, temperature: u32| {
this.set_color(google_home::traits::Color { temperature })
.await
.unwrap();
Ok(())
},
);
methods.add_async_method("color_temperature", async |_lua, this, ()| {
Ok(this.color().await.temperature)
});
}
fn interface_name() -> Option<&'static str> {
Some("ColorSettingInterface")
}
}
pub struct OpenClose;
impl<T> PartialUserData<T> for OpenClose
where
T: google_home::traits::OpenClose + 'static,
{
fn add_methods<M: mlua::UserDataMethods<T>>(methods: &mut M) {
methods.add_async_method("set_open_percent", async |_lua, this, open_percent: u8| {
this.set_open_percent(open_percent).await.unwrap();
Ok(())
});
methods.add_async_method("open_percent", async |_lua, this, _: ()| {
Ok(this.open_percent().await.unwrap())
});
}
fn interface_name() -> Option<&'static str> {
Some("OpenCloseInterface")
}
}

View File

@@ -0,0 +1,48 @@
mod timeout;
use std::time::{SystemTime, UNIX_EPOCH};
use lua_typed::Typed;
pub use timeout::Timeout;
use crate::Module;
fn create_module(lua: &mlua::Lua) -> mlua::Result<mlua::Table> {
let utils = lua.create_table()?;
utils.set(Timeout::type_name(), lua.create_proxy::<Timeout>()?)?;
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)
})?;
utils.set("get_hostname", get_hostname)?;
let get_epoch = lua.create_function(|_lua, ()| {
Ok(SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time is after UNIX EPOCH")
.as_millis())
})?;
utils.set("get_epoch", get_epoch)?;
Ok(utils)
}
fn generate_definitions() -> String {
let mut output = String::new();
output += "---@meta\n\nlocal utils\n\n";
output += &Timeout::generate_full().expect("Timeout should have generate_full");
output += "\n";
output += "---@return string\nfunction utils.get_hostname() end\n\n";
output += "---@return integer\nfunction utils.get_epoch() end\n\n";
output += "return utils";
output
}
inventory::submit! {Module::new("automation:utils", create_module, Some(generate_definitions))}

View File

@@ -0,0 +1,118 @@
use std::sync::Arc;
use std::time::Duration;
use lua_typed::Typed;
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",
async |_lua, this, (timeout, callback): (f32, ActionCallback<()>)| {
if let Some(handle) = this.state.write().await.handle.take() {
handle.abort();
}
debug!("Running timeout callback after {timeout}s");
let timeout = Duration::from_secs_f32(timeout);
this.state.write().await.handle = Some(tokio::spawn({
async move {
tokio::time::sleep(timeout).await;
callback.call(()).await;
}
}));
Ok(())
},
);
methods.add_async_method("cancel", async |_lua, this, ()| {
debug!("Canceling timeout callback");
if let Some(handle) = this.state.write().await.handle.take() {
handle.abort();
}
Ok(())
});
methods.add_async_method("is_waiting", async |_lua, this, ()| {
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)
});
}
}
impl Typed for Timeout {
fn type_name() -> String {
"Timeout".into()
}
fn generate_header() -> Option<String> {
let type_name = Self::type_name();
Some(format!("---@class {type_name}\nlocal {type_name}\n"))
}
fn generate_members() -> Option<String> {
let mut output = String::new();
let type_name = Self::type_name();
output += &format!(
"---@async\n---@param timeout number\n---@param callback {}\nfunction {type_name}:start(timeout, callback) end\n",
ActionCallback::<()>::type_name()
);
output += &format!("---@async\nfunction {type_name}:cancel() end\n",);
output +=
&format!("---@async\n---@return boolean\nfunction {type_name}:is_waiting() end\n",);
Some(output)
}
fn generate_footer() -> Option<String> {
let mut output = String::new();
let type_name = Self::type_name();
output += &format!("utils.{type_name} = {{}}\n");
output += &format!("---@return {type_name}\n");
output += &format!("function utils.{type_name}.new() end\n");
Some(output)
}
}

View File

@@ -68,13 +68,8 @@ pub enum RemoteAction {
// Message used to report the action performed by a remote // Message used to report the action performed by a remote
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct RemoteMessage { pub struct RemoteMessage {
action: RemoteAction, pub action: Option<RemoteAction>,
} pub battery: Option<f32>,
impl RemoteMessage {
pub fn action(&self) -> RemoteAction {
self.action
}
} }
impl TryFrom<Publish> for RemoteMessage { impl TryFrom<Publish> for RemoteMessage {
@@ -144,13 +139,8 @@ impl TryFrom<Publish> for BrightnessMessage {
// Message to report the state of a contact sensor // Message to report the state of a contact sensor
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ContactMessage { pub struct ContactMessage {
contact: bool, pub contact: Option<bool>,
} pub battery: Option<f32>,
impl ContactMessage {
pub fn is_closed(&self) -> bool {
self.contact
}
} }
impl TryFrom<Publish> for ContactMessage { impl TryFrom<Publish> for ContactMessage {
@@ -162,40 +152,6 @@ impl TryFrom<Publish> for ContactMessage {
} }
} }
// Message used to report the current darkness state
#[derive(Debug, Deserialize, Serialize)]
pub struct DarknessMessage {
state: bool,
updated: Option<u128>,
}
impl DarknessMessage {
pub fn new(state: bool) -> Self {
Self {
state,
updated: Some(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time is after UNIX EPOCH")
.as_millis(),
),
}
}
pub fn is_dark(&self) -> bool {
self.state
}
}
impl TryFrom<Publish> for DarknessMessage {
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())))
}
}
// Message used to report the power draw a smart plug // Message used to report the power draw a smart plug
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct PowerMessage { pub struct PowerMessage {
@@ -241,39 +197,3 @@ impl TryFrom<Bytes> for HueMessage {
serde_json::from_slice(&bytes).or(Err(ParseError::InvalidPayload(bytes.clone()))) serde_json::from_slice(&bytes).or(Err(ParseError::InvalidPayload(bytes.clone())))
} }
} }
// TODO: Import this from the air_filter code itself instead of copying
#[derive(PartialEq, Eq, Debug, Clone, Copy, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AirFilterFanState {
Off,
Low,
Medium,
High,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
pub struct SetAirFilterFanState {
state: AirFilterFanState,
}
#[derive(PartialEq, Debug, Clone, Copy, Deserialize, Serialize)]
pub struct AirFilterState {
pub state: AirFilterFanState,
pub humidity: f32,
}
impl SetAirFilterFanState {
pub fn new(state: AirFilterFanState) -> Self {
Self { state }
}
}
impl TryFrom<Publish> for AirFilterState {
type Error = ParseError;
fn try_from(message: Publish) -> Result<Self, Self::Error> {
serde_json::from_slice(&message.payload)
.or(Err(ParseError::InvalidPayload(message.payload.clone())))
}
}

128
automation_lib/src/mqtt.rs Normal file
View File

@@ -0,0 +1,128 @@
use std::ops::{Deref, DerefMut};
use std::time::Duration;
use automation_macro::LuaDeviceConfig;
use lua_typed::Typed;
use mlua::FromLua;
use rumqttc::{AsyncClient, Event, Incoming, MqttOptions, Transport};
use serde::Deserialize;
use tracing::{debug, warn};
use crate::event::{self, EventChannel};
#[derive(Debug, Clone, LuaDeviceConfig, Deserialize, Typed)]
pub struct MqttConfig {
pub host: String,
pub port: u16,
pub client_name: String,
pub username: String,
pub password: String,
#[serde(default)]
#[typed(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, Clone, FromLua)]
pub struct WrappedAsyncClient(pub AsyncClient);
impl Typed for WrappedAsyncClient {
fn type_name() -> String {
"AsyncClient".into()
}
fn generate_header() -> Option<String> {
let type_name = Self::type_name();
Some(format!("---@class {type_name}\nlocal {type_name}\n"))
}
fn generate_members() -> Option<String> {
let mut output = String::new();
let type_name = Self::type_name();
output += &format!(
"---@async\n---@param topic string\n---@param message table?\nfunction {type_name}:send_message(topic, message) end\n"
);
Some(output)
}
}
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 {
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
methods.add_async_method(
"send_message",
async |_lua, this, (topic, message): (String, mlua::Value)| {
// serde_json converts nil => "null", but we actually want nil to send an empty
// message
let message = if message.is_nil() {
"".into()
} else {
serde_json::to_string(&message).unwrap()
};
debug!("message = {message}");
this.0
.publish(topic, rumqttc::QoS::AtLeastOnce, true, message)
.await
.unwrap();
Ok(())
},
);
}
}
pub fn start(config: MqttConfig, event_channel: &EventChannel) -> WrappedAsyncClient {
let tx = event_channel.get_tx();
let (client, mut eventloop) = AsyncClient::new(config.into(), 100);
tokio::spawn(async move {
debug!("Listening for MQTT events");
loop {
let notification = eventloop.poll().await;
match notification {
Ok(Event::Incoming(Incoming::Publish(p))) => {
tx.send(event::Event::MqttMessage(p)).await.ok();
}
Ok(..) => continue,
Err(err) => {
// Something has gone wrong
// We stay in the loop as that will attempt to reconnect
warn!("{}", err);
}
}
}
});
WrappedAsyncClient(client)
}

View File

@@ -1,13 +1,16 @@
[package] [package]
name = "automation_macro" name = "automation_macro"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
[lib] [lib]
proc-macro = true proc-macro = true
[dependencies] [dependencies]
itertools = "0.12.1" itertools = { workspace = true }
proc-macro2 = "1.0.81" proc-macro2 = { workspace = true }
quote = "1.0.36" quote = { workspace = true }
syn = { version = "2.0.60", features = ["extra-traits", "full"] } syn = { workspace = true }
[dev-dependencies]
mlua = { workspace = true }

View File

@@ -0,0 +1,265 @@
use std::collections::HashMap;
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::spanned::Spanned;
use syn::{Attribute, DeriveInput, Token, parenthesized};
enum Attr {
Trait(TraitAttr),
ExtraUserData(ExtraUserDataAttr),
}
impl Attr {
fn parse(attr: &Attribute) -> syn::Result<Self> {
let mut parsed = None;
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("traits") {
let input;
_ = parenthesized!(input in meta.input);
parsed = Some(Attr::Trait(input.parse()?));
} else if meta.path.is_ident("extra_user_data") {
let value = meta.value()?;
parsed = Some(Attr::ExtraUserData(value.parse()?));
} else {
return Err(syn::Error::new(meta.path.span(), "Unknown attribute"));
}
Ok(())
})?;
Ok(parsed.expect("Parsed should be set"))
}
}
struct TraitAttr {
traits: Traits,
aliases: Aliases,
}
impl Parse for TraitAttr {
fn parse(input: ParseStream) -> syn::Result<Self> {
Ok(Self {
traits: input.parse()?,
aliases: input.parse()?,
})
}
}
#[derive(Default)]
struct Traits(Vec<syn::Ident>);
impl Traits {
fn extend(&mut self, other: &Traits) {
self.0.extend_from_slice(&other.0);
}
}
impl Parse for Traits {
fn parse(input: ParseStream) -> syn::Result<Self> {
input
.call(Punctuated::<_, Token![,]>::parse_separated_nonempty)
.map(|traits| traits.into_iter().collect())
.map(Self)
}
}
#[derive(Default)]
struct Aliases(Vec<syn::Ident>);
impl Aliases {
fn has_aliases(&self) -> bool {
!self.0.is_empty()
}
}
impl Parse for Aliases {
fn parse(input: ParseStream) -> syn::Result<Self> {
if !input.peek(Token![for]) {
if input.is_empty() {
return Ok(Default::default());
} else {
return Err(input.error("Expected ')' or 'for'"));
}
}
_ = input.parse::<syn::Token![for]>()?;
input
.call(Punctuated::<_, Token![,]>::parse_separated_nonempty)
.map(|aliases| aliases.into_iter().collect())
.map(Self)
}
}
#[derive(Clone)]
struct ExtraUserDataAttr(syn::Ident);
impl Parse for ExtraUserDataAttr {
fn parse(input: ParseStream) -> syn::Result<Self> {
Ok(Self(input.parse()?))
}
}
struct Implementation {
name: syn::Ident,
traits: Traits,
extra_user_data: Vec<ExtraUserDataAttr>,
}
impl quote::ToTokens for Implementation {
fn to_tokens(&self, tokens: &mut TokenStream2) {
let Self {
name,
traits,
extra_user_data,
} = &self;
let Traits(traits) = traits;
let extra_user_data: Vec<_> = extra_user_data.iter().map(|tr| tr.0.clone()).collect();
tokens.extend(quote! {
impl mlua::UserData for #name {
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
methods.add_async_function("new", async |_lua, config| {
let device: Self = 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)
});
<::automation_lib::lua::traits::Device as ::automation_lib::lua::traits::PartialUserData<#name>>::add_methods(methods);
#(
<::automation_lib::lua::traits::#traits as ::automation_lib::lua::traits::PartialUserData<#name>>::add_methods(methods);
)*
#(
<#extra_user_data as ::automation_lib::lua::traits::PartialUserData<#name>>::add_methods(methods);
)*
}
}
impl ::lua_typed::Typed for #name {
fn type_name() -> String {
stringify!(#name).into()
}
fn generate_header() -> std::option::Option<::std::string::String> {
let type_name = <Self as ::lua_typed::Typed>::type_name();
let mut output = String::new();
let interfaces: String = [
<::automation_lib::lua::traits::Device as ::automation_lib::lua::traits::PartialUserData<#name>>::interface_name(),
#(
<::automation_lib::lua::traits::#traits as ::automation_lib::lua::traits::PartialUserData<#name>>::interface_name(),
)*
].into_iter().flatten().intersperse(", ").collect();
let interfaces = if interfaces.is_empty() {
"".into()
} else {
format!(": {interfaces}")
};
Some(format!("---@class {type_name}{interfaces}\nlocal {type_name}\n"))
}
fn generate_members() -> Option<String> {
let mut output = String::new();
let type_name = <Self as ::lua_typed::Typed>::type_name();
output += &format!("devices.{type_name} = {{}}\n");
let config_name = <<Self as ::automation_lib::device::LuaDeviceCreate>::Config as ::lua_typed::Typed>::type_name();
output += &format!("---@param config {config_name}\n");
output += &format!("---@return {type_name}\n");
output += &format!("function devices.{type_name}.new(config) end\n");
output += &<::automation_lib::lua::traits::Device as ::automation_lib::lua::traits::PartialUserData<#name>>::definitions().unwrap_or("".into());
#(
output += &<::automation_lib::lua::traits::#traits as ::automation_lib::lua::traits::PartialUserData<#name>>::definitions().unwrap_or("".into());
)*
#(
output += &<#extra_user_data as ::automation_lib::lua::traits::PartialUserData<#name>>::definitions().unwrap_or("".into());
)*
Some(output)
}
}
});
}
}
struct Implementations(Vec<Implementation>);
impl Implementations {
fn from_attr(attributes: Vec<Attr>, name: syn::Ident) -> Self {
let mut add_methods = Vec::new();
let mut all = Traits::default();
let mut implementations: HashMap<_, Traits> = HashMap::new();
for attribute in attributes {
match attribute {
Attr::Trait(attribute) => {
if attribute.aliases.has_aliases() {
for alias in &attribute.aliases.0 {
implementations
.entry(Some(alias.clone()))
.or_default()
.extend(&attribute.traits);
}
} else {
all.extend(&attribute.traits);
}
}
Attr::ExtraUserData(attribute) => add_methods.push(attribute),
}
}
if implementations.is_empty() {
implementations.entry(None).or_default().extend(&all);
} else {
for traits in implementations.values_mut() {
traits.extend(&all);
}
}
Self(
implementations
.into_iter()
.map(|(alias, traits)| Implementation {
name: alias.unwrap_or(name.clone()),
traits,
extra_user_data: add_methods.clone(),
})
.collect(),
)
}
}
pub fn device(input: DeriveInput) -> TokenStream2 {
let Implementations(imp) = match input
.attrs
.iter()
.filter(|attr| attr.path().is_ident("device"))
.map(Attr::parse)
.try_collect::<Vec<_>>()
{
Ok(attr) => Implementations::from_attr(attr, input.ident),
Err(err) => return err.into_compile_error(),
};
quote! {
#(
#imp
)*
}
}

View File

@@ -1,16 +1,11 @@
mod lua_device; #![feature(iter_intersperse)]
#![feature(iterator_try_collect)]
mod device;
mod lua_device_config; mod lua_device_config;
use lua_device::impl_lua_device_macro;
use lua_device_config::impl_lua_device_config_macro; use lua_device_config::impl_lua_device_config_macro;
use syn::{parse_macro_input, DeriveInput}; use quote::quote;
use syn::{DeriveInput, parse_macro_input};
#[proc_macro_derive(LuaDevice, attributes(config))]
pub fn lua_device_derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
impl_lua_device_macro(&ast).into()
}
#[proc_macro_derive(LuaDeviceConfig, attributes(device_config))] #[proc_macro_derive(LuaDeviceConfig, attributes(device_config))]
pub fn lua_device_config_derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream { pub fn lua_device_config_derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
@@ -18,3 +13,61 @@ pub fn lua_device_config_derive(input: proc_macro::TokenStream) -> proc_macro::T
impl_lua_device_config_macro(&ast).into() impl_lua_device_config_macro(&ast).into()
} }
#[proc_macro_derive(LuaSerialize, attributes(traits))]
pub fn lua_serialize(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let name = &ast.ident;
quote! {
impl ::mlua::IntoLua for #name {
fn into_lua(self, lua: &::mlua::Lua) -> ::mlua::Result<::mlua::Value> {
::mlua::LuaSerdeExt::to_value(lua, &self)
}
}
}
.into()
}
/// Derive macro generating an impl for the trait `::mlua::UserData`
///
/// # Device traits
/// The `device(traits)` attribute can be used to tell the macro what traits are implemented so that
/// the appropriate methods can automatically be registered.
/// If the struct does not have any type parameters the syntax is very simple:
/// ```rust
/// #[device(traits(TraitA, TraitB))]
/// ```
///
/// If the type does have type parameters you will have to manually specify all variations that
/// have the trait available:
/// ```rust
/// #[device(traits(TraitA, TraitB for <StateA>, <StateB>))]
/// ```
/// If multiple of these attributes are specified they will all combined appropriately.
///
///
/// ## NOTE
/// If your type _has_ type parameters any instance of the traits attribute that does not specify
/// any type parameters will have the traits applied to _all_ other type parameter variations
/// listed in the other trait attributes. This behavior only applies if there is at least one
/// instance with type parameters specified.
///
/// # Additional methods
/// Additional methods can be added by using the `device(add_methods)` attribute. This attribute
/// takes the path to a function with the following signature that can register the additional methods:
///
/// ```rust
/// # struct D;
/// fn top_secret<M: mlua::UserDataMethods<D>>(methods: &mut M) {}
/// ```
/// It can then be registered with:
/// ```rust
/// #[device(add_methods = top_secret)]
/// ```
#[proc_macro_derive(Device, attributes(device))]
pub fn device(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
device::device(ast).into()
}

View File

@@ -1,28 +0,0 @@
use proc_macro2::TokenStream;
use quote::quote;
use syn::DeriveInput;
pub fn impl_lua_device_macro(ast: &DeriveInput) -> TokenStream {
let name = &ast.ident;
let gen = quote! {
impl #name {
pub fn register_with_lua(lua: &mlua::Lua) -> mlua::Result<()> {
lua.globals().set(stringify!(#name), lua.create_proxy::<#name>()?)
}
}
impl mlua::UserData for #name {
fn add_methods<'lua, M: mlua::UserDataMethods<'lua, Self>>(methods: &mut M) {
methods.add_async_function("new", |lua, config: mlua::Value| async {
let config = mlua::FromLua::from_lua(config, lua)?;
// TODO: Using crate:: could cause issues
let device: #name = crate::devices::LuaDeviceCreate::create(config).await.map_err(mlua::ExternalError::into_lua_err)?;
Ok(crate::device_manager::WrappedDevice::new(Box::new(device)))
});
}
}
};
gen
}

View File

@@ -6,8 +6,8 @@ use syn::punctuated::Punctuated;
use syn::spanned::Spanned; use syn::spanned::Spanned;
use syn::token::Paren; use syn::token::Paren;
use syn::{ use syn::{
parenthesized, Data, DataStruct, DeriveInput, Expr, Field, Fields, FieldsNamed, LitStr, Result, Data, DataStruct, DeriveInput, Expr, Field, Fields, FieldsNamed, LitStr, Result, Token, Type,
Token, Type, parenthesized,
}; };
mod kw { mod kw {
@@ -155,7 +155,7 @@ fn field_from_lua(field: &Field) -> TokenStream {
[] => field.ident.clone().unwrap().to_string(), [] => field.ident.clone().unwrap().to_string(),
[rename] => rename.to_owned(), [rename] => rename.to_owned(),
_ => { _ => {
return quote_spanned! {field.span() => compile_error!("Field contains duplicate 'rename'")} return quote_spanned! {field.span() => compile_error!("Field contains duplicate 'rename'")};
} }
}; };
@@ -174,7 +174,7 @@ fn field_from_lua(field: &Field) -> TokenStream {
[] => quote! {panic!(#missing)}, [] => quote! {panic!(#missing)},
[default] => default.to_owned(), [default] => default.to_owned(),
_ => { _ => {
return quote_spanned! {field.span() => compile_error!("Field contains duplicate 'default'")} return quote_spanned! {field.span() => compile_error!("Field contains duplicate 'default'")};
} }
}; };
@@ -232,7 +232,7 @@ fn field_from_lua(field: &Field) -> TokenStream {
[] => value, [] => value,
[value] => value.to_owned(), [value] => value.to_owned(),
_ => { _ => {
return quote_spanned! {field.span() => compile_error!("Only one of either 'from' or 'with' is allowed")} return quote_spanned! {field.span() => compile_error!("Only one of either 'from' or 'with' is allowed")};
} }
}; };
@@ -260,9 +260,10 @@ pub fn impl_lua_device_config_macro(ast: &DeriveInput) -> TokenStream {
}) })
.collect(); .collect();
let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
let impl_from_lua = quote! { let impl_from_lua = quote! {
impl<'lua> mlua::FromLua<'lua> for #name { impl #impl_generics mlua::FromLua for #name #type_generics #where_clause {
fn from_lua(value: mlua::Value<'lua>, lua: &'lua mlua::Lua) -> mlua::Result<Self> { fn from_lua(value: mlua::Value, lua: &mlua::Lua) -> mlua::Result<Self> {
if !value.is_table() { if !value.is_table() {
panic!("Expected table"); panic!("Expected table");
} }

View File

@@ -1,178 +0,0 @@
print("Hello from lua")
local host = automation.util.get_hostname()
print("Running @" .. host)
local debug, value = pcall(automation.util.get_env, "DEBUG")
if debug and value ~= "true" then
debug = false
end
local function mqtt_z2m(topic)
return "zigbee2mqtt/" .. topic
end
local function mqtt_automation(topic)
return "automation/" .. topic
end
automation.fulfillment = {
openid_url = "https://login.huizinga.dev/api/oidc",
}
local mqtt_client = automation.new_mqtt_client({
host = (host == "zeus" and "olympus.lan.huizinga.dev")
or (host == "hephaestus" and "olympus.vpn.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 = "automation_dev/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.146"
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,
},
}))
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.0.255",
}))
local living_mixer = KasaOutlet.new({ identifier = "living_mixer", ip = "10.0.0.49" })
automation.device_manager:add(living_mixer)
local living_speakers = KasaOutlet.new({ identifier = "living_speakers", ip = "10.0.0.182" })
automation.device_manager:add(living_speakers)
automation.device_manager:add(AudioSetup.new({
identifier = "living_audio",
topic = mqtt_z2m("living/remote"),
client = mqtt_client,
mixer = living_mixer,
speakers = living_speakers,
}))
automation.device_manager:add(IkeaOutlet.new({
outlet_type = "Kettle",
name = "Kettle",
room = "Kitchen",
topic = mqtt_z2m("kitchen/kettle"),
client = mqtt_client,
timeout = debug and 5 or 300,
remotes = {
{ topic = mqtt_z2m("bedroom/remote") },
{ topic = mqtt_z2m("kitchen/remote") },
},
}))
automation.device_manager:add(IkeaOutlet.new({
outlet_type = "Light",
name = "Light",
room = "Bathroom",
topic = mqtt_z2m("batchroom/light"),
client = mqtt_client,
timeout = debug and 60 or 45 * 60,
}))
automation.device_manager:add(Washer.new({
identifier = "bathroom_washer",
topic = mqtt_z2m("batchroom/washer"),
client = mqtt_client,
threshold = 1,
event_channel = automation.device_manager:event_channel(),
}))
automation.device_manager:add(IkeaOutlet.new({
outlet_type = "Charger",
name = "Charger",
room = "Workbench",
topic = mqtt_z2m("workbench/charger"),
client = mqtt_client,
timeout = debug and 5 or 20 * 3600,
}))
automation.device_manager:add(IkeaOutlet.new({
name = "Outlet",
room = "Workbench",
topic = mqtt_z2m("workbench/outlet"),
client = mqtt_client,
}))
local hallway_lights = automation.device_manager:add(HueGroup.new({
identifier = "hallway_lights",
ip = hue_ip,
login = hue_token,
group_id = 81,
scene_id = "3qWKxGVadXFFG4o",
timer_id = 1,
remotes = {
{ topic = mqtt_z2m("hallway/remote") },
},
client = mqtt_client,
}))
automation.device_manager:add(ContactSensor.new({
identifier = "hallway_frontdoor",
topic = mqtt_z2m("hallway/frontdoor"),
client = mqtt_client,
presence = {
topic = mqtt_automation("presence/contact/frontdoor"),
timeout = debug and 10 or 15 * 60,
},
trigger = {
devices = { hallway_lights },
timeout = debug and 10 or 2 * 60,
},
}))
local bedroom_air_filter = AirFilter.new({
name = "Air Filter",
room = "Bedroom",
topic = "pico/filter/bedroom",
client = mqtt_client,
})
automation.device_manager:add(bedroom_air_filter)
automation.device_manager:schedule("0/1 * * * * *", function()
print("Device: " .. bedroom_air_filter:get_id())
end)

47
config/battery.lua Normal file
View File

@@ -0,0 +1,47 @@
local ntfy = require("config.ntfy")
--- @class BatteryModule: Module
local module = {}
--- @type {[string]: number}
local low_battery = {}
--- @param device DeviceInterface
--- @param battery number
function module.callback(device, battery)
local id = device:get_id()
if battery < 15 then
print("Device '" .. id .. "' has low battery: " .. tostring(battery))
low_battery[id] = battery
else
low_battery[id] = nil
end
end
local function notify_low_battery()
-- Don't send notifications if there are now devices with low battery
if next(low_battery) == nil then
print("No devices with low battery")
return
end
local lines = {}
for name, battery in pairs(low_battery) do
table.insert(lines, name .. ": " .. tostring(battery) .. "%")
end
local message = table.concat(lines, "\n")
ntfy.send_notification({
title = "Low battery",
message = message,
tags = { "battery" },
priority = "default",
})
end
--- @type Schedule
module.schedule = {
["0 0 21 */1 * *"] = notify_low_battery,
}
return module

32
config/config.lua Normal file
View File

@@ -0,0 +1,32 @@
local utils = require("automation:utils")
local secrets = require("automation:secrets")
local host = utils.get_hostname()
print("Lua " .. _VERSION .. " running on " .. utils.get_hostname())
---@type Config
return {
fulfillment = {
openid_url = "https://login.huizinga.dev/api/oidc",
},
mqtt = {
host = ((host == "zeus" or host == "hephaestus") and "olympus.lan.huizinga.dev") or "mosquitto",
port = 8883,
client_name = "automation-" .. host,
username = "mqtt",
password = secrets.mqtt_password,
tls = host == "zeus" or host == "hephaestus",
},
modules = {
require("config.battery"),
require("config.debug"),
require("config.hallway_automation"),
require("config.helper"),
require("config.hue_bridge"),
require("config.light"),
require("config.ntfy"),
require("config.presence"),
require("config.rooms"),
require("config.windows"),
},
}

35
config/debug.lua Normal file
View File

@@ -0,0 +1,35 @@
local helper = require("config.helper")
local light = require("config.light")
local presence = require("config.presence")
local utils = require("automation:utils")
local variables = require("automation:variables")
--- @class DebugModule: Module
local module = {}
if variables.debug == "true" then
module.debug_mode = true
elseif not variables.debug or variables.debug == "false" then
module.debug_mode = false
else
error("Variable debug has invalid value '" .. variables.debug .. "', expected 'true' or 'false'")
end
--- @type SetupFunction
function module.setup(mqtt_client)
presence.add_callback(function(p)
mqtt_client:send_message(helper.mqtt_automation("debug") .. "/presence", {
state = p,
updated = utils.get_epoch(),
})
end)
light.add_callback(function(l)
mqtt_client:send_message(helper.mqtt_automation("debug") .. "/darkness", {
state = not l,
updated = utils.get_epoch(),
})
end)
end
return module

View File

@@ -0,0 +1,85 @@
local debug = require("config.debug")
local utils = require("automation:utils")
--- @class HallwayAutomationModule: Module
local module = {}
local timeout = utils.Timeout.new()
local forced = false
--- @type OpenCloseInterface?
local trash = nil
--- @type OpenCloseInterface?
local door = nil
--- @type fun(on: boolean)[]
local callbacks = {}
--- @param on boolean
local function callback(on)
for _, f in ipairs(callbacks) do
f(on)
end
end
---@type fun(device: DeviceInterface, on: boolean)
function module.switch_callback(_, on)
timeout:cancel()
callback(on)
forced = on
end
---@type fun(device: DeviceInterface, open: boolean)
function module.door_callback(_, open)
if open then
timeout:cancel()
callback(true)
elseif not forced then
timeout:start(debug.debug_mode and 10 or 2 * 60, function()
if trash == nil or trash:open_percent() == 0 then
callback(false)
end
end)
end
end
---@type fun(device: DeviceInterface, open: boolean)
function module.trash_callback(_, open)
if open then
callback(true)
else
if not forced and not timeout:is_waiting() and (door == nil or door:open_percent() == 0) then
callback(false)
end
end
end
---@type fun(device: DeviceInterface, state: { state: boolean })
function module.light_callback(_, state)
print("LIGHT = " .. tostring(state.state))
if state.state and (trash == nil or trash:open_percent()) == 0 and (door == nil or door:open_percent() == 0) then
-- If the door and trash are not open, that means the light got turned on manually
timeout:cancel()
forced = true
elseif not state.state then
-- The light is never forced when it is off
forced = false
end
end
--- @param t OpenCloseInterface
function module.set_trash(t)
trash = t
end
--- @param d OpenCloseInterface
function module.set_door(d)
door = d
end
--- @param c fun(on: boolean)
function module.add_callback(c)
table.insert(callbacks, c)
end
return module

49
config/helper.lua Normal file
View File

@@ -0,0 +1,49 @@
local utils = require("automation:utils")
--- @class HelperModule: Module
local module = {}
--- @param topic string
--- @return string
function module.mqtt_z2m(topic)
return "zigbee2mqtt/" .. topic
end
--- @param topic string
--- @return string
function module.mqtt_automation(topic)
return "automation/" .. topic
end
--- @return fun(self: OnOffInterface, state: {state: boolean, power: number})
function module.auto_off()
local timeout = utils.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
--- @param duration number
--- @return fun(self: OnOffInterface, state: {state: boolean})
function module.off_timeout(duration)
local timeout = utils.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
return module

41
config/hue_bridge.lua Normal file
View File

@@ -0,0 +1,41 @@
local devices = require("automation:devices")
local light = require("config.light")
local presence = require("config.presence")
local secrets = require("automation:secrets")
--- @class HueBridgeModule: Module
local module = {}
module.ip = "10.0.0.102"
module.token = secrets.hue_token
if module.token == nil then
error("Hue token is not specified")
end
--- @type SetupFunction
function module.setup()
local bridge = devices.HueBridge.new({
identifier = "hue_bridge",
ip = module.ip,
login = module.token,
flags = {
presence = 41,
darkness = 43,
},
})
light.add_callback(function(l)
bridge:set_flag("darkness", not l)
end)
presence.add_callback(function(p)
bridge:set_flag("presence", p)
end)
return {
bridge,
}
end
return module

44
config/light.lua Normal file
View File

@@ -0,0 +1,44 @@
local devices = require("automation:devices")
local helper = require("config.helper")
--- @class LightModule: Module
local module = {}
--- @class OnPresence
--- @field [integer] fun(light: boolean)
local callbacks = {}
--- @param callback fun(light: boolean)
function module.add_callback(callback)
table.insert(callbacks, callback)
end
--- @param _ DeviceInterface
--- @param light boolean
local function callback(_, light)
for _, f in ipairs(callbacks) do
f(light)
end
end
--- @type LightSensor?
module.device = nil
--- @type SetupFunction
function module.setup(mqtt_client)
module.device = devices.LightSensor.new({
identifier = "living_light_sensor",
topic = helper.mqtt_z2m("living/light"),
client = mqtt_client,
min = 22000,
max = 23500,
callback = callback,
})
--- @type Module
return {
module.device,
}
end
return module

34
config/ntfy.lua Normal file
View File

@@ -0,0 +1,34 @@
local devices = require("automation:devices")
local secrets = require("automation:secrets")
--- @class NtfyModule: Module
local module = {}
local ntfy_topic = secrets.ntfy_topic
if ntfy_topic == nil then
error("Ntfy topic is not specified")
end
--- @type Ntfy?
local ntfy = nil
--- @param notification Notification
function module.send_notification(notification)
if ntfy then
ntfy:send_notification(notification)
end
end
--- @type SetupFunction
function module.setup()
ntfy = devices.Ntfy.new({
topic = ntfy_topic,
})
--- @type Module
return {
ntfy,
}
end
return module

80
config/presence.lua Normal file
View File

@@ -0,0 +1,80 @@
local devices = require("automation:devices")
local helper = require("config.helper")
local ntfy = require("config.ntfy")
--- @class PresenceModule: Module
local module = {}
--- @class OnPresence
--- @field [integer] fun(presence: boolean)
local callbacks = {}
--- @param callback fun(presence: boolean)
function module.add_callback(callback)
table.insert(callbacks, callback)
end
--- @param device OnOffInterface
function module.turn_off_when_away(device)
module.add_callback(function(presence)
if not presence then
device:set_on(false)
end
end)
end
--- @param _ DeviceInterface
--- @param presence boolean
local function callback(_, presence)
for _, f in ipairs(callbacks) do
f(presence)
end
end
--- @type Presence?
local presence = nil
--- @type SetupFunction
function module.setup(mqtt_client)
presence = devices.Presence.new({
topic = helper.mqtt_automation("presence/+/#"),
client = mqtt_client,
callback = callback,
})
module.add_callback(function(p)
ntfy.send_notification({
title = "Presence",
message = p and "Home" or "Away",
tags = { "house" },
priority = "low",
actions = {
{
action = "broadcast",
extras = {
cmd = "presence",
state = p and "0" or "1",
},
label = p and "Set away" or "Set home",
clear = true,
},
},
})
end)
--- @type Module
return {
presence,
}
end
function module.overall_presence()
-- Default to no presence when the device has not been created yet
if not presence then
return false
end
return presence:overall_presence()
end
return module

12
config/rooms.lua Normal file
View File

@@ -0,0 +1,12 @@
--- @type Module
return {
require("config.rooms.bathroom"),
require("config.rooms.bedroom"),
require("config.rooms.guest_bedroom"),
require("config.rooms.hallway_bottom"),
require("config.rooms.hallway_top"),
require("config.rooms.kitchen"),
require("config.rooms.living_room"),
require("config.rooms.storage"),
require("config.rooms.workbench"),
}

40
config/rooms/bathroom.lua Normal file
View File

@@ -0,0 +1,40 @@
local debug = require("config.debug")
local devices = require("automation:devices")
local helper = require("config.helper")
local ntfy = require("config.ntfy")
--- @type Module
local module = {}
function module.setup(mqtt_client)
local light = devices.LightOnOff.new({
name = "Light",
room = "Bathroom",
topic = helper.mqtt_z2m("bathroom/light"),
client = mqtt_client,
callback = helper.off_timeout(debug.debug_mode and 60 or 45 * 60),
})
local washer = devices.Washer.new({
identifier = "bathroom_washer",
topic = helper.mqtt_z2m("bathroom/washer"),
client = mqtt_client,
threshold = 1,
done_callback = function()
ntfy.send_notification({
title = "Laundy is done",
message = "Don't forget to hang it!",
tags = { "womans_clothes" },
priority = "high",
})
end,
})
--- @type Module
return {
light,
washer,
}
end
return module

78
config/rooms/bedroom.lua Normal file
View File

@@ -0,0 +1,78 @@
local battery = require("config.battery")
local devices = require("automation:devices")
local helper = require("config.helper")
local hue_bridge = require("config.hue_bridge")
local windows = require("config.windows")
--- @type Module
local module = {}
--- @type AirFilter?
local air_filter = nil
function module.setup(mqtt_client)
local lights = devices.HueGroup.new({
identifier = "bedroom_lights",
ip = hue_bridge.ip,
login = hue_bridge.token,
group_id = 3,
scene_id = "PvRs-lGD4VRytL9",
})
local lights_relax = devices.HueGroup.new({
identifier = "bedroom_lights_relax",
ip = hue_bridge.ip,
login = hue_bridge.token,
group_id = 3,
scene_id = "60tfTyR168v2csz",
})
air_filter = devices.AirFilter.new({
name = "Air Filter",
room = "Bedroom",
url = "http://10.0.0.103",
})
local switch = devices.HueSwitch.new({
name = "Switch",
room = "Bedroom",
client = mqtt_client,
topic = helper.mqtt_z2m("bedroom/switch"),
left_callback = function()
lights:set_on(not lights:on())
end,
left_hold_callback = function()
lights_relax:set_on(true)
end,
battery_callback = battery.callback,
})
local window = devices.ContactSensor.new({
name = "Window",
room = "Bedroom",
topic = helper.mqtt_z2m("bedroom/window"),
client = mqtt_client,
battery_callback = battery.callback,
})
windows.add(window)
--- @type Module
return {
devices = {
lights,
lights_relax,
air_filter,
switch,
window,
},
schedule = {
["0 0 19 * * *"] = function()
air_filter:set_on(true)
end,
["0 0 20 * * *"] = function()
air_filter:set_on(false)
end,
},
}
end
return module

View File

@@ -0,0 +1,35 @@
local battery = require("config.battery")
local devices = require("automation:devices")
local helper = require("config.helper")
local presence = require("config.presence")
local windows = require("config.windows")
--- @type Module
local module = {}
function module.setup(mqtt_client)
local light = devices.LightOnOff.new({
name = "Light",
room = "Guest Room",
topic = helper.mqtt_z2m("guest/light"),
client = mqtt_client,
})
presence.turn_off_when_away(light)
local window = devices.ContactSensor.new({
name = "Window",
room = "Guest Room",
topic = helper.mqtt_z2m("guest/window"),
client = mqtt_client,
battery_callback = battery.callback,
})
windows.add(window)
--- @type Module
return {
light,
window,
}
end
return module

View File

@@ -0,0 +1,110 @@
local battery = require("config.battery")
local debug = require("config.debug")
local devices = require("automation:devices")
local hallway_automation = require("config.hallway_automation")
local helper = require("config.helper")
local hue_bridge = require("config.hue_bridge")
local presence = require("config.presence")
local utils = require("automation:utils")
local windows = require("config.windows")
--- @type Module
local module = {}
function module.setup(mqtt_client)
local main_light = devices.HueGroup.new({
identifier = "hallway_main_light",
ip = hue_bridge.ip,
login = hue_bridge.token,
group_id = 81,
scene_id = "3qWKxGVadXFFG4o",
})
hallway_automation.add_callback(function(on)
main_light:set_on(on)
end)
local storage_light = devices.LightBrightness.new({
name = "Storage",
room = "Hallway",
topic = helper.mqtt_z2m("hallway/storage"),
client = mqtt_client,
callback = hallway_automation.light_callback,
})
presence.turn_off_when_away(storage_light)
hallway_automation.add_callback(function(on)
if on then
storage_light:set_brightness(80)
else
storage_light:set_on(false)
end
end)
local remote = devices.IkeaRemote.new({
name = "Remote",
room = "Hallway",
client = mqtt_client,
topic = helper.mqtt_z2m("hallway/remote"),
callback = hallway_automation.switch_callback,
battery_callback = battery.callback,
})
local trash = devices.ContactSensor.new({
name = "Trash",
room = "Hallway",
sensor_type = "Drawer",
topic = helper.mqtt_z2m("hallway/trash"),
client = mqtt_client,
callback = hallway_automation.trash_callback,
battery_callback = battery.callback,
})
hallway_automation.set_trash(trash)
---@param duration number
---@return fun(_, open: boolean)
local function frontdoor_presence(duration)
local timeout = utils.Timeout.new()
return function(_, open)
if open then
timeout:cancel()
if presence.overall_presence() then
mqtt_client:send_message(helper.mqtt_automation("presence/contact/frontdoor"), {
state = true,
updated = utils.get_epoch(),
})
end
else
timeout:start(duration, function()
mqtt_client:send_message(helper.mqtt_automation("presence/contact/frontdoor"), nil)
end)
end
end
end
local frontdoor = devices.ContactSensor.new({
name = "Frontdoor",
room = "Hallway",
sensor_type = "Door",
topic = helper.mqtt_z2m("hallway/frontdoor"),
client = mqtt_client,
callback = {
frontdoor_presence(debug.debug_mode and 10 or 15 * 60),
hallway_automation.door_callback,
},
battery_callback = battery.callback,
})
windows.add(frontdoor)
hallway_automation.set_door(frontdoor)
--- @type Module
return {
main_light,
storage_light,
remote,
trash,
frontdoor,
}
end
return module

View File

@@ -0,0 +1,48 @@
local battery = require("config.battery")
local devices = require("automation:devices")
local helper = require("config.helper")
local hue_bridge = require("config.hue_bridge")
--- @type Module
local module = {}
function module.setup(mqtt_client)
local light = devices.HueGroup.new({
identifier = "hallway_top_light",
ip = hue_bridge.ip,
login = hue_bridge.token,
group_id = 83,
scene_id = "QeufkFDICEHWeKJ7",
})
local top_switch = devices.HueSwitch.new({
name = "SwitchTop",
room = "Hallway",
client = mqtt_client,
topic = helper.mqtt_z2m("hallway/switchtop"),
left_callback = function()
light:set_on(not light:on())
end,
battery_callback = battery.callback,
})
local bottom_switch = devices.HueSwitch.new({
name = "SwitchBottom",
room = "Hallway",
client = mqtt_client,
topic = helper.mqtt_z2m("hallway/switchbottom"),
left_callback = function()
light:set_on(not light:on())
end,
battery_callback = battery.callback,
})
--- @type Module
return {
light,
top_switch,
bottom_switch,
}
end
return module

71
config/rooms/kitchen.lua Normal file
View File

@@ -0,0 +1,71 @@
local battery = require("config.battery")
local devices = require("automation:devices")
local helper = require("config.helper")
local hue_bridge = require("config.hue_bridge")
local presence = require("config.presence")
--- @class KitchenModule: Module
local module = {}
--- @type HueGroup?
local lights = nil
--- @type SetupFunction
function module.setup(mqtt_client)
lights = devices.HueGroup.new({
identifier = "kitchen_lights",
ip = hue_bridge.ip,
login = hue_bridge.token,
group_id = 7,
scene_id = "7MJLG27RzeRAEVJ",
})
local kettle = devices.OutletPower.new({
outlet_type = "Kettle",
name = "Kettle",
room = "Kitchen",
topic = helper.mqtt_z2m("kitchen/kettle"),
client = mqtt_client,
callback = helper.auto_off(),
})
presence.turn_off_when_away(kettle)
local kettle_remote = devices.IkeaRemote.new({
name = "Remote",
room = "Kitchen",
client = mqtt_client,
topic = helper.mqtt_z2m("kitchen/remote"),
single_button = true,
callback = function(_, on)
kettle:set_on(on)
end,
battery_callback = battery.callback,
})
local kettle_remote_bedroom = devices.IkeaRemote.new({
name = "Remote",
room = "Bedroom",
client = mqtt_client,
topic = helper.mqtt_z2m("bedroom/remote"),
single_button = true,
callback = function(_, on)
kettle:set_on(on)
end,
battery_callback = battery.callback,
})
return {
lights,
kettle,
kettle_remote,
kettle_remote_bedroom,
}
end
function module.toggle_lights()
if lights then
lights:set_on(not lights:on())
end
end
return module

View File

@@ -0,0 +1,126 @@
local battery = require("config.battery")
local devices = require("automation:devices")
local helper = require("config.helper")
local hue_bridge = require("config.hue_bridge")
local presence = require("config.presence")
local windows = require("config.windows")
--- @type Module
local module = {}
function module.setup(mqtt_client)
local lights = devices.HueGroup.new({
identifier = "living_lights",
ip = hue_bridge.ip,
login = hue_bridge.token,
group_id = 1,
scene_id = "SNZw7jUhQ3cXSjkj",
})
local lights_relax = devices.HueGroup.new({
identifier = "living_lights_relax",
ip = hue_bridge.ip,
login = hue_bridge.token,
group_id = 1,
scene_id = "eRJ3fvGHCcb6yNw",
})
local switch = devices.HueSwitch.new({
name = "Switch",
room = "Living",
client = mqtt_client,
topic = helper.mqtt_z2m("living/switch"),
left_callback = require("config.rooms.kitchen").toggle_lights,
right_callback = function()
lights:set_on(not lights:on())
end,
right_hold_callback = function()
lights_relax:set_on(true)
end,
battery_callback = battery.callback,
})
local pc = devices.WakeOnLAN.new({
name = "Zeus",
room = "Living Room",
topic = helper.mqtt_automation("appliance/living_room/zeus"),
client = mqtt_client,
mac_address = "30:9c:23:60:9c:13",
broadcast_ip = "10.0.3.255",
})
local mixer = devices.OutletOnOff.new({
name = "Mixer",
room = "Living Room",
topic = helper.mqtt_z2m("living/mixer"),
client = mqtt_client,
})
presence.turn_off_when_away(mixer)
local speakers = devices.OutletOnOff.new({
name = "Speakers",
room = "Living Room",
topic = helper.mqtt_z2m("living/speakers"),
client = mqtt_client,
})
presence.turn_off_when_away(speakers)
local audio_remote = devices.IkeaRemote.new({
name = "Remote",
room = "Living Room",
client = mqtt_client,
topic = helper.mqtt_z2m("living/remote"),
single_button = true,
callback = function(_, on)
if on then
if mixer:on() then
mixer:set_on(false)
speakers:set_on(false)
else
mixer:set_on(true)
speakers:set_on(true)
end
else
if not mixer:on() then
mixer:set_on(true)
else
speakers:set_on(not speakers:on())
end
end
end,
battery_callback = battery.callback,
})
local balcony = devices.ContactSensor.new({
name = "Balcony",
room = "Living Room",
sensor_type = "Door",
topic = helper.mqtt_z2m("living/balcony"),
client = mqtt_client,
battery_callback = battery.callback,
})
windows.add(balcony)
local window = devices.ContactSensor.new({
name = "Window",
room = "Living Room",
topic = helper.mqtt_z2m("living/window"),
client = mqtt_client,
battery_callback = battery.callback,
})
windows.add(window)
--- @type Module
return {
lights,
lights_relax,
switch,
pc,
mixer,
speakers,
audio_remote,
balcony,
window,
}
end
return module

41
config/rooms/storage.lua Normal file
View File

@@ -0,0 +1,41 @@
local battery = require("config.battery")
local devices = require("automation:devices")
local helper = require("config.helper")
local presence = require("config.presence")
--- @type Module
local module = {}
function module.setup(mqtt_client)
local light = devices.LightBrightness.new({
name = "Light",
room = "Storage",
topic = helper.mqtt_z2m("storage/light"),
client = mqtt_client,
})
presence.turn_off_when_away(light)
local door = devices.ContactSensor.new({
name = "Door",
room = "Storage",
sensor_type = "Door",
topic = helper.mqtt_z2m("storage/door"),
client = mqtt_client,
callback = function(_, open)
if open then
light:set_brightness(100)
else
light:set_on(false)
end
end,
battery_callback = battery.callback,
})
--- @type Module
return {
light,
door,
}
end
return module

View File

@@ -0,0 +1,69 @@
local battery = require("config.battery")
local debug = require("config.debug")
local devices = require("automation:devices")
local helper = require("config.helper")
local presence = require("config.presence")
local utils = require("automation:utils")
--- @type Module
local module = {}
function module.setup(mqtt_client)
local charger = devices.OutletOnOff.new({
name = "Charger",
room = "Workbench",
topic = helper.mqtt_z2m("workbench/charger"),
client = mqtt_client,
callback = helper.off_timeout(debug.debug_mode and 5 or 20 * 3600),
})
local outlets = devices.OutletOnOff.new({
name = "Outlets",
room = "Workbench",
topic = helper.mqtt_z2m("workbench/outlet"),
client = mqtt_client,
})
presence.turn_off_when_away(outlets)
local light = devices.LightColorTemperature.new({
name = "Light",
room = "Workbench",
topic = helper.mqtt_z2m("workbench/light"),
client = mqtt_client,
})
presence.turn_off_when_away(light)
local delay_color_temp = utils.Timeout.new()
local remote = devices.IkeaRemote.new({
name = "Remote",
room = "Workbench",
client = mqtt_client,
topic = helper.mqtt_z2m("workbench/remote"),
callback = function(_, on)
delay_color_temp:cancel()
if on then
light:set_brightness(82)
-- NOTE: This light does NOT support changing both the brightness and color
-- temperature at the same time, so we first change the brightness and once
-- that is complete we change the color temperature, as that is less likely
-- to have to actually change.
delay_color_temp:start(0.5, function()
light:set_color_temperature(3333)
end)
else
light:set_on(false)
end
end,
battery_callback = battery.callback,
})
--- @type Module
return {
charger,
outlets,
light,
remote,
}
end
return module

43
config/windows.lua Normal file
View File

@@ -0,0 +1,43 @@
local ntfy = require("config.ntfy")
local presence = require("config.presence")
--- @class WindowsModule: Module
local module = {}
--- @class OnPresence
--- @field [integer] OpenCloseInterface
local sensors = {}
--- @param sensor OpenCloseInterface
function module.add(sensor)
table.insert(sensors, sensor)
end
--- @type SetupFunction
function module.setup()
presence.add_callback(function(p)
if not p then
local open = {}
for _, sensor in ipairs(sensors) do
if sensor:open_percent() > 0 then
local id = sensor:get_id()
print("Open window detected: " .. id)
table.insert(open, id)
end
end
if #open > 0 then
local message = table.concat(open, "\n")
ntfy.send_notification({
title = "Windows are open",
message = message,
tags = { "window" },
priority = "high",
})
end
end
end)
end
return module

View File

@@ -0,0 +1,337 @@
-- DO NOT MODIFY, FILE IS AUTOMATICALLY GENERATED
---@meta
local devices
---@class Action
---@field action
---| "broadcast"
---@field extras (table<string, string>)?
---@field label string
---@field clear (boolean)?
local Action
---@class AirFilter: DeviceInterface, OnOffInterface
local AirFilter
devices.AirFilter = {}
---@param config AirFilterConfig
---@return AirFilter
function devices.AirFilter.new(config) end
---@class AirFilterConfig
---@field name string
---@field room (string)?
---@field url string
local AirFilterConfig
---@class ConfigLightLightStateBrightness
---@field name string
---@field room (string)?
---@field topic string
---@field callback (fun(_: LightBrightness, _: LightStateBrightness) | fun(_: LightBrightness, _: LightStateBrightness)[])?
---@field client (AsyncClient)?
local ConfigLightLightStateBrightness
---@class ConfigLightLightStateColorTemperature
---@field name string
---@field room (string)?
---@field topic string
---@field callback (fun(_: LightColorTemperature, _: LightStateColorTemperature) | fun(_: LightColorTemperature, _: LightStateColorTemperature)[])?
---@field client (AsyncClient)?
local ConfigLightLightStateColorTemperature
---@class ConfigLightLightStateOnOff
---@field name string
---@field room (string)?
---@field topic string
---@field callback (fun(_: LightOnOff, _: LightStateOnOff) | fun(_: LightOnOff, _: LightStateOnOff)[])?
---@field client (AsyncClient)?
local ConfigLightLightStateOnOff
---@class ConfigOutletOutletStateOnOff
---@field name string
---@field room (string)?
---@field topic string
---@field outlet_type (OutletType)?
---@field callback (fun(_: OutletOnOff, _: OutletStateOnOff) | fun(_: OutletOnOff, _: OutletStateOnOff)[])?
---@field client AsyncClient
local ConfigOutletOutletStateOnOff
---@class ConfigOutletOutletStatePower
---@field name string
---@field room (string)?
---@field topic string
---@field outlet_type (OutletType)?
---@field callback (fun(_: OutletPower, _: OutletStatePower) | fun(_: OutletPower, _: OutletStatePower)[])?
---@field client AsyncClient
local ConfigOutletOutletStatePower
---@class ContactSensor: DeviceInterface, OpenCloseInterface
local ContactSensor
devices.ContactSensor = {}
---@param config ContactSensorConfig
---@return ContactSensor
function devices.ContactSensor.new(config) end
---@class ContactSensorConfig
---@field name string
---@field room (string)?
---@field topic string
---@field sensor_type (SensorType)?
---@field callback (fun(_: ContactSensor, _: boolean) | fun(_: ContactSensor, _: boolean)[])?
---@field battery_callback (fun(_: ContactSensor, _: number) | fun(_: ContactSensor, _: number)[])?
---@field client (AsyncClient)?
local ContactSensorConfig
---@alias Flag
---| "presence"
---| "darkness"
---@class FlagIDs
---@field presence integer
---@field darkness integer
local FlagIDs
---@class HueBridge: DeviceInterface
local HueBridge
devices.HueBridge = {}
---@param config HueBridgeConfig
---@return HueBridge
function devices.HueBridge.new(config) end
---@async
---@param flag Flag
---@param value boolean
function HueBridge:set_flag(flag, value) end
---@class HueBridgeConfig
---@field identifier string
---@field ip string
---@field login string
---@field flags FlagIDs
local HueBridgeConfig
---@class HueGroup: DeviceInterface, OnOffInterface
local HueGroup
devices.HueGroup = {}
---@param config HueGroupConfig
---@return HueGroup
function devices.HueGroup.new(config) end
---@class HueGroupConfig
---@field identifier string
---@field ip string
---@field login string
---@field group_id integer
---@field scene_id string
local HueGroupConfig
---@class HueSwitch: DeviceInterface
local HueSwitch
devices.HueSwitch = {}
---@param config HueSwitchConfig
---@return HueSwitch
function devices.HueSwitch.new(config) end
---@class HueSwitchConfig
---@field name string
---@field room (string)?
---@field topic string
---@field client AsyncClient
---@field left_callback (fun(_: HueSwitch) | fun(_: HueSwitch)[])?
---@field right_callback (fun(_: HueSwitch) | fun(_: HueSwitch)[])?
---@field left_hold_callback (fun(_: HueSwitch) | fun(_: HueSwitch)[])?
---@field right_hold_callback (fun(_: HueSwitch) | fun(_: HueSwitch)[])?
---@field battery_callback (fun(_: HueSwitch, _: number) | fun(_: HueSwitch, _: number)[])?
local HueSwitchConfig
---@class IkeaRemote: DeviceInterface
local IkeaRemote
devices.IkeaRemote = {}
---@param config IkeaRemoteConfig
---@return IkeaRemote
function devices.IkeaRemote.new(config) end
---@class IkeaRemoteConfig
---@field name string
---@field room (string)?
---@field single_button (boolean)?
---@field topic string
---@field client AsyncClient
---@field callback (fun(_: IkeaRemote, _: boolean) | fun(_: IkeaRemote, _: boolean)[])?
---@field battery_callback (fun(_: IkeaRemote, _: number) | fun(_: IkeaRemote, _: number)[])?
local IkeaRemoteConfig
---@class KasaOutlet: DeviceInterface, OnOffInterface
local KasaOutlet
devices.KasaOutlet = {}
---@param config KasaOutletConfig
---@return KasaOutlet
function devices.KasaOutlet.new(config) end
---@class KasaOutletConfig
---@field identifier string
---@field ip string
local KasaOutletConfig
---@class LightBrightness: DeviceInterface, OnOffInterface, BrightnessInterface
local LightBrightness
devices.LightBrightness = {}
---@param config ConfigLightLightStateBrightness
---@return LightBrightness
function devices.LightBrightness.new(config) end
---@class LightColorTemperature: DeviceInterface, OnOffInterface, BrightnessInterface, ColorSettingInterface
local LightColorTemperature
devices.LightColorTemperature = {}
---@param config ConfigLightLightStateColorTemperature
---@return LightColorTemperature
function devices.LightColorTemperature.new(config) end
---@class LightOnOff: DeviceInterface, OnOffInterface
local LightOnOff
devices.LightOnOff = {}
---@param config ConfigLightLightStateOnOff
---@return LightOnOff
function devices.LightOnOff.new(config) end
---@class LightSensor: DeviceInterface
local LightSensor
devices.LightSensor = {}
---@param config LightSensorConfig
---@return LightSensor
function devices.LightSensor.new(config) end
---@class LightSensorConfig
---@field identifier string
---@field topic string
---@field min integer
---@field max integer
---@field callback (fun(_: LightSensor, _: boolean) | fun(_: LightSensor, _: boolean)[])?
---@field client AsyncClient
local LightSensorConfig
---@class LightStateBrightness
---@field state boolean
---@field brightness number
local LightStateBrightness
---@class LightStateColorTemperature
---@field state boolean
---@field brightness number
---@field color_temp integer
local LightStateColorTemperature
---@class LightStateOnOff
---@field state boolean
local LightStateOnOff
---@class Notification
---@field title string
---@field message (string)?
---@field tags ((string)[])?
---@field priority (Priority)?
---@field actions ((Action)[])?
local Notification
---@class Ntfy: DeviceInterface
local Ntfy
devices.Ntfy = {}
---@param config NtfyConfig
---@return Ntfy
function devices.Ntfy.new(config) end
---@async
---@param notification Notification
function Ntfy:send_notification(notification) end
---@class NtfyConfig
---@field url (string)?
---@field topic string
local NtfyConfig
---@class OutletOnOff: DeviceInterface, OnOffInterface
local OutletOnOff
devices.OutletOnOff = {}
---@param config ConfigOutletOutletStateOnOff
---@return OutletOnOff
function devices.OutletOnOff.new(config) end
---@class OutletPower: DeviceInterface, OnOffInterface
local OutletPower
devices.OutletPower = {}
---@param config ConfigOutletOutletStatePower
---@return OutletPower
function devices.OutletPower.new(config) end
---@class OutletStateOnOff
---@field state boolean
local OutletStateOnOff
---@class OutletStatePower
---@field state boolean
---@field power number
local OutletStatePower
---@alias OutletType
---| "Outlet"
---| "Kettle"
---@class Presence: DeviceInterface
local Presence
devices.Presence = {}
---@param config PresenceConfig
---@return Presence
function devices.Presence.new(config) end
---@async
---@return boolean
function Presence:overall_presence() end
---@class PresenceConfig
---@field topic string
---@field callback (fun(_: Presence, _: boolean) | fun(_: Presence, _: boolean)[])?
---@field client AsyncClient
local PresenceConfig
---@alias Priority
---| "min"
---| "low"
---| "default"
---| "high"
---| "max"
---@alias SensorType
---| "Door"
---| "Drawer"
---| "Window"
---@class WakeOnLAN: DeviceInterface
local WakeOnLAN
devices.WakeOnLAN = {}
---@param config WolConfig
---@return WakeOnLAN
function devices.WakeOnLAN.new(config) end
---@class Washer: DeviceInterface
local Washer
devices.Washer = {}
---@param config WasherConfig
---@return Washer
function devices.Washer.new(config) end
---@class WasherConfig
---@field identifier string
---@field topic string
---@field threshold number
---@field done_callback (fun(_: Washer) | fun(_: Washer)[])?
---@field client AsyncClient
local WasherConfig
---@class WolConfig
---@field name string
---@field room (string)?
---@field topic string
---@field mac_address string
---@field broadcast_ip (string)?
---@field client AsyncClient
local WolConfig
return devices

View File

@@ -0,0 +1,6 @@
---@meta
---@type table<string, string?>
local secrets
return secrets

View File

@@ -0,0 +1,27 @@
-- DO NOT MODIFY, FILE IS AUTOMATICALLY GENERATED
---@meta
local utils
---@class Timeout
local Timeout
---@async
---@param timeout number
---@param callback fun() | fun()[]
function Timeout:start(timeout, callback) end
---@async
function Timeout:cancel() end
---@async
---@return boolean
function Timeout:is_waiting() end
utils.Timeout = {}
---@return Timeout
function utils.Timeout.new() end
---@return string
function utils.get_hostname() end
---@return integer
function utils.get_epoch() end
return utils

View File

@@ -0,0 +1,6 @@
---@meta
---@type table<string, string?>
local variables
return variables

41
definitions/config.lua Normal file
View File

@@ -0,0 +1,41 @@
-- DO NOT MODIFY, FILE IS AUTOMATICALLY GENERATED
---@meta
---@class FulfillmentConfig
---@field openid_url string
---@field ip (string)?
---@field port (integer)?
local FulfillmentConfig
---@class Config
---@field fulfillment FulfillmentConfig
---@field modules (Module)[]
---@field mqtt MqttConfig
local Config
---@alias SetupFunction fun(mqtt_client: AsyncClient): Module | DeviceInterface[] | nil
---@alias Schedule table<string, fun() | fun()[]>
---@class Module
---@field setup (SetupFunction)?
---@field devices (DeviceInterface)[]?
---@field schedule Schedule?
---@field [number] (Module)[]?
local Module
---@class MqttConfig
---@field host string
---@field port integer
---@field client_name string
---@field username string
---@field password string
---@field tls (boolean)?
local MqttConfig
---@class AsyncClient
local AsyncClient
---@async
---@param topic string
---@param message table?
function AsyncClient:send_message(topic, message) end

View File

@@ -0,0 +1,42 @@
--- @meta
---@class DeviceInterface
local DeviceInterface
---@return string
function DeviceInterface:get_id() end
---@class OnOffInterface: DeviceInterface
local OnOffInterface
---@async
---@param on boolean
function OnOffInterface:set_on(on) end
---@async
---@return boolean
function OnOffInterface:on() end
---@class BrightnessInterface: DeviceInterface
local BrightnessInterface
---@async
---@param brightness integer
function BrightnessInterface:set_brightness(brightness) end
---@async
---@return integer
function BrightnessInterface:brightness() end
---@class ColorSettingInterface: DeviceInterface
local ColorSettingInterface
---@async
---@param temperature integer
function ColorSettingInterface:set_color_temperature(temperature) end
---@async
---@return integer
function ColorSettingInterface:color_temperature() end
---@class OpenCloseInterface: DeviceInterface
local OpenCloseInterface
---@async
---@param open_percent integer
function OpenCloseInterface:set_open_percent(open_percent) end
---@async
---@return integer
function OpenCloseInterface:open_percent() end

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]
automation_cast = { path = "../automation_cast/" }
serde = { version = "1.0.149", features = ["derive"] }
serde_json = "1.0.89"
thiserror = "1.0.37"
tokio = { version = "1", features = ["sync"] }
async-trait = "0.1.61"
futures = "0.3.25"
anyhow = "1.0.75"

View File

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

View File

@@ -1,174 +0,0 @@
use async_trait::async_trait;
use automation_cast::Cast;
use serde::Serialize;
use crate::errors::{DeviceError, ErrorCode};
use crate::request::execute::CommandType;
use crate::response;
use crate::traits::{FanSpeed, HumiditySetting, OnOff, Scene, Trait};
use crate::types::Type;
#[async_trait]
pub trait GoogleHomeDevice:
Sync + Send + Cast<dyn OnOff> + Cast<dyn Scene> + Cast<dyn FanSpeed> + Cast<dyn HumiditySetting>
{
fn get_device_type(&self) -> Type;
fn get_device_name(&self) -> Name;
fn get_id(&self) -> String;
fn is_online(&self) -> bool;
// Default values that can optionally be overridden
fn will_report_state(&self) -> bool {
false
}
fn get_room_hint(&self) -> Option<&str> {
None
}
fn get_device_info(&self) -> Option<Info> {
None
}
async fn sync(&self) -> response::sync::Device {
let name = self.get_device_name();
let mut device =
response::sync::Device::new(&self.get_id(), &name.name, self.get_device_type());
device.name = name;
device.will_report_state = self.will_report_state();
// notification_supported_by_agent
if let Some(room) = self.get_room_hint() {
device.room_hint = Some(room.into());
}
device.device_info = self.get_device_info();
let mut traits = Vec::new();
// OnOff
if let Some(on_off) = self.cast() as Option<&dyn OnOff> {
traits.push(Trait::OnOff);
device.attributes.command_only_on_off = on_off.is_command_only();
device.attributes.query_only_on_off = on_off.is_query_only();
}
// Scene
if let Some(scene) = self.cast() as Option<&dyn Scene> {
traits.push(Trait::Scene);
device.attributes.scene_reversible = scene.is_scene_reversible();
}
// FanSpeed
if let Some(fan_speed) = self.cast() as Option<&dyn FanSpeed> {
traits.push(Trait::FanSpeed);
device.attributes.command_only_fan_speed = fan_speed.command_only_fan_speed();
device.attributes.available_fan_speeds = Some(fan_speed.available_speeds());
}
if let Some(humidity_setting) = self.cast() as Option<&dyn HumiditySetting> {
traits.push(Trait::HumiditySetting);
device.attributes.query_only_humidity_setting =
humidity_setting.query_only_humidity_setting();
}
device.traits = traits;
device
}
async fn query(&self) -> response::query::Device {
let mut device = response::query::Device::new();
if !self.is_online() {
device.set_offline();
}
// OnOff
if let Some(on_off) = self.cast() as Option<&dyn OnOff> {
device.state.on = on_off
.is_on()
.await
.map_err(|err| device.set_error(err))
.ok();
}
// FanSpeed
if let Some(fan_speed) = self.cast() as Option<&dyn FanSpeed> {
device.state.current_fan_speed_setting = Some(fan_speed.current_speed().await);
}
if let Some(humidity_setting) = self.cast() as Option<&dyn HumiditySetting> {
device.state.humidity_ambient_percent =
Some(humidity_setting.humidity_ambient_percent().await);
}
device
}
async fn execute(&mut self, command: &CommandType) -> Result<(), ErrorCode> {
match command {
CommandType::OnOff { on } => {
if let Some(t) = self.cast_mut() as Option<&mut dyn OnOff> {
t.set_on(*on).await?;
} else {
return Err(DeviceError::ActionNotAvailable.into());
}
}
CommandType::ActivateScene { deactivate } => {
if let Some(t) = self.cast_mut() as Option<&mut dyn Scene> {
t.set_active(!deactivate).await?;
} else {
return Err(DeviceError::ActionNotAvailable.into());
}
}
CommandType::SetFanSpeed { fan_speed } => {
if let Some(t) = self.cast_mut() as Option<&mut dyn FanSpeed> {
t.set_speed(fan_speed).await?;
}
}
}
Ok(())
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Name {
#[serde(skip_serializing_if = "Vec::is_empty")]
default_names: Vec<String>,
name: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
nicknames: Vec<String>,
}
impl Name {
pub fn new(name: &str) -> Self {
Self {
default_names: Vec::new(),
name: name.into(),
nicknames: Vec::new(),
}
}
pub fn add_default_name(&mut self, name: &str) {
self.default_names.push(name.into());
}
pub fn add_nickname(&mut self, name: &str) {
self.nicknames.push(name.into());
}
}
#[derive(Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Info {
#[serde(skip_serializing_if = "Option::is_none")]
pub manufacturer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hw_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sw_version: Option<String>,
// attributes
// customData
// otherDeviceIds
}

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,83 +0,0 @@
use async_trait::async_trait;
use serde::Serialize;
use crate::errors::ErrorCode;
#[derive(Debug, Serialize)]
pub enum Trait {
#[serde(rename = "action.devices.traits.OnOff")]
OnOff,
#[serde(rename = "action.devices.traits.Scene")]
Scene,
#[serde(rename = "action.devices.traits.FanSpeed")]
FanSpeed,
#[serde(rename = "action.devices.traits.HumiditySetting")]
HumiditySetting,
}
#[async_trait]
pub trait OnOff: Sync + Send {
fn is_command_only(&self) -> Option<bool> {
None
}
fn is_query_only(&self) -> Option<bool> {
None
}
// TODO: Implement correct error so we can handle them properly
async fn is_on(&self) -> Result<bool, ErrorCode>;
async fn set_on(&mut self, on: bool) -> Result<(), ErrorCode>;
}
#[async_trait]
pub trait Scene: Sync + Send {
fn is_scene_reversible(&self) -> Option<bool> {
None
}
async fn set_active(&self, activate: bool) -> Result<(), ErrorCode>;
}
#[derive(Debug, Serialize)]
pub struct SpeedValues {
pub speed_synonym: Vec<String>,
pub lang: String,
}
#[derive(Debug, Serialize)]
pub struct Speed {
pub speed_name: String,
pub speed_values: Vec<SpeedValues>,
}
#[derive(Debug, Serialize)]
pub struct AvailableSpeeds {
pub speeds: Vec<Speed>,
pub ordered: bool,
}
#[async_trait]
pub trait FanSpeed: Sync + Send {
fn reversible(&self) -> Option<bool> {
None
}
fn command_only_fan_speed(&self) -> Option<bool> {
None
}
fn available_speeds(&self) -> AvailableSpeeds;
async fn current_speed(&self) -> String;
async fn set_speed(&self, speed: &str) -> Result<(), ErrorCode>;
}
#[async_trait]
pub trait HumiditySetting: Sync + Send {
// TODO: This implementation is not complete, I have only implemented what I need right now
fn query_only_humidity_setting(&self) -> Option<bool> {
None
}
async fn humidity_ambient_percent(&self) -> isize;
}

View File

@@ -0,0 +1,15 @@
[package]
name = "google_home"
version = "0.1.0"
edition = "2024"
[dependencies]
async-trait = { workspace = true }
automation_cast = { workspace = true }
futures = { workspace = true }
google_home_macro = { workspace = true }
json_value_merge = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { 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

@@ -2,14 +2,14 @@ use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use automation_cast::Cast; use automation_cast::Cast;
use futures::future::{join_all, OptionFuture}; use futures::future::{OptionFuture, join_all};
use thiserror::Error; use thiserror::Error;
use tokio::sync::{Mutex, RwLock}; use tokio::sync::Mutex;
use crate::Device;
use crate::errors::{DeviceError, ErrorCode}; use crate::errors::{DeviceError, ErrorCode};
use crate::request::{self, Intent, Request}; use crate::request::{self, Intent, Request};
use crate::response::{self, execute, query, sync, Response, ResponsePayload, State}; use crate::response::{self, Response, ResponsePayload, execute, query, sync};
use crate::GoogleHomeDevice;
#[derive(Debug)] #[derive(Debug)]
pub struct GoogleHome { pub struct GoogleHome {
@@ -30,18 +30,17 @@ impl GoogleHome {
} }
} }
pub async fn handle_request<T: Cast<dyn GoogleHomeDevice> + ?Sized + 'static>( pub async fn handle_request<T: Cast<dyn Device> + ?Sized + 'static>(
&self, &self,
request: Request, request: Request,
devices: &HashMap<String, Arc<RwLock<Box<T>>>>, devices: &HashMap<String, Box<T>>,
) -> Result<Response, FulfillmentError> { ) -> Result<Response, FulfillmentError> {
// TODO: What do we do if we actually get more then one thing in the input array, right now // TODO: What do we do if we actually get more then one thing in the input array, right now
// we only respond to the first thing // we only respond to the first thing
let intent = request.inputs.into_iter().next(); let intent = request.inputs.into_iter().next();
let payload: OptionFuture<_> = intent let payload: OptionFuture<_> = intent
.map(|intent| async move { .map(async |intent| match intent {
match intent {
Intent::Sync => ResponsePayload::Sync(self.sync(devices).await), Intent::Sync => ResponsePayload::Sync(self.sync(devices).await),
Intent::Query(payload) => { Intent::Query(payload) => {
ResponsePayload::Query(self.query(payload, devices).await) ResponsePayload::Query(self.query(payload, devices).await)
@@ -49,7 +48,6 @@ impl GoogleHome {
Intent::Execute(payload) => { Intent::Execute(payload) => {
ResponsePayload::Execute(self.execute(payload, devices).await) ResponsePayload::Execute(self.execute(payload, devices).await)
} }
}
}) })
.into(); .into();
@@ -59,14 +57,14 @@ impl GoogleHome {
.map(|payload| Response::new(&request.request_id, payload)) .map(|payload| Response::new(&request.request_id, payload))
} }
async fn sync<T: Cast<dyn GoogleHomeDevice> + ?Sized + 'static>( async fn sync<T: Cast<dyn Device> + ?Sized + 'static>(
&self, &self,
devices: &HashMap<String, Arc<RwLock<Box<T>>>>, devices: &HashMap<String, Box<T>>,
) -> sync::Payload { ) -> sync::Payload {
let mut resp_payload = sync::Payload::new(&self.user_id); let mut resp_payload = sync::Payload::new(&self.user_id);
let f = devices.iter().map(|(_, device)| async move { let f = devices.values().map(async |device| {
if let Some(device) = device.read().await.as_ref().cast() { if let Some(device) = device.as_ref().cast() {
Some(device.sync().await) Some(Device::sync(device).await)
} else { } else {
None None
} }
@@ -76,22 +74,22 @@ impl GoogleHome {
resp_payload resp_payload
} }
async fn query<T: Cast<dyn GoogleHomeDevice> + ?Sized + 'static>( async fn query<T: Cast<dyn Device> + ?Sized + 'static>(
&self, &self,
payload: request::query::Payload, payload: request::query::Payload,
devices: &HashMap<String, Arc<RwLock<Box<T>>>>, devices: &HashMap<String, Box<T>>,
) -> query::Payload { ) -> query::Payload {
let mut resp_payload = query::Payload::new(); let mut resp_payload = query::Payload::new();
let f = payload let f = payload
.devices .devices
.into_iter() .into_iter()
.map(|device| device.id) .map(|device| device.id)
.map(|id| async move { .map(async |id| {
// NOTE: Requires let_chains feature // NOTE: Requires let_chains feature
let device = if let Some(device) = devices.get(id.as_str()) let device = if let Some(device) = devices.get(id.as_str())
&& let Some(device) = device.read().await.as_ref().cast() && let Some(device) = device.as_ref().cast()
{ {
device.query().await Device::query(device).await
} else { } else {
let mut device = query::Device::new(); let mut device = query::Device::new();
device.set_offline(); device.set_offline();
@@ -108,25 +106,23 @@ impl GoogleHome {
resp_payload resp_payload
} }
async fn execute<T: Cast<dyn GoogleHomeDevice> + ?Sized + 'static>( async fn execute<T: Cast<dyn Device> + ?Sized + 'static>(
&self, &self,
payload: request::execute::Payload, payload: request::execute::Payload,
devices: &HashMap<String, Arc<RwLock<Box<T>>>>, devices: &HashMap<String, Box<T>>,
) -> execute::Payload { ) -> execute::Payload {
let resp_payload = Arc::new(Mutex::new(response::execute::Payload::new())); let resp_payload = Arc::new(Mutex::new(response::execute::Payload::new()));
let f = payload.commands.into_iter().map(|command| { let f = payload.commands.into_iter().map(async |command| {
let resp_payload = resp_payload.clone();
async move {
let mut success = response::execute::Command::new(execute::Status::Success); let mut success = response::execute::Command::new(execute::Status::Success);
success.states = Some(execute::States { success.states = Some(execute::States {
online: true, online: true,
state: State::default(), state: Default::default(),
}); });
let mut offline = response::execute::Command::new(execute::Status::Offline); let mut offline = response::execute::Command::new(execute::Status::Offline);
offline.states = Some(execute::States { offline.states = Some(execute::States {
online: false, online: false,
state: State::default(), state: Default::default(),
}); });
let mut errors: HashMap<ErrorCode, response::execute::Command> = HashMap::new(); let mut errors: HashMap<ErrorCode, response::execute::Command> = HashMap::new();
@@ -134,26 +130,23 @@ impl GoogleHome {
.devices .devices
.into_iter() .into_iter()
.map(|device| device.id) .map(|device| device.id)
.map(|id| { .map(async |id| {
let execution = command.execution.clone();
async move {
if let Some(device) = devices.get(id.as_str()) if let Some(device) = devices.get(id.as_str())
&& let Some(device) = device.write().await.as_mut().cast_mut() && let Some(device) = device.as_ref().cast()
{ {
if !device.is_online() { if !device.is_online().await {
return (id, Ok(false)); return (id, Ok(false));
} }
// NOTE: We can not use .map here because async =( // NOTE: We can not use .map here because async =(
let mut results = Vec::new(); let mut results = Vec::new();
for cmd in &execution { for cmd in &command.execution {
results.push(device.execute(cmd).await); results.push(Device::execute(device, cmd.clone()).await);
} }
// Convert vec of results to a result with a vec and the first // Convert vec of results to a result with a vec and the first
// encountered error // encountered error
let results = let results = results.into_iter().collect::<Result<Vec<_>, ErrorCode>>();
results.into_iter().collect::<Result<Vec<_>, ErrorCode>>();
// TODO: We only get one error not all errors // TODO: We only get one error not all errors
if let Err(err) = results { if let Err(err) = results {
@@ -164,7 +157,6 @@ impl GoogleHome {
} else { } else {
(id.clone(), Err(DeviceError::DeviceNotFound.into())) (id.clone(), Err(DeviceError::DeviceNotFound.into()))
} }
}
}); });
let a = join_all(f).await; let a = join_all(f).await;
@@ -193,7 +185,6 @@ impl GoogleHome {
cmd.error_code = Some(error); cmd.error_code = Some(error);
resp_payload.add_command(cmd); resp_payload.add_command(cmd);
} }
}
}); });
join_all(f).await; join_all(f).await;

View File

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

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,11 +15,13 @@ pub struct Device {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use serde_json::json;
use crate::request::{Intent, Request}; use crate::request::{Intent, Request};
#[test] #[test]
fn deserialize() { fn deserialize() {
let json = r#"{ let req = json!({
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf", "requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
"inputs": [ "inputs": [
{ {
@@ -46,9 +48,9 @@ mod tests {
} }
} }
] ]
}"#; });
let req: Request = serde_json::from_str(json).unwrap(); let req: Request = serde_json::from_value(req).unwrap();
println!("{:?}", req); println!("{:?}", req);

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
use serde::Serialize; use serde::Serialize;
use crate::attributes::Attributes;
use crate::device; use crate::device;
use crate::errors::ErrorCode; use crate::errors::ErrorCode;
use crate::traits::Trait; use crate::traits::Trait;
@@ -47,7 +46,8 @@ pub struct Device {
pub room_hint: Option<String>, pub room_hint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub device_info: Option<device::Info>, pub device_info: Option<device::Info>,
pub attributes: Attributes, #[serde(skip_serializing_if = "serde_json::Value::is_null")]
pub attributes: serde_json::Value,
} }
impl Device { impl Device {
@@ -61,13 +61,15 @@ impl Device {
notification_supported_by_agent: None, notification_supported_by_agent: None,
room_hint: None, room_hint: None,
device_info: None, device_info: None,
attributes: Attributes::default(), attributes: Default::default(),
} }
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use serde_json::json;
use super::*; use super::*;
use crate::response::{Response, ResponsePayload}; use crate::response::{Response, ResponsePayload};
use crate::traits::Trait; use crate::traits::Trait;
@@ -97,10 +99,35 @@ mod tests {
ResponsePayload::Sync(sync_resp), ResponsePayload::Sync(sync_resp),
); );
let json = serde_json::to_string(&resp).unwrap(); let resp = serde_json::to_value(resp).unwrap();
println!("{}", json); let resp_expected = json!({
"requestId": "ff36a3cc-ec34-11e6-b1a0-64510650abcf",
// 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"}}]}}"#) "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,103 @@
#![allow(non_snake_case)]
use automation_cast::Cast;
use google_home_macro::traits;
use serde::{Deserialize, Serialize};
use crate::Device;
use crate::errors::ErrorCode;
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.ColorSetting" => trait ColorSetting {
color_temperature_range: ColorTemperatureRange,
async fn color(&self) -> Color,
"action.devices.commands.ColorAbsolute" => async fn set_color(&self, color: Color) -> 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 TemperatureControl {
query_only_temperature_control: Option<bool>,
// TODO: Add rename
temperatureUnitForUX: TemperatureUnit,
async fn temperature_ambient_celsius(&self) -> Result<f32, ErrorCode>,
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ColorTemperatureRange {
pub temperature_min_k: u32,
pub temperature_max_k: u32,
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Color {
#[serde(rename(serialize = "temperatureK"))]
pub temperature: u32,
}
#[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,
}

Some files were not shown because too many files have changed in this diff Show More