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.
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.
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.
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:
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.
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.
- Plugged in (OCPP) — authorize the session, only if below target
- Cable removed (OCPP) — restore max amps for next session
- Charging finished/reset (cloud/Thor) — restore max amps
- Car left home — reset target and amps for next arrival
- Car arrived home — slow start at 6 A
- Cabin preheat — prep for imminent departure
- Surplus gate failed — drop ceiling back to normal target
- Importing while solar-boosted — throttle down before ever stopping
- Solar surplus available — boost target, throttle amperage to match export
- Notify — car’s own onboard limit is wasting available solar
- At or above target charge — stop charging
- 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.
| |
show 306 more lines
| |
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.
Wallbox Pulsar Plus — a 48 A level-2 charger. There are two on the panel,
xolotlandthor; onlyxolotlis on local control so far. The car normally charges onthor, and is onxolotlwhile the garage gets drywalled — which spoils a small joke, sincetanngrisniris one of the two goats that pull Thor’s chariot in Norse myth.xolotlis from an entirely different pantheon. ↩︎Open Charge Point Protocol, via the
lbbrhzn/ocppHome Assistant integration.xolotloriginally ran on HA’s built-inwallboxintegration, which talks to Wallbox’s cloud API — fine until that API had an outage and took the automation’s control down with it. Movingxolotlto 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. ↩︎ ↩︎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. ↩︎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 withvolvo2mqttor 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. ↩︎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. ↩︎
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. ↩︎
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. ↩︎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. ↩︎
Home Assistant script syntax —
choose: branches are evaluated in order and only the first match runs — which is exactly why order matters. ↩︎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. ↩︎Home Assistant
filterintegration — here atime_simple_moving_averageover 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. ↩︎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. ↩︎