skip to content
BitsAndBytes
Table of Contents

In the last post I built a self-driving ventilation loop off four IKEA VINDSTYRKA sensors, and then spent the closing section explaining an automation I deleted before building it: summer free-cooling, where you crank the MVHR fan on a cool night to flush the day’s heat out. It died on one sentence — heat rises, my attic is a permanent hotspot, and you can’t cool one hot room by ventilating the four cooler ones. Worst-floor-wins is right for air quality and wrong for temperature.

This post is the other half of that argument. The attic is a permanent hotspot — so it gets a permanent, dedicated machine: an airco, sitting in the one room that needs it, driven off the one sensor in that room. Where ventilation takes the max of four sensors to drive one fan, cooling takes one sensor to drive one unit. Same hardware on the sensing side, opposite topology, and both correct for the same underlying reason — you ventilate the whole house but you cool a single room.

flowchart LR
subgraph V["Ventilation: one fan, whole house"]
direction TB
S0[Floor 0] --> MAX{max}
S1[Floor 1] --> MAX
S2[Kids' room] --> MAX
S3[Attic] --> MAX
MAX --> FAN[fan.wtw_ventilatie]
end
subgraph C["Cooling: one machine, one room"]
direction TB
A3[Attic VINDSTYRKA] --> AC[airco_slaapkamer]
end

Like the rest of this series, it was built by talking to Claude Code over the Home Assistant MCP server: “cool the attic at night when it’s too warm, off when it’s comfortable.” But before writing a line of automation, the first thing I asked it to do was look at the data — and the data is where all the interesting decisions came from.

The data said something I didn’t expect

The intuition was “the attic gets hot during the day.” A week of recorder statistics on sensor.3_slaapkamer_vindstyrka_temperature said something more specific and more useful: the attic doesn’t cool down at night. It sits at 22–23°C essentially around the clock — long, flat, unbroken overnight stretches at 23°C — while the kids’ room a floor down holds a steadier, cooler 21–22°C and the rest of the house drops off after dark.

That reframes the problem. It isn’t “shave the afternoon peak”; it’s “the room has too much thermal mass and never sheds it, so it’s still 23°C at 3am.” Night air doesn’t rescue it — which is exactly why the free-cooling automation would have failed, and exactly why a dedicated cooler earns its keep. The discomfort is overnight heat retention, so the automation only needs to run overnight.

Gotcha: this is mild-June data. Daily max only crept from 21°C to 24°C over the month — the genuinely hot 30°C-outside nights aren’t in the history yet. Every threshold below is set against a temperate baseline and flagged for revalidation once a real heatwave lands. Calibrating against history is the right instinct; just know which history you’re holding.

The sensor only speaks in whole degrees

Here’s the detail that shaped the control logic more than anything else: the VINDSTYRKA reports temperature in integer degrees. No decimals. It’s 21, then 22, then 23 — there is no 21.5.

That single fact kills any idea of tight hysteresis. You cannot start at 21.5 and stop at 21.0 when the sensor can’t tell those apart; you’d get chatter, the relay-clack of a thermostat with no deadband. With a 1°C-resolution sensor your start and stop thresholds need to be at least 2°C apart, full stop.

There was a second, subtler tell in the history. Most of the cooling events showed the room dropping to a sensible 19–20°C — but a few brief dips went all the way to 15°C, far colder than the unit was ever asked to reach. That smells like the sensor catching the airco’s cold airflow directly rather than reading true room temperature. Worth knowing before you trust it as a cutoff: a sensor sitting in the supply stream will read low and shut the unit off too early. (Mine is close enough to matter but not enough to break the logic; if yours dips hard, move it off the airflow path.)

So the design leans on a wide band and lets the unit’s own thermostat do the fine control:

  • Start cooling when the attic reads above 21°C (so, at 22).
  • Hold at a 20°C target — set on the unit itself, not chased by Home Assistant.
  • Hard cutoff if the room ever reads below 19°C, as a backstop against that cold-airflow overshoot.

Start at 22, hold at 20, panic-stop at 19: a 2–3°C spread that the coarse sensor can actually resolve, with the MHI’s internal thermostat cycling the compressor in between. Home Assistant decides when the unit may run; the unit decides how hard.

The control loop

Only run overnight, only when it’s actually too warm, and stop cleanly in the morning. As a state machine that’s almost trivially small — which is the point:

stateDiagram-v2
[*] --> Off
Off --> Cooling: 22:00–08:00 AND attic > 21°C
Cooling --> Off: attic < 19°C OR clock hits 08:00
Cooling --> Cooling: unit holds 20°C itself

In Home Assistant that’s a single automation with four native triggers and a two-branch choose — no templates anywhere in the logic. The triggers are the two threshold crossings plus the two window edges:

trigger:
- platform: numeric_state # too warm
entity_id: sensor.3_slaapkamer_vindstyrka_temperature
above: 21
- platform: numeric_state # cold-overshoot backstop
entity_id: sensor.3_slaapkamer_vindstyrka_temperature
below: 19
- platform: time # window opens
at: "22:00:00"
- platform: time # window closes
at: "08:00:00"

And the action decides which way to move. The OFF branch fires when the room is cold enough or we’ve fallen outside the sleep window — and only if the unit is currently running, so it never sends a redundant command. The ON branch is the mirror: inside the window, too warm, not already cooling.

action:
- choose:
- alias: Off — outside the window, or cold enough
conditions:
- condition: or
conditions:
- {condition: numeric_state, entity_id: sensor.3_slaapkamer_vindstyrka_temperature, below: 19}
- condition: not # i.e. NOT inside 22:00–08:00
conditions:
- {condition: time, after: "22:00:00", before: "08:00:00"}
- condition: not # don't re-send if already off
conditions:
- {condition: state, entity_id: climate.airco_slaapkamer_mhi_air_conditioner, state: "off"}
sequence:
- service: climate.set_hvac_mode
target: {entity_id: climate.airco_slaapkamer_mhi_air_conditioner}
data: {hvac_mode: "off"}
- alias: On — in the window and too warm
conditions:
- {condition: time, after: "22:00:00", before: "08:00:00"}
- {condition: numeric_state, entity_id: sensor.3_slaapkamer_vindstyrka_temperature, above: 21}
- condition: not # don't re-send if already cooling
conditions:
- {condition: state, entity_id: climate.airco_slaapkamer_mhi_air_conditioner, state: "cool"}
sequence:
- service: climate.set_hvac_mode
target: {entity_id: climate.airco_slaapkamer_mhi_air_conditioner}
data: {hvac_mode: "cool"}

Two things worth pointing out. The condition: time with after later than before handles the midnight crossing natively — Home Assistant reads 22:00 → 08:00 as the overnight window without any date arithmetic, and wrapping it in condition: not gives you “outside the window” for free. And every branch is guarded by a not state check so the automation only ever sends a command when something actually needs to change. That guard isn’t just tidiness — as we’re about to see, every command has a cost you can hear.

There was also an old automation in the way: a price-driven one that cooled the attic to 18°C whenever electricity was cheap, regardless of whether anyone was asleep up there. It’s comfort versus cost, and for a bedroom at night comfort wins — so I left it disabled rather than letting two automations fight over the same climate entity. Worth checking for, when you add a new controller for something that already had one.

The unit beeps at everything you tell it

First night it ran, the feedback was immediate and analog: the indoor unit beeps when it starts, and lights a run-lamp. In a dark bedroom at the moment you’re falling asleep, both are exactly as welcome as they sound.

The reflex is “find the switch in Home Assistant and turn it off.” There is no switch. The airco runs the MHI-AC-Ctrl firmware on an ESP — a brilliant little project that taps the indoor unit’s control board over SPI and re-exposes it through ESPHome — and I enumerated every one of the 37 entities it surfaces. There is no buzzer entity and no lamp entity, not even a hidden one. The beep is the indoor unit’s hardware acknowledgement of each command frame it receives; the lamp is its operation indicator. Neither is in the protocol, so neither is something software can mute.

But “you can’t silence it” isn’t the same as “you can’t improve it,” and the fix falls out of how the beep works. One command frame, one beep. My first cut of the automation sent three commands every time it started cooling — set mode, set temperature, set fan — so it beeped three times in quick succession, which is precisely the burst most likely to wake you.

The key realisation is that the MHI controller remembers its target temperature and fan setting even while it’s off. So those don’t belong in the nightly automation at all. I set them once, as a persistent baseline —

climate.set_temperature airco_slaapkamer → 20°C # held even while off
climate.set_fan_mode airco_slaapkamer → quiet

— and stripped the per-night actions down to the single set_hvac_mode command that actually does the work. Three beeps became one. The unit resumes at 20°C and quiet because that’s what it still remembers from the day I set it.

Gotcha: the cost of that minimalism is a small assumption — if someone changes the temperature or fan with the physical IR remote, the baseline drifts and the next night resumes at whatever they left it. For a bedroom unit nobody fiddles with, that’s a fine trade for halving-and-then-some the beeps. If it were a problem you’d add a guarded check that only corrects a drifted setting — at the price of an occasional extra beep.

And the lamp? That one really is hardware with no software seam. The honest, century-old fix: a small piece of opaque tape over the LED. Not every problem in a smart home is solved in YAML, and it’s worth keeping a roll of electrical tape in the same mental toolbox as the MCP server.

What I’d take away from this

  • Look at the data before you design. I’d have built a daytime peak-shaver; the history said the real problem was a room that never cools at night. The automation got simpler and more correct once the shape of the problem was actual rather than assumed.
  • Know your sensor’s resolution. A 1°C-quantised sensor forbids tight hysteresis — start and stop have to be ≥2°C apart, and the device’s own thermostat does the fine control. Reading decimals that aren’t there is how you build a chattering relay.
  • The opposite of a good rule can also be a good rule. Ventilation takes the max of every sensor; cooling takes a single one. Worst-floor-wins and one-room-one-machine look contradictory but come from the same fact — you ventilate a house and you cool a room.
  • Every command can have a physical cost. The beep turned “send the obvious three commands” into “send one and remember the rest.” Guarding actions with a not state check and pushing static settings into a persisted baseline is good practice anyway; here you could hear the difference.
  • Some fixes aren’t software. No buzzer entity, no lamp entity — and a strip of tape closes the last gap that Home Assistant never could.

The house can see the air and drive its own ventilation; now the one room that runs hot cools itself overnight, quietly, on a single sensor. The free-cooling automation I deleted last time wasn’t a dead end — it was the argument that pointed here.