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 defineFAN_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 ascargo:rustc-envand read back throughenv!()insrc/configuration.rs.- The Home Assistant MQTT discovery payload is generated at build time by
set_discovery_payload(), which builds ahome_assistant_discovery::DiscoveryPayloadfromtopicconstants, serializes it to JSON, and exposes it asFAN_CONTROLLER_DISCOVERY_PAYLOAD. The firmware publishes that string verbatim on boot. Changing MQTT topics or discovery fields means editingtopic/src/lib.rsand/orbuild.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/HEADchanges.
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-listenerreads 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
PhaseControlFactorfor Aussteuergrad andCurrentDesiredEffectfor Aktueller Wirksinn, both invented, and neither can be looked up indocs/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.mdwere found. Keep new device work anchored the same way. - Solid 2.0 is not Solid 1.x.
createEffecttakes a compute and an effect function; there is noonMount, because a component body already runs once during setup; DOM rendering is in@solidjs/web, which is also thejsxImportSource. - Register write fields rely on the browser's own validation. The bounds are real
min/max/stepattributes taken from the register definition, and:user-invalidstyles 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:
mqtt_routinebrings up the network stack, connects to the broker, and pushes decoded publishes intoIN.input_routineis the alternative source: it debounces the button and cycles off → low → medium → high from the minimum of both fans' current states.mqtt_brain_routineinterpretsINin fan-controller terms and signalsFAN_*_STATE. It keepslast_fan_stateso a Home Assistant "on" restores the previous speed.is_synchronization_on(hardcodedtrue) makes a command to either fan drive both.fan_control_routine(pool of 2, one per fan address) awaits itsSignal, writes the holding register over Modbus, and only on success sends the value to its displayWatch.display_routinedebounces both watches by 250 ms so the two fans' updates land as one, then drivesLED_STATEand publishes state back to MQTT viaOUT.led_routinerendersLedState; 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 asfan::set_point::MAX), wrapped in theSetPointnewtype. - User-facing speeds are deliberately not the full range —
fan::user_setting::{LOW, MEDIUM, HIGH}cap at 50 % to reduce wear. Home Assistant is toldspeed_range_max: 32_000to match, so the range it shows is that capped one.LOWandMEDIUMare the thirds of it (MAX / 6andMAX / 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;0x01is 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_SIZEbuffer.main.rsasserts 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
//TODOinfan-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 --initafter 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.