Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# CURRENT EV Charging

![GitHub Release](https://img.shields.io/github/v/release/aunefyren/current?style=for-the-badge)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/aunefyren/current/total?style=for-the-badge)
![GitHub issues](https://img.shields.io/github/issues/aunefyren/current?style=for-the-badge)
![GitHub Repo stars](https://img.shields.io/github/stars/aunefyren/current?style=for-the-badge)
![GitHub forks](https://img.shields.io/github/forks/aunefyren/current?style=for-the-badge)
Expand All @@ -25,6 +24,7 @@ Must be added as a custom repository.
- Live session monitoring (power, current, energy, charging duration)
- Charger status: `Available`, `Charging`, `Standby` (car full/paused), `Unavailable`
- Last session summary (energy and cost in account currency)
- Full charging history in the Energy dashboard, with costs
- Charger controls: require authentication, permanent cable lock, restart
- Multiple chargers supported — each appears as a separate device

Expand Down Expand Up @@ -56,6 +56,25 @@ Each charger appears as its own device. The following entities are created per c

<br>

## Energy dashboard

The integration reads your whole charging history from CURRENT and writes it to Home Assistant's long-term statistics. Every charger gets two statistics:

| Statistic | Unit |
|---|---|
| `current:charger_<id>_energy` | kWh |
| `current:charger_<id>_cost` | Account currency |

This includes sessions from before the integration was installed and sessions that finished while Home Assistant was down. The history is read when Home Assistant starts and again whenever a session finishes or CURRENT revises one.

To see the charger's usage in the Energy dashboard, add the energy statistic under **Settings → Dashboards → Energy → Individual devices**. Both statistics can also be shown with a **Statistics graph** card, for example charging costs per month.

- CURRENT only reports a total for each session. Its energy and cost are spread evenly from when the car was plugged in until it was unplugged, so hourly values are an estimate, and so is how a session that runs past midnight is split between the days. Each session's total is exact.
- A session appears in the statistics once it has finished.
- Don't also add the **Session Energy** sensor to the Energy dashboard, or every charge is counted twice.

<br>

## Installation

1. Add this repo to HACS as a custom repository
Expand Down
9 changes: 6 additions & 3 deletions custom_components/current/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,14 +173,17 @@ async def stop_charging(self, box_id: str | int, session_id: str | int) -> dict:
"GET", f"Commands/RemoteStop/{box_id}/{session_id}"
)

async def get_history(self, count: int = 5) -> dict:
"""Return the most recent charging sessions and account totals."""
async def get_history(self, count: int = 5, start_index: int = 0) -> dict:
"""Return completed charging sessions, newest first, and account totals.

`start_index` skips that many sessions, for paging further back.
"""
data = await self._request_with_refresh(
"GET",
f"ChargingHistory/customers/{self._customer_id}",
params={
"number": count,
"startIndex": 0,
"startIndex": start_index,
"fromDateTimestamp": 0,
"toDateTimestamp": 0,
"calculateTotalPrice": "true",
Expand Down
55 changes: 55 additions & 0 deletions custom_components/current/coordinator.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Data update coordinator for CURRENT."""

import asyncio
import logging
import time
from collections.abc import Awaitable
Expand All @@ -13,6 +14,7 @@

from .api import AuthError, CannotConnectError, CurrentApiClient
from .const import DOMAIN, SCAN_INTERVAL_ACTIVE, SCAN_INTERVAL_IDLE
from .statistics_import import async_fetch_all_sessions, async_import_statistics

_LOGGER = logging.getLogger(__name__)

Expand All @@ -37,6 +39,10 @@ def __init__(
)
self.client = client
self._fast_poll_until: float = 0
self._statistics_lock = asyncio.Lock()
# What the latest history looked like when statistics were last
# imported, so the full history is only read again when it changes.
self._imported_history: tuple | None = None

def start_fast_polling(self, duration: int = 120) -> None:
"""Poll faster for a while, so a start or stop shows up quickly."""
Expand Down Expand Up @@ -80,8 +86,57 @@ async def _async_update_data(self) -> dict[str, Any]:
seconds=SCAN_INTERVAL_ACTIVE if ongoing else SCAN_INTERVAL_IDLE
)

self._schedule_statistics(history, chargers)

return {
"ongoing": ongoing,
"chargers": chargers,
"history": history,
}

def _schedule_statistics(self, history: dict, chargers: list[dict]) -> None:
"""Import statistics in the background when the history has changed.

Every poll fetches the latest few sessions. Reading the whole history
takes several requests, so that only happens at startup and when one
of those sessions is new or has been revised.
"""
fingerprint = tuple(
(
(item.get("Session") or {}).get("PK_ServiceSessionID"),
(item.get("Session") or {}).get("SessionEnd"),
item.get("TotalkWH"),
item.get("TotalPrice"),
)
for item in (history or {}).get("List") or []
)
if fingerprint == self._imported_history:
return
if self._statistics_lock.locked():
_LOGGER.debug("Statistics import still running, skipping this cycle")
return

charger_names = {
c["FK_ChargePointID"]: c["Name"]
for c in chargers
if c.get("FK_ChargePointID") is not None and c.get("Name")
}
self.config_entry.async_create_background_task(
self.hass,
self._async_import_statistics(fingerprint, charger_names),
name=f"{DOMAIN}_statistics",
)

async def _async_import_statistics(
self, fingerprint: tuple, charger_names: dict[int, str]
) -> None:
"""Read the whole charging history and write it to statistics."""
async with self._statistics_lock:
try:
sessions = await async_fetch_all_sessions(self.client)
except (AuthError, CannotConnectError) as err:
# The regular poll reports these; try again on the next one.
_LOGGER.warning("Could not read charging history: %s", err)
return
async_import_statistics(self.hass, sessions, charger_names)
self._imported_history = fingerprint
4 changes: 2 additions & 2 deletions custom_components/current/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
"name": "CURRENT EV Charging",
"codeowners": ["@aunefyren"],
"config_flow": true,
"dependencies": [],
"dependencies": ["recorder"],
"documentation": "https://github.com/aunefyren/current",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/aunefyren/current/issues",
"requirements": [],
"version": "1.2.0"
"version": "2.0.0"
}
25 changes: 25 additions & 0 deletions custom_components/current/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from typing import Any

from homeassistant.components.sensor import (
Expand All @@ -26,6 +27,7 @@

from .const import DOMAIN
from .coordinator import CurrentCoordinator
from .statistics_import import parse_time

_LOGGER = logging.getLogger(__name__)

Expand All @@ -49,6 +51,19 @@ def _get_history_sessions(data: dict) -> list:
return (data.get("history") or {}).get("List") or []


def _get_last_session_end(data: dict) -> datetime | None:
"""Return when the last completed session ended.

The last session sensors are totals that start over with every session, so
this is their last_reset. Without it, the recorder would read a smaller
session following a bigger one as negative energy.
"""
sessions = _get_history_sessions(data)
if not sessions:
return None
return parse_time((sessions[0].get("Session") or {}).get("SessionEnd"))


def _get_live(data: dict) -> dict:
"""Return the charger's live readings.

Expand All @@ -74,6 +89,7 @@ class CurrentSensorEntityDescription(SensorEntityDescription):
value_fn: Callable[[dict[str, Any]], Any]
unit_fn: Callable[[dict[str, Any]], str | None] | None = None
attributes_fn: Callable[[dict[str, Any]], dict[str, Any]] | None = None
last_reset_fn: Callable[[dict[str, Any]], datetime | None] | None = None


SENSOR_DESCRIPTIONS: tuple[CurrentSensorEntityDescription, ...] = (
Expand Down Expand Up @@ -143,6 +159,7 @@ class CurrentSensorEntityDescription(SensorEntityDescription):
"TotalPrice"
),
unit_fn=lambda data: (data.get("chargers") or [{}])[0].get("Currency"),
last_reset_fn=_get_last_session_end,
),
CurrentSensorEntityDescription(
key="last_session_energy",
Expand All @@ -151,6 +168,7 @@ class CurrentSensorEntityDescription(SensorEntityDescription):
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
value_fn=lambda data: (_get_history_sessions(data) or [{}])[0].get("TotalkWH"),
last_reset_fn=_get_last_session_end,
),
)

Expand Down Expand Up @@ -234,6 +252,13 @@ def native_value(self) -> Any:
"""Return the value for this charger."""
return self.entity_description.value_fn(self._filtered_data())

@property
def last_reset(self) -> datetime | None:
"""Return when a per-session total last started over."""
if self.entity_description.last_reset_fn is None:
return None
return self.entity_description.last_reset_fn(self._filtered_data())

@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return extra detail for this charger, where the sensor has any."""
Expand Down
Loading
Loading