Back to Home
Charles

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.


Charging Dashboard 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:

  1. 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.

  2. 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_predict with a prices attribute containing timestamped agile_pred values 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.

Agile Pricing Dashboard Current and predicted Agile rates, showing actual Octopus data alongside the Agile Predict forecasts.

General Electricity Supply Dashboard 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:

  1. The charger shows charger_insert — cable connected, not charging
  2. The system starts a session
  3. 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)
  4. 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

Vehicle Control Dashboard 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:

  1. Car is plugged in, ready-by defaults to 7am tomorrow
  2. PyScript runs with all available price data (actuals + predictions) and finds the cheapest window
  3. Predicted start/end times and average cost per kWh are shown in the dashboard
  4. As actual Octopus rates are published each afternoon (~4pm), predictions are replaced with settled prices and the window may shift slightly
  5. 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