On a sunny afternoon my roof makes more power than the house can swallow. The battery fills, the inverter would rather give the rest to the grid for almost nothing, and meanwhile there's a 200-litre tank of cold water sitting in the cellar. The obvious move is to dump that surplus into the immersion heater and bank it as hot water. The device that does it is a my-PV AC·THOR 9s — a resistive PV-surplus diverter that modulates power into the boiler element instead of letting it spill to the grid. This post is the story of getting it under Home Assistant control, and the wall I hit doing it.
It's Part 03 of how I'm self-hosting my house. Part 01 put Home Assistant on a Docker host, Part 02 added HACS, and the inverter side — a Huawei SUN2000-8KTL-M1 read over Modbus TCP through a Huawei SDongle, plus a battery whose charge and discharge I track — was already prior art. Surplus, for the AC·THOR, is simply what's left after house load and battery charging. So the hard part wasn't the energy logic. It was the control plane.
The plan that should have worked
The AC·THOR speaks Modbus TCP, and my whole energy stack already lives on Modbus, so the design wrote itself. Two registers do everything: register 5000 is heating enable/disable (0 or 1), and register 5006 is the power limit in watts. The device has two useful modes — an Eco setting at 1500 W for pure-solar heating, and Full Power at 3000 W, which is also its hardware maximum. You can switch between them live while it's heating.
In Home Assistant I modelled that as a small control surface: an input_select for the power mode, an input_boolean for the heating-active flag, and a dashboard card — AC·THOR Steuerung — with plain Start and Stop buttons. Behind the buttons, a couple of mbpoll-style writes: poke 5000 to enable, set 5006 to 1500 or 3000. I tested the reads first to make sure I had the right slave and port. Boiler temperature came back clean on holding register 1001 (int16, in °C), polled every 30 seconds. Everything looked ready.
The wall: writes refused, reads fine
Then I sent the first write, and the AC·THOR slammed the door. Connection refused — not a timeout, not a bad-value error, a flat refusal at the device level. I checked the slave id, the register, the byte order, the function code. None of it mattered. The thing simply does not accept Modbus write operations. What made it genuinely confusing is that the very same connection happily answers reads: register 1001 kept returning boiler temperature every 30 seconds without complaint, on the same host and port, while every write bounced.
I'll be honest, that stung. I'd built the whole control path on the assumption that a device exposing Modbus exposes it both ways. It doesn't here, and that's a deliberate choice on my-PV's side — the registers are real, they're just read-only over local Modbus. So the switch-based, all-local design I was proud of was dead for control. The boiler-temperature monitoring, though, survives untouched, because that was only ever a read.
# Modbus read still works — boiler temp stays local; only WRITES are refused
- name: ac_thor
type: tcp
host: <LOCAL_IP> # redacted — never publish your LAN address
port: 502
delay: 1
timeout: 5
sensors:
- name: "Boiler Temperature"
slave: 1
address: 1001
input_type: holding
data_type: int16
unit_of_measurement: "°C"
scan_interval: 30The pivot: read local, write cloud
The path that actually worked is the my-PV Cloud API. Every AC·THOR phones home, and that cloud exposes the same configuration the local Modbus writes would have touched: a GET /data for live monitoring and a GET/PUT /setup to read and change config. So the split I shipped is read-local, write-cloud — boiler temperature stays on local Modbus (register 1001, every 30 s, no internet dependency), and all control goes out through the cloud.
I resent it a little. Depending on a vendor cloud to turn my own immersion heater on is exactly the kind of dependency I self-host to avoid, and if their API goes down my morning shower is at the mercy of someone else's uptime. But it's the split that actually works today, and it unlocks something the local-only design never could: proper time-aware behaviour.
There's one gotcha worth a sentence. A PUT to /setup returns ok instantly, but the device doesn't apply it for another 4 to 6 seconds. If you immediately GET /setup back to confirm, you'll read the old values and think your write was ignored. It wasn't — you just have to wait, then poll.
What the cloud control actually buys: free heat by day, a warm tank by morning
The interesting part isn't on/off — it's two temperature targets and a time window. The AC·THOR carries a max solar target (ww1target) and a lower boost target (ww1boost). By day I let surplus push the tank all the way to a high solar cap — 65 °C — because that heat is free; every degree the sun banks is a degree I don't buy at night. Temperatures are encoded in tenths of a degree, so 650 means 65.0 °C and 400 means 40.0 °C, which trips you up exactly once.
The morning is handled by the cloud's assurance mode — Sicherstellungsmodus, parameter bstmode. I set a boost window from 01:00 to 08:00 (bstton1=1, bsttof1=8) with a modest 40 °C needed-target, so the tank is guaranteed warm by the time anyone showers, regardless of whether the night was sunny. A second optional window exists if you want it. Daytime stays solar-only up to the high cap; the night just guarantees a floor.
# Local Modbus writes are refused — drive config via the my-PV Cloud API instead.
# Set a 40°C morning-boost window 01:00–08:00 (values are tenths of a degree: 400 = 40.0°C)
curl -X PUT "https://api.my-pv.com/api/v1/device/<SERIAL>/setup" \
-H "Authorization: <REDACTED_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"bstmode": 1, "ww1boost": 400, "bstton1": 1, "bsttof1": 8}'
# API replies "ok" instantly; the device applies it ~4–6 s later, so poll GET /setup to confirm.Seeing the split that justifies the whole thing
The reason any of this is worth the trouble shows up in two sensors: solar heating power and grid heating power, reported separately. The whole point of surplus diversion is that the first number is large and the second is zero — heat from sun I'd otherwise have exported, not from the meter. Alongside those I watch the current power limit and the boiler temperature, and the inverter's own Modbus reads give PV yield, grid export and import, and the battery's daily charge and discharge. With all of that in one place I can actually watch surplus turn into hot water in real time.
And one quiet safety note that made me trust the setup: whatever power or target I command, the AC·THOR enforces its own internal thermal and safety limits anyway. Software requests, hardware protects. That separation is exactly what you want for a 3 kW element in a water tank — my automation can be wrong without being dangerous.
Where this fits
The hot-water diverter is one slice of a whole-home energy setup, not a toy. The same Home Assistant install runs per-room radiator thermostats that drop to a setback preset when a window contact opens, and a scheduled house-wide Nachtmodus at 21:00 with Tagmodus back at 05:00 across a dozen-ish zones. The hot water just happens to be the one corner where the obvious local path was a dead end and I had to swallow a cloud dependency to ship.
If I had to compress the lesson: a device speaking a protocol doesn't promise it speaks every verb of it. Read worked, write didn't, and the fix wasn't to fight the device — it was to keep the cheap, local, no-internet read where it belonged and move only the writes to the one transport the vendor actually allows. Not the architecture I wanted. The one that put hot water in the tank by morning.



