Table of Contents
- The plan that should have worked
- The wall: writes refused, reads fine
- The pivot: read local, write cloud
- What the cloud control actually buys: free heat by day, a warm tank by morning
- Seeing the split that justifies the whole thing
- Where this fits
- Stepless, not on/off — that is the whole point
- Three ways to drive a heating element from PV surplus
- The automation behind it
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.
Stepless, not on/off — that is the whole point
If you take one thing from this post, take this: an immersion heater driven by a relay is a bad match for solar surplus, and no amount of automation fixes it. A relay has two positions. Your surplus does not.
Say the element is rated 3 kW. Surplus at four in the afternoon is 1.4 kW and falling. Switch the relay on and you pull the missing 1.6 kW out of the grid — you are now heating water with bought electricity while telling yourself it is free. Leave it off and 1.4 kW goes to the grid for whatever the feed-in tariff pays, which around here is not much.
Stepless control is the fix. A power controller chops each mains half-wave by phase angle, so the element can draw any value between roughly zero and its rating and follow the surplus curve continuously. That is the single reason a purpose-built device like the AC·THOR exists rather than a smart plug.
And the part that surprised me: with a device like this, Home Assistant does not compute the surplus at all. The AC·THOR measures at the meter itself and modulates on its own. HA sets targets and reads state. The control loop lives in the hardware — which is exactly what I was paying for and did not realise until I tried to build the alternative on paper.
Three ways to drive a heating element from PV surplus
There are really only three, and they differ far more in attention required than in hardware cost.
1 — Relay, on/off. A Shelly plus a contactor, switched by a Home Assistant automation on a surplus threshold. Cheapest by a wide margin. It works acceptably if your tank is large and your surplus is stable, because a big tank forgives coarse control. You accept either some grid draw or some wasted surplus at every edge.
2 — Staged. Two or three elements, or a multi-tap element, switched in steps — 1 kW, 2 kW, 3 kW. Coarse modulation using relays only. More wiring, no electronics, and it lands closer to the surplus than a single relay ever will.
3 — Stepless power controller. Phase-angle or PWM control that follows the surplus continuously. Either a purpose-built diverter or an SSR-based controller you build yourself. Most expensive in hardware, cheapest in attention.
I went route 3, and the honest reason is not efficiency — it is that route 1 makes Home Assistant the controller. You own the surplus calculation, the hysteresis, the anti-chatter delays, and every failure mode: what happens when the meter integration drops out, when a template sensor returns unknown at restart, when a cloud passes for ninety seconds. None of that is hard. All of it is yours to keep working.
Had I gone the cheap route, this is roughly the shape it takes. Note the two thresholds — the off-threshold sits deliberately below the on-threshold, because a single threshold plus a fluctuating input equals a relay that cycles itself to death:
# The surplus signal, if YOU own the loop instead of the device.
# Grid power is the honest input: negative = exporting, positive = importing.
template:
- sensor:
- name: "PV surplus"
unit_of_measurement: "W"
state: >
{{ [0, (states('sensor.grid_power') | float(0)) * -1] | max | round(0) }}
automation:
- alias: "Immersion heater on surplus"
triggers:
- trigger: numeric_state
entity_id: sensor.pv_surplus
above: 2200 # element rating + headroom, not the bare rating
for: "00:05:00" # ride out clouds instead of chattering the relay
conditions:
- condition: numeric_state
entity_id: sensor.boiler_temperature
below: 55
actions:
- action: switch.turn_on
target: { entity_id: switch.immersion_heater }
mode: single
- alias: "Immersion heater off when surplus collapses"
triggers:
- trigger: numeric_state
entity_id: sensor.pv_surplus
below: 1800 # deliberately lower than the on-threshold = hysteresis
for: "00:03:00"
actions:
- action: switch.turn_off
target: { entity_id: switch.immersion_heater }
mode: singleThe for: durations matter more than the thresholds. Without them a passing cloud toggles a contactor rated for a few hundred thousand operations, several times a minute. That is the failure mode nobody warns you about, and it is mechanical rather than logical, so no amount of testing in summer reveals it.
One thing all three routes share: use grid power as the input, not PV production. Production tells you what the roof makes; grid power tells you what the house could not swallow. Those are different numbers, and only the second one is surplus. A Shelly with energy metering gives you that reading locally without touching the inverter — and the same device can be the relay in route 1, which is a tidy coincidence. I wrote up wiring one into Home Assistant for local switching and metering separately.
The automation behind it
Once the boiler hits 55 °C I switch the AC·THOR boost back off automatically — with a push confirmation to my phone. This is the exact automation I run:
# Stop the boost as soon as the boiler hits the 55 °C target
alias: "AC THOR: stop boost at 55 °C"
triggers:
- trigger: numeric_state
entity_id: sensor.boiler_temperature # your own sensor name
above: 55
for:
minutes: 5
conditions: []
actions:
- action: shell_command.acthor_disable_boost
- action: notify.mobile_app # generic — no device/person name
data:
title: "✅ AC·THOR target reached"
message: "Boost off at {{ states('sensor.boiler_temperature') }} °C"
mode: single
My PV / boiler dashboard in Home Assistant — live PV power, battery SOC and boiler temperature at a glance.
The AC·THOR itself has its own post, too — why the local Modbus writes got refused and how the my-PV Cloud API ended up running the show, if you want the control-plane side of this same device.



