diff --git a/CLAUDE.md b/CLAUDE.md index 044d641..5f7ef9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,7 @@ Testing reality below. | `mqtt` | `no_std` | Protocol-level MQTT types shared between firmware and build script. Feature-gated `defmt` / `serde` so the same types work on device and on host. | | `topic` | `no_std` | The single source of truth for Home Assistant MQTT topic strings, composed at compile time with `const_format`. Used by both the firmware and `build.rs`. | | `set_point` | `no_std` | The `SetPoint` newtype and its bounds, parsing and formatting. Its own crate purely so it can be tested on the host; re-exported by the firmware as `crate::fan::set_point`. Feature-gated `defmt`. | +| `fan_sensors` | `no_std` | Decoding what a fan reports about itself — actual speed, both temperatures, power, energy — from its input registers, plus the JSON payload Home Assistant reads. Owns the register addresses and the layout of the two runs that are read. Its own crate for the same reason as `set_point`; re-exported as `crate::fan::sensors`. Feature-gated `defmt`. | | `home_assistant_discovery` | host | Serde model of the Home Assistant MQTT discovery payload. Build-dependency only. `components` is a `BTreeMap` so the generated payload is byte-stable across builds. | | `debug-listener` | host | Reads the RS-485/Modbus line off a USB serial adapter to inspect fan traffic. The port path is hardcoded in `src/main.rs`. | @@ -102,6 +103,15 @@ Flow of a speed change: drives `LED_STATE` and publishes state back to MQTT via `OUT`. 5. `led_routine` renders `LedState`; an in-flight animation is cancelled when the state changes. +Independently of that flow, `sensor_routine` (pool of 2, one per fan address) polls what the fans +measure about themselves every `SENSOR_POLL_INTERVAL` and publishes it through `OUT`. It shares the +Modbus mutex with `fan_control_routine` and yields to it: a failed poll is logged and dropped +rather than retried, because the next poll carries fresher values than a retry would and a silent +fan would otherwise hold the mutex through a run of timeouts. The fan's maximum speed +(`D119`, a holding register) is read once and cached, because every speed the fan reports is a +fraction of it; until it is known the reading reports the speed as `null` rather than withholding +the other four values. + The `Publish` trait (`task.rs`) plus `TryEncode`/`TryDecode` (`mqtt/mod.rs`) let outgoing messages be encoded straight into the TCP buffer without intermediate allocation — there is no allocator. @@ -114,6 +124,14 @@ be encoded straight into the TCP buffer without intermediate allocation — ther `speed_range_max: 32_000` to match. - Fan Modbus addresses start at `0x02`/`0x03`; `0x01` is avoided as a likely factory default. - UART is 19_200 baud, 8 data bits, **even** parity, 1 stop bit. +- Sensor values live in *input* registers (function code `0x04`), which are read only, unlike the + holding registers (`0x03` / `0x06`) the set point lives in. `ReadInputRegisters` asks for a + range rather than one register, because a range costs the same round trip; the fan refuses more + than 37 registers or an answer over 80 bytes. +- The Home Assistant discovery payload is encoded into a fixed `mqtt::task::SEND_BUFFER_SIZE` + buffer. `main.rs` asserts at compile time that it still fits, because the encoder refuses an + oversized packet and only logs it — which would leave a device that runs fine and is never + discovered. ## Testing reality @@ -133,6 +151,10 @@ cd set_point && cargo test cd home_assistant_discovery && cargo test ``` +```bash +cd fan_sensors && cargo test +``` + That is also the way to make firmware logic testable at all: move it into its own `no_std` crate and re-export it, the way `fan/mod.rs` re-exports `set_point`. Worth doing for anything with rules of its own; not worth it for code that only exists to drive a peripheral. diff --git a/Cargo.lock b/Cargo.lock index 3dee55c..adf5033 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1018,6 +1018,7 @@ dependencies = [ "embassy-time", "embedded-io-async", "embedded-nal-async", + "fan_sensors", "heapless 0.8.0", "home_assistant_discovery", "mqtt", @@ -1033,6 +1034,15 @@ dependencies = [ "topic", ] +[[package]] +name = "fan_sensors" +version = "0.1.0" +dependencies = [ + "defmt 1.0.1", + "heapless 0.8.0", + "set_point", +] + [[package]] name = "ff" version = "0.13.0" diff --git a/Cargo.toml b/Cargo.toml index 99b12fc..74a09e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "debug-listener", "fan-controller", + "fan_sensors", "home_assistant_discovery", "mqtt", "set_point", diff --git a/fan-controller/Cargo.toml b/fan-controller/Cargo.toml index 71dca6f..cc9c076 100644 --- a/fan-controller/Cargo.toml +++ b/fan-controller/Cargo.toml @@ -39,6 +39,7 @@ embassy-sync = { git = "https://github.com/embassy-rs/embassy.git", package = "e embassy-time = { version = "0.3.1", features = ["defmt"] } embedded-io-async = "0.6.1" embedded-nal-async = "0.7.1" +fan_sensors = { version = "0.1.0", path = "../fan_sensors", features = ["defmt"] } heapless = "0.8.0" mqtt = { version = "0.1.0", path = "../mqtt", features = ["defmt"] } nb = "1.1.0" diff --git a/fan-controller/TODO.md b/fan-controller/TODO.md index fceddda..a95fced 100644 --- a/fan-controller/TODO.md +++ b/fan-controller/TODO.md @@ -8,12 +8,12 @@ treat them as a starting point rather than an exact address. The first section is a suggested order of work with the reasoning; the sections after it are the full inventory grouped by area, so nothing gets lost. -Everything in the priority section is done: the four ranked items and the cheap win. They are kept -here rather than deleted because each one records what was actually wrong, what was decided, and -what has never run on hardware — the write-ups are the closest thing this firmware has to a -changelog with reasons. Nothing that follows is ranked; pick from the inventory. The one thing -worth doing before anything else is flashing the device and watching the log, because all four of -the ranked items are untested on hardware. +Everything in the priority section is done: the four ranked items, the cheap win, and the sensor +polling that followed them. They are kept here rather than deleted because each one records what was +actually wrong, what was decided, and what has never run on hardware — the write-ups are the closest +thing this firmware has to a changelog with reasons. Nothing that follows is ranked; pick from the +inventory. The one thing worth doing before anything else is flashing the device and watching the +log, because none of them have run on hardware. --- @@ -186,6 +186,57 @@ situation later and without saying why it happened. Untested on hardware, and hard to reach on purpose: it needs both fans to fail after the retries, which means pulling the bus rather than anything Home Assistant can ask for. +### Done since — sensor polling + +**Poll what the fans measure about themselves** — done, `fan_sensors/`, `src/modbus/`, `src/main.rs` + +This is the "Read temperature sensors" item from `README.md`, done wider than it was written: the +fans report an actual speed, a motor temperature, an electronics temperature, a current power draw +and an energy counter, and all five now reach Home Assistant. + +They live in *input* registers rather than holding registers, so function code `0x04` had to be +implemented. `ReadInputRegisters` asks for a range rather than a single register — the values +worth polling sit next to each other and a range costs the same round trip — and carries the count +in the type so the request and the array it is answered with cannot disagree. `COUNT` is checked at +compile time against the fan's limit of 37 registers, which it otherwise reports as exception `0x03` +saying only that the answer would be the wrong length. + +`sensor_routine` polls each fan every 30 s, after a 10 s startup delay that leaves the bus to the +set point both `fan_control_routine`s read on boot. Two reads under one lock, so the five values +describe the same moment. Nothing on the device acts on them, so a failed poll is logged and +dropped rather than retried: the next poll carries fresher values than a retry would, and a fan +that has stopped answering does not hold the Modbus mutex through a run of timeouts while a speed +change waits behind it. + +Decoding lives in the `fan_sensors` crate, following the `set_point` pattern, so the rules it has — +a speed that is a fraction of the fan's configured maximum, temperatures that are signed, an energy +counter spanning two registers — are tested on the host. The fan's maximum speed (`D119`) is read +once and cached; until it is known the reading reports the speed as `null`, which Home Assistant +shows as unknown, rather than holding back the four values that do not depend on it. + +Two things had to be fixed to make it work at all, both of which were already wrong: + +- The discovery payload went from 2 components to 12 and from roughly 1.3 kB to 3.8 kB, and `send` + encoded every packet into a 1024 byte buffer. An oversized packet is refused by the encoder and + only logged, so the device would have run perfectly and never been discovered. The buffer is now + `mqtt::task::SEND_BUFFER_SIZE`, and `main.rs` asserts at compile time that the payload still fits, + because it grows every time a component is added. +- `send` used `write`, whose return value says how many bytes were actually taken and was discarded. + Anything past the room left in the socket's send buffer was dropped without a word. It now uses + `write_all`. This was survivable while every packet was short and is not for the discovery + payload, which is several times that buffer. + +Untested on hardware. Worth watching on the first flash: whether the fans answer `0x04` at all, +what they report for a fan that is off, whether `D119` reads back the maximum speed these fans are +actually configured for, and whether the energy counter is non-zero — it counts from the factory, so +a zero would suggest the wrong register. + +Still open in the same area: the temperature/humidity sensor inputs (`D02E`-`D031`) and the PT1000 +inputs (`D038`/`D039`) are not read, because they only report anything if sensors are physically +wired to the fans. The motor status (`D011`) and warning (`D012`) bitfields are read as part of the +status run and thrown away; decoding them would give Home Assistant a real diagnostic instead of +inference from a temperature. + ### Cheap win worth slotting in anywhere **Make `SetPoint` host-testable** — done, `set_point/` @@ -260,8 +311,8 @@ the rest are protocol conformance polish against a broker you control. |---|---| | `src/modbus/client.rs:329` | Understand why the flush must be blocking to avoid `WouldBlock` | -Response validation and the short-read hazard are done; see P0 item 1, and reading holding -registers is done; see P1 item 3. +Response validation and the short-read hazard are done; see P0 item 1, reading holding registers is +done; see P1 item 3, and reading input registers is done; see the sensor polling item. ### Configuration — `src/configuration.rs` @@ -285,7 +336,6 @@ Not already covered above: - When retrying fails after a while, reset the other fan to avoid under- or overpressure in the house - Confirm the fan speed is set in Home Assistant and retry otherwise; MQTT QoS can implement this - Make the button press pick up state changed through Home Assistant instead of keeping its own state -- Read temperature sensors - Switch to only using the refactored `send` for Modbus - Try bundling all channels into an event-bus / actor-model shape - Aspirational: a web interface for configuring the fan when Wi-Fi is not set up yet diff --git a/fan-controller/build.rs b/fan-controller/build.rs index b2ab3ca..d5dbd92 100644 --- a/fan-controller/build.rs +++ b/fan-controller/build.rs @@ -16,7 +16,9 @@ use std::process::Command; use std::rc::Rc; use std::str::Utf8Error; -use home_assistant_discovery::{Component, Device, DiscoveryPayload, ListOrString, Origin}; +use home_assistant_discovery::{ + Component, Device, DeviceClass, DiscoveryPayload, ListOrString, Origin, StateClass, +}; #[derive(Debug, thiserror::Error)] enum GitHashError { @@ -109,27 +111,100 @@ fn get_git_hash() -> Result, GitHashError> { Ok(Rc::from(str::from_utf8(&output.stdout)?.trim())) } -fn set_discovery_payload(git_hash: &str) { - let package_version = env!("CARGO_PKG_VERSION"); - // Following semantic versioning build metadata - let version: Option> = Option::from(Rc::from(format!("{package_version}+{git_hash}"))); - println!("Setting version to {version:?}"); - let payload = DiscoveryPayload { - device: Device { - identifiers: Some(ListOrString::String(topic::fan_controller::OBJECT_ID)), - name: Some("New Fan Controller"), - model: Some("Raspberry Pi Pico W 1"), - manufacturer: Some("claas.dev"), - hardware_version: Some("1.0"), - software_version: version.clone(), - ..Default::default() - }, - origin: Origin { - name: "fan-controller", - software_version: version, - support_url: Some("https://github.com/SantaClaas/embedded-fan-control"), - }, - components: BTreeMap::from([ +/// Which identifiers one fan's five sensors are announced under. All of them are composed from +/// that fan's identifier in the `topic` crate, so the two fans differ only in what is passed here +struct SensorIdentifiers { + /// The topic all five values arrive on, as one JSON object + state: &'static str, + speed: &'static str, + motor_temperature: &'static str, + electronics_temperature: &'static str, + power: &'static str, + energy: &'static str, +} + +/// The five values a fan reports about itself. +/// +/// Every one of them reads the same topic and picks its value out of the JSON object published +/// there, so a poll costs one publish rather than five. The keys the templates use are the field +/// names `fan_sensors::Reading` serializes, which is the one place they have to agree. +/// +/// Built per fan rather than written out twice, because only the identifiers and the name differ +fn fan_sensor_components( + fan_name: &str, + identifiers: SensorIdentifiers, +) -> [(String, Component); 5] { + [ + ( + identifiers.speed.to_string(), + Component::Sensor { + name: Some(format!("{fan_name} speed")), + state_topic: Some(identifiers.state), + // Home Assistant has no device class for a rotation rate, so the unit carries it + device_class: None, + state_class: Some(StateClass::Measurement), + unit_of_measurement: Some("rpm"), + value_template: Some("{{ value_json.speed }}"), + unique_id: Some(identifiers.speed), + }, + ), + ( + identifiers.motor_temperature.to_string(), + Component::Sensor { + name: Some(format!("{fan_name} motor temperature")), + state_topic: Some(identifiers.state), + device_class: Some(DeviceClass::Temperature), + state_class: Some(StateClass::Measurement), + unit_of_measurement: Some("°C"), + value_template: Some("{{ value_json.motor_temperature }}"), + unique_id: Some(identifiers.motor_temperature), + }, + ), + ( + identifiers.electronics_temperature.to_string(), + Component::Sensor { + name: Some(format!("{fan_name} electronics temperature")), + state_topic: Some(identifiers.state), + device_class: Some(DeviceClass::Temperature), + state_class: Some(StateClass::Measurement), + unit_of_measurement: Some("°C"), + value_template: Some("{{ value_json.electronics_temperature }}"), + unique_id: Some(identifiers.electronics_temperature), + }, + ), + ( + identifiers.power.to_string(), + Component::Sensor { + name: Some(format!("{fan_name} power")), + state_topic: Some(identifiers.state), + device_class: Some(DeviceClass::Power), + state_class: Some(StateClass::Measurement), + unit_of_measurement: Some("W"), + value_template: Some("{{ value_json.power }}"), + unique_id: Some(identifiers.power), + }, + ), + ( + identifiers.energy.to_string(), + Component::Sensor { + name: Some(format!("{fan_name} energy")), + state_topic: Some(identifiers.state), + device_class: Some(DeviceClass::Energy), + // Counts up since the fan left the factory, which is what puts it in the energy + // dashboard instead of only in a graph + state_class: Some(StateClass::TotalIncreasing), + unit_of_measurement: Some("kWh"), + value_template: Some("{{ value_json.energy }}"), + unique_id: Some(identifiers.energy), + }, + ), + ] +} + +/// Everything the fan controller announces to Home Assistant: the two fans, and the five sensors +/// each of them reports +fn components() -> BTreeMap { + let mut components = BTreeMap::from([ // Fan 1 ( topic::fan_controller::fan_1::UNIQUE_ID.to_string(), @@ -160,7 +235,58 @@ fn set_discovery_payload(git_hash: &str) { speed_range_max: Some(32_000), }, ), - ]), + ]); + + components.extend(fan_sensor_components( + "Fan 1", + SensorIdentifiers { + state: topic::fan_controller::fan_1::sensors::STATE, + speed: topic::fan_controller::fan_1::sensors::SPEED, + motor_temperature: topic::fan_controller::fan_1::sensors::MOTOR_TEMPERATURE, + electronics_temperature: + topic::fan_controller::fan_1::sensors::ELECTRONICS_TEMPERATURE, + power: topic::fan_controller::fan_1::sensors::POWER, + energy: topic::fan_controller::fan_1::sensors::ENERGY, + }, + )); + + components.extend(fan_sensor_components( + "Fan 2", + SensorIdentifiers { + state: topic::fan_controller::fan_2::sensors::STATE, + speed: topic::fan_controller::fan_2::sensors::SPEED, + motor_temperature: topic::fan_controller::fan_2::sensors::MOTOR_TEMPERATURE, + electronics_temperature: + topic::fan_controller::fan_2::sensors::ELECTRONICS_TEMPERATURE, + power: topic::fan_controller::fan_2::sensors::POWER, + energy: topic::fan_controller::fan_2::sensors::ENERGY, + }, + )); + + components +} + +fn set_discovery_payload(git_hash: &str) { + let package_version = env!("CARGO_PKG_VERSION"); + // Following semantic versioning build metadata + let version: Option> = Option::from(Rc::from(format!("{package_version}+{git_hash}"))); + println!("Setting version to {version:?}"); + let payload = DiscoveryPayload { + device: Device { + identifiers: Some(ListOrString::String(topic::fan_controller::OBJECT_ID)), + name: Some("New Fan Controller"), + model: Some("Raspberry Pi Pico W 1"), + manufacturer: Some("claas.dev"), + hardware_version: Some("1.0"), + software_version: version.clone(), + ..Default::default() + }, + origin: Origin { + name: "fan-controller", + software_version: version, + support_url: Some("https://github.com/SantaClaas/embedded-fan-control"), + }, + components: components(), quality_of_service: None, state_topic: Some(topic::fan_controller::STATE), command_topic: Some(topic::fan_controller::COMMAND), diff --git a/fan-controller/documentation.md b/fan-controller/documentation.md index e194ac0..fe2db48 100644 --- a/fan-controller/documentation.md +++ b/fan-controller/documentation.md @@ -48,6 +48,21 @@ After successfully joining the network it tries to look up Homeassistant under t Homeassistant needs to have the MQTT broker installed as the controller uses MQTT to connect to homeassitant and send data between them. After successful connection to the MQTT broker, the controller sends a discovery packet as defined by Homeassistant and the device should appear in Homeassistant on the dashboard when using the default Homeassistant configuration. +### 3. Sensors + +Alongside the two fans the device announces five sensors per fan: speed in rpm, motor temperature, +electronics temperature, power draw in watts, and an energy counter in kWh. The fans are polled +every 30 seconds, starting 10 seconds after boot so the initial fan speed read has the bus to +itself. + +The energy counter counts from when the fan left the factory and never resets, which is what lets +Homeassistant put it in the energy dashboard rather than only in a graph. + +Speed shows as unknown until the controller has read the fan's configured maximum speed, which +every speed the fan reports is a fraction of. It retries that read on each poll, so a fan that was +unreachable at boot fills in on its own. The other four values do not depend on it and appear +right away. + ### Wiring (TODO) This section is planned to describe how the fan controller is wired up and how all the parts are connected to each other. diff --git a/fan-controller/src/fan/mod.rs b/fan-controller/src/fan/mod.rs index 637ef88..15ae16d 100644 --- a/fan-controller/src/fan/mod.rs +++ b/fan-controller/src/fan/mod.rs @@ -5,6 +5,10 @@ /// fan is. See the crate documentation for why it cannot live in this one pub(crate) use ::set_point; +/// Decoding what the fan reports about itself, in its own crate for the same reason as +/// [`set_point`] and re-exported here for the same one +pub(crate) use ::fan_sensors as sensors; + use embassy_rp::uart::{self, DataBits, Parity, StopBits}; pub(crate) const BAUD_RATE: u32 = 19_200; @@ -56,8 +60,30 @@ pub(super) mod holding_registers { pub(crate) const REFERENCE_SET_POINT: modbus::register::Address = modbus::register::Address::new(0xd001_u16); + + /// The speed the fan is configured for, which every speed it reports and accepts is a fraction + /// of. Only changes when the fan is reconfigured, so it is read once rather than on every poll + pub(crate) const MAXIMUM_SPEED: modbus::register::Address = + modbus::register::Address::new(super::sensors::MAXIMUM_SPEED_REGISTER); +} + +/// Where the fan reports what it measures about itself. Read only, and read as two runs rather +/// than register by register because a range costs the same round trip as one register. +/// The addresses and the layout of each run belong to the [`sensors`] crate, which is what decodes +/// them; these only wrap them in the address type the modbus client asks for +pub(super) mod input_registers { + use crate::modbus; + + /// The run holding the actual speed and both temperatures + pub(crate) const STATUS: modbus::register::Address = + modbus::register::Address::new(super::sensors::STATUS_START); + + /// The run holding the current power draw and the energy counter + pub(crate) const ENERGY: modbus::register::Address = + modbus::register::Address::new(super::sensors::ENERGY_START); } +#[derive(Clone, Copy)] pub(crate) enum Fan { One, Two, diff --git a/fan-controller/src/main.rs b/fan-controller/src/main.rs index 2e792eb..046cf48 100644 --- a/fan-controller/src/main.rs +++ b/fan-controller/src/main.rs @@ -516,6 +516,26 @@ impl From for UpdateSpeedPayload { } } +/// The Home Assistant discovery payload, generated by `build.rs` from the `topic` constants and +/// baked into the binary. Published verbatim on boot +const DISCOVERY_PAYLOAD: &[u8] = env!("FAN_CONTROLLER_DISCOVERY_PAYLOAD").as_bytes(); + +/// What a publish packet adds around its payload: the fixed header, the remaining length as a +/// variable byte integer at its longest, the topic name and the two bytes of its length, and the +/// property length +const PUBLISH_OVERHEAD: usize = 1 + 4 + 2 + topic::fan_controller::DISCOVERY.len() + 1; + +/// The discovery payload is several times the size of anything else the controller publishes, and +/// it is encoded into a buffer of a fixed size before it goes out. A payload that does not fit is +/// refused by the encoder and logged, which would leave a device that runs perfectly well and is +/// never discovered by Home Assistant. Failing the build instead is the cheaper way to find out, +/// because the payload grows every time a component is added +// `core::assert` because `defmt::*` is glob imported and its `assert` is not const +const _: () = core::assert!( + DISCOVERY_PAYLOAD.len() + PUBLISH_OVERHEAD <= mqtt::task::SEND_BUFFER_SIZE, + "the Home Assistant discovery payload no longer fits the MQTT send buffer" +); + enum OutgoingPublish { Discovery, UpdateSpeed { @@ -526,6 +546,13 @@ enum OutgoingPublish { fan: Fan, payload: SetStateCommandValue, }, + /// All five values a fan reports about itself, as the one JSON object every one of its sensors + /// reads. Owned rather than borrowed because it is built during a poll and outlives it in the + /// channel + UpdateSensors { + fan: Fan, + payload: heapless::String<{ fan::sensors::JSON_CAPACITY }>, + }, } impl Publish for OutgoingPublish { @@ -548,12 +575,16 @@ impl Publish for OutgoingPublish { fan: Fan::Two, payload: _, } => topic::fan_controller::fan_2::state::STATE, + OutgoingPublish::UpdateSensors { fan: Fan::One, .. } => { + topic::fan_controller::fan_1::sensors::STATE + } + OutgoingPublish::UpdateSensors { fan: Fan::Two, .. } => { + topic::fan_controller::fan_2::sensors::STATE + } } } fn payload(&self) -> &[u8] { - const DISCOVERY_PAYLOAD: &[u8] = env!("FAN_CONTROLLER_DISCOVERY_PAYLOAD").as_bytes(); - match self { OutgoingPublish::Discovery => DISCOVERY_PAYLOAD, OutgoingPublish::UpdateSpeed { fan: _, payload } => { @@ -564,6 +595,7 @@ impl Publish for OutgoingPublish { SetStateCommandValue::On => b"ON", SetStateCommandValue::Off => b"OFF", }, + OutgoingPublish::UpdateSensors { fan: _, payload } => payload.as_bytes(), } } } @@ -923,6 +955,123 @@ async fn fan_control_routine( } } +/// How often each fan is asked what it is measuring. One poll is two modbus transactions, which +/// at 19_200 baud is roughly 60 ms of bus time per fan, so this is about half a percent of it. +/// Slow enough that a speed change never waits long behind a poll, quick enough that a fan warming +/// up is visible in Home Assistant while it happens +const SENSOR_POLL_INTERVAL: Duration = Duration::from_secs(30); + +/// How long to leave the bus alone before the first poll, so the set point both fan control +/// routines read on boot — with retries, and a timeout each if a fan is silent — is done first. +/// The fans are the reason the controller exists; what they report about themselves can wait +const SENSOR_POLL_STARTUP_DELAY: Duration = Duration::from_secs(10); + +/// Polls one fan for what it reports about itself and publishes it to Home Assistant. +/// +/// Nothing on the device acts on these values, so a failed poll is logged and dropped rather than +/// retried: the next poll is along in [`SENSOR_POLL_INTERVAL`] and carries fresher values than a +/// retry would. That also keeps a fan that has stopped answering from holding the modbus mutex +/// through a run of timeouts while a speed change waits behind it +#[embassy_executor::task(pool_size = 2)] +async fn sensor_routine( + fan: Fan, + fan_address: modbus::device::Address, + modbus: &'static ModbusOnceLock, + mqtt_out: channel::Sender<'static, CriticalSectionRawMutex, OutgoingPublish, CHANNEL_SIZE>, +) { + let fan_identifier = match fan { + Fan::One => "[Fan 1 sensors]", + Fan::Two => "[Fan 2 sensors]", + }; + + let modbus_mutex = modbus.get().await; + Timer::after(SENSOR_POLL_STARTUP_DELAY).await; + + // Every speed the fan reports is a fraction of this, so without it a speed cannot be turned + // into a rate at all. It only changes when the fan is reconfigured, so it is read once and + // then kept, and retried on the next poll for as long as it is not known + let mut maximum_speed: Option = None; + + loop { + if maximum_speed.is_none() { + let function = modbus::function::ReadHoldingRegister::new( + fan_address, + fan::holding_registers::MAXIMUM_SPEED, + ); + + match modbus_mutex + .lock() + .await + .read_holding_register(&function) + .await + { + Ok(value) => { + info!("{} Fan's maximum speed is {} rpm", fan_identifier, value); + maximum_speed = Some(value); + } + // Not fatal: the other four values do not depend on it, and the reading reports + // the speed as unknown until it can be read + Err(error) => warn!( + "{} Failed to read the fan's maximum speed: {:?}", + fan_identifier, error + ), + } + } + + let status_request = modbus::function::ReadInputRegisters::< + { fan::sensors::STATUS_LENGTH }, + >::new(fan_address, fan::input_registers::STATUS); + let energy_request = modbus::function::ReadInputRegisters::< + { fan::sensors::ENERGY_LENGTH }, + >::new(fan_address, fan::input_registers::ENERGY); + + // Both runs are read under one lock so the five values describe the same moment. It costs + // a speed change at most the two transactions rather than one, which is still well under a + // tenth of a second + let mut client = modbus_mutex.lock().await; + let reading = match client.read_input_registers(&status_request).await { + Ok(status) => match client.read_input_registers(&energy_request).await { + Ok(energy) => Some(fan::sensors::decode(&status, &energy, maximum_speed)), + Err(error) => { + warn!( + "{} Failed to read the energy registers: {:?}", + fan_identifier, error + ); + None + } + }, + Err(error) => { + warn!( + "{} Failed to read the status registers: {:?}", + fan_identifier, error + ); + None + } + }; + drop(client); + + if let Some(reading) = reading { + info!("{} Read {:?}", fan_identifier, reading); + + let publish = OutgoingPublish::UpdateSensors { + fan, + payload: reading.to_json(), + }; + + // Dropped rather than waited on, like the other display updates: only the latest + // reading is worth anything, and the next one is along in SENSOR_POLL_INTERVAL + if let Err(channel::TrySendError::Full(_publish)) = mqtt_out.try_send(publish) { + error!( + "{} MQTT out channel is full, dropping this reading", + fan_identifier + ); + } + } + + Timer::after(SENSOR_POLL_INTERVAL).await; + } +} + #[derive(Debug, Clone, Copy)] enum Blink { Off, @@ -1111,9 +1260,10 @@ async fn main(spawner: Spawner) { /// Transmit buffer for UART static TX_BUFFER: StaticCell<[u8; 16]> = StaticCell::new(); let tx_buffer = &mut TX_BUFFER.init([0; 16])[..]; - /// Receive buffer for UART - static RX_BUFFER: StaticCell<[u8; 16]> = StaticCell::new(); - let rx_buffer = &mut RX_BUFFER.init([0; 16])[..]; + /// Receive buffer for UART. Large enough for the longest response the fan sends, which is the + /// run of input registers a sensor poll reads rather than the eight byte echo of a write + static RX_BUFFER: StaticCell<[u8; 64]> = StaticCell::new(); + let rx_buffer = &mut RX_BUFFER.init([0; 64])[..]; let client = modbus::client::Client::new( uart0, @@ -1230,4 +1380,17 @@ async fn main(spawner: Spawner) { &FANS, display_fan_two_sender, ))); + + unwrap!(spawner.spawn(sensor_routine( + Fan::One, + fan::address::FAN_1, + &FANS, + OUT.sender(), + ))); + unwrap!(spawner.spawn(sensor_routine( + Fan::Two, + fan::address::FAN_2, + &FANS, + OUT.sender(), + ))); } diff --git a/fan-controller/src/modbus/client.rs b/fan-controller/src/modbus/client.rs index b4d125c..26043e7 100644 --- a/fan-controller/src/modbus/client.rs +++ b/fan-controller/src/modbus/client.rs @@ -10,7 +10,9 @@ use embedded_io_async::{Read, ReadExactError, Write}; use crate::{ configuration, - modbus::function::{ReadHoldingRegister, WriteHoldingRegister, code}, + modbus::function::{ + ReadHoldingRegister, ReadInputRegisters, WriteHoldingRegister, code, read_input_registers, + }, }; /// How sending a request to the fan failed @@ -158,8 +160,8 @@ pub(crate) enum ReadError { Contents(ReceiveFailure), /// The checksum of the answer does not match its contents ContentsChecksum, - /// The answer announced a different number of data bytes than the one register that was asked - /// for. Holds the byte count it announced + /// The answer announced a different number of data bytes than the registers that were asked + /// for take up. Holds the byte count it announced ByteCount(u8), } @@ -187,6 +189,16 @@ const READ_RESPONSE_LENGTH: usize = 7; /// How many data bytes a read of the single register asked for has to announce const READ_BYTE_COUNT: u8 = 2; +/// What a read response carries around its data bytes: the header, the byte count, and the +/// checksum +const READ_OVERHEAD_LENGTH: usize = HEADER_LENGTH + 1 + 2; + +/// The longest response a read of input registers can produce, which is the buffer every one of +/// them is read into. An array cannot be sized from a const generic on stable, so the buffer is +/// sized for the longest run the fan will answer and only the part that was asked for is used +const MAX_INPUT_REGISTERS_RESPONSE_LENGTH: usize = + READ_OVERHEAD_LENGTH + 2 * read_input_registers::MAX_COUNT; + /// An exception response replaces the register and value of the echo with a single exception code const EXCEPTION_RESPONSE_LENGTH: usize = 5; @@ -249,6 +261,22 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { result } + /// Reads a run of the fan's input registers, which is where it reports what it measures + /// about itself. Read only, unlike the holding registers the set point lives in + pub(crate) async fn read_input_registers( + &mut self, + message: &ReadInputRegisters, + ) -> Result<[u16; COUNT], ReadError> { + let fan_identifier = fan_identifier(*message.device_address()); + + let result = self + .transact_read_input_registers(message, fan_identifier) + .await; + self.clear_line_after(&result, fan_identifier).await; + + result + } + /// Reads back what a fan currently holds in one of its registers pub(crate) async fn read_holding_register( &mut self, @@ -381,6 +409,66 @@ impl<'a, UART: uart::Instance, PIN: Pin> Client<'a, UART, PIN> { Ok(value) } + async fn transact_read_input_registers( + &mut self, + message: &ReadInputRegisters, + fan_identifier: &str, + ) -> Result<[u16; COUNT], ReadError> { + let request = message.as_ref(); + self.send_request(request, fan_identifier).await?; + + // Like the holding register read the answer carries a byte count and the contents rather + // than repeating the request, so its length is known from the count that was asked for. + // Only the part of the buffer this request can fill is read into, so the rest of a frame + // is never left on the line for the next transaction to pick up + let mut buffer = [0u8; MAX_INPUT_REGISTERS_RESPONSE_LENGTH]; + let response = &mut buffer[..READ_OVERHEAD_LENGTH + 2 * COUNT]; + + if let Answer::Exception(code) = self.read_header(response, request, fan_identifier).await? + { + return Err(ReadError::Exception(code.into())); + } + + self.receive_exact(&mut response[HEADER_LENGTH..]) + .await + .map_err(ReadError::Contents)?; + + // Checked before the checksum for the same reason as in the single register read: a + // different byte count means a different frame length was just read, so a failing checksum + // would report something other than what actually went wrong. + // The cast cannot truncate because `MAX_COUNT` registers are 74 data bytes + let expected_byte_count = (2 * COUNT) as u8; + if response[2] != expected_byte_count { + warn!( + "{} Response announced {:?} data bytes instead of {:?}: {:?}", + fan_identifier, response[2], expected_byte_count, response + ); + return Err(ReadError::ByteCount(response[2])); + } + + if !is_checksum_valid(response) { + warn!( + "{} Response failed checksum: {:?}", + fan_identifier, response + ); + return Err(ReadError::ContentsChecksum); + } + + let mut registers = [0u16; COUNT]; + for (index, register) in registers.iter_mut().enumerate() { + // The data bytes start after the header and the byte count + let offset = HEADER_LENGTH + 1 + 2 * index; + *register = u16::from_be_bytes([response[offset], response[offset + 1]]); + } + + info!( + "{} Fan answered the read with {:?}: {:?}", + fan_identifier, registers, response + ); + + Ok(registers) + } + /// Drives the line, writes the request, and hands the line back to the fan async fn send_request( &mut self, diff --git a/fan-controller/src/modbus/function/code.rs b/fan-controller/src/modbus/function/code.rs index 6ff4c8b..981f8d9 100644 --- a/fan-controller/src/modbus/function/code.rs +++ b/fan-controller/src/modbus/function/code.rs @@ -1,5 +1,7 @@ pub const READ_HOLDING_REGISTERS: u8 = 0x03; +pub const READ_INPUT_REGISTERS: u8 = 0x04; + pub const WRITE_SINGLE_REGISTER: u8 = 0x06; /// A device reports an error by responding with the function code of the request and this bit set diff --git a/fan-controller/src/modbus/function/mod.rs b/fan-controller/src/modbus/function/mod.rs index 23eadf8..e31d48c 100644 --- a/fan-controller/src/modbus/function/mod.rs +++ b/fan-controller/src/modbus/function/mod.rs @@ -1,6 +1,8 @@ pub(super) mod code; pub(crate) mod read_holding_register; +pub(crate) mod read_input_registers; pub(crate) mod write_holding_register; pub(crate) use read_holding_register::ReadHoldingRegister; +pub(crate) use read_input_registers::ReadInputRegisters; pub(crate) use write_holding_register::WriteHoldingRegister; diff --git a/fan-controller/src/modbus/function/read_input_registers.rs b/fan-controller/src/modbus/function/read_input_registers.rs new file mode 100644 index 0000000..36db036 --- /dev/null +++ b/fan-controller/src/modbus/function/read_input_registers.rs @@ -0,0 +1,64 @@ +use crate::modbus; + +/// The most registers one request may ask for. The fan answers with at most 80 bytes and refuses +/// anything longer with exception `0x03`. +/// See MODBUS Parameter RadiCal im Spiralgehäuse V1.00, section 1.3.2 +pub(crate) const MAX_COUNT: usize = 37; + +/// Reads a run of input registers, which hold the values the fan measures about itself and which +/// cannot be written. Unlike [`super::ReadHoldingRegister`] this asks for a range rather than a +/// single register, because the values worth polling sit next to each other and a range costs the +/// same round trip as one register would. +/// +/// `COUNT` is part of the type so the request and the array it is answered with cannot disagree +/// about how many registers are in flight. +/// See MODBUS Parameter RadiCal im Spiralgehäuse V1.00, section 1.3.2 +pub(crate) struct ReadInputRegisters([u8; 8]); + +impl ReadInputRegisters { + pub(crate) fn new( + device_address: modbus::device::Address, + start_address: modbus::register::Address, + ) -> Self { + // The fan reports both of these as exception `0x03`, which says only that the answer would + // be the wrong length. Catching them here names the actual mistake, at compile time + const { + assert!(COUNT >= 1, "a request for zero registers is refused by the fan"); + assert!( + COUNT <= MAX_COUNT, + "more registers than fit in the fan's 80 byte answer" + ); + } + + let start_address = start_address.to_be_bytes(); + let count = (COUNT as u16).to_be_bytes(); + let mut data = [ + *device_address, + modbus::function::code::READ_INPUT_REGISTERS, + start_address[0], + start_address[1], + count[0], + count[1], + // CRC set in next step + 0, + 0, + ]; + + let checksum = modbus::CRC.checksum(&data[..6]).to_be_bytes(); + + // They come out reversed (or is us using to_be_bytes reversed?) + data[6] = checksum[1]; + data[7] = checksum[0]; + Self(data) + } + + pub(crate) fn device_address(&self) -> modbus::device::Address { + self.0[0].into() + } +} + +impl AsRef<[u8]> for ReadInputRegisters { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} diff --git a/fan-controller/src/mqtt/task.rs b/fan-controller/src/mqtt/task.rs index 24e8c4b..46f8989 100644 --- a/fan-controller/src/mqtt/task.rs +++ b/fan-controller/src/mqtt/task.rs @@ -18,6 +18,11 @@ pub(crate) enum SendError { Flush(E), } +/// How much room a packet is encoded into before it goes out. Sized for the largest one the +/// controller sends by far, the Home Assistant discovery payload, which `main` asserts against at +/// compile time so this cannot fall behind it unnoticed +pub(crate) const SEND_BUFFER_SIZE: usize = 4096; + pub(crate) async fn send, TWriteError>( socket: &mut TWrite, packet: T, @@ -27,13 +32,16 @@ where { info!("Sending packet"); let mut offset = 0; - let mut send_buffer = [0; 1024]; + let mut send_buffer = [0; SEND_BUFFER_SIZE]; packet .try_encode(&mut send_buffer, &mut offset) .map_err(SendError::Encode)?; + // `write` returns how many bytes it took, which is capped by the room left in the socket's own + // send buffer, and the rest would be dropped without a word. That is survivable for a short + // state update and not for the discovery payload, which is several times that buffer socket - .write(&send_buffer[..offset]) + .write_all(&send_buffer[..offset]) .await .map_err(SendError::Write)?; socket.flush().await.map_err(SendError::Flush)?; diff --git a/fan_sensors/Cargo.toml b/fan_sensors/Cargo.toml new file mode 100644 index 0000000..676d4ec --- /dev/null +++ b/fan_sensors/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fan_sensors" +version = "0.1.0" +edition = "2024" + +[dependencies] +defmt = { workspace = true, optional = true } +heapless = "0.8.0" +set_point = { version = "0.1.0", path = "../set_point" } + +[features] +defmt = ["dep:defmt"] diff --git a/fan_sensors/src/lib.rs b/fan_sensors/src/lib.rs new file mode 100644 index 0000000..c945067 --- /dev/null +++ b/fan_sensors/src/lib.rs @@ -0,0 +1,259 @@ +//! What an ebm-papst RadiCal fan reports about itself: how fast it is actually turning, how warm +//! it is, and what it is costing to run. +//! +//! The fan keeps these in input registers, which are read only. Their raw contents are not the +//! quantities they describe — a speed is relative to the maximum the fan is configured for, and +//! energy spans two registers — so decoding them has rules of its own. That is why this is its own +//! crate: `fan-controller` only builds for `thumbv6m-none-eabi`, which has no test harness, so +//! anything left in there is compiled by nothing and rots unnoticed. See the `set_point` crate, +//! which is here for the same reason. +//! +//! All register addresses and codings are from MODBUS Parameter RadiCal im Spiralgehäuse V1.00, +//! chapter 3. + +#![no_std] + +use core::fmt::Write; + +/// The fan's configured maximum speed, which every speed it reports is relative to. A holding +/// register rather than an input register, and the only value here that has to be read separately. +/// See section 2.25 +pub const MAXIMUM_SPEED_REGISTER: u16 = 0xD119; + +/// Where the run of input registers holding the speed and the two temperatures starts, and how +/// many registers it spans. Modbus reads a range, so asking for `D010` through `D017` in one +/// request costs the same round trip as asking for any one of them. See section 3.1 +pub const STATUS_START: u16 = 0xD010; +pub const STATUS_LENGTH: usize = 8; + +/// Where the run holding the current power and the energy counter starts, and how many registers +/// it spans. A second request rather than one larger one, because everything between `D017` and +/// `D027` is either reserved or of no interest here +pub const ENERGY_START: u16 = 0xD027; +pub const ENERGY_LENGTH: usize = 4; + +/// Offsets into the block starting at [`STATUS_START`] +mod status { + /// `D010`, section 3.8 + pub(super) const ACTUAL_SPEED: usize = 0x0; + /// `D016`, section 3.13 + pub(super) const MOTOR_TEMPERATURE: usize = 0x6; + /// `D017`, section 3.14 + pub(super) const ELECTRONICS_TEMPERATURE: usize = 0x7; +} + +/// Offsets into the block starting at [`ENERGY_START`] +mod energy { + /// `D027`, section 3.20.2 + pub(super) const CURRENT_POWER: usize = 0x0; + /// `D029`, the high half of the counter in section 3.22 + pub(super) const CONSUMPTION_HIGH: usize = 0x2; + /// `D02A`, the low half of the counter in section 3.22 + pub(super) const CONSUMPTION_LOW: usize = 0x3; +} + +/// One poll of a fan, decoded into the units the values actually describe +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Reading { + /// Revolutions per minute, or `None` while the fan's configured maximum speed is not known. + /// The reported speed is a fraction of that maximum, so without it the raw value cannot be + /// turned into a rate at all + pub speed: Option, + /// Degrees celsius, and genuinely signed: a fan in an unheated loft reports below zero + pub motor_temperature: i16, + /// Degrees celsius, measured inside the electronics housing rather than in the air stream + pub electronics_temperature: i16, + /// Watts the fan is drawing right now + pub power: u16, + /// Kilowatt hours since the fan left the factory. Only ever counts up, short of a reset + pub energy: u32, +} + +/// Turns the two blocks of input registers into the quantities they describe. +/// +/// `maximum_speed` is the contents of [`MAXIMUM_SPEED_REGISTER`], which only the speed needs. It +/// is separate because it is a holding register that changes only when the fan is reconfigured, +/// so it is read once rather than on every poll +pub fn decode( + status: &[u16; STATUS_LENGTH], + energy_block: &[u16; ENERGY_LENGTH], + maximum_speed: Option, +) -> Reading { + Reading { + speed: maximum_speed.map(|maximum| speed(status[status::ACTUAL_SPEED], maximum)), + motor_temperature: status[status::MOTOR_TEMPERATURE] as i16, + electronics_temperature: status[status::ELECTRONICS_TEMPERATURE] as i16, + power: energy_block[energy::CURRENT_POWER], + energy: u32::from(energy_block[energy::CONSUMPTION_HIGH]) << 16 + | u32::from(energy_block[energy::CONSUMPTION_LOW]), + } +} + +/// The fan reports speed the same way it accepts one: as a fraction of [`set_point::MAX`], which +/// stands for the maximum speed the fan is configured for. See section 3.8. +/// +/// The multiplication is done before the division so the rounding happens once, at the end, and it +/// is done in `u32` because the product does not fit in 16 bits. It cannot overflow `u32` either: +/// the fan caps what it reports at `1.02 * maximum` (`0xFF00`), and even the full `u16` range on +/// both sides stays under `u32::MAX` +fn speed(reported: u16, maximum: u16) -> u16 { + let scaled = u32::from(reported) * u32::from(maximum) / u32::from(set_point::MAX); + // Saturating rather than `as`, because a fan configured with a maximum near the top of `u16` + // reports up to 1.02 times it, which no longer fits + scaled.min(u32::from(u16::MAX)) as u16 +} + +/// Enough for every field at its longest, including the minus signs and a `null` speed. Proven by +/// `json_fits_the_worst_case` +pub const JSON_CAPACITY: usize = 128; + +impl Reading { + /// The payload Home Assistant reads, as one JSON object per fan so that all five values arrive + /// in a single publish and each sensor picks its own out with a value template. + /// + /// An unknown speed is written as `null`, which Home Assistant renders as unknown. That is + /// the honest answer while the maximum speed has not been read, and it keeps the four values + /// that are known from being held back with it + pub fn to_json(&self) -> heapless::String { + let mut json = heapless::String::new(); + + // Every write is into a buffer proven large enough by the test below, so the only way this + // can fail is a change to the fields without a change to the capacity, which that test + // catches + let result = match self.speed { + Some(speed) => write!(json, "{{\"speed\":{speed}"), + None => write!(json, "{{\"speed\":null"), + } + .and_then(|()| { + write!( + json, + ",\"motor_temperature\":{},\"electronics_temperature\":{},\"power\":{},\"energy\":{}}}", + self.motor_temperature, self.electronics_temperature, self.power, self.energy + ) + }); + + debug_assert!(result.is_ok(), "the reading did not fit JSON_CAPACITY"); + let _ = result; + + json + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Half of the configured maximum in, half of it out + #[test] + fn speed_is_a_fraction_of_the_configured_maximum() { + assert_eq!(speed(set_point::MAX / 2, 3_000), 1_500); + assert_eq!(speed(set_point::MAX, 3_000), 3_000); + assert_eq!(speed(0, 3_000), 0); + } + + /// The fan caps what it reports at 1.02 times the maximum rather than letting it run over + #[test] + fn speed_handles_the_capped_reading() { + assert_eq!(speed(0xFF00, 3_000), 3_060); + } + + /// The product of the two overflows 16 bits long before either side does + #[test] + fn speed_does_not_overflow_on_a_large_maximum() { + assert_eq!(speed(set_point::MAX, u16::MAX), u16::MAX); + assert_eq!(speed(0xFF00, u16::MAX), u16::MAX); + } + + /// Both temperatures are signed, which the raw register does not say + #[test] + fn temperatures_below_zero_stay_below_zero() { + let status = [0, 0, 0, 0, 0, 0, 0xFFFB, 0x0015]; + let reading = decode(&status, &[0; ENERGY_LENGTH], None); + + assert_eq!(reading.motor_temperature, -5); + assert_eq!(reading.electronics_temperature, 21); + } + + /// The counter spans two registers, high half first + #[test] + fn energy_spans_both_registers() { + let energy_block = [0, 0, 0x0001, 0x0002]; + let reading = decode(&[0; STATUS_LENGTH], &energy_block, None); + + assert_eq!(reading.energy, 65_538); + } + + #[test] + fn decodes_a_whole_poll() { + // Speed at half of the range, motor at 42 °C, electronics at 38 °C + let status = [set_point::MAX / 2, 0, 0, 0, 0, 0, 0x002A, 0x0026]; + // 25 W, and 1234 kWh since the factory + let energy_block = [25, 0, 0, 1_234]; + + let reading = decode(&status, &energy_block, Some(3_000)); + + assert_eq!( + reading, + Reading { + speed: Some(1_500), + motor_temperature: 42, + electronics_temperature: 38, + power: 25, + energy: 1_234, + } + ); + } + + #[test] + fn serializes_to_json() { + let reading = Reading { + speed: Some(1_500), + motor_temperature: 42, + electronics_temperature: 38, + power: 25, + energy: 1_234, + }; + + assert_eq!( + reading.to_json().as_str(), + r#"{"speed":1500,"motor_temperature":42,"electronics_temperature":38,"power":25,"energy":1234}"# + ); + } + + /// A speed that is not known yet must not hold back the four values that are + #[test] + fn serializes_an_unknown_speed_as_null() { + let reading = Reading { + speed: None, + motor_temperature: -5, + electronics_temperature: 38, + power: 25, + energy: 1_234, + }; + + assert_eq!( + reading.to_json().as_str(), + r#"{"speed":null,"motor_temperature":-5,"electronics_temperature":38,"power":25,"energy":1234}"# + ); + } + + /// [`JSON_CAPACITY`] is asserted against rather than guessed at. `null` is shorter than the + /// longest speed, so the widest object is the one with every number at its longest + #[test] + fn json_fits_the_worst_case() { + let reading = Reading { + speed: Some(u16::MAX), + motor_temperature: i16::MIN, + electronics_temperature: i16::MIN, + power: u16::MAX, + energy: u32::MAX, + }; + + let json = reading.to_json(); + + // Would have been silently truncated rather than panicking in a release build + assert!(json.ends_with('}'), "truncated at {} bytes: {json}", json.len()); + assert!(json.len() <= JSON_CAPACITY); + } +} diff --git a/home_assistant_discovery/src/lib.rs b/home_assistant_discovery/src/lib.rs index 70172ae..edbe89b 100644 --- a/home_assistant_discovery/src/lib.rs +++ b/home_assistant_discovery/src/lib.rs @@ -77,6 +77,21 @@ pub struct Origin { pub enum DeviceClass { Temperature, Humidity, + Power, + Energy, +} + +/// What kind of quantity a sensor reports over time, which is what decides whether Home Assistant +/// keeps long term statistics for it and whether it can go into the energy dashboard. +/// See https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StateClass { + /// The current value of something that moves in both directions, like a temperature + Measurement, + /// A counter that only ever goes up, short of a reset. Home Assistant handles the reset by + /// treating a drop as the start of a new cycle rather than as negative consumption + TotalIncreasing, } /// Internally tagged by the required `platform` (`p`) field @@ -110,13 +125,31 @@ pub enum Component { speed_range_max: Option, }, Sensor { + /// The name of the sensor. Owned rather than borrowed, unlike the name of a fan, because + /// both fans report the same five values and each name is composed from the fan it + /// belongs to + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, + /// The MQTT topic the value arrives on. Several sensors can share one topic and pick their + /// own value out of it with a `value_template`, which is how one publish per fan feeds all + /// of its sensors + #[serde(rename = "stat_t")] + #[serde(skip_serializing_if = "Option::is_none")] + state_topic: Option<&'static str>, #[serde(rename = "dev_cla")] + #[serde(skip_serializing_if = "Option::is_none")] device_class: Option, + #[serde(rename = "stat_cla")] + #[serde(skip_serializing_if = "Option::is_none")] + state_class: Option, #[serde(rename = "unit_of_meas")] + #[serde(skip_serializing_if = "Option::is_none")] unit_of_measurement: Option<&'static str>, #[serde(rename = "val_tpl")] + #[serde(skip_serializing_if = "Option::is_none")] value_template: Option<&'static str>, #[serde(rename = "uniq_id")] + #[serde(skip_serializing_if = "Option::is_none")] unique_id: Option<&'static str>, }, } @@ -175,7 +208,10 @@ mod tests { ( "some_unique_component_id1".to_string(), Component::Sensor { + name: None, + state_topic: None, device_class: Some(DeviceClass::Temperature), + state_class: None, unit_of_measurement: Some("°C"), value_template: Some("{{ value_json.temperature}}"), unique_id: Some("temp01ae_t"), @@ -184,7 +220,10 @@ mod tests { ( "some_unique_id2".to_string(), Component::Sensor { + name: None, + state_topic: None, device_class: Some(DeviceClass::Humidity), + state_class: None, unit_of_measurement: Some("%"), value_template: Some("{{ value_json.humidity}}"), unique_id: Some("temp01ae_h"), diff --git a/topic/src/lib.rs b/topic/src/lib.rs index f69d75b..06bf409 100644 --- a/topic/src/lib.rs +++ b/topic/src/lib.rs @@ -42,6 +42,25 @@ pub mod fan_controller { pub const STATE: &str = formatcp!("{UNIQUE_ID}/speed/percentage_state"); pub const COMMAND: &str = formatcp!("{UNIQUE_ID}/speed/percentage"); } + + /// All five sensor values a fan reports arrive as one JSON object on this topic, so a poll + /// costs a single publish and Home Assistant picks each value out with a value template + pub mod sensors { + use super::UNIQUE_ID; + use const_format::formatcp; + + pub const STATE: &str = formatcp!("{UNIQUE_ID}/sensors/state"); + + /// The identifiers Home Assistant tells the five sensors apart by. They are not + /// topics, but they are composed from the same fan identifier and have to stay unique + /// alongside it, so they belong next to it rather than in the build script + pub const SPEED: &str = formatcp!("{UNIQUE_ID}/sensors/speed"); + pub const MOTOR_TEMPERATURE: &str = formatcp!("{UNIQUE_ID}/sensors/motor-temperature"); + pub const ELECTRONICS_TEMPERATURE: &str = + formatcp!("{UNIQUE_ID}/sensors/electronics-temperature"); + pub const POWER: &str = formatcp!("{UNIQUE_ID}/sensors/power"); + pub const ENERGY: &str = formatcp!("{UNIQUE_ID}/sensors/energy"); + } } pub mod fan_2 { @@ -69,5 +88,24 @@ pub mod fan_controller { pub const STATE: &str = formatcp!("{UNIQUE_ID}/speed/percentage_state"); pub const COMMAND: &str = formatcp!("{UNIQUE_ID}/speed/percentage"); } + + /// All five sensor values a fan reports arrive as one JSON object on this topic, so a poll + /// costs a single publish and Home Assistant picks each value out with a value template + pub mod sensors { + use super::UNIQUE_ID; + use const_format::formatcp; + + pub const STATE: &str = formatcp!("{UNIQUE_ID}/sensors/state"); + + /// The identifiers Home Assistant tells the five sensors apart by. They are not + /// topics, but they are composed from the same fan identifier and have to stay unique + /// alongside it, so they belong next to it rather than in the build script + pub const SPEED: &str = formatcp!("{UNIQUE_ID}/sensors/speed"); + pub const MOTOR_TEMPERATURE: &str = formatcp!("{UNIQUE_ID}/sensors/motor-temperature"); + pub const ELECTRONICS_TEMPERATURE: &str = + formatcp!("{UNIQUE_ID}/sensors/electronics-temperature"); + pub const POWER: &str = formatcp!("{UNIQUE_ID}/sensors/power"); + pub const ENERGY: &str = formatcp!("{UNIQUE_ID}/sensors/energy"); + } } }