Smart EV Charging with Octopus Agile and Home Assistant
I've been on the Octopus Agile tariff for a while now. The premise is that electricity prices change every 30 minutes, pegged to the wholesale spot price. During the peaks (morning, evening) they can be expensive. In the middle of the night they regularly drop to 2–3p/kWh (on a good day), and more normally ~6-12p, and occasionally go negative. If you have an EV or PHEV, this is money sitting on the table.
The obvious answer — and one I'd considered — is to just use Octopus Go, which gives you a guaranteed cheap overnight window, typically 6 hours. The problem is I also run a lot of other loads in the house during the day (when the unit rate is far higher on Go compared to the wholesale price and even the prce cap unit rate), Agile on balance works out cheaper despite its volatility.
The next obvious answer is to set a schedule: charge between midnight and 6am and call it done. That works for some people. But I don't use my car every day — rarely on weekdays, sometimes on evenings if something's planned — and that usage pattern changes what I want from the system. If I plug in on a Sunday and there's a forecast storm on Wednesday that'll push wind generation up and prices down, I'd rather the system wait until Wednesday than chew through a mediocre Sunday night.
All of that trying to justify my time investment, but at the end of the day I find it interesting, which is the real reason.
I've decided to write this up, as I think it's an interesting problem and I think it's a good example of the kind of thing Home Assistant can do, and also, it's really hard to explain it to people when they ask.
The car charging dashboard in Home Assistant showing the current state, cheapest period start/end times, and cost per kWh.
Why Not Just Use the Car's Built-In Scheduling?
The Volvo app has a departure time feature. In theory you set a time, it charges before then, done.
In practice: it can't be configured over an API — it's set via the app. That rules out any dynamic control from Home Assistant. And it doesn't know anything about price, so it'd just start charging whenever, at whatever rate. The prediction piece — looking days forward using actual wholesale market forecasts — that's the real value here, and you simply can't get that with on-device scheduling.
The Tariff and Price Data
Octopus Agile publishes the next day's half-hourly prices around 4pm each afternoon, giving you settled rates up until 22:00 the next day. Beyond that, you're in prediction territory.
I use two sources:
-
Octopus Energy integration by BottlecapDave — the gold standard for HA + Octopus. It surfaces your actual half-hourly rates as HA events (
current_day_rates,next_day_rates), running costs, consumption data, and much more. If you're on Octopus, you want this. -
Agile Predict — a community-built forecasting API that predicts Agile prices several days ahead based on wholesale market data. I pull this in as a custom REST sensor, producing
sensor.agile_predictwith apricesattribute containing timestampedagile_predvalues in p/kWh. It's not perfectly accurate, but it's directionally correct and far better than nothing when planning a charge several days out.
Current and predicted Agile rates, showing actual Octopus data alongside the Agile Predict forecasts.
Whole-home electricity usage and cost dashboard.
The Car
I drive a Volvo S90 T8 Extended Range — a PHEV with roughly 19kWh usable battery. I get through a full battery cycle around three times a week on average, sometimes more.
The Volvo is connected via the official Home Assistant Volvo integration (previously the community ha-volvo-cars — the integration eventually made it into core). This gives me access to battery state of charge (sensor.volvo_s90_battery), charging connection status, and a set of actuator buttons.
One practical constraint worth knowing upfront: the 2022 S90 Extended Range OBC supports ~3.5kW AC charging — roughly 16A at 230v — which is why my charging times are on the slower side. Newer Volvo PHEVs have pushed this up to 7.2kW per phase, but that's not a retrofit situation. The QubEV is rated for 7.2kW per phase across up to three phases; I'm on single phase, so the bottleneck is firmly the car. At 3.5kW into a 19kWh battery the maths are about what you'd expect.
Worth noting: I can't use Intelligent Octopus Go — neither my car nor my charger support it. So all the intelligence has to live elsewhere.
The OBC Deep Sleep Problem
Once the car has been locked and sitting idle for a while, the on-board charger (OBC) goes into a deep sleep. This isn't a brief standby — it genuinely stops responding to the charging station.
Here's what actually happens when you try to start a session with a sleeping OBC:
- The charger shows
charger_insert— cable connected, not charging - The system starts a session
- The charger enters
charger_wait— this is actually a mandated UK behaviour where chargers randomly delay session start by 1–10 minutes to prevent grid overload at tariff changeover times (typically 00:30) - After that wait, rather than starting, it enters
charger_pause— and sits there indefinitely until something wakes the car
Eventually the car would wake on its own. But "eventually" isn't particularly useful when you want to charge at 2am and leave at 7am.
The fix: the Volvo API has a "Stop Engine" command — intended for stopping the combustion engine, but it sends a wake signal to the vehicle which brings the OBC back online. I trigger this via button.volvo_s90_stop_engine.
The delay after sending that command is down to the API being asynchronous — I don't know whether the request succeeded, and from experience with the app, there's sometimes a lag of several seconds before it takes effect. 90 seconds is a conservative delay; 30 seconds is often enough.
I used to do this via an unlock/lock sequence, which was more reliable but had an obvious downside: a ~90 second window where the car was unlocked. When the Volvo integration moved from third-party to HACS and then eventually to core, the lock API was removed — which actually solved the problem for me, even if for the wrong reasons.
The important safety check: this automation only fires when the car is confirmed as being connected to my home charger and the charging connection status from the Volvo API confirms it's plugged in. I'm not randomly issuing stop engine commands to the car when it's out and about. I've tested this as well, the Volvo API will NOT let me do this whilst the vehicle is moving.
The Unblock Automation
There's a watchdog that runs every 5 minutes. If charging is desired, the car is connected, the battery is below 95%, and the charger has been in charger_pause for 3+ minutes — it re-sends the stop engine command:
alias: Auto Unblock Charging - Volvo S90
description: >
If charging is desired but the charger has been paused for 3+ minutes,
send the Stop Engine command to wake the OBC.
triggers:
- trigger: time_pattern
minutes: /5
seconds: '30'
conditions:
- entity_id: input_boolean.ev_charging_currently_desired
state: 'on'
- entity_id: sensor.car_charger_work_state
state: charger_pause
for:
minutes: 3
- entity_id: input_boolean.enable_ev_charger_toggle_when_paused
state: 'on'
- type: is_battery_level
entity_id: sensor.volvo_s90_battery
below: 95
- entity_id: sensor.volvo_s90_charging_connection_status
state: connected
for:
minutes: 5
actions:
- entity_id: button.volvo_s90_stop_engine
type: press
The delay before acting is deliberate — I biased this towards not being aggressive, partly to account for the charger_wait state (which can last up to 10 minutes legitimately, it's a requirement in the UK that chargers can't go straight to on), and partly because Tuya control isn't always instantaneous.
The enable_ev_charger_toggle_when_paused boolean is a manual override so I can disable this behaviour when I've deliberately paused charging for another reason.
The Charger
The charger is a Rolec QubEV Smart — a 7.2kW unit. It does support OCPP, but I went with Tuya because I already had LocalTuya running on other devices and it was the path of least resistance at the time.
I'll write a dedicated post on my Tuya network setup at some point — but the short version: my IoT devices are on an isolated VLAN with no inter-device communication and blocked outbound internet access. The EV charger is on that network. LocalTuya runs outside of Home Assistant, DNS spoofing is done at the router level to keep local communication local, and everything that would normally go to Tuya's cloud is blocked at the firewall. I also use XTend Tuya alongside the standard Tuya integration for some extra functionality where LocalTuya is a bit.. not great, and those devices can punch through the firewall.
The charger itself has a mode select — the relevant options are effectively:
| Mode | Meaning |
|---|---|
| Default On | Starts charging immediately when connected |
| Default Off | Waits for a command before starting |
| Charger Now | Managed mode — what the automations use |
| Schedule | Uses the charger's internal schedule (not useful here) |
| Solar | Solar-matched charging (not applicable, no solar) |
The Tuya Reliability Problem
Here's the real issue with Tuya control for a charger: the stop command is often ignored, or it works but then the charger becomes unresponsive to subsequent commands until you physically unplug the car.
I don't have a software workaround for this. The consequence is that I can't use cheapest-slot-per-day mode from something like Target Timeframes — if a stop command gets lost and the charger stays on into a peak period at 3pm, that's a meaningful cost hit. So instead I target a single contiguous block that runs through to completion, rather than cherry-picking individual slots through the day. The contiguous window approach means the charger runs, finishes (or the car sleeps), and the window closes cleanly. Fewer start/stop cycles, fewer opportunities for things to go wrong.
A Note on Whole-Home Monitoring
I don't need a CT clamp on the charger circuit — I have whole-home monitoring already, and the charger reports its own power draw via Tuya. I can see actual charging power in real time without additional hardware.
The Logic: PyScripts
The scheduling logic lives in two Python scripts running via the PyScript integration. The source is in my pyscripts repo on GitHub. Fair warning: this was largely vibe-coded using Kiro (primarily Claude Sonnet 3.7 and 4) — it's been running for nearly a year, started small, and never really got cleaned up. Tests were added later once I moved to an IDE. It works, which is the main thing. (I work for AWS — this is not a plug for this product, I just happen to quite like it.)
agile_forecast_processor.py — Grouping Prices into Time Blocks
This script takes the raw 30-minute price slots and groups them into broader daily blocks:
| Block | Time |
|---|---|
| Nighttime | 23:00 – 06:00 |
| Morning | 06:00 – 12:00 |
| Afternoon | 12:00 – 16:00 |
| Peak | 16:00 – 20:00 |
| Evening | 20:00 – 23:00 |
The reason for these groupings: energy pricing in the UK tends to align fairly well with these periods — night is cheap, peak is expensive, and so on. It's not dynamic and it's not perfect, but it covers 95% of cases. The practical benefit is that it's much easier to write Home Assistant automations against a discrete block like "is it nighttime cheapest" than against a stream of half-hourly values.
For each block it calculates min, max, and average price, producing sensors like sensor.agile_forecast_24_48h through to 144h. The next "Peak" at 16:00 is used as the fixed reference point for aligning 24-hour forecast periods.
update_ev_charging_schedule.py — Finding the Cheapest Window
This is the core. It takes:
- Battery state of charge from the Volvo (
sensor.volvo_s90_battery) - Required charging hours (
input_number.volvo_s90_charging_hours_required, derived from SoC) - "Ready By" deadline (
input_datetime.ev_charger_ready_by) - Price data from both Octopus (settled actuals) and Agile Predict (forecasts)
The two price sources are merged, with actual Octopus rates taking priority over predictions where they overlap. Agile Predict values come in as p/kWh, so they're converted to £/kWh for consistent internal maths.
Then it runs a sliding window search: given N 30-minute slots required, find the cheapest contiguous block that completes before the ready-by deadline. Results are written to input_datetime.ev_charging_start and input_datetime.ev_charging_end.
# Simplified version of the price merging and scheduling logic
# 1. Actual settled rates from Octopus — highest priority
actual_rates = get_octopus_actual_rates() # current_day_rates + next_day_rates
# 2. Multi-day predictions from Agile Predict as fallback
predicted_rates = get_agile_predict_rates() # agile_pred (p/kWh) -> converted to £/kWh
# 3. Merge, preferring actuals over predictions where they overlap
merged = merge_deduplicate(actual_rates, predicted_rates)
# 4. Sliding window: find cheapest contiguous block that completes by ready_by
cheapest = find_cheapest_block(merged, slots_required, ready_by_deadline)
The Entities
These are the holding variables everything reads from and writes to:
input_datetime:
ev_charger_ready_by:
name: "EV Charger Ready By"
has_date: true
has_time: true
ev_charging_start:
name: "EV Charging Start"
has_date: true
has_time: true
ev_charging_end:
name: "EV Charging End"
has_date: true
has_time: true
input_number:
volvo_s90_charging_hours_required:
name: "Charging Hours Required"
min: 0
max: 8
step: 0.5
input_boolean:
ev_charging_currently_desired:
name: "EV Charging Currently Desired"
automatic_ev_state_changes:
name: "Automatic EV State Changes"
set_charge_time_automatically:
name: "Set Charge Time Automatically"
enable_ev_charger_toggle_when_paused:
name: "Toggle Charger When Paused"
The key binary sensor everything pivots around:
template:
- binary_sensor:
- name: "EV Charging Is Cheapest Period"
unique_id: ev_charging_is_cheapest_period
state: >
{{ now() >= states('input_datetime.ev_charging_start') | as_datetime
and now() <= states('input_datetime.ev_charging_end') | as_datetime }}
automatic_ev_state_changes is a master switch for the whole system. I turn it off primarily when someone else is using my charger — otherwise the automations would be fighting their session.
The split between binary_sensor.ev_charging_is_cheapest_period and input_boolean.ev_charging_currently_desired is intentional. The cheapest period sensor just sets the boolean on/off — it doesn't directly control the charger. The charger automation reads the boolean. That indirection means I can add other influences later (a "charge now regardless" button, a SoC ceiling check, whatever) without touching the charger automation itself. It's a natural extension of a microservice mindset: individual components with clear responsibilities, loosely coupled, individually replaceable. Perfectly possible to collapse this into a single automation, but that ties the scheduling logic too tightly to one specific vehicle or charger.
The Automations
Recalculating the Schedule
This fires whenever something meaningful changes — new prices, a change to the ready-by time, a change in required hours, or on a 5-minute fallback poll:
alias: Run EV Charging Schedule PyScript
triggers:
- entity_id: sensor.agile_predict
trigger: state
- entity_id: event.octopus_energy_electricity_..._current_day_rates
trigger: state
- entity_id: event.octopus_energy_electricity_..._next_day_rates
trigger: state
- entity_id: input_datetime.ev_charger_ready_by
trigger: state
- entity_id: input_number.volvo_s90_charging_hours_required
trigger: state
- minutes: /5
trigger: time_pattern
- at: '16:15'
trigger: time # Just after Octopus publishes next-day prices
actions:
- action: pyscript.update_ev_charging_schedule
The 16:15 time trigger is deliberate — Octopus publishes next-day Agile prices around 4pm, so a recalculation shortly after means you get settled actuals as soon as they're available, replacing any predictions for that window. I do also trigger it on state changes, but this thing has evolved and I forgot to remove some of the old triggers...
Turning Charging On and Off
With binary_sensor.ev_charging_is_cheapest_period acting as the source of truth, these two automations are straightforward:
# Enable charging when in the cheapest window
alias: EV Charger - Enable Automatically
triggers:
- trigger: time_pattern
minutes: /15
- entity_id: input_boolean.ev_charging_currently_desired
to: 'on'
trigger: state
conditions:
- entity_id: input_boolean.ev_charging_currently_desired
state: 'on'
- type: is_off
entity_id: switch.ev_charger
- entity_id: select.ev_charger_mode
option: Charger Now
- entity_id: sensor.car_charger_work_state
state: charger_insert
actions:
- type: turn_on
entity_id: switch.ev_charger
# Disable charging when leaving the cheap window
alias: EV Charger - Disable Automatically
triggers:
- trigger: time_pattern
minutes: /1
- entity_id: input_boolean.ev_charging_currently_desired
to: 'off'
trigger: state
conditions:
- entity_id: input_boolean.ev_charging_currently_desired
state: 'off'
- type: is_on
entity_id: switch.ev_charger
actions:
- type: turn_off
entity_id: switch.ev_charger
The enable automation polls every 15 minutes as a belt-and-braces approach — Tuya command reliability being what it is, I'd rather check periodically than rely purely on state-change triggers.
Auto-Setting the "Ready By" Time
When the car is plugged in, the ready-by time is set automatically:
alias: EV Charger Ready By - Set Automatically
triggers:
- at: input_datetime.ev_charger_ready_by
trigger: time
id: rollover
- entity_id: sensor.car_charger_work_state
from: charger_free
to: charger_insert
trigger: state
id: inserted
actions:
- choose:
- conditions:
- condition: trigger
id: rollover
sequence:
- target:
entity_id: input_datetime.ev_charger_ready_by
data:
date: '{{ (now() + timedelta(days=1)).strftime("%Y-%m-%d") }}'
time: "07:00:00"
action: input_datetime.set_datetime
- conditions:
- condition: trigger
id: inserted
sequence:
- choose:
- conditions:
- condition: template
value_template: '{{ now().hour < 2 }}'
sequence:
- target:
entity_id: input_datetime.ev_charger_ready_by
data:
date: '{{ now().strftime("%Y-%m-%d") }}'
time: "08:00:00"
action: input_datetime.set_datetime
default:
- target:
entity_id: input_datetime.ev_charger_ready_by
data:
date: '{{ (now() + timedelta(days=1)).strftime("%Y-%m-%d") }}'
time: "07:00:00"
action: input_datetime.set_datetime
The 7am default is because I seldom drive before then — if I do, I can override it manually. If the car is plugged in after midnight, it sets the ready-by to 8am the same day rather than tomorrow. When the ready-by time passes, it rolls forward to the next day at 7am automatically.
Charging Hours Based on Battery Level
Rather than requiring manual input each session, this derives the required charge time from the current battery level:
alias: Set Volvo S90 Charging Hours Based on Battery Level
triggers:
- entity_id: sensor.volvo_s90_battery
trigger: state
conditions:
- entity_id: input_boolean.set_charge_time_automatically
state: 'on'
- condition: not # Don't update while it's already been connected and charging for a while
conditions:
- entity_id: sensor.volvo_s90_charging_connection_status
state: connected
for:
minutes: 5
actions:
- choose:
- conditions:
- numeric_state:
entity_id: sensor.volvo_s90_battery
above: 85
sequence:
- service: input_number.set_value
data: {value: 1}
target: {entity_id: input_number.volvo_s90_charging_hours_required}
- conditions:
- numeric_state:
entity_id: sensor.volvo_s90_battery
above: 70
sequence:
- service: input_number.set_value
data: {value: 2}
target: {entity_id: input_number.volvo_s90_charging_hours_required}
- conditions:
- numeric_state:
entity_id: sensor.volvo_s90_battery
above: 55
sequence:
- service: input_number.set_value
data: {value: 3}
target: {entity_id: input_number.volvo_s90_charging_hours_required}
- conditions:
- numeric_state:
entity_id: sensor.volvo_s90_battery
above: 40
sequence:
- service: input_number.set_value
data: {value: 4}
target: {entity_id: input_number.volvo_s90_charging_hours_required}
- conditions:
- numeric_state:
entity_id: sensor.volvo_s90_battery
above: 25
sequence:
- service: input_number.set_value
data: {value: 5}
target: {entity_id: input_number.volvo_s90_charging_hours_required}
default:
- service: input_number.set_value
data: {value: 6}
target: {entity_id: input_number.volvo_s90_charging_hours_required}
The thresholds are fairly arbitrary and specific to this car — 7.2kW into a ~19kWh battery at various states of charge. I could make this dynamic and calculated from first principles, but the coarse banding works well enough in practice. At 90% battery you need an hour; fully depleted you need 6 hours. Everything in between falls somewhere reasonable.
The Prediction Display
Car control panel showing charger state, battery percentage, and manual override options.
One of the main things I wanted was visibility into when the car will probably charge, even several days out. Not just "the system will handle it" — an actual estimate.
The flow:
- Car is plugged in, ready-by defaults to 7am tomorrow
- PyScript runs with all available price data (actuals + predictions) and finds the cheapest window
- Predicted start/end times and average cost per kWh are shown in the dashboard
- As actual Octopus rates are published each afternoon (~4pm), predictions are replaced with settled prices and the window may shift slightly
- Inside 24 hours, you have actual half-hourly rates rather than forecasts — accuracy improves significantly
If I plug in on a Sunday and there's a weather forecast suggesting cheap electricity on Wednesday (storm, high wind generation), the system will find that and schedule accordingly. That's the whole point — the prediction layer means you're not just optimising for the next few hours, you're optimising across several days.
Target Timeframes
The Target Timeframes integration is also by BottlecapDave. I use it for other things — hot water immersion heater, some smart plugs — and it's excellent for fixed-duration loads. You give it a target run time and a deadline, and it creates a binary sensor that's on during the cheapest available periods.
For those use cases it's a far simpler setup than what I've described above, and it's the right tool. The car is different: the required charge time varies with battery state, I need forward prediction beyond the 48-hour Octopus window, and I need predictability in the on/off pattern to avoid Tuya getting stuck. So custom it is.
Notifications
State changes on the charger trigger notifications to all my devices via the notify service. I use the HA companion app, which means notifications land on my phone and on my Android Auto head unit.
Notification grouping is particularly useful for the Android Auto display — it means that if I haven't looked at the head unit in a while, I end up with one notification per logical group rather than a stack of them. Combined with tags (which replace an existing notification rather than adding a new one), it stays manageable:
| State | Message | Urgency |
|---|---|---|
charger_insert |
Car connected | Active |
charger_free |
Car disconnected | Active |
charger_wait |
Charging start delayed | Passive |
charger_charging |
Charging started | Active |
charger_pause |
Charging paused | Active |
charger_end |
Charging complete | Active |
charger_free_fault |
Charger faulty (no car) | Time-sensitive |
charger_fault |
Charger faulty | Time-sensitive |
The urgency levels are deliberate. A charger_end notification is active — I care that it finished. A charger_wait is passive because it's informational and I don't need to do anything.
alias: EV Charger state notifications
triggers:
- entity_id: sensor.car_charger_work_state
not_from: unavailable
trigger: state
actions:
- choose:
- conditions:
- entity_id: sensor.car_charger_work_state
state: charger_charging
sequence:
- action: notify.charles_all_devices
data:
title: "EV Charger"
message: "Charging started"
data:
group: "EV Charger"
tag: "ev-charger-state-charging"
channel: "EV Charger"
url: /dashboard-car/car-charger
push:
interruption-level: active
- conditions:
- entity_id: sensor.car_charger_work_state
state: charger_end
sequence:
- action: notify.charles_all_devices
data:
title: "EV Charger"
message: "Charging complete"
data:
group: "EV Charger"
tag: "ev-charger-state-end"
push:
interruption-level: active
# ... and so on for each state
The Not-So-Great Parts
Tuya reliability — as mentioned, the stop command is unreliable. This is the single biggest source of friction in the whole setup. If I were starting fresh with this charger, I'd use OCPP.
No effective "finished" detection — I don't have a reliable way to know when the car is genuinely full and stop charging gracefully. What actually happens: the car charges to near-full, then the OBC goes back to sleep. Every few hours, when the system enters a new cheap timeslot, it tries to start a session — charger_wait, then eventually charger_pause again. Annoying, but not the end of the world.
The silver lining: keeping the charger eager to reconnect means that when I run climate or pre-conditioning (which wakes the OBC briefly), power is available immediately and the car stays topped up. In practice I'd rather have this behaviour than not — I'd just like to suppress the notifications when the battery percentage hasn't actually moved. That's a future improvement.
The Volvo API being async — I send a wake command and have no direct confirmation it worked. I add delays and hope for the best, which is about as sophisticated as it sounds.
Predictions - they're just that, predictions. They can be wrong, they can change suddenly. And sometimes no predictions get returned (usually when one of THEIR upstreams change a data format), it's open source and free, i'm not complaining. When this happens, I just fall back to the known pricing slots direct from octopus.
Edge Cases - i've not tested this for all possible edge cases, but it's been working well for me. I've never once walked out to a car that's not charged (having a PHEV, I care less about this anyway). But that's not to say some scenario with windows/slots aligning could become true that my logic (with no real tests) hadn't considered.
Results
My average charging cost across 2025 was ~8.1p/kWh. That's slightly higher than Octopus Go's fixed overnight rate, but the lower daytime costs on Agile mean the overall household energy bill balances out better. The car does roughly three full battery cycles a week, so the per-kWh rate matters.
The prediction-based approach has genuinely caught some very cheap windows — negative-price nights after windy days, extended low-rate periods that a fixed schedule would have missed. Whether that fully offsets the complexity vs. just running Octopus Go is a reasonable question, but the visibility alone is worth it to me. I like knowing what the system is planning and why.
References
- Octopus Energy HA integration — BottlecapDave
- Target Timeframes integration — BottlecapDave
- Agile Predict API
- ha-volvo-cars — thomasddn (community, now superseded)
- Home Assistant Volvo integration
- Home Assistant Tuya integration
- LocalTuya
- XTend Tuya
- PyScript
- My pyscripts repo
- Rolec QubEV Smart