├── docker ├── .gitignore ├── build.sh ├── Dockerfile └── README.md ├── .idea ├── .gitignore ├── copyright │ ├── profiles_settings.xml │ └── MPL_2_0_SPDX_identifier_template.xml ├── modules.xml ├── uc-intg-hass.iml ├── vcs.xml └── runConfigurations │ ├── doc.xml │ ├── fmt_check.xml │ ├── clippy__mdns_sd_.xml │ ├── clippy__zeroconf_.xml │ ├── Run_ha_test.xml │ ├── All_Tests.xml │ ├── Run_voice_command.xml │ ├── Run_intg_home_assistant___mdns_sd_.xml │ └── Run_intg_home_assistant___zeroconf_.xml ├── .gitignore ├── .cargo └── config.toml ├── src ├── lib.rs ├── util │ ├── macros.rs │ ├── mod.rs │ ├── env.rs │ ├── from_msg_data.rs │ ├── certificates.rs │ ├── network.rs │ ├── json.rs │ └── color.rs ├── client │ ├── entity │ │ ├── mod.rs │ │ ├── button.rs │ │ └── switch.rs │ ├── service │ │ ├── switch.rs │ │ ├── mod.rs │ │ ├── light.rs │ │ ├── climate.rs │ │ ├── button.rs │ │ ├── cover.rs │ │ └── media_player.rs │ ├── actor.rs │ ├── set_remote_id.rs │ ├── subscribed_entities.rs │ ├── assist │ │ ├── mod.rs │ │ └── call_pipeline.rs │ ├── close_handler.rs │ ├── streamhandler.rs │ ├── messages.rs │ ├── event.rs │ ├── get_states.rs │ └── get_entities.rs ├── startup.rs ├── server │ ├── mod.rs │ ├── ws │ │ ├── events.rs │ │ ├── responses.rs │ │ ├── requests.rs │ │ └── mod.rs │ ├── zeroconf.rs │ └── mdns.rs ├── controller │ ├── handler │ │ ├── r2_connection.rs │ │ ├── r2_response.rs │ │ ├── mod.rs │ │ ├── r2_event.rs │ │ └── ha_connection.rs │ └── messages.rs ├── errors.rs ├── bin │ └── ha_test.rs └── main.rs ├── about.toml ├── resources ├── home-assistant.json ├── README.md └── driver.json ├── configuration.yaml ├── .github ├── workflows │ └── audit-on-push.yml └── actions │ └── rust-setup │ └── action.yml ├── about-markdown.hbs ├── CONTRIBUTING.md ├── Cargo.toml ├── about.hbs └── CHANGELOG.md /docker/.gitignore: -------------------------------------------------------------------------------- 1 | app 2 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target 4 | 5 | # These are backup files generated by rustfmt 6 | **/*.rs.bk 7 | 8 | .DS_Store 9 | *.bak 10 | /integration-hass_licenses.* 11 | /home-assistant.json 12 | /certs 13 | -------------------------------------------------------------------------------- /.idea/copyright/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.aarch64-unknown-linux-gnu] 2 | linker = "ucr2-aarch64-none-linux-gnu-gcc" 3 | rustflags = "-C target-cpu=cortex-a55" 4 | 5 | [target.arm-unknown-linux-gnueabihf] 6 | linker = "yio-arm-buildroot-linux-gnueabihf-gcc" 7 | rustflags = "-C target-cpu=arm1176jzf-s" 8 | 9 | [target.'cfg(all(windows, target_env = "msvc"))'] 10 | rustflags = ["-C", "target-feature=+crt-static"] 11 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | #![forbid(unsafe_code)] 5 | #![forbid(non_ascii_idents)] 6 | 7 | pub mod client; 8 | pub mod controller; 9 | pub mod server; 10 | pub mod util; 11 | 12 | pub mod configuration; 13 | pub mod errors; 14 | pub mod startup; 15 | 16 | pub use controller::*; 17 | pub use startup::*; 18 | -------------------------------------------------------------------------------- /about.toml: -------------------------------------------------------------------------------- 1 | accepted = [ 2 | "Apache-2.0", 3 | "MIT", 4 | "BSD-2-Clause", 5 | "BSD-3-Clause", 6 | "CDLA-Permissive-2.0", 7 | "ISC", 8 | "MPL-2.0", 9 | "NOASSERTION", 10 | "OpenSSL", 11 | "Unicode-DFS-2016", 12 | "Unicode-3.0", 13 | "Unlicense", 14 | "Zlib" 15 | ] 16 | ignore-build-dependencies = true 17 | ignore-dev-dependencies = true 18 | 19 | workarounds = [ 20 | "ring", 21 | "rustls", 22 | ] 23 | -------------------------------------------------------------------------------- /.idea/copyright/MPL_2_0_SPDX_identifier_template.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /src/util/macros.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | macro_rules! return_fut_ok { 5 | ($result:expr) => { 6 | return Box::pin(fut::result(Ok($result))); 7 | }; 8 | } 9 | 10 | macro_rules! return_fut_err { 11 | ($result:expr) => { 12 | return Box::pin(fut::result(Err($result))); 13 | }; 14 | } 15 | 16 | pub(crate) use return_fut_err; 17 | pub(crate) use return_fut_ok; 18 | -------------------------------------------------------------------------------- /resources/home-assistant.json: -------------------------------------------------------------------------------- 1 | { 2 | "hass": { 3 | "url": "ws://homeassistant.local:8123/api/websocket", 4 | "token": "", 5 | "connection_timeout": 6, 6 | "request_timeout": 6, 7 | "max_frame_size_kb": 5120, 8 | "reconnect": { 9 | "attempts": 0, 10 | "duration_ms": 300, 11 | "duration_max_ms": 30000, 12 | "backoff_factor": 1.5 13 | }, 14 | "heartbeat": { 15 | "ping_frames": false, 16 | "interval_sec": 5, 17 | "timeout_sec": 20 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/util/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Common utility functions. 5 | 6 | mod certificates; 7 | mod color; 8 | mod env; 9 | mod from_msg_data; 10 | pub mod json; 11 | mod macros; 12 | mod network; 13 | 14 | pub use certificates::create_single_cert_server_config; 15 | pub use color::*; 16 | pub use env::*; 17 | pub use from_msg_data::DeserializeMsgData; 18 | pub(crate) use macros::*; 19 | pub use network::*; 20 | -------------------------------------------------------------------------------- /.idea/uc-intg-hass.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /resources/README.md: -------------------------------------------------------------------------------- 1 | # Resources 2 | 3 | ## driver.json 4 | 5 | - Integration driver metadata returned in `get_driver_metadata`. 6 | - Embedded in Rust: [src/main.rs](../src/main.rs) 7 | - `version` property value is overwritten at runtime with application version. 8 | - `token` value is removed if set. 9 | - `driver_id` and `name` are automatically set if missing. 10 | 11 | ## home-assistant.json 12 | 13 | Template configuration file for the [src/bin/ha_test.rs](../src/bin/ha_test.rs) tool. 14 | 15 | This is the same configuration file format written by the integration during the setup flow. 16 | -------------------------------------------------------------------------------- /docker/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | 6 | if [ "$(uname)" == "Darwin" ]; then 7 | echo "This build script is only for Linux" 8 | exit 1 9 | fi 10 | 11 | cargo build --release 12 | mkdir -p ./app 13 | cp ../target/release/uc-intg-hass ./app/ 14 | cp ../configuration.yaml ./app/ 15 | 16 | BUILD_LABELS="\ 17 | --build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ 18 | --build-arg VERSION=$(git describe --match "v[0-9]*" --tags HEAD --always) \ 19 | --build-arg REVISION=$(git log -1 --format="%H")" 20 | 21 | docker build $BUILD_LABELS -t integration-hass . 22 | -------------------------------------------------------------------------------- /src/client/entity/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Home Assistant entity helper functions. 5 | 6 | mod button; 7 | mod climate; 8 | mod cover; 9 | mod light; 10 | mod media_player; 11 | mod remote; 12 | mod sensor; 13 | mod switch; 14 | 15 | pub(crate) use button::*; 16 | pub(crate) use climate::*; 17 | pub(crate) use cover::*; 18 | pub(crate) use light::*; 19 | pub(crate) use media_player::*; 20 | pub(crate) use remote::*; 21 | pub(crate) use sensor::*; 22 | pub(crate) use switch::*; 23 | -------------------------------------------------------------------------------- /src/util/env.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | use std::env; 5 | use std::ffi::OsStr; 6 | 7 | /// Retrieves a boolean value from the given environment variable. 8 | /// 9 | /// The following string values are considered true: `true` or `1`. 10 | /// 11 | /// Returns `false` if the variable is not defined or contains an invalid value. 12 | pub fn bool_from_env>(key: K) -> bool { 13 | env::var(key) 14 | .map(|v| v == "true" || v == "1") 15 | .unwrap_or_default() 16 | } 17 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/startup.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | use const_format::formatcp; 5 | 6 | /// Build information like timestamp, git hash, etc. 7 | pub mod built_info { 8 | include!(concat!(env!("OUT_DIR"), "/built.rs")); 9 | include!(concat!(env!("OUT_DIR"), "/git_built.rs")); 10 | } 11 | 12 | /// Application version built from git version information. 13 | pub const APP_VERSION: &str = formatcp!( 14 | "{}{}", 15 | match built_info::GIT_VERSION { 16 | Some(v) => v, 17 | None => formatcp!("{}-non-git", built_info::PKG_VERSION), 18 | }, 19 | match built_info::GIT_DIRTY { 20 | Some(true) => "-dirty", 21 | _ => "", 22 | } 23 | ); 24 | -------------------------------------------------------------------------------- /src/client/service/switch.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Switch entity specific HA service call logic. 5 | 6 | use crate::client::cmd_from_str; 7 | use crate::errors::ServiceError; 8 | use serde_json::Value; 9 | use uc_api::SwitchCommand; 10 | use uc_api::intg::EntityCommand; 11 | 12 | pub(crate) fn handle_switch(msg: &EntityCommand) -> Result<(String, Option), ServiceError> { 13 | let cmd: SwitchCommand = cmd_from_str(&msg.cmd_id)?; 14 | 15 | let result = match cmd { 16 | SwitchCommand::On => ("turn_on".to_string(), None), 17 | SwitchCommand::Off => ("turn_off".to_string(), None), 18 | SwitchCommand::Toggle => ("Toggle".to_string(), None), 19 | }; 20 | 21 | Ok(result) 22 | } 23 | -------------------------------------------------------------------------------- /configuration.yaml: -------------------------------------------------------------------------------- 1 | integration: 2 | interface: 0.0.0.0 3 | http: 4 | enabled: true 5 | port: 8000 6 | https: 7 | enabled: false 8 | port: 9443 9 | certs: 10 | public: certs/local-cert.pem 11 | private: certs/local-key.pem 12 | websocket: 13 | #token: 1-2-3 14 | heartbeat: 15 | interval_sec: 10 16 | timeout_sec: 20 17 | # to override default configuration: 18 | #hass: 19 | # url: ws://homeassistant.local:8123/api/websocket 20 | # token: YOUR_HA_TOKEN - better use UC_HASS_TOKEN environment variable to set it! 21 | # connection_timeout: 3 22 | # max_frame_size_kb: 5120 23 | # reconnect: 24 | # attempts: 100 25 | # duration_ms: 1000 26 | # duration_max_ms: 30000 27 | # backoff_factor: 1.5 28 | # heartbeat: 29 | # interval_sec: 20 30 | # timeout_sec: 40 31 | # disconnect_in_standby: true -------------------------------------------------------------------------------- /.github/workflows/audit-on-push.yml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | on: 3 | push: 4 | paths: 5 | - '**/Cargo.toml' 6 | - '**/Cargo.lock' 7 | - '.github/**/audit-on-push.yml' 8 | jobs: 9 | security_audit: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v5 13 | 14 | - name: Cache cargo-bin 15 | id: cache-dependencies 16 | uses: actions/cache@v4 17 | with: 18 | path: | 19 | ~/.cargo/bin 20 | key: ${{ runner.os }}-cargo-bin 21 | 22 | - name: Install cargo audit 23 | shell: bash 24 | run: | 25 | if [[ ! -x "$(command -v cargo-audit)" ]]; then 26 | echo "cargo-audit not found, installing it with cargo" 27 | cargo install --force cargo-audit 28 | fi; 29 | 30 | - run: cargo audit 31 | -------------------------------------------------------------------------------- /src/util/from_msg_data.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022-2023 {person OR org} <{email}> 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | use serde::de::{DeserializeOwned, Error}; 5 | use serde_json::Value; 6 | 7 | /// Deserialize a serde json value from a generic message to a typed message struct. 8 | #[allow(dead_code)] 9 | pub trait DeserializeMsgData: Into> { 10 | fn deserialize(self) -> Result { 11 | match self.into() { 12 | None => Err(serde_json::Error::custom("Missing field: 'msg_data'")), 13 | Some(m) => T::deserialize(m), 14 | } 15 | } 16 | 17 | fn deserialize_or_default(self) -> Result { 18 | match self.into() { 19 | None => Ok(T::default()), // optional 20 | Some(m) => T::deserialize(m), 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/client/actor.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Actix `Actor` trait implementation. 5 | 6 | use actix::{Actor, Context}; 7 | use log::debug; 8 | 9 | use crate::client::HomeAssistantClient; 10 | use crate::client::messages::{ConnectionEvent, ConnectionState}; 11 | 12 | impl Actor for HomeAssistantClient { 13 | type Context = Context; 14 | 15 | fn started(&mut self, ctx: &mut Context) { 16 | debug!("[{}] HA client started", self.id); 17 | self.heartbeat(ctx); 18 | } 19 | 20 | fn stopped(&mut self, _ctx: &mut Self::Context) { 21 | debug!("[{}] HA client stopped", self.id); 22 | self.controller_actor.do_send(ConnectionEvent { 23 | client_id: self.id.clone(), 24 | state: ConnectionState::Closed, 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/client/set_remote_id.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Actix actor handler implementation for the `SetRemoteId` message 5 | 6 | use crate::client::HomeAssistantClient; 7 | use crate::client::messages::SetRemoteId; 8 | use crate::errors::ServiceError; 9 | use actix::Handler; 10 | use log::debug; 11 | 12 | impl Handler for HomeAssistantClient { 13 | type Result = Result<(), ServiceError>; 14 | 15 | fn handle(&mut self, msg: SetRemoteId, ctx: &mut Self::Context) -> Self::Result { 16 | debug!("[{}] SetRemoteId: '{}'", self.id, msg.remote_id); 17 | self.remote_id = msg.remote_id; 18 | if self.uc_ha_component { 19 | self.unsubscribe_uc_configuration(ctx); 20 | self.subscribe_uc_configuration(ctx); 21 | } 22 | Ok(()) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.idea/runConfigurations/doc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 19 | -------------------------------------------------------------------------------- /.idea/runConfigurations/fmt_check.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 19 | -------------------------------------------------------------------------------- /.idea/runConfigurations/clippy__mdns_sd_.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 19 | -------------------------------------------------------------------------------- /.idea/runConfigurations/clippy__zeroconf_.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 19 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Run_ha_test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 20 | -------------------------------------------------------------------------------- /.idea/runConfigurations/All_Tests.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 20 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Run_voice_command.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 20 | -------------------------------------------------------------------------------- /src/server/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Server modules of the integration driver. Handling WebSocket and mDNS advertisement. 5 | 6 | // zeroconf has priority over mdns-sd 7 | #[cfg(feature = "zeroconf")] 8 | mod zeroconf; 9 | #[cfg(feature = "zeroconf")] 10 | pub use self::zeroconf::publish_service; 11 | 12 | #[cfg(feature = "mdns-sd")] 13 | mod mdns; 14 | #[cfg(feature = "mdns-sd")] 15 | #[cfg(not(feature = "zeroconf"))] 16 | pub use mdns::publish_service; 17 | 18 | mod ws; 19 | 20 | pub use ws::{json_error_handler, ws_index}; 21 | 22 | /// Fallback if no mDNS library is enabled 23 | #[cfg(not(feature = "zeroconf"))] 24 | #[cfg(not(feature = "mdns-sd"))] 25 | pub fn publish_service( 26 | _instance_name: impl AsRef, 27 | _service_name: impl AsRef, 28 | _reg_type: impl Into, 29 | _port: u16, 30 | _txt: Vec, 31 | ) -> Result<(), crate::errors::ServiceError> { 32 | log::warn!("No mDNS library support included: service will not be published!"); 33 | Ok(()) 34 | } 35 | -------------------------------------------------------------------------------- /src/client/entity/button.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Button entity specific logic. 5 | 6 | use crate::errors::ServiceError; 7 | use serde_json::{Map, Value}; 8 | use std::collections::HashMap; 9 | use uc_api::EntityType; 10 | use uc_api::intg::AvailableIntgEntity; 11 | 12 | pub(crate) fn convert_button_entity( 13 | entity_id: String, 14 | _state: String, 15 | ha_attr: &mut Map, 16 | ) -> Result { 17 | let friendly_name = ha_attr.get("friendly_name").and_then(|v| v.as_str()); 18 | let name = HashMap::from([("en".into(), friendly_name.unwrap_or(&entity_id).into())]); 19 | 20 | Ok(AvailableIntgEntity { 21 | entity_id, 22 | device_id: None, // prepared for device_id handling 23 | entity_type: EntityType::Button, 24 | device_class: None, 25 | name, 26 | icon: None, 27 | features: None, // no optional features, default = "press" 28 | area: None, 29 | options: None, 30 | attributes: None, 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /src/client/subscribed_entities.rs: -------------------------------------------------------------------------------- 1 | use crate::client::HomeAssistantClient; 2 | use crate::client::messages::SubscribedEntities; 3 | use actix::Handler; 4 | use log::debug; 5 | 6 | impl Handler for HomeAssistantClient { 7 | type Result = (); 8 | 9 | /// Method called by controller when subscribed entities change 10 | /// The custom HA component has to be updated then (if used) 11 | /// msg contains the new entity ids to subscribe 12 | fn handle(&mut self, msg: SubscribedEntities, ctx: &mut Self::Context) { 13 | debug!( 14 | "[{}] Updated subscribed entities: {:?}", 15 | self.id, msg.entity_ids 16 | ); 17 | self.subscribed_entities = msg.entity_ids; 18 | if !self.authenticated { 19 | return; 20 | } 21 | // Occurs when the remote reloads (wakes up) or when the user 22 | // selected new entities from HA, subscribe to configuration event if not already done 23 | self.unsubscribe_uc_configuration(ctx); 24 | self.subscribe_uc_configuration(ctx); 25 | self.unsubscribe_uc_events(ctx); 26 | self.subscribe_uc_events(ctx); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bookworm-slim 2 | 3 | EXPOSE 8000 4 | EXPOSE 9443 5 | 6 | ARG CONFIG_PATH=/config 7 | ENV UC_CONFIG_HOME=$CONFIG_PATH 8 | 9 | RUN mkdir $CONFIG_PATH && chown 10000 $CONFIG_PATH 10 | 11 | RUN apt update && apt -y install ca-certificates && apt clean 12 | 13 | # for static configuration without driver setup flow: 14 | #ENV UC_HASS_URL=ws://hassio.local:8123/api/websocket 15 | #ENV UC_HASS_TOKEN=OVERRIDE_WITH_YOUR_LONG_LIVED_ACCESS_TOKEN 16 | 17 | WORKDIR /app 18 | 19 | COPY ./app /app 20 | 21 | USER 10000 22 | 23 | VOLUME $CONFIG_PATH 24 | 25 | CMD ["/app/uc-intg-hass"] 26 | 27 | # Labels, see: https://github.com/opencontainers/image-spec/blob/master/annotations.md 28 | ARG BUILD_DATE 29 | ARG VERSION 30 | ARG REVISION 31 | LABEL org.opencontainers.image.created=$BUILD_DATE 32 | LABEL org.opencontainers.image.authors="markus.z@unfoldedcircle.com" 33 | LABEL org.opencontainers.image.url="https://hub.docker.com/r/unfoldedcircle/integration-hass" 34 | LABEL org.opencontainers.image.version=$VERSION 35 | LABEL org.opencontainers.image.revision=$REVISION 36 | LABEL org.opencontainers.image.vendor="Unfolded Circle" 37 | LABEL org.opencontainers.image.title="Unfolded Circle Home Assistant integration" 38 | LABEL org.opencontainers.image.description="Remote Two integration for Home Assistant written in Rust" 39 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Run_intg_home_assistant___mdns_sd_.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 25 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Run_intg_home_assistant___zeroconf_.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 25 | -------------------------------------------------------------------------------- /src/client/assist/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | use crate::client::HomeAssistantClient; 5 | use crate::controller::R2AudioChunkMsg; 6 | use crate::errors::ServiceError; 7 | use actix::Handler; 8 | use awc::ws; 9 | use bytes::{BufMut, BytesMut}; 10 | 11 | mod call_pipeline; 12 | 13 | pub const DEF_SAMPLE_RATE: u32 = 16000; 14 | 15 | impl Handler for HomeAssistantClient { 16 | type Result = Result<(), ServiceError>; 17 | 18 | fn handle(&mut self, msg: R2AudioChunkMsg, ctx: &mut Self::Context) -> Self::Result { 19 | let (_, session) = self 20 | .assist_sessions 21 | .iter() 22 | .find(|(_, session)| session.session_id == msg.session_id) 23 | .ok_or_else(|| { 24 | ServiceError::BadRequest(format!( 25 | "No HA assist session found for session id {}", 26 | msg.session_id 27 | )) 28 | })?; 29 | 30 | let bin_id = session 31 | .stt_binary_handler_id 32 | .ok_or(ServiceError::BadRequest("No binary handler id".into()))?; 33 | 34 | let mut buffer = BytesMut::with_capacity(msg.data.len() + 1); 35 | buffer.put_u8(bin_id); 36 | buffer.put_slice(&msg.data); 37 | 38 | self.send_message(ws::Message::Binary(buffer.into()), "Audio", ctx)?; 39 | 40 | Ok(()) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/server/ws/events.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Handle events from Remote Two 5 | 6 | use crate::Controller; 7 | use crate::controller::R2EventMsg; 8 | use crate::errors::ServiceError; 9 | use crate::server::ws::WsConn; 10 | use actix::Addr; 11 | use log::{error, info, warn}; 12 | use std::str::FromStr; 13 | use uc_api::intg::ws::R2Event; 14 | use uc_api::ws::WsMessage; 15 | 16 | impl WsConn { 17 | /// Handle events from R2 18 | pub(crate) async fn on_event( 19 | session_id: &str, 20 | event: WsMessage, 21 | controller_addr: Addr, 22 | ) -> Result<(), ServiceError> { 23 | let msg = event 24 | .msg 25 | .as_deref() 26 | .ok_or_else(|| ServiceError::BadRequest("Missing property: msg".into()))?; 27 | 28 | info!("[{session_id}] Got event: {msg}"); 29 | 30 | if let Ok(req_msg) = R2Event::from_str(msg) { 31 | if let Err(e) = controller_addr.try_send(R2EventMsg { 32 | ws_id: session_id.into(), 33 | event: req_msg, 34 | msg_data: event.msg_data, 35 | }) { 36 | // avoid returning an Err which would be sent back to the client 37 | error!("[{session_id}] Controller mailbox error: {e}"); 38 | } 39 | } else { 40 | warn!("[{session_id}] Unknown event: {msg}"); 41 | } 42 | 43 | Ok(()) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/client/close_handler.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Actix actor handler implementation for the `Close` message 5 | 6 | use std::time::Duration; 7 | 8 | use actix::{ActorContext, AsyncContext, Handler}; 9 | use awc::ws; 10 | use awc::ws::CloseReason; 11 | use log::info; 12 | 13 | use crate::client::HomeAssistantClient; 14 | use crate::client::messages::Close; 15 | 16 | impl Handler for HomeAssistantClient { 17 | type Result = (); 18 | 19 | fn handle(&mut self, msg: Close, ctx: &mut Self::Context) -> Self::Result { 20 | info!("[{}] Close msg: sending Close to HomeAssistant", self.id); 21 | // Try graceful shutdown first: we'll receive a Close frame back from the server which will Stop the context. 22 | // If send_message fails the actor will be closed. 23 | if self 24 | .send_message( 25 | ws::Message::Close(Some(CloseReason { 26 | code: msg.code, 27 | description: msg.description, 28 | })), 29 | "Close", 30 | ctx, 31 | ) 32 | .is_ok() 33 | { 34 | // Then a hard disconnect as safety net if the connection is stale 35 | ctx.run_later(Duration::from_millis(100), move |act, ctx| { 36 | info!("[{}] Force stopping actor", act.id); 37 | act.sink.close(); 38 | ctx.stop(); 39 | }); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/server/ws/responses.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Handle response messages from Remote Two 5 | 6 | use crate::Controller; 7 | use crate::controller::R2ResponseMsg; 8 | use crate::errors::ServiceError; 9 | use crate::server::ws::WsConn; 10 | use actix::Addr; 11 | use log::{debug, error, warn}; 12 | use std::str::FromStr; 13 | use uc_api::intg::ws::R2Response; 14 | use uc_api::ws::WsMessage; 15 | 16 | impl WsConn { 17 | /// Handle response messages from R2 18 | pub(crate) async fn on_response( 19 | session_id: &str, 20 | response: WsMessage, 21 | controller_addr: Addr, 22 | ) -> Result<(), ServiceError> { 23 | let msg = response 24 | .msg 25 | .as_deref() 26 | .ok_or_else(|| ServiceError::BadRequest("Missing property: msg".into()))?; 27 | 28 | debug!("[{session_id}] Got response: {msg}"); 29 | 30 | if let Ok(resp_msg) = R2Response::from_str(msg) { 31 | if let Err(e) = controller_addr.try_send(R2ResponseMsg { 32 | ws_id: session_id.into(), 33 | msg: resp_msg, 34 | response, 35 | }) { 36 | // avoid returning an Err which would be sent back to the client 37 | error!("[{session_id}] Controller mailbox error: {e}"); 38 | } 39 | } else { 40 | warn!("[{session_id}] Unknown response: {msg}"); 41 | } 42 | 43 | Ok(()) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/controller/handler/r2_connection.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Actix message handler for Remote Two connection messages. 5 | 6 | use crate::controller::{Controller, NewR2Session, R2Session, R2SessionDisconnect, SendWsMessage}; 7 | use actix::{Context, Handler}; 8 | use log::{error, info}; 9 | use uc_api::ws::WsMessage; 10 | 11 | impl Handler for Controller { 12 | type Result = (); 13 | 14 | fn handle(&mut self, msg: NewR2Session, _: &mut Context) -> Self::Result { 15 | self.sessions 16 | .insert(msg.id.clone(), R2Session::new(msg.addr)); 17 | 18 | self.send_device_state(&msg.id); 19 | 20 | // Retrieve the version info to store the remote id (used later to identify the remote 21 | // from unified HA component 22 | if let Some(session) = self.sessions.get_mut(&msg.id) { 23 | let request_id = session.new_msg_id(); 24 | let message = WsMessage::simple_request(request_id, "get_version"); 25 | match session.recipient.try_send(SendWsMessage(message)) { 26 | Ok(_) => info!("[{}] Request sent", request_id), 27 | Err(e) => error!("[{}] Error sending entity_states: {e:?}", msg.id), 28 | } 29 | } 30 | } 31 | } 32 | 33 | impl Handler for Controller { 34 | type Result = (); 35 | 36 | fn handle(&mut self, msg: R2SessionDisconnect, _: &mut Context) { 37 | self.sessions.remove(&msg.id); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/controller/handler/r2_response.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Actix message handler for [R2ResponseMsg]. 5 | 6 | use crate::client::messages::SetRemoteId; 7 | use crate::controller::{Controller, R2ResponseMsg}; 8 | use actix::Handler; 9 | use log::{debug, error, info}; 10 | use uc_api::intg::ws::R2Response; 11 | 12 | impl Handler for Controller { 13 | type Result = (); 14 | 15 | fn handle(&mut self, msg: R2ResponseMsg, _ctx: &mut Self::Context) -> Self::Result { 16 | match msg.msg { 17 | R2Response::RuntimeInfo => { 18 | info!("{:?}", msg); 19 | } 20 | R2Response::Version => { 21 | info!("{:?}", msg); 22 | if let Some(remote_id) = msg 23 | .response 24 | .msg_data 25 | .unwrap() 26 | .as_object_mut() 27 | .unwrap() 28 | .get_mut("hostname") 29 | .and_then(|v| v.as_str()) 30 | { 31 | info!("Remote identifier: '{remote_id}'"); 32 | self.remote_id = remote_id.to_string(); 33 | if let Some(ha_client) = &self.ha_client 34 | && let Err(e) = ha_client.try_send(SetRemoteId { 35 | remote_id: self.remote_id.clone(), 36 | }) 37 | { 38 | error!("Error sending remote identifier to client: {e:?}"); 39 | } 40 | } 41 | } 42 | _ => { 43 | debug!("[{}] Ignoring remote response: {}", msg.ws_id, msg.msg); 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/server/ws/requests.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Handle request messages from Remote Two 5 | 6 | use crate::Controller; 7 | use crate::controller::R2RequestMsg; 8 | use crate::errors::ServiceError; 9 | use crate::server::ws::WsConn; 10 | use actix::Addr; 11 | use log::{debug, warn}; 12 | use std::str::FromStr; 13 | use uc_api::intg::ws::R2Request; 14 | use uc_api::ws::WsMessage; 15 | 16 | impl WsConn { 17 | /// Handle request messages from R2. 18 | /// 19 | /// The received WebSocket text message will be forwarded with an Actix [R2RequestMsg] to the 20 | /// Controller. 21 | /// 22 | /// - A returned [ServiceError] from the [R2RequestMsg] will be propagated back, which is then 23 | /// mapped to a WebSocket response error message. 24 | /// - The successful response message must be sent asynchronously by the Controller! 25 | pub(crate) async fn on_request( 26 | session_id: &str, 27 | request: WsMessage, 28 | controller_addr: Addr, 29 | ) -> Result, ServiceError> { 30 | let id = request 31 | .id 32 | .ok_or_else(|| ServiceError::BadRequest("Missing property: id".into()))?; 33 | let msg = request 34 | .msg 35 | .as_deref() 36 | .ok_or_else(|| ServiceError::BadRequest("Missing property: msg".into()))?; 37 | 38 | debug!("[{session_id}] Got request: {msg}"); 39 | 40 | if let Ok(req_msg) = R2Request::from_str(msg) { 41 | controller_addr 42 | .send(R2RequestMsg { 43 | ws_id: session_id.into(), 44 | req_id: id, 45 | request: req_msg, 46 | msg_data: request.msg_data, 47 | }) 48 | .await? 49 | } else { 50 | warn!("[{session_id}] Unknown message: {msg}"); 51 | Err(ServiceError::BadRequest(format!("Unknown message: {msg}"))) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/util/certificates.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | use rustls::{ServerConfig, pki_types::PrivateKeyDer}; 5 | use rustls_pemfile::{certs, pkcs8_private_keys}; 6 | use std::ffi::OsStr; 7 | use std::io::{BufReader, ErrorKind}; 8 | use std::path::Path; 9 | use std::{fs, io}; 10 | 11 | /// Create a [`rustls::ServerConfig`] from the given public & private certificates. 12 | /// 13 | /// # Arguments 14 | /// 15 | /// * `cert_file`: path to public key file 16 | /// * `key_file`: path to private key file (PKCS8-encoded) 17 | /// 18 | /// returns: Result 19 | pub fn create_single_cert_server_config + ?Sized>( 20 | cert_file: &S, 21 | key_file: &S, 22 | ) -> Result { 23 | let cert_file = Path::new(cert_file); 24 | let key_file = Path::new(key_file); 25 | 26 | if !(cert_file.exists() && key_file.exists()) { 27 | return Err(io::Error::new( 28 | ErrorKind::NotFound, 29 | format!("Custom certificates not found: {cert_file:?}, {key_file:?}"), 30 | )); 31 | } 32 | 33 | let f = fs::File::open(cert_file)?; 34 | let mut reader = BufReader::new(f); 35 | 36 | let cert_chain = certs(&mut reader).collect::, _>>()?; 37 | 38 | let f = fs::File::open(key_file)?; 39 | let mut reader = BufReader::new(f); 40 | let mut keys = pkcs8_private_keys(&mut reader).collect::, _>>()?; 41 | 42 | let config = ServerConfig::builder() 43 | .with_no_client_auth() 44 | .with_single_cert(cert_chain, PrivateKeyDer::Pkcs8(keys.remove(0))) 45 | .expect("bad certificate/key"); 46 | 47 | Ok(config) 48 | } 49 | 50 | #[cfg(test)] 51 | mod tests { 52 | 53 | #[test] 54 | fn load_ssl_with_invalid_cert_paths_returns_error() { 55 | let result = super::create_single_cert_server_config("invalid", "invalid"); 56 | assert!( 57 | result.is_err(), 58 | "load_ssl must return an error with invalid cert paths" 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/controller/handler/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Actix message handlers. 5 | 6 | mod ha_connection; 7 | mod ha_event; 8 | mod r2_connection; 9 | mod r2_event; 10 | mod r2_request; 11 | mod r2_response; 12 | mod setup; 13 | 14 | use crate::controller::R2RequestMsg; 15 | use crate::errors::ServiceError; 16 | use actix::Message; 17 | use uc_api::intg::{IntegrationSetup, SetupDriver}; 18 | 19 | /// Internal message to delegate [`R2Request::SubscribeEvents`] requests. 20 | #[derive(Debug, Message)] 21 | #[rtype(result = "Result<(), ServiceError>")] 22 | struct SubscribeHaEventsMsg(pub R2RequestMsg); 23 | 24 | /// Internal message to delegate [`R2Request::UnsubscribeEvents`] requests. 25 | #[derive(Debug, Message)] 26 | #[rtype(result = "Result<(), ServiceError>")] 27 | struct UnsubscribeHaEventsMsg(pub R2RequestMsg); 28 | 29 | /// Internal message to connect to Home Assistant. 30 | #[derive(Message, Default)] 31 | #[rtype(result = "Result<(), std::io::Error>")] 32 | struct ConnectMsg { 33 | // device identifier for multi-HA connections: feature not yet available 34 | // pub device_id: String, 35 | } 36 | 37 | /// Internal message to disconnect from Home Assistant. 38 | #[derive(Message)] 39 | #[rtype(result = "()")] 40 | struct DisconnectMsg { 41 | // device identifier for multi-HA connections: feature not yet available 42 | // pub device_id: String, 43 | } 44 | 45 | /// Internal message to start driver setup flow. 46 | #[derive(Message)] 47 | #[rtype(result = "Result<(), ServiceError>")] 48 | struct SetupDriverMsg { 49 | pub ws_id: String, 50 | pub data: SetupDriver, 51 | } 52 | 53 | /// Internal message to set driver setup input data 54 | #[derive(Message)] 55 | #[rtype(result = "Result<(), ServiceError>")] 56 | struct SetDriverUserDataMsg { 57 | pub ws_id: String, 58 | pub data: IntegrationSetup, 59 | } 60 | 61 | /// Internal message to abort setup flow due to a timeout or an abort message from Remote Two. 62 | #[derive(Message)] 63 | #[rtype(result = "()")] 64 | pub(crate) struct AbortDriverSetup { 65 | pub ws_id: String, 66 | /// internal timeout 67 | pub timeout: bool, 68 | } 69 | -------------------------------------------------------------------------------- /src/client/streamhandler.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! StreamHandler trait implementation to receive WebSocket frames. 5 | 6 | use actix::{ActorContext, AsyncContext, Context, StreamHandler}; 7 | use awc::error::WsProtocolError; 8 | use awc::ws::Frame; 9 | use log::{debug, error, info}; 10 | 11 | use crate::client::HomeAssistantClient; 12 | use crate::client::messages::Close; 13 | 14 | impl StreamHandler> for HomeAssistantClient { 15 | fn handle(&mut self, msg: Result, ctx: &mut Self::Context) { 16 | let msg = match msg { 17 | Err(e) => { 18 | error!("[{}] Protocol error, terminating connection: {e}", self.id); 19 | // immediately close connection in case of a protocol error 20 | self.sink.close(); 21 | ctx.stop(); 22 | return; 23 | } 24 | Ok(msg) => msg, 25 | }; 26 | 27 | match msg { 28 | Frame::Text(txt) => self.on_text_message(txt, ctx), 29 | Frame::Binary(bytes) => self.on_binary_message(bytes, ctx), 30 | Frame::Ping(b) => self.on_ping_message(b, ctx), 31 | Frame::Pong(b) => self.on_pong_message(b, ctx), 32 | Frame::Close(c) => { 33 | info!("[{}] HA closed connection. Reason: {c:?}", self.id); 34 | self.sink.close(); 35 | ctx.stop(); 36 | } 37 | Frame::Continuation(_) => { 38 | error!( 39 | "[{}] Continuation frames not supported! Disconnecting", 40 | self.id 41 | ); 42 | ctx.notify(Close::unsupported()); 43 | } 44 | } 45 | } 46 | 47 | fn started(&mut self, _: &mut Context) { 48 | debug!("[{}] HA StreamHandler connected", self.id); 49 | } 50 | 51 | fn finished(&mut self, ctx: &mut Context) { 52 | debug!("[{}] HA StreamHandler disconnected", self.id); 53 | ctx.stop() 54 | } 55 | } 56 | 57 | impl actix::io::WriteHandler for HomeAssistantClient {} 58 | -------------------------------------------------------------------------------- /src/controller/handler/r2_event.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Actix message handler for [R2EventMsg]. 5 | 6 | use crate::controller::handler::{AbortDriverSetup, ConnectMsg, DisconnectMsg}; 7 | use crate::controller::{Controller, R2EventMsg}; 8 | use actix::{AsyncContext, Handler}; 9 | use log::error; 10 | use uc_api::intg::DeviceState; 11 | use uc_api::intg::ws::R2Event; 12 | 13 | impl Handler for Controller { 14 | type Result = (); 15 | 16 | fn handle(&mut self, msg: R2EventMsg, ctx: &mut Self::Context) -> Self::Result { 17 | let session = match self.sessions.get_mut(&msg.ws_id) { 18 | None => { 19 | error!("Session not found: {}", msg.ws_id); 20 | return; 21 | } 22 | Some(s) => s, 23 | }; 24 | 25 | match msg.event { 26 | R2Event::Connect => { 27 | if self.device_state != DeviceState::Connected { 28 | ctx.notify(ConnectMsg::default()); 29 | } 30 | // make sure client has the correct state, it might be out of sync, or not calling get_device_state 31 | self.send_device_state(&msg.ws_id); 32 | } 33 | R2Event::Disconnect => { 34 | ctx.notify(DisconnectMsg {}); 35 | } 36 | R2Event::EnterStandby => { 37 | session.standby = true; 38 | if self.settings.hass.disconnect_in_standby { 39 | ctx.notify(DisconnectMsg {}); 40 | } 41 | } 42 | R2Event::ExitStandby => { 43 | session.standby = false; 44 | if self.settings.hass.disconnect_in_standby { 45 | ctx.notify(ConnectMsg::default()); 46 | self.send_device_state(&msg.ws_id); 47 | } 48 | } 49 | R2Event::AbortDriverSetup => { 50 | ctx.notify(AbortDriverSetup { 51 | ws_id: msg.ws_id, 52 | timeout: false, 53 | }); 54 | } 55 | R2Event::Oauth2Authorization | R2Event::Oauth2Refreshed => { 56 | // ignore OAuth events: not supported 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /about-markdown.hbs: -------------------------------------------------------------------------------- 1 | # Licenses 2 | 3 | The Unfolded Circle Home-Assistant Integration for Remote Two is licensed under the Mozilla Public License 2.0. 4 | 5 | {{{{raw}}}} 6 | The project can be found at 7 | {{{{/raw}}}} 8 | 9 | ## Overview of Third Party Licenses 10 | 11 | These are the licenses for the libraries we use in the Home-Assistant Integration for Remote Two: 12 | 13 | {{#each overview}} 14 | - [{{{name}}}](#{{{id}}}) ({{count}}) 15 | {{/each}} 16 | - [Other](#other) (1) 17 | 18 | ## Attributions 19 | 20 | --- 21 | Copyright (c) 1998-2019 The OpenSSL Project. All rights reserved. 22 | 23 | This product contains software developed by the OpenSSL Project for use in the OpenSSL Toolkit (www.openssl.org/). 24 | This product contains cryptographic software by Eric Young (eay@cryptsoft.com). 25 | This product contains software by Tim Hudson (tjh@cryptsoft.com). 26 | 27 | --- 28 | 29 | ## All license text 30 | {{#each licenses}} 31 | ### {{{id}}} 32 | {{{name}}} 33 | 34 | #### Used by 35 | {{#each used_by}} 36 | - [{{{crate.name}}}]({{#if crate.repository}} {{crate.repository}} {{else}} https://crates.io/crates/{{crate.name}} {{/if}}) {{{crate.version}}} 37 | {{/each}} 38 | 39 | #### License 40 | ``` 41 | {{{text}}} 42 | ``` 43 | {{/each}} 44 | 45 | {{! Manually add licenses which could not be processed automatically}} 46 | ## Other 47 | ### webpki 48 | [webpki](https://github.com/briansmith/webpki) 0.22.0 49 | 50 | #### License 51 | ``` 52 | Except as otherwise noted, this project is licensed under the following 53 | (ISC-style) terms: 54 | 55 | Copyright 2015 Brian Smith. 56 | 57 | Permission to use, copy, modify, and/or distribute this software for any 58 | purpose with or without fee is hereby granted, provided that the above 59 | copyright notice and this permission notice appear in all copies. 60 | 61 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES 62 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 63 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR 64 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 65 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 66 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 67 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 68 | 69 | The files under third-party/chromium are licensed as described in 70 | third-party/chromium/LICENSE. 71 | ``` 72 | -------------------------------------------------------------------------------- /src/client/entity/switch.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Switch entity specific logic. 5 | 6 | use serde_json::{Map, Value}; 7 | use std::collections::HashMap; 8 | use uc_api::intg::AvailableIntgEntity; 9 | use uc_api::{EntityType, intg::EntityChange}; 10 | 11 | use crate::client::event::convert_ha_onoff_state; 12 | use crate::client::model::EventData; 13 | use crate::errors::ServiceError; 14 | 15 | pub(crate) fn map_switch_attributes( 16 | _entity_id: &str, 17 | state: &str, 18 | _ha_attr: Option<&mut Map>, 19 | ) -> Result, ServiceError> { 20 | let mut attributes = serde_json::Map::with_capacity(1); 21 | let state = convert_ha_onoff_state(state)?; 22 | 23 | attributes.insert("state".into(), state); 24 | 25 | Ok(attributes) 26 | } 27 | 28 | pub(crate) fn switch_event_to_entity_change( 29 | mut data: EventData, 30 | ) -> Result { 31 | let attributes = map_switch_attributes( 32 | &data.entity_id, 33 | &data.new_state.state, 34 | data.new_state.attributes.as_mut(), 35 | )?; 36 | 37 | Ok(EntityChange { 38 | device_id: None, 39 | entity_type: EntityType::Switch, 40 | entity_id: data.entity_id, 41 | attributes, 42 | }) 43 | } 44 | 45 | pub(crate) fn convert_switch_entity( 46 | entity_id: String, 47 | state: String, 48 | ha_attr: &mut Map, 49 | ) -> Result { 50 | let friendly_name = ha_attr.get("friendly_name").and_then(|v| v.as_str()); 51 | let name = HashMap::from([("en".into(), friendly_name.unwrap_or(&entity_id).into())]); 52 | let device_class = ha_attr.get("device_class").and_then(|v| v.as_str()); 53 | let device_class = match device_class { 54 | Some("outlet") | Some("switch") => device_class.map(|v| v.into()), 55 | _ => None, 56 | }; 57 | 58 | let attributes = Some(map_switch_attributes(&entity_id, &state, Some(ha_attr))?); 59 | 60 | Ok(AvailableIntgEntity { 61 | entity_id, 62 | device_id: None, // prepared device_id handling 63 | entity_type: EntityType::Switch, 64 | device_class, 65 | name, 66 | icon: None, 67 | features: Some(vec!["toggle".into()]), // OnOff is a default feature 68 | area: None, 69 | options: None, 70 | attributes, 71 | }) 72 | } 73 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | # Container Image for Home-Assistant Integration 2 | 3 | The provided [Dockerfile](Dockerfile) creates a Linux amd64 container for the Home Assistant integration. 4 | 5 | The information on this page are for building a container image yourself on a Linux host. 6 | To get the latest image from us, you can simply pull it from Dockerhub: 7 | 8 | ```bash 9 | docker pull docker.io/unfoldedcircle/integration-hass 10 | ``` 11 | 12 | ## Build 13 | 14 | Just run the provided `build.sh` script. This builds the project and creates the container image. 15 | 16 | Minimal manual build: 17 | ```bash 18 | cargo build --release 19 | mkdir -p app 20 | cp ../target/release/uc-intg-hass /app 21 | docker build -t integration-hass . 22 | ``` 23 | 24 | See [build script](build.sh) for more information, e.g. optional configuration file and build labels. 25 | 26 | ## Run 27 | 28 | To run the Home Assistant integration you need the Home Assistant server API URL and a long-lived access token. 29 | 30 | They can either be provided by the user in the driver setup flow, or statically configured in the configuration file or 31 | set with the environment variables `UC_HASS_URL` and `UC_HASS_TOKEN`. 32 | 33 | By default, the integration tries to connect to . 34 | 35 | Provide configuration with the driver setup flow and store user configuration in a volume: 36 | ```bash 37 | docker run --rm --name uc-intg-hass \ 38 | -p 8000:8000 \ 39 | integration-hass:latest 40 | ``` 41 | 42 | The configuration will be saved in the `$UC_CONFIG_HOME` directory, which is by default a volume. This can also be 43 | bind-mounted to the host (directory needs to be writeable for user_id 10000): 44 | ```bash 45 | docker run --rm --name uc-intg-hass \ 46 | -p 8000:8000 \ 47 | -v $YOUR_HOST_CONFIG_DIRECTORY:/config \ 48 | integration-hass:latest 49 | ``` 50 | 51 | The Home Assistant server configuration can also be set with environment variables: 52 | ```bash 53 | docker run --rm --name uc-intg-hass \ 54 | -e UC_HASS_URL=$YOUR_HOME_ASSISTANT_URL \ 55 | -e UC_HASS_TOKEN=$YOUR_LONG_LIVED_ACCESS_TOKEN \ 56 | -p 8000:8000 \ 57 | integration-hass:latest 58 | ``` 59 | 60 | ## FAQ 61 | 62 | - Where do I get the long-lived access token from? 63 | - A long-lived access token must be created in the Home Assistant user profile (your name, bottom left). 64 | - Why is this in a subdirectory? 65 | - To reduce the build context. After building debug & release versions the full project can easily reach multiple 66 | gigabytes instead of just a few megabytes for the static release binary. 67 | - Why just a Linux container? 68 | - Our main development environment is Linux and our resources are limited. 69 | - We happily accept PRs to support other architectures. 70 | -------------------------------------------------------------------------------- /src/server/zeroconf.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! mDNS advertisement with Zeroconf (Avahi or Bonjour) 5 | 6 | use crate::errors::ServiceError; 7 | 8 | use log::{error, info}; 9 | use std::any::Any; 10 | use std::sync::Arc; 11 | use std::time::Duration; 12 | use zeroconf::prelude::*; 13 | use zeroconf::{MdnsService, ServiceRegistration, ServiceType, TxtRecord}; 14 | 15 | /// Publish a service on all available network interfaces with the default hostname. 16 | /// 17 | /// # Arguments 18 | /// 19 | /// * `instance_name`: Instance name 20 | /// * `service_name`: The service name (e.g. `http`). 21 | /// * `protocol`: The protocol of the service (e.g. `tcp`). 22 | /// * `port`: The port on which the service accepts connections. 23 | /// * `txt`: Optional TXT record data with format: `key=value`. The value is optional. 24 | pub fn publish_service( 25 | instance_name: impl ToString, 26 | service_name: impl AsRef, 27 | protocol: impl AsRef, 28 | port: u16, 29 | txt: Vec, 30 | ) -> Result<(), ServiceError> { 31 | let instance_name = instance_name.to_string(); 32 | let service = ServiceType::new(service_name.as_ref(), protocol.as_ref()) 33 | .map_err(|e| ServiceError::BadRequest(e.to_string()))?; 34 | std::thread::spawn(move || service_publisher(instance_name, service, port, txt)); 35 | 36 | Ok(()) 37 | } 38 | 39 | /// Publisher thread polling the event loop. 40 | fn service_publisher( 41 | instance_name: String, 42 | service_type: ServiceType, 43 | port: u16, 44 | txt: Vec, 45 | ) { 46 | let mut service = MdnsService::new(service_type, port); 47 | let mut txt_record = TxtRecord::new(); 48 | 49 | for record in txt { 50 | if let Some((key, value)) = record.split_once('=') { 51 | txt_record.insert(key, value).unwrap(); 52 | } 53 | } 54 | 55 | service.set_name(instance_name.as_ref()); 56 | service.set_registered_callback(Box::new(on_service_registered)); 57 | service.set_txt_record(txt_record); 58 | 59 | let event_loop = match service.register() { 60 | Ok(el) => el, 61 | Err(e) => { 62 | error!("Failed to register service! Error: {e}"); 63 | return; 64 | } 65 | }; 66 | 67 | loop { 68 | // What is a good production timeout? 69 | if let Err(e) = event_loop.poll(Duration::from_secs(1)) { 70 | error!("mDNS event loop polling error: {e}"); 71 | return; 72 | } 73 | } 74 | } 75 | 76 | fn on_service_registered( 77 | result: zeroconf::Result, 78 | _context: Option>, 79 | ) { 80 | match result { 81 | Ok(r) => info!("{:?}", r), 82 | Err(e) => error!("Service registration error: {e}"), 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/server/mdns.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! mDNS advertisement with mdns-sd Rust crate 5 | 6 | use crate::errors::ServiceError; 7 | use crate::util::my_ipv4_interfaces; 8 | use lazy_static::lazy_static; 9 | use log::{error, info}; 10 | use mdns_sd::{ServiceDaemon, ServiceInfo}; 11 | use std::net::IpAddr; 12 | 13 | lazy_static! { 14 | pub static ref MDNS_SERVICE: Option = match ServiceDaemon::new() { 15 | Ok(s) => Some(s), 16 | Err(e) => { 17 | error!( 18 | "Failed to create mdns daemon, service publishing won't be available! Error: {e}" 19 | ); 20 | None 21 | } 22 | }; 23 | } 24 | 25 | /// Publish a service on all available network interfaces with the default hostname. 26 | /// 27 | /// # Arguments 28 | /// 29 | /// * `instance_name`: Instance name 30 | /// * `service_name`: The service name (e.g. `http`). 31 | /// * `protocol`: The protocol of the service (e.g. `tcp`). 32 | /// * `port`: The port on which the service accepts connections. 33 | /// * `txt`: Optional TXT record data with format: `key=value`. The value is optional. 34 | pub fn publish_service( 35 | instance_name: impl AsRef, 36 | service_name: impl AsRef, 37 | protocol: impl AsRef, 38 | port: u16, 39 | txt: Vec, 40 | ) -> Result<(), ServiceError> { 41 | if let Some(mdns_service) = &*MDNS_SERVICE { 42 | let mut reg_type = format!("_{}._{}", service_name.as_ref(), protocol.as_ref()); 43 | if !reg_type.ends_with(".local.") { 44 | reg_type.push_str(".local."); 45 | } 46 | let my_addrs: Vec = my_ipv4_interfaces().iter().map(|i| i.ip()).collect(); 47 | let hostname = hostname::get().map(|name| name.to_string_lossy().to_string())?; 48 | 49 | let properties = txt 50 | .iter() 51 | .filter_map(|v| v.split_once('=')) 52 | .map(|(k, v)| (k.into(), v.into())) 53 | .collect(); 54 | if let Err(e) = ServiceInfo::new( 55 | ®_type, 56 | instance_name.as_ref(), 57 | &hostname, 58 | &my_addrs[..], 59 | port, 60 | Some(properties), 61 | ) 62 | .and_then(|service_info| { 63 | let fullname = service_info.get_fullname().to_string(); 64 | mdns_service.register(service_info)?; 65 | info!("Registered service: {fullname}"); 66 | Ok(()) 67 | }) { 68 | Err(ServiceError::InternalServerError(format!( 69 | "Failed to register {reg_type} mdns service! Error: {e}" 70 | ))) 71 | } else { 72 | Ok(()) 73 | } 74 | } else { 75 | Err(ServiceError::ServiceUnavailable( 76 | "mDNS service not available".into(), 77 | )) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /.github/actions/rust-setup/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Rust setup' 2 | description: 'Install all required tools to build uc-intg-hass' 3 | inputs: 4 | toolchain_components: 5 | description: 'Comma-separated string of additional components to install e.g. clippy, rustfmt' 6 | required: true 7 | default: '' 8 | target: 9 | description: 'Rust target. Only required for cross compiling' 10 | required: true 11 | default: 'default' 12 | build: 13 | description: 'Target build (release or debug). Only used for caching key.' 14 | required: true 15 | default: 'default' 16 | # parameter & boolean handling is a major PITA!!! Stick to string compare, everything else just asks for trouble! 17 | use_gh_cache: 18 | description: | 19 | Use GitHub cache for cargo dependencies and target artifacts. Significantly speeds up GitHub runners, but has 20 | a huge negative impact on self-hosted runners! Caches are around 2.3 GB and transfer speed to self-hosted 21 | runners is max 1 MB/s. 22 | required: true 23 | default: 'true' 24 | incremental: 25 | description: Enable rust incremental build 26 | required: true 27 | default: 'false' 28 | 29 | runs: 30 | using: "composite" 31 | steps: 32 | - name: GH cache is active 33 | if: inputs.use_gh_cache == 'true' 34 | shell: bash 35 | run: | 36 | echo "GitHub cache is active" 37 | 38 | - name: GH cache is NOT active 39 | if: inputs.use_gh_cache == 'false' 40 | shell: bash 41 | run: | 42 | echo "GitHub cache is NOT active" 43 | 44 | - name: Cache dependencies 45 | if: inputs.use_gh_cache == 'true' 46 | id: cache-dependencies 47 | uses: actions/cache@v4 48 | with: 49 | path: | 50 | ~/.cargo/registry 51 | ~/.cargo/git 52 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 53 | 54 | - name: Cache target 55 | if: inputs.use_gh_cache == 'true' 56 | id: cache-target 57 | uses: actions/cache@v4 58 | with: 59 | path: | 60 | target 61 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}-${{ inputs.target }}-${{ inputs.build }} 62 | 63 | - name: Set incremental build 64 | if: inputs.incremental == 'true' 65 | shell: bash 66 | run: | 67 | echo "Enabling Rust incremental build with CARGO_INCREMENTAL=1" 68 | echo "CARGO_INCREMENTAL=1" >> $GITHUB_ENV 69 | 70 | - name: Install toolchain 71 | uses: dtolnay/rust-toolchain@stable 72 | with: 73 | components: ${{ inputs.toolchain_components }} 74 | 75 | # - name: Install required libraries 76 | # # only required for default host target. Crosscompile targets must include libraries in required toolchain. 77 | # if: inputs.target == 'default' 78 | # run: | 79 | # sudo apt-get update 80 | # sudo apt-get install libdbus-1-dev libavahi-client-dev libsystemd-dev -y 81 | # shell: bash 82 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First off, thanks for taking the time to contribute! 4 | 5 | Found a bug, typo, missing feature or a description that doesn't make sense or needs clarification? 6 | Great, please let us know! 7 | 8 | ### Bug Reports :bug: 9 | 10 | If you find a bug, please search for it first in the [GitHub issues](https://github.com/unfoldedcircle/integration-home-assistant/issues), 11 | and if it isn't already tracked, [create a new issue](https://github.com/unfoldedcircle/integration-home-assistant/issues/new). 12 | 13 | ### Pull Requests 14 | 15 | **Any pull request needs to be reviewed and approved by the Unfolded Circle development team.** 16 | 17 | We love contributions from everyone. 18 | 19 | ⚠️ If you plan to make substantial changes, we kindly ask you, that you please reach out to us first. 20 | Either by opening a feature request describing your proposed changes before submitting code, or by contacting us on 21 | one of the other [feedback channels](#feedback-speech_balloon). 22 | 23 | Since this software (or part of it) is being used on the embedded Remote Two device, we have to make sure it remains 24 | compatible with the embedded runtime environment and runs smoothly. 25 | 26 | With that out of the way, here's the process of creating a pull request and making sure it passes the automated tests: 27 | 28 | ### Contributing Code :bulb: 29 | 30 | 1. Fork the repo. 31 | 32 | 2. Make your changes or enhancements (preferably on a feature-branch). 33 | 34 | Contributed code must be licensed under the Mozilla Public License 2.0 (MPL-2.0). 35 | It is required to add a boilerplate copyright notice to the top of each file: 36 | 37 | ``` 38 | // Copyright {year} {person OR org} <{email}> 39 | // SPDX-License-Identifier: MPL-2.0 40 | ``` 41 | 42 | 3. Make sure your changes make the tests pass: 43 | ```shell 44 | cargo test 45 | ``` 46 | 47 | 4. Make sure your changes make the lints pass: 48 | ```shell 49 | cargo clippy 50 | ``` 51 | 52 | - If clippy is missing, install it with: `cargo install clippy`. 53 | - ℹ️ Keep clippy up to date (e.g. with `rustup update`), it's regularly improved with updates. 54 | 55 | 5. Make sure your changes follow the project's code style. 56 | We are using the official [Rust style guide](https://github.com/rust-lang/style-team/blob/master/guide/guide.md). 57 | ```shell 58 | cargo fmt --all -- --check 59 | ``` 60 | 61 | 6. If you added new Rust crate dependencies verify their licenses: 62 | ```shell 63 | cargo install cargo-about 64 | cargo about generate abouthbs > integration-hass_licenses.html 65 | ``` 66 | 67 | 7. Push to your fork. 68 | 69 | 8. Submit a pull request. 70 | 71 | At this point we will review the PR and give constructive feedback. 72 | This is a time for discussion and improvements, and making the necessary changes will be required before we can 73 | merge the contribution. 74 | 75 | ### Feedback :speech_balloon: 76 | 77 | There are a few different ways to provide feedback: 78 | 79 | - [Create a new issue](https://github.com/unfoldedcircle/integration-home-assistant/issues/new) 80 | - [Reach out to us on Twitter](https://twitter.com/unfoldedcircle) 81 | - [Visit our community forum](http://unfolded.community/) 82 | - [Chat with us in our Discord channel](http://unfolded.chat/) 83 | - [Send us a message on our website](https://unfoldedcircle.com/contact) 84 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "uc-intg-hass" 3 | version = "0.14.1" 4 | edition = "2024" 5 | rust-version = "1.85.0" 6 | authors = ["Markus Zehnder "] 7 | license = "MPL-2.0" 8 | description = "Unfolded Circle Home-Assistant integration for Remote Two/3" 9 | repository = "https://github.com/unfoldedcircle/integration-home-assistant" 10 | default-run = "uc-intg-hass" 11 | 12 | [profile.release] 13 | strip = true # Automatically strip symbols from the binary. 14 | 15 | [lib] 16 | path = "src/lib.rs" 17 | 18 | [[bin]] 19 | name = "uc-intg-hass" 20 | path = "src/main.rs" 21 | 22 | [[bin]] 23 | name = "ha-test" 24 | path = "src/bin/ha_test.rs" 25 | 26 | [[bin]] 27 | name = "voice-command" 28 | path = "src/bin/voice_command.rs" 29 | required-features = ["audio"] 30 | 31 | [features] 32 | default = [] 33 | mdns-sd = ["dep:mdns-sd"] 34 | zeroconf = ["dep:zeroconf"] 35 | audio = ["dep:hound"] 36 | 37 | [dependencies] 38 | uc_api = { git = "https://github.com/unfoldedcircle/api-model-rs", tag = "v0.15.0", features = ["prost"] } 39 | # for local development: 40 | #uc_api = { path = "../api-model-rs", features = ["prost"] } 41 | # Using a GitHub revision: 42 | #uc_api = { git = "https://github.com/unfoldedcircle/api-model-rs", rev = "20dda30b0614c9d886eea8c417c96b2f7b3bbf82", features = ["prost"] } 43 | 44 | # WebSockets server 45 | actix-web = { version = "4.11", features = ["rustls-0_23"] } 46 | actix-ws = "0.3" 47 | actix = "0.13" 48 | rustls = { version = "0.23" } 49 | rustls-platform-verifier = "0.6" 50 | rustls-pemfile = "2" 51 | # WebSockets client 52 | actix-codec = "0.5" 53 | awc = { version = "3.8", features = ["rustls-0_23"] } 54 | bytes = "1" 55 | futures = "0.3" 56 | # Tokio is used by actix, but AFAIK the select! macro is not re-exported 57 | tokio = { version = "1", features = ["macros"] } 58 | prost = "0.14" 59 | 60 | # see mdns-sd patch at the end of this file 61 | mdns-sd = { version = "0.9.3", optional = true } 62 | if-addrs = "0.14" 63 | hostname = "0.4" 64 | zeroconf = { version = "0.15", optional = true } 65 | 66 | # JSON (de)serialization 67 | serde = { version = "1", features = ["derive"] } 68 | serde_json = "1" 69 | serde_with = "3" 70 | 71 | rust-fsm = "0.6" 72 | 73 | clap = { version = "4", features = ["derive"] } 74 | config = { version = "0.15", default-features = false, features = ["yaml", "json"] } 75 | const_format = "0.2" 76 | env_logger = "0.11" 77 | lazy_static = "1.4" 78 | log = "0.4" 79 | 80 | uuid = { version = "1", features = ["v4"] } 81 | url = { version = "2", features = ["serde"] } 82 | 83 | # Helpful macros for working with enums and strings 84 | # Attention: strum needs to be in sync with uc_api 85 | strum = "0.27" 86 | derive_more = { version = "2", features = ["constructor", "display"] } 87 | derive_builder = "0.20" 88 | 89 | anyhow = { version = "1", features = [] } 90 | 91 | hound = { version = "3.5", optional = true } 92 | 93 | [build-dependencies] 94 | # Warning: git2 feature causes a stack smashing error in cross-compilation, even with 16MB stack! 95 | # Solved with a custom git callout in build.rs 96 | built = { version = "0.8", features = ["chrono", "semver", "cargo-lock"] } 97 | 98 | [dev-dependencies] 99 | rstest = "0.26" 100 | 101 | [patch.crates-io] 102 | mdns-sd = { git = "https://github.com/zehnm/mdns-sd", rev = "aa95af75e21f40ee9ea74d117ca31e0672d722fb" } 103 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Custom application error with conversions from common Rust and 3rd-party errors. 5 | 6 | use actix::MailboxError; 7 | use actix::dev::SendError; 8 | use derive_more::Display; 9 | use log::error; 10 | use std::io::ErrorKind; 11 | 12 | #[derive(Debug, Display, PartialEq)] 13 | pub enum ServiceError { 14 | #[display("Internal server error")] 15 | InternalServerError(String), 16 | 17 | #[display("Internal serialization error")] 18 | SerializationError(String), 19 | 20 | #[display("BadRequest: {}", _0)] 21 | BadRequest(String), 22 | 23 | #[display("Not found: {}", _0)] 24 | NotFound(String), 25 | 26 | #[display("The connection is closed or closing")] 27 | NotConnected, 28 | 29 | ServiceUnavailable(String), 30 | 31 | #[allow(dead_code)] // temporarily used for development 32 | NotYetImplemented, 33 | } 34 | 35 | impl From for ServiceError { 36 | fn from(error: std::io::Error) -> Self { 37 | match error.kind() { 38 | ErrorKind::NotFound => ServiceError::NotFound(error.to_string()), 39 | // ErrorKind::PermissionDenied => ServiceError::AuthError(error.to_string()), 40 | ErrorKind::AlreadyExists | ErrorKind::InvalidInput | ErrorKind::InvalidData => { 41 | ServiceError::BadRequest(error.to_string()) 42 | } 43 | 44 | ErrorKind::ConnectionRefused 45 | | ErrorKind::ConnectionReset 46 | | ErrorKind::ConnectionAborted 47 | | ErrorKind::NotConnected 48 | | ErrorKind::AddrInUse 49 | | ErrorKind::AddrNotAvailable 50 | | ErrorKind::TimedOut => { 51 | error!("Connection error: {error:?}"); 52 | ServiceError::ServiceUnavailable(error.to_string()) 53 | } 54 | ErrorKind::BrokenPipe 55 | | ErrorKind::WouldBlock 56 | | ErrorKind::WriteZero 57 | | ErrorKind::Interrupted 58 | | ErrorKind::Unsupported 59 | | ErrorKind::UnexpectedEof 60 | | ErrorKind::OutOfMemory 61 | | ErrorKind::Other => { 62 | error!("Internal error: {:?}", error); 63 | ServiceError::InternalServerError(format!("{error:?}")) 64 | } 65 | _ => { 66 | error!("Other error: {:?}", error); 67 | ServiceError::InternalServerError(format!("{error:?}")) 68 | } 69 | } 70 | } 71 | } 72 | 73 | impl From for ServiceError { 74 | fn from(e: MailboxError) -> Self { 75 | ServiceError::InternalServerError(format!("Internal message error: {:?}", e)) 76 | } 77 | } 78 | 79 | impl From for ServiceError { 80 | fn from(e: serde_json::Error) -> Self { 81 | error!("{:?}", e); 82 | ServiceError::SerializationError(e.to_string()) 83 | } 84 | } 85 | 86 | impl From for ServiceError { 87 | fn from(e: strum::ParseError) -> Self { 88 | ServiceError::SerializationError(e.to_string()) 89 | } 90 | } 91 | 92 | impl From> for ServiceError { 93 | fn from(e: SendError) -> Self { 94 | ServiceError::InternalServerError(format!("Error sending internal message: {:?}", e)) 95 | } 96 | } 97 | 98 | impl From for ServiceError { 99 | fn from(e: url::ParseError) -> Self { 100 | ServiceError::BadRequest(e.to_string()) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/controller/messages.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Actix actor message definitions used to communicate with the [`Controller`]. 5 | //! 6 | //! These are the Actix messages used for the Remote Two WebSocket server connections and the 7 | //! Home Assistant client connections to interact with the Controller. 8 | 9 | #[allow(unused_imports)] // used for doc links 10 | use crate::controller::Controller; 11 | use crate::errors::ServiceError; 12 | use crate::util::DeserializeMsgData; 13 | use actix::prelude::{Message, Recipient}; 14 | use bytes::Bytes; 15 | use derive_more::Constructor; 16 | use uc_api::intg::ws::{R2Event, R2Request, R2Response}; 17 | use uc_api::ws::WsMessage; 18 | 19 | /// Send a WebSocket message to Remote Two. 20 | /// 21 | /// The [`WsMessage`] is either an Integration-API request, response or event message. 22 | /// Sending is best-effort only! 23 | #[derive(Message)] 24 | #[rtype(result = "()")] 25 | pub struct SendWsMessage(pub WsMessage); 26 | 27 | /// New WebSocket connection from Remote Two established. 28 | /// 29 | /// Event to notify the [`Controller`] that a new WS integration client connected. 30 | #[derive(Message)] 31 | #[rtype(result = "()")] 32 | pub struct NewR2Session { 33 | /// Actor address of the WS session to send messages to 34 | pub addr: Recipient, 35 | /// unique identifier of WS connection 36 | pub id: String, 37 | } 38 | 39 | /// Remote Two WebSocket connection disconnected. 40 | /// 41 | /// Event to notify the [`Controller`] that a WS client connection disconnected. 42 | #[derive(Message)] 43 | #[rtype(result = "()")] 44 | pub struct R2SessionDisconnect { 45 | /// unique identifier of WS connection 46 | pub id: String, 47 | } 48 | 49 | /// Actor message for a Remote Two request. 50 | /// 51 | /// Pass an integration API request message fom a connected integration client to the 52 | /// [`Controller`]. The controller can either respond directly with a response if some [WsMessage] 53 | /// is returned, or asynchronously at a later time if `None` is returned. 54 | /// 55 | /// - a returned [ServiceError] will mapped to an error response message for the Remote Two. 56 | #[derive(Debug, Message)] 57 | #[rtype(result = "Result, ServiceError>")] 58 | pub struct R2RequestMsg { 59 | pub ws_id: String, 60 | pub req_id: u32, 61 | pub request: R2Request, 62 | pub msg_data: Option, 63 | } 64 | 65 | /// Actor message for a Remote Two response. 66 | #[derive(Debug, Message)] 67 | #[rtype(result = "()")] 68 | pub struct R2ResponseMsg { 69 | pub ws_id: String, 70 | pub msg: R2Response, 71 | pub response: WsMessage, 72 | } 73 | 74 | /// Convert the full request message to only the message data payload. 75 | /// 76 | /// Required for [`DeserializeMsgData`] trait. 77 | #[allow(clippy::from_over_into)] // we only need into 78 | impl Into> for R2RequestMsg { 79 | fn into(self) -> Option { 80 | self.msg_data 81 | } 82 | } 83 | 84 | impl DeserializeMsgData for R2RequestMsg {} 85 | 86 | /// Actor message for a Remote Two event. 87 | /// 88 | /// Pass an integration API event message fom a connected integration client to the [`Controller`]. 89 | #[derive(Debug, Message)] 90 | #[rtype(result = "()")] 91 | #[allow(dead_code)] // msg_data not used 92 | pub struct R2EventMsg { 93 | pub ws_id: String, 94 | pub event: R2Event, 95 | pub msg_data: Option, 96 | } 97 | 98 | /// Actor message containing a received audio chunk. 99 | #[derive(Constructor, Message)] 100 | #[rtype(result = "Result<(), ServiceError>")] 101 | pub struct R2AudioChunkMsg { 102 | /// Remote audio session ID 103 | pub session_id: u32, 104 | /// Audio chunk 105 | pub data: Bytes, 106 | } 107 | -------------------------------------------------------------------------------- /src/util/network.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | use crate::configuration::ENV_DISABLE_CERT_VERIFICATION; 5 | use crate::util::bool_from_env; 6 | use rustls::ClientConfig; 7 | use std::sync::Arc; 8 | use std::time::Duration; 9 | 10 | #[cfg(feature = "mdns-sd")] 11 | pub fn my_ipv4_interfaces() -> Vec { 12 | if_addrs::get_if_addrs() 13 | .unwrap_or_default() 14 | .into_iter() 15 | .filter_map(|i| { 16 | if i.is_loopback() { 17 | None 18 | } else { 19 | match i.addr { 20 | if_addrs::IfAddr::V4(_) => Some(i.addr), 21 | _ => None, 22 | } 23 | } 24 | }) 25 | .collect() 26 | } 27 | 28 | pub fn new_websocket_client( 29 | connection_timeout: Duration, 30 | request_timeout: Duration, 31 | disable_cert_validation: bool, 32 | ) -> awc::Client { 33 | use rustls_platform_verifier::ConfigVerifierExt as _; 34 | 35 | // TLS configuration: https://github.com/actix/actix-web/blob/master/awc/tests/test_rustls_client.rs 36 | // TODO self-signed certificate handling #4 37 | let mut config = 38 | ClientConfig::with_platform_verifier().expect("Platform certificate verifier required"); 39 | 40 | // http2 has (or at least had) issues with wss. Needs further investigation. 41 | config.alpn_protocols = vec![b"http/1.1".to_vec()]; 42 | 43 | // Disable TLS verification 44 | if disable_cert_validation || bool_from_env(ENV_DISABLE_CERT_VERIFICATION) { 45 | config 46 | .dangerous() 47 | .set_certificate_verifier(Arc::new(danger::NoCertificateVerification {})); 48 | } 49 | 50 | awc::Client::builder() 51 | .timeout(request_timeout) 52 | .connector( 53 | awc::Connector::new() 54 | .rustls_0_23(Arc::new(config)) 55 | .timeout(connection_timeout), 56 | ) 57 | .finish() 58 | } 59 | 60 | mod danger { 61 | use log::warn; 62 | use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; 63 | use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; 64 | use rustls::{DigitallySignedStruct, Error, SignatureScheme}; 65 | use std::fmt::Debug; 66 | 67 | #[derive(Debug)] 68 | pub struct NoCertificateVerification {} 69 | 70 | impl ServerCertVerifier for NoCertificateVerification { 71 | fn verify_server_cert( 72 | &self, 73 | _end_entity: &CertificateDer<'_>, 74 | _intermediates: &[CertificateDer<'_>], 75 | _server_name: &ServerName<'_>, 76 | _ocsp_response: &[u8], 77 | _now: UnixTime, 78 | ) -> Result { 79 | warn!("Certificate verification disabled"); 80 | Ok(ServerCertVerified::assertion()) 81 | } 82 | 83 | fn verify_tls12_signature( 84 | &self, 85 | _message: &[u8], 86 | _cert: &CertificateDer<'_>, 87 | _dss: &DigitallySignedStruct, 88 | ) -> Result { 89 | Ok(HandshakeSignatureValid::assertion()) 90 | } 91 | 92 | fn verify_tls13_signature( 93 | &self, 94 | _message: &[u8], 95 | _cert: &CertificateDer<'_>, 96 | _dss: &DigitallySignedStruct, 97 | ) -> Result { 98 | Ok(HandshakeSignatureValid::assertion()) 99 | } 100 | 101 | fn supported_verify_schemes(&self) -> Vec { 102 | rustls::crypto::aws_lc_rs::default_provider() 103 | .signature_verification_algorithms 104 | .supported_schemes() 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /resources/driver.json: -------------------------------------------------------------------------------- 1 | { 2 | "driver_id": "hass", 3 | "features": [ 4 | { 5 | "name": "auth.external_tokens", 6 | "required": false 7 | } 8 | ], 9 | "version": "", 10 | "min_core_api": "0.20.0", 11 | "name": { 12 | "en": "Home Assistant" 13 | }, 14 | "icon": "uc:integration", 15 | "description": { 16 | "en": "Control Home Assistant entities with Remote Two/3.", 17 | "de": "Steuere Home Assistant Entitäten mit Remote Two/3.", 18 | "fr": "Contrôler les entités de Home Assistant avec Remote Two/3." 19 | }, 20 | "developer": { 21 | "name": "Unfolded Circle ApS", 22 | "email": "hello@unfoldedcircle.com", 23 | "url": "https://support.unfoldedcircle.com/hc/en-us/articles/19479726340380" 24 | }, 25 | "home_page": "https://www.unfoldedcircle.com", 26 | "setup_data_schema": { 27 | "title": { 28 | "en": "Home Assistant settings", 29 | "de": "Home Assistant Konfiguration", 30 | "fr": "Configuration Home Assistant" 31 | }, 32 | "settings": [ 33 | { 34 | "id": "info", 35 | "label": { 36 | "en": "Home Assistant Server", 37 | "fr": "Serveur Home Assistant" 38 | }, 39 | "field": { 40 | "label": { 41 | "value": { 42 | "en": "The driver requires WebSocket API access to communicate with Home Assistant.\nSee [Home Assistant documentation](https://www.home-assistant.io/docs/authentication/) for more information on how to create a long lived access token.\n\nWhen using the [Unfolded Circle for Home Assistant component](https://github.com/JackJPowell/hass-unfoldedcircle), the connection parameters are automatically configured and you don't need to manually set an access token.\n\nThe _enhanced options_ below can be used to change expert connection parameters.\n\nPlease see our [support article](https://support.unfoldedcircle.com/hc/en-us/articles/19479726340380) for requirements, features and restrictions.", 43 | "de": "Der Treiber benötigt WebSocket-API Zugriff, um mit Home Assistant zu kommunizieren.\nWeitere Informationen zur Erstellung eines langlebigen Zugriffstokens findest du in der [Home Assistant Dokumentation](https://www.home-assistant.io/docs/authentication/).\n\nWenn Du die [Unfolded Circle for Home Assistant](https://github.com/JackJPowell/hass-unfoldedcircle) Komponente verwendest, werden die Verbindungsparameter automatisch konfiguriert, und Du musst kein Zugriffstoken manuell setzen.\n\nMit den _erweiterten Optionen_ können Experten-Verbindungsparameter geändert werden.\n\nBitte beachte unseren [Support-Artikel](https://support.unfoldedcircle.com/hc/en-us/articles/19479726340380) zu Anforderungen, unterstützten Funktionen und Einschränkungen.", 44 | "fr": "Le pilote nécessite l'accès à l'API WebSocket pour communiquer avec Home Assistant.\nVoir [Home Assistant documentation](https://www.home-assistant.io/docs/authentication/) pour plus d'informations sur la création d'un \"long lived access token\".\n\nLorsque vous utilisez le composant [Unfolded Circle for Home Assistant](https://github.com/JackJPowell/hass-unfoldedcircle), les paramètres de connexion sont automatiquement configurés et vous n'avez pas besoin de définir manuellement un jeton d'accès.\n\nLes _options avancées_ ci-dessous peuvent être utilisées pour modifier les paramètres de connexion des experts.\n\nConsulte [l’article de support](https://support.unfoldedcircle.com/hc/en-us/articles/19479726340380) pour connaître les exigences, les fonctionnalités prises en charge et les restrictions." 45 | } 46 | } 47 | } 48 | }, 49 | { 50 | "id": "expert", 51 | "label": { 52 | "en": "Configure enhanced options", 53 | "de": "Erweiterte Optionen konfigurieren", 54 | "fr": "Configurer les options avancées" 55 | }, 56 | "field": { 57 | "checkbox": { 58 | "value": false 59 | } 60 | } 61 | } 62 | ] 63 | }, 64 | "release_date": "2025-12-02" 65 | } 66 | -------------------------------------------------------------------------------- /src/client/service/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Home Assistant WebSocket service call handler. 5 | //! Translates Remote Two's entity commands into HA `call_service` JSON messages. 6 | //! 7 | //! See for further 8 | //! information. 9 | 10 | use crate::client::HomeAssistantClient; 11 | use crate::client::messages::CallService; 12 | use crate::client::model::{CallServiceMsg, Target}; 13 | use crate::errors::ServiceError; 14 | use actix::Handler; 15 | use log::info; 16 | use uc_api::EntityType; 17 | 18 | mod button; 19 | mod climate; 20 | mod cover; 21 | mod light; 22 | mod media_player; 23 | mod remote; 24 | mod switch; 25 | 26 | impl Handler for HomeAssistantClient { 27 | type Result = Result<(), ServiceError>; 28 | 29 | /// Convert a R2 `EntityCommand` to a HA `call_service` request and send it as WebSocket text 30 | /// message. 31 | /// The conversion of the entity logic is delegated to entity specific functions in this crate. 32 | /// 33 | /// # Arguments 34 | /// 35 | /// * `msg`: Actor message containing the R2 `EntityCommand` structure. 36 | /// * `ctx`: Actor execution context 37 | /// 38 | /// returns: Result<(), ServiceError> 39 | fn handle(&mut self, msg: CallService, ctx: &mut Self::Context) -> Self::Result { 40 | // map Remote Two command name & parameters to HA service name and service_data payload 41 | let (service, service_data) = match msg.command.entity_type { 42 | EntityType::Button => button::handle_button(&msg.command), 43 | EntityType::Switch => switch::handle_switch(&msg.command), 44 | EntityType::Climate => climate::handle_climate(&msg.command), 45 | EntityType::Cover => cover::handle_cover(&msg.command), 46 | EntityType::Light => light::handle_light(&msg.command), 47 | EntityType::MediaPlayer => media_player::handle_media_player(&msg.command), 48 | EntityType::Remote => remote::handle_remote(&msg.command), 49 | EntityType::Sensor => Err(ServiceError::BadRequest( 50 | "Sensor doesn't support sending commands to! Ignoring call".to_string(), 51 | )), 52 | EntityType::Activity | EntityType::Macro => Err(ServiceError::BadRequest(format!( 53 | "{} is an internal remote-core entity", 54 | msg.command.entity_type 55 | ))), 56 | EntityType::IrEmitter => Err(ServiceError::BadRequest( 57 | "IR-emitter not supported! Ignoring call".to_string(), 58 | )), 59 | EntityType::VoiceAssistant => Err(ServiceError::BadRequest( 60 | "Voice Assistant doesn't support sending entity service commands! Ignoring call" 61 | .to_string(), 62 | )), 63 | }?; 64 | info!( 65 | "[{}] Calling {} service '{service}'", 66 | self.id, msg.command.entity_id 67 | ); 68 | 69 | let domain = match msg.command.entity_id.split_once('.') { 70 | None => return Err(ServiceError::BadRequest("Invalid entity_id format".into())), 71 | Some((l, _)) => l.to_string(), 72 | }; 73 | 74 | let call_srv_msg = CallServiceMsg { 75 | id: self.new_msg_id(), 76 | msg_type: "call_service".to_string(), 77 | domain, 78 | service, 79 | service_data, 80 | target: Target { 81 | entity_id: msg.command.entity_id, 82 | }, 83 | }; 84 | 85 | let msg = serde_json::to_value(call_srv_msg)?; 86 | self.send_json(msg, ctx) 87 | 88 | // TODO wait for HA response message? If the service call fails we'll get a result back with "success: false" 89 | // However, some services take a long time to respond! E.g. Sonos might take 10 seconds if there's an issue with the network. 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /about.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 36 | Third Party Licenses 37 | 38 | 39 | 40 |
41 |
42 |

Licenses

43 |

The Unfolded Circle Home-Assistant Integration for Remote Two is licensed under the Mozilla Public License 2.0.

44 |
45 | 46 |

Overview of Third Party Licenses

47 |

These are the licenses for the libraries we use in the Home-Assistant Integration for Remote Two:

48 |
    49 | {{#each overview}} 50 |
  • {{name}} ({{count}})
  • 51 | {{/each}} 52 |
  • Other (1)
  • 53 |
54 | 55 |

Attributions

56 |
 57 | Copyright (c) 1998-2019 The OpenSSL Project.  All rights reserved.
 58 | 
 59 | This product contains software developed by the OpenSSL Project for use in the OpenSSL Toolkit (www.openssl.org/).
 60 | This product contains cryptographic software by Eric Young (eay@cryptsoft.com).
 61 | This product contains software by Tim Hudson (tjh@cryptsoft.com).
 62 | 
63 | 64 |

All license text

65 |
    66 | {{#each licenses}} 67 |
  • 68 |

    {{name}}

    69 |

    Used by:

    70 | 75 |
    {{text}}
    76 |
  • 77 | {{/each}} 78 |
79 | 80 |

Other

81 |
    82 |
  • 83 |

    webpki

    84 | 87 |
     88 | Except as otherwise noted, this project is licensed under the following
     89 | (ISC-style) terms:
     90 | 
     91 | Copyright 2015 Brian Smith.
     92 | 
     93 | Permission to use, copy, modify, and/or distribute this software for any
     94 | purpose with or without fee is hereby granted, provided that the above
     95 | copyright notice and this permission notice appear in all copies.
     96 | 
     97 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES
     98 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
     99 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR
    100 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
    101 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
    102 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
    103 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
    104 | 
    105 | The files under third-party/chromium are licensed as described in
    106 | third-party/chromium/LICENSE.
    107 |                 
    108 |
  • 109 |
110 |
111 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /src/client/service/light.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Light entity specific HA service call logic. 5 | 6 | use crate::client::cmd_from_str; 7 | use crate::errors::ServiceError; 8 | use serde_json::{Map, Value, json}; 9 | use uc_api::LightCommand; 10 | use uc_api::intg::EntityCommand; 11 | 12 | pub(crate) fn handle_light(msg: &EntityCommand) -> Result<(String, Option), ServiceError> { 13 | let cmd: LightCommand = cmd_from_str(&msg.cmd_id)?; 14 | 15 | let result = match cmd { 16 | LightCommand::On => { 17 | let mut data = Map::new(); 18 | if let Some(params) = msg.params.as_ref() { 19 | if let Some(brightness @ 0..=255) = 20 | params.get("brightness").and_then(|v| v.as_u64()) 21 | { 22 | data.insert("brightness".into(), Value::Number(brightness.into())); 23 | } 24 | if let Some(color_temp_pct) = 25 | params.get("color_temperature").and_then(|v| v.as_u64()) 26 | { 27 | // TODO keep an inventory of mired range per light #9 28 | let min_mireds = 150; 29 | let max_mireds = 500; 30 | let color_temp = 31 | color_temp_percent_to_mired(color_temp_pct, min_mireds, max_mireds)?; 32 | data.insert("color_temp".into(), Value::Number(color_temp.into())); 33 | } 34 | if let Some(hue @ 0..=360) = params.get("hue").and_then(|v| v.as_u64()) 35 | && let Some(saturation @ 0..=255) = 36 | params.get("saturation").and_then(|v| v.as_u64()) 37 | { 38 | data.insert("hs_color".into(), json!([hue, saturation * 100 / 255])); 39 | } 40 | } 41 | ("turn_on".into(), Some(Value::Object(data))) 42 | } 43 | LightCommand::Off => ("turn_off".into(), None), 44 | LightCommand::Toggle => ("Toggle".into(), None), 45 | }; 46 | 47 | Ok(result) 48 | } 49 | 50 | fn color_temp_percent_to_mired( 51 | value: u64, 52 | min_mireds: u16, 53 | max_mireds: u16, 54 | ) -> Result { 55 | if max_mireds <= min_mireds { 56 | return Err(ServiceError::BadRequest(format!( 57 | "Invalid min_mireds or max_mireds value! min_mireds={}, max_mireds={}", 58 | min_mireds, max_mireds 59 | ))); 60 | } 61 | if value <= 100 { 62 | Ok(value as u16 * (max_mireds - min_mireds) / 100 + min_mireds) 63 | } else { 64 | Err(ServiceError::BadRequest(format!( 65 | "Invalid color_temperature value {}: Valid: 0..100", 66 | value 67 | ))) 68 | } 69 | } 70 | 71 | #[cfg(test)] 72 | mod tests { 73 | use crate::client::service::light::color_temp_percent_to_mired; 74 | use crate::errors::ServiceError; 75 | use rstest::rstest; 76 | 77 | #[test] 78 | fn color_temp_percent_to_mired_with_invalid_input_returns_err() { 79 | let result = color_temp_percent_to_mired(101, 150, 500); 80 | assert!( 81 | matches!(result, Err(ServiceError::BadRequest(_))), 82 | "Invalid value must return BadRequest, but got: {:?}", 83 | result 84 | ); 85 | } 86 | 87 | #[rstest] 88 | #[case(150, 150)] 89 | #[case(200, 150)] 90 | fn color_temp_percent_to_mired_with_invalid_min_max_mireds_returns_err( 91 | #[case] min_mireds: u16, 92 | #[case] max_mireds: u16, 93 | ) { 94 | let result = color_temp_percent_to_mired(50, min_mireds, max_mireds); 95 | assert!( 96 | matches!(result, Err(ServiceError::BadRequest(_))), 97 | "Invalid min_ / max_mireds value must return BadRequest" 98 | ); 99 | } 100 | 101 | #[rstest] 102 | #[case(0, 150)] 103 | #[case(1, 153)] 104 | #[case(50, 325)] 105 | #[case(99, 496)] 106 | #[case(100, 500)] 107 | fn color_temp_percent_to_mired_returns_scaled_values( 108 | #[case] input: u64, 109 | #[case] expected: u16, 110 | ) { 111 | let min_mireds = 150; 112 | let max_mireds = 500; 113 | let result = color_temp_percent_to_mired(input, min_mireds, max_mireds); 114 | 115 | assert_eq!(Ok(expected), result); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/util/json.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | use serde_json::{Map, Value}; 5 | 6 | /// Copy (and clone) an entry from one serde_json::Map to another. 7 | /// 8 | /// Returns true if an entry has been copied, false if the key could not be found. 9 | pub fn copy_entry(source: &Map, dest: &mut Map, key: &str) -> bool { 10 | source 11 | .get(key) 12 | .map(|v| { 13 | dest.insert(key.to_string(), v.clone()); 14 | }) 15 | .is_some() 16 | } 17 | 18 | /// Move an entry from one serde_json::Map to another without any conversions. 19 | /// 20 | /// Returns true if an entry has been moved, false if the key could not be found. 21 | pub fn move_entry( 22 | source: &mut Map, 23 | dest: &mut Map, 24 | key: &str, 25 | ) -> bool { 26 | source 27 | .remove_entry(key) 28 | .map(|(k, v)| { 29 | dest.insert(k, v); 30 | }) 31 | .is_some() 32 | } 33 | 34 | /// Move a value from one serde_json::Map to another while renaming the key. 35 | /// 36 | /// Returns true if an entry has been moved, false if the key could not be found. 37 | pub fn move_value( 38 | source: &mut Map, 39 | dest: &mut Map, 40 | key: &str, 41 | dest_key: impl Into, 42 | ) -> bool { 43 | source 44 | .remove_entry(key) 45 | .map(|(_, value)| { 46 | dest.insert(dest_key.into(), value); 47 | }) 48 | .is_some() 49 | } 50 | 51 | #[allow(dead_code)] 52 | pub fn map_str_value Value>( 53 | source: &Map, 54 | dest: &mut Map, 55 | key: &str, 56 | f: F, 57 | ) -> bool { 58 | source 59 | .get(key) 60 | .and_then(|v| v.as_str()) 61 | .map(|v| { 62 | let v = f(v); 63 | dest.insert(key.to_string(), v); 64 | }) 65 | .is_some() 66 | } 67 | 68 | pub fn is_float_value(json: &Map, key: &str) -> bool { 69 | json.get(key).and_then(|v| v.as_f64()).is_some() 70 | } 71 | 72 | pub fn number_value(json: &Map, key: &str) -> Option { 73 | match json.get(key) { 74 | Some(v) if v.is_number() => Some(v.clone()), 75 | _ => None, 76 | } 77 | } 78 | 79 | #[cfg(test)] 80 | mod tests { 81 | use crate::util::json::{copy_entry, move_entry, move_value}; 82 | use serde_json::{Map, json}; 83 | 84 | #[test] 85 | fn copy_entry_with_non_existing_key_returns_false() { 86 | let source = Map::new(); 87 | let mut dest = Map::new(); 88 | assert!( 89 | !copy_entry(&source, &mut dest, "foo"), 90 | "Non existing key must return false" 91 | ); 92 | } 93 | 94 | #[test] 95 | fn copy_entry_with_existing_key_returns_true() { 96 | let mut source = Map::new(); 97 | let mut dest = Map::new(); 98 | source.insert("foo".into(), "bar".into()); 99 | assert!( 100 | copy_entry(&source, &mut dest, "foo"), 101 | "Existing key must return true" 102 | ); 103 | assert_eq!(Some(&json!("bar")), dest.get("foo")); 104 | } 105 | 106 | #[test] 107 | fn move_entry_with_non_existing_key_returns_false() { 108 | let mut source = Map::new(); 109 | let mut dest = Map::new(); 110 | assert!( 111 | !move_entry(&mut source, &mut dest, "foo"), 112 | "Non existing key must return false" 113 | ); 114 | } 115 | 116 | #[test] 117 | fn move_entry_with_existing_key_returns_true() { 118 | let mut source = Map::new(); 119 | let mut dest = Map::new(); 120 | source.insert("foo".into(), "bar".into()); 121 | assert!( 122 | move_entry(&mut source, &mut dest, "foo"), 123 | "Existing key must return true" 124 | ); 125 | assert_eq!(None, source.get("foo"), "Source entry must be removed"); 126 | assert_eq!(Some(&json!("bar")), dest.get("foo")); 127 | } 128 | 129 | #[test] 130 | fn move_value_with_non_existing_key_returns_false() { 131 | let mut source = Map::new(); 132 | let mut dest = Map::new(); 133 | assert!( 134 | !move_value(&mut source, &mut dest, "foo", "bar"), 135 | "Non existing key must return false" 136 | ); 137 | } 138 | 139 | #[test] 140 | fn move_value_with_existing_key_returns_true() { 141 | let mut source = Map::new(); 142 | let mut dest = Map::new(); 143 | source.insert("foo".into(), "bar".into()); 144 | assert!( 145 | move_value(&mut source, &mut dest, "foo", "bar"), 146 | "Existing key must return true" 147 | ); 148 | assert_eq!(None, source.get("foo"), "Source entry must be removed"); 149 | assert_eq!(Some(&json!("bar")), dest.get("bar")); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/client/messages.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Actix Actor message definitions for HomeAssistantClient 5 | 6 | use crate::client::model::{AssistPipelineEvent, GetPipelinesResult}; 7 | use crate::errors::ServiceError; 8 | use actix::prelude::Message; 9 | use awc::ws::CloseCode; 10 | use derive_more::Constructor; 11 | use std::collections::HashSet; 12 | use uc_api::intg::{AvailableIntgEntity, EntityChange, EntityCommand}; 13 | 14 | /// Call a service in Home Assistant 15 | #[derive(Message)] 16 | #[rtype(result = "Result<(), ServiceError>")] 17 | pub struct CallService { 18 | /// Remote Two `msg_data` json object from `entity_command` message. 19 | pub command: EntityCommand, 20 | } 21 | 22 | /// Run an assist pipeline in Home Assistant and wait for the response. 23 | /// 24 | /// See 25 | /// 26 | /// Returns: 27 | /// - ServiceError::ServiceUnavailable if the pipeline failed to start 28 | /// - ServiceError::NotFound if the pipeline wasn't found 29 | #[derive(Message)] 30 | #[rtype(result = "Result<(), ServiceError>")] 31 | pub struct CallRunAssistPipeline { 32 | pub entity_id: String, 33 | pub session_id: u32, 34 | pub sample_rate: u32, 35 | pub timeout: Option, 36 | pub speech_response: bool, 37 | pub pipeline_id: Option, 38 | } 39 | 40 | /// Retrieve all available assist pipelines from Home Assistant. 41 | #[derive(Message)] 42 | #[rtype(result = "Result")] 43 | pub struct CallListAssistPipelines { 44 | /// Speech to text is required. Pipelines without STT are filtered out. 45 | pub stt_required: bool, 46 | } 47 | 48 | impl Default for CallListAssistPipelines { 49 | fn default() -> Self { 50 | Self { stt_required: true } 51 | } 52 | } 53 | 54 | /// Fetch all states from Home Assistant 55 | #[derive(Message)] 56 | #[rtype(result = "Result<(), ServiceError>")] 57 | pub struct GetStates { 58 | pub remote_id: String, 59 | pub entity_ids: HashSet, 60 | } 61 | 62 | /// Get available entities from Home Assistant 63 | #[derive(Message)] 64 | #[rtype(result = "Result<(), ServiceError>")] 65 | pub struct GetAvailableEntities { 66 | pub remote_id: String, 67 | } 68 | 69 | /// Asynchronous HA response from `GetStates` 70 | #[derive(Message)] 71 | #[rtype(result = "()")] 72 | #[allow(dead_code)] // client_id not used 73 | pub struct AvailableEntities { 74 | pub client_id: String, 75 | pub entities: Vec, 76 | } 77 | 78 | /// Asynchronous HA response from `GetStates` 79 | #[derive(Message)] 80 | #[rtype(result = "()")] 81 | pub struct SetAvailableEntities { 82 | #[allow(dead_code)] 83 | pub client_id: String, 84 | pub entities: Vec, 85 | } 86 | 87 | /// Sent by controller when subscribed entities change 88 | /// TODO : identifier necessary for multiple remotes ? 89 | #[derive(Message)] 90 | #[rtype(result = "()")] 91 | pub struct SubscribedEntities { 92 | pub entity_ids: HashSet, 93 | } 94 | 95 | /// HA client connection states 96 | pub enum ConnectionState { 97 | AuthenticationFailed, 98 | Connected, 99 | Closed, 100 | } 101 | 102 | /// HA client connection events 103 | #[derive(Message)] 104 | #[rtype(result = "()")] 105 | pub struct ConnectionEvent { 106 | pub client_id: String, 107 | pub state: ConnectionState, 108 | } 109 | 110 | /// HA entity events 111 | #[derive(Message)] 112 | #[rtype(result = "()")] 113 | #[allow(dead_code)] // client_id not used 114 | pub struct EntityEvent { 115 | pub client_id: String, 116 | pub entity_change: EntityChange, 117 | } 118 | 119 | /// HA assist pipeline events 120 | #[derive(Constructor, Message)] 121 | #[rtype(result = "()")] 122 | pub struct AssistEvent { 123 | /// Remote audio session ID 124 | pub session_id: u32, 125 | /// Remote voice assistant entity ID 126 | pub entity_id: String, 127 | pub event: AssistPipelineEvent, 128 | } 129 | 130 | /// Set remote id from remote to client 131 | #[derive(Message)] 132 | #[rtype(result = "Result<(), ServiceError>")] 133 | pub struct SetRemoteId { 134 | pub remote_id: String, 135 | } 136 | 137 | /// HA client request: disconnect and close the session. 138 | // Used internally by the client and from Controller 139 | #[derive(Message)] 140 | #[rtype(result = "()")] 141 | pub struct Close { 142 | /// WebSocket close code 143 | pub code: CloseCode, 144 | pub description: Option, 145 | } 146 | 147 | impl Default for Close { 148 | fn default() -> Self { 149 | Self { 150 | code: CloseCode::Normal, 151 | description: None, 152 | } 153 | } 154 | } 155 | 156 | impl Close { 157 | pub fn invalid() -> Self { 158 | Self { 159 | code: CloseCode::Invalid, 160 | description: None, 161 | } 162 | } 163 | pub fn unsupported() -> Self { 164 | Self { 165 | code: CloseCode::Unsupported, 166 | description: None, 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/server/ws/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! WebSocket server for the Remote Two integration API 5 | 6 | use crate::Controller; 7 | use crate::configuration::{ENV_API_MSG_TRACING, HeartbeatSettings, WebSocketSettings}; 8 | use actix::Addr; 9 | use actix_web::error::JsonPayloadError; 10 | use actix_web::{Error, HttpRequest, HttpResponse, error, get, web}; 11 | use log::{debug, info}; 12 | use std::env; 13 | use std::time::Instant; 14 | use uc_api::core::web::ApiResponse; 15 | use uuid::Uuid; 16 | 17 | mod connection; 18 | mod events; 19 | mod requests; 20 | mod responses; 21 | 22 | /// WebSocket connection instance and Actix WebSocket actor. 23 | struct WsConn { 24 | /// Unique connection identifier. 25 | /// 26 | /// Used to associate received messages when passing them to the [`Controller`] and for logging 27 | /// purposes. 28 | id: String, 29 | /// Heartbeat timestamp of last activity. 30 | hb: Instant, 31 | /// [`Controller`] actix address for sending WS events & requests. 32 | controller_addr: Addr, 33 | heartbeat: HeartbeatSettings, 34 | /// Enable incoming websocket message tracing: log every message. SECRETS ARE EXPOSED! 35 | msg_tracing_in: bool, 36 | /// Enable outgoing websocket message tracing: log every message 37 | msg_tracing_out: bool, 38 | } 39 | 40 | impl WsConn { 41 | fn new( 42 | client_id: String, 43 | controller_addr: Addr, 44 | heartbeat: HeartbeatSettings, 45 | ) -> Self { 46 | let msg_tracing = env::var(ENV_API_MSG_TRACING).unwrap_or_default(); 47 | Self { 48 | id: client_id, 49 | hb: Instant::now(), 50 | controller_addr, 51 | heartbeat, 52 | msg_tracing_in: msg_tracing == "all" || msg_tracing == "in", 53 | msg_tracing_out: msg_tracing == "all" || msg_tracing == "out", 54 | } 55 | } 56 | } 57 | 58 | /// HTTP endpoint for the WebSocket upgrade 59 | #[get("/ws")] 60 | pub async fn ws_index( 61 | request: HttpRequest, 62 | stream: web::Payload, 63 | websocket_settings: web::Data, 64 | controller: web::Data>, 65 | ) -> actix_web::Result { 66 | let client_addr = request.peer_addr().map(|p| p.to_string()); 67 | // Note: don't print full request, it may contain an auth-token header! 68 | let client = client_addr.as_deref().unwrap_or("?"); 69 | debug!("New WebSocket connection from: {client}"); 70 | 71 | // Authenticate connection if a token is configured 72 | if websocket_settings.token.is_some() { 73 | let auth_token = request 74 | .headers() 75 | .get("auth-token") 76 | .and_then(|v| match v.to_str() { 77 | Ok(v) => Some(v.to_string()), 78 | Err(_) => None, 79 | }); 80 | 81 | if auth_token != websocket_settings.token { 82 | info!("Invalid token, closing client connection {client}"); 83 | return Ok(HttpResponse::Unauthorized() 84 | .json(ApiResponse::new("ERROR", "Authentication failed"))); 85 | } 86 | } 87 | 88 | // TODO limit number of active ws sessions? 89 | // use peer IP:port as unique client identifier 90 | let client_id = request 91 | .peer_addr() 92 | .map(|addr| format!("{}:{}", addr.ip(), addr.port())) 93 | .unwrap_or_else(|| Uuid::new_v4().as_hyphenated().to_string()); 94 | 95 | let (resp, session, msg_stream) = actix_ws::handle(&request, stream)?; 96 | 97 | // Increase the maximum allowed frame size to 128KiB. Default is 64KiB. 98 | // Note: there shouldn't be a need to increase this, since the Remote request message payloads are small. 99 | // Also, aggregate continuation frames is just "future proofing" and not used at the moment in the Remote. 100 | let stream = msg_stream 101 | .max_frame_size(128 * 1024) 102 | .aggregate_continuations(); 103 | 104 | let conn = WsConn::new( 105 | client_id, 106 | controller.get_ref().clone(), 107 | websocket_settings.heartbeat, 108 | ); 109 | 110 | actix_web::rt::spawn(async move { 111 | conn.run(session, stream).await; 112 | }); 113 | 114 | Ok(resp) 115 | } 116 | 117 | /// Custom Actix Web error handler 118 | pub fn json_error_handler(err: JsonPayloadError, _: &HttpRequest) -> Error { 119 | let message = err.to_string(); 120 | 121 | let resp = match &err { 122 | JsonPayloadError::ContentType => HttpResponse::UnsupportedMediaType() 123 | .json(ApiResponse::new("UNSUPPORTED_MEDIA_TYPE", &message[..])), 124 | JsonPayloadError::Deserialize(json_err) if json_err.is_data() => { 125 | // alternative: HttpResponse::UnprocessableEntity 422 126 | HttpResponse::BadRequest().json(ApiResponse::new("INVALID_JSON", &message[..])) 127 | } 128 | _ => HttpResponse::BadRequest().json(ApiResponse::new("BAD_REQUEST", &message[..])), 129 | }; 130 | 131 | error::InternalError::from_response(err, resp).into() 132 | } 133 | -------------------------------------------------------------------------------- /src/client/service/climate.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Climate entity specific HA service call logic. 5 | 6 | use crate::client::{cmd_from_str, get_required_params}; 7 | use crate::errors::ServiceError; 8 | use crate::util::json::copy_entry; 9 | use serde_json::{Map, Value, json}; 10 | use uc_api::ClimateCommand; 11 | use uc_api::intg::EntityCommand; 12 | 13 | pub(crate) fn handle_climate(msg: &EntityCommand) -> Result<(String, Option), ServiceError> { 14 | let cmd: ClimateCommand = cmd_from_str(&msg.cmd_id)?; 15 | 16 | let result = match cmd { 17 | ClimateCommand::On => ("turn_on".into(), None), 18 | ClimateCommand::Off => ("turn_off".into(), None), 19 | ClimateCommand::HvacMode => { 20 | let mut data = Map::new(); 21 | let params = get_required_params(msg)?; 22 | let mode = params 23 | .get("hvac_mode") 24 | .and_then(|v| v.as_str()) 25 | .unwrap_or_default(); 26 | match mode { 27 | "OFF" | "HEAT" | "COOL" | "HEAT_COOL" | "AUTO" => { 28 | data.insert("hvac_mode".into(), mode.to_lowercase().into()); 29 | } 30 | "FAN" => { 31 | data.insert("hvac_mode".into(), "fan_only".into()); 32 | } 33 | _ => { 34 | return Err(ServiceError::BadRequest(format!( 35 | "Invalid or missing params.hvac_mode attribute: {}", 36 | mode 37 | ))); 38 | } 39 | } 40 | 41 | // TODO can we send a temperature param in set_hvac_mode? #12 42 | // If not: remove example from entity docs... 43 | copy_entry(params, &mut data, "temperature"); 44 | 45 | ("set_hvac_mode".into(), Some(data.into())) 46 | } 47 | ClimateCommand::TargetTemperature => { 48 | let params = get_required_params(msg)?; 49 | if let Some(temp) = params.get("temperature").and_then(|v| v.as_f64()) { 50 | ( 51 | "set_temperature".into(), 52 | Some(json!({ "temperature": temp })), 53 | ) 54 | } else { 55 | return Err(ServiceError::BadRequest( 56 | "Invalid or missing params.temperature attribute".into(), 57 | )); 58 | } 59 | } 60 | }; 61 | 62 | Ok(result) 63 | } 64 | 65 | #[cfg(test)] 66 | mod tests { 67 | use crate::client::service::climate::handle_climate; 68 | use rstest::rstest; 69 | use serde_json::{Value, json}; 70 | use uc_api::intg::EntityCommand; 71 | 72 | #[test] 73 | fn turn_on() { 74 | let msg_data = json!({ 75 | "cmd_id": "on", 76 | "entity_id": "climate.bathroom_floor_heating_mode", 77 | "entity_type": "climate" 78 | }); 79 | let (cmd, data) = map_msg_data(msg_data); 80 | assert_eq!("turn_on", cmd); 81 | assert!(data.is_none(), "no cmd data allowed"); 82 | } 83 | 84 | #[test] 85 | fn turn_off() { 86 | let msg_data = json!({ 87 | "cmd_id": "off", 88 | "entity_id": "climate.bathroom_floor_heating_mode", 89 | "entity_type": "climate" 90 | }); 91 | let (cmd, data) = map_msg_data(msg_data); 92 | assert_eq!("turn_off", cmd); 93 | assert!(data.is_none(), "no cmd data allowed"); 94 | } 95 | 96 | #[rstest] 97 | #[case("OFF", "off")] 98 | #[case("HEAT", "heat")] 99 | #[case("COOL", "cool")] 100 | #[case("HEAT_COOL", "heat_cool")] 101 | #[case("AUTO", "auto")] 102 | #[case("FAN", "fan_only")] 103 | fn hvac_mode(#[case] uc_cmd: &str, #[case] ha_cmd: &str) { 104 | let msg_data = json!({ 105 | "cmd_id": "hvac_mode", 106 | "entity_id": "climate.bathroom_floor_heating_mode", 107 | "entity_type": "climate", 108 | "params": { 109 | "hvac_mode": uc_cmd 110 | } 111 | }); 112 | let (cmd, data) = map_msg_data(msg_data); 113 | assert_eq!("set_hvac_mode", cmd); 114 | assert!(data.is_some(), "cmd data expected"); 115 | let data = data.unwrap(); 116 | assert_eq!(Some(&json!(ha_cmd)), data.get("hvac_mode")); 117 | } 118 | 119 | #[test] 120 | fn set_temperature() { 121 | let msg_data = json!({ 122 | "cmd_id": "target_temperature", 123 | "entity_id": "climate.bathroom_floor_heating_mode", 124 | "entity_type": "climate", 125 | "params": { 126 | "temperature": 22.5 127 | } 128 | }); 129 | let (cmd, data) = map_msg_data(msg_data); 130 | assert_eq!("set_temperature", cmd); 131 | assert!(data.is_some(), "cmd data expected"); 132 | let data = data.unwrap(); 133 | assert_eq!(Some(&json!(22.5)), data.get("temperature")); 134 | } 135 | 136 | fn map_msg_data(msg_data: Value) -> (String, Option) { 137 | let cmd: EntityCommand = serde_json::from_value(msg_data).expect("invalid test data"); 138 | let result = handle_climate(&cmd); 139 | assert!( 140 | result.is_ok(), 141 | "Expected successful cmd mapping but got: {:?}", 142 | result.unwrap_err() 143 | ); 144 | result.unwrap() 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/client/service/button.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Button entity specific HA service call logic. 5 | 6 | use crate::client::cmd_from_str; 7 | use crate::errors::ServiceError; 8 | use serde_json::Value; 9 | use uc_api::ButtonCommand; 10 | use uc_api::intg::EntityCommand; 11 | 12 | pub(crate) fn handle_button(msg: &EntityCommand) -> Result<(String, Option), ServiceError> { 13 | let cmd: ButtonCommand = cmd_from_str(&msg.cmd_id)?; 14 | 15 | let entity: Vec<&str> = msg.entity_id.split('.').collect(); 16 | 17 | let service_call: &str = match entity[0] { 18 | "script" => entity[1], 19 | "scene" => "turn_on", 20 | &_ => "press", 21 | }; 22 | 23 | let result = match cmd { 24 | ButtonCommand::Push => (service_call.into(), None), 25 | }; 26 | 27 | Ok(result) 28 | } 29 | 30 | #[cfg(test)] 31 | mod tests { 32 | use crate::client::service::button::handle_button; 33 | use rstest::rstest; 34 | use uc_api::EntityType; 35 | use uc_api::intg::EntityCommand; 36 | 37 | fn new_entity_command( 38 | entity_id: impl Into, 39 | cmd_id: impl Into, 40 | ) -> EntityCommand { 41 | EntityCommand { 42 | device_id: None, 43 | entity_type: EntityType::Button, 44 | entity_id: entity_id.into(), 45 | cmd_id: cmd_id.into(), 46 | params: None, 47 | } 48 | } 49 | 50 | #[rstest] 51 | #[case("script.foobar", "foobar")] 52 | #[case("script.turn_on_lights", "turn_on_lights")] 53 | #[case("script.complex_script_123", "complex_script_123")] 54 | fn script_entities_use_script_name_as_service( 55 | #[case] entity_id: &str, 56 | #[case] expected_service: &str, 57 | ) { 58 | let cmd = new_entity_command(entity_id, "push"); 59 | let result = handle_button(&cmd); 60 | 61 | assert!( 62 | result.is_ok(), 63 | "Valid script entity must return Ok, but got: {:?}", 64 | result.unwrap_err() 65 | ); 66 | let (service, param) = result.unwrap(); 67 | assert_eq!(expected_service, &service); 68 | assert!( 69 | param.is_none(), 70 | "Button commands should not have parameters" 71 | ); 72 | } 73 | 74 | #[rstest] 75 | #[case("scene.morning", "turn_on")] 76 | #[case("scene.evening", "turn_on")] 77 | #[case("scene.party_mode", "turn_on")] 78 | fn scene_entities_use_turn_on_service(#[case] entity_id: &str, #[case] expected_service: &str) { 79 | let cmd = new_entity_command(entity_id, "push"); 80 | let result = handle_button(&cmd); 81 | 82 | assert!( 83 | result.is_ok(), 84 | "Valid scene entity must return Ok, but got: {:?}", 85 | result.unwrap_err() 86 | ); 87 | let (service, param) = result.unwrap(); 88 | assert_eq!(expected_service, &service); 89 | assert!( 90 | param.is_none(), 91 | "Button commands should not have parameters" 92 | ); 93 | } 94 | 95 | #[rstest] 96 | #[case("button.doorbell", "press")] 97 | #[case("input_button.test_button", "press")] 98 | #[case("automation.my_automation", "press")] 99 | #[case("switch.some_switch", "press")] 100 | #[case("light.some_light", "press")] 101 | #[case("unknown.entity_type", "press")] 102 | fn other_entities_use_press_service(#[case] entity_id: &str, #[case] expected_service: &str) { 103 | let cmd = new_entity_command(entity_id, "push"); 104 | let result = handle_button(&cmd); 105 | 106 | assert!( 107 | result.is_ok(), 108 | "Valid entity must return Ok, but got: {:?}", 109 | result.unwrap_err() 110 | ); 111 | let (service, param) = result.unwrap(); 112 | assert_eq!(expected_service, &service); 113 | assert!( 114 | param.is_none(), 115 | "Button commands should not have parameters" 116 | ); 117 | } 118 | 119 | #[test] 120 | fn entity_id_without_domain_separator_uses_press_service() { 121 | let cmd = new_entity_command("no_separator_entity", "push"); 122 | let result = handle_button(&cmd); 123 | 124 | assert!( 125 | result.is_ok(), 126 | "Entity without separator must return Ok, but got: {:?}", 127 | result.unwrap_err() 128 | ); 129 | let (service, param) = result.unwrap(); 130 | assert_eq!("press", &service); 131 | assert!( 132 | param.is_none(), 133 | "Button commands should not have parameters" 134 | ); 135 | } 136 | 137 | #[test] 138 | fn script_entity_with_multiple_dots_uses_first_part_after_domain() { 139 | let cmd = new_entity_command("script.my.complex.script.name", "push"); 140 | let result = handle_button(&cmd); 141 | 142 | assert!( 143 | result.is_ok(), 144 | "Script with multiple dots must return Ok, but got: {:?}", 145 | result.unwrap_err() 146 | ); 147 | let (service, param) = result.unwrap(); 148 | assert_eq!("my", &service); 149 | assert!( 150 | param.is_none(), 151 | "Button commands should not have parameters" 152 | ); 153 | } 154 | 155 | #[test] 156 | fn scene_entity_with_multiple_dots_uses_turn_on() { 157 | let cmd = new_entity_command("scene.my.complex.scene.name", "push"); 158 | let result = handle_button(&cmd); 159 | 160 | assert!( 161 | result.is_ok(), 162 | "Scene with multiple dots must return Ok, but got: {:?}", 163 | result.unwrap_err() 164 | ); 165 | let (service, param) = result.unwrap(); 166 | assert_eq!("turn_on", &service); 167 | assert!( 168 | param.is_none(), 169 | "Button commands should not have parameters" 170 | ); 171 | } 172 | 173 | #[test] 174 | fn invalid_command_returns_error() { 175 | let cmd = new_entity_command("button.test", "invalid_command"); 176 | let result = handle_button(&cmd); 177 | 178 | assert!(result.is_err(), "Invalid command should return error"); 179 | } 180 | 181 | #[test] 182 | fn push_command_is_case_sensitive() { 183 | let cmd = new_entity_command("button.test", "push"); 184 | let result = handle_button(&cmd); 185 | 186 | assert!( 187 | result.is_ok(), 188 | "Lowercase 'push' command must work, but got: {:?}", 189 | result.unwrap_err() 190 | ); 191 | 192 | let cmd_upper = new_entity_command("button.test", "PUSH"); 193 | let result_upper = handle_button(&cmd_upper); 194 | 195 | assert!( 196 | result_upper.is_err(), 197 | "Uppercase 'PUSH' command should return error due to case sensitivity" 198 | ); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/client/service/cover.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Cover entity specific HA service call logic. 5 | 6 | use crate::client::cmd_from_str; 7 | use crate::errors::ServiceError; 8 | use serde_json::{Map, Value}; 9 | use uc_api::CoverCommand; 10 | use uc_api::intg::EntityCommand; 11 | 12 | pub(crate) fn handle_cover(msg: &EntityCommand) -> Result<(String, Option), ServiceError> { 13 | let cmd: CoverCommand = cmd_from_str(&msg.cmd_id)?; 14 | 15 | let result = match cmd { 16 | CoverCommand::Open => ("open_cover".into(), None), 17 | CoverCommand::Close => ("close_cover".into(), None), 18 | CoverCommand::Stop => ("stop_cover".into(), None), 19 | CoverCommand::Position => { 20 | let mut data = Map::new(); 21 | if let Some(params) = msg.params.as_ref() 22 | && let Some(pos @ 0..=100) = params.get("position").and_then(|v| v.as_u64()) 23 | { 24 | data.insert("position".into(), Value::Number(pos.into())); 25 | } 26 | ("set_cover_position".into(), Some(data.into())) 27 | } // TODO implement tilt command #6 28 | }; 29 | 30 | Ok(result) 31 | } 32 | 33 | #[cfg(test)] 34 | mod tests { 35 | use crate::client::service::cover::handle_cover; 36 | use rstest::rstest; 37 | use serde_json::{Map, Value, json}; 38 | use uc_api::EntityType; 39 | use uc_api::intg::EntityCommand; 40 | 41 | fn new_entity_command(cmd_id: impl Into, params: Value) -> EntityCommand { 42 | EntityCommand { 43 | device_id: None, 44 | entity_type: EntityType::Cover, 45 | entity_id: "test".into(), 46 | cmd_id: cmd_id.into(), 47 | params: if params.is_object() { 48 | Some(params.as_object().unwrap().clone()) 49 | } else { 50 | None 51 | }, 52 | } 53 | } 54 | 55 | #[rstest] 56 | #[case("open", "open_cover")] 57 | #[case("close", "close_cover")] 58 | #[case("stop", "stop_cover")] 59 | fn simple_commands_return_proper_service_call( 60 | #[case] cmd_id: &str, 61 | #[case] expected_service: &str, 62 | ) { 63 | let cmd = new_entity_command(cmd_id, Value::Null); 64 | let result = handle_cover(&cmd); 65 | 66 | assert!( 67 | result.is_ok(), 68 | "Valid command must return Ok, but got: {:?}", 69 | result.unwrap_err() 70 | ); 71 | let (service, param) = result.unwrap(); 72 | assert_eq!(expected_service, &service); 73 | assert!( 74 | param.is_none(), 75 | "Simple commands should not have parameters" 76 | ); 77 | } 78 | 79 | #[rstest] 80 | #[case(json!(0), json!(0))] 81 | #[case(json!(1), json!(1))] 82 | #[case(json!(50), json!(50))] 83 | #[case(json!(100), json!(100))] 84 | fn position_cmd_with_valid_position_returns_proper_request( 85 | #[case] position: Value, 86 | #[case] expected: Value, 87 | ) { 88 | let cmd = new_entity_command("position", json!({ "position": position })); 89 | let result = handle_cover(&cmd); 90 | 91 | assert!( 92 | result.is_ok(), 93 | "Valid position must return Ok, but got: {:?}", 94 | result.unwrap_err() 95 | ); 96 | let (service, param) = result.unwrap(); 97 | assert_eq!("set_cover_position", &service); 98 | assert!(param.is_some(), "Position command should have parameters"); 99 | assert_eq!(Some(&expected), param.unwrap().get("position")); 100 | } 101 | 102 | #[test] 103 | fn position_cmd_without_params_returns_empty_data() { 104 | let cmd = new_entity_command("position", Value::Null); 105 | let result = handle_cover(&cmd); 106 | 107 | assert!( 108 | result.is_ok(), 109 | "Position command without params should return Ok, but got: {:?}", 110 | result.unwrap_err() 111 | ); 112 | let (service, param) = result.unwrap(); 113 | assert_eq!("set_cover_position", &service); 114 | assert!( 115 | param.is_some(), 116 | "Position command should have parameters object" 117 | ); 118 | let param_obj = param.unwrap(); 119 | assert!( 120 | param_obj.as_object().unwrap().is_empty(), 121 | "Parameters should be empty when no valid position provided" 122 | ); 123 | } 124 | 125 | #[rstest] 126 | #[case(json!(-1))] 127 | #[case(json!(101))] 128 | #[case(json!(200))] 129 | #[case(json!(0.0))] 130 | #[case(json!(50.5))] 131 | #[case(json!(true))] 132 | #[case(json!(false))] 133 | #[case(json!("50"))] 134 | fn position_cmd_with_invalid_position_returns_empty_data(#[case] position: Value) { 135 | let cmd = new_entity_command("position", json!({ "position": position })); 136 | let result = handle_cover(&cmd); 137 | 138 | assert!( 139 | result.is_ok(), 140 | "Position command with invalid position should return Ok, but got: {:?}", 141 | result.unwrap_err() 142 | ); 143 | let (service, param) = result.unwrap(); 144 | assert_eq!("set_cover_position", &service); 145 | assert!( 146 | param.is_some(), 147 | "Position command should have parameters object" 148 | ); 149 | let param_obj = param.unwrap(); 150 | assert!( 151 | param_obj.as_object().unwrap().is_empty(), 152 | "Parameters should be empty when invalid position provided" 153 | ); 154 | } 155 | 156 | #[rstest] 157 | #[case(Value::Object(Map::new()))] 158 | #[case(json!({ "other_param": 50 }))] 159 | fn position_cmd_with_missing_position_param_returns_empty_data(#[case] params: Value) { 160 | let cmd = new_entity_command("position", params); 161 | let result = handle_cover(&cmd); 162 | 163 | assert!( 164 | result.is_ok(), 165 | "Position command with missing position param should return Ok, but got: {:?}", 166 | result.unwrap_err() 167 | ); 168 | let (service, param) = result.unwrap(); 169 | assert_eq!("set_cover_position", &service); 170 | assert!( 171 | param.is_some(), 172 | "Position command should have parameters object" 173 | ); 174 | let param_obj = param.unwrap(); 175 | assert!( 176 | param_obj.as_object().unwrap().is_empty(), 177 | "Parameters should be empty when position param is missing" 178 | ); 179 | } 180 | 181 | #[test] 182 | fn invalid_command_returns_error() { 183 | let cmd = new_entity_command("invalid_command", Value::Null); 184 | let result = handle_cover(&cmd); 185 | 186 | assert!(result.is_err(), "Invalid command should return error"); 187 | // The specific error type depends on the cmd_from_str implementation 188 | // but it should be some kind of ServiceError 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/bin/ha_test.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Home-Assistant WebSocket API connection test tool 5 | 6 | use actix::{Actor, Addr, Context, Handler}; 7 | use actix_web::rt::time::sleep; 8 | use clap::{Arg, Command}; 9 | use log::{debug, error, info}; 10 | use std::env; 11 | use std::str::FromStr; 12 | use std::time::Duration; 13 | use uc_api::intg::ws::{R2Event, R2Request}; 14 | use uc_intg_hass::configuration::{ENV_HASS_MSG_TRACING, Settings, get_configuration}; 15 | use uc_intg_hass::{ 16 | APP_VERSION, Controller, NewR2Session, R2EventMsg, R2RequestMsg, SendWsMessage, configuration, 17 | }; 18 | use url::Url; 19 | 20 | #[actix_web::main] 21 | async fn main() -> anyhow::Result<()> { 22 | let cfg = parse_args_load_cfg()?; 23 | 24 | println!( 25 | "Connecting to Home Assistant WebSocket server: {} (timeout={}s, request-timeout={}s, disable-cert={})", 26 | cfg.hass.get_url(), 27 | cfg.hass.connection_timeout, 28 | cfg.hass.request_timeout, 29 | cfg.hass.disable_cert_validation, 30 | ); 31 | 32 | let driver_metadata = configuration::get_driver_metadata()?; 33 | let controller = Controller::new(cfg, driver_metadata.clone()).start(); 34 | 35 | // Mock server to simulate an R2 connection 36 | let ws_id = "HA-test".to_string(); 37 | let server = ServerMock::new(&ws_id, controller.clone()).start(); 38 | 39 | // establish a mock session 40 | controller 41 | .send(NewR2Session { 42 | addr: server.recipient(), 43 | id: ws_id.clone(), 44 | }) 45 | .await?; 46 | 47 | // connect to HA 48 | controller 49 | .send(R2EventMsg { 50 | ws_id: ws_id.clone(), 51 | event: R2Event::Connect, 52 | msg_data: None, 53 | }) 54 | .await?; 55 | 56 | // quick and dirty for now 57 | sleep(Duration::from_secs(30)).await; 58 | 59 | Ok(()) 60 | } 61 | 62 | fn parse_args_load_cfg() -> anyhow::Result { 63 | let args = Command::new("ha-test") 64 | .author("Unfolded Circle ApS") 65 | .version(APP_VERSION) 66 | .about("Home Assistant server communication test") 67 | .arg( 68 | Arg::new("url") 69 | .short('u') 70 | .help("Home Assistant WebSocket API URL (overrides home-assistant.json)"), 71 | ) 72 | .arg( 73 | Arg::new("disable_cert_validation") 74 | .long("disable-cert-validation") 75 | .num_args(0) 76 | .help("Disable SSL certificate verification (overrides home-assistant.json)"), 77 | ) 78 | .arg( 79 | Arg::new("token") 80 | .short('t') 81 | .help("Home Assistant long lived access token (overrides home-assistant.json)"), 82 | ) 83 | .arg( 84 | Arg::new("connection_timeout") 85 | .short('c') 86 | .help("TCP connection timeout in seconds (overrides home-assistant.json)"), 87 | ) 88 | .arg( 89 | Arg::new("request_timeout") 90 | .short('r') 91 | .help("Request timeout in seconds (overrides home-assistant.json)"), 92 | ) 93 | .arg( 94 | Arg::new("trace_level") 95 | .long("trace") 96 | .value_name("MESSAGES") 97 | .value_parser(["in", "out", "all", "none"]) 98 | .default_value("all") 99 | .help("Message tracing for HA server communication"), 100 | ) 101 | .get_matches(); 102 | 103 | env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("debug")).init(); 104 | if let Some(msg_trace) = args.get_one::("trace_level") { 105 | unsafe { 106 | env::set_var(ENV_HASS_MSG_TRACING, msg_trace); 107 | } 108 | } 109 | 110 | rustls::crypto::aws_lc_rs::default_provider() 111 | .install_default() 112 | .unwrap(); 113 | 114 | let cfg_file = None; 115 | let mut cfg = get_configuration(cfg_file).expect("Failed to read configuration"); 116 | if let Some(url) = args.get_one::("url") { 117 | cfg.hass.set_url(Url::parse(url)?); 118 | } 119 | if let Some(disable_cert_validation) = args.get_one::("disable_cert_validation") { 120 | cfg.hass.disable_cert_validation = *disable_cert_validation; 121 | } 122 | if let Some(token) = args.get_one::("token") { 123 | cfg.hass.set_token(token); 124 | } 125 | if let Some(timeout) = args.get_one::("connection_timeout") { 126 | cfg.hass.connection_timeout = u8::from_str(timeout)?; 127 | } 128 | if let Some(timeout) = args.get_one::("request_timeout") { 129 | cfg.hass.request_timeout = u8::from_str(timeout)?; 130 | } 131 | 132 | if !cfg.hass.get_url().has_host() || cfg.hass.get_token().is_empty() { 133 | eprintln!("Can't connect to Home Assistant: URL or token is missing"); 134 | std::process::exit(1); 135 | } 136 | 137 | Ok(cfg) 138 | } 139 | 140 | struct ServerMock { 141 | id: String, 142 | connected: bool, 143 | controller_addr: Addr, 144 | } 145 | 146 | impl ServerMock { 147 | fn new(id: impl Into, controller_addr: Addr) -> Self { 148 | Self { 149 | id: id.into(), 150 | connected: false, 151 | controller_addr, 152 | } 153 | } 154 | } 155 | impl Actor for ServerMock { 156 | type Context = Context; 157 | } 158 | 159 | impl Handler for ServerMock { 160 | type Result = (); 161 | 162 | fn handle(&mut self, msg: SendWsMessage, _ctx: &mut Self::Context) { 163 | let msg_name = msg.0.msg.clone().unwrap_or_default(); 164 | if msg_name == "device_state" 165 | && let Some(msg_data) = msg.0.msg_data.as_ref() 166 | { 167 | self.connected = msg_data 168 | .as_object() 169 | .and_then(|o| o.get("state")) 170 | .and_then(|s| s.as_str()) 171 | == Some("CONNECTED"); 172 | 173 | if self.connected { 174 | self.controller_addr.do_send(R2RequestMsg { 175 | ws_id: self.id.clone(), 176 | req_id: 0, 177 | request: R2Request::GetEntityStates, 178 | msg_data: None, 179 | }); 180 | } 181 | } 182 | 183 | if let Ok(msg) = serde_json::to_string(&msg.0) { 184 | if msg_name == "entity_states" { 185 | info!("[{}] <- entity_states:\n{msg}", self.id); 186 | self.controller_addr.do_send(R2EventMsg { 187 | ws_id: self.id.clone(), 188 | event: R2Event::Disconnect, 189 | msg_data: None, 190 | }); 191 | info!("Entity states received, disconnecting!"); 192 | } else { 193 | debug!("[{}] <- {msg}", self.id); 194 | } 195 | } else { 196 | error!("[{}] Error serializing {:?}", self.id, msg.0) 197 | } 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/client/event.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Home Assistant WebSocket event message handling. 5 | //! 6 | //! See for further 7 | //! information. 8 | 9 | use crate::client::HomeAssistantClient; 10 | use crate::client::entity::*; 11 | use crate::client::messages::{AssistEvent, EntityEvent}; 12 | use crate::client::model::{AssistPipelineEvent, Event}; 13 | use crate::errors::ServiceError; 14 | use log::{debug, warn}; 15 | use serde_json::json; 16 | use std::time::Instant; 17 | 18 | impl HomeAssistantClient { 19 | /// Whenever an entity `event` message is received from HA, this method is called to handle it. 20 | /// The event conversion is delegated to entity-type-specific functions for the supported entity 21 | /// types. 22 | /// 23 | /// The converted `EntityChange` is sent to the controller in an Actix `EntityEvent` message to 24 | /// be delegated to the connected remotes. 25 | /// 26 | /// # Arguments 27 | /// 28 | /// * `event`: Transformed `.event` json object containing only the required data. 29 | /// 30 | /// returns: Result<(), ServiceError> 31 | pub(super) fn handle_entity_event(&mut self, event: Event) -> Result<(), ServiceError> { 32 | let entity_type = match event.data.entity_id.split_once('.') { 33 | None => return Err(ServiceError::BadRequest("Invalid entity_id format".into())), 34 | Some((l, _)) => l, 35 | }; 36 | 37 | if event.data.entity_id.is_empty() || event.data.new_state.state.is_empty() { 38 | return Err(ServiceError::BadRequest(format!( 39 | "Missing data in state_changed event: {:?}", 40 | event.data 41 | ))); 42 | } 43 | 44 | let entity_change = match entity_type { 45 | "light" => light_event_to_entity_change(event.data), 46 | "switch" | "input_boolean" => switch_event_to_entity_change(event.data), 47 | "button" | "input_button" | "script" => { 48 | // the button & script entity is stateless and the remote doesn't need to be notified when the button was pressed externally 49 | return Ok(()); 50 | } 51 | "cover" => cover_event_to_entity_change(event.data), 52 | "sensor" | "binary_sensor" => sensor_event_to_entity_change(event.data), 53 | "climate" => climate_event_to_entity_change(event.data), 54 | "media_player" => media_player_event_to_entity_change(&self.server, event.data), 55 | "remote" => remote_event_to_entity_change(event.data), 56 | &_ => { 57 | debug!("[{}] Unsupported entity: {}", self.id, entity_type); 58 | return Ok(()); // it's not really an error, so it's ok ;-) 59 | } 60 | }?; 61 | 62 | self.controller_actor.try_send(EntityEvent { 63 | client_id: self.id.clone(), 64 | entity_change, 65 | })?; 66 | 67 | Ok(()) 68 | } 69 | 70 | pub(super) fn handle_assist_pipeline_event( 71 | &mut self, 72 | id: u32, 73 | mut event: AssistPipelineEvent, 74 | ) -> Result<(), ServiceError> { 75 | self.remove_expired_assist_sessions(); 76 | 77 | let session = match self.assist_sessions.get_mut(&id) { 78 | None => { 79 | warn!( 80 | "[{}] no assist session found for id {id}: ignoring event {event:?}", 81 | self.id 82 | ); 83 | return Ok(()); 84 | } 85 | Some(session) => session, 86 | }; 87 | 88 | debug!("[{}] assist pipeline event: {:?}", self.id, event); 89 | // intercept events to update the session state or patch certain fields 90 | match &mut event { 91 | AssistPipelineEvent::RunStart { data } => { 92 | let bin_id = data.runner_data.as_ref().map(|d| d.stt_binary_handler_id); 93 | session.stt_binary_handler_id = bin_id; 94 | } 95 | AssistPipelineEvent::SttEnd { .. } => {} 96 | AssistPipelineEvent::IntentEnd { .. } => {} 97 | AssistPipelineEvent::RunEnd => { 98 | // Don't remove session yet: we might still get an Error event AFTER RunEnd! 99 | // Session will be removed in `remove_expired_assist_sessions`. 100 | session.run_end = Some(Instant::now()); 101 | } 102 | AssistPipelineEvent::Error { data } => { 103 | // we might still get a RunEnd event after an Error event! 104 | session.error = Some(data.clone()); 105 | } 106 | // not (yet) interested in the remaining events: 107 | AssistPipelineEvent::WakeWordStart => {} 108 | AssistPipelineEvent::WakeWordEnd => {} 109 | AssistPipelineEvent::SttStart { .. } => {} 110 | AssistPipelineEvent::SttVadStart => {} 111 | AssistPipelineEvent::SttVadEnd => {} 112 | AssistPipelineEvent::IntentStart { .. } => {} 113 | AssistPipelineEvent::IntentProgress => {} 114 | AssistPipelineEvent::TtsStart { .. } => {} 115 | AssistPipelineEvent::TtsEnd { data } => { 116 | if let Some(output) = data.tts_output.as_mut() 117 | && output.url.starts_with('/') 118 | { 119 | output.url = format!( 120 | "{}://{}:{}{}", 121 | self.server.scheme(), 122 | self.server.host_str().unwrap_or_default(), 123 | self.server.port_or_known_default().unwrap_or_default(), 124 | output.url 125 | ); 126 | } 127 | } 128 | } 129 | 130 | let _ = self.controller_actor.try_send(AssistEvent::new( 131 | session.session_id, 132 | session.entity_id.clone(), 133 | event, 134 | )); 135 | 136 | Ok(()) 137 | } 138 | } 139 | 140 | /// Convert a HA sensor state to a UC sensor-entity state. 141 | /// 142 | /// The UC sensor entity only supports the ON state, and the common entity states: 143 | /// https://unfoldedcircle.github.io/core-api/entities/entity_sensor.html#states 144 | /// # Arguments 145 | /// 146 | /// * `state`: Home Assistant sensor or binary-sensor state. 147 | /// 148 | /// returns: "ON", "UNAVAILABLE", or "UNKNOWN" 149 | pub(crate) fn convert_ha_sensor_state(state: &str) -> Result { 150 | match state { 151 | "unavailable" | "unknown" => Ok(serde_json::Value::String(state.to_uppercase())), 152 | &_ => Ok(json!("ON")), 153 | } 154 | } 155 | 156 | pub(crate) fn convert_ha_onoff_state(state: &str) -> Result { 157 | match state { 158 | "on" | "off" | "unavailable" | "unknown" => { 159 | Ok(serde_json::Value::String(state.to_uppercase())) 160 | } 161 | &_ => Err(ServiceError::BadRequest(format!( 162 | "Unknown state: {}", 163 | state 164 | ))), 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Home-Assistant Integration for Remote Two 5 | //! 6 | //! This service application connects [`Home Assistant`](https://www.home-assistant.io/) with the 7 | //! [`Remote Two`](https://www.unfoldedcircle.com/) and allows to interact with most entities on 8 | //! the remote. 9 | //! It implements the Remote Two [`Integration-API`](https://github.com/unfoldedcircle/core-api) 10 | //! which communicates with JSON messages over WebSocket. 11 | //! 12 | //! The WebSocket server and client uses [`Actix Web`](https://actix.rs/) with the Actix actor 13 | //! system for internal service communication. 14 | 15 | #![forbid(non_ascii_idents)] 16 | #![deny(unsafe_code)] 17 | 18 | use crate::configuration::{ 19 | CertificateSettings, ENV_DISABLE_MDNS_PUBLISH, IntegrationSettings, get_configuration, 20 | }; 21 | use crate::controller::Controller; 22 | use crate::server::publish_service; 23 | use crate::util::{bool_from_env, create_single_cert_server_config}; 24 | use actix::Actor; 25 | use actix_web::{App, HttpServer, middleware, web}; 26 | use clap::{Command, arg}; 27 | use configuration::DEF_CONFIG_FILE; 28 | use log::{error, info}; 29 | use std::io; 30 | use std::net::TcpListener; 31 | use std::path::Path; 32 | use uc_api::intg::IntegrationDriverUpdate; 33 | use uc_api::util::text_from_language_map; 34 | use uc_intg_hass::{APP_VERSION, built_info}; 35 | 36 | mod client; 37 | mod configuration; 38 | mod controller; 39 | mod errors; 40 | mod server; 41 | mod util; 42 | 43 | #[actix_web::main] 44 | async fn main() -> io::Result<()> { 45 | let args = Command::new(built_info::PKG_NAME) 46 | .author("Unfolded Circle ApS") 47 | .version(APP_VERSION) 48 | .about("Home Assistant integration for Remote Two") 49 | .arg(arg!(-c --config ... "Configuration file").required(false)) 50 | .get_matches(); 51 | 52 | env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); 53 | info!("Starting {} {}", built_info::PKG_NAME, APP_VERSION); 54 | 55 | rustls::crypto::aws_lc_rs::default_provider() 56 | .install_default() 57 | .unwrap(); 58 | 59 | let cfg_file: Option<&str> = 60 | args.get_one("config") 61 | .map(|c: &String| c.as_str()) 62 | .or_else(|| { 63 | if Path::new(DEF_CONFIG_FILE).exists() { 64 | info!("Loading default configuration file: {}", DEF_CONFIG_FILE); 65 | Some(DEF_CONFIG_FILE) 66 | } else { 67 | None 68 | } 69 | }); 70 | 71 | let cfg = get_configuration(cfg_file).expect("Failed to read configuration"); 72 | 73 | let listeners = create_tcp_listeners(&cfg.integration)?; 74 | let api_port = cfg.integration.http.port; 75 | let websocket_settings = web::Data::new(cfg.integration.websocket.clone().unwrap_or_default()); 76 | let driver_metadata = configuration::get_driver_metadata()?; 77 | 78 | let controller = web::Data::new(Controller::new(cfg, driver_metadata.clone()).start()); 79 | 80 | let mut http_server = HttpServer::new(move || { 81 | App::new() 82 | .wrap(middleware::Logger::default()) 83 | .app_data( 84 | web::JsonConfig::default() 85 | .limit(16 * 1024) // limit size of the payload (global configuration) 86 | .error_handler(server::json_error_handler), 87 | ) 88 | .app_data(websocket_settings.clone()) 89 | .app_data(controller.clone()) 90 | // Websockets 91 | .service(server::ws_index) 92 | }) 93 | .workers(1); 94 | 95 | if let Some(listener) = listeners.listener_tls { 96 | let server_cfg = 97 | create_single_cert_server_config(&listeners.certs.public, &listeners.certs.private)?; 98 | http_server = http_server.listen_rustls_0_23(listener, server_cfg)?; 99 | } 100 | 101 | if let Some(listener) = listeners.listener { 102 | http_server = http_server.listen(listener)?; 103 | } 104 | 105 | if !bool_from_env(ENV_DISABLE_MDNS_PUBLISH) { 106 | publish_mdns(api_port, driver_metadata); 107 | } 108 | 109 | http_server.run().await?; 110 | 111 | Ok(()) 112 | } 113 | 114 | struct Listeners { 115 | pub listener: Option, 116 | pub listener_tls: Option, 117 | pub certs: CertificateSettings, 118 | } 119 | 120 | fn create_tcp_listeners(cfg: &IntegrationSettings) -> Result { 121 | let version = built_info::GIT_VERSION.unwrap_or(built_info::PKG_VERSION); 122 | let listener = if cfg.http.enabled { 123 | let address = format!("{}:{}", cfg.interface, cfg.http.port); 124 | println!("{} {version} listening on: {address}", built_info::PKG_NAME); 125 | Some(TcpListener::bind(address)?) 126 | } else { 127 | None 128 | }; 129 | 130 | let (listener_tls, certs) = if cfg.https.enabled { 131 | let address = format!("{}:{}", cfg.interface, cfg.https.port); 132 | let certs = match cfg.certs.as_ref() { 133 | None => { 134 | error!("https requires integration.certs settings"); 135 | std::process::exit(1); 136 | } 137 | Some(c) => c.clone(), 138 | }; 139 | 140 | println!("{} {version} listening on: {address}", built_info::PKG_NAME); 141 | (Some(TcpListener::bind(address)?), certs) 142 | } else { 143 | (None, Default::default()) 144 | }; 145 | 146 | if listener.is_none() && listener_tls.is_none() { 147 | return Err(io::Error::new( 148 | io::ErrorKind::InvalidData, 149 | "At least one http or https listener must be specified", 150 | )); 151 | } 152 | 153 | Ok(Listeners { 154 | listener, 155 | listener_tls, 156 | certs, 157 | }) 158 | } 159 | 160 | /// Advertise integration driver with mDNS. 161 | fn publish_mdns(api_port: u16, drv_metadata: IntegrationDriverUpdate) { 162 | if let Err(e) = publish_service( 163 | drv_metadata 164 | .driver_id 165 | .expect("driver_id must be set in driver metadata"), 166 | "uc-integration", 167 | "tcp", 168 | api_port, 169 | vec![ 170 | format!( 171 | "name={}", 172 | text_from_language_map(drv_metadata.name.as_ref(), "en") 173 | .unwrap_or("Home Assistant") 174 | ), 175 | format!( 176 | "developer={}", 177 | drv_metadata 178 | .developer 179 | .and_then(|d| d.name) 180 | .unwrap_or("Unfolded Circle ApS".into()) 181 | ), 182 | // "ws_url=wss://localhost:8008".into(), // to override the complete WS url. Ignores ws_path, wss, wss_port! 183 | "ws_path=/ws".into(), // otherwise `/` is used and the remote can't connect 184 | //"wss=false".into(), // if wss is required 185 | //format!("wss_port={}", cfg.integration.https.port), // if https port if different from the published service port above 186 | format!("pwd={}", drv_metadata.pwd_protected.unwrap_or_default()), 187 | format!("ver={APP_VERSION}"), 188 | ], 189 | ) { 190 | error!("Error publishing mDNS service: {e}"); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Home-Assistant Integration for Remote Two/3 Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## Unreleased 8 | 9 | _Changes in the next release_ 10 | 11 | --- 12 | 13 | ## v0.14.1 - 2025-12-02 14 | ### Fixed 15 | - Voice assistant entity state updates and setting the correct "response_speech" attribute. 16 | 17 | ## v0.14.0 - 2025-11-29 18 | ### Added 19 | - Assist support with the new voice_assistant entity ([#78](https://github.com/unfoldedcircle/integration-home-assistant/pull/78)). 20 | 21 | ### Changed 22 | - Remove the 'v'-prefix from the version information. 23 | - Replace deprecated actix-web-actors with actix-ws ([#77](https://github.com/unfoldedcircle/integration-home-assistant/pull/77)). 24 | 25 | ## v0.13.1 - 2025-09-11 26 | ### Fixed 27 | - Docker image build with updated GitHub actions. 28 | 29 | ## v0.13.0 - 2025-09-11 30 | ### Added 31 | - Expert option to disable certificate validation of the Home Assistant WebSocket server connection ([#71](https://github.com/unfoldedcircle/integration-home-assistant/pull/71)). 32 | 33 | ### Fixed 34 | - Initial setup with a wss:// Home Assistant URL or switching from ws to wss doesn't require a reboot anymore ([#73](https://github.com/unfoldedcircle/integration-home-assistant/pull/73)). 35 | - Report the correct integration version without the "-dirty" suffix if it was built with a clean codebase. 36 | - Only report the documented sensor states `ON`, `UNAVAILABLE` and `UNKNOWN`. 37 | 38 | ### Breaking changes 39 | - The mapping of binary sensors has changed to better represent them on the user interface ([#74](https://github.com/unfoldedcircle/integration-home-assistant/pull/74)): 40 | - The device class is now set to `binary` instead of `custom`. 41 | - The Home Assistant device class is stored in the `unit` attribute. 42 | - The `value` attribute is no longer a boolean, but contains the `on` and `off` sensor text values from Home Assistant. 43 | 44 | ### Changed 45 | - Rustls upgrade to 0.23 with system certificate verifier ([#73](https://github.com/unfoldedcircle/integration-home-assistant/pull/73)). 46 | - Update Rust crates and cross-compile toolchain ([#73](https://github.com/unfoldedcircle/integration-home-assistant/pull/73)). 47 | 48 | ## v0.12.2 - 2025-04-17 49 | ### Added 50 | - Propagate media player attribute `media_position_updated_at` ([feature-and-bug-tracker#443](https://github.com/unfoldedcircle/feature-and-bug-tracker/issues/443)). 51 | 52 | ### Fixed 53 | - Media player `media_type` attribute value should be upper case to match entity documentation. 54 | ### Changed 55 | - update README and driver description ([#67](https://github.com/unfoldedcircle/integration-home-assistant/pull/67)). 56 | 57 | ## v0.12.1 - 2025-02-17 58 | ### Fixed 59 | - Docker image build regression in v0.12.0 ([#65](https://github.com/unfoldedcircle/integration-home-assistant/pull/65)). 60 | 61 | ## v0.12.0 - 2024-12-13 62 | ### Added 63 | - Available entities mode with HA component: get states will retrieve only available entities from HA component instead of all entities in HA. Contributed by @albaintor, thanks! ([#62](https://github.com/unfoldedcircle/integration-home-assistant/pull/62)). 64 | 65 | ## v0.11.0 - 2024-09-27 66 | ### Added 67 | - Automatic configuration and setup through the [Unfolded Circle for Home Assistant component](https://github.com/JackJPowell/hass-unfoldedcircle). In cooperation with @albaintor and @JackJPowell, thanks! ([#60](https://github.com/unfoldedcircle/integration-home-assistant/pull/60)). 68 | - Please note that the autoconfiguration feature in the Home Assistant component is still under development at the time of this release. 69 | ### Fixed 70 | - Avoid initial failed Home Assistant login attempt with default HA server url and empty access token. 71 | 72 | ## v0.10.0 - 2024-08-24 73 | ### Added 74 | - Initial support for the [Unfolded Circle for Home Assistant component](https://github.com/JackJPowell/hass-unfoldedcircle) for optimized message communication. Contributed by @albaintor, thanks! ([#58](https://github.com/unfoldedcircle/integration-home-assistant/pull/58)) 75 | 76 | ### Changed 77 | - Update uc_api crate to latest 0.12.0 version. 78 | 79 | ## v0.9.0 - 2024-04-10 80 | ### Added 81 | - Remote-entity support ([#23](https://github.com/unfoldedcircle/integration-home-assistant/issues/23)). 82 | 83 | ## v0.8.2 - 2024-03-04 84 | ### Changed 85 | - Update uc-api to 0.9.3 for new media-player features (currently not used for Home Assistant). 86 | - Update Rust crates. 87 | 88 | ## v0.8.1 - 2024-02-27 89 | ### Fixed 90 | - driver_version response field. 91 | 92 | ## v0.8.0 - 2024-02-16 93 | ### Added 94 | - Option to disconnect from HA when device enters standby ([#50](https://github.com/unfoldedcircle/integration-home-assistant/issues/50)). 95 | ### Changed 96 | - Update Rust crates. 97 | 98 | ## v0.7.0 - 2024-02-05 99 | ### Added 100 | - Home Assistant WebSocket API connection test tool. 101 | ### Fixed 102 | - Extract and convert color information from received HA light entities to follow external color changes. Supported color models: xy, hs, rgb ([#7](https://github.com/unfoldedcircle/integration-home-assistant/issues/7)). 103 | - Connection timeout setting was used as request timeout. TCP connection timeout was always set to 5 seconds ([#47](https://github.com/unfoldedcircle/integration-home-assistant/issues/47)). 104 | - Connection state handling in initial setup to avoid restart ([#43](https://github.com/unfoldedcircle/integration-home-assistant/issues/43)). 105 | ### Changed 106 | - Immediately close HA WS connection in case of a protocol error. 107 | 108 | ## v0.6.1 - 2024-01-04 109 | ### Fixed 110 | - Reconnection logic regression introduced in v0.6.0 111 | 112 | ## v0.6.0 - 2024-01-03 113 | ### Fixed 114 | - Reconnect to HA server after driver reconfiguration ([#36](https://github.com/unfoldedcircle/integration-home-assistant/issues/36)). 115 | - Improved reconnection logic to prevent multiple connections. 116 | 117 | ### Changed 118 | - Use Ping-Pong API messages as defined in the HA WebSocket API by default instead of WebSocket ping frames. 119 | 120 | ## v0.5.1 - 2023-12-17 121 | ### Fixed 122 | - Allow unlimited reconnection ([#35](https://github.com/unfoldedcircle/integration-home-assistant/issues/35)). 123 | 124 | ## v0.5.0 - 2023-11-15 125 | ### Added 126 | - Map scenes to push buttons ([#29](https://github.com/unfoldedcircle/integration-home-assistant/issues/29)). 127 | 128 | ### Changed 129 | - Rename media-player `select_sound_mode` command parameter ([feature-and-bug-tracker#165](https://github.com/unfoldedcircle/feature-and-bug-tracker/issues/165)). 130 | - Update dependencies, including rustls 0.21. 131 | 132 | ## v0.4.0 - 2023-09-13 133 | ### Added 134 | - Allow to use HA Scripts as Button Entity. 135 | 136 | ## v0.3.0 - 2023-07-17 137 | ### Added 138 | - option to use zeroconf library for mDNS advertisement instead of mdns-sd 139 | - new media player features: 140 | - Add support for input source and sound mode selection. 141 | - Propagate entity states `standby` and `buffering`. 142 | 143 | ## v0.2.1 - 2023-05-25 144 | ### Fixed 145 | - mdns-sd workaround for mDNS query flooding 146 | 147 | ## v0.2.0 - 2023-03-28 148 | ### Added 149 | - mDNS announcement and `get_driver_metadata` message implementation. 150 | - driver setup flow with main & advanced configuration settings. 151 | - initial TLS WebSocket client and server support. 152 | -------------------------------------------------------------------------------- /src/client/get_states.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Actix actor handler implementation for the `GetStates` message 5 | 6 | use std::str::FromStr; 7 | 8 | use crate::client::HomeAssistantClient; 9 | use crate::client::entity::*; 10 | use crate::client::messages::{CallListAssistPipelines, GetStates}; 11 | use crate::errors::ServiceError; 12 | use actix::{ActorFutureExt, AsyncContext, Handler, ResponseActFuture, WrapFuture}; 13 | use log::{debug, error, info, warn}; 14 | use serde_json::{Value, json}; 15 | use uc_api::EntityType; 16 | use uc_api::intg::AvailableIntgEntity; 17 | 18 | impl Handler for HomeAssistantClient { 19 | type Result = ResponseActFuture>; 20 | 21 | // TODO cleanup: almost same code as GetAvailableEntities, only difference is subscribed_entities vs parameter in GetStates 22 | fn handle(&mut self, msg: GetStates, ctx: &mut Self::Context) -> Self::Result { 23 | debug!("[{}] GetStates from '{}'", self.id, msg.remote_id); 24 | self.remote_id = msg.remote_id; 25 | 26 | let ha_client = ctx.address(); 27 | Box::pin( 28 | async move { 29 | // Retrieve available assist pipelines and map them to UC voice-assistant entities 30 | // This is best effort, old HA setups might return an error 31 | match ha_client.send(CallListAssistPipelines::default()).await { 32 | Ok(Ok(result)) => Some(result), 33 | Ok(Err(e)) => { 34 | error!("Failed to retrieve Home Assistant assist pipelines: {e}"); 35 | None 36 | } 37 | Err(e) => { 38 | error!("Failed to send CallListAssistPipelines: {e}"); 39 | None 40 | } 41 | } 42 | } 43 | .into_actor(self) // converts future to ActorFuture 44 | .map(move |result, act, ctx| { 45 | if let Some(result) = result { 46 | info!("Got assist pipelines: {result:?}"); 47 | act.assist_pipelines = Some(result); 48 | } 49 | // Retrieve Home Assistant entities 50 | let id = act.new_msg_id(); 51 | // Use the same message id for get states and get available entities (same result format) 52 | act.entity_states_id = Some(id); 53 | // Try to subscribe again to custom events if not already done when 54 | // GetStates command is received from the remote 55 | act.send_uc_info_command(ctx); 56 | // If UC HA component available, get states only on given (subscribed) entities 57 | if act.uc_ha_component { 58 | act.send_json( 59 | json!( 60 | { 61 | "id": id, 62 | "type": "unfoldedcircle/entities/states", 63 | "data": { 64 | "entity_ids": msg.entity_ids, 65 | "client_id": act.remote_id 66 | } 67 | } 68 | ), 69 | ctx, 70 | ) 71 | } else { 72 | act.send_json( 73 | json!( 74 | {"id": id, "type": "get_states"} 75 | ), 76 | ctx, 77 | ) 78 | } 79 | }), 80 | ) 81 | } 82 | } 83 | 84 | impl HomeAssistantClient { 85 | pub(crate) fn handle_get_states_result( 86 | &mut self, 87 | entities: Vec, 88 | ) -> Result, ServiceError> { 89 | let mut available = Vec::with_capacity(32); 90 | 91 | for mut entity in entities { 92 | let entity_id = entity 93 | .get("entity_id") 94 | .and_then(|v| v.as_str()) 95 | .unwrap_or_default(); 96 | let entity_id = entity_id.to_string(); 97 | let error_id = entity_id.to_string(); 98 | let entity_type = match entity_id.split_once('.') { 99 | None => { 100 | error!( 101 | "[{}] Invalid entity_id format, missing dot to extract domain: {entity_id}", 102 | self.id 103 | ); 104 | continue; // best effort 105 | } 106 | // map different entity type names 107 | Some((domain, _)) => match domain { 108 | "input_boolean" => "switch", 109 | "binary_sensor" => "sensor", 110 | "input_button" => "button", 111 | "script" => "button", 112 | "scene" => "button", 113 | v => v, 114 | }, 115 | }; 116 | 117 | let entity_type = match EntityType::from_str(entity_type) { 118 | Err(_) => { 119 | debug!("[{}] Filtering non-supported entity: {entity_id}", self.id); 120 | continue; 121 | } 122 | Ok(v) => v, 123 | }; 124 | 125 | let state = entity 126 | .get("state") 127 | .and_then(|v| v.as_str()) 128 | .map(|v| v.to_string()) 129 | .unwrap_or_default(); 130 | let attr = match entity.get_mut("attributes").and_then(|v| v.as_object_mut()) { 131 | None => { 132 | warn!( 133 | "[{}] Could not convert HASS entity {error_id}: missing attributes", 134 | self.id 135 | ); 136 | continue; 137 | } 138 | Some(o) => o, 139 | }; 140 | 141 | let avail_entity = match entity_type { 142 | EntityType::Button => convert_button_entity(entity_id, state, attr), 143 | EntityType::Switch => convert_switch_entity(entity_id, state, attr), 144 | EntityType::Climate => convert_climate_entity(entity_id, state, attr), 145 | EntityType::Cover => convert_cover_entity(entity_id, state, attr), 146 | EntityType::Light => convert_light_entity(entity_id, state, attr), 147 | EntityType::MediaPlayer => { 148 | convert_media_player_entity(&self.server, entity_id, state, attr) 149 | } 150 | EntityType::Remote => convert_remote_entity(entity_id, state, attr), 151 | EntityType::Sensor => convert_sensor_entity(entity_id, state, attr), 152 | EntityType::IrEmitter => { 153 | // no related HA entity 154 | continue; 155 | } 156 | // internal core entities for the moment 157 | EntityType::Activity | EntityType::Macro => { 158 | info!("[{}] skipping internal entity {entity_type}", self.id); 159 | continue; 160 | } 161 | EntityType::VoiceAssistant => { 162 | // mapped from HA assist pipeline to UC entity 163 | continue; 164 | } 165 | }; 166 | 167 | match avail_entity { 168 | Ok(entity) => available.push(entity), 169 | Err(e) => warn!( 170 | "[{}] Could not convert HASS entity {error_id}: {e:?}", 171 | self.id 172 | ), 173 | } 174 | } 175 | 176 | Ok(available) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/client/get_entities.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Actix actor handler implementation for the `GetStates` message 5 | 6 | use crate::client::HomeAssistantClient; 7 | use crate::client::messages::{CallListAssistPipelines, GetAvailableEntities}; 8 | use crate::errors::ServiceError; 9 | use actix::{ActorFutureExt, AsyncContext, Handler, ResponseActFuture, WrapFuture}; 10 | use log::{debug, error, info}; 11 | use serde_json::json; 12 | use std::collections::HashMap; 13 | use uc_api::intg::AvailableIntgEntity; 14 | use uc_api::{ 15 | AudioConfiguration, EntityType, VoiceAssistantAttribute, VoiceAssistantEntityOptions, 16 | VoiceAssistantFeature, VoiceAssistantProfile, 17 | }; 18 | 19 | impl Handler for HomeAssistantClient { 20 | type Result = ResponseActFuture>; 21 | 22 | fn handle(&mut self, msg: GetAvailableEntities, ctx: &mut Self::Context) -> Self::Result { 23 | debug!("[{}] GetAvailableEntities from {}", self.id, msg.remote_id); 24 | self.remote_id = msg.remote_id; 25 | 26 | let ha_client = ctx.address(); 27 | Box::pin( 28 | async move { 29 | // Retrieve available assist pipelines and map them to UC voice-assistant entities 30 | // This is best effort, old HA setups might return an error 31 | match ha_client.send(CallListAssistPipelines::default()).await { 32 | Ok(Ok(result)) => Some(result), 33 | Ok(Err(e)) => { 34 | error!("Failed to retrieve Home Assistant assist pipelines: {e}"); 35 | None 36 | } 37 | Err(e) => { 38 | error!("Failed to send CallListAssistPipelines: {e}"); 39 | None 40 | } 41 | } 42 | } 43 | .into_actor(self) // converts future to ActorFuture 44 | .map(move |result, act, ctx| { 45 | if let Some(result) = result { 46 | info!("Got assist pipelines: {result:?}"); 47 | act.assist_pipelines = Some(result); 48 | } 49 | // Retrieve Home Assistant entities 50 | let id = act.new_msg_id(); 51 | 52 | act.entity_states_id = Some(id); 53 | // Try to subscribe again to custom events if not already done when 54 | // GetAvailableEntities command is received from the remote 55 | act.send_uc_info_command(ctx); 56 | if act.uc_ha_component { 57 | // Retrieve the states of available entities (including subscribed entities) 58 | // Available entities are defined on HA component side and should include 59 | // subscribed entities but sent anyway just in case some are missing 60 | debug!( 61 | "[{}] Get states from {} with unfoldedcircle/get_states", 62 | act.id, act.remote_id 63 | ); 64 | act.send_json( 65 | json!( 66 | { 67 | "id": id, 68 | "type": "unfoldedcircle/entities/states", 69 | "data": { 70 | "entity_ids": act.subscribed_entities, 71 | "client_id": act.remote_id 72 | } 73 | } 74 | ), 75 | ctx, 76 | ) 77 | } else { 78 | debug!("[{}] Get standard states from {} ", act.id, act.remote_id); 79 | act.send_json( 80 | json!( 81 | {"id": id, "type": "get_states"} 82 | ), 83 | ctx, 84 | ) 85 | } 86 | }), 87 | ) 88 | } 89 | } 90 | 91 | impl HomeAssistantClient { 92 | pub(super) fn get_voice_assistant_entities(&self) -> Vec { 93 | if let Some(assist) = &self.assist_pipelines 94 | && !assist.pipelines.is_empty() 95 | { 96 | let name = HashMap::from([ 97 | ("en".into(), "Voice assistant (Assist)".into()), 98 | ("de".into(), "Sprachassistent (Assist)".into()), 99 | ("es".into(), "Asistente de voz (Assist)".into()), 100 | ("fr".into(), "Assistant vocal (Assist)".into()), 101 | ("it".into(), "Assistenti vocali (Assist)".into()), 102 | ("nl".into(), "Spraakassistent (Assist)".into()), 103 | ("no".into(), "Språkassistent (Assist)".into()), 104 | ("pt".into(), "Assistente de voz (Assist)".into()), 105 | ("sv".into(), "Språkassistent (Assist)".into()), 106 | ]); 107 | 108 | let pref_id = assist.preferred_pipeline.as_deref().unwrap_or_default(); 109 | let pref_pipe = assist.pipelines.iter().find(|p| p.id == pref_id); 110 | 111 | let mut features = vec![ 112 | VoiceAssistantFeature::Transcription.to_string(), 113 | VoiceAssistantFeature::ResponseText.to_string(), 114 | ]; 115 | 116 | if let Some(pref_pipe) = pref_pipe 117 | && pref_pipe.tts_engine.is_some() 118 | { 119 | features.push(VoiceAssistantFeature::ResponseSpeech.to_string()); 120 | } 121 | 122 | let profiles = assist 123 | .pipelines 124 | .iter() 125 | .map(|p| { 126 | // afaik, transcription & text response are always active 127 | let mut prof_feat = vec![ 128 | VoiceAssistantFeature::Transcription, 129 | VoiceAssistantFeature::ResponseText, 130 | ]; 131 | // speech response can be configured in a pipeline and might not be active for all. 132 | if p.tts_engine.is_some() { 133 | prof_feat.push(VoiceAssistantFeature::ResponseSpeech); 134 | } 135 | VoiceAssistantProfile { 136 | id: p.id.clone(), 137 | name: p.name.clone(), 138 | language: Some(p.language.clone()), 139 | features: Some(prof_feat), 140 | } 141 | }) 142 | .collect(); 143 | 144 | let options = VoiceAssistantEntityOptions { 145 | audio_cfg: Some(AudioConfiguration::default()), 146 | profiles: Some(profiles), 147 | preferred_profile: pref_pipe.map(|p| p.id.clone()), 148 | }; 149 | 150 | let mut attributes = serde_json::Map::with_capacity(1); 151 | attributes.insert(VoiceAssistantAttribute::State.to_string(), "ON".into()); 152 | 153 | match (AvailableIntgEntity { 154 | entity_id: "assist".to_string(), 155 | device_id: None, 156 | entity_type: EntityType::VoiceAssistant, 157 | device_class: None, 158 | name, 159 | icon: Some("uc:microphone".to_string()), 160 | features: Some(features), 161 | area: None, 162 | options: None, 163 | attributes: Some(attributes), 164 | } 165 | .with_options(options)) 166 | { 167 | Ok(entity) => return vec![entity], 168 | Err(e) => { 169 | error!("Failed to create voice assistant entity: {e}"); 170 | } 171 | } 172 | } 173 | 174 | vec![] 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/client/assist/call_pipeline.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Home Assistant WebSocket assist pipeline call handler. 5 | 6 | use crate::client::HomeAssistantClient; 7 | use crate::client::messages::{CallListAssistPipelines, CallRunAssistPipeline}; 8 | use crate::client::model::{ 9 | AssistPipelineMsg, AssistPipelineRequest, AssistSession, EndStage, GetPipelinesResult, 10 | OpenRequest, ResponseMsg, RunAssistPipelineBuilder, 11 | }; 12 | use crate::errors::ServiceError; 13 | use crate::errors::ServiceError::InternalServerError; 14 | use crate::util::return_fut_err; 15 | use actix::{ActorFutureExt, Handler, ResponseActFuture, WrapFuture, fut}; 16 | use log::{error, info, warn}; 17 | use std::time::Duration; 18 | use tokio::sync::oneshot; 19 | 20 | impl Handler for HomeAssistantClient { 21 | type Result = ResponseActFuture>; 22 | 23 | fn handle(&mut self, msg: CallRunAssistPipeline, ctx: &mut Self::Context) -> Self::Result { 24 | self.remove_expired_assist_sessions(); 25 | 26 | let msg_id = self.new_msg_id(); 27 | info!( 28 | "[{}] Starting assist session with request_id {msg_id}, session id {}", 29 | self.id, msg.session_id 30 | ); 31 | self.assist_sessions.insert( 32 | msg_id, 33 | AssistSession::new(msg_id, msg.entity_id, msg.session_id), 34 | ); 35 | let (tx, rx) = oneshot::channel(); 36 | self.open_requests.insert(msg_id, OpenRequest::new(tx)); 37 | 38 | let run = match RunAssistPipelineBuilder::default() 39 | .id(msg_id) 40 | .input(serde_json::json!({ "sample_rate": msg.sample_rate })) 41 | .timeout(msg.timeout) 42 | .end_stage(if msg.speech_response { 43 | EndStage::Tts 44 | } else { 45 | EndStage::Intent 46 | }) 47 | .pipeline(msg.pipeline_id) 48 | .build() 49 | .map_err(|e| InternalServerError(format!("Error building pipeline run request: {e}"))) 50 | { 51 | Ok(run) => run, 52 | Err(e) => { 53 | return_fut_err!(e); 54 | } 55 | }; 56 | let assist_pipeline_req = AssistPipelineRequest::Run(run); 57 | 58 | if let Err(e) = self.send_json( 59 | serde_json::to_value(assist_pipeline_req).expect("BUG serializing"), 60 | ctx, 61 | ) { 62 | self.assist_sessions.remove(&msg_id); 63 | self.open_requests.remove(&msg_id); 64 | return_fut_err!(e); 65 | }; 66 | 67 | Box::pin( 68 | async move { 69 | let request_timeout = Duration::from_secs(5); 70 | match tokio::time::timeout(request_timeout, rx).await { 71 | Ok(Ok(r)) => Ok(r), 72 | _ => Err(ServiceError::ServiceUnavailable( 73 | "Timeout while waiting for pipeline run result".into(), 74 | )), 75 | } 76 | } 77 | .into_actor(self) // converts future to ActorFuture 78 | .map(move |result, act, _ctx| { 79 | // note: starting the assist session here is mostly too late, since the first pipeline event was already received while this async block was scheduled! 80 | act.open_requests.remove(&msg_id); 81 | 82 | // only keep session if pipeline run succeeded 83 | let error = match result { 84 | Ok(response) if response.success => return Ok(()), 85 | Ok(response) => map_error(response, "run pipeline"), 86 | Err(e) => e, 87 | }; 88 | 89 | act.assist_sessions.remove(&msg_id); 90 | 91 | Err(error) 92 | }), 93 | ) 94 | } 95 | } 96 | 97 | impl Handler for HomeAssistantClient { 98 | type Result = ResponseActFuture>; 99 | 100 | fn handle(&mut self, msg: CallListAssistPipelines, ctx: &mut Self::Context) -> Self::Result { 101 | let msg_id = self.new_msg_id(); 102 | let (tx, rx) = oneshot::channel(); 103 | self.open_requests.insert(msg_id, OpenRequest::new(tx)); 104 | 105 | let assist_pipeline_req = 106 | AssistPipelineRequest::GetPipelines(AssistPipelineMsg::new(msg_id)); 107 | 108 | if let Err(e) = self.send_json( 109 | serde_json::to_value(assist_pipeline_req).expect("BUG serializing"), 110 | ctx, 111 | ) { 112 | self.assist_sessions.remove(&msg_id); 113 | self.open_requests.remove(&msg_id); 114 | return_fut_err!(e); 115 | }; 116 | 117 | Box::pin( 118 | async move { 119 | let request_timeout = Duration::from_secs(5); 120 | match tokio::time::timeout(request_timeout, rx).await { 121 | Ok(Ok(r)) => Ok(r), 122 | _ => Err(ServiceError::ServiceUnavailable( 123 | "Timeout while waiting for pipeline list result".into(), 124 | )), 125 | } 126 | } 127 | .into_actor(self) // converts future to ActorFuture 128 | .map(move |result, act, _ctx| { 129 | act.open_requests.remove(&msg_id); 130 | match result { 131 | Ok(response) if response.success => { 132 | if let Some(result) = 133 | response.msg.as_object().and_then(|map| map.get("result")) 134 | { 135 | let mut result: GetPipelinesResult = 136 | serde_json::from_value(result.clone())?; 137 | // filter out non-speech capable assist pipelines if required 138 | if msg.stt_required { 139 | result.pipelines.retain(|p| { 140 | p.stt_engine 141 | .as_ref() 142 | .map(|stt| !stt.is_empty()) 143 | .unwrap_or_default() 144 | }); 145 | // make sure the preferred pipeline is still valid 146 | if let Some(preferred) = result.preferred_pipeline.as_deref() 147 | && !result.pipelines.iter().any(|p| p.id == preferred) 148 | { 149 | warn!( 150 | "Preferred assist pipeline {preferred} not found, resetting" 151 | ); 152 | result.preferred_pipeline = None; 153 | } 154 | } 155 | Ok(result) 156 | } else { 157 | error!("Unexpected list assist pipelines response: {:?}", response); 158 | Err(ServiceError::InternalServerError( 159 | "Unexpected list assist pipelines response".into(), 160 | )) 161 | } 162 | } 163 | Ok(response) => Err(map_error(response, "list assist pipelines")), 164 | Err(e) => Err(e), 165 | } 166 | }), 167 | ) 168 | } 169 | } 170 | 171 | fn map_error(response: ResponseMsg, action: &str) -> ServiceError { 172 | if let Some(map) = response 173 | .msg 174 | .as_object() 175 | .and_then(|map| map.get("error")) 176 | .and_then(|v| v.as_object()) 177 | { 178 | let code = map.get("code").and_then(|v| v.as_str()); 179 | let msg = map.get("message").and_then(|v| v.as_str()); 180 | match code { 181 | Some("pipeline-not-found") => { 182 | return ServiceError::NotFound("Pipeline not found".into()); 183 | } 184 | Some(code) => { 185 | return ServiceError::ServiceUnavailable(format!( 186 | "Pipeline error {code}: {}", 187 | msg.unwrap_or_default() 188 | )); 189 | } 190 | _ => {} 191 | } 192 | } 193 | 194 | ServiceError::ServiceUnavailable(format!("Failed to {action}")) 195 | } 196 | -------------------------------------------------------------------------------- /src/controller/handler/ha_connection.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Actix message handler for Home Assistant client connection messages. 5 | 6 | use crate::client::HomeAssistantClient; 7 | use crate::client::messages::{ 8 | Close, ConnectionEvent, ConnectionState, SetRemoteId, SubscribedEntities, 9 | }; 10 | use crate::controller::OperationModeInput::{AbortSetup, Connected}; 11 | use crate::controller::handler::{ConnectMsg, DisconnectMsg}; 12 | use crate::controller::{Controller, OperationModeState}; 13 | use actix::{ActorFutureExt, AsyncContext, Context, Handler, ResponseActFuture, WrapFuture, fut}; 14 | use futures::StreamExt; 15 | use log::{debug, error, info, warn}; 16 | use std::io::{Error, ErrorKind}; 17 | use uc_api::intg::DeviceState; 18 | 19 | impl Handler for Controller { 20 | type Result = (); 21 | 22 | fn handle(&mut self, msg: ConnectionEvent, ctx: &mut Self::Context) -> Self::Result { 23 | // TODO #39 state machine with connection & reconnection states (as in remote-core). 24 | // This patched-up implementation might still contain race conditions! 25 | match msg.state { 26 | ConnectionState::AuthenticationFailed => { 27 | // error state prevents auto-reconnect in upcoming Closed event 28 | self.set_device_state(DeviceState::Error); 29 | } 30 | ConnectionState::Connected => { 31 | self.ha_client_id = Some(msg.client_id); 32 | self.set_device_state(DeviceState::Connected); 33 | } 34 | ConnectionState::Closed => { 35 | if Some(&msg.client_id) == self.ha_client_id.as_ref() { 36 | info!("[{}] HA client disconnected", msg.client_id); 37 | self.ha_client = None; 38 | self.ha_client_id = None; 39 | } else { 40 | info!("[{}] Old HA client disconnected: ignoring", msg.client_id); 41 | return; 42 | } 43 | 44 | if matches!( 45 | self.device_state, 46 | DeviceState::Connecting | DeviceState::Connected 47 | ) { 48 | info!("[{}] Start reconnecting to HA", msg.client_id); 49 | self.set_device_state(DeviceState::Connecting); 50 | 51 | self.reconnect_handle = 52 | Some(ctx.notify_later(ConnectMsg::default(), self.ha_reconnect_duration)); 53 | } 54 | } 55 | }; 56 | } 57 | } 58 | 59 | impl Handler for Controller { 60 | type Result = (); 61 | 62 | fn handle(&mut self, _msg: DisconnectMsg, ctx: &mut Self::Context) -> Self::Result { 63 | info!("Disconnect request: forcing immediate disconnect from HA server"); 64 | self.disconnect(ctx) 65 | } 66 | } 67 | 68 | impl Controller { 69 | pub(crate) fn disconnect(&mut self, ctx: &mut Context) { 70 | // this prevents automatic reconnects. TODO #39 this should be handled with a state machine! 71 | self.set_device_state(DeviceState::Disconnected); 72 | 73 | if let Some(handle) = self.reconnect_handle.take() { 74 | ctx.cancel_future(handle); 75 | } 76 | if let Some(addr) = self.ha_client.as_ref() { 77 | addr.do_send(Close::default()); 78 | } 79 | // Make sure the old connection is no longer used and doesn't interfere with reconnection 80 | self.ha_client = None; 81 | self.ha_client_id = None; 82 | } 83 | } 84 | 85 | impl Handler for Controller { 86 | type Result = ResponseActFuture>; 87 | 88 | fn handle(&mut self, _msg: ConnectMsg, ctx: &mut Self::Context) -> Self::Result { 89 | if let Some(handle) = self.reconnect_handle.take() { 90 | ctx.cancel_future(handle); 91 | } 92 | if !matches!( 93 | self.machine.state(), 94 | &OperationModeState::Running | &OperationModeState::RequireSetup 95 | ) { 96 | error!("Cannot connect in state: {:?}", self.machine.state()); 97 | return Box::pin(fut::result(Err(Error::new( 98 | ErrorKind::InvalidInput, 99 | "Not in running state", 100 | )))); 101 | } 102 | 103 | if let Some(client_id) = self.ha_client_id.as_ref() 104 | && self.ha_client.is_some() 105 | { 106 | warn!("[{client_id}] Ignoring connect request: already connected to HA server"); 107 | return Box::pin(fut::ok(())); 108 | } 109 | 110 | let url = self.settings.hass.get_url(); 111 | let token = self.settings.hass.get_token(); 112 | 113 | if url.host_str().is_none() || token.is_empty() { 114 | error!("Cannot connect: HA url or token missing"); 115 | let dummy_ws_id = "0"; // we don't have a WS request msg id 116 | if let Err(e) = self.sm_consume(dummy_ws_id, &AbortSetup, ctx) { 117 | error!("{e}"); 118 | } 119 | return Box::pin(fut::result(Err(Error::new( 120 | ErrorKind::InvalidInput, 121 | "Missing HA url or token", 122 | )))); 123 | } 124 | 125 | self.set_device_state(DeviceState::Connecting); 126 | 127 | let ws_request = self.ws_client.ws(url.as_str()); 128 | // align frame size to Home Assistant 129 | let ws_request = ws_request.max_frame_size(self.settings.hass.max_frame_size_kb * 1024); 130 | let client_address = ctx.address(); 131 | let heartbeat = self.settings.hass.heartbeat; 132 | let remote_id = self.remote_id.clone(); 133 | 134 | info!( 135 | "Connecting to: {url} (timeout: {}s, request_timeout: {}s)", 136 | self.settings.hass.connection_timeout, self.settings.hass.request_timeout 137 | ); 138 | Box::pin( 139 | async move { 140 | let (_, framed) = match ws_request.connect().await { 141 | Ok((r, f)) => (r, f), 142 | Err(e) => { 143 | warn!("Could not connect to {url}: {e:?}"); 144 | return Err(Error::other(e.to_string())); 145 | } 146 | }; 147 | info!("Connected to: {url} ({heartbeat})"); 148 | 149 | let (sink, stream) = framed.split(); 150 | let addr = 151 | HomeAssistantClient::start(url, client_address, token, sink, stream, heartbeat); 152 | 153 | Ok(addr) 154 | } 155 | .into_actor(self) // converts future to ActorFuture 156 | .map(move |result, act, ctx| { 157 | act.ha_client_id = None; // will be set with Connected event 158 | match result { 159 | Ok(addr) => { 160 | let dummy_ws_id = "0"; // we don't have a WS request msg id 161 | if let Err(e) = act.sm_consume(dummy_ws_id, &Connected, ctx) { 162 | error!("{e}"); 163 | } 164 | 165 | act.ha_client = Some(addr); 166 | act.ha_reconnect_duration = act.settings.hass.reconnect.duration; 167 | act.ha_reconnect_attempt = 0; 168 | debug!("Sending subscribed entities to client for events subscriptions"); 169 | if let Some(session) = act.sessions.values().next() { 170 | let entities = session.subscribed_entities.clone(); 171 | if let Some(ha_client) = &act.ha_client { 172 | if let Err(e) = ha_client.try_send(SetRemoteId { remote_id }) { 173 | error!("Error sending remote identifier to client: {:?}", e); 174 | } 175 | 176 | if let Err(e) = ha_client.try_send(SubscribedEntities { 177 | entity_ids: entities, 178 | }) { 179 | error!("Error updating subscribed entities to client: {:?}", e); 180 | } 181 | } 182 | } 183 | Ok(()) 184 | } 185 | Err(e) => { 186 | act.ha_client = None; 187 | // TODO #39 quick and dirty: simply send Connect message as simple reconnect mechanism. Needs to be refined! 188 | if act.device_state != DeviceState::Disconnected { 189 | act.ha_reconnect_attempt += 1; 190 | if act.settings.hass.reconnect.attempts > 0 191 | && act.ha_reconnect_attempt > act.settings.hass.reconnect.attempts 192 | { 193 | info!( 194 | "Max reconnect attempts reached ({}). Giving up!", 195 | act.settings.hass.reconnect.attempts 196 | ); 197 | act.set_device_state(DeviceState::Error); 198 | } else { 199 | act.reconnect_handle = Some(ctx.notify_later( 200 | ConnectMsg::default(), 201 | act.ha_reconnect_duration, 202 | )); 203 | act.increment_reconnect_timeout(); 204 | } 205 | } 206 | Err(e) 207 | } 208 | } 209 | }), 210 | ) 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/client/service/media_player.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Unfolded Circle ApS, Markus Zehnder 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //! Media player entity specific HA service call logic. 5 | 6 | use crate::client::{cmd_from_str, get_required_params}; 7 | use crate::errors::ServiceError; 8 | use serde_json::{Map, Value, json}; 9 | use uc_api::MediaPlayerCommand; 10 | use uc_api::intg::EntityCommand; 11 | 12 | pub fn handle_media_player(msg: &EntityCommand) -> Result<(String, Option), ServiceError> { 13 | let cmd: MediaPlayerCommand = cmd_from_str(&msg.cmd_id)?; 14 | 15 | let result = match cmd { 16 | MediaPlayerCommand::On => ("turn_on".into(), None), 17 | MediaPlayerCommand::Off => ("turn_off".into(), None), 18 | MediaPlayerCommand::Toggle => ("toggle".into(), None), 19 | MediaPlayerCommand::PlayPause => ("media_play_pause".into(), None), 20 | MediaPlayerCommand::Stop => ("media_stop".into(), None), 21 | MediaPlayerCommand::Previous => ("media_previous_track".into(), None), 22 | MediaPlayerCommand::Next => ("media_next_track".into(), None), 23 | MediaPlayerCommand::Seek => { 24 | let mut data = Map::new(); 25 | let params = get_required_params(msg)?; 26 | // TODO test and verify seeking! Docs says: platform dependent... 27 | if let Some(value) = params.get("media_position").and_then(|v| v.as_u64()) { 28 | data.insert("seek_position".into(), value.into()); 29 | } else { 30 | return Err(ServiceError::BadRequest( 31 | "Invalid or missing params.media_position attribute".into(), 32 | )); 33 | } 34 | ("media_seek".into(), Some(data.into())) 35 | } 36 | MediaPlayerCommand::Volume => { 37 | let mut data = Map::new(); 38 | let params = get_required_params(msg)?; 39 | if let Some(volume @ 0..=100) = params.get("volume").and_then(|v| v.as_u64()) { 40 | data.insert("volume_level".into(), (volume as f64 / 100_f64).into()); 41 | } else { 42 | return Err(ServiceError::BadRequest( 43 | "Invalid or missing params.volume attribute".into(), 44 | )); 45 | } 46 | ("volume_set".into(), Some(data.into())) 47 | } 48 | MediaPlayerCommand::VolumeUp => ("volume_up".into(), None), 49 | MediaPlayerCommand::VolumeDown => ("volume_down".into(), None), 50 | MediaPlayerCommand::FastForward 51 | | MediaPlayerCommand::Rewind 52 | | MediaPlayerCommand::MuteToggle => { 53 | return Err(ServiceError::BadRequest("Not supported".into())); 54 | } 55 | MediaPlayerCommand::Mute => ( 56 | "volume_mute".into(), 57 | Some(json!({ "is_volume_muted": true })), 58 | ), 59 | MediaPlayerCommand::Unmute => ( 60 | "volume_mute".into(), 61 | Some(json!({ "is_volume_muted": false })), 62 | ), 63 | MediaPlayerCommand::Repeat => { 64 | let mut data = Map::new(); 65 | let params = get_required_params(msg)?; 66 | if let Some(repeat) = params.get("repeat").and_then(|v| v.as_str()) { 67 | data.insert("repeat".into(), repeat.to_lowercase().into()); 68 | } else { 69 | return Err(ServiceError::BadRequest( 70 | "Invalid or missing params.repeat attribute".into(), 71 | )); 72 | } 73 | ("repeat_set".into(), Some(data.into())) 74 | } 75 | MediaPlayerCommand::Shuffle => { 76 | let mut data = Map::new(); 77 | let params = get_required_params(msg)?; 78 | if let Some(shuffle) = params.get("shuffle").and_then(|v| v.as_bool()) { 79 | data.insert("shuffle".into(), shuffle.into()); 80 | } else { 81 | return Err(ServiceError::BadRequest( 82 | "Invalid or missing params.shuffle attribute".into(), 83 | )); 84 | } 85 | ("shuffle_set".into(), Some(data.into())) 86 | } 87 | // TODO can we find out related HA entities and forward the command to these? Would we very convenient for the user! 88 | // E.g. the remote entity which usually comes with a media-player entity as for ATV or LG TV 89 | MediaPlayerCommand::ChannelUp 90 | | MediaPlayerCommand::ChannelDown 91 | | MediaPlayerCommand::CursorUp 92 | | MediaPlayerCommand::CursorDown 93 | | MediaPlayerCommand::CursorLeft 94 | | MediaPlayerCommand::CursorRight 95 | | MediaPlayerCommand::CursorEnter 96 | | MediaPlayerCommand::FunctionRed 97 | | MediaPlayerCommand::FunctionGreen 98 | | MediaPlayerCommand::FunctionYellow 99 | | MediaPlayerCommand::FunctionBlue 100 | | MediaPlayerCommand::Home 101 | | MediaPlayerCommand::Menu 102 | | MediaPlayerCommand::Back 103 | | MediaPlayerCommand::Digit_0 104 | | MediaPlayerCommand::Digit_1 105 | | MediaPlayerCommand::Digit_2 106 | | MediaPlayerCommand::Digit_3 107 | | MediaPlayerCommand::Digit_4 108 | | MediaPlayerCommand::Digit_5 109 | | MediaPlayerCommand::Digit_6 110 | | MediaPlayerCommand::Digit_7 111 | | MediaPlayerCommand::Digit_8 112 | | MediaPlayerCommand::Digit_9 113 | | MediaPlayerCommand::Guide 114 | | MediaPlayerCommand::Info 115 | | MediaPlayerCommand::Record 116 | | MediaPlayerCommand::MyRecordings 117 | | MediaPlayerCommand::Live 118 | | MediaPlayerCommand::Eject 119 | | MediaPlayerCommand::OpenClose 120 | | MediaPlayerCommand::AudioTrack 121 | | MediaPlayerCommand::Subtitle 122 | | MediaPlayerCommand::ContextMenu 123 | | MediaPlayerCommand::Settings => { 124 | return Err(ServiceError::BadRequest("Not supported".into())); 125 | } 126 | MediaPlayerCommand::SelectSource => { 127 | let mut data = Map::new(); 128 | let params = get_required_params(msg)?; 129 | if let Some(source) = params.get("source").and_then(|v| v.as_str()) { 130 | data.insert("source".into(), source.into()); 131 | } else { 132 | return Err(ServiceError::BadRequest( 133 | "Invalid or missing params.source attribute".into(), 134 | )); 135 | } 136 | ("select_source".into(), Some(data.into())) 137 | } 138 | MediaPlayerCommand::SelectSoundMode => { 139 | let mut data = Map::new(); 140 | let params = get_required_params(msg)?; 141 | if let Some(mode) = params.get("mode").and_then(|v| v.as_str()) { 142 | data.insert("sound_mode".into(), mode.into()); 143 | } else { 144 | return Err(ServiceError::BadRequest( 145 | "Invalid or missing params.mode attribute".into(), 146 | )); 147 | } 148 | ("select_sound_mode".into(), Some(data.into())) 149 | } 150 | }; 151 | 152 | Ok(result) 153 | } 154 | 155 | #[cfg(test)] 156 | mod tests { 157 | use crate::client::service::media_player::handle_media_player; 158 | use crate::errors::ServiceError; 159 | use rstest::rstest; 160 | use serde_json::{Map, Value, json}; 161 | use uc_api::EntityType; 162 | use uc_api::intg::EntityCommand; 163 | 164 | fn new_entity_command(cmd_id: impl Into, params: Value) -> EntityCommand { 165 | EntityCommand { 166 | device_id: None, 167 | entity_type: EntityType::MediaPlayer, 168 | entity_id: "test".into(), 169 | cmd_id: cmd_id.into(), 170 | params: if params.is_object() { 171 | Some(params.as_object().unwrap().clone()) 172 | } else { 173 | None 174 | }, 175 | } 176 | } 177 | 178 | #[rstest] 179 | #[case(json!(0), json!(0.0))] // TODO find a safer way to compare floats, this might blow any time 180 | #[case(json!(1), json!(0.01))] 181 | #[case(json!(50), json!(0.5))] 182 | #[case(json!(100), json!(1.0))] 183 | fn volume_cmd_returns_proper_request(#[case] volume: Value, #[case] output: Value) { 184 | let cmd = new_entity_command("volume", json!({ "volume": volume })); 185 | let result = handle_media_player(&cmd); 186 | 187 | assert!( 188 | result.is_ok(), 189 | "Valid value must return Ok, but got: {:?}", 190 | result.unwrap_err() 191 | ); 192 | let (cmd, param) = result.unwrap(); 193 | assert_eq!("volume_set", &cmd); 194 | assert!(param.is_some(), "Param object missing"); 195 | assert_eq!(Some(&output), param.unwrap().get("volume_level")); 196 | } 197 | 198 | #[rstest] 199 | #[case(json!(-1))] 200 | #[case(json!(0.0))] 201 | #[case(json!(50.0))] 202 | #[case(json!(101))] 203 | #[case(json!(200))] 204 | #[case(json!(true))] 205 | #[case(json!(false))] 206 | fn volume_cmd_with_invalid_volume_param_returns_bad_request(#[case] volume: Value) { 207 | let cmd = new_entity_command("volume", json!({ "volume": volume })); 208 | let result = handle_media_player(&cmd); 209 | 210 | assert!( 211 | matches!(result, Err(ServiceError::BadRequest(_))), 212 | "Invalid value must return BadRequest, but got: {:?}", 213 | result 214 | ); 215 | } 216 | 217 | #[rstest] 218 | #[case(Value::Null)] 219 | #[case(Value::Object(Map::new()))] 220 | fn volume_cmd_with_invalid_param_object_returns_bad_request(#[case] params: Value) { 221 | let cmd = new_entity_command("volume", params); 222 | let result = handle_media_player(&cmd); 223 | 224 | assert!( 225 | matches!(result, Err(ServiceError::BadRequest(_))), 226 | "Invalid value must return BadRequest, but got: {:?}", 227 | result 228 | ); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/util/color.rs: -------------------------------------------------------------------------------- 1 | //! Color util methods. 2 | //! 3 | //! Converted from https://github.com/home-assistant/core/blob/dev/homeassistant/util/color.py 4 | //! Apache-2.0 license 5 | 6 | use derive_more::Constructor; 7 | 8 | /// Represents a CIE 1931 XY coordinate pair. 9 | #[derive(Constructor, Clone, Copy)] 10 | pub struct XYPoint { 11 | pub x: f32, 12 | pub y: f32, 13 | } 14 | 15 | /// Represents the Gamut of a light. 16 | #[derive(Constructor, Clone, Copy)] 17 | pub struct GamutType { 18 | // ColorGamut = gamut(XYPoint::new(xR,yR),XYPoint::new(xG,yG),XYPoint::new(xB,yB)) 19 | pub red: XYPoint, 20 | pub green: XYPoint, 21 | pub blue: XYPoint, 22 | } 23 | 24 | /// Convert from XY to a normalized RGB. 25 | pub fn color_xy_to_rgb(x: f32, y: f32, gamut: Option) -> (u16, u16, u16) { 26 | color_xy_brightness_to_rgb(x, y, 255, gamut) 27 | } 28 | 29 | /// Convert from XYZ to RGB. 30 | // Converted to Rust from Python from Obj-C, original source from: 31 | // https://github.com/PhilipsHue/PhilipsHueSDK-iOS-OSX/blob/00187a3/ApplicationDesignNotes/RGB%20to%20xy%20Color%20conversion.md 32 | pub fn color_xy_brightness_to_rgb( 33 | mut v_x: f32, 34 | mut v_y: f32, 35 | ibrightness: u16, 36 | gamut: Option, 37 | ) -> (u16, u16, u16) { 38 | if let Some(gamut) = gamut 39 | && !check_point_in_lamps_reach((v_x, v_y), gamut) 40 | { 41 | let xy_closest = get_closest_point_to_point((v_x, v_y), gamut); 42 | v_x = xy_closest.0; 43 | v_y = xy_closest.1; 44 | } 45 | 46 | let brightness = ibrightness as f32 / 255.0; 47 | if brightness == 0.0 { 48 | return (0, 0, 0); 49 | } 50 | 51 | let y = brightness; 52 | 53 | if v_y == 0.0 { 54 | v_y += 0.00000000001; 55 | } 56 | 57 | let x = (y / v_y) * v_x; 58 | let z = (y / v_y) * (1_f32 - v_x - v_y); 59 | 60 | // Convert to RGB using Wide RGB D65 conversion. 61 | let mut r = x * 1.656492 - y * 0.354851 - z * 0.255038; 62 | let mut g = -x * 0.707196 + y * 1.655397 + z * 0.036152; 63 | let mut b = x * 0.051713 - y * 0.121364 + z * 1.01153; 64 | 65 | // Apply reverse gamma correction. 66 | fn reverse_gamma(x: f32) -> f32 { 67 | if x <= 0.0031308 { 68 | 12.92 * x 69 | } else { 70 | (1.0 + 0.055) * x.powf(1.0 / 2.4) - 0.055 71 | } 72 | } 73 | r = reverse_gamma(r); 74 | g = reverse_gamma(g); 75 | b = reverse_gamma(b); 76 | 77 | // Bring all negative components to zero. 78 | r = r.max(0.); 79 | g = g.max(0.); 80 | b = b.max(0.); 81 | 82 | // If one component is greater than 1, weight components by that value. 83 | let max_component = r.max(g).max(b); 84 | if max_component > 1_f32 { 85 | r /= max_component; 86 | g /= max_component; 87 | b /= max_component; 88 | } 89 | 90 | ((r * 255.) as u16, (g * 255.) as u16, (b * 255.) as u16) 91 | } 92 | 93 | /// Convert an rgb color to its hsv representation. 94 | /// 95 | /// - Hue is scaled 0-360 96 | /// - Sat is scaled 0-100 97 | /// - Val is scaled 0-100 98 | pub fn color_rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) { 99 | let (h, s, v) = rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0); 100 | (round(h * 360., 3), round(s * 100., 3), round(v * 100., 3)) 101 | } 102 | 103 | /// Convert an xy color to its hs representation. 104 | pub fn color_xy_to_hs(x: f32, y: f32, gamut: Option) -> (f32, f32) { 105 | let (r, g, b) = color_xy_to_rgb(x, y, gamut); 106 | let (h, s, _) = color_rgb_to_hsv(r as f32, g as f32, b as f32); 107 | (h, s) 108 | } 109 | 110 | // The following 5 functions are adapted from rgbxy provided by Benjamin Knight 111 | // License: The MIT License (MIT), 2014. 112 | // https://github.com/benknight/hue-python-rgb-converter 113 | 114 | /// Calculate the cross product of two XYPoints. 115 | fn cross_product(p1: XYPoint, p2: XYPoint) -> f32 { 116 | p1.x * p2.y - p1.y * p2.x 117 | } 118 | 119 | /// Calculate the distance between two XYPoints. 120 | fn get_distance_between_two_points(one: XYPoint, two: XYPoint) -> f32 { 121 | let dx = one.x - two.x; 122 | let dy = one.y - two.y; 123 | 124 | (dx * dx + dy * dy).sqrt() 125 | } 126 | 127 | /// Find the closest point from P to a line defined by A and B. 128 | /// 129 | /// This point will be reproducible by the lamp 130 | /// as it is on the edge of the gamut. 131 | fn get_closest_point_to_line(a: XYPoint, b: XYPoint, p: XYPoint) -> XYPoint { 132 | let ap = XYPoint::new(p.x - a.x, p.y - a.y); 133 | let ab = XYPoint::new(b.x - a.x, b.y - a.y); 134 | let ab2 = ab.x * ab.x + ab.y * ab.y; 135 | let ap_ab = ap.x * ab.x + ap.y * ab.y; 136 | let mut t = ap_ab / ab2; 137 | 138 | t = t.clamp(0.0, 1.0); 139 | 140 | XYPoint::new(a.x + ab.x * t, a.y + ab.y * t) 141 | } 142 | 143 | /// Get the closest matching color within the gamut of the light. 144 | /// 145 | /// Should only be used if the supplied color is outside of the color gamut. 146 | fn get_closest_point_to_point(xy_tuple: (f32, f32), gamut: GamutType) -> (f32, f32) { 147 | let xy_point = XYPoint::new(xy_tuple.0, xy_tuple.1); 148 | 149 | // find the closest point on each line in the CIE 1931 'triangle'. 150 | let p_ab = get_closest_point_to_line(gamut.red, gamut.green, xy_point); 151 | let p_ac = get_closest_point_to_line(gamut.blue, gamut.red, xy_point); 152 | let p_bc = get_closest_point_to_line(gamut.green, gamut.blue, xy_point); 153 | 154 | // Get the distances per point and see which point is closer to our Point. 155 | let d_ab = get_distance_between_two_points(xy_point, p_ab); 156 | let d_ac = get_distance_between_two_points(xy_point, p_ac); 157 | let d_bc = get_distance_between_two_points(xy_point, p_bc); 158 | 159 | let mut lowest = d_ab; 160 | let mut closest_point = p_ab; 161 | 162 | if d_ac < lowest { 163 | lowest = d_ac; 164 | closest_point = p_ac; 165 | } 166 | 167 | if d_bc < lowest { 168 | // lowest = dBC; 169 | closest_point = p_bc; 170 | } 171 | 172 | // Change the xy value to a value which is within the reach of the lamp. 173 | let cx = closest_point.x; 174 | let cy = closest_point.y; 175 | (cx, cy) 176 | } 177 | 178 | /// Check if the provided XYPoint can be recreated by a Hue lamp. 179 | fn check_point_in_lamps_reach(p: (f32, f32), gamut: GamutType) -> bool { 180 | let v1 = XYPoint::new(gamut.green.x - gamut.red.x, gamut.green.y - gamut.red.y); 181 | let v2 = XYPoint::new(gamut.blue.x - gamut.red.x, gamut.blue.y - gamut.red.y); 182 | 183 | let q = XYPoint::new(p.0 - gamut.red.x, p.1 - gamut.red.y); 184 | let s = cross_product(q, v2) / cross_product(v1, v2); 185 | let t = cross_product(v1, q) / cross_product(v1, v2); 186 | 187 | (s >= 0.0) && (t >= 0.0) && (s + t <= 1.0) 188 | } 189 | 190 | // HSV: Hue, Saturation, Value 191 | // H: position in the spectrum 192 | // S: color saturation ("purity") 193 | // V: color brightness 194 | // From: https://github.com/python/cpython/blob/3.12/Lib/colorsys.py 195 | 196 | pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) { 197 | let maxc = r.max(g).max(b); 198 | let minc = r.min(g).min(b); 199 | let rangec = maxc - minc; 200 | let v = maxc; 201 | 202 | if minc == maxc { 203 | return (0.0, 0.0, v); 204 | } 205 | let s = rangec / maxc; 206 | let rc = (maxc - r) / rangec; 207 | let gc = (maxc - g) / rangec; 208 | let bc = (maxc - b) / rangec; 209 | let mut h = if r == maxc { 210 | bc - gc 211 | } else if g == maxc { 212 | 2.0 + rc - bc 213 | } else { 214 | 4.0 + gc - rc 215 | }; 216 | 217 | // Modulo operation: in Python the remainder will take the sign of the divisor, in Rust it will take the sign of the dividend 218 | // h = (h / 6.0) % 1.0; 219 | h = (h / 6.0).rem_euclid(1.0); 220 | (h, s, v) 221 | } 222 | 223 | fn round(x: f32, decimals: u32) -> f32 { 224 | let y = 10i32.pow(decimals) as f32; 225 | (x * y).round() / y 226 | } 227 | 228 | #[cfg(test)] 229 | mod tests { 230 | use super::*; 231 | use lazy_static::lazy_static; 232 | use rstest::rstest; 233 | 234 | lazy_static! { 235 | static ref GAMUT: GamutType = GamutType::new( 236 | XYPoint::new(0.704, 0.296), 237 | XYPoint::new(0.2151, 0.7106), 238 | XYPoint::new(0.138, 0.08), 239 | ); 240 | } 241 | 242 | #[rstest] 243 | #[case((0, 0, 0), 1., 1., 0, None)] 244 | #[case((194, 186, 169), 0.35, 0.35, 128, None)] 245 | #[case((255, 243, 222), 0.35, 0.35, 255, None)] 246 | #[case((255, 0, 60), 1., 0., 255, None)] 247 | #[case((0, 255, 0), 0., 1., 255, None)] 248 | #[case((0, 63, 255), 0., 0., 255, None)] 249 | #[case((255, 0, 3), 1., 0., 255, Some(*GAMUT))] 250 | #[case((82, 255, 0), 0., 1., 255, Some(*GAMUT))] 251 | #[case((9, 85, 255), 0., 0., 255, Some(*GAMUT))] 252 | fn test_color_xy_brightness_to_rgb( 253 | #[case] expected: (u16, u16, u16), 254 | #[case] x: f32, 255 | #[case] y: f32, 256 | #[case] brightness: u16, 257 | #[case] gamut: Option, 258 | ) { 259 | assert_eq!( 260 | expected, 261 | color_xy_brightness_to_rgb(x, y, brightness, gamut) 262 | ); 263 | } 264 | 265 | #[rstest] 266 | #[case((255, 243, 222), 0.35, 0.35, None)] 267 | #[case((255, 0, 60), 1., 0., None)] 268 | #[case((0, 255, 0), 0., 1., None)] 269 | #[case((0, 63, 255), 0., 0., None)] 270 | #[case((255, 0, 3), 1., 0., Some(*GAMUT))] 271 | #[case((82, 255, 0), 0., 1., Some(*GAMUT))] 272 | #[case((9, 85, 255), 0., 0., Some(*GAMUT))] 273 | fn test_color_xy_to_rgb( 274 | #[case] expected: (u16, u16, u16), 275 | #[case] x: f32, 276 | #[case] y: f32, 277 | #[case] gamut: Option, 278 | ) { 279 | assert_eq!(expected, color_xy_to_rgb(x, y, gamut)); 280 | } 281 | 282 | #[rstest] 283 | #[case((0., 0., 0.), 0., 0., 0.)] 284 | #[case((0., 0., 100.), 255., 255., 255.)] 285 | #[case((240., 100., 100.), 0., 0., 255.)] 286 | #[case((120., 100., 100.), 0., 255., 0.)] 287 | #[case((0., 100., 100.), 255., 0., 0.)] 288 | fn test_color_rgb_to_hsv( 289 | #[case] expected: (f32, f32, f32), 290 | #[case] r: f32, 291 | #[case] g: f32, 292 | #[case] b: f32, 293 | ) { 294 | assert_eq!(expected, color_rgb_to_hsv(r, g, b)); 295 | } 296 | 297 | #[rstest] 298 | #[case((47.294, 100.), 1., 1., None)] 299 | #[case((38.182, 12.941), 0.35, 0.35, None)] 300 | #[case((345.882, 100.), 1., 0., None)] 301 | #[case((120., 100.), 0., 1., None)] 302 | #[case((225.176, 100.), 0., 0., None)] 303 | #[case((359.294, 100.), 1., 0., Some(*GAMUT))] 304 | #[case((100.706, 100.), 0., 1., Some(*GAMUT))] 305 | #[case((221.463, 96.471), 0., 0., Some(*GAMUT))] 306 | fn test_color_xy_to_hs( 307 | #[case] expected: (f32, f32), 308 | #[case] x: f32, 309 | #[case] y: f32, 310 | #[case] gamut: Option, 311 | ) { 312 | assert_eq!(expected, color_xy_to_hs(x, y, gamut)); 313 | } 314 | } 315 | --------------------------------------------------------------------------------