A Spring Scorcher, a Powerwall, and a Spa That Only Heats on Spare Sunshine

I wrote a careful brief for this. Built as written, it would have quietly flattened my Powerwall any afternoon a cloud went over, and nothing in the house would have told me.

TL;DR

We have an inflatable spa in the backyard, solar panels on the roof and a Tesla Powerwall. The spa’s heater draws about what a kettle does, for hours at a time, and it used to run on a timer whether the sun was out or not.

Now the house heats it only with sunshine it would otherwise send to the grid for very little, and only once the Powerwall is full, because the Powerwall is what keeps our evenings cheap. When the sun fades, the heater stops. If the water’s still cold at half past three, it gets topped up anyway, so it’s usable that evening.

It went live on a hot spring afternoon, and so far it has proven one thing: it’s very good at turning the heater off. Whether it turns on when it should is waiting on the next sunny day.

If you’re here for the config, it’s at the bottom.

The spa that ran on a timer

The spa is a Bestway AirJet: the inflatable kind, a pump unit on the side, an app on the phone. The app has a timer, and the timer did what timers do: pump on at nine, off at four, every day, with the heater chasing its thermostat whenever the pump ran, regardless of whether the roof was making four kilowatts or none.

Which is daft, because a spa is about the best thermal battery a house can own. A thousand-odd litres of water hold heat for hours. It doesn’t care when it was heated, only that it’s warm by evening. It should soak up the solar the house can’t use and sit on it until someone gets in.

I’d known that for as long as we’ve had it, and never done anything about it, for the usual reasons. The integration is cloud-only and works through a reverse-engineered API. The newer Bestway pumps use a different backend from the older ones. The setup instructions warn that the right server region is “often not the obvious one”. Every step was guesswork, and there was always something more useful to do with an afternoon.

What finally moved it was the weather. Late September, a proper spring scorcher, the aircon going flat out, and the house pulling somewhere between nine and thirteen kilowatts, swinging minute to minute. On a day like that you start thinking hard about where your electrons go.

I’ve written about this house before: turning Home Assistant into a Grafana data platform, building the dashboard I’d abandoned three times, the plug-in alert that paid for the lot, and the MAGI panel on the cluster. Same collaborator, same principle: I supply direction, the agent supplies execution. This time I thought I’d supply the direction in advance, in writing. That’s where it went wrong.

The brief I wrote, and the house it forgot

I did the research in the morning in Claude’s ordinary chat app, and came out with a tidy brief and a draft Home Assistant package. It’s the same Claude I build everything else with. What it didn’t have was the context. The homelab project lives in Claude Code: the repo of runbooks and notes about every device in the house, the memory of what we’ve built and what bit us, and live access to Home Assistant itself. The chat had my description of the house and nothing else. The rules it helped me write were simple:

  • Heat on when the house has been exporting more than 2,400 W for five minutes, or when the price for exporting goes negative.
  • Heat off when the house has been importing more than 300 W for five minutes, once the heater has run at least twenty minutes.
  • A comfort floor at 15:30: if the water’s under temperature, heat it regardless.

It had a task list, a section on known gaps, and a house rule at the bottom: verify against the live system. I was quite pleased with it.

Then I handed the brief to Claude Code, which has all that context and can see the house. The first task in the brief was “discover current state, read-only first”, and it did exactly that before touching anything. Among the integrations it listed was the one the brief never mentioned: Tessie, driving a Tesla Powerwall.

I haven’t forgotten we have a Tesla Powerwall; it had a starring role in the car alert piece. I just didn’t mention it in the chat, so the chat couldn’t know. And what I’d forgotten myself was what it does to a rule that watches the grid.

On a sunny afternoon spare solar goes into the battery first, so the house exports only once the battery’s full. So far, so harmless: the heat-on rule would simply wait. The problem is the other end. When a cloud comes over while the spa is heating, the house doesn’t start importing. The Powerwall covers the shortfall, instantly, silently, which is the entire point of a Powerwall. Grid import stays near zero. The stop rule, watching for 300 W of import, never fires.

The spa would keep heating, the battery would keep emptying, and every reading in the brief would say everything was fine. That evening the house would buy back at full price the power I’d poured into the spa at lunchtime.

The redesign took one conversation. The spa doesn’t get “exported power”; it gets spare power, defined so the battery comes first:

  • Export counts as spare.
  • Battery charging counts as spare only once the battery is at 95 %. Below that, the battery gets it.
  • Battery discharging always counts as a shortfall. The spa never drains the Powerwall.

The part I find properly embarrassing: I’d already made this argument. In the plug-in alert piece I wrote that charging the car from solar while the battery sits at 60 % “is just moving the problem”. Then I sat down and specified a spa heater that did precisely that, because I’d researched it in a conversation that knew only what I remembered to tell it, and on that morning, I didn’t remember the battery.

Lesson: A brief written without access to the system is a hypothesis about the system. Its first task should be finding out what it got wrong, and this one’s did.

Two meters, one lie

The house has two ways of measuring power. An Enphase Envoy on the switchboard, read locally, and the Powerwall’s own figures, read from Tesla’s cloud through Tessie. The first cut of the spare-power sensor used both: grid from the Envoy, battery from Tessie. It seemed sensible. The Envoy is local and fast; why go to the cloud for something you can read on the LAN?

The first live reading came back at −8,721 W. On a moment when the true figure was a few hundred.

Nothing was broken. Both numbers were correct, just not at the same instant. They sample at different moments, and in a house whose load was lurching several kilowatts a minute that afternoon, one meter was describing a different second from the other. Subtract one from the other and you get a number that describes no moment at all.

The fix was to take all three inputs, grid, battery and charge level, from the same Tessie poll, where solar + battery + grid adds up to the house load exactly. Before trusting it, the agent checked that balance at nine points across the previous twenty-four hours, which is how the sign conventions got pinned down too: grid is positive when importing, battery is positive when discharging. It didn’t assume either. They’re exactly the kind of thing that’s the other way round on someone else’s integration.

Lesson: Never compute a difference across two sources that sample at different moments. Take every term of a balance from the same reading, or the balance is fiction.

The same instinct settled a question the brief had left open: which way round does the Amber feed-in price go when exporting costs you? Rather than guess, the agent read Home Assistant’s own Amber source code and found it multiplies feed-in by −1. So “less than zero” means you’re paying to export. The draft had it right. Now it was right on purpose.

The ID that could read but not write

The Bestway integration went in cleanly, and the spa showed up in Home Assistant with its water temperature, its thermostat and its switches. Reads worked. So the agent tried a write: heater off, then on.

Error 10001. Both times, at 15:27 and 15:30, from Bestway’s cloud, through both versions of its command API. And after the writes, the reads went bad too: the version sensors went to unknown and the temperatures to nothing.

The setup had used the app’s own visitor ID. My phone’s app has never had an account: it’s a guest, and the integration will happily log in as that same guest. That turns out to be a known, open upstream issue, ha-bestway#121: when Home Assistant and the phone app share one identity, control breaks. The readings look fine until you try to change something, which is the only thing we were there to do.

The way out was a feature I’d assumed the new app had dropped. It still offers Share the device with a QR code, the route the older setup instructions describe. I screenshotted it, the agent decoded it locally, never printing it (it grants control of the spa), and re-added the spa with it. Home Assistant got its own guest identity. It shows up in the app as an extra “guest” user, which I’m now under strict instructions from myself never to tidy away.

Then came the test that counts. Setpoint from 33 °C to 34 °C in Home Assistant, and I read 34 off the app on my phone. Heat off, heat on, both reported back from the spa itself. No 10001s.

We couldn’t prove it from the power meter. A ~2 kW heater step is invisible when the house is swinging between nine and thirteen kilowatts because the aircon is fighting the afternoon. The app was the witness.

Lesson: An identity that can read isn’t one that can write. Prove control with a change you can see from the other side, not with the tool’s report that it sent the command.

There is no heater switch

The brief, the draft and the integration’s README all talked about a heater switch. On this pump, there isn’t one. The heater is the thermostat’s mode: heat or off. The draft was rewritten around that before anything was deployed.

At 15:55 the automations went in, with solar mode still off. At 16:00:06 the heater and the filter pump both switched off.

Nobody had touched them. Home Assistant’s logbook showed no automation, no user and no context, just the state changing. The timestamp was the clue: six seconds past the hour is what a schedule looks like, not a person. It was the Bestway app’s own filter timer, nine to four, still running. The heater can’t run without the pump, so when the timer stopped the pump at four, it stopped the heating as well, including, as it would turn out, any 15:30 comfort top-up still in progress.

A quick test at 16:04 settled the rest: switching heat on from Home Assistant starts the pump by itself. So the app timer went (I disabled it on my phone), and filtration moved into Home Assistant: same nine-to-four window, but the pump is never switched off while the thermostat is in heat mode. A run that finishes after four stops the pump a minute later.

One more thing from that minute: as the pump stopped, the reported water temperature jumped from 29 °C to 34 °C in sixty-five seconds. The water didn’t warm five degrees; the sensor lost its flow. Nothing now acts on the temperature straight after a pump change, and it’s on the trial list to watch.

The first cycle was a no

At 16:14 I turned solar mode on.

The spa was heating from the earlier test. The five-minute spare-power figure was about minus four kilowatts: the aircon was eating everything the roof could make and then some. The rule’s view was clear. There was no spare power, so there should be no spa.

It waited, because the heater had to have run for twenty minutes first. At 16:25:00, the first five-minute check after that, the heat-off rule fired and the spa went to idle. At 16:26:00, one minute later, the filter rule noticed it was after four with the heat off and stopped the pump.

Nobody touched anything. It was the first thing the system did on its own, and it was to say no, correctly, on exactly the kind of day that started all this.

Smaller traps, for the record

  • The Envoy reports kilowatts, not watts. The brief expected watts, so a 2,400 threshold would have needed 2,400 kW before it did anything. It’s moot now that the Envoy isn’t in the formula, but it’s the same units trap as the car alert, in the same house, again.
  • The server region was the non-obvious one, as warned. The brief said try Europe first; it was the US.
  • A restart resets the heater’s “on since” time. The brief worried that would cut a minimum run short. It can only lengthen one: the twenty-minute clock starts again. Relay protection holds.
  • A trigger that only fires on a crossing misses a value that’s already high. If there’s already plenty of spare power when solar mode is switched on, nothing crosses anything. Every rule re-checks every five minutes as well.
  • Hysteresis wider than the load. Start at +2,400 W, stop at −300 W: a 2.7 kW gap, more than the heater’s ~2 kW, so switching the heater on can’t by itself trip the stop rule.
  • Helpers created without initial:, which would reset every threshold on restart. The brief flagged that one itself, which is the most useful thing a brief can do.
  • The dashboard took three passes. A first cut; then a full-width two-by-two row of graphs; then three balanced columns, each graph sitting next to the thing it explains, so the whole story fits on one screen. Then I swapped two cards, because it’s my dashboard.

What it looks like now

  • Six automations and a script: heat on, heat off, the 15:30 floor, target sync, the filter schedule, and an alert if the spa drops off the cloud for half an hour, because a cloud-only integration that fails silently means a cold spa.
  • One number to change. The target temperature on the dashboard is the spa’s setpoint; change it and it’s pushed to the spa. 33 °C, with a 31 °C floor.
  • Every threshold on the dashboard, not in code: 2,400 W to start, 300 W to stop, battery first to 95 %.
  • A spa dashboard with the spa, the house’s power right now, the day’s graphs, and when each rule last fired.
  • An eleven-point trial checklist, each item with how to check it and what counts as a pass. Two items are ticked.

The honest part

The other pieces in this series each ended on what actually unblocked the thing. Delegation, for the dashboard. The earlier projects already existing, for the car alert. For the MAGI panel, a fictional interface that forced every number on screen to justify itself. This one lands somewhere less flattering.

I specified it wrong, and the most useful thing the agent did was not do what I said.

Not by overruling me: every call that mattered stayed mine. Battery first was mine. Heating regardless when the grid pays us to import was mine. Moving filtration off the app was mine. But the brief I wrote in the morning described a house without a battery, and it was confident, well-organised, and cleanly formatted. It’s worth being precise about why. The chat wasn’t a worse AI; it was the same one, with less context. Everything it knew about the house came from me in that one conversation. The Claude Code side had months of accumulated notes, the memory of every previous project in this house, and a live connection to the system. The pushback didn’t come from anything clever. It came from that context, and from reading the house before building on the brief, which is what the brief’s own first task said to do. I wrote “verify against the live system” at the bottom of a document that hadn’t.

I think that’s the shift, and it’s a bit uncomfortable. The bottleneck used to be execution, and the series has been about that bottleneck going away. With execution cheap, the weak link moves upstream to the direction itself, to the brief written at a distance, from memory, about a system that’s changed since you last thought about it, and to wherever the context lives. The same model gave me a confident wrong design in one window and caught it in the other. An agent that does exactly what the brief says is dangerous in proportion to how good the brief looks.

And the other honest thing: it hasn’t done its job yet. What’s proven is the stop side, the target sync, and control. The thing the whole project is for, heating on a sunny afternoon from sunshine the battery didn’t need, hasn’t happened. The first time it ran, the right answer was no. I’ll know it works when a mild, sunny day comes along with the battery full by lunch, and the checklist gets its third tick.

Until then I have a spa that’s very good at declining to heat, and a brief I’ve kept as a reminder.

Write the brief. Then let something that can see the house tell you what’s wrong with it.

Under the hood: if you want to build this

What it runs on

  • Home Assistant 2026.9.3: runs everything; helpers and automations are UI/storage, not YAML packages.
  • ha-bestway v1.11.1, via HACS: the Bestway cloud integration; AirJet on the V02 (AWS IoT) backend. Cloud-only.
  • Tessie: Powerwall grid, battery and charge readings, all three from one poll.
  • Amber Electric: import and feed-in prices, for the negative-price rules.
  • Statistics + Template: the spare-power sensor and its 5-minute mean.
  • ntfy: the “spa’s gone offline” push.
  • Enphase Envoy: deliberately not used in the formula (see Gotchas).

Load-bearing upstream issue: ha-bestway#121. Sharing the app’s visitor ID breaks control with error 10001.

Decisions

  • Battery first. Charging counts as spare only at ≥ 95 %; discharging always counts as a shortfall. Otherwise the spa quietly drains the battery that covers your evening.
  • Stop on “spare power”, not on grid import. With a home battery, import barely moves when solar dips; the battery absorbs it.
  • Negative import price → heat regardless, and heat-off is suppressed. You’re being paid to use power.
  • Negative feed-in on its own only lowers the bar to start, with the battery full and the sun up. Solar is probably being curtailed, so the real surplus doesn’t show in any sensor. Heat-off still catches a deficit after 20 minutes, and a failed attempt costs about 0.7 kWh from the battery, which then drops below 95 % and blocks the next try.
  • Min run 20 min, min off 10 min; start +2,400 W, stop −300 W. The 2.7 kW gap is bigger than the ~2 kW heater, so switching the heater on can’t trip the stop rule.
  • Fixed setpoint, toggle the heat mode, and only write the setpoint when it differs. Fewer cloud writes.
  • Filtration in Home Assistant, not the app. The app’s timer stops the pump on schedule, and the pump stopping stops the heating.
  • ha-bestway over New_bestway_spa (kept as the fallback) and over the ESP8266 local-control mod, which has no documented support for 2024+ pumps and voids the warranty. The price is cloud dependence, hence the offline alert.

Gotchas

  • Add the spa with the app’s Share the device QR, not the app’s own user ID: reads work, every write fails with 10001. The QR gives HA its own guest user. Don’t remove it in the app.
  • There’s no heater switch on the AirJet V02. The heater is the climate entity’s hvac_mode.
  • Disable the app’s timers. An on-the-hour off with no Home Assistant context in the logbook is an app schedule.
  • Don’t mix a local meter with a cloud battery reading: sampling skew gave −8,721 W.
  • Check signs and units live. Tessie: grid + import, battery + discharging, in kW. HA’s Amber integration flips feed-in, so < 0 means exporting costs money.
  • The water temperature jumps when the pump stops (29 → 34 °C in 65 s). Don’t act on it straight after a pump change.
  • A house load swinging 9–13 kW hides a 2 kW step. Prove control in the app.

The spare-power sensor

The core of it. Mine is a UI template helper; this is the YAML form, written from the same formula rather than exported, so treat it as reviewed rather than run:

state: >
  {% set k = 1000 %}  {# 1000 if your sensors report kW, 1 for W #}
  {% set grid = states('__GRID_POWER__') | float * k %}
  {% set batt = states('__BATTERY_POWER__') | float * k %}
  {% set charging = [0 - batt, 0] | max %}
  {% set discharging = [batt, 0] | max %}
  {% set full_enough = states('__BATTERY_SOC__') | float
                       >= states('input_number.spa_battery_soc_min') | float(95) %}
  {{ (0 - grid + (charging if full_enough else 0) - discharging) | round(0) }}

Positive is spare, negative is shortfall. A statistics helper takes its 5-minute mean, and the rules trigger on the mean, never the raw value.

Take it with you

The full set is on GitHub at angusmaul/homelab-recipes: the helpers, the six automations and the script, and a deploy script that refuses to run with anything unsubstituted or missing, then shows a diff before it writes. There’s a README written for people and for AI agents, and it’s MIT licensed.

What to substitute: the spa’s climate entity and filter switch, your battery’s grid/battery/charge sensors, your Amber price sensors (or drop those two rules), and a notifier. Proven live so far: control, heat-off on a real deficit, the filter following it, and target sync. Not yet: heat-on from spare solar, the negative-price hold, the 15:30 floor, and a restart.