From 0d9939d965d76e50b3d64cccb903fd22aef52d04 Mon Sep 17 00:00:00 2001 From: Claas Date: Tue, 4 Nov 2025 22:46:25 +0100 Subject: [PATCH] Refactor to make testable --- Cargo.lock | 94 +++++++-- Cargo.toml | 1 + fan-controller/Cargo.toml | 2 +- fan-controller/build.rs | 112 +---------- home_assistant_discovery/Cargo.toml | 12 ++ home_assistant_discovery/src/lib.rs | 188 ++++++++++++++++++ ...very__tests__serialize_custom_example.snap | 43 ++++ 7 files changed, 326 insertions(+), 126 deletions(-) create mode 100644 home_assistant_discovery/Cargo.toml create mode 100644 home_assistant_discovery/src/lib.rs create mode 100644 home_assistant_discovery/src/snapshots/home_assistant_discovery__tests__serialize_custom_example.snap diff --git a/Cargo.lock b/Cargo.lock index 3d3c756..505f435 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -338,6 +338,18 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1035,6 +1047,12 @@ dependencies = [ "log", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.1" @@ -1093,6 +1111,7 @@ dependencies = [ "embedded-io-async", "embedded-nal-async", "heapless 0.8.0", + "home_assistant_discovery", "mqtt", "nb 1.1.0", "panic-probe", @@ -1101,7 +1120,6 @@ dependencies = [ "reqwless", "ruff_python_ast 0.0.0 (git+https://github.com/astral-sh/ruff)", "ruff_python_parser", - "serde", "serde_json", "static_cell", "thiserror 2.0.12", @@ -1435,6 +1453,16 @@ dependencies = [ "digest", ] +[[package]] +name = "home_assistant_discovery" +version = "0.1.0" +dependencies = [ + "insta", + "mqtt", + "serde", + "topic", +] + [[package]] name = "httparse" version = "1.9.4" @@ -1466,6 +1494,18 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "insta" +version = "1.43.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fdb647ebde000f43b5b53f773c30cf9b0cb4300453208713fa38b2c70935a0" +dependencies = [ + "console", + "once_cell", + "serde", + "similar", +] + [[package]] name = "interpolator" version = "0.5.0" @@ -1502,7 +1542,7 @@ checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" dependencies = [ "hermit-abi", "libc", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -1686,7 +1726,7 @@ dependencies = [ "hermit-abi", "libc", "wasi", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -1785,9 +1825,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "opaque-debug" @@ -2436,18 +2476,28 @@ checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -2456,14 +2506,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] @@ -2506,6 +2557,12 @@ dependencies = [ "libc", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "siphasher" version = "0.3.11" @@ -2545,7 +2602,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -2742,7 +2799,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -2895,7 +2952,7 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" dependencies = [ - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -2913,6 +2970,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-targets" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 9407448..c15a882 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "debug-listener", "exponential_distribution", "fan-controller", + "home_assistant_discovery", "mqtt", "topic", ] diff --git a/fan-controller/Cargo.toml b/fan-controller/Cargo.toml index 7850d05..8f836c6 100644 --- a/fan-controller/Cargo.toml +++ b/fan-controller/Cargo.toml @@ -57,7 +57,7 @@ dotenvy = "0.15.7" mqtt = { version = "0.1.0", path = "../mqtt", features = ["serde"] } ruff_python_ast = { git = "https://github.com/astral-sh/ruff", version = "0.0.0" } ruff_python_parser = { git = "https://github.com/astral-sh/ruff", rev = "9bee8376a17401f9736b45fdefffb62edc2f1668", version = "0.0.0" } -serde = { version = "1.0.219", features = ["derive", "rc"] } serde_json = "1.0.140" thiserror = "2.0.12" topic = { version = "0.1.0", path = "../topic" } +home_assistant_discovery = { version = "0.1.0", path = "../home_assistant_discovery" } diff --git a/fan-controller/build.rs b/fan-controller/build.rs index 266b346..c0a6122 100644 --- a/fan-controller/build.rs +++ b/fan-controller/build.rs @@ -19,119 +19,9 @@ use std::process::Command; use std::rc::Rc; use std::string::FromUtf8Error; +use home_assistant_discovery::{Component, Device, DiscoveryPayload, ListOrString, Origin}; use mqtt::QualityOfService; use ruff_python_ast::{DictItem, Expr, Stmt}; -use serde::Serialize; - -#[derive(Serialize, Clone, Debug)] -#[serde(untagged)] -enum ListOrString { - List(Vec), - String(&'static str), -} - -/// Information about the device this fan is a part of to tie it into the device registry. Only works when unique_id is set. At least one of identifiers or connections must be present to identify the device. -#[derive(Serialize, Default)] -struct Device { - /// A list of IDs that uniquely identify the device. For example a serial number. - #[serde(rename = "ids")] - #[serde(skip_serializing_if = "Option::is_none")] - identifiers: Option, - /// The name of the device. - #[serde(skip_serializing_if = "Option::is_none")] - name: Option<&'static str>, - /// The model of the device. - #[serde(rename = "mdl")] - #[serde(skip_serializing_if = "Option::is_none")] - model: Option<&'static str>, - /// The manufacturer of the device. - #[serde(rename = "mf")] - #[serde(skip_serializing_if = "Option::is_none")] - manufacturer: Option<&'static str>, - /// The hardware version of the device. - #[serde(rename = "hw")] - #[serde(skip_serializing_if = "Option::is_none")] - hardware_version: Option<&'static str>, - /// The firmware version of the device. - #[serde(rename = "sw")] - #[serde(skip_serializing_if = "Option::is_none")] - software_version: Option>, -} - -#[derive(Serialize, Default)] -struct Origin { - /// The name of the application that is the origin of the discovered MQTT item. (Required) - name: &'static str, - /// Software version of the application that supplies the discovered MQTT item. - #[serde(rename = "sw")] - #[serde(skip_serializing_if = "Option::is_none")] - software_version: Option>, - /// Support URL of the application that supplies the discovered MQTT item. - #[serde(rename = "url")] - #[serde(skip_serializing_if = "Option::is_none")] - support_url: Option<&'static str>, -} - -/// Internally tagged by the required `platform` (`p`) field -#[derive(Serialize)] -#[serde(tag = "p")] -enum Component { - Fan { - /// The name of the fan. Can be set to null if only the device name is relevant. - name: Option<&'static str>, - /// An ID that uniquely identifies this fan. If two fans have the same unique ID, Home Assistant will raise an exception. Required when used with device-based discovery. - #[serde(rename = "uniq_id")] - #[serde(skip_serializing_if = "Option::is_none")] - unique_id: Option<&'static str>, - /// The MQTT topic subscribed to receive state updates. A “None” payload resets to an unknown state. An empty payload is ignored. By default, valid state payloads are OFF and ON. The accepted payloads can be overridden with the payload_off and payload_on config options. - #[serde(rename = "stat_t")] - #[serde(skip_serializing_if = "Option::is_none")] - state_topic: Option<&'static str>, - - /// The MQTT topic to publish commands to change the fan state. - #[serde(rename = "cmd_t")] - command_topic: &'static str, - /// The MQTT topic subscribed to receive fan speed based on percentage. - #[serde(rename = "pct_stat_t")] - percentage_state_topic: Option<&'static str>, - /// The MQTT topic to publish commands to change the fan speed state based on a percentage. - #[serde(rename = "pct_cmd_t")] - percentage_command_topic: Option<&'static str>, - /// The maximum of numeric output range (representing 100 %). The percentage_step is defined by 100 / the number of speeds within the speed range. - /// Default: 100 - #[serde(rename = "spd_rng_max")] - speed_range_max: Option, - }, -} - -/// Home Assistant MQTT device-based Discovery Payload -/// This is for the multi [device discovery payload](https://www.home-assistant.io/integrations/mqtt/#device-discovery-payload). -/// It requires -/// - device -/// - origin -#[derive(Serialize, Default)] -struct DiscoveryPayload { - #[serde(rename = "dev")] - device: Device, - #[serde(rename = "o")] - origin: Origin, - #[serde(rename = "cmps")] - components: HashMap, - #[serde(rename = "qos")] - #[serde(skip_serializing_if = "Option::is_none")] - quality_of_service: Option, - #[serde(rename = "stat_t")] - #[serde(skip_serializing_if = "Option::is_none")] - state_topic: Option<&'static str>, - #[serde(rename = "cmd_t")] - #[serde(skip_serializing_if = "Option::is_none")] - command_topic: Option<&'static str>, - /// The encoding of the payloads received and published messages. Set to "" to disable decoding of incoming payload. - /// Default is "utf-8" - #[serde(rename = "e")] - #[serde(skip_serializing_if = "Option::is_none")] - encoding: Option, -} #[derive(Debug, thiserror::Error)] enum GitHashError { diff --git a/home_assistant_discovery/Cargo.toml b/home_assistant_discovery/Cargo.toml new file mode 100644 index 0000000..57a08c3 --- /dev/null +++ b/home_assistant_discovery/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "home_assistant_discovery" +version = "0.1.0" +edition = "2024" + +[dependencies] +mqtt = { version = "0.1.0", path = "../mqtt", features = ["serde"] } +serde = { version = "1.0.219", features = ["derive", "rc"] } + +[dev-dependencies] +insta = { version = "1.43.2", features = ["json"] } +topic = { version = "0.1.0", path = "../topic" } diff --git a/home_assistant_discovery/src/lib.rs b/home_assistant_discovery/src/lib.rs new file mode 100644 index 0000000..a68f562 --- /dev/null +++ b/home_assistant_discovery/src/lib.rs @@ -0,0 +1,188 @@ +//! # Home Assistant Discovery Library +//! Tools and utilities for creating the discovery payload for MQTT Home Assistant devices during build time for embedded systems. + +use std::{collections::HashMap, rc::Rc}; + +use mqtt::QualityOfService; +use serde::Serialize; + +/// Utility type for when the Home Assistant type definition allows either a list or a string. +#[derive(Serialize, Clone, Debug)] +#[serde(untagged)] +pub enum ListOrString { + List(Vec), + String(&'static str), +} + +/// Information about the device this fan is a part of to tie it into the device registry. Only works when unique_id is set. At least one of identifiers or connections must be present to identify the device. +#[derive(Serialize, Default)] +pub struct Device { + /// A list of IDs that uniquely identify the device. For example a serial number. + #[serde(rename = "ids")] + #[serde(skip_serializing_if = "Option::is_none")] + pub identifiers: Option, + /// The name of the device. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option<&'static str>, + /// The model of the device. + #[serde(rename = "mdl")] + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option<&'static str>, + /// The manufacturer of the device. + #[serde(rename = "mf")] + #[serde(skip_serializing_if = "Option::is_none")] + pub manufacturer: Option<&'static str>, + /// The hardware version of the device. + #[serde(rename = "hw")] + #[serde(skip_serializing_if = "Option::is_none")] + pub hardware_version: Option<&'static str>, + /// The firmware version of the device. + #[serde(rename = "sw")] + #[serde(skip_serializing_if = "Option::is_none")] + pub software_version: Option>, +} + +#[derive(Serialize, Default)] +pub struct Origin { + /// The name of the application that is the origin of the discovered MQTT item. (Required) + pub name: &'static str, + /// Software version of the application that supplies the discovered MQTT item. + #[serde(rename = "sw")] + #[serde(skip_serializing_if = "Option::is_none")] + pub software_version: Option>, + /// Support URL of the application that supplies the discovered MQTT item. + #[serde(rename = "url")] + #[serde(skip_serializing_if = "Option::is_none")] + pub support_url: Option<&'static str>, +} + +/// Internally tagged by the required `platform` (`p`) field +#[derive(Serialize)] +#[serde(tag = "p")] +pub enum Component { + Fan { + /// The name of the fan. Can be set to null if only the device name is relevant. + name: Option<&'static str>, + /// An ID that uniquely identifies this fan. If two fans have the same unique ID, Home Assistant will raise an exception. Required when used with device-based discovery. + #[serde(rename = "uniq_id")] + #[serde(skip_serializing_if = "Option::is_none")] + unique_id: Option<&'static str>, + /// The MQTT topic subscribed to receive state updates. A “None” payload resets to an unknown state. An empty payload is ignored. By default, valid state payloads are OFF and ON. The accepted payloads can be overridden with the payload_off and payload_on config options. + #[serde(rename = "stat_t")] + #[serde(skip_serializing_if = "Option::is_none")] + state_topic: Option<&'static str>, + + /// The MQTT topic to publish commands to change the fan state. + #[serde(rename = "cmd_t")] + command_topic: &'static str, + /// The MQTT topic subscribed to receive fan speed based on percentage. + #[serde(rename = "pct_stat_t")] + percentage_state_topic: Option<&'static str>, + /// The MQTT topic to publish commands to change the fan speed state based on a percentage. + #[serde(rename = "pct_cmd_t")] + percentage_command_topic: Option<&'static str>, + /// The maximum of numeric output range (representing 100 %). The percentage_step is defined by 100 / the number of speeds within the speed range. + /// Default: 100 + #[serde(rename = "spd_rng_max")] + speed_range_max: Option, + }, +} + +/// Home Assistant MQTT device-based Discovery Payload +/// This is for the multi [device discovery payload](https://www.home-assistant.io/integrations/mqtt/#device-discovery-payload). +/// It requires +/// - device +/// - origin +#[derive(Serialize, Default)] +pub struct DiscoveryPayload { + #[serde(rename = "dev")] + pub device: Device, + #[serde(rename = "o")] + pub origin: Origin, + #[serde(rename = "cmps")] + pub components: HashMap, + #[serde(rename = "qos")] + #[serde(skip_serializing_if = "Option::is_none")] + pub quality_of_service: Option, + #[serde(rename = "stat_t")] + #[serde(skip_serializing_if = "Option::is_none")] + pub state_topic: Option<&'static str>, + #[serde(rename = "cmd_t")] + #[serde(skip_serializing_if = "Option::is_none")] + pub command_topic: Option<&'static str>, + /// The encoding of the payloads received and published messages. Set to "" to disable decoding of incoming payload. + /// Default is "utf-8" + #[serde(rename = "e")] + #[serde(skip_serializing_if = "Option::is_none")] + pub encoding: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serialize_home_assistant_example() {} + + #[test] + fn serialize_custom_example() { + let version = Some(Rc::from("test-version")); + let payload = DiscoveryPayload { + device: Device { + identifiers: Some(ListOrString::String("fancontroller-device")), + name: Some("Fan Controller"), + model: Some("Raspberry Pi Pico W 1"), + manufacturer: Some("claas.dev"), + hardware_version: Some("1.0"), + software_version: version.clone(), + }, + origin: Origin { + name: "fan-controller", + software_version: version, + support_url: Some("https://github.com/SantaClaas/embedded-fan-control"), + }, + components: HashMap::from([ + // Fan 1 + ( + "fan-1".to_string(), + Component::Fan { + name: Some("Fan 1"), + unique_id: Some("fancontroller/fan-1"), + state_topic: Some(topic::fan_controller::fan_1::STATE), + command_topic: topic::fan_controller::fan_1::COMMAND, + percentage_state_topic: Some( + topic::fan_controller::fan_1::percentage::STATE, + ), + percentage_command_topic: Some( + topic::fan_controller::fan_1::percentage::COMMAND, + ), + speed_range_max: Some(32_000), + }, + ), + // Fan 2 + ( + "fan-2".to_string(), + Component::Fan { + name: Some("Fan 2"), + unique_id: Some("fancontroller/fan-2"), + state_topic: Some(topic::fan_controller::fan_2::STATE), + command_topic: topic::fan_controller::fan_2::COMMAND, + percentage_state_topic: Some( + topic::fan_controller::fan_2::percentage::STATE, + ), + percentage_command_topic: Some( + topic::fan_controller::fan_2::percentage::COMMAND, + ), + speed_range_max: Some(32_000), + }, + ), + ]), + quality_of_service: None, + state_topic: Some(topic::fan_controller::STATE), + command_topic: Some(topic::fan_controller::COMMAND), + encoding: None, + }; + + insta::assert_json_snapshot!(payload) + } +} diff --git a/home_assistant_discovery/src/snapshots/home_assistant_discovery__tests__serialize_custom_example.snap b/home_assistant_discovery/src/snapshots/home_assistant_discovery__tests__serialize_custom_example.snap new file mode 100644 index 0000000..d8b64da --- /dev/null +++ b/home_assistant_discovery/src/snapshots/home_assistant_discovery__tests__serialize_custom_example.snap @@ -0,0 +1,43 @@ +--- +source: home_assistant_discovery/src/lib.rs +expression: payload +--- +{ + "dev": { + "ids": "fancontroller-device", + "name": "Fan Controller", + "mdl": "Raspberry Pi Pico W 1", + "mf": "claas.dev", + "hw": "1.0", + "sw": "test-version" + }, + "o": { + "name": "fan-controller", + "sw": "test-version", + "url": "https://github.com/SantaClaas/embedded-fan-control" + }, + "cmps": { + "fan-1": { + "p": "Fan", + "name": "Fan 1", + "uniq_id": "fancontroller/fan-1", + "stat_t": "fancontroller/fan-1/on/state", + "cmd_t": "fancontroller/fan-1/on/set", + "pct_stat_t": "fancontroller/fan-2/speed/percentage_state", + "pct_cmd_t": "fancontroller/fan-2/speed/percentage", + "spd_rng_max": 32000 + }, + "fan-2": { + "p": "Fan", + "name": "Fan 2", + "uniq_id": "fancontroller/fan-2", + "stat_t": "fancontroller/fan-2/on/state", + "cmd_t": "fancontroller/fan-2/on/set", + "pct_stat_t": "fancontroller/fan-2/speed/percentage_state", + "pct_cmd_t": "fancontroller/fan-2/speed/percentage", + "spd_rng_max": 32000 + } + }, + "stat_t": "fancontroller/on/state", + "cmd_t": "fancontroller/on/set" +} -- 2.51.2