diff --git a/README.md b/README.md index 98b20e1a..064abe09 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,18 @@ Confirmed to work with Zaptec products [here](https://www.home-assistant.io/common-tasks/general/#defining-a-custom-polling-interval), will have unexpected effects. If the automatic polling is turned off, not all the data in the integration will update properly. -* Using the _Energy Meter_ entity as an input to the Energy Dashboard will give values that are delayed by 1 hour - in the graphs (see [issue 162](https://github.com/custom-components/zaptec/issues/162) for details). - There is a plan to solve this in [issue 300](https://github.com/custom-components/zaptec/issues/300), but until that is implemented, - a workaround is to use the more frequently updated _Session total charge_ entity instead. This reduces the delay-issue, - but has a separate drawback where a restart of Home Assistant during a charging session can give a fake spike in the logged - consumption that needs to be manually edited using "Adjust sum" in the Statistics tab of the Developer tools dashboard. +* Using the _Energy Meter_ entity directly as an input to the Energy Dashboard will still give values delayed by up to an hour + in the graphs, since that entity reflects live (polling-delayed) state (see [issue 162](https://github.com/custom-components/zaptec/issues/162)). + Each tracked charger now also gets a separate, invisible statistics feed (backdated hourly from Zaptec's charge history) + that appears in the Energy Dashboard's device picker + as " Energy" - use that entry instead of _Energy Meter_ or _Session total charge_ for accurate, + correctly-timed consumption graphs. + Because it is built from completed charge sessions and imported hourly, the most recent hour or two can lag - + an active session's energy only appears once that session ends and the next import runs - so it is meant for + accurate historical graphs rather than real-time monitoring (the _Energy Meter_ sensor covers the live view). + This feed requires Home Assistant's [Recorder](https://www.home-assistant.io/integrations/recorder/) + integration (enabled by default). If you have disabled the recorder, the " Energy" entry + simply isn't created - the rest of the integration continues to work as normal. ## Features missing from the API diff --git a/custom_components/zaptec/__init__.py b/custom_components/zaptec/__init__.py index acb5e111..7a3af44b 100644 --- a/custom_components/zaptec/__init__.py +++ b/custom_components/zaptec/__init__.py @@ -4,6 +4,7 @@ import logging +from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant @@ -25,9 +26,11 @@ from .coordinator import ZaptecUpdateCoordinator, ZaptecUpdateOptions from .manager import ZaptecConfigEntry, ZaptecManager from .services import async_setup_services, async_unload_services +from .statistics import ZaptecStatisticsCoordinator from .zaptec import ( RETRYABLE_HTTP_STATUSES, AuthenticationError, + Charger, Installation, RequestConnectionError, RequestError, @@ -185,9 +188,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), ) + # One statistics coordinator per tracked charger, backdating hourly energy + # into the Energy Dashboard. Recorder is an after_dependency, so + # if it's disabled we skip only this feed, not the whole integration. + if RECORDER_DOMAIN in hass.config.components: + for deviceid in tracked_devices: + zaptec_obj = zaptec[deviceid] + if isinstance(zaptec_obj, Charger): + manager.statistics_coordinators[deviceid] = ZaptecStatisticsCoordinator( + hass, entry=entry, charger=zaptec_obj + ) + else: + _LOGGER.debug("Recorder not enabled; skipping energy-statistics import") + # Initialize the coordinators for co in manager.all_coordinators: await co.async_config_entry_first_refresh() + # async_refresh (not async_config_entry_first_refresh): a failure on this + # secondary, Owner-only feed must not abort setup of the whole config entry. + for co in manager.statistics_coordinators.values(): + await co.async_refresh() # Done setting up, change back to not log all updates. Having this enabled # will create a lot of debug log output. diff --git a/custom_components/zaptec/const.py b/custom_components/zaptec/const.py index 739326f3..0540eabb 100644 --- a/custom_components/zaptec/const.py +++ b/custom_components/zaptec/const.py @@ -51,3 +51,9 @@ "three_to_one_phase_switch_current", "total_charge_power_session", } + +ZAPTEC_STATISTICS_POLL_INTERVAL = 60 * 60 +"""Interval in seconds between imports of archived charge sessions into HA statistics.""" + +ZAPTEC_STATISTICS_BACKFILL_DAYS = 730 +"""How far back (in days) to backfill energy statistics on first import.""" diff --git a/custom_components/zaptec/manager.py b/custom_components/zaptec/manager.py index 79b9fc02..e328b878 100644 --- a/custom_components/zaptec/manager.py +++ b/custom_components/zaptec/manager.py @@ -18,6 +18,7 @@ from .const import DOMAIN, KEYS_TO_SKIP_ENTITY_AVAILABILITY_CHECK, MANUFACTURER from .coordinator import ZaptecUpdateCoordinator from .entity import KeyUnavailableError, ZaptecBaseEntity +from .statistics import ZaptecStatisticsCoordinator from .zaptec import Charger, Installation, Zaptec, ZaptecBase _LOGGER = logging.getLogger(__name__) @@ -53,6 +54,9 @@ class ZaptecManager: device_coordinators: dict[str, ZaptecUpdateCoordinator] """Coordinators for the devices, both installation and chargers.""" + statistics_coordinators: dict[str, ZaptecStatisticsCoordinator] + """Coordinators that backdate hourly energy statistics, one per tracked charger.""" + streams: list[tuple[asyncio.Task, Installation]] """List of active streams for the installations.""" @@ -72,6 +76,7 @@ def __init__( self.tracked_devices = tracked_devices or set() self.name_prefix = name_prefix self.device_coordinators = {} + self.statistics_coordinators = {} self.streams = [] @property diff --git a/custom_components/zaptec/manifest.json b/custom_components/zaptec/manifest.json index 317de830..529692d1 100644 --- a/custom_components/zaptec/manifest.json +++ b/custom_components/zaptec/manifest.json @@ -1,12 +1,14 @@ { "domain": "zaptec", "name": "Zaptec EV charger", + "after_dependencies": [ + "recorder" + ], "codeowners": [ "@sveinse", "@hellowlol" ], "config_flow": true, - "dependencies": [], "documentation": "https://github.com/custom-components/zaptec", "integration_type": "hub", "iot_class": "cloud_polling", diff --git a/custom_components/zaptec/statistics.py b/custom_components/zaptec/statistics.py new file mode 100644 index 00000000..feedf160 --- /dev/null +++ b/custom_components/zaptec/statistics.py @@ -0,0 +1,224 @@ +"""Import Zaptec charge history into Home Assistant's long-term statistics.""" + +from __future__ import annotations + +from collections import defaultdict +from datetime import datetime, timedelta +from http import HTTPStatus +import logging +from typing import TYPE_CHECKING, Any + +from homeassistant.components.recorder.db_schema import StatisticsMeta +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + get_last_statistics, +) +from homeassistant.const import UnitOfEnergy +from homeassistant.core import HomeAssistant +from homeassistant.helpers.recorder import get_instance +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util +from homeassistant.util.unit_conversion import EnergyConverter + +from .const import DOMAIN, ZAPTEC_STATISTICS_BACKFILL_DAYS, ZAPTEC_STATISTICS_POLL_INTERVAL +from .zaptec import Charger, RequestError, ZaptecApiError + +if TYPE_CHECKING: + from .manager import ZaptecConfigEntry + +_LOGGER = logging.getLogger(__name__) + +RESUME_MARGIN = timedelta(hours=26) +"""Lookback added to the fetch window on resume - not a session-length limit. + +The API filters by session *end* time, which can lag a session's final meter +timestamp slightly; querying from `last_start - RESUME_MARGIN` keeps such a +session in the window. No-double-count is guaranteed by bucket_sessions_hourly's +`after` filter regardless - this only bounds how far back we re-scan.""" + +_SUPPORTS_UNIT_CLASS = hasattr(StatisticsMeta, "unit_class") +"""HA core gained the statistics `unit_class` field around 2026.4 - feature-detect +it rather than pin a version.""" + + +_HOUR_BOUNDARY_TOLERANCE = timedelta(seconds=5) +"""Snap tolerance before flooring, so a scheduled on-the-hour report landing a +few seconds either side of the boundary still buckets into the intended hour. + +5s is safely between the two relevant scales: an order of magnitude above the +sub-second jitter observed on real on-the-hour reports (so it reliably catches +them), yet far below the minutes-scale gap between distinct reports (meter +interval is 30 min or hourly), so it can never merge two different reports.""" + + +def _floor_hour(value: datetime) -> datetime: + """Floor a datetime to the start of its UTC hour. + + Snaps up first if `value` is within `_HOUR_BOUNDARY_TOLERANCE` of the next + hour, so a report timestamped a few seconds early/late buckets correctly. + """ + value = dt_util.as_utc(value) + floor = value.replace(minute=0, second=0, microsecond=0) + if value - floor >= timedelta(hours=1) - _HOUR_BOUNDARY_TOLERANCE: + floor += timedelta(hours=1) + return floor + + +def bucket_sessions_hourly( + sessions: list[dict[str, Any]], + *, + after: datetime | None, + running_sum: float, +) -> list[StatisticData]: + """Convert archived charge sessions into hourly external-statistics points. + + Each `energyDetails` point's `energy` is the incremental delta since the + previous point (not a cumulative total; verified against the session's + OCMF-signed meter readings). Reports arrive roughly hourly while metering + but are skipped while idle, so gaps can span many hours. A delta is bucketed + to the hour immediately *before* its own timestamp - where the energy was + actually drawn, confirmed against the charger's power sensor - except a + session's final, irregular point, which is not walked back past the previous + point's hour. For back-to-back hourly reports both rules agree. A delta + straddling an hour boundary lands wholly in one hour rather than being split, + but this still fixes the live sensor's hour lag. + + Sessions without `energyDetails` (pre-3.2 firmware) fall back to a single + point at `endDateTime` with the session's total `energy`; `voided`/`aborted` + sessions are skipped (no meaningful energy). + + `sessions` must be oldest-first (guaranteed by the API). `after` is the start + of the last hour already imported; points are skipped when their *floored + hour* is <= `after`, not by raw timestamp - otherwise a mid-hour point like + 11:10 would be re-added to the already-stored 11:00 bucket and compound on + every poll. `running_sum` is the kWh imported so far; returned points chain + onto it so `sum` stays monotonic. + """ + hourly_deltas: dict[datetime, float] = defaultdict(float) + + for session in sessions: + if session.get("voided") or session.get("aborted"): + continue + + details = session.get("energyDetails") or [] + if not details: + end = session.get("endDateTime") + energy = session.get("energy") or 0.0 + if end and energy: + details = [{"timestamp": end, "energy": energy}] + + prev_timestamp: datetime | None = None + for point in details: + timestamp = dt_util.parse_datetime(point["timestamp"]) + if timestamp is None: + continue + delta = point["energy"] + if prev_timestamp is None: + hour = _floor_hour(timestamp) + else: + hour = max( + _floor_hour(timestamp) - timedelta(hours=1), _floor_hour(prev_timestamp) + ) + prev_timestamp = timestamp + if after is not None and hour <= after: + continue + hourly_deltas[hour] += delta + + statistics: list[StatisticData] = [] + for hour in sorted(hourly_deltas): + running_sum += hourly_deltas[hour] + statistics.append(StatisticData(start=hour, state=hourly_deltas[hour], sum=running_sum)) + return statistics + + +class ZaptecStatisticsCoordinator(DataUpdateCoordinator[None]): + """Imports one charger's archived sessions into HA long-term statistics. + + Independent of the live-state coordinators (coordinator.py), so a failure + here (e.g. a non-Owner 403) degrades only the Energy Dashboard feed, + not the whole integration. + """ + + config_entry: ZaptecConfigEntry + + def __init__( + self, hass: HomeAssistant, *, entry: ZaptecConfigEntry, charger: Charger + ) -> None: + """Initialize the statistics coordinator for one charger.""" + self.charger = charger + self.statistic_id = f"{DOMAIN}:energy_{charger.id.replace('-', '')}" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=f"{DOMAIN}-statistics-{charger.qual_id}", + update_interval=timedelta(seconds=ZAPTEC_STATISTICS_POLL_INTERVAL), + ) + + async def _async_update_data(self) -> None: + """Fetch new archived sessions and import them as external statistics.""" + last_stats = await get_instance(self.hass).async_add_executor_job( + get_last_statistics, + self.hass, + 1, + self.statistic_id, + True, # noqa: FBT003 + {"sum"}, + ) + rows = last_stats.get(self.statistic_id) if last_stats else None + last = rows[0] if rows else None + if last is not None and (start_ts := last.get("start")) is not None: + last_start = dt_util.utc_from_timestamp(start_ts) + running_sum = last.get("sum") or 0.0 + else: + last_start = dt_util.utcnow() - timedelta(days=ZAPTEC_STATISTICS_BACKFILL_DAYS) + running_sum = 0.0 + + sessions: list[dict[str, Any]] = [] + cursor: str | None = None + try: + while True: + page = await self.charger.get_archived_sessions( + from_time=last_start - RESUME_MARGIN, + to_time=dt_util.utcnow(), + cursor=cursor, + ) + sessions.extend(page.get("sessions") or []) + if not page.get("hasMore"): + break + cursor = page.get("cursor") + except RequestError as err: + if err.error_code == HTTPStatus.FORBIDDEN: + # Owner-only endpoint: warn rather than fail the coordinator + # every poll for non-Owner accounts. + _LOGGER.warning( + "No permission to read charge history for %s (requires Owner role), " + "skipping energy statistics import", + self.charger.qual_id, + ) + return + raise UpdateFailed(err) from err + except ZaptecApiError as err: + raise UpdateFailed(err) from err + + statistics = bucket_sessions_hourly(sessions, after=last_start, running_sum=running_sum) + if not statistics: + return + + metadata_kwargs: dict[str, Any] = { + "mean_type": StatisticMeanType.NONE, + "has_sum": True, + "name": f"{self.charger.name} Energy", + "source": DOMAIN, + "statistic_id": self.statistic_id, + "unit_of_measurement": UnitOfEnergy.KILO_WATT_HOUR, + } + if _SUPPORTS_UNIT_CLASS: + metadata_kwargs["unit_class"] = EnergyConverter.UNIT_CLASS + metadata = StatisticMetaData(**metadata_kwargs) + async_add_external_statistics(self.hass, metadata, statistics) diff --git a/custom_components/zaptec/zaptec/api.py b/custom_components/zaptec/zaptec/api.py index cd818dd4..51f3becf 100644 --- a/custom_components/zaptec/zaptec/api.py +++ b/custom_components/zaptec/zaptec/api.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable, Iterator, Mapping from contextlib import aclosing +from datetime import datetime from http import HTTPStatus import itertools import json @@ -22,6 +23,7 @@ import pydantic from .const import ( + API_ARCHIVED_SESSIONS_PAGE_SIZE, API_RATELIMIT_MAX_REQUEST_RATE, API_RATELIMIT_PERIOD, API_RETRIES, @@ -765,6 +767,39 @@ async def authorize_charge(self) -> Any: # NOTE: Undocumented API call return await self.zaptec.request(f"chargers/{self.id}/authorizecharge", method="post") + async def get_archived_sessions( + self, + *, + from_time: datetime, + to_time: datetime, + cursor: str | None = None, + page_size: int = API_ARCHIVED_SESSIONS_PAGE_SIZE, + ) -> dict[str, Any]: + """Fetch one page of archived (completed) charge sessions for this charger. + + Wraps `GET /api/sessions/archived`, filtered to this charger and + ordered oldest-first by the API. `from_time`/`to_time` are required by + the endpoint and filter by session *end* time (not start time), so a + long-running session only appears once it closes within the window. + Returns the page as-is (`Sessions`, `Cursor`, `HasMore`); the caller + follows `Cursor` while `HasMore` is true to page through the full + result set. + + Requires the Owner role on this charger; raises `RequestError` with + `error_code == HTTPStatus.FORBIDDEN` otherwise (see + `ZaptecStatisticsCoordinator._async_update_data` in statistics.py for + how that's handled). + """ + params: TDict = { + "ChargerId": self.id, + "PageSize": page_size, + "From": from_time.isoformat(), + "To": to_time.isoformat(), + } + if cursor is not None: + params["Cursor"] = cursor + return await self.zaptec.request("sessions/archived", params=params) + async def set_permanent_cable_lock(self, lock: bool) -> Any: """Set the permanent cable lock on the charger.""" _LOGGER.debug("Set permanent cable lock %s", lock) @@ -1161,7 +1196,13 @@ async def _refresh_token(self) -> None: ) async def request( - self, url: str, *, method: str = "get", data: Any = None, base_url: str = API_URL + self, + url: str, + *, + method: str = "get", + data: Any = None, + params: dict[str, Any] | None = None, + base_url: str = API_URL, ) -> Any: """Make a request to the API.""" @@ -1175,6 +1216,8 @@ async def request( } if data is not None: kwargs["json"] = data + if params is not None: + kwargs["params"] = params # Run the _request_worker() in a context manager that will close the # generator when the context is exited, ensuring the request and diff --git a/custom_components/zaptec/zaptec/const.py b/custom_components/zaptec/zaptec/const.py index 10e9f589..4cdd601b 100644 --- a/custom_components/zaptec/zaptec/const.py +++ b/custom_components/zaptec/zaptec/const.py @@ -57,6 +57,9 @@ class Missing: API_RATELIMIT_MAX_REQUEST_RATE = 10 """Maximum number of requests allowed per API rate limit period.""" +API_ARCHIVED_SESSIONS_PAGE_SIZE = 200 +"""Page size for GET /api/sessions/archived - the endpoint's documented maximum.""" + MAX_DEBUG_TEXT_LEN_ON_500 = 150 """Maximum text length to add to debug log without truncating.""" diff --git a/custom_components/zaptec/zaptec/validate.py b/custom_components/zaptec/zaptec/validate.py index efa5be57..18009415 100644 --- a/custom_components/zaptec/zaptec/validate.py +++ b/custom_components/zaptec/zaptec/validate.py @@ -101,6 +101,23 @@ class InstallationConnectionDetails(BaseModel): Topic: str +class ArchivedSession(BaseModel): + """Pydantic model for a single archived (completed) charge session.""" + + model_config = ConfigDict(extra="allow") + id: str + chargerId: str # noqa: N815 (matches the API's actual camelCase field name) + startDateTime: str # noqa: N815 (matches the API's actual camelCase field name) + + +class GetArchivedSessionsResponse(BaseModel): + """Pydantic model for a page of archived charge sessions.""" + + model_config = ConfigDict(extra="allow") + sessions: list[ArchivedSession] + hasMore: bool # noqa: N815 (matches the API's actual camelCase field name) + + CHARGER_FIRMWARES = TypeAdapter(list[ChargerFirmware]) CHARGER_STATES = TypeAdapter(list[ChargerState]) CONSTANTS = TypeAdapter(dict[str, Any]) @@ -124,6 +141,7 @@ class InstallationConnectionDetails(BaseModel): r"chargers/[0-9a-f\-]+/localSettings": None, r"chargers/[0-9a-f\-]+/update": None, r"chargerFirmware/installation/[0-9a-f\-]+": CHARGER_FIRMWARES, + "sessions/archived": GetArchivedSessionsResponse, } _URLS = [(k, re.compile(k), v) for k, v in URLS.items()] diff --git a/tests/test_statistics.py b/tests/test_statistics.py new file mode 100644 index 00000000..38bc468b --- /dev/null +++ b/tests/test_statistics.py @@ -0,0 +1,240 @@ +"""Tests for statistics.py.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from custom_components.zaptec.statistics import _floor_hour, bucket_sessions_hourly + + +def _session( + session_id: str, + points: list[tuple[str, float]], + *, + end: str | None = None, + energy: float = 0.0, +) -> dict: + """Build a raw archived-session dict with the given energyDetails points.""" + return { + "id": session_id, + "endDateTime": end, + "energy": energy, + "energyDetails": [{"timestamp": ts, "energy": e} for ts, e in points], + } + + +def test_single_session_within_one_hour() -> None: + """A session with all points inside one hour produces a single bucket. + + Each point's `energy` is already the delta for that interval (confirmed + live against OCMF-signed meter readings), so same-hour points sum + directly rather than differencing against the previous point. + """ + session = _session( + "s1", [("2026-01-01T10:10:00+00:00", 1.0), ("2026-01-01T10:40:00+00:00", 2.5)] + ) + + result = bucket_sessions_hourly([session], after=None, running_sum=0.0) + + assert len(result) == 1 + assert result[0]["start"] == datetime(2026, 1, 1, 10, tzinfo=timezone.utc) # noqa: UP017 + assert result[0]["state"] == 3.5 # noqa: PLR2004 + assert result[0]["sum"] == 3.5 # noqa: PLR2004 + + +def test_session_spanning_two_hours_creates_two_buckets() -> None: + """Successive points landing in different hours produce separate buckets. + + For back-to-back hourly reports (no gap), "the hour before this point" + and "the previous point's hour" are the same hour, so this is the + baseline case both attribution rules agree on. + """ + session = _session( + "s1", + [ + ("2026-01-01T10:05:00+00:00", 0.0), + ("2026-01-01T11:00:00+00:00", 1.0), + ("2026-01-01T12:00:00+00:00", 1.6), + ], + ) + + result = bucket_sessions_hourly([session], after=None, running_sum=0.0) + + assert [r["start"] for r in result] == [ + datetime(2026, 1, 1, 10, tzinfo=timezone.utc), # noqa: UP017 + datetime(2026, 1, 1, 11, tzinfo=timezone.utc), # noqa: UP017 + ] + assert result[0]["state"] == 1.0 + assert result[0]["sum"] == 1.0 + assert result[1]["state"] == 1.6 # noqa: PLR2004 + assert result[1]["sum"] == 2.6 # noqa: PLR2004 + + +def test_delta_after_a_multi_hour_gap_is_attributed_to_the_hour_before_the_report() -> None: + """A report following a multi-hour gap attributes to the hour before it. + + Live-confirmed (2026-07-12, cross-checked against the charger's power + sensor): a report at 14:00 following a gap since 10:00 actually described + charging that happened around 13:00, not 10:00 (the gap's start hour). + """ + session = _session( + "s1", + [ + ("2026-01-01T09:00:00+00:00", 0.0), + ("2026-01-01T10:00:00+00:00", 0.184), + ("2026-01-01T14:00:00+00:00", 0.212), # 4-hour gap since the last report + ], + ) + + result = bucket_sessions_hourly([session], after=None, running_sum=0.0) + + assert [r["start"] for r in result] == [ + datetime(2026, 1, 1, 9, tzinfo=timezone.utc), # noqa: UP017 + datetime(2026, 1, 1, 13, tzinfo=timezone.utc), # noqa: UP017 + ] + assert result[0]["state"] == 0.184 # noqa: PLR2004 + assert result[0]["sum"] == 0.184 # noqa: PLR2004 + assert result[1]["state"] == 0.212 # noqa: PLR2004 + assert result[1]["sum"] == 0.396 # noqa: PLR2004 + + +def test_session_end_is_not_walked_back_past_the_previous_report() -> None: + """A session's real end must use the previous report's hour, not its own. + + Unlike a report on a scheduled hourly tick, an irregular end timestamp + isn't a tick to walk back an hour from. + """ + session = _session( + "s1", + [ + ("2026-01-01T14:00:00+00:00", 0.835), + ("2026-01-01T14:18:54+00:00", 0.279), + ], + ) + + result = bucket_sessions_hourly([session], after=None, running_sum=0.0) + + assert len(result) == 1 + assert result[0]["start"] == datetime(2026, 1, 1, 14, tzinfo=timezone.utc) # noqa: UP017 + assert result[0]["state"] == 0.835 + 0.279 + + +def test_after_cutoff_excludes_already_imported_points() -> None: + """A delta whose interval starts at or before `after` is skipped, avoiding double-counting.""" + session = _session( + "s1", + [ + ("2026-01-01T10:10:00+00:00", 0.0), + ("2026-01-01T11:10:00+00:00", 1.0), + ("2026-01-01T12:10:00+00:00", 2.0), + ], + ) + cutoff = datetime(2026, 1, 1, 10, tzinfo=timezone.utc) # noqa: UP017 + + result = bucket_sessions_hourly([session], after=cutoff, running_sum=5.0) + + assert len(result) == 1 + assert result[0]["start"] == datetime(2026, 1, 1, 11, tzinfo=timezone.utc) # noqa: UP017 + assert result[0]["state"] == 2.0 # noqa: PLR2004 + assert result[0]["sum"] == 7.0 # noqa: PLR2004 + + +def test_after_cutoff_excludes_entire_last_imported_hour() -> None: + """A delta whose raw timestamp is past `after` must still be excluded. + + This applies if its interval started within the already-imported hour + (regression: was double-counting). + """ + session = _session( + "s1", + [ + ("2026-01-01T11:50:00+00:00", 0.0), + ("2026-01-01T12:50:00+00:00", 1.5), + ("2026-01-01T13:50:00+00:00", 2.0), + ], + ) + after = datetime(2026, 1, 1, 11, tzinfo=timezone.utc) # noqa: UP017 + + result = bucket_sessions_hourly([session], after=after, running_sum=3.0) + + assert len(result) == 1 + assert result[0]["start"] == datetime(2026, 1, 1, 12, tzinfo=timezone.utc) # noqa: UP017 + assert result[0]["state"] == 2.0 # noqa: PLR2004 + assert result[0]["sum"] == 5.0 # noqa: PLR2004 + + +def test_session_without_energy_details_falls_back_to_total() -> None: + """A legacy session with no energyDetails uses its total energy at endDateTime.""" + session = { + "id": "s1", + "endDateTime": "2026-01-01T10:45:00+00:00", + "energy": 3.0, + "energyDetails": [], + } + + result = bucket_sessions_hourly([session], after=None, running_sum=0.0) + + assert len(result) == 1 + assert result[0]["start"] == datetime(2026, 1, 1, 10, tzinfo=timezone.utc) # noqa: UP017 + assert result[0]["state"] == 3.0 # noqa: PLR2004 + + +def test_running_sum_carries_across_sessions() -> None: + """The running sum accumulates across multiple sessions, oldest first.""" + session1 = _session("s1", [("2026-01-01T10:10:00+00:00", 1.0)]) + session2 = _session("s2", [("2026-01-01T12:10:00+00:00", 2.0)]) + + result = bucket_sessions_hourly([session1, session2], after=None, running_sum=10.0) + + assert [r["sum"] for r in result] == [11.0, 13.0] + + +def test_voided_and_aborted_sessions_are_skipped() -> None: + """Voided/aborted sessions have no meaningful energy and must not be counted.""" + voided = _session("s1", [("2026-01-01T10:10:00+00:00", 1.0)]) + voided["voided"] = True + aborted = _session("s2", [("2026-01-01T11:10:00+00:00", 2.0)]) + aborted["aborted"] = True + real = _session("s3", [("2026-01-01T12:10:00+00:00", 3.0)]) + + result = bucket_sessions_hourly([voided, aborted, real], after=None, running_sum=0.0) + + assert len(result) == 1 + assert result[0]["start"] == datetime(2026, 1, 1, 12, tzinfo=timezone.utc) # noqa: UP017 + assert result[0]["state"] == 3.0 # noqa: PLR2004 + + +def test_bucket_sessions_hourly_uses_camelcase_keys() -> None: + """Regression test: /api/sessions/archived genuinely returns camelCase (confirmed live 2026-07-12), unlike every other Zaptec endpoint's PascalCase - this must keep working.""" + session = { + "id": "b9b00000-0000-0000-0000-000000000000", + "chargerId": "c1", + "startDateTime": "2026-01-01T09:00:00+00:00", + "endDateTime": "2026-01-01T10:00:00+00:00", + "energy": 1.5, + "energyDetails": [{"timestamp": "2026-01-01T09:30:00+00:00", "energy": 1.5}], + "voided": False, + "aborted": False, + } + + result = bucket_sessions_hourly([session], after=None, running_sum=0.0) + + assert len(result) == 1 + assert result[0]["state"] == 1.5 # noqa: PLR2004 + + +def test_floor_hour_snaps_reports_a_few_seconds_early_to_the_next_hour() -> None: + """A report a few seconds early still floors to its intended hour. + + Zaptec reports land within ~200ms of the hour in practice, but nothing + guarantees that across all firmware/devices. + """ + noon = datetime(2026, 1, 1, 12, tzinfo=timezone.utc) # noqa: UP017 + eleven = datetime(2026, 1, 1, 11, tzinfo=timezone.utc) # noqa: UP017 + + # Comfortably inside the hour: floors normally, no snapping. + assert _floor_hour(datetime(2026, 1, 1, 12, 0, 3, tzinfo=timezone.utc)) == noon # noqa: UP017 + # A few seconds early: within tolerance, snaps up to the intended hour. + assert _floor_hour(datetime(2026, 1, 1, 11, 59, 58, tzinfo=timezone.utc)) == noon # noqa: UP017 + # Comfortably early (outside tolerance): floors down as normal. + assert _floor_hour(datetime(2026, 1, 1, 11, 59, 50, tzinfo=timezone.utc)) == eleven # noqa: UP017 diff --git a/tests/zaptec/test_api.py b/tests/zaptec/test_api.py index 825b05d9..9b4fcfc5 100644 --- a/tests/zaptec/test_api.py +++ b/tests/zaptec/test_api.py @@ -1,5 +1,6 @@ """Tests for zaptec/api.py.""" +from datetime import UTC, datetime, timedelta from http import HTTPStatus import json import logging @@ -7,6 +8,7 @@ from unittest.mock import AsyncMock, Mock import aiohttp +from homeassistant.util import dt as dt_util import pytest from custom_components.zaptec.zaptec.api import Charger, Installation, Zaptec, ZaptecBase @@ -56,6 +58,35 @@ async def test_api(zaptec_username: str, zaptec_password: str) -> None: _LOGGER.info(obj.asdict()) +@pytest.mark.asyncio +async def test_get_archived_sessions_live(zaptec_username: str, zaptec_password: str) -> None: + """Smoke-test the real field casing of /api/sessions/archived (manual, skipped in CI). + + swagger.json declares this endpoint camelCase, but Zaptec endpoints actually + return PascalCase (see validate.py); run once against a real account to confirm + the casing assumed in validate.py/statistics.py. A 403 here means the account + lacks the Owner role this endpoint requires - expected, not a bug. + """ + async with Zaptec(zaptec_username, zaptec_password) as zaptec: + await zaptec.login() + await zaptec.build() + chargers = list(zaptec.chargers) + if not chargers: + pytest.skip("Account has no chargers to test against") + + now = dt_util.utcnow() + page = await chargers[0].get_archived_sessions( + from_time=now - timedelta(days=730), to_time=now, page_size=5 + ) + assert "sessions" in page + assert "hasMore" in page + if page["sessions"]: + session = page["sessions"][0] + assert "id" in session + assert "startDateTime" in session + _LOGGER.info("Sample archived session: %s", session) + + # =========================================================================== # Offline unit tests (no network / no live login required) # =========================================================================== @@ -174,6 +205,24 @@ async def test_request_ok_returns_json() -> None: assert len(session.calls) == 1 +@pytest.mark.asyncio +async def test_request_passes_params_to_session() -> None: + """Query params are forwarded to the underlying session.request() call.""" + payload = {"value": "answer"} + zap, session = _make_zaptec([FakeResponse(HTTPStatus.OK, json_data=payload)]) + await zap.request("unregistered/url", params={"Foo": "bar", "PageSize": 200}) + assert session.calls[0][2]["params"] == {"Foo": "bar", "PageSize": 200} + + +@pytest.mark.asyncio +async def test_request_omits_params_when_not_given() -> None: + """No params kwarg is passed to session.request() when none are given.""" + payload = {"value": "answer"} + zap, session = _make_zaptec([FakeResponse(HTTPStatus.OK, json_data=payload)]) + await zap.request("unregistered/url") + assert "params" not in session.calls[0][2] + + @pytest.mark.asyncio async def test_request_no_content_returns_bytes() -> None: """A 204/201 response returns the raw body bytes.""" @@ -695,6 +744,52 @@ async def test_authorize_charge_posts() -> None: assert url.endswith("chargers/c1/authorizecharge") +@pytest.mark.asyncio +async def test_get_archived_sessions_builds_params() -> None: + """get_archived_sessions() sends ChargerId, PageSize, From, To and Cursor as query params.""" + payload = {"sessions": [], "cursor": None, "hasMore": False} + zap, session = _make_zaptec([FakeResponse(HTTPStatus.OK, json_data=payload)]) + charger = Charger({"Id": "charger-1"}, zap, installation=None) + + result = await charger.get_archived_sessions( + from_time=datetime(2026, 1, 1, tzinfo=UTC), + to_time=datetime(2026, 1, 2, tzinfo=UTC), + cursor="abc", + ) + + assert result == payload + method, url, kwargs = session.calls[0] + assert method == "get" + assert url == "https://api.zaptec.com/api/sessions/archived" + assert kwargs["params"] == { + "ChargerId": "charger-1", + "PageSize": 200, + "From": "2026-01-01T00:00:00+00:00", + "To": "2026-01-02T00:00:00+00:00", + "Cursor": "abc", + } + + +@pytest.mark.asyncio +async def test_get_archived_sessions_omits_cursor_when_not_given() -> None: + """Cursor is omitted from params when not given; From/To are always sent.""" + payload = {"sessions": [], "cursor": None, "hasMore": False} + zap, session = _make_zaptec([FakeResponse(HTTPStatus.OK, json_data=payload)]) + charger = Charger({"Id": "charger-1"}, zap, installation=None) + + await charger.get_archived_sessions( + from_time=datetime(2026, 1, 1, tzinfo=UTC), + to_time=datetime(2026, 1, 2, tzinfo=UTC), + ) + + assert session.calls[0][2]["params"] == { + "ChargerId": "charger-1", + "PageSize": 200, + "From": "2026-01-01T00:00:00+00:00", + "To": "2026-01-02T00:00:00+00:00", + } + + @pytest.mark.asyncio async def test_set_permanent_cable_lock_payload() -> None: """The permanent cable lock is sent under Cable.PermanentLock."""