Skip to content

Instantly share code, notes, and snippets.

@mikezs
Last active April 28, 2026 08:09
Show Gist options
  • Select an option

  • Save mikezs/ce72828069ce0acd2be1d5222b1187ab to your computer and use it in GitHub Desktop.

Select an option

Save mikezs/ce72828069ce0acd2be1d5222b1187ab to your computer and use it in GitHub Desktop.
ebike-smart-charge.yaml
blueprint:
name: 🚲 E-Bike Smart Charge (v0.0.1a6)
description: >
πŸ”‹ Charges an e-bike battery during off-peak hours via a smart plug,
stopping automatically when the target charge level is reached.
## ⚑ How it works
The blueprint measures energy delivered via the smart plug's kWh sensor
and accounts for charger efficiency to calculate how much wall energy is
needed to reach your target battery level. Every 5 minutes during charging
it updates an input_number helper with the estimated current battery level,
so you can pause mid-charge and the state stays accurate.
## πŸ”§ Required helpers (create once in Settings β†’ Helpers)
Create two **Number** helpers before setting up this automation:
1. **Current Battery Level** β€” range 0–100, step 1, unit %. Update this
from your bike display before each charge. The automation keeps it
current during charging, so after a completed charge you only need to
update it if you've ridden since.
2. **Charge Target** β€” range 0–100, step 5, unit %. Change this each week
depending on your planned ride. Use 80% for battery longevity on regular
commutes, 100% for long rides.
Add both helpers to a dashboard card so they're easy to update.
## ⏰ Hard cutoff time
The cutoff is optional. Enable it to ensure charging always stops within
your cheap-rate electricity window. Disable it to let charging run as long
as needed to reach the target (useful if you start charging earlier in the
evening at normal rate and just want it to finish automatically).
## πŸ”‹ Storage charge
When enabled, the automation will also charge the battery up to a lower
storage level (default 50%) on nights that are not ride nights. This keeps
the battery healthy during periods of low use. The storage charge has its
own start and end times so it can run in a different off-peak window from
the ride charge. It always stops at its configured end time. On any night
where a ride charge would run, the storage charge is automatically skipped.
## πŸ”„ Restart survival
If Home Assistant restarts mid-charge, the automation automatically resumes
when HA starts up again. It detects that the smart plug is still on and that
the current time is within the charge window, then picks up from the last
saved battery level. No energy is double-counted.
## πŸ›‘ Smart stop detection
The automation watches the smart plug's live power draw and reacts to
sudden drops. When power falls below 5 W for 3 continuous minutes:
- **If you're near the target** (β‰₯90% of estimated need delivered), the
charge is treated as **complete**.
- **If you're not near the target**, the charge is treated as
**interrupted** (most likely the battery was unplugged from the
charger). The notification reports how much was delivered before the
disconnection.
## πŸ’― 100% target β€” special handling
When the charge target is set to **100%**, energy-based completion is
disabled entirely. E-bike chargers enter a CC/CV trickle phase near full
where they add real battery percentage while drawing little wall energy,
so our kWh estimate stops the charge a few percent short. At 100% the
only natural-completion signal is the charger's own power drop (handled
by Smart stop detection above). The cutoff time still applies as a
backstop. For any target below 100%, energy-based completion is used
as normal.
## 🎯 Tuning charger efficiency
If the battery level shown on your bike display after a completed charge
doesn't match your target, adjust the efficiency setting: increase it if
HA stopped too early (charger is more efficient than assumed), decrease it
if it ran too long. The smart stop detection above acts as a backstop
when efficiency is slightly off, especially at high charge targets.
## πŸ”„ Auto-reset target after ride charge
Lithium batteries are happiest when not sitting at high charge levels for
long periods. If you bump the Charge Target to 100% for a long ride and
forget to reset it, the next charge will fill to 100% again even if you
only need 80%. When enabled, this option automatically resets the target
helper back to a default (80% recommended) after each successful ride
charge completes. Storage charges and interrupted/cutoff ride charges
don't trigger the reset β€” only successful natural completions do.
## πŸ”Œ Smart plug accuracy
Many smart plugs under-report (or over-report) energy by 10–25%, which
is independent from charger efficiency. Use **Plug Accuracy Factor** to
correct for this. Calibrate by running a known load through the plug β€”
e.g. a 100 W incandescent bulb for 1 hour should deliver 100 Wh, or a
kettle whose label wattage and boil time give an expected Wh. The factor
is `actual_wh / reported_wh`: set above 1.00 if your plug under-reads,
below 1.00 if it over-reads. Leave at 1.00 if you trust the plug.
domain: automation
input:
charger_switch:
name: Charger Switch
description: The smart plug switch entity controlling the charger.
selector:
entity:
domain: switch
energy_sensor:
name: Energy Sensor (kWh)
description: >
The cumulative energy sensor on the smart plug (device class: energy,
unit kWh). Most smart plugs list this as "Energy" in the device page.
selector:
entity:
domain: sensor
device_class: energy
power_sensor:
name: Power Sensor (W)
description: >
The live power sensor on the smart plug (device class: power, unit W).
Used to verify the battery is actually connected and drawing power
shortly after the charger turns on.
selector:
entity:
domain: sensor
device_class: power
current_level_helper:
name: Current Battery Level (%)
description: >
An input_number helper showing the estimated current charge level of
the battery. Update this from your bike display before each charge.
The automation will keep it updated every 5 minutes while charging.
selector:
entity:
domain: input_number
charge_target_helper:
name: Charge Target (%)
description: >
An input_number helper showing the desired charge level to reach.
Change this each week β€” use 80% to be kind to the battery on normal
weeks, 100% before a long ride.
selector:
entity:
domain: input_number
enable_target_reset:
name: Reset Target After Ride Charge
description: >
When enabled, the Charge Target helper resets to a default value
(below) after each successful ride charge. Useful so a one-off bump
to 100% for a long ride doesn't leave the target at 100% for next
week's regular ride. Storage charges and interrupted/cutoff/manual
ride charges don't trigger the reset.
default: true
selector:
boolean: {}
target_reset_pct:
name: Default Ride Target (%)
description: >
The value to reset the Charge Target helper to after a successful
ride charge. 80% is the standard recommendation β€” kind to the
battery while leaving plenty of range. Only used when "Reset Target
After Ride Charge" is enabled.
default: 80
selector:
number:
min: 0
max: 100
step: 5
unit_of_measurement: "%"
battery_capacity_wh:
name: Battery Capacity (Wh)
description: >
The total energy capacity of your e-bike battery in watt-hours.
Check your battery label or manual β€” common sizes are 250–750 Wh.
Set this once and leave it.
default: 500
selector:
number:
min: 100
max: 3000
step: 10
unit_of_measurement: Wh
charger_efficiency:
name: Charger Efficiency (%)
description: >
How efficiently the charger converts wall power to battery power.
A 95% efficient charger draws 100 Wh from the wall to deliver 95 Wh
to the battery. Most quality e-bike chargers are 90–98%. Start with
95 and adjust over time: if your bike display shows less than the
target after charging, lower this value (charger is less efficient
than assumed); if HA stopped early and the battery isn't full enough,
raise it.
default: 95
selector:
number:
min: 70
max: 100
step: 1
unit_of_measurement: "%"
plug_accuracy_factor:
name: Smart Plug Accuracy Factor
description: >
Calibration multiplier applied to the smart plug's energy readings.
Many cheap smart plugs under-report by 10–25%. Set this to
`actual_wh / reported_wh` from a known-load test (e.g. a 100 W bulb
run for 1 hour should report 100 Wh). Values above 1.00 mean the plug
under-reads (corrects upward); below 1.00 means it over-reads. Leave
at 1.00 if you trust the plug or haven't calibrated yet.
default: 1.00
selector:
number:
min: 0.7
max: 1.3
step: 0.01
charge_days:
name: Ride Days
description: >
The day(s) you want the bike ready to ride. If your start time is in
the evening (6pm or later), charging begins the night before β€” so
select Sunday if you want to charge Saturday night ready for a Sunday
ride. If your start time is in the morning or afternoon, select the
same day you want to ride.
selector:
select:
multiple: true
options:
- Monday
- Tuesday
- Wednesday
- Thursday
- Friday
- Saturday
- Sunday
charge_start_time:
name: Charge Start Time
description: When to start charging. Defaults to the start of the cheap-rate window.
default: "23:30:00"
selector:
time: {}
use_cutoff_time:
name: Use Hard Cutoff Time
description: >
When enabled, charging always stops at the cutoff time below regardless
of whether the target has been reached. Enable this if you want to stay
within a cheap-rate electricity window. When disabled, charging runs
until the target is reached (or the plug is turned off manually).
default: true
selector:
boolean: {}
charge_end_time:
name: Charge End Time (Hard Cutoff)
description: >
The time to stop charging regardless of progress. Only used when
"Use Hard Cutoff Time" is enabled above. Set to the end of your
cheap electricity window.
default: "05:30:00"
selector:
time: {}
enable_storage_charge:
name: Enable Storage Charge
description: >
When enabled, the battery will be charged to the storage level on
non-ride nights. Useful for keeping the battery healthy during weeks
when you're not riding.
default: false
selector:
boolean: {}
storage_target_pct:
name: Storage Charge Target (%)
description: >
The battery level to charge to on non-ride nights. 50% is the
recommended storage level for most lithium batteries.
default: 50
selector:
number:
min: 10
max: 90
step: 5
unit_of_measurement: "%"
storage_start_time:
name: Storage Charge Start Time
description: When to start the storage charge. Can be the same as or different from the ride charge start time.
default: "23:30:00"
selector:
time: {}
storage_end_time:
name: Storage Charge End Time
description: >
The storage charge always stops by this time. Set to the end of your
cheap electricity window for storage charging.
default: "05:30:00"
selector:
time: {}
price_per_kwh:
name: Electricity Price (per kWh)
description: >
The cost of electricity per kWh in your local currency. Used to estimate
the cost of each charge session in the completion notification. Set this
to your cheap-rate tariff price β€” e.g. 0.075 for 7.5p/kWh.
default: 0.075
selector:
number:
min: 0.01
max: 1.00
step: 0.001
unit_of_measurement: "/kWh"
enable_soc_reminder:
name: Enable Battery Level Reminder
description: >
When enabled, a notification is sent on each ride day evening reminding
you to update the current battery level helper after your ride. This
ensures the storage charge (if enabled) uses an accurate starting level.
default: true
selector:
boolean: {}
soc_reminder_time:
name: Battery Level Reminder Time
description: >
The time on ride days to send the reminder. Set to sometime after you
typically return from your ride, e.g. 20:00.
default: "20:00:00"
selector:
time: {}
trigger:
- platform: time
at: !input charge_start_time
id: ride
- platform: time
at: !input storage_start_time
id: storage
- platform: time
at: !input soc_reminder_time
id: soc_reminder
- platform: homeassistant
event: start
id: restart
variables:
charger_switch: !input charger_switch
energy_sensor: !input energy_sensor
power_sensor: !input power_sensor
current_level_helper: !input current_level_helper
charge_target_helper: !input charge_target_helper
enable_target_reset: !input enable_target_reset
target_reset_pct: !input target_reset_pct
battery_capacity_wh: !input battery_capacity_wh
charger_efficiency: !input charger_efficiency
plug_accuracy_factor: !input plug_accuracy_factor
charge_days: !input charge_days
charge_start_time: !input charge_start_time
use_cutoff_time: !input use_cutoff_time
charge_end_time: !input charge_end_time
enable_storage_charge: !input enable_storage_charge
storage_target_pct: !input storage_target_pct
storage_start_time: !input storage_start_time
storage_end_time: !input storage_end_time
price_per_kwh: !input price_per_kwh
enable_soc_reminder: !input enable_soc_reminder
soc_reminder_time: !input soc_reminder_time
condition:
- condition: template
value_template: >
{% set start_hour = charge_start_time.split(':')[0] | int %}
{% set check_day = (now() + timedelta(days=1)).strftime('%A') if start_hour >= 18 else now().strftime('%A') %}
{% set is_ride_night = check_day in charge_days %}
{% if trigger.id == 'soc_reminder' %}
{{ enable_soc_reminder and now().strftime('%A') in charge_days }}
{% elif trigger.id == 'ride' %}
{{ is_ride_night and
states(charge_target_helper) | float(0) > states(current_level_helper) | float(0) }}
{% elif trigger.id == 'storage' %}
{{ enable_storage_charge and
not is_ride_night and
states(current_level_helper) | float(0) < storage_target_pct | float }}
{% else %}
{# restart: plug must be on, within ride or storage window, and something left to charge #}
{% if use_cutoff_time %}
{% set r_end = today_at(charge_end_time) %}
{% if r_end <= today_at(charge_start_time) %}{% set r_end = r_end + timedelta(days=1) %}{% endif %}
{% set in_ride_window = now() >= today_at(charge_start_time) and now() < r_end %}
{% else %}
{% set s_min = charge_start_time.split(':')[0] | int * 60 + charge_start_time.split(':')[1] | int %}
{% set in_ride_window = now().hour * 60 + now().minute >= s_min or now().hour < 6 %}
{% endif %}
{% if enable_storage_charge %}
{% set st_end = today_at(storage_end_time) %}
{% if st_end <= today_at(storage_start_time) %}{% set st_end = st_end + timedelta(days=1) %}{% endif %}
{% set in_storage_window = now() >= today_at(storage_start_time) and now() < st_end %}
{% else %}
{% set in_storage_window = false %}
{% endif %}
{% set needs_charge = states(charge_target_helper) | float(0) > states(current_level_helper) | float(0) or
(enable_storage_charge and states(current_level_helper) | float(0) < storage_target_pct | float) %}
{{ is_state(charger_switch, 'on') and (in_ride_window or in_storage_window) and needs_charge }}
{% endif %}
action:
# SOC reminder: notify and stop β€” nothing else to do.
- if:
- condition: template
value_template: "{{ trigger.id == 'soc_reminder' }}"
then:
- service: notify.persistent_notification
data:
title: "🚲 Update Your Battery Level"
message: >
πŸ”‹ Don't forget to update your current battery level after today's
ride. It's currently set to {{ states(current_level_helper) | round(0) }}%.
Update it in the dashboard before tonight's charge window.
- stop: "SOC reminder sent"
- variables:
# Single {{ }} expression so HA stores a clean Python string, not a
# rendered block with surrounding whitespace that would always be truthy.
charge_type: >
{{ 'storage' if (trigger.id == 'storage' or
(trigger.id == 'restart' and enable_storage_charge and
((now() + timedelta(days=1)).strftime('%A') if charge_start_time.split(':')[0] | int >= 18
else now().strftime('%A')) not in charge_days)) else 'ride' }}
current_pct: "{{ states(current_level_helper) | float(0) }}"
target_pct: >
{{ storage_target_pct | float if charge_type == 'storage'
else states(charge_target_helper) | float(80) }}
# Wall energy needed = battery energy needed Γ· efficiency.
# Then divide by plug_accuracy_factor: we compare against the plug's
# reported reading, and if the plug under-reads (factor > 1), each
# reported Wh represents more actual Wh so we stop sooner.
needed_kwh: >
{{ ((target_pct - current_pct) / 100 * battery_capacity_wh | float)
/ (charger_efficiency | float / 100)
/ (plug_accuracy_factor | float) / 1000 }}
start_energy: "{{ states(energy_sensor) | float(0) }}"
cutoff_dt: >
{% if charge_type == 'storage' %}
{% set end = today_at(storage_end_time) %}
{% if end <= now() %}{% set end = end + timedelta(days=1) %}{% endif %}
{{ end.isoformat() }}
{% elif use_cutoff_time %}
{% set end = today_at(charge_end_time) %}
{% if end <= now() %}{% set end = end + timedelta(days=1) %}{% endif %}
{{ end.isoformat() }}
{% else %}
none
{% endif %}
# Turn on the plug only if it's currently off.
# On a restart-recovery run the plug is already on, so we skip this.
- if:
- condition: template
value_template: "{{ is_state(charger_switch, 'off') }}"
then:
- service: switch.turn_on
target:
entity_id: !input charger_switch
# Wait for the plug to confirm it's on (state updates are async).
- wait_template: "{{ is_state(charger_switch, 'on') }}"
timeout:
seconds: 10
# Give the charger a moment to start drawing power, then check the battery
# is actually connected. A real charger will draw well above 5 W within
# 20 seconds; near-zero means nothing is plugged in.
- delay:
seconds: 20
- if:
- condition: template
value_template: "{{ states(power_sensor) | float(0) < 5 }}"
then:
- service: switch.turn_off
target:
entity_id: !input charger_switch
- service: notify.persistent_notification
data:
title: "🚲 E-Bike Charger Warning"
message: >
⚠️ Charger turned on but no power draw detected after 20 seconds.
Is the battery plugged into the charger? Charging has been cancelled.
- stop: "No power draw detected β€” battery may not be connected"
# Wait for any exit condition to fire. The wait times out every 5 minutes
# so we can update the current-level helper, which keeps mid-charge pauses
# and HA-restart recoveries accurate. Each exit trigger has an id: we can
# read back from wait.trigger to determine why we stopped.
#
# condition: numeric_state doesn't support for:, but triggers do β€” that's
# why this is structured as wait_for_trigger rather than repeat: while.
- repeat:
sequence:
- wait_for_trigger:
# Template trigger (not numeric_state) because numeric_state
# above:/below: don't reliably accept action-scoped variables β€”
# they evaluate to None at trigger setup time.
#
# When target is 100% the energy estimate is unreliable: e-bike
# chargers enter a CC/CV trickle phase near full that adds a few
# percent of real charge while delivering little wall energy. So
# at 100%, we suppress this trigger entirely and let power_dropped
# be the only natural-completion signal (cutoff is still a backstop).
- platform: template
value_template: >
{{ target_pct < 100
and (states(energy_sensor) | float(0) - start_energy) >= needed_kwh }}
id: target_reached
- platform: state
entity_id: !input charger_switch
to: "off"
id: manual
# Smart stop: power below 5W for 3 continuous minutes means the
# charger has finished (if near target) or the battery has been
# disconnected (if not). The 3-minute window absorbs brief CC/CV
# transition dips without false-tripping. Differentiated post-loop.
- platform: numeric_state
entity_id: !input power_sensor
below: 5
for:
minutes: 3
id: power_dropped
timeout:
minutes: 5
continue_on_timeout: true
# Wait timed out (no exit trigger fired) β€” update the helper and loop.
- if:
- condition: template
value_template: "{{ wait.trigger is none }}"
then:
- variables:
delivered_kwh: "{{ states(energy_sensor) | float(0) - start_energy }}"
- service: input_number.set_value
target:
entity_id: !input current_level_helper
data:
# Convert reported kWh β†’ actual wall Wh (Γ— plug_accuracy_factor)
# β†’ battery Wh (Γ— efficiency) β†’ battery %.
value: >
{{ [current_pct + (delivered_kwh * (plug_accuracy_factor | float)
* (charger_efficiency | float / 100)
* 1000 / battery_capacity_wh | float * 100),
target_pct] | min | round(1) }}
until:
- condition: template
value_template: >
{% set past_cutoff = cutoff_dt != 'none' and now() >= cutoff_dt | as_datetime %}
{{ wait.trigger is not none or past_cutoff }}
# Determine why we stopped. wait.trigger.id tells us which exit fired
# (target_reached / manual / power_dropped); if it's None we hit the cutoff.
# power_dropped splits into charger_finished vs interrupted based on how
# close we got to the target.
- variables:
final_delivered_kwh: "{{ states(energy_sensor) | float(0) - start_energy }}"
exit_reason: >-
{{ (('charger_finished' if final_delivered_kwh >= needed_kwh * 0.9
else 'interrupted')
if (wait.trigger is not none and wait.trigger.id == 'power_dropped')
else (wait.trigger.id if wait.trigger is not none else 'cutoff')) }}
- service: switch.turn_off
target:
entity_id: !input charger_switch
# Final accurate helper update and notification. final_wall_wh is the
# corrected actual energy (reported Γ— plug_accuracy_factor), so the cost
# line and battery estimate reflect reality, not the plug's biased reading.
# final_pct is computed here once and reused by both the helper-update
# service call and the notification.
- variables:
final_wall_wh: >
{{ (final_delivered_kwh * 1000 * (plug_accuracy_factor | float)) | round(0) }}
final_battery_wh: >
{{ (final_wall_wh | float * (charger_efficiency | float / 100)) | round(0) }}
charge_cost: >
{{ (final_wall_wh | float / 1000 * price_per_kwh | float) | round(3) }}
# When the charger says it's done (target reached or power dropped near
# target), trust that and pin to target_pct. Otherwise use the
# calculated estimate β€” best info we have for partial charges.
final_pct: >-
{{ target_pct | float | round(1)
if exit_reason in ['target_reached', 'charger_finished']
else ([current_pct + (final_battery_wh | float
/ battery_capacity_wh | float * 100),
target_pct] | min | round(1)) }}
- service: input_number.set_value
target:
entity_id: !input current_level_helper
data:
value: "{{ final_pct }}"
# Auto-reset the ride target after a successful ride charge so a one-off
# bump to 100% doesn't carry over. Skipped on storage charges (different
# target entirely), and on interrupted/cutoff/manual exits (likely want
# to retry tomorrow with the same target). Also skipped if the target is
# already at the reset value, to avoid a noise state-change event.
- variables:
target_was_reset: >
{{ enable_target_reset
and charge_type == 'ride'
and exit_reason in ['target_reached', 'charger_finished']
and (target_reset_pct | float) != (target_pct | float) }}
- if:
- condition: template
value_template: "{{ target_was_reset }}"
then:
- service: input_number.set_value
target:
entity_id: !input charge_target_helper
data:
value: "{{ target_reset_pct }}"
- service: notify.persistent_notification
data:
title: "🚲 E-Bike {{ 'πŸ”‹ Storage' if charge_type == 'storage' else '🚴 Ride' }} Charge {{ 'Interrupted' if exit_reason == 'interrupted' else 'Complete' }}"
message: >
πŸ“Š Charged from {{ current_pct | round(1) }}% β†’ {{ final_pct }}%
(target {{ target_pct | round(1) }}%).
⚑ Drew {{ final_wall_wh }} Wh from the wall, delivered ~{{ final_battery_wh }} Wh
to the battery ({{ charger_efficiency }}% efficiency).
Needed {{ (needed_kwh * 1000) | round(0) }} Wh from the wall.
πŸ’° Estimated cost: {{ charge_cost }} (@ {{ price_per_kwh }}/kWh).
{% if exit_reason == 'target_reached' %}βœ… Target reached.
{% elif exit_reason == 'charger_finished' %}πŸ”Œ Charger finished β€” power dropped near target.
{% elif exit_reason == 'interrupted' %}⚠️ Charge interrupted β€” battery may have been disconnected (power dropped well before reaching target).
{% elif exit_reason == 'cutoff' %}⏰ Stopped at cutoff time.
{% else %}πŸ›‘ Stopped early β€” plug turned off manually.{% endif %}
{% if target_was_reset %}
πŸ”„ Target reset to {{ target_reset_pct }}% for next charge.{% endif %}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment