[READ-ONLY] Mirror of https://github.com/SantaClaas/embedded-fan-control.

CLAUDE.md #

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is #

A monorepo for a Raspberry Pi Pico W (RP2040) fan controller that drives two ebm-papst RadiCal centrifugal fans over Modbus RTU and integrates with Home Assistant over MQTT. The firmware is no_std and built on Embassy. The other crates are supporting libraries and desktop-side helper binaries.

Most of the repository is one Cargo workspace, rooted at Cargo.toml. Alongside it sits serial, a browser tool for the same Modbus bus that is a separate pnpm project and not a workspace member — see The serial tool below.

Commands #

Always run cargo from the package directory, never the workspace root. fan-controller only compiles for thumbv6m-none-eabi, which is set in fan-controller/.cargo/config.toml; cargo only reads that file when invoked from inside that directory (see README.md).

cd fan-controller && cargo run

cargo run in fan-controller flashes and runs on a connected RP2040 via probe-rs run --chip RP2040 (the configured runner) — it requires a debug probe. To flash without a probe, swap the runner in .cargo/config.toml to elf2uf2-rs -d. DEFMT_LOG=debug is set there too, so RTT logs come out at debug level.

The same holds for serial, for a different reason — it is a pnpm project rather than a workspace member, so it is only buildable from its own directory (cd serial && pnpm dev).

Tests, checks, and lints run per crate from that crate's directory:

cd home_assistant_discovery && cargo test
cd home_assistant_discovery && cargo test -- serialize_custom_example

home_assistant_discovery uses insta snapshots (src/snapshots/); accept changes with cargo insta review. fan-controller has a bacon.toml; bacon defaults to cargo check and c is bound to clippy-all. Its test job comes from the stock template and cannot work — see Testing reality below.

Build-time configuration (fan-controller) #

fan-controller/build.rs bakes configuration into the binary; there is no runtime configuration.

  • fan-controller/.env (gitignored) must define FAN_CONTROL_WIFI_NETWORK, FAN_CONTROL_WIFI_PASSWORD, FAN_CONTROL_MQTT_BROKER_USERNAME, FAN_CONTROL_MQTT_BROKER_PASSWORD, FAN_CONTROL_MQTT_BROKER_ADDRESS, FAN_CONTROL_MQTT_BROKER_PORT. The build script fails without them. They are re-exported as cargo:rustc-env and read back through env!() in src/configuration.rs.
  • The Home Assistant MQTT discovery payload is generated at build time by set_discovery_payload(), which builds a home_assistant_discovery::DiscoveryPayload from topic constants, serializes it to JSON, and exposes it as FAN_CONTROLLER_DISCOVERY_PAYLOAD. The firmware publishes that string verbatim on boot. Changing MQTT topics or discovery fields means editing topic/src/lib.rs and/or build.rs — not the firmware.
  • The build script also embeds the short git hash as semver build metadata in the reported software version, and re-runs on ../.git/HEAD changes.

Workspace crates #

Crate Target Purpose
fan-controller thumbv6m-none-eabi The firmware. Everything below supports it.
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_sensor no_std Decoding what a fan reports about itself — actual speed, both temperatures, power — 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::sensor. 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.

Note mqtt appears twice in the firmware: the workspace crate (::mqtt) holds protocol constants, while fan-controller/src/mqtt/ (crate::mqtt) holds the packet encode/decode and client task.

The serial tool #

serial is not a crate and not a workspace member — it is a SolidJS 2.0 app on Vite that talks to the RS-485/Modbus bus from the browser over the Web Serial API. It uses pnpm, and like the Rust packages it only builds from its own directory:

cd serial && pnpm install && pnpm dev

pnpm test runs the protocol tests, pnpm build type-checks and builds.

It does two things:

  • Watches the bus without joining in, decoding the traffic between the fan controller and the fans. debug-listener reads the same line from the desktop but prints bytes; this frames and decodes them.
  • Talks to a device directly, reading input registers and reading and writing holding registers — which is how a device is given its address, baud rate and correction values before it is wired in. The firmware never writes anything but the set point.

Those two are mutually exclusive on one port: a half-duplex RS-485 line has room for one master, so the active side must not be used while the controller is polling.

Path What it is
serial/src/modbus/ CRC, frame lengths and decoding, and recovering frames from a bus the app is not driving. Plain TypeScript over bytes, and where the tests are.
serial/src/devices/ Each device as a list of registers that know their address, their manual's name for them, and how to decode them. The UI is generated from these.
serial/src/serial/ One open port: a single read loop serving both the monitor and outstanding requests.
serial/src/ui/ The components.

Four things to know before changing it:

  • Register names are the manual's own headings, in the manual's own language, with an English gloss beside them and a section number. For the RadiCal that means German — Aussteuergrad, Wirksinn, Sollwert. Do not replace them with translations: the old Svelte tool had PhaseControlFactor for Aussteuergrad and CurrentDesiredEffect for Aktueller Wirksinn, both invented, and neither can be looked up in docs/manufacturer/radical/.
  • The tests are built on frames copied out of the manufacturers' manuals, check bytes included. They agree only if the code agrees with the devices rather than with itself, which is how two wrong CRCs in docs/temperature-sensor.md were found. Keep new device work anchored the same way.
  • Solid 2.0 is not Solid 1.x. createEffect takes a compute and an effect function; there is no onMount, because a component body already runs once during setup; DOM rendering is in @solidjs/web, which is also the jsxImportSource.
  • Register write fields rely on the browser's own validation. The bounds are real min/max/step attributes taken from the register definition, and :user-invalid styles them only after the field is left. Do not add a parallel bounds check in a signal.

Firmware architecture #

main() in fan-controller/src/main.rs destructures the RP2040 peripherals, declares every synchronization primitive as a static, and spawns tasks that communicate only through them. Nothing shares mutable state directly.

Pin assignments live in that destructuring: PIN_4 Modbus driver-enable, UART0 on PIN_12/PIN_13, PIN_18 button, PIN_20/PIN_21 status LEDs, PIN_23/25/24/29 + PIO0 + DMA_CH0 for the CYW43 Wi-Fi chip.

The primitive type encodes the intent, so pick deliberately when adding one:

  • Channel (IN / OUT, size 8) — MQTT publishes in and out; every message must be delivered.
  • Signal (FAN_ONE_STATE / FAN_TWO_STATE) — the requested set point; only the latest matters.
  • Watch (FAN_ONE_DISPLAY_STATE / FAN_TWO_DISPLAY_STATE, 2 receivers each) — the confirmed set point, published only after the fan acknowledged the Modbus write, and fanned out to both the display routine and the button routine.
  • OnceLock (FANS) — the Modbus client, so fan tasks await initialization rather than race it.

Flow of a speed change:

  1. mqtt_routine brings up the network stack, connects to the broker, and pushes decoded publishes into IN. input_routine is the alternative source: it debounces the button and cycles off → low → medium → high from the minimum of both fans' current states.
  2. mqtt_brain_routine interprets IN in fan-controller terms and signals FAN_*_STATE. It keeps last_fan_state so a Home Assistant "on" restores the previous speed. is_synchronization_on (hardcoded true) makes a command to either fan drive both.
  3. fan_control_routine (pool of 2, one per fan address) awaits its Signal, writes the holding register over Modbus, and only on success sends the value to its display Watch.
  4. display_routine debounces both watches by 250 ms so the two fans' updates land as one, then 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 three values. The documented energy counter (D029/D02A) is not read: both fans answer 0xFFFF for it, so the sensor was removed rather than announced as a value that is never real.

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.

Domain constants #

  • Set points are 0..=64_000 (set_point::MAX, re-exported as fan::set_point::MAX), wrapped in the SetPoint newtype.
  • User-facing speeds are deliberately not the full range — fan::user_setting::{LOW, MEDIUM, HIGH} cap at 50 % to reduce wear. Home Assistant is told speed_range_max: 32_000 to match, so the range it shows is that capped one. LOW and MEDIUM are the thirds of it (MAX / 6 and MAX / 3), which makes the button cycle through the same steps the Home Assistant slider shows rather than through arbitrary points on it.
  • 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<COUNT> 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 #

fan-controller cannot be tested by any normal means, so don't waste time trying: cargo test targets thumbv6m-none-eabi, which has no test harness, and cargo test --target aarch64-apple-darwin fails because cortex-m uses ARM inline assembly. A #[cfg(test)] module anywhere in fan-controller/src/ is compiled by nothing and will rot unnoticed — set_point used to be one and did.

Tests therefore only exist in the crates that build for the host:

cd set_point && cargo test
cd home_assistant_discovery && cargo test
cd fan_sensor && 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.

serial has its own suite, which runs on the host without a browser because everything under src/modbus and src/devices is plain TypeScript over bytes:

cd serial && pnpm test

Reference documents #

  • fan-controller/documentation.md — the LED status protocol (which blink pattern means which fan speed / out-of-sync state) and the Home Assistant onboarding sequence. Update it when LED behaviour changes.
  • fan-controller/TODO.md — the working TODO list for the firmware: every outstanding item with source line references and a suggested priority order. Keep it in sync when adding or resolving a //TODO in fan-controller/src/.
  • README.md — probe firmware updates and where Home Assistant logs rejected discovery payloads.
  • docs/manufacturer/ — a submodule pointing at the private fan-documentation repo, holding the manufacturer material kept out of this public repo: the ebm-papst RadiCal MODBUS specification (radical/) and the Modbus relay module manual (relay/). git submodule update --init after cloning; it needs access to that private repo. This is the authority on RadiCal register addresses, units and naming.
  • docs/temperature-sensor.md — the Modbus registers and protocol of the RS-485 temperature/humidity sensor. No manufacturer PDF exists for that device, so this file, and the raw text it was formatted from next to it, is the only documentation there is.