# TODO inventory Every outstanding item in the firmware, collected from [README.md](README.md), [documentation.md](documentation.md), and `//TODO` comments in `src/`. Paths and line numbers are relative to `fan-controller/`. They drift as the source changes, so 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, 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. What follows them is what has been asked for but not started, and then the unranked inventory to pick from. All of it has now run on hardware, as of 2026-09-06: both Modbus buses answer, every sensor reaches Home Assistant, and the discovery payload is accepted. What has *not* been exercised is the failure paths — the reconnect loop, the retry back-off, and the out-of-sync LED pattern all still need a pulled cable to prove. --- ## Suggested priority ### P0 — the two items that made the device wrong or dead in the field **1. Validate the Modbus response** — done, `src/modbus/client.rs` `Client::send_3` used to end in an unconditional `Ok(())`: it never inspected the response bytes and never checked how many arrived, so anything that was not a UART error or a timeout counted as success — a modbus exception, a reply from the *other* fan, or line noise. That mattered because the whole "confirmed set point" design rests on it. The `Watch` senders are documented as publishing only after the fan acknowledged the write, and `fan_control_routine` updates `current_set_point`, the display state, the LEDs, and the Home Assistant state on the strength of that `Ok(())`. `send_3` now reads the address and function code first, branches on whether an echo or an exception frame is arriving, and reads exactly the length of that frame with `read_exact` instead of a possibly short `read`. A successful write is confirmed by comparing the whole frame against the request, which validates the register, the value, and the checksum at once. Exception frames are checksum checked and returned as `WriteError::Exception`, so the existing retry loop now fires for a fan that answers but refuses. Every failure carries the reason and, where it applies, which part of the exchange it happened in, so the retry log in `fan_control_routine` identifies the fault without having to read back through the trace. After any failure the receive buffer is drained, so a partial frame cannot be read as the answer to the next transaction — both fans share the UART, so leftovers from one would otherwise alias onto the other. The vendor specification confirms the frame layout this relies on: the response to `0x06` echoes the request, an exception sets the MSB of the function code and carries a single code byte, and the fan stays silent rather than answering when the address, length, or checksum of the request is wrong. It also documents that the fan ignores the four least significant bits of a set point, which `LOW` and `MEDIUM` both set, so the echo is compared with those bits masked rather than byte for byte. Untested on hardware. The exception, checksum, and incomplete-frame paths have never run, and `BLOCK_FOR` was changed from 5 ms to 800 µs at the same time — the old value kept the driver enabled into the window where the fan may already be answering. Both want a look at the log on the first flash. Still open in the same area: `src/modbus/client.rs:329`, why the flush must be blocking to avoid `WouldBlock`. **2. The MQTT client never reconnects** — done, `src/task.rs`, and then done again on 2026-09-07 The reconnect loop below was right and still deadlocked one line outside itself, so it went a whole night without reconnecting. `TcpSocket::abort` closes the socket while leaving its remote endpoint set, which is the exact state `flush` waits on, and nothing wakes a send waker on a closed socket — so the unbounded `socket.flush().await` after the abort never returned. The log said "Lost connection ... Reconnecting" and then nothing at all, while the fans polled on into a channel nobody drained. The flush is now bounded; see `configuration::MQTT_SOCKET_RESET_TIMEOUT`. Worth remembering as the shape of the bug rather than the bug: the loop was tested, and the thing that broke it was the cleanup *before* the loop got its turn. `mqtt_routine` (`src/main.rs`) called `mqtt_with_connect` exactly once, and the session inside it had no way to end cleanly: `listen` and `keep_alive` returned on a read error, a timeout or a closed connection, but `talk` and `handle_publish_send` loop forever, so `join5` never completed and the task hung on a dead socket. Either way the controller was offline until it was power cycled, quietly — the button routine is independent and keeps working, and no LED pattern signals that Home Assistant has been disconnected. `mqtt_with_connect` is now a loop that runs one session at a time and never returns. Per attempt it creates a fresh socket, resolves the broker address, connects once, and runs the session; when the session ends it aborts the socket, flushes so the reset actually goes out, and waits before trying again. The wait starts at `MQTT_RECONNECT_BACKOFF_INITIAL` (1 s), doubles up to `MQTT_RECONNECT_BACKOFF_MAX` (60 s), and is reset as soon as an MQTT connection is established, so a short hiccup recovers immediately and a broker that is down for the evening is not asked every second. The TCP connect no longer retries on its own — that was the one path that would have skipped the backoff entirely. The session is now `select3(listen, keep_alive, join3(talk, set_up_subscriptions, handle_publish_send))`. Only the first two notice that the connection is gone; selecting on them drops the other three, which cancels them. That is the `src/task.rs` "cancel all tasks" TODO, and it is what makes reconnecting possible at all — everything belonging to a session (subscription acknowledgements, the outgoing channel, the writer mutex) is scoped to it and rebuilt on the next one, so subscriptions are re-established automatically. `listen` and `keep_alive` return a `SessionEnd` describing why instead of signalling a `ClientState` that nothing ever waited on, so the reconnect log says whether the broker closed the connection, sent a disconnect packet, the socket read failed, a packet could not be parsed, or the keep alive ping went unanswered. The write-only `ClientState` signal is gone. A received disconnect packet used to be logged and otherwise ignored, leaving the session to limp on until the keep alive timed out; it now ends the session like any other lost connection, which is also the `src/task.rs` "close the TCP connection on `Disconnect`" TODO. Untested on hardware. Worth watching on the first flash: that a broker restart is actually noticed and recovered from, and that reusing the socket buffers across attempts does not leave the stack in a bad state. Follow-up in the same area: nothing is re-announced after reconnecting. Home Assistant keeps its discovered entities, so the device does not disappear, but its state is whatever it was before the drop until the next fan change publishes a new one. Re-publishing the discovery payload and the current display state on every successful connect needs a signal from the session out to `display_routine`. P1 item 3 made that worth doing: the display state is now seeded from the fans on boot, so there is a real state to re-announce rather than a `None`. Messages that `talk` or `handle_publish_send` had picked up but not yet written are also lost when the session is cancelled; for state updates where only the latest value matters that is acceptable. ### P1 — state was wrong after every reset **3. Read the fan speed on boot** — done, `src/modbus/`, `src/main.rs` After a reset the fans keep spinning at whatever they were set to, but the controller assumed nothing: `current_set_point` started as `None` and `last_fan_state` at `SetPoint::ZERO`. The LEDs and the Home Assistant state were wrong until someone issued a command, and a Home Assistant "on" restored a set point that was never actually in effect. Reading holding registers (function code `0x03`) is now implemented. `ReadHoldingRegister` asks for exactly one register, which keeps the response a fixed length, and `Client::read_holding_register` returns its contents. The parts both functions share are factored out of the write path rather than copied: `send_request` drives the line and writes the frame, and `read_header` reads the address and function code and checks an exception frame, reporting a refusal as the raw code because the specification lists different ones per function. Each transaction then only has to read its own body. A read is rejected if it announces a byte count other than the one register asked for, which is checked before the checksum because a different length means the wrong bytes were just read. `send_3` was renamed to `write_holding_register` to pair with it. `fan_control_routine` reads the set point before it waits for anything, and sends what it gets into the display `Watch`, so the LEDs, Home Assistant and the button all start from what the fans are actually doing. `read_set_point` retries `MAX_ATTEMPTS` times sharing the write path's backoff, and gives up early if a set point is requested in the meantime — a command is worth more than the state being read, and an unreachable fan costs a timeout per attempt. `current_set_point` stays an `Option` rather than becoming a plain `SetPoint` as this item originally suggested. A fan that does not answer leaves its state genuinely unknown, and every place that uses it — the redundant-write check and the "push the other fan back" path — already treats not knowing as its own case. Filling it with a guess would make those two silently wrong instead. `last_fan_state` no longer starts at zero and no longer records the set points that Home Assistant asks for. `mqtt_brain_routine` now watches the confirmed display state of both fans, so it is seeded from the boot read and picks up the speeds set with the button as well, and it only remembers non-zero speeds since zero is the state "on" restores from. It records what a fan actually accepted rather than what was asked of it, so a failed write no longer leaves a speed behind that was never in effect. The display `Watch` went from two receivers to three for this, through a `DISPLAY_STATE_RECEIVERS` alias so the count lives in one place. `display_routine` also tells Home Assistant whether the fans are on or off on the first update after boot. It only published that on a *change*, and the first update is not a change, so the speed arrived without the on/off state that Home Assistant needs to render it. The 250 ms alternating LED pattern that `documentation.md` describes as "while the initial fan speed data is getting read from the fan" needed no work: it is what `led_routine` already plays before it has an `LedState`. It just describes reality now instead of blinking until the first command. Untested on hardware. The read path has never run: worth checking on the first flash that the fans answer `0x03` at all, what they report for a fan that is off, and whether the value comes back with the four least significant bits the fan ignores zeroed or as they were written. **4. Fans ping-pong forever when the bus is down** — done, `src/main.rs` After exhausting `MAX_ATTEMPTS`, a fan signalled the *other* fan back to its own last known good set point, to keep the two from drifting apart and putting the house under or over pressure. If the bus itself was down, that fan failed too and signalled back, and neither ever stopped. A slow churn rather than a spin — roughly four attempts at a 5 s timeout plus backoff per round — but it never terminated and kept cycling the Modbus mutex. The signal now carries where the set point came from, as `RequestedSetPoint::FromUser` or `FromOtherFan`, and only a request from a user pushes the other fan back when it fails. That is option A from the notes that used to sit here, a retry strategy on the signal set to once, except the "once" falls out of what the value means rather than being counted. The reasoning is that a correction which fails is not the two fans disagreeing, it is the bus being down, and there is nothing left for a second correction to fix: the pushing fan is at its own set point and the fan being pushed already failed at that exact value. Answering it with another correction is precisely what bounced the same value back and forth. So a correction is applied and retried like anything else, but it never produces another correction, and every round of signalling ends after at most two: one if a single fan failed, two if both were commanded at once and both failed. That leaves the fans genuinely out of sync in one case — a fan that fails to apply a correction — which is the honest outcome, because nothing else can be tried until the bus comes back. It is not silent: the display state is only updated on a confirmed write, so the two LEDs blink the out-of-sync pattern until a later command succeeds. Option B, a counter that detects the loop, is not needed on top of this. It would spot the same 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_sensor/`, `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 and a current power draw, and all four now reach Home Assistant. A fifth, the energy counter, was announced at first and has since been removed — see below. 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 four 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_sensor` 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 — 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 three 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. The one thing that came back wrong was the energy counter, which this section had flagged as worth watching. Both fans answer `0xFFFF` for both of its registers (`D029`/`D02A`), i.e. `4294967295` kWh in Home Assistant, which is what an ebm-papst fan reports for a register its hardware variant does not implement — there is no parameter to switch the counter on, and the specification's foreword warns that the documented feature set depends on the variant. The sensor is gone from the discovery payload and from `fan_sensor::Reading`; the run at `D027` is now a single register and is named after the power draw it actually carries. That leaves 10 components in the payload rather than the 12 described above. **Report the air as well as the fan** — done, `fan_sensor/`, `topic/`, `build.rs`, `src/main.rs` The temperature/humidity sensor inputs were written off above as reporting nothing, on the assumption that no sensor was wired to the fans. Reading them with the `serial` tool showed real values, so the sensor 1 pair (`D02E`/`D02F`, section 3.17.3) and the volume flow in m³/h (`D033`, section 3.17.5) are now polled and announced, three components per fan. Sensor 1 rather than sensor 2 because it is the one the fan's own mass flow calculation uses. Both of the sensor's values are finer than the fan's own: the temperature is signed *tenths* of a degree and the humidity is a fraction of `65536` rather than of 100. They are carried as tenths in a `Tenths` newtype and formatted by hand, because the RP2040 has no floating point unit and a tenth is exactly what the sensor resolves. The awkward case is `-0.5`, whose whole part is zero and so loses its sign to the division; `Tenths` writes the sign separately, and a test holds it there. The run at `D027` grew from the single register it had been shrunk to back to `D027`-`D033`, so all three arrive without a third round trip. It spans `D02B`-`D02D`, which the manual's register table does not list at all — safe for the same reason the status run is, which has always spanned the equally unlisted `D015`. The payload went from 10 components to 16 and past the 4 kB send buffer, which the compile-time assertion caught. Growing the buffer to 8 kB was the wrong answer and hard faulted before the device ever reached the broker — proven by A/B, since the parent commit runs and the parent commit with nothing changed but `SEND_BUFFER_SIZE` does not. The payload never needed to be in that buffer at all: it is a slice the caller already holds, in flash for the discovery payload, so `send` now writes the header and then the payload straight from where it is. The buffer is back to 1 kB and no longer grows with the payload; the assertion now checks the header. That took three flashes longer than it should have, because `panic-probe` had no print feature: a real panic only executed an undefined instruction, so it was indistinguishable from a hard fault and the executor's task arena spent a while as the prime suspect. `print-defmt` is on now. **The fans are in constant-volume control, which is why they run at different speeds.** Measured on 2026-09-06 by changing the set point from Home Assistant and watching the sensor topics: at 13760 both fans reported exactly 129 m³/h, at 8320 both settled on exactly 78 — which is `set point / 64000 · 600`, so the *Bezugswert Volumenstrom* (`D1ED`, section 2.48) is 600 m³/h. The speeds are not the same and are not meant to be: holding 78 m³/h took fan 1 1318 rpm and fan 2 976 rpm, the difference being what their two ducts restrict. Two fans reporting an identical flow at different rpm is the system working, not a stuck register. **How badly the fans overshoot depends on the direction and the size of the change.** Measured on 2026-09-06 across four ramps. This note was written twice before it was right; the reversals are worth knowing about, because the first two ramps each supported a conclusion the third destroyed. Going *up* is uneventful. Commanded from 93 to 210 m³/h the fans peaked at 213 — 1.4 % over — and were done in a few seconds. Going *down* depends entirely on how far: | Commanded | Reduction | Undershoot below target | |---|---|---| | 210 → 141 | 33 % | 2 % and 4 % | | 180 → 93 | 48 % | 15 % and 20 % | | 141 → 39 | 72 % | 23 % and 18 % | The largest is not just a dip but a genuine oscillation. Asked for 39 m³/h the fans fell to 30 and 32, climbed back past the target to 47 and 43, and were still hunting in that band forty seconds later when the set point changed again — so whether they would have settled at all is unknown. 39 is 6.5 % of the range, which is likely near the bottom of what the fans can hold, and it is the region worth suspecting when a low speed behaves oddly. None of this is the firmware's doing; it is the fans' own control loop, now visible in Home Assistant because a change is sampled every two seconds. Read a dip after a large speed reduction as this rather than as a fault — but a *large* dip only follows a *large* reduction. That trace is also the best evidence for the length of the settling run. At the bottom the flow read 30 twice in succession before climbing away to 47. A rule that called two steady readings settled would have published 30 m³/h for a fan on its way to 39 and then to 102. `D033` is the flow *achieved* rather than the flow commanded — it converged on 78 from above as the fans spun down, reading 82 and 84 sixteen seconds after the change and disagreeing between the fans while they were still ramping. That is what makes it worth having: at steady state it should sit on the target, so a persistent shortfall, or a slow climb in the rpm needed to hold the target, is a blocked duct or a loading filter saying so. Still open in the same area: the second temperature/humidity sensor (`D030`/`D031`), the vane anemometer speed (`D032`) and the mass flow (`D034`), none of which have been checked for whether they carry anything, and the PT1000 inputs (`D038`/`D039`), which have a documented sentinel for being unwired that the temperature/humidity pair does not. **Let the fans say what is wrong** — done, `fan_sensor/`, `topic/`, `build.rs` `D011` (what is wrong now) and `D012` (what is close to going wrong) were being read as part of the status run and thrown away, so a fault could only be inferred from a temperature climbing. Both are bit fields, printed in sections 3.9 and 3.10 as two rows of eight, most significant row first. They are decoded into the manual's own abbreviations, so Home Assistant shows `BLK, FB` rather than `0x0090`, and `OK` when there is nothing to say. No extra Modbus traffic: both sit at offsets 1 and 2 of a run already being read. Two decisions worth keeping: - **A bit the manual prints as `0` is reported as raw hex rather than swallowed.** A fan saying something this code does not understand must not read as healthy. - **The bit positions are tested against whole 16 bit words**, not against the bit numbers they were transcribed into. Testing `1 << 7` against a constant `7` proves only that the constant equals itself; `0b0000_0000_1000_0000` against the manual's printed row is the actual claim. On hardware both fans report `motor_status: OK` and, at first, `warning: Kabelbruch` — bit 10, the cable break on the *analog* set point input. That input is deliberately unwired, because the set point arrives over RS-485, so it sits below the break threshold and both fans flag it on every poll. A warning that can never clear is the state a real one is easiest to miss in, so that bit is reported as its own entity instead: `warning` reads `OK`, and `analog set point` reads `Kabelbruch`. It is excluded from `Warning::is_healthy` for the same reason, and still counts as a documented bit so it cannot fall through to the hex branch. Wiring something to that input later needs no firmware change to see it. ### Cheap win worth slotting in anywhere **Make `SetPoint` host-testable** — done, `set_point/` The `#[cfg(test)]` module in `src/fan/set_point.rs` never compiled, and it had already rotted once and was being kept correct by hand. `SetPoint` now lives in its own `no_std` crate at `set_point/`, with `defmt` behind a feature so the same type works on the device and on the host, and `fan/mod.rs` re-exports it so every `fan::set_point::…` path still reads the same. Its tests run: `cd set_point && cargo test`. The two that were rotting are back, and two more cover `FromStr`, which every speed Home Assistant asks for goes through and which nothing tested before — including the payloads that are not set points at all. This is the pattern for testing anything else in the firmware: move the logic into its own crate and re-export it. Recorded in `CLAUDE.md`, which also no longer claims `bacon test` works in `fan-controller`, because that job is from the stock template and the target has no test harness. --- ## Asked for, not yet started ### Break up `main.rs` `src/main.rs` is 1,396 lines and holds nearly everything that is not a protocol: the button routine, the display debounce and its MQTT publishing, the LED state machine and its animations, the MQTT brain that interprets incoming publishes, the fan control routine with its retry and correction logic, the sensor polling routine, the shared type aliases and statics, and the pin destructuring in `main()` itself. Reading any one of those means scrolling past the other seven. The task boundaries are already the seams — the routines only talk through the statics declared in `main()`, so moving each into its own module costs nothing structurally. What needs deciding is where the shared vocabulary goes: `RequestedSetPoint`, `LedState`/`Blink`, the `DisplayState*` aliases, `ModbusMutex`/`ModbusOnceLock`, and `back_off`/`MAX_ATTEMPTS` are used across several routines. `main()` should end up as the wiring — peripherals, statics, spawns — and nothing else. `src/task.rs` (778 lines) is the second candidate and may be the bigger win: it is the MQTT session, the reconnect loop, the subscription acknowledgement machinery and the packet plumbing in one file, and the acknowledgement cluster listed under *MQTT client* below is a refactor waiting inside it. `src/modbus/client.rs` (607 lines) is long but coherent — one client, one transaction per function code — so it is likely fine as it is. Worth doing before the next feature rather than after, since the item below adds code to `main.rs` in several places. Nothing here changes behaviour, and nothing in `fan-controller` can be tested, so the only check is that it still compiles and still runs on hardware — which argues for moving code verbatim first and cleaning up in separate commits. ### A Home Assistant toggle for running the fans out of sync Not in there today, in any form, but most of the pieces are. Home Assistant already exposes the two fans as separate entities, so the per-fan command path exists — it is just overridden. `is_synchronization_on` is a hardcoded `true` in `mqtt_brain_routine` (`src/main.rs:698`) that mirrors every speed command onto the other fan, and `fan_control_routine` pushes the other fan back to its last good speed when a write fails (`src/main.rs:930`), which is a second, independent way the two are kept equal. Both existing `//TODO` comments there are this item. The control is a switch in Home Assistant, not a second physical button. The physical button stays what it is — the thing that puts the fans back together — so pressing it turns synchronization back *on* in addition to cycling both fans to the same speed. It already signals the same set point to both (`src/main.rs:104`), so the speeds take care of themselves; what is new is that it has to set the mode and get the toggle in Home Assistant to follow. The work, in the order it has to happen: 1. **A switch component in the discovery payload.** `home_assistant_discovery::Component` only has `Fan` and `Sensor` variants; a `Switch` has to be added with its state and command topics. The topics belong in `topic/src/lib.rs` next to the existing controller-wide `STATE`/`COMMAND`, since synchronization is a property of the controller rather than of either fan, and the component itself is added in `set_discovery_payload()` in `build.rs`. Watch the payload size: `main.rs` asserts at compile time that it fits `mqtt::task::SEND_BUFFER_SIZE`, and that assertion exists precisely because a payload that does not fit is refused and only logged. 2. **A sixth subscription.** `SUBSCRIPTIONS` in `src/task.rs:441` is a fixed array of five with a `SUBSCRIPTIONS_LENGTH` next to it; the command topic joins it. Decoding needs a new `IncomingPublish` variant alongside `FanCommand` (`src/main.rs:427`) and an arm in the `TryFrom` match (`src/main.rs:450`) — the payload is `ON`/`OFF`, which the existing arms match as raw `b"ON"`/`b"OFF"` bytes inline rather than through a shared parser. 3. **Somewhere for the mode to live.** It is read by `mqtt_brain_routine` and needs to be honoured by both `fan_control_routine`s in the correction path, which are different tasks, and written by both the MQTT brain and the button routine. A `Watch` with a receiver per reader fits the existing vocabulary; an `AtomicBool` would do too but would not let anything wait on a change. 4. **Publish the switch state back.** Home Assistant has to see the toggle flip when the button turns synchronization back on, the same way `display_routine` publishes fan state after a confirmed write. Whether that publish lives in `display_routine` or next to whatever owns the mode is the one structural choice here. 5. **Honour the mode in the correction path** (`src/main.rs:930`). Leaving this out would mean a failed write silently re-synchronizes fans that were deliberately set apart. 6. **Snap the fans together when synchronization is turned on.** Decided: turning the toggle on takes the *minimum* of both confirmed set points and signals it to both fans, the same base state the button picks (`src/main.rs:121`). Without this the toggle changes no set point at all, so the fans would sit at whatever speeds they were left at until the next command — turning synchronization on and watching nothing happen is the wrong answer. Taking the minimum rather than the maximum keeps the house from being pushed harder than either fan was asked for. Worth factoring out of `input_routine` so the button and the toggle share one function instead of two copies of the same rule. One thing is still open. The LED protocol already has an out-of-sync pattern, but it means *unintentionally* out of sync — a failed write — and `documentation.md` describes it that way. Deliberately unequal fans would blink the error pattern forever unless the two are distinguished. `documentation.md` needs updating either way, both for the LEDs and for the new entity in the onboarding sequence. --- ## Full inventory ### Fan state and control logic — `src/main.rs` | Where | Item | |---|---| | `src/main.rs:665` | Make `is_synchronization_on` configurable through a switch (hardcoded `true`) | | `src/main.rs:898` | Skip pushing the other fan's speed when a setting allows the fans to run out of sync | | `src/main.rs:834` | Consider updating the display state even when the new set point equals the current one | | `src/main.rs:294`, `:307`, `:332`, `:345` | Handle backpressure when the MQTT out channel is full | The four backpressure sites are the same code twice per fan (state update, then speed update) and currently log an error and drop the publish, so Home Assistant silently misses the update. Since these are display updates where only the latest value matters, dropping the *oldest* or moving to a latest-wins primitive fits better than blocking. Worth factoring into one helper while you are there. ### MQTT client — `src/task.rs` | Where | Item | |---|---| | `src/task.rs:46` | Rework subscribe acknowledgement into a channel that sends the packet identifier | | `src/task.rs:72` | `wait_for_acknowledgement` can hang if called concurrently: one waker plus a `try_lock`. Use `embassy-sync::waitqueue` and/or a blocking mutex | | `src/task.rs:674` | Replace the static "global" acknowledgement state once wakers and polling are settled | | `src/task.rs:319` | Free packet-identifier management | | `src/task.rs:655` | Real error handling instead of `defmt::unwrap!` on `Connect::try_from` | | `src/task.rs:532` | Support IPv6 in broker DNS resolution (currently `DnsQueryType::A` only) | The acknowledgement cluster (`:46`, `:72`, `:674`, `:319`) is one refactor, not four. The concurrency hazard is real but is only exercised at startup with a fixed subscription set today; it becomes urgent if subscriptions ever become dynamic. ### MQTT packet encode / decode — `src/mqtt/` | Where | Item | |---|---| | `src/mqtt/packet/publish.rs:158` | Validate there is enough space left in the buffer | | `src/mqtt/packet/publish.rs:146` | Validate the topic name contains no MQTT wildcard characters | | `src/mqtt/packet/publish.rs:66`, `src/mqtt/mod.rs:54` | Set the duplicate and QoS flags. Retain is done — the reset cause is published with it | | `src/mqtt/packet/connect.rs:40` | Check the fixed header can even be written | | `src/mqtt/packet/subscribe.rs:127` | Support packet identifiers greater than `u8::MAX` | | `src/mqtt/packet/subscribe_acknowledgement.rs:18` | Convert to the decode trait | | `src/mqtt/packet/subscribe_acknowledgement.rs:27` | Stop ignoring properties | | `src/mqtt/packet/subscribe_acknowledgement.rs:28` | Check the topics are actually acknowledged | | `src/mqtt/packet/connect_acknowledgement.rs:187` | Decode properties | | `src/mqtt/packet/disconnect.rs:103` | Decode more fields when needed | Of these only `publish.rs:158` has a memory-safety flavour and is worth checking for a panic path; the rest are protocol conformance polish against a broker you control. ### Modbus — `src/modbus/client.rs` | Where | Item | |---|---| | `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, reading holding registers is done; see P1 item 3, and reading input registers is done; see the sensor polling item. ### Configuration — `src/configuration.rs` `src/configuration.rs:23`, `:27`, `:31`, `:34` all say the same thing: make the Wi-Fi SSID, Wi-Fi password, broker address, and broker port configurable at runtime instead of baked in by `build.rs`. This is the same underlying work as the aspirational web-configuration item in `README.md`, and it is the largest single change on this list — there is no runtime configuration or persistent storage in the firmware at all today. ### Remaining items from `README.md` Not already covered above: - Retry if there was an error writing to one or two fans - 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 - 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 Already done: debounce the button tap; flash the LEDs for a second after boot.