217 lines
12 KiB
Markdown
217 lines
12 KiB
Markdown
---
|
||
name: homeassistant-ev
|
||
description: "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 10–30 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.8–5 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:
|
||
|
||
```bash
|
||
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 (50–100, step 5) | Target SOC%, default 80 |
|
||
| `input_number.ev_charging_power_kw` | number (1–22, step 0.1) | Below-80% rate in kW implied by mpp_below (display/fallback) |
|
||
| `input_number.ev_min_per_pct_below_80` | number (0.5–15, step 0.01) | Minutes per % SOC for charging below 80%; fitted from BMS sessions |
|
||
| `input_number.ev_min_per_pct_above_80` | number (0.5–30, step 0.01) | Minutes per % SOC for charging above 80%; derived from crossing sessions |
|
||
| `input_number.ev_real_battery` | number (0–100) | 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:00–05: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_sessions` → `get_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 `chargeStartDate`–`chargeEndDate` (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:**
|
||
```bash
|
||
podman logs homeassistant 2>&1 | grep "EV Charge Control"
|
||
```
|
||
|
||
**Run scripts manually:**
|
||
```bash
|
||
python3 /home/jonas/homeassistant/get_ev_battery.py
|
||
python3 /home/jonas/homeassistant/get_ev_battery.py --sync-sessions
|
||
```
|
||
|
||
**Query VM session history:**
|
||
```bash
|
||
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
|