Files
dotfiles/pi/.pi/agent/skills/homeassistant-ev/SKILL.md

12 KiB
Raw Blame History

name, description
name description
homeassistant-ev Edit and debug EV charging automations for the Renault R4 E-Tech in Home Assistant. Covers automations.yaml, get_ev_battery.py, entity IDs, the Renault API capabilities, charge scheduling logic, pricing integration, and VictoriaMetrics session logging. Any changes made to the EV automations or scripts must also be reflected in this skill file.

Home Assistant EV Charging Automations

Working directory: /home/jonas/homeassistant/

Key files

File Purpose
automations.yaml All automations including the full EV charging suite
get_ev_battery.py Two modes: poll current battery from Renault API; sync completed session data
configuration.yaml Defines input_number, input_text, timer, shell_command helpers

Architecture overview

The EV charger is a Shelly Pro 1PM smart switch (switch.shellypro1pm_8c4f00b426d8) that sits between the wall socket and the car's charging cable.

Important limitations of the Renault API:

  • battery-status.batteryLevel is arbitrarily stale (typically 1030 min old)
  • The timestamp field tells you when the car last reported, but there is no way to know the current battery level in real time
  • sensor.shellypro1pm_8c4f00b426d8_power does NOT reflect what the car draws — the car controls its own intake independently
  • There is no charge cap / target SOC API in renault-api for this model

Consequence: mid-session polling cannot give reliable trajectory data. The stop mechanism is purely time-based.

Stop mechanism: time-based timer with two-zone charging curve

AC home charging is not linear. The R4 E-Tech charges at roughly 10 kW below 80% SOC and tapers to around 3.85 kW above 80%. A single average rate would overestimate duration for sessions that don't cross 80% (causing early stop), and underestimate for sessions that do (causing overshoot).

Instead, duration is computed with a two-zone formula:

total_min = pct_below_80 * mpp_below + pct_above_80 * mpp_above

Where pct_below_80 = min(80, target) - start and pct_above_80 = max(0, target - 80).

The two min/% values are stored in:

  • input_number.ev_min_per_pct_below_80 — EMA-fitted from sessions entirely or mostly below 80%
  • input_number.ev_min_per_pct_above_80 — derived from sessions that cross 80%, using the below-80 rate to split session time

Both are updated after every session via get_ev_battery.py --sync-sessions, which re-fits the model deterministically from all available sessions in the last 60 days (starting from neutral seeds, not stored values, so the result is always idempotent).

timer.ev_charge_session is set to this duration and is the primary stop signal. When it fires, the Shelly is turned off and a Renault cloud charge-stop is sent.

A single mid-session sanity check fires at the halfway point (timer.ev_charge_poll). If the API reading at that point already confirms ≥ target, it stops early and cancels the session timer. Otherwise it does nothing — the session timer continues.

Rate calibration: BMS session data

After the Shelly turns off, timer.ev_post_charge_sync starts a 30-minute delay. When it fires, get_ev_battery.py --sync-sessions queries:

renault-api charge sessions --from YYYY-MM-DD --to YYYY-MM-DD

This returns the car's BMS-recorded sessions with exact chargeStartDate, chargeEndDate, chargeStartBatteryLevel, chargeEndBatteryLevel, and chargeEnergyRecovered (kWh).

Duration is computed from the timestamps (not from the chargeDuration field, which has display issues). Rate = energy_kwh / duration_hours. This is updated into input_number.ev_charging_power_kw as a rolling average of the last 3 sessions and logged to VictoriaMetrics as ev_session.

Helper entities (defined in configuration.yaml)

Entity Type Purpose
input_number.ev_target_charge number (50100, step 5) Target SOC%, default 80
input_number.ev_charging_power_kw number (122, step 0.1) Below-80% rate in kW implied by mpp_below (display/fallback)
input_number.ev_min_per_pct_below_80 number (0.515, step 0.01) Minutes per % SOC for charging below 80%; fitted from BMS sessions
input_number.ev_min_per_pct_above_80 number (0.530, step 0.01) Minutes per % SOC for charging above 80%; derived from crossing sessions
input_number.ev_real_battery number (0100) Raw API battery % (updated by get_ev_battery.py poll)
input_number.ev_charge_session_start_battery number SOC% at session start
input_text.ev_charge_hours text JSON array of planned charge hours, e.g. [22, 0, 1]
input_text.ev_charge_session_start_ts text Unix timestamp of session start
input_text.ev_charge_rates text JSON array of last 3 BMS-measured rates in kW
timer.ev_charge_session timer Primary stop: fires after calculated charge duration
timer.ev_charge_poll timer One-shot sanity check at session midpoint
timer.ev_post_charge_sync timer 30-min post-session delay before querying BMS data

Automation IDs and what they do

ev_calculate_charge_hours

  • Trigger: Time 21:50:00
  • Action: Reads spot prices (DK2, Energi Data Service), computes hours needed from battery/target/rate, picks cheapest N hours in the 22:0005:00 window, writes to input_text.ev_charge_hours. Logs detail (prices, selection) and creates a persistent notification.

ev_hourly_charge_control

  • Triggers: Time at 22:00, 23:00, 00:00, 01:00, 02:00, 03:00, 04:00, 05:00
  • Action: Turns Shelly ON if current hour is in the planned list AND sensor.r4_e_tech_battery < target; OFF otherwise. Logs decision with reason.

ev_startup_recovery

  • Trigger: homeassistant.start
  • Conditions: now().hour in [21,22,23,0,1,2,3,4,5] AND battery < target
  • Action: Waits 2 min, recalculates charge hours, immediately applies the Shelly decision. Recovers from HA crashes during the charge window.

ev_charge_started (alias: "EV: Start charge session")

  • Trigger: Shelly → on
  • Action: Polls API (raw battery → ev_real_battery), records session start. Sets:
    • timer.ev_charge_session → full calculated duration (primary stop)
    • timer.ev_charge_poll → half duration (sanity check)

ev_charge_poll_check (alias: "EV: Mid-session sanity check")

  • Trigger: timer.ev_charge_poll finished
  • Condition: Shelly still on
  • Action: Polls API. If ev_real_battery >= target: cancels session timer, stops Shelly + Renault stop. Otherwise: logs reading only, leaves session timer running.

ev_session_time_reached (alias: "EV: Session timer stop")

  • Trigger: timer.ev_charge_session finished
  • Condition: Shelly still on
  • Action: Turns off Shelly + presses button.r4_e_tech_stop_charge. This is the primary stop path.

ev_charge_stopped (alias: "EV: Charge session ended")

  • Trigger: Shelly → off
  • Action: Cancels timer.ev_charge_poll and timer.ev_charge_session (whichever is still running). Starts timer.ev_post_charge_sync (30 min). Logs session start battery and duration.

ev_sync_session_data (alias: "EV: Post-charge session sync")

  • Trigger: timer.ev_post_charge_sync finished
  • Action: Calls shell_command.sync_ev_sessionsget_ev_battery.py --sync-sessions. Logs updated rate.

Shelly off (id 1768306419435)

  • Trigger: Time 06:00:00
  • Action: Turns off Shelly, resets ev_charge_hours to [], resets ev_target_charge to 80.

Shelly entity IDs (do not change)

device_id: e18cd73e8b837834b77acf81eca52224
entity_id: 81ed4fbd80fee38a4817667cd8737748   (used in type: turn_on/off)
switch.shellypro1pm_8c4f00b426d8             (used in state conditions and logbook)

Renault entity IDs

sensor.r4_e_tech_battery          — SOC% from HA integration (stale, for scheduling only)
binary_sensor.r4_e_tech_charging  — whether car reports actively charging
button.r4_e_tech_stop_charge      — sends charge-stop via Renault cloud
device_id: 5866f4d42ced21f6ce609e6b19d1ef65

Renault API capabilities

CLI: ~/.local/bin/renault-api

Command Status Notes
renault-api --json status Returns battery-status with timestamp, batteryLevel, chargingRemainingTime
renault-api charge sessions --from DATE --to DATE BMS-recorded sessions with start/end SOC and kWh. Primary data source for rate calibration.
renault-api charge stop ⚠️ Model A4E1VE undocumented; may fail if not actively charging
renault-api charge mode Access forbidden
renault-api charge schedule show/set /⚠️ show forbidden; set exists but is time+duration only, no SOC target

chargeDuration field display note: the tabulated CLI output shows this in a misleading format. Always compute duration from chargeStartDate - chargeEndDate timestamps instead.

get_ev_battery.py — two modes

Poll mode (default):

  • Calls renault-api --json status, extracts raw batteryLevel and timestamp
  • Updates input_number.ev_real_battery with the raw API value (no projection)
  • Logs to VM: ev_charging measurement with battery_api_pct, api_age_seconds, target_pct, api_timestamp_unix

Sync mode (--sync-sessions):

  • Calls renault-api charge sessions --from <60 days ago> --to <today>
  • Parses tabulated output: timestamps, start/end SOC%, energy kWh. Duration is always computed from chargeStartDatechargeEndDate (the chargeDuration field has display issues).
  • Fits the two-zone model via chronological EMA starting from neutral seeds (3.0, 6.0):
    • Sessions entirely ≤80%: directly update mpp_below
    • Sessions crossing 80%: split time using current mpp_below, derive mpp_above from the above-80% portion
    • Sessions entirely ≥80%: directly update mpp_above
  • Discards sessions with delta_pct < 3 or duration < 2 min as noise
  • Updates input_number.ev_min_per_pct_below_80 and input_number.ev_min_per_pct_above_80
  • Updates input_number.ev_charging_power_kw (implied kW from mpp_below, for display)
  • Logs to VM: ev_session measurement with start/end %, duration, energy, rate, and new mpp values

Note on crossing sessions with tiny above-80 range (e.g. 72→81%): the 1% above 80% amplifies timing noise into large mpp estimates. These sessions are processed but have low signal for mpp_above. Sessions with pct_above ≥ 5% are more reliable.

VictoriaMetrics data

VM at http://127.0.0.1:8428. Written by get_ev_battery.py via InfluxDB line protocol.

Measurement When written Key fields
ev_charging Each poll (session start, sanity check) battery_api_pct, api_age_seconds
ev_session 30 min after session end start_battery_pct, end_battery_pct, duration_minutes, energy_kwh, rate_kw

HA influxdb integration also passively logs sensor.r4_e_tech_battery, input_number.ev_real_battery, input_number.ev_charging_power_kw on state change.

Debugging

Check logbook:

podman logs homeassistant 2>&1 | grep "EV Charge Control"

Run scripts manually:

python3 /home/jonas/homeassistant/get_ev_battery.py
python3 /home/jonas/homeassistant/get_ev_battery.py --sync-sessions

Query VM session history:

curl -s "http://127.0.0.1:8428/api/v1/query_range?query=ev_session_rate_kw&start=$(date -d '7 days ago' +%s)&end=$(date +%s)&step=3600"

HA restarted during charge window → ev_startup_recovery fires 2 min after start.

ev_min_per_pct values are initial defaults → run python3 /home/jonas/homeassistant/get_ev_battery.py --sync-sessions to fit from full history. The model is deterministic from session history so re-running is safe.

mpp_above is noisy if most sessions only go to 81% → sessions with pct_above ≥ 5% give better signal. The model converges over time.

Automation didn't run → check for HA restart at podman logs homeassistant 2>&1 | head -5.

How to edit the automations

  1. Edit /home/jonas/homeassistant/automations.yaml
  2. Reload: Developer Tools → YAML → Reload automations
  3. For new entities: Developer Tools → YAML → Reload input_number / Reload timer
  4. Always update this skill file to reflect any logic, entity, or script changes