E-Bike Charging Monitor with Self-Calibrating SOC Estimation (Shelly Plug PM)

If you charge an e-bike (or any battery-powered device) through a Shelly plug with power monitoring, here’s a setup that goes beyond a simple on/off relay: it estimates the battery’s State of Charge from energy consumption, shows a live time-remaining estimate, automatically cuts power once the target % (or 100%) is reached, and self-calibrates its own accuracy over time.

Hardware

Any Shelly plug with power monitoring (Plug S, Plus Plug S, Pro, etc.) wired to the charger. That’s it, no extra sensors needed, everything else is template sensors and automations inside Home Assistant.

How it works

A template sensor estimates SOC using the % you set as the starting point, plus the energy consumed since charging began, divided by a calibration factor (Wh needed per 1% of battery). Four automations manage the session lifecycle: detecting charge start (power rises above a threshold for a few seconds), detecting the target % being reached (switches the plug off immediately), detecting real charge end/idle (power drops and stays low for a few minutes, in case the battery’s own BMS stops charging before your target), and, the interesting part, auto-calibration.

Every time you confirm or correct the “real final %” (because you actually checked the bike’s own display), the automation compares your correction against its own estimate and nudges the Wh-per-percent calibration factor with a weighted average (70% old value / 30% newly observed value), clamped to a sane range. A small counter tracks how many calibration passes have run, so you can see the estimate’s accuracy improve the more you use it.

The dashboard card (Mushroom cards) shows time remaining, live power/energy, starting/target/real % sliders, and a status tile whose icon changes color: grey battery when off, red while actively charging, green once the target or 100% is reached.

Dashboard

Blueprint

I packaged the four automations into a single blueprint (using trigger IDs + choose, so it’s one automation instead of four). You’ll still need to create the helper entities (a few input_number/input_boolean/counter helpers) and the “calculated SOC” template sensor yourself, see the comments in the blueprint for what’s expected. I adapted this from a working setup I run daily, but haven’t independently tested the blueprint packaging itself, so feedback welcome if you hit snags adapting it to your entities.

blueprint:
  name: E-Bike Charging Monitor & Self-Calibrating SOC (Shelly PM)
  description: >
    Tracks a charging session using a Shelly power-monitoring plug, estimates
    battery State of Charge from energy consumption, cuts power at the
    target % (or near 100%), and self-calibrates the Wh-per-percent ratio
    every time you correct the real final charge level. Requires a set of
    input_number/input_boolean/counter helpers and a "calculated SOC"
    template sensor (see forum post for setup notes).
  domain: automation
  input:
    power_sensor:
      name: Charger power sensor
      selector:
        entity:
          domain: sensor
          device_class: power
    energy_sensor:
      name: Charger energy sensor (kWh)
      selector:
        entity:
          domain: sensor
          device_class: energy
    charger_switch:
      name: Charger switch
      selector:
        entity:
          domain: switch
    calculated_soc_sensor:
      name: Calculated SOC template sensor (you create this, see notes)
      selector:
        entity:
          domain: sensor
    start_soc_helper:
      name: "Helper: starting % (input_number)"
      selector:
        entity:
          domain: input_number
    target_soc_helper:
      name: "Helper: target % (input_number)"
      selector:
        entity:
          domain: input_number
    real_final_soc_helper:
      name: "Helper: real final % (input_number)"
      selector:
        entity:
          domain: input_number
    wh_per_percent_helper:
      name: "Helper: Wh-per-percent calibration (input_number)"
      selector:
        entity:
          domain: input_number
    session_start_soc_helper:
      name: "Helper: session start % (input_number, internal)"
      selector:
        entity:
          domain: input_number
    session_start_energy_helper:
      name: "Helper: session start energy (input_number, internal)"
      selector:
        entity:
          domain: input_number
    charging_active_helper:
      name: "Helper: charging-active (input_boolean)"
      selector:
        entity:
          domain: input_boolean
    calibration_counter:
      name: "Helper: calibration counter (optional, counter)"
      default: ""
      selector:
        entity:
          domain: counter
    start_power_threshold:
      name: Power threshold to detect charging start (W)
      default: 15
      selector:
        number: { min: 1, max: 200, unit_of_measurement: W }
    end_power_threshold:
      name: Power threshold to detect charging end (W)
      default: 5
      selector:
        number: { min: 1, max: 100, unit_of_measurement: W }
    notify_start_actions:
      name: Actions to run when charging starts (e.g. notifications)
      default: []
      selector:
        action: {}
    notify_target_actions:
      name: Actions to run when target % is reached
      default: []
      selector:
        action: {}
    notify_end_actions:
      name: Actions to run when charging ends
      default: []
      selector:
        action: {}

triggers:
  - id: start
    trigger: numeric_state
    entity_id: !input power_sensor
    above: !input start_power_threshold
    for: { seconds: 10 }
  - id: target
    trigger: numeric_state
    entity_id: !input calculated_soc_sensor
    above: !input target_soc_helper
  - id: end
    trigger: numeric_state
    entity_id: !input power_sensor
    below: !input end_power_threshold
    for: { minutes: 5 }
  - id: calibrate
    trigger: state
    entity_id: !input real_final_soc_helper

conditions: []

actions:
  - variables:
      power_sensor: !input power_sensor
      energy_sensor: !input energy_sensor
      charger_switch: !input charger_switch
      calculated_soc_sensor: !input calculated_soc_sensor
      start_soc_helper: !input start_soc_helper
      target_soc_helper: !input target_soc_helper
      real_final_soc_helper: !input real_final_soc_helper
      wh_per_percent_helper: !input wh_per_percent_helper
      session_start_soc_helper: !input session_start_soc_helper
      session_start_energy_helper: !input session_start_energy_helper
      charging_active_helper: !input charging_active_helper
      calibration_counter: !input calibration_counter
  - choose:
      - conditions:
          - condition: trigger
            id: start
          - condition: state
            entity_id: !input charging_active_helper
            state: "off"
        sequence:
          - action: input_number.set_value
            target: { entity_id: !input session_start_energy_helper }
            data: { value: "{{ states(energy_sensor) | float(0) }}" }
          - action: input_number.set_value
            target: { entity_id: !input session_start_soc_helper }
            data: { value: "{{ states(start_soc_helper) | float(0) }}" }
          - action: input_boolean.turn_on
            target: { entity_id: !input charging_active_helper }
          - choose: []
            default: !input notify_start_actions

      - conditions:
          - condition: trigger
            id: target
          - condition: state
            entity_id: !input charging_active_helper
            state: "on"
        sequence:
          - action: switch.turn_off
            target: { entity_id: !input charger_switch }
          - choose: []
            default: !input notify_target_actions

      - conditions:
          - condition: trigger
            id: end
          - condition: state
            entity_id: !input charging_active_helper
            state: "on"
        sequence:
          - variables:
              final_soc: "{{ states(calculated_soc_sensor) | float(0) }}"
          - action: input_number.set_value
            target: { entity_id: !input start_soc_helper }
            data: { value: "{{ final_soc | round(0) }}" }
          - action: switch.turn_off
            target: { entity_id: !input charger_switch }
          - action: input_boolean.turn_off
            target: { entity_id: !input charging_active_helper }
          - action: input_number.set_value
            target: { entity_id: !input real_final_soc_helper }
            data: { value: "{{ (100 if final_soc >= 97 else final_soc) | round(0) }}" }
          - choose: []
            default: !input notify_end_actions

      - conditions:
          - condition: trigger
            id: calibrate
          - condition: state
            entity_id: !input charging_active_helper
            state: "off"
        sequence:
          - variables:
              real_final: "{{ states(real_final_soc_helper) | float(0) }}"
              start_before: "{{ states(session_start_soc_helper) | float(0) }}"
              wh_now: "{{ states(energy_sensor) | float(0) * 1000 }}"
              wh_start: "{{ states(session_start_energy_helper) | float(0) * 1000 }}"
              consumed: "{{ wh_now - wh_start }}"
              real_gain: "{{ real_final - start_before }}"
              old_factor: "{{ states(wh_per_percent_helper) | float(9.1) }}"
          - if:
              - condition: template
                value_template: "{{ real_gain > 1 and consumed > 0 }}"
            then:
              - variables:
                  observed_factor: "{{ consumed / real_gain }}"
                  new_factor_clamped: "{{ min(max((old_factor*0.7 + observed_factor*0.3), 5), 15) | round(2) }}"
              - action: input_number.set_value
                target: { entity_id: !input wh_per_percent_helper }
                data: { value: "{{ new_factor_clamped }}" }
              - action: input_number.set_value
                target: { entity_id: !input start_soc_helper }
                data: { value: "{{ real_final | round(0) }}" }
              - if:
                  - condition: template
                    value_template: "{{ calibration_counter not in ['', none] }}"
                then:
                  - action: counter.increment
                    target: { entity_id: !input calibration_counter }

mode: single

Happy to answer questions about adapting this to other plugs or batteries. Let me know if you try it out!