All Posts
Charles6 min read

Smart EV Charging, Part 2: A State Machine

Part 1 covered charging a PHEV against the Octopus Agile tariff with two small PyScripts: one that grouped prices into blocks, one that found the single cheapest window and aimed for it. That was enough for a 19kWh battery in a car I drove three times a week.

Then I switched to a full EV, a Polestar 2 in the Dual Motor Performance trim with an 82kWh battery. That is more than four times the Volvo's usable capacity. I drive it most days, and the Performance variant gets through the charge quicker than most. I charge more, and more often, sometimes across more than one night. The old pair of scripts (now sitting in src/legacy) couldn't do that. A single sliding-window search finds one contiguous block, which is the wrong shape when the cheap hours are scattered across two or three days. I also wanted to see and tune the decision rather than trust a one-shot search.

I rewrote the lot as a single state machine: ev_charging_state_machine.py. One module, 300-odd tests, running my charging since. Every link in this post is pinned to one commit, because the code keeps moving.


The problem

Agile publishes confirmed rates for today and tomorrow around 4pm. Beyond that you get a forecast, and it gets less reliable the further out you look. A naive "charge during the cheapest N hours" approach breaks down. It commits early to a forecast slot that later changes. Prices wobble, so it chops the charge into slivers and cycles the charger on and off. Worst of all, it has no memory of what it promised, and re-decides from scratch on each run, even mid-session.

I wanted one thing that could schedule across several days, weigh a forecast against a confirmed price on a dial I control, and keep its hands off a charge already under way.


The state machine

There are five working states (idle, scheduled, charging, complete, and boosting, which is the manual force-charge override) plus two side-states for when it can't schedule or something has broken. determine_state resolves one on each evaluation tick.

The distinction I care about most is unschedulable against error. Unschedulable means my own constraints can't be met by the current market: the max price is set too low, the minimum block is longer than tonight's cheap run, the deadline is too tight. Error means something broke: no price data, a missing input, stale data. One is a market condition, and I want the dashboard to show it as such. The other is a real fault, and I want it flagged as one. Collapse the two and a cheap-but-impossible day looks like a crash, which sends you debugging a system that is working fine.


Picking the windows

The scheduler is find_optimal_slots, and it runs in six steps:

  1. Merge three price sources: today's actual Octopus rates, tomorrow's actuals, and a longer-range forecast sensor. Actuals beat forecast for the same slot (deduplicate_and_sort_prices).
  2. Tag each 30-minute slot with a credibility tier by how far ahead it is. Actual is 1.0, under 24h is 0.90, 24–48h is 0.75, 48–72h is 0.60, and beyond 72h is 0.40 (BASE_CREDIBILITY).
  3. Enumerate every contiguous window of at least min_block_hours.
  4. Score each window by a gamble-adjusted price (more on that below).
  5. Drop any window whose average raw price is over max_price. The ceiling is a per-window average, not per-slot (filter_runs_by_max_avg_price), so a cheap window with one brief spike inside it still qualifies.
  6. Use a greedy pass to pick the cheapest non-overlapping set of windows that sums to the hours needed. If no combination of full-sized blocks fits, the last block is allowed to run shorter than the minimum.
# The shape of it (real version is in the repo)
slots = merge(today_actual, tomorrow_actual, forecast)   # actuals win
for slot in slots:
    slot.tier = tier_for(slot, now)                      # actual .. >72h
    slot.effective = risk_adjust(slot.raw, slot.tier, gamble)
windows = [w for w in contiguous_windows(slots, min_block_hours)
           if avg(w.raw) <= max_price]                    # per-window ceiling
schedule = cheapest_non_overlapping(windows, hours_needed)

Selecting non-contiguous blocks across days is the whole point of the rewrite. Two hours tonight and three on Wednesday beats five hours in one mediocre window if the maths says so, and the old single-window search couldn't express that.


Gamble tolerance

Trusting a forecast is a bet. Gamble tolerance is the dial that sets how big a bet I'm willing to place, and it lives in compute_effective_price:

eff_cred = base_cred + (1.0 - base_cred) * (gamble_tolerance / 100.0)
effective_price = raw_price / eff_cred

At a tolerance of 0, the machine divides a forecast slot more than 72h out by its base credibility of 0.40, so it looks 2.5x its quoted price and the scheduler leans hard on confirmed Octopus rates. At 100, it trusts each slot at face value regardless of source. In between, it slides. The max_price ceiling checks the raw price, not this adjusted one, so the dial changes what gets picked without changing what's allowed.

Each scheduled session also reports a confidence score from 0 to 100, the average base credibility of its slots (slots_to_sessions). A block at 100 is locked-in actual rates. A block at 40 is a rough guess three days out. I can read which is which on the dashboard before I decide to trust the plan.


The bits that were harder than they look

Don't evict a live session. Prices update while the car is charging. A fresh evaluation might now prefer a different window and, left alone, would cancel the session I'm halfway through. I pull the active session out and preserve it before re-scheduling anything, and a transient data error can't clear an in-progress slot from persistence (set_all_unavailable). This sounds trivial and was the fiddliest part of the whole thing, because it's a race between a price refresh and a charge already running.

Stop the cycling. The minimum block size and the per-window average ceiling both exist to keep the charger from flicking on and off chasing individual cheap slots.

Only write when something changes. The state machine evaluates every five minutes. If it rewrote its output sensors each time, each downstream automation would fire on an unchanged schedule. set_state_sensor compares first and only writes on a real change.

Survive restarts. The schedule persists in a sensor attribute (store_schedule / get_stored_schedule), so a Home Assistant restart mid-charge resumes the in-progress session rather than starting over.

A timestamp quirk. The Octopus integration emits ISO timestamps with a Z suffix on some Python versions and +00:00 on others, and Python 3.10 refuses to parse Z. _parse_iso_str normalises it. Small, but it's the kind of thing that breaks a schedule without a word on a version bump.


Testing without Home Assistant

The scheduler is pure functions with no Home Assistant dependency, and all the HA I/O sits behind one adapter class, EVChargingHA. That split is what makes the scheduling logic testable at all. The 300-plus tests run against the pure functions with no live HA instance, in Docker so they're reproducible off my own install (run-tests.sh, docker-compose.yml).


Wiring it up

Four PyScript services expose the machine to Home Assistant: update_ev_charge_state is the evaluation tick, ev_charging_boost forces an immediate charge for N hours, ev_charging_boost_cancel drops back to the schedule, and ev_charging_stop clears it. The sample automations wire the run-state-machine trigger, the enable and disable charger rules, and the boost and stop buttons.


Where it's still rough

This runs my charging day to day, but it isn't finished, and your mileage will vary if you pick it up. The core does its job: it schedules against the price curve and it charges the car. The edges are where it gets ropey.

Slot selection sometimes makes an odd choice. My best guess is the boundaries between forecast nodes, plus blocks that don't butt up against each other, tipping the selection into a split it didn't need. It still lands a workable schedule, but not the one I'd have picked by eye.

The stop button is temperamental and often ignores the first press. That's the same Tuya reliability problem from part 1, still with me.

One thing that got easier: the Polestar 2 doesn't need the onboard-charger wake dance the Volvo S90 did. No stop-engine command to knock the OBC out of deep sleep, no watchdog re-poking it every few minutes. The car charges when it's told to.

I could fix the slot selection. But am I going to spend an evening on it to save a few pence here and there? It's not worth the time. It runs, and it's good enough to share.


Making it shareable

This began as my own config, full of my entity names, meter IDs, and device UUIDs. There's no real risk in those going public. It's more mess than hazard, so I swapped them for labelled placeholders like DEVICE_ID_HERE. The README lists the constants to change for your own meter, charger, and car, and there's a sample Polestar 2 dashboard to start from rather than drop in.


References