Charging the Car on Sunshine

The homelab series so far have been about infrastructure — servers, identity, certificates. This starts a new one, Automating the House, about what all that infrastructure actually does all day: Home Assistant running the physical house. First up: teaching the car to drink sunshine, and the three control-loop bugs that lesson produced — the third one hiding inside the fix for the second. They weren’t the first bugs, only the first ones the recorder caught properly: the loop was two days old and eight revisions deep before the story even starts.

The setup

The car — a Volvo C40 Recharge named tanngrisnir — charges on a Wallbox Pulsar Plus1 called xolotl, which Home Assistant now controls locally over OCPP2. Battery, range, and climate data come from the car itself via volvo2mqtt3. Presence — whether the car is home, which most of the automation’s branches gate on — comes from a separate source: the device_tracker entity created by the Home Assistant companion app, which runs natively on the car via Android Automotive4 — not projected from a phone.

Being able to do this is not a given. Volvo publishes an actual developer API5, and the car runs Android Automotive with the Play Store on board, so the Home Assistant companion app installs on the dash like any other app6. It’s a large part of why this project is possible at all, and honestly a real factor in wanting to stay a Volvo household.

Wallbox deserves the same credit on the charger side. Every amperage change, every start/stop, every status read in this whole post happens because Wallbox exposes real control surfaces — a documented API, and hardware that speaks OCPP so it can be run entirely on the local network with zero cloud dependency once you bother to set it up2. A charger that only worked through the vendor’s app and cloud would have made this entire project a non-starter.

I can’t do any of this with my wife’s Ford Mustang Mach-E7. A community integration used to work by talking to Ford’s private API; Ford changed that API, the integration broke, and there’s no open one to rebuild on. The Mach-E’s infotainment doesn’t take third-party apps either — if someone has found a way in, I haven’t come across it.

So the Mach-E gets the other charger and a slider. Its entire automation is one rule with one action: mirror a number I set by hand onto the charger’s amperage. No target, no solar gating, no pacing — with no visible battery there’s nothing to gate on.

It isn’t invisible, though. It draws through the same house meter the C40’s logic reads, so when the Mach-E starts charging the export shrinks and the C40 throttles itself down without ever learning why. The two chargers also share a circuit, with Wallbox’s own load management arbitrating between them below anything Home Assistant can see.

The inference only runs one way, and that costs something. Nothing coordinates the car, the charger, and the battery: when the Mach-E asks for power outside the wallbox’s own 5–9 p.m. block and the Powerwall happens to be in a discharging mode, the Powerwall covers the car. The order of preference I set out above puts the house first and the battery second, and the C40’s automation enforces exactly that — it won’t boost at all unless the Powerwall reads 100%. The Mach-E has no way to be told. It draws, and the outage insurance quietly pays for it. The closed car isn’t outside the system; it’s a load the system can only infer, and occasionally subsidize.

The goal

The house has solar on the roof and a Tesla Powerwall. On a good summer day the panels fill the battery by late morning and the house starts exporting. My preference order for every marginal solar watt is: house first, Powerwall second, the car third, and only then the grid.

A full Powerwall is outage and evening insurance I want to keep topped up before anything else gets a share. Below that, charging the car straight off solar surplus just seems like the more grid-friendly thing to do when there’s power to spare — pulling less, and dumping less, feels like the right default even before it’s worth anything. I was also eyeing a time-of-use rate, which would only pay off by keeping 5–9 p.m. usage low — but I haven’t confirmed I can actually combine that with net metering, or whether Maine has something EV-specific instead, so take that one as a maybe, not a plan.

None of this is about today’s export credit, though — Maine’s net metering is a straight 1-for-1 kWh swap8, so a kilowatt-hour sent to the grid at noon and one pulled back at 9 p.m. cancel out no matter when either happens. Whatever savings this automation chases are speculative and down the road, if they exist at all. What it gets me today is simpler: the car runs on sunshine instead of grid power whenever there’s sunshine to spare, which by itself feels like a better way to treat the grid.

So the rule I actually want is: charge to 60% at full speed no matter what, and above that, keep charging only while the house has surplus solar to spare — throttled so the car consumes exactly the export, never pulling extra from the grid and never draining the Powerwall to do it.

That rule lives in one automation, c40 Smart Charge Control. It replaced an earlier hand-written automation I’d built directly in the UI. Partway through updating it with Claude, we adopted a git-first workflow for anything of this size — drafted and reviewed as JSON in the repo (by me and Claude both), then pushed to the live instance, with drift checked by diffing live against the repo. Everything from that point on, including the bugs in this post, went through that discipline. That discipline is about to earn its keep twice.

How it’s supposed to work

The automation treats the charge target as a ceiling, not a set-point — hitting it stops charging, but reverting a ceiling never discharges the car.

  • Below the 60% daily target: charge at the full 48 A, no conditions (beyond the charger’s own 5–9 p.m. utility-window block, which the automation defers to rather than fights).
  • At or above 60%: charging stops — unless the surplus gate is open: Powerwall at 100%, house net-exporting, solar-boost enabled, car home, no imminent departure. While the gate holds, the ceiling rises to 80% and the amperage is recomputed from the Powerwall’s net meter: whatever the car is already drawing, plus the export, clamped to 6–48 A.
  • The moment the gate fails, the ceiling drops back to 60% and charging above it stops. Solar-only charging has no standing to continue once the surplus that justified it is gone.

The net meter, sensor.powerwall_site_now, reports the house’s grid exchange about every 30 seconds — negative numbers are export. Hold that 30-second cadence in mind; two of the three bugs live in it.

Before day one

It’s worth being clear about how new all of this was, because it explains what follows. The automation I’d built in the UI had six branches, and none of them mentioned solar. It restored full amps when a session finished, stopped at the target, paced a charge to a departure time, dropped to 6 A when the car arrived home, and topped the battery up when the cabin preheat came on. Reasonable house bookkeeping. Home Assistant never looked at the power meter at all — so there was no surplus gate, no throttle, no amperage arithmetic, and nothing that could shadow a branch or thrash a contactor.

All of that arrived at once, and not smoothly. The first version’s approach to solar wasn’t arithmetic at all: it flipped the charger’s own solar_charging select to full_solar and left the hardware to do the thinking.

That wasn’t a guess. Claude asked to see what the select actually supported before designing anything, and Home Assistant answered:

[<EcoSmartMode.OFF: 'off'>, <EcoSmartMode.ECO_MODE: 'eco_mode'>,
 <EcoSmartMode.FULL_SOLAR: 'full_solar'>]

Both chargers, same answer. full_solar was right there in the list, so that’s what the design used — and the design summary I approved said so in as many words: “raises the target ceiling to c40_solar_boost_target (80) and flips the wallbox to full_solar mode.”

That mode needs Wallbox’s EcoSmart option, which xolotl doesn’t have. The integration hardcodes the enum for every charger it talks to — the entity’s internal id is ecosmart-646222 whether or not there’s EcoSmart hardware behind it. So the capability check passed, the write succeeded, and nothing happened. Meanwhile the departure-pacing branch, firing on the same trigger, set 48 A. The car charged from 60% to 80% on grid-blend power that afternoon while the automation believed it was running on sunshine.

Both halves of the review failed in the same direction. The automated check asked exactly the right question and got a wrong answer. I knew the hardware couldn’t do it, and read straight past the line that said it would. Hold that shape in mind, because it comes back: a control surface that accepts a command, reports success, and changes nothing.

The fix, at 16:54 the same day, is the loop this post is actually about — stop asking the charger to find the surplus, and compute the amperage from the Powerwall’s meter directly. The next afternoon the whole thing was reworked again for local OCPP control and picked up three more fixes by evening: surplus-tracking oscillation, a thirty-second start/stop flap, and starting sessions on less export than the 6 A floor costs. So when the car sailed past its target on the 9th, the control loop was two days old and had already been amended eight times.

That’s the honest version of the git-first argument, and it’s smaller than I’d like: the discipline didn’t prevent any of this. Every bug in this post shipped through it. What it bought was being able to answer “what changed, and when” from a git log rather than from memory — which is the only reason the sections below can put a timestamp on anything.

What it emphatically is not is a test. There’s no harness for a Home Assistant automation — no way to replay a cloudy Thursday against a candidate choose block and watch what it does to a contactor. Review catches what a reader can see. Everything else waits for the weather, and turns up in the recorder afterward.

Day one: 83% is not 80%

July 9 was sunny. The Powerwall hit 100% at 11:10, the house started exporting, and the boost branch raised the ceiling to 80% and started charging the car — exactly as designed. The car’s battery climbed about 1% every 15 minutes (the Volvo integration polls its cloud roughly that often), reached 80% at 12:41… and kept going. 81, 82, 83% by 13:47.

Car battery vs. target ceiling, 2026-07-09Step chart from 09:00 to 15:15 EDT. The target ceiling starts at 60 percent, rises to 80 when solar boost engages at 11:11, then flaps between 60 and 80 from about noon to 2pm as appliance spikes cycle the surplus gate. The car battery climbs steadily through the 80 percent boost target and peaks at 83 percent at 13:47.6070809083% — three past the targetbattery %car batterytarget ceiling09:0010:0011:0012:0013:0014:0015:00
July 9 (times EDT). The car’s battery climbs through the 80% boost target and peaks at 83%. The square wave is the target ceiling flapping 60↔80 as appliance spikes closed and reopened the surplus gate — about 15 charger stop/start cycles in two hours. chart data (CSV)

The automation is a single choose9 with an ordered list of branches, and the bug is pure branch order. The “solar surplus available” branch sat before “above target — stop charging,” matched the same triggers (battery level change, site power change), and had no condition comparing the battery to the target. While the surplus gate held, every battery update was consumed by the boost branch — re-throttle amps, keep charging — and the stop branch below it was unreachable. The car would have kept going well past 83% if load spikes hadn’t occasionally closed the gate.

BefSt(At^oorntrrlio/iubeeagagnrvrgtbgrae(eaoeenr2srrvracy0usgesch2r:e:hr6pttaae-lbabbll0uacralre7sthagteev-telwet:aa0aecwhtedn9vrkairbytay)yl-yoAi,se,omtMlssar)asmgtsttibiaaoicgltttpthgeeceeeehdrehsoldsAf^teSt+Atrgrortreolib/iae(agaagcs2rgtbgh0etoeai2srevrbn6usresle-r:y:er0ptt7lb<abo-uarana0stbgtct9toieteaeontetPvrserbaMaytr-yori,t,ogfltsseiasaatsttxbirtoi)ltgpteee>et=80%

choose evaluates branches top to bottom and runs only the first match — on the left, “solar surplus” always wins the tie, so “stop” never gets a turn. The fix wasn’t reordering the branches; it was making the boost branch stop claiming triggers it had no business answering once the car was at target.

The fix, deployed that evening, was three small changes:

             {"condition": "numeric_state", "entity_id": "sensor.powerwall_site_now", "below": 0},
+            {"condition": "numeric_state", "entity_id": "sensor.volvo_…_battery_charge_level",
+             "below": "input_number.c40_solar_boost_target"},

One: the boost branch now requires the battery to be below the boost target, so it goes inert at 80% and the stop branch becomes reachable. Two: the stop branch compared with a strict “above,” which still leaves a systematic +1% overshoot once boost goes inert at exactly 80 — it now uses a >= template (HA’s numeric_state can’t express >= against another entity). Three, the bonus find in the same data: the surplus gate was closing on any single reading above +0.5 kW, so every appliance spike cycled the charger’s contactor — those ~15 stop/start cycles in the chart. The gate’s import case now requires a sustained-import trigger — site power above 0.5 kW continuously for three minutes, with amps already throttled to the floor — before it drops the ceiling:

{
  "trigger": "numeric_state",
  "entity_id": "sensor.powerwall_site_now",
  "above": 0.5,
  "for": {"minutes": 3},
  "id": "sustained_import"
}

Residual overshoot after the fix is bounded by the Volvo cloud’s ~15-minute polling: at solar amperages, about 1% past the target, worst case. I can live with that.

Day two: the whipsaw

The next morning I noticed the car charging while the house was importing from the grid — the one thing the whole design exists to prevent. Not the same bug: the ceiling was correct, the battery was below it, and every branch was doing what it said. The problem was the weather.

July 10 was partly cloudy. Under cloud shadows, sensor.powerwall_site_now doesn’t drift — it lurches. In the two hours after the Powerwall filled, it ranged from 4.6 kW of export to 8.7 kW of import, and the single biggest jump between two consecutive 30-second readings was 12.3 kW, at 11:14.

Site power and charger amp ceiling, 2026-07-10Two aligned panels from 10:30 to 12:34 EDT on a partly-cloudy day. Top: net site power swings between roughly 6 kW import and 8 kW export, sometimes within a single 30-second tick. Bottom: the automation's charger amp ceiling ricochets between the 6 A floor and 15 to 30 A about once a minute, chasing every swing; the measured draw tracks it a few seconds behind.-10-50510Powerwall full — boost gate openssite power (kW)importingexporting0122436486 A floorcharger ampsamp ceiling (set by HA)measured draw10:3011:0011:3012:0012:30
July 10, 10:30–12:34 (times EDT). Top: net site power on a partly-cloudy day — red importing, blue exporting; the biggest tick-to-tick reversal is 12.3 kW in 30 seconds. Bottom: the amp ceiling the automation kept rewriting — 91 setpoint changes in two hours, ricocheting between the 6 A floor and 15–30 A — with the charger’s measured draw tracking a few seconds behind. chart data (CSV)

Two branches both react to that sensor: the surplus branch throttles amps up toward the export, and the import branch throttles them down toward the 6 A floor. Neither had any debounce, so each 30-second lurch handed control to the opposite branch. The amp ceiling changed 91 times in two hours. And the July 9 safety net never fired — sustained_import needs three continuous minutes of import with amps at the floor, and the surplus branch kept yanking amps back up on the next momentary export blip before three minutes could ever elapse. The charger even dropped and re-authorized the whole session three times along the way. Net effect: the car sipped grid power in bursts, forever, and the automation considered every sip correct.

The fix is almost embarrassingly small — the trigger itself now has to hold for 75 seconds before either branch reacts:

     {
       "trigger": "state",
       "entity_id": "sensor.powerwall_site_now",
+      "for": {"seconds": 75},
       "id": "site_power_change"
     },

Debouncing at the trigger10 would smooth both branches with one change and leave the three-minute dwell logic alone. 75 seconds felt right: long enough to reject a single-tick cloud shadow (the observed swings resolve within one 30-second reading), short enough to still react to a real weather change inside a couple of minutes. Claude wrote it, I reviewed it, it went live at 12:30, and the amp chart went beautifully quiet.

It stayed quiet for the wrong reason.

Day two, afternoon: quiet is not the same as fixed

Reread that footnote — the one I linked while shipping the fix. A state trigger’s for: requires the state to hold for the duration, and the timer resets on every state change. sensor.powerwall_site_now reports a fresh reading every ~30 seconds. A meter that never repeats itself never holds a value for 75 seconds — so the trigger didn’t fire less often. It stopped firing at all. The logbook is unambiguous: the last site_power_change firing was 12:30:18, seconds before the new automation loaded. Then zero, across six hours and roughly 720 sensor updates. The whipsaw hadn’t been smoothed; the control loop had been unplugged. The documentation described exactly this behavior, two of us read it, and neither of us caught it.

The afternoon then demonstrated, on the recorder, everything that trigger had been doing for free:

The afternoon the control loop went silent, 2026-07-10Three aligned panels from 12:30 to 18:30 EDT. Top: net site power — one import spike to 5.6 kW just before 13:00, then hours of steady 2 to 3.5 kW export. Middle: the car battery reaches exactly 80 percent at 13:18 and stays flat; the target ceiling is raised by hand to 90 percent at 13:44 and nothing responds, until the surplus gate drops it back to 60 at 17:17. Bottom: the charger amp ceiling changes once all afternoon — to 19 A at 12:48 — and the measured draw falls to zero when the car stops at 13:18.-505debounce fix deploys — site trigger goes silent5.6 kW import, no throttle responsesite power (kW)importingexporting60708090stops at exactly 80%raised to 90% by hand — no responsegate closesbattery %car batterytarget ceiling012243648the loop's only move all afternooncharger ampsamp ceiling (set by HA)measured draw13:0014:0015:0016:0017:0018:00
July 10 afternoon (times EDT). Top: net site power — one import spike to 5.6 kW just before 13:00, then hours of 2–3.5 kW export. Middle: the car stops at exactly 80% at 13:18; I raise the target ceiling to 90% at 13:44 and nothing responds until the surplus gate closes at 17:17. Bottom: one amp-ceiling change all afternoon, versus 91 in the two hours before the fix. chart data (CSV)

At 12:55, clouds rolled in and the house started importing — 5.6 kW at the peak — while the car kept charging at 19 A. The throttle-down branch is the only consumer of grid import while boosted, and it listens only to the dead trigger. The sustained_import safety net actually fired at 12:58 (a numeric_state trigger, where for: means the threshold condition must hold, not the exact value — which is why it survived) and then did nothing, because its branch requires amps already throttled to the 6 A floor. Only 0.38 kWh leaked to the grid before the clouds passed, but nothing in the automation stopped it, and nothing would have.

At 13:18:46, the car hit exactly 80% and the stop branch cut charging 185 milliseconds after the battery reading arrived. Day one’s fix, fully vindicated — the branches that still had live triggers worked flawlessly all afternoon.

Then the expensive part. At 13:41 I raised the solar boost target to 90%, and at 13:44 the working ceiling along with it — Powerwall full, house exporting between two and three and a half kilowatts, car sitting at 80%. Free sunshine, explicit permission to use it. Nothing happened, for three and a half hours.

That first raise is the one that matters, and it’s why “the car was already at the boost target” isn’t the explanation. Day one’s fix makes the boost branch go inert once the battery reaches c40_solar_boost_target; with the target still at 80 the branch would have been correctly asleep, and the afternoon would prove nothing. At 90 it was wide awake, and every condition on it would have passed. The problem was that none of its three triggers could fire. Site power: dead. Powerwall percentage: pegged at 100 since 11:04, no state change to report. Car battery: frozen at 80 the moment charging stopped. An event-driven system with all of its events silenced is just a diagram. Between the 80% stop and sunset, 7.61 kWh went to the grid — 6.24 kWh of it after I had explicitly asked the car to take it.

At 17:17, the Powerwall finally dipped to 99% — a state change, at last — and the surplus gate dropped the ceiling back to 60% within a second, exactly as designed. The automation’s last functioning reflex, cleaning up after the ones that had died.

The real fix is the textbook one I should have written the first time: you can’t debounce an analog signal by asking it to hold still — you filter it. A Home Assistant filter helper11 now maintains sensor.site_power_smoothed, a three-minute moving average of the raw meter, and the trigger watches that, with no for: at all:

     {
       "trigger": "state",
-      "entity_id": "sensor.powerwall_site_now",
-      "for": {"seconds": 75},
+      "entity_id": "sensor.site_power_smoothed",
       "id": "site_power_change"
     },

The loop runs every 30 seconds again, but its input drifts instead of lurching, and the existing deadbands keep the actuator quiet. Every throttle and boost decision now reads the smoothed value too; the sustained_import net deliberately stays on the raw meter, so the last line of defense doesn’t depend on the filter helper existing. It went live on July 11 and merged the same day — the repo tracks what’s deployed, not what’s proven. Proven is a separate claim, and this time I wrote down in advance what it would take: amps that track weather, zero sustained import, and a site_power_change count in the logbook that is neither 91 per two hours nor zero.

Then I waited for a cloudy day.

Four days later: the cloudy-day verdict

The weather immediately turned helpful in the least useful way — three clear days in a row. They made a decent control run: on July 13 the Powerwall reported full at 10:40:12.071 and the boost branch raised the ceiling four milliseconds later, then held one continuous session for six hours and twenty minutes (66% → 88%) while the amp ceiling made 203 small corrections — mean step 2.6 A, not one of them 10 A or larger, zero charger stop/start cycles. Site import across the whole session: 0.88 kWh, never sustained. Pretty, but sunny-day pretty was never the question.

July 14 was the day the fix was written for. Overcast all morning, then a violently choppy midday — the raw meter swinging several kilowatts between 30-second readings — then overcast again. The Powerwall filled at 11:57, but the house was still net-importing under cloud, so the gate correctly kept waiting; at 12:00:13 the smoothed meter crossed into export and the ceiling went up.

Site power (raw and smoothed) and charger amp ceiling, 2026-07-14Two aligned panels from 11:55 to 13:30 EDT on a genuinely cloudy day. Top: raw net site power lurches several kilowatts between 30-second readings while the 3-minute moving average drifts smoothly through it; one import spike reaches 4 kW just after noon. Bottom: the charger amp ceiling follows the smoothed curve in small steps between the 6 A floor and 32 A, with the measured draw tracking close behind — one continuous charging session ending the moment the car reports 90%.-505Powerwall full + smoothed export — boost opens4 kW cloud edge — throttled to the floor in 3 minsite power (kW)importing (raw)exporting (raw)3-min moving average012243648car hits 90% — stop, 152 ms later6 A floorcharger ampsamp ceiling (set by HA)measured draw12:0012:1512:3012:4513:0013:1513:30
July 14, 11:55–13:30 (times EDT). Top: the raw net meter (red importing, blue exporting) lurches under cloud edges while the 3-minute moving average drifts through it. Bottom: the amp ceiling follows the smoothed curve in small steps between the 6 A floor and 32 A — one continuous 81-minute session, ending the moment the car reports 90%. chart data (CSV)

Criterion one, amps that track weather: pass. Seventy ceiling changes in 81 minutes, range 6–32 A, and the charging session stayed up the whole time — no stop/start cycling, no dropped authorizations, versus three on whipsaw day. At 13:20:53.983 the car reported 90% — the boost target, still where I’d left it on the 10th — and the charger was off 152 milliseconds later. Exactly at target, third clean stop since the day-one fix.

Criterion three, a trigger count that is neither 91 per two hours nor zero: technically pass — the smoothed trigger fires with every 30-second reading, all day, so the loop is demonstrably alive. But the data called my bluff on the number itself. On a genuinely choppy day the ceiling changed 52 times an hour — a higher count than the whipsaw’s 44 — with a mean step of 2.9 A instead of 10.5 A, and exactly one move of 10 A or more instead of 49. Tracking and thrashing run at similar cadence under a stormy sky; what separates them is amplitude, not frequency. I’d written down the wrong axis.

Criterion two, zero sustained import: not literally met, and the miss is the most instructive part. The raw meter crossed 0.5 kW of import and stayed there past three minutes twice — 12:03 and 12:12, about 0.35 kWh leaked across the whole session, peaking at 4 kW. Both episodes are the same shape: a cloud edge arrives, the 3-minute average takes its time believing it, and the throttle rides down from 32 A to the 6 A floor about ninety seconds behind the truth. That lag isn’t a bug in the filter — it is the filter. Underneath it, sustained_import fired at 12:06:13 and 12:15:13 and did nothing, correctly: its stop-gate requires the amps to already be at the floor, and the throttle was still mid-descent — throttle before ever stopping, as designed. The first episode ended thirty seconds after the amps reached the floor; one more minute of cloud and the gate would have dropped the ceiling and ended the session. The safety net is alive, armed, and holding its fire in precisely the situation it was designed to hold fire in.

So: one clean pass, one criterion I’d aimed at the wrong axis, and one that graded the filter against a promise it never made. A low-pass filter doesn’t offer zero import; it trades a bounded, minutes-long lag for not hammering a contactor on every cloud shadow. 0.35 kWh over an 81-minute session — with the failure modes of the previous three fixes all demonstrably absent on the recorder — is a trade I’ll sign. This one is deployed, merged, and now proven, with the asterisk written down where I can’t forget it.

What the first week taught me

A 10 kW actuator wired to a 30-second noisy sensor is a control system, whether or not you meant to build one. Everything that bit me has a textbook name — priority shadowing, missing hysteresis, an un-debounced input, and finally a fix that disabled the subsystem it was protecting — and the fixes are the textbook fixes: an explicit guard condition, a dwell timer, and a low-pass filter where I’d first reached for a debounce. Home automation tutorials rarely mention that you’re signing up for control engineering; the electrical panel doesn’t care.

The second lesson is that a control loop can fail silent, and silent looks identical to fixed. The morning chart screamed; the afternoon chart was serene, and the serenity was the bug. If a fix is supposed to calm a system down, decide in advance what “calm but alive” should look like — or you’ll grade stillness as success. My first definition used a trigger count; the cloudy day corrected it to amplitude. Writing down a wrong criterion beforehand still beat grading by vibes afterward, because the data could argue with a number.

The other lesson is about data. All three diagnoses came straight from Home Assistant’s recorder history — pulled the same day, before the ~10-day purge, and committed next to the automation they indict. The charts above are rendered from those exact captures (static SVG, generated at write time12; the data link under each chart is the same series as CSV). When the car is doing something weird at 11 a.m., the trace from 11 a.m. settles arguments that speculation can’t.

And honestly: every bug in this post shipped in an automation Claude helped write, and every one was found the same way — Claude pulling traces and history, me confirming against what the house was actually doing, the fix reviewed in git before it touched the live instance. Two of them are the humbling ones, and they’re humbling in the same way. On July 7, Claude asked the system whether the charger supported solar mode, the system said yes, and I approved a design that named the mode in plain text while knowing the hardware couldn’t do it. Three days later, Claude wrote a 75-second debounce, I reviewed it, and the documentation we’d both linked described the failure exactly. Neither time did anyone skip a step. The step just didn’t catch it. The collaboration doesn’t prevent the bugs; what it does is make the trip from “that’s odd” to “fixed, documented, and charted” a same-day affair.

The next bug that comes down to “which branch wins” converts this whole choose into a proper state machine. That’s the deal I’ve made with myself.

Appendix: the full automation

The bug sections above show diff hunks — the minimum needed to follow each fix. This is the whole thing, as of the filter fix: c40 Smart Charge Control, 12 branches in one choose, in the order they’re evaluated.

  1. Plugged in (OCPP) — authorize the session, only if below target
  2. Cable removed (OCPP) — restore max amps for next session
  3. Charging finished/reset (cloud/Thor) — restore max amps
  4. Car left home — reset target and amps for next arrival
  5. Car arrived home — slow start at 6 A
  6. Cabin preheat — prep for imminent departure
  7. Surplus gate failed — drop ceiling back to normal target
  8. Importing while solar-boosted — throttle down before ever stopping
  9. Solar surplus available — boost target, throttle amperage to match export
  10. Notify — car’s own onboard limit is wasting available solar
  11. At or above target charge — stop charging
  12. Below normal target — pace amperage to departure time

Branches 7, 8, 9 and 11 are the ones this post is about — the surplus gate, the throttle logic, and the stop condition, in the order that let day one’s bug happen and day two’s fixes land. The rest (session bookkeeping, arrival slow-start, preheat, the onboard-limit notification) exist for reasons outside this post’s story but are left in so the automation reads as what actually ran, not an edited-for-narrative excerpt.

Rather than dump all 315 lines on you, the excerpt below shows just the surplus gate — branch 9’s conditions, where both of this post’s fixes ended up living two lines apart. Line 209 reads sensor.site_power_smoothed rather than the raw meter (day two’s filter fix), and line 210 is the c40_solar_boost_target check whose absence let the boost branch shadow the stop branch on day one.

Expand it for the full automation, with the same line numbering. The branches this post is about are 7 (lines 152–178), 8 (179–203), 9 (204–244) and 11 (268–286); each branch ends with an alias matching the numbered list above, so you can jump to whichever you want.

205
206
207
208
209
210
211
212
213
          "conditions": [
            {"condition": "trigger", "id": ["site_power_change", "c40_battery_level_change", "powerwall_charge_change"]},
            {"condition": "state", "entity_id": "input_boolean.c40_solar_boost_enabled", "state": "on"},
            {"condition": "state", "entity_id": "sensor.powerwall_charge", "state": "100"},
            {"condition": "numeric_state", "entity_id": "sensor.site_power_smoothed", "below": 0},
            {"condition": "numeric_state", "entity_id": "sensor.volvo_…_battery_charge_level", "below": "input_number.c40_solar_boost_target"},
            {"condition": "template", "value_template": "{{ states(charger_status) == 'Charging' or (states('sensor.site_power_smoothed') | float(0)) <= -1.5 }}"},
            {"condition": "state", "entity_id": "device_tracker.volvo_c40", "state": "home"},
            {"condition": "template", "value_template": "{{ states(charger_sched_sensor) != 'Scheduled' }}"},
show 306 more lines
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
{
  "id": "1783425787965",
  "alias": "c40 Smart Charge Control",
  "description": "Manages charging for the Volvo C40 on whichever wallbox is active (Xolotl or Thor). Xolotl is controlled locally via OCPP (ocpp integration, no cloud dependency); Thor still uses the Wallbox cloud integration until Phase 3 of the OCPP migration. Below c40_normal_target, charges at full amperage. Above it, charging stops unless the Powerwall is full and the house is net-exporting solar (sensor.powerwall_site_now < 0) -- in that case the ceiling rises to c40_solar_boost_target and amperage is throttled to track the export exactly, so the car only consumes power that would otherwise be wasted. When importing while boosted, amperage is trimmed (import + 1A margin) and charging only stops once throttled to the 6A floor. Defers to the wallbox's own 5-9pm Scheduled state (still visible only on the cloud status sensor -- schedules stay app/cloud-managed under OCPP) rather than fighting it. Under OCPP, plugging in no longer auto-starts charging: session authorization moved to HA, so the Preparing branch turns on charge control explicitly -- but only when battery is below target, because Wallbox returns to Preparing after a remote stop and an unconditional authorize would un-do every stop. c40_target_charge is a ceiling, not a set-point -- it stops further charging once reached (>=, not just above) but never discharges the car. The boost branch checks the car is below c40_solar_boost_target so it cannot shadow the stop branch past the target (2026-07-09 fix), and the surplus gate's import case only drops the ceiling after 3 minutes of sustained import at minimum amps, so brief appliance spikes no longer cycle the charger. On choppy/partly-cloudy days sensor.powerwall_site_now swings +-8kW within a single 30-second tick, so the surplus-boost and import-throttle branches originally reacted to every reading and ratcheted amps up and down before the 3-minute sustained_import dwell could ever complete, letting the car keep drawing grid power in bursts instead of settling (2026-07-10 AM whipsaw). The first fix attempt -- for: 75s on the site_power_change state trigger -- silenced the trigger instead of debouncing it: a state trigger's hold timer resets on every state change, and a meter that reports a fresh value every ~30s never holds one for 75 seconds (observed live 2026-07-10 PM: zero firings in 6 hours, one unthrottled ~3kW import episode at 12:55, ~7kWh of export stranded after the target was raised to 90%). The real fix (2026-07-10 PM) filters the signal, not the trigger: site_power_change watches sensor.site_power_smoothed -- a filter helper (time_simple_moving_average, 3-minute window, precision 3, source sensor.powerwall_site_now; created via the helper config flow, so it lives in HA storage, not this repo) -- and every throttle/boost decision reads the smoothed value, so the loop runs every ~30s again but on a signal that drifts instead of lurching. The sustained_import trigger and its re-check condition deliberately stay on the raw meter: that final stop-gate safety net must not depend on the filter helper entity existing.",
  "mode": "single",
  "variables": {
    "charger_is_ocpp": "{{ states('input_select.c40_active_charger') == 'Xolotl' }}",
    "charger_current": "{{ 'number.xolotl_maximum_current' if charger_is_ocpp else 'number.wallbox_portal_max_charging_current_2' }}",
    "charger_switch": "{{ 'switch.xolotl_charge_control' if charger_is_ocpp else 'switch.wallbox_portal_pause_resume_2' }}",
    "charger_status": "{{ 'sensor.xolotl_status_connector' if charger_is_ocpp else 'sensor.wallbox_portal_status_description_2' }}",
    "charger_sched_sensor": "{{ 'sensor.wallbox_portal_status_description' if charger_is_ocpp else 'sensor.wallbox_portal_status_description_2' }}",
    "line_voltage": "{{ states('sensor.xolotl_voltage') | float(states('sensor.powerwall_gateway_battery_voltage') | float(240)) if charger_is_ocpp else states('sensor.powerwall_gateway_battery_voltage') | float(240) }}"
  },
  "triggers": [
    {
      "trigger": "state",
      "entity_id": ["sensor.xolotl_status_connector", "sensor.wallbox_portal_status_description", "sensor.wallbox_portal_status_description_2"],
      "id": "wallbox_state"
    },
    {
      "trigger": "state",
      "entity_id": ["sensor.volvo_…_battery_charge_level"],
      "id": "c40_battery_level_change"
    },
    {
      "trigger": "state",
      "entity_id": ["input_number.c40_target_charge", "input_select.c40_active_charger"],
      "for": {"seconds": 30},
      "id": "c40_target_or_charger_change"
    },
    {
      "trigger": "state",
      "entity_id": "input_number.c40_target_charge",
      "for": {"seconds": 2},
      "id": "c40_target_lowered_instant"
    },
    {
      "trigger": "state",
      "entity_id": ["input_datetime.c40_departure"],
      "for": {"seconds": 30},
      "id": "c40_departure_change"
    },
    {
      "trigger": "state",
      "entity_id": "sensor.site_power_smoothed",
      "id": "site_power_change"
    },
    {
      "trigger": "state",
      "entity_id": "sensor.powerwall_charge",
      "id": "powerwall_charge_change"
    },
    {
      "trigger": "numeric_state",
      "entity_id": "sensor.powerwall_site_now",
      "above": 0.5,
      "for": {"minutes": 3},
      "id": "sustained_import"
    },
    {
      "trigger": "state",
      "entity_id": "device_tracker.volvo_c40",
      "to": "not_home",
      "id": "c40_left_home"
    },
    {
      "trigger": "state",
      "entity_id": "device_tracker.volvo_c40",
      "to": "home",
      "id": "c40_arrived_home"
    },
    {
      "trigger": "state",
      "entity_id": "switch.volvo_…_climate_status",
      "to": "on",
      "id": "c40_ac_on"
    },
    {
      "trigger": "state",
      "entity_id": "sensor.volvo_…_charging_system_status",
      "to": "Idle",
      "for": {"minutes": 5},
      "id": "c40_charging_idle"
    }
  ],
  "conditions": [],
  "actions": [
    {
      "choose": [
        {
          "conditions": [
            {"condition": "trigger", "id": ["wallbox_state"]},
            {"condition": "template", "value_template": "{{ charger_is_ocpp and states(charger_status) == 'Preparing' }}"},
            {"condition": "numeric_state", "entity_id": "sensor.volvo_…_battery_charge_level", "below": "input_number.c40_target_charge"}
          ],
          "sequence": [
            {"action": "switch.turn_on", "target": {"entity_id": "{{ charger_switch }}"}}
          ],
          "alias": "Plugged in (OCPP) - authorize session ONLY if below target (Wallbox returns to Preparing after a remote stop, so an unconditional authorize un-does every stop); amps untouched so arrived-home slow start survives"
        },
        {
          "conditions": [
            {"condition": "trigger", "id": ["wallbox_state"]},
            {"condition": "template", "value_template": "{{ charger_is_ocpp and states(charger_status) == 'Available' }}"}
          ],
          "sequence": [
            {"action": "number.set_value", "target": {"entity_id": "{{ charger_current }}"}, "data": {"value": 48}}
          ],
          "alias": "Cable removed (OCPP) - restore max amps for next session"
        },
        {
          "conditions": [
            {"condition": "trigger", "id": ["wallbox_state"]},
            {"condition": "template", "value_template": "{{ not charger_is_ocpp and states(charger_status) == 'Ready' }}"}
          ],
          "sequence": [
            {"action": "number.set_value", "target": {"entity_id": "{{ charger_current }}"}, "data": {"value": 48}},
            {"action": "switch.turn_on", "target": {"entity_id": "{{ charger_switch }}"}}
          ],
          "alias": "Charging finished/reset (cloud/Thor) - restore max amps"
        },
        {
          "conditions": [{"condition": "trigger", "id": ["c40_left_home"]}],
          "sequence": [
            {"action": "input_number.set_value", "target": {"entity_id": "input_number.c40_target_charge"}, "data": {"value": "{{ states('input_number.c40_normal_target') | float(60) }}"}},
            {"action": "number.set_value", "target": {"entity_id": "{{ charger_current }}"}, "data": {"value": 48}},
            {"action": "switch.turn_on", "target": {"entity_id": "{{ charger_switch }}"}}
          ],
          "alias": "Car left home - reset for next arrival"
        },
        {
          "conditions": [{"condition": "trigger", "id": ["c40_arrived_home"]}],
          "sequence": [
            {"action": "number.set_value", "target": {"entity_id": "{{ charger_current }}"}, "data": {"value": 6}}
          ],
          "alias": "Car arrived home - slow start"
        },
        {
          "conditions": [
            {"condition": "trigger", "id": ["c40_ac_on"]},
            {"condition": "state", "entity_id": "device_tracker.volvo_c40", "state": "home"},
            {"condition": "template", "value_template": "{{ states(charger_sched_sensor) != 'Scheduled' }}"}
          ],
          "sequence": [
            {"action": "number.set_value", "target": {"entity_id": "{{ charger_current }}"}, "data": {"value": 48}},
            {"action": "input_number.set_value", "target": {"entity_id": "input_number.c40_target_charge"}, "data": {"value": 100}},
            {"action": "input_datetime.set_datetime", "target": {"entity_id": "input_datetime.c40_departure"}, "data": {"timestamp": "{{ now().timestamp() + 1800 }}"}},
            {"action": "switch.turn_on", "target": {"entity_id": "{{ charger_switch }}"}}
          ],
          "alias": "Cabin preheat - prep for imminent departure (still respects wallbox Scheduled block)"
        },
        {
          "conditions": [
            {
              "condition": "or",
              "conditions": [
                {"condition": "and", "conditions": [
                  {"condition": "trigger", "id": ["site_power_change", "powerwall_charge_change", "c40_battery_level_change"]},
                  {"condition": "or", "conditions": [
                    {"condition": "not", "conditions": [{"condition": "state", "entity_id": "sensor.powerwall_charge", "state": "100"}]},
                    {"condition": "not", "conditions": [{"condition": "state", "entity_id": "input_boolean.c40_solar_boost_enabled", "state": "on"}]}
                  ]}
                ]},
                {"condition": "and", "conditions": [
                  {"condition": "trigger", "id": ["sustained_import"]},
                  {"condition": "numeric_state", "entity_id": "sensor.powerwall_site_now", "above": 0.5},
                  {"condition": "template", "value_template": "{{ states(charger_current) | float(48) <= 6 }}"}
                ]}
              ]
            },
            {"condition": "state", "entity_id": "device_tracker.volvo_c40", "state": "home"},
            {"condition": "numeric_state", "entity_id": "input_number.c40_target_charge", "above": "input_number.c40_normal_target"}
          ],
          "sequence": [
            {"action": "input_number.set_value", "target": {"entity_id": "input_number.c40_target_charge"}, "data": {"value": "{{ states('input_number.c40_normal_target') | float(60) }}"}}
          ],
          "alias": "Surplus gate failed - drop ceiling back to normal target (import case requires 3-min sustained_import at min amps so appliance spikes don't cycle the charger; powerwall/boost-off cases stay instant)"
        },
        {
          "conditions": [
            {"condition": "trigger", "id": ["site_power_change"]},
            {"condition": "state", "entity_id": "input_boolean.c40_solar_boost_enabled", "state": "on"},
            {"condition": "numeric_state", "entity_id": "input_number.c40_target_charge", "above": "input_number.c40_normal_target"},
            {"condition": "numeric_state", "entity_id": "sensor.site_power_smoothed", "above": 0.1},
            {"condition": "state", "entity_id": "device_tracker.volvo_c40", "state": "home"},
            {"condition": "template", "value_template": "{{ states(charger_status) == 'Charging' }}"}
          ],
          "sequence": [
            {
              "variables": {
                "limit_amps": "{{ states(charger_current) | float(48) }}",
                "measured_amps": "{{ states('sensor.xolotl_current_import') | float(0) if charger_is_ocpp else states(charger_current) | float(6) }}",
                "import_watts": "{{ (states('sensor.site_power_smoothed') | float(0)) * 1000 }}",
                "c40_throttle_amp": "{{ [[(measured_amps - (import_watts / line_voltage) - 1), 6] | max, 48] | min | round(0) }}"
              }
            },
            {
              "if": [{"condition": "template", "value_template": "{{ c40_throttle_amp < (limit_amps | round(0)) }}"}],
              "then": [{"action": "number.set_value", "target": {"entity_id": "{{ charger_current }}"}, "data": {"value": "{{ c40_throttle_amp }}"}}]
            }
          ],
          "alias": "Importing while solar-boosted - throttle down (import amps + 1A margin) before ever stopping"
        },
        {
          "conditions": [
            {"condition": "trigger", "id": ["site_power_change", "c40_battery_level_change", "powerwall_charge_change"]},
            {"condition": "state", "entity_id": "input_boolean.c40_solar_boost_enabled", "state": "on"},
            {"condition": "state", "entity_id": "sensor.powerwall_charge", "state": "100"},
            {"condition": "numeric_state", "entity_id": "sensor.site_power_smoothed", "below": 0},
            {"condition": "numeric_state", "entity_id": "sensor.volvo_…_battery_charge_level", "below": "input_number.c40_solar_boost_target"},
            {"condition": "template", "value_template": "{{ states(charger_status) == 'Charging' or (states('sensor.site_power_smoothed') | float(0)) <= -1.5 }}"},
            {"condition": "state", "entity_id": "device_tracker.volvo_c40", "state": "home"},
            {"condition": "template", "value_template": "{{ states(charger_sched_sensor) != 'Scheduled' }}"},
            {
              "condition": "or",
              "conditions": [
                {"condition": "state", "entity_id": "input_datetime.c40_departure", "state": "unknown"},
                {"condition": "template", "value_template": "{% set minutes_until = (as_timestamp(states('input_datetime.c40_departure')) - now().timestamp()) / 60 %} {{ minutes_until > (states('input_number.c40_departure_guard_minutes') | float(45)) or minutes_until < -360 }}"}
              ]
            }
          ],
          "sequence": [
            {"action": "input_number.set_value", "target": {"entity_id": "input_number.c40_target_charge"}, "data": {"value": "{{ [states('input_number.c40_solar_boost_target') | float(80), states('input_number.c40_target_charge') | float(60)] | max }}"}},
            {
              "variables": {
                "limit_amps": "{{ states(charger_current) | float(48) }}",
                "measured_amps": "{{ states('sensor.xolotl_current_import') | float(0) if charger_is_ocpp else states(charger_current) | float(6) }}",
                "export_watts": "{{ (states('sensor.site_power_smoothed') | float(0)) * -1000 }}",
                "amp_delta": "{{ export_watts / line_voltage }}",
                "raw_target_amps": "{{ measured_amps + amp_delta }}",
                "c40_surplus_amp": "{{ [[raw_target_amps, 6] | max, 48] | min | round(0) }}"
              }
            },
            {
              "if": [{"condition": "template", "value_template": "{{ (c40_surplus_amp - measured_amps) | abs >= 2 and c40_surplus_amp != (limit_amps | round(0)) }}"}],
              "then": [{"action": "number.set_value", "target": {"entity_id": "{{ charger_current }}"}, "data": {"value": "{{ c40_surplus_amp }}"}}]
            },
            {
              "if": [{"condition": "template", "value_template": "{{ states(charger_switch) == 'off' }}"}],
              "then": [{"action": "switch.turn_on", "target": {"entity_id": "{{ charger_switch }}"}}]
            }
          ],
          "alias": "Solar surplus available - boost target, throttle amperage to match export"
        },
        {
          "conditions": [
            {"condition": "trigger", "id": ["c40_charging_idle", "site_power_change"]},
            {"condition": "state", "entity_id": "sensor.volvo_…_charging_system_status", "state": "Idle"},
            {"condition": "state", "entity_id": "sensor.volvo_…_charging_connection_status", "state": "Connected"},
            {"condition": "state", "entity_id": "sensor.powerwall_charge", "state": "100"},
            {"condition": "numeric_state", "entity_id": "sensor.site_power_smoothed", "below": 0},
            {"condition": "state", "entity_id": "device_tracker.volvo_c40", "state": "home"},
            {"condition": "numeric_state", "entity_id": "sensor.volvo_…_battery_charge_level", "below": "input_number.c40_target_charge"}
          ],
          "sequence": [
            {
              "action": "notify.send_message",
              "target": {
                "entity_id": "{{ label_devices('notifications_tod') | map('device_entities') | sum(start=[]) | select('match', 'notify\\.') | list }}"
              },
              "data": {
                "message": "c40 stopped charging at {{ states('sensor.volvo_…_battery_charge_level') }}% (below your {{ states('input_number.c40_target_charge') }}% target) even though solar surplus is available. The car's own onboard charge limit is probably capping it -- raise it in the Volvo app to use the free power."
              }
            }
          ],
          "alias": "Notify - car's own limit is wasting available solar (once per idle episode)"
        },
        {
          "conditions": [
            {
              "condition": "or",
              "conditions": [
                {"condition": "trigger", "id": ["c40_battery_level_change"]},
                {"condition": "trigger", "id": ["c40_target_or_charger_change"]},
                {"condition": "trigger", "id": ["c40_target_lowered_instant"]}
              ]
            },
            {"condition": "state", "entity_id": "device_tracker.volvo_c40", "state": "home"},
            {"condition": "template", "value_template": "{{ states('sensor.volvo_…_battery_charge_level') | float(0) >= states('input_number.c40_target_charge') | float(100) }}"},
            {"condition": "template", "value_template": "{{ states(charger_status) == 'Charging' }}"}
          ],
          "sequence": [
            {"action": "switch.turn_off", "target": {"entity_id": "{{ charger_switch }}"}}
          ],
          "alias": "At or above target charge - stop charging (template because numeric_state cannot express >= against an entity; strict above left a systematic +1% overshoot)"
        },
        {
          "conditions": [
            {
              "condition": "or",
              "conditions": [
                {"condition": "trigger", "id": ["c40_battery_level_change"]},
                {"condition": "trigger", "id": ["c40_departure_change"]},
                {"condition": "trigger", "id": ["c40_target_or_charger_change"]}
              ]
            },
            {"condition": "state", "entity_id": "device_tracker.volvo_c40", "state": "home"},
            {"condition": "numeric_state", "entity_id": "sensor.volvo_…_battery_charge_level", "below": "input_number.c40_normal_target"},
            {"condition": "template", "value_template": "{{ states(charger_sched_sensor) != 'Scheduled' }}"}
          ],
          "sequence": [
            {
              "variables": {
                "c40_target_amp": "{% set target_charge = states('input_number.c40_normal_target')|int %} {% set current_charge = states('sensor.volvo_…_battery_charge_level')|int %} {% set charge_left = target_charge - current_charge %} {% set gross_capacity_kwh = states('sensor.volvo_…_battery_capacity')|float(78) %} {% set usable_capacity_kwh = gross_capacity_kwh * (75/78) %} {% set wh_left = (usable_capacity_kwh * 1000) * (charge_left/100) %} {% set depart_timestamp = as_timestamp(states('input_datetime.c40_departure')) %} {% set secs_left = depart_timestamp - now().timestamp() %} {% set hours_left = secs_left/60/60 %} {% if hours_left <= 0 %}\n  48\n{% else %}\n  {% set watts_needed = wh_left / hours_left %}\n  {% set amps = watts_needed / line_voltage %}\n  {% if amps < 6 %}\n    6\n  {% elif amps > 48 %}\n    48\n  {% else %}\n    {{ amps|round(0) }}\n  {% endif %}\n{% endif %}"
              }
            },
            {"action": "number.set_value", "target": {"entity_id": "{{ charger_current }}"}, "data": {"value": "{{ c40_target_amp }}"}},
            {"if": [{"condition": "template", "value_template": "{{ states(charger_switch) == 'off' }}"}], "then": [{"action": "switch.turn_on", "target": {"entity_id": "{{ charger_switch }}"}}]}
          ],
          "alias": "Below normal target - pace amperage to departure time"
        }
      ]
    }
  ]
}

What broke after this snapshot

That JSON is the version this post’s story ends on, not the version running today. Two more bugs surfaced afterward, both in branches this post doesn’t cover — worth knowing before anyone copies the blob above.

Branch 12 paced against any departure, however far away. Below the normal target the automation will stretch a charge to finish just in time for a departure you’ve set, rather than slamming in 48 A at midnight. The only test for “is there a deadline” was hours_left <= 0, so a departure three days out charged at a crawl, and a departure in the past fell through to an unconditional 48 A. Since input_datetime.c40_departure is only ever written by the preheat branch, it goes stale on its own whenever the car sits home without preheating. On July 22 that combination had the car pulling 48 A into a house that was already importing 10 kW. The fix replaces the single test with an explicit “set, in the future, and within twelve hours” check — every other case means no deadline, so charge at full amps. It’s the same stale-clock root cause the boost branch had been fixed for weeks earlier; the other branch reading the same entity never got the equivalent treatment. There’s a longer story in that pattern — one entity, written by one branch, quietly going stale and breaking two others weeks apart — and I’d happily write it up if it’s the sort of thing you’d read.

Branch 10’s alias was a lie. It says “once per idle episode,” and the branch has neither a dedup flag nor a check for the wallbox’s own schedule. So inside the 5–9 p.m. block, with the Powerwall full and the house exporting, it fired on every 30-second meter tick and blamed the car’s onboard limit for what was actually the schedule doing its job. Both halves are fixed now: a Scheduled guard, and an input_boolean set on notify and cleared when charging resumes, the cable comes out, or the car leaves home.

Neither changes anything above, which is why the snapshot stays where the story does. But that second one earns its place in a post about control loops: the alias described the behavior I intended, the dedup never got written, and for four weeks — since the automation’s very first deploy — nothing compared the two. A comment is not a test.


AI-assistant disclaimer: This post was drafted by Claude Code (Claude Fable 5, claude-fable-5) from project notes, commit history, and decision records, with a later revision and accuracy pass by Claude Opus 5 (claude-opus-5), then reviewed and edited by me. It may contain inaccuracies; verify specifics before relying on them.


  1. Wallbox Pulsar Plus — a 48 A level-2 charger. There are two on the panel, xolotl and thor; only xolotl is on local control so far. The car normally charges on thor, and is on xolotl while the garage gets drywalled — which spoils a small joke, since tanngrisnir is one of the two goats that pull Thor’s chariot in Norse myth. xolotl is from an entirely different pantheon. ↩︎

  2. Open Charge Point Protocol, via the lbbrhzn/ocpp Home Assistant integration. xolotl originally ran on HA’s built-in wallbox integration, which talks to Wallbox’s cloud API — fine until that API had an outage and took the automation’s control down with it. Moving xolotl to local OCPP so it doesn’t depend on Wallbox’s cloud was a project of its own, and I’ve been thinking about writing it up separately — cutting a charger loose from its vendor cloud is the sort of thing that’s either widely useful or of interest to about four people. If you’d read it, say so (I’m on Mastodon and Bluesky, linked in the sidebar) and I’ll move it up the queue. ↩︎ ↩︎

  3. volvo2mqtt — a Home Assistant add-on that polls Volvo’s cloud API and republishes battery, range, climate, and lock state over MQTT. It’s what backs most of the Volvo-branded entities the automation reads — but not presence; see the Android Automotive footnote below. ↩︎

  4. Android Automotive — a full OS running natively on the car’s own infotainment hardware, distinct from Android Auto (which only projects a phone’s screen). The Home Assistant companion app is installed directly on the car under Android Automotive and reports the car’s own GPS as device_tracker.volvo_c40 — nothing to do with volvo2mqtt or Volvo’s own API. That entity went stale for 30 days at one point (companion app not reporting location), and every “is the car home” condition in the automation silently stopped gating on anything real until that got fixed. ↩︎

  5. Volvo Cars Developer Portal — Volvo publishes an actual API for third-party integration, and lets the Home Assistant companion app run natively on the car rather than treating either as a threat. That’s not the industry default. ↩︎

  6. Worth being precise about who deserves the credit here: this isn’t Volvo granting special permission. Home Assistant publishes a dedicated Android Automotive build of the companion app, and any car shipping Android Automotive with Google Automotive Services gets a Play Store that can install it — the docs treat installing on the vehicle itself as a normal path, quirks and all. Polestar 4 owners are running it, and Renault, Honda, Lucid, GM and others ship Google built-in too. What Volvo chose was to ship a system that takes third-party apps at all, rather than a sealed one. ↩︎

  7. There used to be a HACS custom integration for Ford/Lincoln vehicles (itchannel/fordpass-ha). It still carries a notice dated 5 August 2024: “Due to what looks like a Ford API change the integration isn’t currently working… I recommend disabling it for the time being.” Two years on, that’s still the state of it, and no open replacement has appeared — so the Mach-E has no Home-Assistant-visible state of charge to automate against. This is the whole argument for a documented API in one example: the community built the thing, and a vendor-side change nobody could see coming took it out permanently. ↩︎

  8. Net Metering in Maine — residential rooftop solar falls under Maine’s Net Energy Billing kWh Credit Program: exported power is banked as a 1-to-1 kWh credit against future usage, not paid out at a separate rate. ↩︎

  9. Home Assistant script syntax — choose: branches are evaluated in order and only the first match runs — which is exactly why order matters. ↩︎

  10. Home Assistant state trigger — for: the state must hold for the given duration before the trigger fires — and the hold timer resets on every state change. On a sensor that reports a fresh value every 30 seconds, that second clause is the whole story of this post’s third act. ↩︎

  11. Home Assistant filter integration — here a time_simple_moving_average over a 3-minute window, created as a UI helper. Its output updates just as often as the raw meter, but each value is the window’s average, so single cloud shadows average away instead of driving the loop. ↩︎

  12. No charting library is involved, and nothing runs in your browser. A small Python script reads the raw recorder JSON plus a TOML spec describing the series, and emits an SVG at site-build time; a Hugo shortcode inlines it into the page so its CSS classes bind to the theme’s colour ramp and it redraws correctly in light, dark, and both high-contrast modes without JavaScript. The upside beyond accessibility is archivability — a static SVG in the page will still render in fifteen years, which a CDN-hosted chart library will not. ↩︎


A Home Assistant automation throttles our EV charger to soak up the house's solar surplus. It charged past its target one day, whipsawed on cloud shadows the next — and then the whipsaw fix silenced the control loop entirely. The recorder data, all three root causes, the fixes — and the cloudy day that finally put the last one to the test.

2026-08-24


On this page: