From 3e129bd3d6972330e8e3932e38b2e404204b737e Mon Sep 17 00:00:00 2001 From: Moritz Ruth Date: Tue, 5 Mar 2024 23:35:51 +0100 Subject: [PATCH] commit --- Cargo.lock | 618 +++++++++++++++++- crates/deckster_mode/Cargo.toml | 1 - crates/deckster_mode/src/lib.rs | 54 +- .../src/handler_communication.rs | 4 +- examples/full/deckster.toml | 5 + examples/full/key-pages/default.toml | 7 +- handlers/home_assistant/Cargo.toml | 20 + handlers/home_assistant/src/config.rs | 51 ++ handlers/home_assistant/src/ha_client.rs | 289 ++++++++ handlers/home_assistant/src/handler.rs | 116 ++++ handlers/home_assistant/src/main.rs | 29 + handlers/home_assistant/src/util.rs | 54 ++ handlers/pa_volume/src/main.rs | 1 + handlers/playerctl/src/main.rs | 1 + src/coordinator/mod.rs | 1 + src/handler_host/mod.rs | 1 + src/handler_runner.rs | 27 +- src/main.rs | 1 + src/model/coordinator_config.rs | 3 + src/model/handler_host_config.rs | 3 + 20 files changed, 1245 insertions(+), 41 deletions(-) create mode 100644 handlers/home_assistant/Cargo.toml create mode 100644 handlers/home_assistant/src/config.rs create mode 100644 handlers/home_assistant/src/ha_client.rs create mode 100644 handlers/home_assistant/src/handler.rs create mode 100644 handlers/home_assistant/src/main.rs create mode 100644 handlers/home_assistant/src/util.rs diff --git a/Cargo.lock b/Cargo.lock index 9756f22..5a5e5b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,6 +149,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.14.0" @@ -161,6 +170,12 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "374d28ec25809ee0e23827c2ab573d729e293f281dfe393500e7ad618baa61c6" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.5.0" @@ -317,6 +332,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpufeatures" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.3.2" @@ -326,6 +350,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.20.3" @@ -361,6 +395,12 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "data-encoding" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" + [[package]] name = "data-url" version = "0.3.1" @@ -410,7 +450,6 @@ name = "deckster_mode" version = "0.1.0" dependencies = [ "deckster_shared", - "either", "im", "serde", "serde_json", @@ -454,6 +493,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "either" version = "1.9.0" @@ -466,6 +515,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" +dependencies = [ + "cfg-if", +] + [[package]] name = "enum-map" version = "3.0.0-beta.2" @@ -556,6 +614,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "errno" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "eyre" version = "0.6.11" @@ -566,6 +634,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fastrand" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" + [[package]] name = "fdeflate" version = "0.3.3" @@ -646,12 +720,56 @@ dependencies = [ "ttf-parser 0.20.0", ] +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + [[package]] name = "futures-sink" version = "0.3.30" @@ -671,12 +789,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ "futures-core", + "futures-macro", + "futures-sink", "futures-task", "pin-project-lite", "pin-utils", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.11" @@ -706,6 +836,25 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "h2" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.1.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -736,6 +885,71 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "home_assistant" +version = "0.1.0" +dependencies = [ + "clap", + "color-eyre", + "deckster_mode", + "env_logger", + "futures-util", + "log", + "native-tls", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "url", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "humantime" version = "2.1.0" @@ -752,6 +966,43 @@ dependencies = [ "serde", ] +[[package]] +name = "hyper" +version = "0.14.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "iana-time-zone" version = "0.1.59" @@ -781,6 +1032,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "im" version = "15.1.0" @@ -839,6 +1100,12 @@ dependencies = [ "mach2", ] +[[package]] +name = "ipnet" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" + [[package]] name = "is_executable" version = "1.0.1" @@ -951,6 +1218,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" + [[package]] name = "lock_api" version = "0.4.11" @@ -1014,6 +1287,12 @@ dependencies = [ "libc", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "miniz_oxide" version = "0.7.1" @@ -1053,6 +1332,24 @@ dependencies = [ "getrandom", ] +[[package]] +name = "native-tls" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nix" version = "0.26.4" @@ -1109,12 +1406,50 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "openssl" +version = "0.10.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" +dependencies = [ + "bitflags 2.4.1", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + [[package]] name = "openssl-probe" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +[[package]] +name = "openssl-sys" +version = "0.9.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dda2b0f344e78efc2facf7d195d098df0dd72151b26ab98da807afc26c198dff" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "owo-colors" version = "3.5.0" @@ -1222,6 +1557,12 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + [[package]] name = "pico-args" version = "0.5.0" @@ -1398,6 +1739,46 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" +[[package]] +name = "reqwest" +version = "0.11.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6920094eb85afde5e4a138be3f2de8bbdf28000f0029e72c45025a56b042251" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "resvg" version = "0.37.0" @@ -1492,6 +1873,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72e572a5e8ca657d7366229cdde4bd14c4eb5499a9573d4d366fe1b599daa316" +dependencies = [ + "bitflags 2.4.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "rustls" version = "0.21.10" @@ -1665,9 +2059,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.113" +version = "1.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79" +checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" dependencies = [ "itoa", "ryu", @@ -1693,6 +2087,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.4.0" @@ -1741,6 +2147,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1936,6 +2353,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sys-locale" version = "0.3.1" @@ -1945,6 +2368,40 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01ce4141aa927a6d1bd34a041795abd0db1cccba5d5f24b009f694bdf3a1f3fa" +dependencies = [ + "cfg-if", + "fastrand", + "redox_syscall", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "thiserror" version = "1.0.56" @@ -2089,6 +2546,16 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.24.1" @@ -2099,6 +2566,45 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + [[package]] name = "toml" version = "0.8.8" @@ -2133,6 +2639,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + [[package]] name = "tracing" version = "0.1.40" @@ -2174,6 +2686,12 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.19.2" @@ -2186,6 +2704,26 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.1.0", + "httparse", + "log", + "native-tls", + "rand", + "sha1", + "thiserror", + "url", + "utf-8", +] + [[package]] name = "typenum" version = "1.17.0" @@ -2231,6 +2769,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-properties" version = "0.1.0" @@ -2261,6 +2808,18 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "url" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "usvg" version = "0.37.0" @@ -2322,6 +2881,12 @@ dependencies = [ "tiny-skia-path", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8parse" version = "0.2.1" @@ -2334,6 +2899,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.4" @@ -2350,6 +2921,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -2381,6 +2961,18 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac36a15a220124ac510204aec1c3e5db8a22ab06fd6706d881dc6149f8ed9a12" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.89" @@ -2410,6 +3002,16 @@ version = "0.2.89" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab9b36309365056cd639da3134bf87fa8f3d86008abf99e612384a6eecd459f" +[[package]] +name = "web-sys" +version = "0.3.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c24a44ec86bb68fbecd1b3efed7e85ea5621b39b35ef2766b66cd984f8010f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "weezl" version = "0.1.7" @@ -2597,6 +3199,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "xmlparser" version = "0.13.6" diff --git a/crates/deckster_mode/Cargo.toml b/crates/deckster_mode/Cargo.toml index 821d97a..9eda772 100644 --- a/crates/deckster_mode/Cargo.toml +++ b/crates/deckster_mode/Cargo.toml @@ -7,6 +7,5 @@ edition = "2021" deckster_shared = { path = "../deckster_shared" } thiserror = "1.0.56" im = "15.1.0" -either = "1.9.0" serde = { version = "1.0.196", default-features = false } serde_json = "1.0.113" diff --git a/crates/deckster_mode/src/lib.rs b/crates/deckster_mode/src/lib.rs index fa82932..5f9c77b 100644 --- a/crates/deckster_mode/src/lib.rs +++ b/crates/deckster_mode/src/lib.rs @@ -2,7 +2,6 @@ use std::any::TypeId; use std::io; use std::io::BufRead; -use either::Either; use serde::de::DeserializeOwned; use thiserror::Error; @@ -24,41 +23,34 @@ pub trait DecksterHandler { fn handle(&mut self, event: HandlerEvent); } +enum Stage { + Initialization(I), + Active(H), +} + pub fn run< + GlobalConfig: Clone + DeserializeOwned + 'static, KeyConfig: Clone + DeserializeOwned + 'static, KnobConfig: Clone + DeserializeOwned + 'static, H: DecksterHandler, - I: FnOnce(InitialHandlerMessage) -> Result, + I: FnOnce(InitialHandlerMessage) -> Result, >( init_handler: I, ) -> Result<(), RunError> { - let mut handler: Either = Either::Right(init_handler); + let mut stage: Stage = Stage::Initialization(init_handler); + let requires_global_config = TypeId::of::() != TypeId::of::<()>(); let supports_keys = TypeId::of::() != TypeId::of::<()>(); let supports_knobs = TypeId::of::() != TypeId::of::<()>(); let handle = io::stdin().lock(); for line in handle.lines() { - let line = line?; + let line = line.map_err(RunError::LineIo)?; - match handler { - Either::Left(mut h) => { - let event: HandlerEvent = serde_json::from_str(&line).map_err(|e| RunError::LineDeserialization { - line, - description: e.to_string(), - })?; - - let should_stop = matches!(event, HandlerEvent::Stop); - - h.handle(event); - handler = Either::Left(h); - - if should_stop { - break; - } - } - Either::Right(init_handler) => { - let initial_message = serde_json::from_str::>(&line); + match stage { + Stage::Initialization(init_handler) => { + // TODO: Serialize the global config and each key/knob config separately for more specific error messages. + let initial_message = serde_json::from_str::>(&line); match initial_message { Ok(initial_message) => match init_handler(initial_message) { @@ -67,7 +59,7 @@ pub fn run< "{}", serde_json::to_string(&HandlerInitializationResultMessage::Ready).expect("serialization of a known value always works") ); - handler = Either::Left(h) + stage = Stage::Active(h) } Err(error) => { println!( @@ -82,6 +74,7 @@ pub fn run< "{}", serde_json::to_string(&HandlerInitializationResultMessage::Error { error: HandlerInitializationError::InvalidConfig { + requires_global_config, supports_keys, supports_knobs, message: err.to_string().into_boxed_str(), @@ -93,6 +86,21 @@ pub fn run< } } } + Stage::Active(mut h) => { + let event: HandlerEvent = serde_json::from_str(&line).map_err(|e| RunError::LineDeserialization { + line, + description: e.to_string(), + })?; + + let should_stop = matches!(event, HandlerEvent::Stop); + + h.handle(event); + stage = Stage::Active(h); + + if should_stop { + break; + } + } } } diff --git a/crates/deckster_shared/src/handler_communication.rs b/crates/deckster_shared/src/handler_communication.rs index 360e43d..820115e 100644 --- a/crates/deckster_shared/src/handler_communication.rs +++ b/crates/deckster_shared/src/handler_communication.rs @@ -90,7 +90,8 @@ pub enum HandlerCommand { } #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] -pub struct InitialHandlerMessage { +pub struct InitialHandlerMessage { + pub global_config: GlobalConfig, pub key_configs: HashMap, pub knob_configs: HashMap, } @@ -106,6 +107,7 @@ pub enum HandlerInitializationResultMessage { pub enum HandlerInitializationError { #[error("The provided handler config is invalid: {message}")] InvalidConfig { + requires_global_config: bool, supports_keys: bool, supports_knobs: bool, message: Box, diff --git a/examples/full/deckster.toml b/examples/full/deckster.toml index ea7da91..8161da7 100644 --- a/examples/full/deckster.toml +++ b/examples/full/deckster.toml @@ -3,6 +3,11 @@ active_button_color = "#eeffff" label_font_family = "Inter" buttons = {} +[handlers.home_assistant] +base_url = "https://ha.mosv.de/" +token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI5YjBjNWRjMjY3ODA0YzI4YjI3Y2VkMGFiZjVkYzQ5ZCIsImlhdCI6MTcwOTY1MDk1NSwiZXhwIjoyMDI1MDEwOTU1fQ.YxYgbnZnT5d0hwsxqqnqgb8IrtBRlCoCked7VqFR0wM" +accept_invalid_certs = true + [initial] key_page = "default" knob_page = "default" diff --git a/examples/full/key-pages/default.toml b/examples/full/key-pages/default.toml index 069cb3b..e4e3cdd 100644 --- a/examples/full/key-pages/default.toml +++ b/examples/full/key-pages/default.toml @@ -53,8 +53,7 @@ config.style.alarm2.label = "00:00" icon = "@ph/computer-tower" label = "Gaming PC" -host = "moira" -handler = "home-assistant" -config.mode = "switch" -config.name = "switch.mwin" +handler = "home_assistant" +config.mode = "toggle" +config.entity_id = "light.moritz_zimmer_stehlampe" config.style.on.icon = "@ph/computer-tower[color=#58fc11]" \ No newline at end of file diff --git a/handlers/home_assistant/Cargo.toml b/handlers/home_assistant/Cargo.toml new file mode 100644 index 0000000..2a1693b --- /dev/null +++ b/handlers/home_assistant/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "home_assistant" +version = "0.1.0" +edition = "2021" + +[dependencies] +deckster_mode = { path = "../../crates/deckster_mode" } +clap = { version = "4.4.18", features = ["derive"] } +color-eyre = "0.6.2" +env_logger = "0.11.1" +log = "0.4.20" +tokio = { version = "1.35.1", features = ["macros", "parking_lot", "rt", "sync"] } +serde = { version = "1.0.196", features = ["derive"] } +serde_json = "1.0.114" +reqwest = "0.11.24" +url = { version = "2.5.0", features = ["serde"] } +tokio-tungstenite = { version = "0.21.0", features = ["native-tls"] } +tokio-stream = "0.1.14" +futures-util = "0.3.30" +native-tls = "0.2.11" \ No newline at end of file diff --git a/handlers/home_assistant/src/config.rs b/handlers/home_assistant/src/config.rs new file mode 100644 index 0000000..865fdba --- /dev/null +++ b/handlers/home_assistant/src/config.rs @@ -0,0 +1,51 @@ +use deckster_mode::shared::state::KeyStyleByStateMap; +use serde::Deserialize; +use url::Url; + +#[derive(Debug, Clone, Deserialize)] +pub struct GlobalConfig { + pub base_url: Url, + pub token: Box, + #[serde(default)] + pub accept_invalid_certs: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct KeyConfig { + pub disconnected_state: Option>, + #[serde(flatten)] + pub mode: KeyMode, + pub style: KeyStyleByStateMap>, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "mode", rename_all = "kebab-case")] +pub enum KeyMode { + Toggle { entity_id: Box }, + Button { state_entity_id: Box, button_entity_id: Box }, +} + +impl KeyMode { + pub fn state_entity_id(&self) -> &Box { + match &self { + KeyMode::Toggle { entity_id, .. } => entity_id, + KeyMode::Button { state_entity_id, .. } => state_entity_id, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct KnobConfig { + pub(crate) entity_id: Box, + pub disconnected_state: Option>, + #[serde(flatten)] + pub mode: KnobMode, + pub style: KeyStyleByStateMap>, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "mode", rename_all = "kebab-case")] +pub enum KnobMode { + Select { states: Box<[Box]>, wrap_around: bool }, + Range, +} diff --git a/handlers/home_assistant/src/ha_client.rs b/handlers/home_assistant/src/ha_client.rs new file mode 100644 index 0000000..1dab348 --- /dev/null +++ b/handlers/home_assistant/src/ha_client.rs @@ -0,0 +1,289 @@ +use futures_util::SinkExt; +use native_tls::TlsConnector; +use reqwest::header::{HeaderMap, HeaderValue}; +use serde::{Deserialize, Serialize}; +use std::cmp::min; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{broadcast, RwLock}; +use tokio_stream::StreamExt; +use tokio_tungstenite::{tungstenite, Connector}; +use url::Url; + +#[derive(Debug, Clone)] +pub enum StateUpdate { + Disconnected, + Actual(Arc), +} + +#[derive(Debug)] +pub struct ActualStateUpdate { + pub entity_id: Box, + pub state: Box, + pub timestamp: Box, +} + +#[derive(Debug, Clone)] +pub struct HaClient { + state_updates_sender: broadcast::Sender, + http_client: reqwest::Client, + base_url: Url, +} + +impl HaClient { + pub async fn new(base_url: Url, token: Box, accept_invalid_certs: bool, subscribed_entity_ids: Vec>) -> Self { + let http_client = reqwest::ClientBuilder::new() + .connect_timeout(Duration::from_secs(10)) + .default_headers({ + let mut map = HeaderMap::new(); + map.insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {token}")).expect("the token generated by Home Assistant only contains valid characters"), + ); + map + }) + .danger_accept_invalid_certs(accept_invalid_certs) + .user_agent(format!("home_assistant deckster handler (v{})", env!("CARGO_PKG_VERSION"))) + .build() + .unwrap(); // The HTTP client being available is essential. + + let state_updates_sender = broadcast::Sender::::new(min(subscribed_entity_ids.len(), 16)); + let state_timestamp_by_entity_id = subscribed_entity_ids.iter().map(|i| (i.clone(), "".to_owned().into_boxed_str())).collect(); + + let tls_connector = TlsConnector::builder().danger_accept_invalid_certs(accept_invalid_certs).build().unwrap(); + + tokio::spawn(do_work( + base_url.clone(), + token, + tls_connector, + state_updates_sender.clone(), + http_client.clone(), + state_timestamp_by_entity_id, + )); + + if log::max_level() <= log::Level::Debug { + let mut updates = state_updates_sender.subscribe(); + tokio::spawn(async move { + while let Ok(u) = updates.recv().await { + log::debug!("State update: {u:?}") + } + }); + } + + HaClient { + state_updates_sender, + http_client, + base_url, + } + } + + pub fn subscribe_to_state_updates(&self) -> broadcast::Receiver { + self.state_updates_sender.subscribe() + } + + pub async fn toggle_entity(&self, entity_id: &str) { + let (domain, _) = entity_id.split_once('.').expect("entity IDs must contain exactly one dot"); + + let result = self + .http_client + .post(self.base_url.join(&format!("/api/services/{domain}/toggle")).unwrap()) + .body(format!("{{\"entity_id\":\"{entity_id}\"}}")) + .send() + .await + .and_then(|a| a.error_for_status()); + + if let Err(error) = result { + log::error!( + "POST request to {} failed: {error}", + error.url().map(|u| u.to_string()).unwrap_or("?".to_owned()) + ) + } + } +} + +async fn do_work( + base_url: Url, + token: Box, + tls_connector: TlsConnector, + state_updates_sender: broadcast::Sender, + http_client: reqwest::Client, + state_timestamp_by_entity_id: HashMap, Box>, +) { + let states_url = base_url.join("/api/states/").unwrap(); + let websocket_url = { + let mut u = base_url.clone(); + u.set_scheme(&u.scheme().replace("http", "ws")).unwrap(); + u.set_path("api/websocket"); + u.to_string() + }; + + let mut is_first_connection_attempt = true; + let state_timestamp_by_entity_id = Arc::new(RwLock::new(state_timestamp_by_entity_id)); + + loop { + let connection_result = + tokio_tungstenite::connect_async_tls_with_config(&websocket_url, None, false, Some(Connector::NativeTls(tls_connector.clone()))).await; + + match connection_result { + Err(tungstenite::Error::Io(error)) => { + if is_first_connection_attempt { + log::warn!("Establishing a WebSocket connection failed: {error}"); + log::info!("Retrying every 5 seconds…") + } + + is_first_connection_attempt = false; + tokio::time::sleep(Duration::from_secs(5)).await; + } + Err(error) => panic!("WebSocket error: {}", error), + Ok((mut socket, _)) => { + log::info!("WebSocket connection successfully established."); + + while let Some(event) = socket.next().await { + match event { + Err(error) => { + log::error!("The WebSocket connection failed: {error}"); + break; + } + Ok(message) => match message { + tungstenite::Message::Ping(data) => socket.send(tungstenite::Message::Pong(data)).await.unwrap(), + tungstenite::Message::Text(data) => { + log::trace!("Received WebSocket message: {data}"); + + match serde_json::from_str::(&data) { + Err(error) => log::error!("Deserializing WebSocket message failed: {error}"), + Ok(message) => match message { + HaIncomingWsMessage::AuthRequired { .. } => socket + .send(tungstenite::Message::Text( + serde_json::to_string(&HaOutgoingWsMessage::Auth { access_token: token.clone() }).unwrap(), + )) + .await + .unwrap(), + HaIncomingWsMessage::AuthInvalid { .. } => panic!("Invalid access token."), + HaIncomingWsMessage::AuthOk { .. } => { + let subscription_message = serde_json::to_string_pretty(&HaOutgoingWsMessage::SubscribeTrigger { + // ID may not be zero (that one took me a while) + id: 1, + trigger: HaTrigger::State { + entity_id: state_timestamp_by_entity_id.read().await.keys().cloned().collect(), + // Setting from to null prevents events being sent when only attributes have changed. + from: serde_json::Value::Null, + }, + }) + .unwrap(); + + socket.send(tungstenite::Message::Text(subscription_message)).await.unwrap(); + } + HaIncomingWsMessage::Result { id, success } => { + if !success { + panic!("A command ({id}) failed."); + } + + for entity_id in state_timestamp_by_entity_id.read().await.keys() { + tokio::spawn(request_entity_state( + states_url.join(entity_id).unwrap(), + http_client.clone(), + Arc::clone(&state_timestamp_by_entity_id), + state_updates_sender.clone(), + )); + } + } + HaIncomingWsMessage::Event { event, .. } => match extract_state_update_from_event(&event) { + None => log::error!("Invalid state change event message: {data}"), + Some(update) => { + // LOCK START + let mut state_timestamp_by_entity_id = state_timestamp_by_entity_id.write().await; + + match state_timestamp_by_entity_id.get(&update.entity_id) { + None => log::warn!("Received unwanted state change event for entity '{}'", update.entity_id), + Some(last_timestamp) => { + if last_timestamp < &update.timestamp { + state_timestamp_by_entity_id.insert(update.entity_id.clone(), update.timestamp.clone()); + state_updates_sender.send(StateUpdate::Actual(Arc::new(update))).unwrap(); + } + } + } + // LOCK END + } + }, + }, + } + } + _ => log::error!("Received unsupported WebSocket message: {message:?}"), + }, + } + } + } + }; + } +} + +async fn request_entity_state( + url: Url, + http_client: reqwest::Client, + state_timestamp_by_entity_id: Arc, Box>>>, + state_updates_sender: broadcast::Sender, +) { + match http_client.get(url).send().await.and_then(|a| a.error_for_status()) { + Err(error) => log::error!( + "A GET request to {} failed: {error}", + error.url().map(|u| u.to_string()).unwrap_or("?".to_owned()) + ), + Ok(response) => match serde_json::from_str(&response.text().await.unwrap()) { + Ok(object) => match extract_state_update_from_state(&object) { + None => log::error!("Invalid entity state object: {object}"), + Some(update) => { + // LOCK START + let mut state_timestamp_by_entity_id = state_timestamp_by_entity_id.write().await; + let last_timestamp = state_timestamp_by_entity_id + .get(&update.entity_id) + .expect("Home Assistant responds with the state of the requested entity."); + + if last_timestamp < &update.timestamp { + state_timestamp_by_entity_id.insert(update.entity_id.clone(), update.timestamp.clone()); + state_updates_sender.send(StateUpdate::Actual(Arc::new(update))).unwrap(); + } + // LOCK END + } + }, + Err(error) => { + log::error!("Failed to deserialize state object: {error}"); + } + }, + } +} + +fn extract_state_update_from_event(object: &serde_json::Value) -> Option { + extract_state_update_from_state(object.get("variables")?.get("trigger")?.get("to_state")?) +} + +fn extract_state_update_from_state(object: &serde_json::Value) -> Option { + Some(ActualStateUpdate { + state: object.get("state")?.as_str()?.to_owned().into_boxed_str(), + entity_id: object.get("entity_id")?.as_str()?.to_owned().into_boxed_str(), + timestamp: object.get("last_changed")?.as_str()?.to_owned().into_boxed_str(), + }) +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum HaIncomingWsMessage { + AuthRequired { ha_version: Box }, + AuthOk { ha_version: Box }, + AuthInvalid { message: Box }, + Result { id: usize, success: bool }, + Event { id: usize, event: serde_json::Value }, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum HaOutgoingWsMessage { + Auth { access_token: Box }, + SubscribeTrigger { id: usize, trigger: HaTrigger }, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "platform", rename_all = "snake_case")] +pub enum HaTrigger { + State { entity_id: Box<[Box]>, from: serde_json::Value }, +} diff --git a/handlers/home_assistant/src/handler.rs b/handlers/home_assistant/src/handler.rs new file mode 100644 index 0000000..c018773 --- /dev/null +++ b/handlers/home_assistant/src/handler.rs @@ -0,0 +1,116 @@ +use crate::config::{GlobalConfig, KeyConfig, KeyMode, KnobConfig, KnobMode}; +use crate::ha_client::{HaClient, StateUpdate}; +use deckster_mode::shared::handler_communication::{HandlerCommand, HandlerEvent, HandlerInitializationError, InitialHandlerMessage, KeyEvent}; +use deckster_mode::shared::path::KeyPath; +use deckster_mode::{send_command, DecksterHandler}; +use std::thread; +use tokio::select; +use tokio::sync::broadcast; +use tokio::task::LocalSet; + +pub struct Handler { + events_sender: broadcast::Sender, +} + +impl Handler { + pub fn new(data: InitialHandlerMessage) -> Result { + let events_sender = broadcast::Sender::::new(5); + let mut subscribed_entity_ids = Vec::new(); + + for c in data.key_configs.values() { + subscribed_entity_ids.push(c.mode.state_entity_id().clone()) + } + + for c in data.knob_configs.values() { + subscribed_entity_ids.push(c.entity_id.clone()) + } + + thread::spawn({ + let events_sender = events_sender.clone(); + + move || { + let runtime = tokio::runtime::Builder::new_current_thread().enable_time().enable_io().build().unwrap(); + let task_set = LocalSet::new(); + + let ha_client = task_set.block_on( + &runtime, + HaClient::new( + data.global_config.base_url, + data.global_config.token, + data.global_config.accept_invalid_certs, + subscribed_entity_ids, + ), + ); + + for (path, config) in data.key_configs { + task_set.spawn_local(manage_key(events_sender.subscribe(), ha_client.clone(), path, config)); + } + + runtime.block_on(task_set) + } + }); + + Ok(Handler { events_sender }) + } +} + +impl DecksterHandler for Handler { + fn handle(&mut self, event: HandlerEvent) { + // No receivers being available can be ignored. + _ = self.events_sender.send(event); + } +} + +async fn manage_key(mut events: broadcast::Receiver, ha_client: HaClient, path: KeyPath, config: KeyConfig) { + let state_entity_id = config.mode.state_entity_id(); + + if let Some(state) = &config.disconnected_state { + send_command(HandlerCommand::SetKeyStyle { + path: path.clone(), + value: config.style.get(state).cloned(), + }) + } + + let mut state_updates = ha_client.subscribe_to_state_updates(); + + loop { + select! { + Ok(update) = state_updates.recv() => { + match update { + StateUpdate::Disconnected => { + if let Some(state) = &config.disconnected_state { + send_command(HandlerCommand::SetKeyStyle { + path: path.clone(), + value: config.style.get(state).cloned() + }) + } + } + StateUpdate::Actual(update) => { + if &update.entity_id == state_entity_id { + send_command(HandlerCommand::SetKeyStyle { + path: path.clone(), + value: config.style.get(&update.state).cloned() + }) + } + } + } + } + Ok(HandlerEvent::Key { path: p, event }) = events.recv() => { + if p != path { + continue + } + + if let KeyEvent::Press = event { + match &config.mode { + KeyMode::Toggle { entity_id } => { + ha_client.toggle_entity(entity_id).await; + } + KeyMode::Button { .. } => { + todo!() + } + } + } + } + } + } +} diff --git a/handlers/home_assistant/src/main.rs b/handlers/home_assistant/src/main.rs new file mode 100644 index 0000000..7c86e09 --- /dev/null +++ b/handlers/home_assistant/src/main.rs @@ -0,0 +1,29 @@ +use clap::Parser; +use color_eyre::Result; + +use crate::handler::Handler; + +mod config; +mod ha_client; +mod handler; +mod util; + +#[derive(Debug, Parser)] +#[command(name = "home_assistant")] +enum CliCommand { + #[command(name = "deckster-run", hide = true)] + Run, +} + +fn main() -> Result<()> { + env_logger::init(); + let command = CliCommand::parse(); + + match command { + CliCommand::Run => { + deckster_mode::run(Handler::new)?; + } + } + + Ok(()) +} diff --git a/handlers/home_assistant/src/util.rs b/handlers/home_assistant/src/util.rs new file mode 100644 index 0000000..a94361d --- /dev/null +++ b/handlers/home_assistant/src/util.rs @@ -0,0 +1,54 @@ +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; +use tokio::sync::mpsc::error::TrySendError; +use tokio::sync::mpsc::{Receiver, Sender}; +use tokio::time::timeout; + +/// Sends a message into the output channel after a message in the input channel was received, with a delay of `duration`. +/// The delay is reset when a new message is reset. +pub fn spawn_debouncer(duration: Duration) -> (Sender<()>, Receiver<()>) { + let (input_sender, mut input_receiver) = mpsc::channel::<()>(1); + let (output_sender, output_receiver) = mpsc::channel::<()>(1); + + tokio::spawn(async move { + 'outer: loop { + if input_receiver.recv().await.is_none() { + break 'outer; + } + + 'inner: loop { + match timeout(duration, input_receiver.recv()).await { + Ok(None) => break 'outer, + Ok(Some(_)) => continue 'inner, + Err(_) => { + if let Err(TrySendError::Closed(_)) = output_sender.try_send(()) { + break 'outer; + } else { + break 'inner; + } + } + } + } + } + }); + + (input_sender, output_receiver) +} + +pub fn format_duration(duration: Duration) -> String { + let full_seconds = duration.as_secs(); + let full_minutes = full_seconds / 60; + let hours = full_minutes / 60; + let minutes = full_minutes % 60; + let seconds = full_seconds % 60; + + if hours == 0 { + format!("{:0>2}:{:0>2}", minutes, seconds) + } else { + format!("{:0>2}:{:0>2}:{:0>2}", hours, minutes, seconds) + } +} + +pub fn get_far_future() -> Instant { + Instant::now() + Duration::from_secs(60 * 60 * 24 * 365 * 30) // 30 years +} diff --git a/handlers/pa_volume/src/main.rs b/handlers/pa_volume/src/main.rs index 7c49c73..0185686 100644 --- a/handlers/pa_volume/src/main.rs +++ b/handlers/pa_volume/src/main.rs @@ -15,6 +15,7 @@ enum CliCommand { } fn main() -> Result<()> { + env_logger::init(); let command = CliCommand::parse(); match command { diff --git a/handlers/playerctl/src/main.rs b/handlers/playerctl/src/main.rs index 243ec04..8b5f195 100644 --- a/handlers/playerctl/src/main.rs +++ b/handlers/playerctl/src/main.rs @@ -13,6 +13,7 @@ enum CliCommand { } fn main() -> Result<()> { + env_logger::init(); let command = CliCommand::parse(); match command { diff --git a/src/coordinator/mod.rs b/src/coordinator/mod.rs index 161a889..5259b20 100644 --- a/src/coordinator/mod.rs +++ b/src/coordinator/mod.rs @@ -106,6 +106,7 @@ pub async fn start(config_directory: &Path, config: Config) -> Result<()> { handler_runner::start( String::default().into_boxed_str(), &config_directory.join("handlers"), + &config.handlers, handler_hosts_config, handler_commands_sender.clone(), events_sender.subscribe(), diff --git a/src/handler_host/mod.rs b/src/handler_host/mod.rs index 1ce2012..c5343fc 100644 --- a/src/handler_host/mod.rs +++ b/src/handler_host/mod.rs @@ -53,6 +53,7 @@ pub async fn start(config_directory: &Path, config: Config) -> Result<()> { handler_runner::start( config.host_id.clone(), &config_directory.join("handlers"), + &config.handlers, handler_hosts_config, commands_sender.clone(), events_sender.subscribe(), diff --git a/src/handler_runner.rs b/src/handler_runner.rs index 34f0e8d..1ea4d24 100644 --- a/src/handler_runner.rs +++ b/src/handler_runner.rs @@ -23,6 +23,7 @@ use crate::model::mqtt::HandlerHostsConfig; pub async fn start( host_id: Box, handlers_directory: &Path, + global_handler_configs: &HashMap, toml::Table>, handler_hosts_config: HandlerHostsConfig, commands_sender: flume::Sender, mut events_receiver: tokio::sync::broadcast::Receiver, @@ -88,6 +89,7 @@ pub async fn start( start_handler( handlers_directory, + global_handler_configs.get(&handler_name), &mut handler_config_by_key_path_by_handler_name, &mut handler_config_by_knob_path_by_handler_name, &mut handler_stdin_by_name, @@ -142,6 +144,7 @@ pub async fn start( async fn start_handler( handlers_directory: &Path, + global_config: Option<&toml::Table>, handler_config_by_key_path_by_handler_name: &mut HashMap, HashMap>>, handler_config_by_knob_path_by_handler_name: &mut HashMap, HashMap>>, handler_stdin_by_name: &mut HashMap, ChildStdin>, @@ -170,7 +173,11 @@ async fn start_handler( let mut stdout_lines = BufReader::new(command.stdout.take().expect("stdout is explicitly captured and has not yet been taken")).lines(); let mut stdin = command.stdin.take().expect("stdin is explicitly captured and has not yet been taken"); - let initial_handler_message = InitialHandlerMessage { key_configs, knob_configs }; + let initial_handler_message = InitialHandlerMessage { + global_config, + key_configs, + knob_configs, + }; let serialized_message = serde_json::to_string(&initial_handler_message).unwrap().into_boxed_str().into_boxed_bytes(); @@ -184,17 +191,19 @@ async fn start_handler( if let HandlerInitializationResultMessage::Error { error } = result { #[rustfmt::skip] - if let HandlerInitializationError::InvalidConfig { supports_keys, supports_knobs, .. } = error { - if !supports_keys && !initial_handler_message.key_configs.is_empty() { + if let HandlerInitializationError::InvalidConfig { requires_global_config, supports_keys, supports_knobs, .. } = error { + if requires_global_config && initial_handler_message.global_config.is_none() { + return Err(eyre!("The '{handler_name}' handler requires a global configuration in the deckster.toml file.")); + } else if !supports_keys && !initial_handler_message.key_configs.is_empty() { return Err(eyre!( - "The '{handler_name}' handler does not support keys, but these keys tried to use it: {}", - initial_handler_message.key_configs.keys().map(|k| k.to_string()).join(", ") - )); + "The '{handler_name}' handler does not support keys, but these keys tried to use it: {}", + initial_handler_message.key_configs.keys().map(|k| k.to_string()).join(", ") + )); } else if !supports_knobs && !initial_handler_message.knob_configs.is_empty() { return Err(eyre!( - "The '{handler_name}' handler does not support knobs, but these knobs tried to use it: {}", - initial_handler_message.knob_configs.keys().map(|k| k.to_string()).join(", ") - )); + "The '{handler_name}' handler does not support knobs, but these knobs tried to use it: {}", + initial_handler_message.knob_configs.keys().map(|k| k.to_string()).join(", ") + )); } }; diff --git a/src/main.rs b/src/main.rs index 288f372..acc34f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -79,6 +79,7 @@ pub async fn main() -> Result<()> { icon_packs: deckster_file.icon_packs, initial: deckster_file.initial, mqtt: deckster_file.mqtt, + handlers: deckster_file.handlers, } .validate()?; diff --git a/src/model/coordinator_config.rs b/src/model/coordinator_config.rs index 473af1f..a120668 100644 --- a/src/model/coordinator_config.rs +++ b/src/model/coordinator_config.rs @@ -25,6 +25,8 @@ pub struct File { pub buttons: HashMap, // EnumMap pub initial: InitialConfig, pub mqtt: Option, + #[serde(default)] + pub handlers: HashMap, toml::Table>, } #[derive(Debug)] @@ -44,6 +46,7 @@ pub struct Config { pub buttons: EnumMap, pub initial: InitialConfig, pub mqtt: Option, + pub handlers: HashMap, toml::Table>, } fn inactive_button_color_default() -> RGB8Wrapper { diff --git a/src/model/handler_host_config.rs b/src/model/handler_host_config.rs index 7cdb6c7..335460e 100644 --- a/src/model/handler_host_config.rs +++ b/src/model/handler_host_config.rs @@ -1,4 +1,5 @@ use serde::Deserialize; +use std::collections::HashMap; use crate::model::mqtt::MqttConfig; @@ -6,4 +7,6 @@ use crate::model::mqtt::MqttConfig; pub struct Config { pub host_id: Box, pub mqtt: MqttConfig, + #[serde(default)] + pub handlers: HashMap, toml::Table>, }