From bc521418831242cb0fdcf6bd883c779f22886314 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 23 Jul 2026 21:01:02 +1000 Subject: [PATCH 1/3] feat(energysite): add pure Tariff V2 rate resolver Add get_tariff_periods, a pure offline resolver over the raw tariff_content_v2 object, returning the current buy/sell rate plus the next boundary. Ports the Home Assistant teslemetry integration's season/day-of-week/midnight-cross matching logic without its file - the reference crashes on real data's toHour: 24 end-of-day periods because it constructs datetime.replace(hour=24); this resolver represents instants as minute-of-week instead, which sidesteps that crash entirely, and treats a 0.0 sell price as a real value rather than a missing one. Includes a live-shaped 48-half-hour-period fixture plus tests for season year-crossing, day-of-week wrap, midnight-crossing periods, the toHour: 24 regression, absent sell_tariff, missing rates, and naive-now rejection. --- AGENTS.md | 4 + tesla_fleet_api/__init__.py | 12 + tesla_fleet_api/tariff.py | 386 ++++++++ tests/fixtures/tariff_moorinya_v2.json | 1124 ++++++++++++++++++++++++ tests/test_tariff.py | 326 +++++++ 5 files changed, 1852 insertions(+) create mode 100644 tesla_fleet_api/tariff.py create mode 100644 tests/fixtures/tariff_moorinya_v2.json create mode 100644 tests/test_tariff.py diff --git a/AGENTS.md b/AGENTS.md index 23b4e89..42bde3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,10 @@ Scope flags on `TeslaFleetApi.__init__` control which submodules are instantiate `util.py` holds small, dependency-free helpers shared across the library, re-exported from the top-level package. `firmware_compare(a, b) -> int` compares dotted, numeric, week-based Tesla firmware version strings (e.g. `2025.14.3`) correctly — plain string comparison misorders them (`"2025.10" < "2025.9"`). It returns 1/-1/0, right-pads shorter versions with zeros before comparing, and treats unparseable strings (e.g. `"Unknown"`) as sorting behind any parseable version. `firmware_at_least(firmware, minimum) -> bool` is a thin wrapper (`firmware_compare(firmware, minimum) >= 0`) for the common "does this vehicle's firmware support feature X" gate — ported from Home Assistant core PR #175745, which fixed the same lexicographic bug in the `teslemetry` integration. Deliberately implemented as native tuple comparison rather than taking on an `AwesomeVersion` dependency, matching this library's narrow, purpose-built dependency list (no general-purpose version-parsing lib elsewhere). +### Tariff V2 Resolver + +`tariff.py` (`get_tariff_periods`, re-exported from the top-level package like `util.py`) is a pure, offline resolver over the raw `tariff_content_v2` object (`response.tariff_content_v2`, or nested under `tou_settings` on the write path - see `EnergySite.time_of_use_settings`/`site_info` in `tesla/energysite.py`). There is no other typed model for this shape in the library; the vehicle protobuf `set_rate_tariff`/`get_rate_tariff` (`vehicle/commands.py`) is a different, unrelated structure. `unwrap_tariff_v2(response)` handles the envelope variants and raises `InvalidResponse` on a malformed/null body, mirroring `_authorized_clients_list` in `teslemetry/energysite.py`. The tariff object carries no timezone - callers must pass a tz-aware `now` already in the site's local zone (`site_info`'s raw `installation_time_zone`, not currently modeled). Internally the resolver represents instants as minute-of-week (`weekday*1440 + hour*60 + minute`) rather than constructing `datetime.replace(hour=...)`, which is what lets it handle real tariffs' `toHour: 24`/`toMinute: 60` (next-midnight) end times without the crash the Home Assistant `teslemetry` integration's `calendar.py` reference hits on the same data. Price lookups use key-presence checks, never truthiness, since a `0.0` sell price is a legitimate value, not a missing one. `tests/test_tariff.py` and `tests/fixtures/tariff_moorinya_v2.json` (a live-shaped 48-half-hour-period capture) cover the edge cases: season year-crossing, day-of-week wrap, midnight-crossing periods, the `toHour: 24` regression, absent `sell_tariff`, missing rates, and naive-`now` rejection. + ### Release Process No release-please or version-bump automation. To ship: bump `version` in `pyproject.toml` and `__version__` in `tesla_fleet_api/__init__.py` in a `Bump version to X.Y.Z` commit on `main`, then push a matching `vX.Y.Z` tag. `.github/workflows/python-publish.yml` runs on every push but only builds+publishes to PyPI (and creates a GitHub Release) when `github.ref` starts with `refs/tags/` — pushing the tag is what actually ships the release; merging to `main` alone does not. diff --git a/tesla_fleet_api/__init__.py b/tesla_fleet_api/__init__.py index b637845..655fdfa 100644 --- a/tesla_fleet_api/__init__.py +++ b/tesla_fleet_api/__init__.py @@ -4,6 +4,13 @@ __version__ = "1.7.7" from tesla_fleet_api.const import Region, is_valid_region +from tesla_fleet_api.tariff import ( + TariffPeriod, + TariffRate, + TariffResolution, + get_tariff_periods, + unwrap_tariff_v2, +) from tesla_fleet_api.tesla.bluetooth import TeslaBluetooth from tesla_fleet_api.tesla.fleet import TeslaFleetApi from tesla_fleet_api.tesla.oauth import TeslaFleetOAuth @@ -13,6 +20,9 @@ __all__ = [ "Region", + "TariffPeriod", + "TariffRate", + "TariffResolution", "TeslaFleetApi", "TeslaBluetooth", "TeslaFleetOAuth", @@ -20,5 +30,7 @@ "Tessie", "firmware_at_least", "firmware_compare", + "get_tariff_periods", "is_valid_region", + "unwrap_tariff_v2", ] diff --git a/tesla_fleet_api/tariff.py b/tesla_fleet_api/tariff.py new file mode 100644 index 0000000..49d4836 --- /dev/null +++ b/tesla_fleet_api/tariff.py @@ -0,0 +1,386 @@ +"""Pure, offline resolver for the Tesla Energy Tariff V2 (time-of-use) object. + +Consumes the raw ``tariff_content_v2`` object as returned by +``EnergySite.site_info()`` (nested under ``tou_settings``) - there is no +typed model for it elsewhere in this library. Everything here is a pure +function over caller-supplied data: no I/O, no network, no vehicle/VPP +access. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from typing import Any, Mapping, cast + +from tesla_fleet_api.exceptions import InvalidResponse + +_MINUTES_PER_DAY = 24 * 60 +_MINUTES_PER_WEEK = 7 * _MINUTES_PER_DAY + + +@dataclass(frozen=True, slots=True) +class TariffRate: + """One side (buy or sell) of a resolved tariff rate. + + ``price`` is ``None`` only when the rate is genuinely unresolvable + (missing from the tariff) - a real ``0.0`` price is returned as such. + """ + + price: float | None + period_name: str | None + season_name: str | None + + +@dataclass(frozen=True, slots=True) +class TariffPeriod: + """One buy/sell rate pair in effect for ``[start, end)``.""" + + start: datetime + end: datetime + buy: TariffRate + sell: TariffRate + + +@dataclass(frozen=True, slots=True) +class TariffResolution: + """The tariff rate in effect at the moment passed to :func:`get_tariff_periods`.""" + + buy: TariffRate + sell: TariffRate + current_start: datetime + next_change: datetime + currency: str | None + upcoming: list[TariffPeriod] | None + + +def unwrap_tariff_v2(response: Any) -> dict[str, Any]: + """Extract the ``tariff_content_v2`` object from a raw API response. + + Accepts the ``site_info`` read envelope + (``{"response": {"tariff_content_v2": {...}}}``), the + ``time_of_use_settings`` write envelope + (``{"tou_settings": {"tariff_content_v2": {...}}}``), or the bare + ``tariff_content_v2`` object itself. Raises + :class:`~tesla_fleet_api.exceptions.InvalidResponse` for a null body or + any shape that doesn't carry a ``tariff_content_v2`` dict - a malformed + body is not the same as "no tariff configured". + """ + if response is None: + raise InvalidResponse("tariff response body was null") + if not isinstance(response, dict): + raise InvalidResponse(repr(response)) + body = cast("dict[str, Any]", response) + + if "tariff_content_v2" in body: + tariff = body["tariff_content_v2"] + if isinstance(tariff, dict): + return cast("dict[str, Any]", tariff) + raise InvalidResponse(str(body)) + + for envelope_key in ("response", "tou_settings"): + if envelope_key in body: + inner = body[envelope_key] + if isinstance(inner, dict) and isinstance( + cast("dict[str, Any]", inner).get("tariff_content_v2"), dict + ): + return cast( + "dict[str, Any]", cast("dict[str, Any]", inner)["tariff_content_v2"] + ) + raise InvalidResponse(str(body)) + + # No recognized envelope key present - treat the input as the bare + # `tariff_content_v2` object itself. + return body + + +def get_tariff_periods( + tariff: Mapping[str, Any], + now: datetime, + *, + horizon_hours: float | None = None, +) -> TariffResolution | None: + """Resolve the buy/sell tariff rate in effect at ``now``. + + ``tariff`` is the already-unwrapped ``tariff_content_v2`` object (see + :func:`unwrap_tariff_v2`). ``now`` must be timezone-aware and expressed + in the site's own local timezone - the tariff object carries no + timezone of its own, so a naive ``now`` would silently resolve against + the wrong wall clock; this raises :class:`ValueError` instead. + + Returns ``None`` when no season in the tariff covers ``now``'s date. + When ``horizon_hours`` is given, ``upcoming`` is populated with every + buy/sell period between ``now`` and ``now + horizon_hours``. + """ + if now.tzinfo is None or now.tzinfo.utcoffset(now) is None: + raise ValueError("now must be timezone-aware") + + resolved = _resolve_at(tariff, now) + if resolved is None: + return None + + upcoming: list[TariffPeriod] | None = None + if horizon_hours is not None: + upcoming = [] + deadline = now + timedelta(hours=horizon_hours) + cursor = resolved + # Bounded defensively against a pathological tariff whose grid never + # advances; real tariffs resolve a new boundary on every iteration. + for _ in range(10_000): + upcoming.append( + TariffPeriod( + start=cursor.current_start, + end=cursor.next_change, + buy=cursor.buy, + sell=cursor.sell, + ) + ) + if cursor.next_change >= deadline: + break + next_resolved = _resolve_at(tariff, cursor.next_change) + if next_resolved is None: + break + cursor = next_resolved + + return TariffResolution( + buy=resolved.buy, + sell=resolved.sell, + current_start=resolved.current_start, + next_change=resolved.next_change, + currency=tariff.get("currency"), + upcoming=upcoming, + ) + + +@dataclass(frozen=True, slots=True) +class _Resolved: + buy: TariffRate + sell: TariffRate + current_start: datetime + next_change: datetime + + +def _resolve_at(tariff: Mapping[str, Any], moment: datetime) -> _Resolved | None: + today = moment.date() + now_mow = _minute_of_week(moment) + + buy_season_name, buy_windows = _season_windows(tariff.get("seasons"), today) + if buy_season_name is None: + return None + buy_match = _match_window(buy_windows, now_mow) + if buy_match is None: + return None + buy_period_name = buy_match[0] + buy_rate = TariffRate( + price=_lookup_price( + tariff.get("energy_charges"), buy_season_name, buy_period_name + ), + period_name=buy_period_name, + season_name=buy_season_name, + ) + + sell_rate = TariffRate(price=None, period_name=None, season_name=None) + sell_windows: list[tuple[str, int, int]] = [] + sell_tariff = tariff.get("sell_tariff") + if isinstance(sell_tariff, dict): + sell_tariff = cast("dict[str, Any]", sell_tariff) + sell_season_name, sell_windows = _season_windows( + sell_tariff.get("seasons"), today + ) + if sell_season_name is not None: + sell_match = _match_window(sell_windows, now_mow) + if sell_match is not None: + sell_period_name = sell_match[0] + sell_rate = TariffRate( + price=_lookup_price( + sell_tariff.get("energy_charges"), + sell_season_name, + sell_period_name, + ), + period_name=sell_period_name, + season_name=sell_season_name, + ) + + starts = [start for _, start, _ in buy_windows] + [ + start for _, start, _ in sell_windows + ] + since_delta, until_delta = _bracket(now_mow, starts) + + moment_floor = moment.replace(second=0, microsecond=0) + return _Resolved( + buy=buy_rate, + sell=sell_rate, + current_start=moment_floor - timedelta(minutes=since_delta), + next_change=moment_floor + timedelta(minutes=until_delta), + ) + + +def _minute_of_week(moment: datetime) -> int: + """Monday 00:00 = 0 .. Sunday 23:59 = 10079, matching ``datetime.weekday()``.""" + return moment.weekday() * _MINUTES_PER_DAY + moment.hour * 60 + moment.minute + + +def _as_int(value: Any, default: int) -> int: + if value is None: + return default + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _season_covers(season: Mapping[str, Any], today: date) -> bool: + from_month = season.get("fromMonth") + from_day = season.get("fromDay") + to_month = season.get("toMonth") + to_day = season.get("toDay") + if from_month is None or from_day is None or to_month is None or to_day is None: + return False + start = (_as_int(from_month, 0), _as_int(from_day, 0)) + end = (_as_int(to_month, 0), _as_int(to_day, 0)) + point = (today.month, today.day) + if start <= end: + return start <= point <= end + # Year-crossing season (e.g. October -> March). + return point >= start or point <= end + + +def _season_windows( + seasons: Any, today: date +) -> tuple[str | None, list[tuple[str, int, int]]]: + """Find the season covering ``today`` and expand its period grid. + + Skips seasons with no ``tou_periods`` (an empty ``{}`` season object is + legal and present in real tariffs, e.g. an unused "Winter"). + """ + if not isinstance(seasons, dict): + return None, [] + for name, season in cast("dict[str, Any]", seasons).items(): + if not isinstance(season, dict): + continue + season = cast("dict[str, Any]", season) + periods = season.get("tou_periods") + if not isinstance(periods, dict) or not periods: + continue + if _season_covers(season, today): + return name, _expand_periods(cast("dict[str, Any]", periods)) + return None, [] + + +def _expand_periods(tou_periods: Mapping[str, Any]) -> list[tuple[str, int, int]]: + windows: list[tuple[str, int, int]] = [] + for period_name, period_obj in tou_periods.items(): + if not isinstance(period_obj, dict): + continue + entries = cast("dict[str, Any]", period_obj).get("periods") + if not isinstance(entries, list): + continue + for entry in cast("list[Any]", entries): + if isinstance(entry, dict): + windows.extend( + _expand_entry(period_name, cast("dict[str, Any]", entry)) + ) + return windows + + +def _expand_entry( + period_name: str, entry: Mapping[str, Any] +) -> list[tuple[str, int, int]]: + """Expand one ``periods[]`` entry into a (name, start, end) minute-of-week + window per day-of-week it applies to. + + Missing fields default per the observed wire format: + ``fromDayOfWeek``/``fromHour``/``fromMinute`` -> 0, ``toDayOfWeek`` -> 6. + ``toHour: 24`` / ``toMinute: 60`` occur in real tariffs and mean "next + midnight" - plain minute-of-day arithmetic rolls them into the next day + without ever constructing an invalid ``datetime(hour=24)``. + """ + from_dow = _as_int(entry.get("fromDayOfWeek"), 0) + to_dow = _as_int(entry.get("toDayOfWeek"), 6) + from_hour = _as_int(entry.get("fromHour"), 0) + from_minute = _as_int(entry.get("fromMinute"), 0) + to_hour = _as_int(entry.get("toHour"), 0) + to_minute = _as_int(entry.get("toMinute"), 0) + + if from_dow <= to_dow: + days = list(range(from_dow, to_dow + 1)) + else: + days = [*range(from_dow, 7), *range(0, to_dow + 1)] + + windows: list[tuple[str, int, int]] = [] + for day in days: + start = day * _MINUTES_PER_DAY + from_hour * 60 + from_minute + end = day * _MINUTES_PER_DAY + to_hour * 60 + to_minute + if end <= start: + end += _MINUTES_PER_DAY + windows.append((period_name, start, end)) + return windows + + +def _window_contains(now_mow: int, start: int, duration: int) -> bool: + start_mod = start % _MINUTES_PER_WEEK + end = start_mod + duration + if start_mod <= now_mow < end: + return True + # Week-boundary wrap: a window ending after Sunday midnight is also + # "this week's" window one week earlier. + return start_mod <= now_mow + _MINUTES_PER_WEEK < end + + +def _match_window( + windows: list[tuple[str, int, int]], now_mow: int +) -> tuple[str, int, int] | None: + for name, start, end in windows: + if _window_contains(now_mow, start, end - start): + return name, start, end + return None + + +def _bracket(now_mow: int, starts: list[int]) -> tuple[int, int]: + """Return (minutes since the most recent boundary, minutes to the next + strictly-future boundary) across every period-start in ``starts``.""" + mods = sorted({s % _MINUTES_PER_WEEK for s in starts}) + if not mods: + return 0, _MINUTES_PER_WEEK + + since = _MINUTES_PER_WEEK + until = _MINUTES_PER_WEEK + for boundary in mods: + d_since = now_mow - boundary + if d_since < 0: + d_since += _MINUTES_PER_WEEK + since = min(since, d_since) + + d_until = boundary - now_mow + if d_until <= 0: + d_until += _MINUTES_PER_WEEK + until = min(until, d_until) + return since, until + + +def _lookup_price(charges: Any, season_name: str, period_name: str) -> float | None: + """``charges[season].rates[period]`` with ``season``/``period`` -> ``"ALL"`` fallback. + + Uses key-presence checks throughout, never truthiness - a legal ``0.0`` + price must never be treated as missing. + """ + if not isinstance(charges, dict): + return None + charges = cast("dict[str, Any]", charges) + rates: dict[str, Any] | None = None + for key in (season_name, "ALL"): + block = charges.get(key) + if isinstance(block, dict) and isinstance( + cast("dict[str, Any]", block).get("rates"), dict + ): + rates = cast("dict[str, Any]", cast("dict[str, Any]", block)["rates"]) + break + if rates is None: + return None + for key in (period_name, "ALL"): + if key in rates: + try: + return float(rates[key]) + except (TypeError, ValueError): + return None + return None diff --git a/tests/fixtures/tariff_moorinya_v2.json b/tests/fixtures/tariff_moorinya_v2.json new file mode 100644 index 0000000..02ac2ed --- /dev/null +++ b/tests/fixtures/tariff_moorinya_v2.json @@ -0,0 +1,1124 @@ +{ + "response": { + "tariff_content_v2": { + "code": "POWER_SYNC:FLOW_POWER", + "name": "Flow Power (PowerSync)", + "utility": "Flow Power", + "currency": "AUD", + "version": 1, + "daily_charges": [ + { + "name": "Charge" + } + ], + "demand_charges": { + "ALL": { + "rates": { + "ALL": 0 + } + }, + "Summer": {}, + "Winter": {} + }, + "energy_charges": { + "ALL": { + "rates": { + "ALL": 0 + } + }, + "Summer": { + "rates": { + "PERIOD_00_00": 0.24, + "PERIOD_00_30": 0.24, + "PERIOD_01_00": 0.24, + "PERIOD_01_30": 0.24, + "PERIOD_02_00": 0.24, + "PERIOD_02_30": 0.24, + "PERIOD_03_00": 0.24, + "PERIOD_03_30": 0.24, + "PERIOD_04_00": 0.24, + "PERIOD_04_30": 0.24, + "PERIOD_05_00": 0.24, + "PERIOD_05_30": 0.24, + "PERIOD_06_00": 0.31, + "PERIOD_06_30": 0.31, + "PERIOD_07_00": 0.31, + "PERIOD_07_30": 0.31, + "PERIOD_08_00": 0.31, + "PERIOD_08_30": 0.31, + "PERIOD_09_00": 0.31, + "PERIOD_09_30": 0.31, + "PERIOD_10_00": 0.31, + "PERIOD_10_30": 0.31, + "PERIOD_11_00": 0.31, + "PERIOD_11_30": 0.31, + "PERIOD_12_00": 0.31, + "PERIOD_12_30": 0.31, + "PERIOD_13_00": 0.31, + "PERIOD_13_30": 0.31, + "PERIOD_14_00": 0.31, + "PERIOD_14_30": 0.31, + "PERIOD_15_00": 0.31, + "PERIOD_15_30": 0.31, + "PERIOD_16_00": 0.42, + "PERIOD_16_30": 0.42, + "PERIOD_17_00": 0.42, + "PERIOD_17_30": 0.42, + "PERIOD_18_00": 0.42, + "PERIOD_18_30": 0.42, + "PERIOD_19_00": 0.42, + "PERIOD_19_30": 0.42, + "PERIOD_20_00": 0.42, + "PERIOD_20_30": 0.42, + "PERIOD_21_00": 0.31, + "PERIOD_21_30": 0.31, + "PERIOD_22_00": 0.31, + "PERIOD_22_30": 0.31, + "PERIOD_23_00": 0.31, + "PERIOD_23_30": 0.31 + } + }, + "Winter": {} + }, + "seasons": { + "Summer": { + "fromDay": 1, + "toDay": 31, + "fromMonth": 1, + "toMonth": 12, + "tou_periods": { + "PERIOD_00_00": { + "periods": [ + { + "toDayOfWeek": 6, + "toMinute": 30 + } + ] + }, + "PERIOD_00_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromMinute": 30, + "toHour": 1 + } + ] + }, + "PERIOD_01_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 1, + "toHour": 1, + "toMinute": 30 + } + ] + }, + "PERIOD_01_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 1, + "fromMinute": 30, + "toHour": 2 + } + ] + }, + "PERIOD_02_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 2, + "toHour": 2, + "toMinute": 30 + } + ] + }, + "PERIOD_02_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 2, + "fromMinute": 30, + "toHour": 3 + } + ] + }, + "PERIOD_03_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 3, + "toHour": 3, + "toMinute": 30 + } + ] + }, + "PERIOD_03_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 3, + "fromMinute": 30, + "toHour": 4 + } + ] + }, + "PERIOD_04_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 4, + "toHour": 4, + "toMinute": 30 + } + ] + }, + "PERIOD_04_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 4, + "fromMinute": 30, + "toHour": 5 + } + ] + }, + "PERIOD_05_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 5, + "toHour": 5, + "toMinute": 30 + } + ] + }, + "PERIOD_05_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 5, + "fromMinute": 30, + "toHour": 6 + } + ] + }, + "PERIOD_06_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 6, + "toHour": 6, + "toMinute": 30 + } + ] + }, + "PERIOD_06_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 6, + "fromMinute": 30, + "toHour": 7 + } + ] + }, + "PERIOD_07_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 7, + "toHour": 7, + "toMinute": 30 + } + ] + }, + "PERIOD_07_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 7, + "fromMinute": 30, + "toHour": 8 + } + ] + }, + "PERIOD_08_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 8, + "toHour": 8, + "toMinute": 30 + } + ] + }, + "PERIOD_08_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 8, + "fromMinute": 30, + "toHour": 9 + } + ] + }, + "PERIOD_09_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 9, + "toHour": 9, + "toMinute": 30 + } + ] + }, + "PERIOD_09_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 9, + "fromMinute": 30, + "toHour": 10 + } + ] + }, + "PERIOD_10_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 10, + "toHour": 10, + "toMinute": 30 + } + ] + }, + "PERIOD_10_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 10, + "fromMinute": 30, + "toHour": 11 + } + ] + }, + "PERIOD_11_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 11, + "toHour": 11, + "toMinute": 30 + } + ] + }, + "PERIOD_11_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 11, + "fromMinute": 30, + "toHour": 12 + } + ] + }, + "PERIOD_12_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 12, + "toHour": 12, + "toMinute": 30 + } + ] + }, + "PERIOD_12_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 12, + "fromMinute": 30, + "toHour": 13 + } + ] + }, + "PERIOD_13_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 13, + "toHour": 13, + "toMinute": 30 + } + ] + }, + "PERIOD_13_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 13, + "fromMinute": 30, + "toHour": 14 + } + ] + }, + "PERIOD_14_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 14, + "toHour": 14, + "toMinute": 30 + } + ] + }, + "PERIOD_14_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 14, + "fromMinute": 30, + "toHour": 15 + } + ] + }, + "PERIOD_15_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 15, + "toHour": 15, + "toMinute": 30 + } + ] + }, + "PERIOD_15_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 15, + "fromMinute": 30, + "toHour": 16 + } + ] + }, + "PERIOD_16_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 16, + "toHour": 16, + "toMinute": 30 + } + ] + }, + "PERIOD_16_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 16, + "fromMinute": 30, + "toHour": 17 + } + ] + }, + "PERIOD_17_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 17, + "toHour": 17, + "toMinute": 30 + } + ] + }, + "PERIOD_17_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 17, + "fromMinute": 30, + "toHour": 18 + } + ] + }, + "PERIOD_18_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 18, + "toHour": 18, + "toMinute": 30 + } + ] + }, + "PERIOD_18_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 18, + "fromMinute": 30, + "toHour": 19 + } + ] + }, + "PERIOD_19_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 19, + "toHour": 19, + "toMinute": 30 + } + ] + }, + "PERIOD_19_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 19, + "fromMinute": 30, + "toHour": 20 + } + ] + }, + "PERIOD_20_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 20, + "toHour": 20, + "toMinute": 30 + } + ] + }, + "PERIOD_20_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 20, + "fromMinute": 30, + "toHour": 21 + } + ] + }, + "PERIOD_21_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 21, + "toHour": 21, + "toMinute": 30 + } + ] + }, + "PERIOD_21_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 21, + "fromMinute": 30, + "toHour": 22 + } + ] + }, + "PERIOD_22_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 22, + "toHour": 22, + "toMinute": 30 + } + ] + }, + "PERIOD_22_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 22, + "fromMinute": 30, + "toHour": 23 + } + ] + }, + "PERIOD_23_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 23, + "toHour": 23, + "toMinute": 30 + } + ] + }, + "PERIOD_23_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 23, + "fromMinute": 30, + "toHour": 24 + } + ] + } + } + }, + "Winter": {} + }, + "sell_tariff": { + "name": "Flow Power (managed by PowerSync)", + "energy_charges": { + "ALL": { + "rates": { + "ALL": 0 + } + }, + "Summer": { + "rates": { + "PERIOD_00_00": 0.0, + "PERIOD_00_30": 0.0, + "PERIOD_01_00": 0.0, + "PERIOD_01_30": 0.0, + "PERIOD_02_00": 0.0, + "PERIOD_02_30": 0.0, + "PERIOD_03_00": 0.0, + "PERIOD_03_30": 0.0, + "PERIOD_04_00": 0.0, + "PERIOD_04_30": 0.0, + "PERIOD_05_00": 0.0, + "PERIOD_05_30": 0.0, + "PERIOD_06_00": 0.0, + "PERIOD_06_30": 0.0, + "PERIOD_07_00": 0.0, + "PERIOD_07_30": 0.0, + "PERIOD_08_00": 0.0, + "PERIOD_08_30": 0.0, + "PERIOD_09_00": 0.0, + "PERIOD_09_30": 0.0, + "PERIOD_10_00": 0.0, + "PERIOD_10_30": 0.0, + "PERIOD_11_00": 0.0, + "PERIOD_11_30": 0.0, + "PERIOD_12_00": 0.0, + "PERIOD_12_30": 0.0, + "PERIOD_13_00": 0.0, + "PERIOD_13_30": 0.0, + "PERIOD_14_00": 0.0, + "PERIOD_14_30": 0.0, + "PERIOD_15_00": 0.0, + "PERIOD_15_30": 0.0, + "PERIOD_16_00": 0.0, + "PERIOD_16_30": 0.0, + "PERIOD_17_00": 0.45, + "PERIOD_17_30": 0.45, + "PERIOD_18_00": 0.45, + "PERIOD_18_30": 0.0, + "PERIOD_19_00": 0.0, + "PERIOD_19_30": 0.0, + "PERIOD_20_00": 0.0, + "PERIOD_20_30": 0.0, + "PERIOD_21_00": 0.0, + "PERIOD_21_30": 0.0, + "PERIOD_22_00": 0.0, + "PERIOD_22_30": 0.0, + "PERIOD_23_00": 0.0, + "PERIOD_23_30": 0.0 + } + }, + "Winter": {} + }, + "seasons": { + "Summer": { + "fromDay": 1, + "toDay": 31, + "fromMonth": 1, + "toMonth": 12, + "tou_periods": { + "PERIOD_00_00": { + "periods": [ + { + "toDayOfWeek": 6, + "toMinute": 30 + } + ] + }, + "PERIOD_00_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromMinute": 30, + "toHour": 1 + } + ] + }, + "PERIOD_01_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 1, + "toHour": 1, + "toMinute": 30 + } + ] + }, + "PERIOD_01_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 1, + "fromMinute": 30, + "toHour": 2 + } + ] + }, + "PERIOD_02_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 2, + "toHour": 2, + "toMinute": 30 + } + ] + }, + "PERIOD_02_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 2, + "fromMinute": 30, + "toHour": 3 + } + ] + }, + "PERIOD_03_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 3, + "toHour": 3, + "toMinute": 30 + } + ] + }, + "PERIOD_03_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 3, + "fromMinute": 30, + "toHour": 4 + } + ] + }, + "PERIOD_04_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 4, + "toHour": 4, + "toMinute": 30 + } + ] + }, + "PERIOD_04_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 4, + "fromMinute": 30, + "toHour": 5 + } + ] + }, + "PERIOD_05_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 5, + "toHour": 5, + "toMinute": 30 + } + ] + }, + "PERIOD_05_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 5, + "fromMinute": 30, + "toHour": 6 + } + ] + }, + "PERIOD_06_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 6, + "toHour": 6, + "toMinute": 30 + } + ] + }, + "PERIOD_06_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 6, + "fromMinute": 30, + "toHour": 7 + } + ] + }, + "PERIOD_07_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 7, + "toHour": 7, + "toMinute": 30 + } + ] + }, + "PERIOD_07_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 7, + "fromMinute": 30, + "toHour": 8 + } + ] + }, + "PERIOD_08_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 8, + "toHour": 8, + "toMinute": 30 + } + ] + }, + "PERIOD_08_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 8, + "fromMinute": 30, + "toHour": 9 + } + ] + }, + "PERIOD_09_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 9, + "toHour": 9, + "toMinute": 30 + } + ] + }, + "PERIOD_09_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 9, + "fromMinute": 30, + "toHour": 10 + } + ] + }, + "PERIOD_10_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 10, + "toHour": 10, + "toMinute": 30 + } + ] + }, + "PERIOD_10_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 10, + "fromMinute": 30, + "toHour": 11 + } + ] + }, + "PERIOD_11_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 11, + "toHour": 11, + "toMinute": 30 + } + ] + }, + "PERIOD_11_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 11, + "fromMinute": 30, + "toHour": 12 + } + ] + }, + "PERIOD_12_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 12, + "toHour": 12, + "toMinute": 30 + } + ] + }, + "PERIOD_12_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 12, + "fromMinute": 30, + "toHour": 13 + } + ] + }, + "PERIOD_13_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 13, + "toHour": 13, + "toMinute": 30 + } + ] + }, + "PERIOD_13_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 13, + "fromMinute": 30, + "toHour": 14 + } + ] + }, + "PERIOD_14_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 14, + "toHour": 14, + "toMinute": 30 + } + ] + }, + "PERIOD_14_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 14, + "fromMinute": 30, + "toHour": 15 + } + ] + }, + "PERIOD_15_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 15, + "toHour": 15, + "toMinute": 30 + } + ] + }, + "PERIOD_15_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 15, + "fromMinute": 30, + "toHour": 16 + } + ] + }, + "PERIOD_16_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 16, + "toHour": 16, + "toMinute": 30 + } + ] + }, + "PERIOD_16_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 16, + "fromMinute": 30, + "toHour": 17 + } + ] + }, + "PERIOD_17_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 17, + "toHour": 17, + "toMinute": 30 + } + ] + }, + "PERIOD_17_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 17, + "fromMinute": 30, + "toHour": 18 + } + ] + }, + "PERIOD_18_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 18, + "toHour": 18, + "toMinute": 30 + } + ] + }, + "PERIOD_18_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 18, + "fromMinute": 30, + "toHour": 19 + } + ] + }, + "PERIOD_19_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 19, + "toHour": 19, + "toMinute": 30 + } + ] + }, + "PERIOD_19_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 19, + "fromMinute": 30, + "toHour": 20 + } + ] + }, + "PERIOD_20_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 20, + "toHour": 20, + "toMinute": 30 + } + ] + }, + "PERIOD_20_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 20, + "fromMinute": 30, + "toHour": 21 + } + ] + }, + "PERIOD_21_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 21, + "toHour": 21, + "toMinute": 30 + } + ] + }, + "PERIOD_21_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 21, + "fromMinute": 30, + "toHour": 22 + } + ] + }, + "PERIOD_22_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 22, + "toHour": 22, + "toMinute": 30 + } + ] + }, + "PERIOD_22_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 22, + "fromMinute": 30, + "toHour": 23 + } + ] + }, + "PERIOD_23_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 23, + "toHour": 23, + "toMinute": 30 + } + ] + }, + "PERIOD_23_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 23, + "fromMinute": 30, + "toHour": 24 + } + ] + } + } + }, + "Winter": {} + } + } + } + } +} diff --git a/tests/test_tariff.py b/tests/test_tariff.py new file mode 100644 index 0000000..cc90891 --- /dev/null +++ b/tests/test_tariff.py @@ -0,0 +1,326 @@ +"""Tests for :mod:`tesla_fleet_api.tariff`, the pure Tariff V2 rate resolver. + +``tests/fixtures/tariff_moorinya_v2.json`` is a live-shaped Tariff V2 capture +(48 half-hour buy/sell periods, one covering the whole year) modeled on the +design report's live Moorinya capture, including its two real anomalies: +``toHour: 24`` on the last period of the day, and an empty ``"Winter": {}`` +season object. The Home Assistant reference this design ports matching logic +from crashes on the ``toHour: 24`` case (``base_day.replace(hour=24)``); the +regression test below locks in that this resolver does not. +""" + +from __future__ import annotations + +import copy +import json +from datetime import datetime, timedelta +from pathlib import Path +from unittest import TestCase +from zoneinfo import ZoneInfo + +from tesla_fleet_api.exceptions import InvalidResponse +from tesla_fleet_api.tariff import get_tariff_periods, unwrap_tariff_v2 + +FIXTURE_PATH = Path(__file__).parent / "fixtures" / "tariff_moorinya_v2.json" +TZ = ZoneInfo("Australia/Brisbane") + + +def _load_moorinya() -> dict: + return json.loads(FIXTURE_PATH.read_text()) + + +def _moorinya_tariff() -> dict: + return copy.deepcopy(unwrap_tariff_v2(_load_moorinya())) + + +class UnwrapTariffV2Tests(TestCase): + def test_site_info_envelope(self): + response = _load_moorinya() + tariff = unwrap_tariff_v2(response) + self.assertEqual(tariff["code"], "POWER_SYNC:FLOW_POWER") + + def test_time_of_use_settings_envelope(self): + tariff = _moorinya_tariff() + response = {"tou_settings": {"tariff_content_v2": tariff}} + self.assertEqual(unwrap_tariff_v2(response), tariff) + + def test_bare_object(self): + tariff = _moorinya_tariff() + self.assertEqual(unwrap_tariff_v2(tariff), tariff) + + def test_null_body_raises(self): + with self.assertRaises(InvalidResponse): + unwrap_tariff_v2(None) + + def test_malformed_shape_raises(self): + with self.assertRaises(InvalidResponse): + unwrap_tariff_v2({"response": {"nope": True}}) + + +class MoorinyaFixtureTests(TestCase): + """Cover the live-shaped fixture's own anomalies.""" + + def setUp(self): + self.tariff = _moorinya_tariff() + + def test_toHour_24_end_of_day_resolves_and_does_not_crash(self): + # 23:45 Monday falls in PERIOD_23_30 (23:30 -> 24:00), the exact + # entry the HA reference crashes on via `base_day.replace(hour=24)`. + now = datetime(2026, 7, 20, 23, 45, tzinfo=TZ) + result = get_tariff_periods(self.tariff, now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.period_name, "PERIOD_23_30") + # The period rolls into next-day midnight. + self.assertEqual( + result.next_change, + now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1), + ) + + def test_zero_sell_price_is_real_not_missing(self): + now = datetime(2026, 7, 20, 10, 0, tzinfo=TZ) + result = get_tariff_periods(self.tariff, now) + self.assertIsNotNone(result) + self.assertEqual(result.sell.price, 0.0) + self.assertIsNotNone(result.sell.period_name) + + def test_nonzero_sell_price_evening_peak(self): + now = datetime(2026, 7, 20, 17, 45, tzinfo=TZ) + result = get_tariff_periods(self.tariff, now) + self.assertIsNotNone(result) + self.assertEqual(result.sell.price, 0.45) + self.assertEqual(result.buy.price, 0.42) + + def test_absent_sell_tariff_still_resolves_buy(self): + tariff = self.tariff + del tariff["sell_tariff"] + now = datetime(2026, 7, 20, 10, 0, tzinfo=TZ) + result = get_tariff_periods(tariff, now) + self.assertIsNotNone(result) + self.assertIsNotNone(result.buy.price) + self.assertIsNone(result.sell.price) + self.assertIsNone(result.sell.period_name) + self.assertIsNone(result.sell.season_name) + + def test_missing_rate_for_period_returns_none_price(self): + tariff = self.tariff + del tariff["energy_charges"]["Summer"]["rates"]["PERIOD_10_00"] + now = datetime(2026, 7, 20, 10, 0, tzinfo=TZ) + result = get_tariff_periods(tariff, now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.period_name, "PERIOD_10_00") + self.assertIsNone(result.buy.price) + + def test_currency_passthrough(self): + now = datetime(2026, 7, 20, 10, 0, tzinfo=TZ) + result = get_tariff_periods(self.tariff, now) + self.assertEqual(result.currency, "AUD") + + def test_no_season_covers_now_returns_none(self): + tariff = self.tariff + tariff["seasons"]["Summer"]["fromMonth"] = 1 + tariff["seasons"]["Summer"]["toMonth"] = 1 + tariff["seasons"]["Summer"]["fromDay"] = 1 + tariff["seasons"]["Summer"]["toDay"] = 31 + del tariff["sell_tariff"] + now = datetime(2026, 7, 20, 10, 0, tzinfo=TZ) + result = get_tariff_periods(tariff, now) + self.assertIsNone(result) + + def test_naive_now_raises_value_error(self): + now = datetime(2026, 7, 20, 10, 0) + with self.assertRaises(ValueError): + get_tariff_periods(self.tariff, now) + + def test_horizon_hours_produces_upcoming_periods(self): + now = datetime(2026, 7, 20, 23, 0, tzinfo=TZ) + result = get_tariff_periods(self.tariff, now, horizon_hours=2) + self.assertIsNotNone(result) + self.assertIsNotNone(result.upcoming) + self.assertGreaterEqual(len(result.upcoming), 3) + self.assertEqual(result.upcoming[0].buy.period_name, "PERIOD_23_00") + + +def _season_geometry(from_month, from_day, to_month, to_day, tou_periods): + return { + "fromMonth": from_month, + "fromDay": from_day, + "toMonth": to_month, + "toDay": to_day, + "tou_periods": tou_periods, + } + + +class SeasonYearCrossTests(TestCase): + """A season spanning Oct -> Mar (year-cross) must be selected on both + sides of the new year boundary.""" + + def _tariff(self): + winter_periods = { + "ALL": {"periods": [{"toDayOfWeek": 6}]}, + } + summer_periods = { + "ON_PEAK": {"periods": [{"toDayOfWeek": 6}]}, + } + return { + "currency": "AUD", + "energy_charges": { + "Summer": {"rates": {"ON_PEAK": 0.4}}, + "Winter": {"rates": {"ALL": 0.2}}, + }, + "seasons": { + "Summer": _season_geometry(10, 1, 3, 31, summer_periods), + "Winter": _season_geometry(4, 1, 9, 30, winter_periods), + }, + } + + def test_late_december_selects_year_crossing_season(self): + now = datetime(2026, 12, 20, 12, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.season_name, "Summer") + self.assertEqual(result.buy.price, 0.4) + + def test_early_january_still_selects_year_crossing_season(self): + now = datetime(2027, 1, 5, 12, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.season_name, "Summer") + + def test_mid_year_selects_non_crossing_season(self): + now = datetime(2026, 7, 15, 12, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.season_name, "Winter") + self.assertEqual(result.buy.price, 0.2) + + +class DayOfWeekWrapTests(TestCase): + """A period spanning Friday -> Monday (a long-weekend rate) must wrap + the day-of-week range correctly, applying in full to each day in + [Fri..Mon] and to no other day.""" + + def _tariff(self): + return { + "currency": "AUD", + "energy_charges": { + "ALL": { + "rates": { + "LONG_WEEKEND": 0.15, + "WEEKDAY": 0.30, + } + } + }, + "seasons": { + "ALL": { + "fromMonth": 1, + "fromDay": 1, + "toMonth": 12, + "toDay": 31, + "tou_periods": { + "LONG_WEEKEND": { + "periods": [ + { + "fromDayOfWeek": 4, + "toDayOfWeek": 0, + "fromHour": 0, + "toHour": 24, + } + ] + }, + "WEEKDAY": { + "periods": [ + { + "fromDayOfWeek": 1, + "toDayOfWeek": 3, + "fromHour": 0, + "toHour": 24, + } + ] + }, + }, + } + }, + } + + def test_saturday_is_long_weekend_rate(self): + # 2026-07-18 is a Saturday, inside the Fri(4)->Mon(0) wrap. + now = datetime(2026, 7, 18, 12, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.period_name, "LONG_WEEKEND") + self.assertEqual(result.buy.price, 0.15) + + def test_monday_is_long_weekend_rate_wrapping_from_friday(self): + # 2026-07-20 is a Monday - the last day of the Fri(4)->Mon(0) wrap. + now = datetime(2026, 7, 20, 8, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.period_name, "LONG_WEEKEND") + + def test_tuesday_is_weekday_rate(self): + # 2026-07-21 is a Tuesday - outside the Fri->Mon wrap. + now = datetime(2026, 7, 21, 12, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.period_name, "WEEKDAY") + self.assertEqual(result.buy.price, 0.30) + + +class MidnightCrossingPeriodTests(TestCase): + """A period whose local window crosses midnight without hitting the + ``toHour: 24`` special case (e.g. 22:00 -> 06:00 overnight rate).""" + + def _tariff(self): + return { + "currency": "AUD", + "energy_charges": {"ALL": {"rates": {"OVERNIGHT": 0.18, "DAY": 0.32}}}, + "seasons": { + "ALL": { + "fromMonth": 1, + "fromDay": 1, + "toMonth": 12, + "toDay": 31, + "tou_periods": { + "OVERNIGHT": { + "periods": [{"toDayOfWeek": 6, "fromHour": 22, "toHour": 6}] + }, + "DAY": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 6, + "toHour": 22, + } + ] + }, + }, + } + }, + } + + def test_just_after_midnight_is_still_overnight(self): + now = datetime(2026, 7, 20, 1, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.period_name, "OVERNIGHT") + self.assertEqual(result.buy.price, 0.18) + + def test_just_before_midnight_is_overnight(self): + now = datetime(2026, 7, 20, 23, 30, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.period_name, "OVERNIGHT") + + def test_daytime_is_day_rate(self): + now = datetime(2026, 7, 20, 12, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.period_name, "DAY") + + def test_next_change_crosses_midnight_correctly(self): + now = datetime(2026, 7, 20, 23, 30, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + # OVERNIGHT ends at 06:00 the next calendar day. + expected = datetime(2026, 7, 21, 6, 0, tzinfo=TZ) + self.assertEqual(result.next_change, expected) From 63bec8e20c2173ac2c178d03fbcb57129d35874f Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 23 Jul 2026 21:04:33 +1000 Subject: [PATCH 2/3] no-mistakes(review): Respect tariff season boundaries in resolved periods --- tesla_fleet_api.egg-info/SOURCES.txt | 23 ++++++++++++ tesla_fleet_api/tariff.py | 55 ++++++++++++++++++++++++---- tests/test_tariff.py | 20 ++++++++++ 3 files changed, 90 insertions(+), 8 deletions(-) diff --git a/tesla_fleet_api.egg-info/SOURCES.txt b/tesla_fleet_api.egg-info/SOURCES.txt index c1defc6..086a860 100644 --- a/tesla_fleet_api.egg-info/SOURCES.txt +++ b/tesla_fleet_api.egg-info/SOURCES.txt @@ -5,6 +5,7 @@ tesla_fleet_api/__init__.py tesla_fleet_api/const.py tesla_fleet_api/exceptions.py tesla_fleet_api/py.typed +tesla_fleet_api/tariff.py tesla_fleet_api/util.py tesla_fleet_api.egg-info/PKG-INFO tesla_fleet_api.egg-info/SOURCES.txt @@ -32,6 +33,17 @@ tesla_fleet_api/tesla/vehicle/fleet.py tesla_fleet_api/tesla/vehicle/signed.py tesla_fleet_api/tesla/vehicle/vehicle.py tesla_fleet_api/tesla/vehicle/vehicles.py +tesla_fleet_api/tesla/vehicle/proto/__init__.py +tesla_fleet_api/tesla/vehicle/proto/car_server_pb2.py +tesla_fleet_api/tesla/vehicle/proto/common_pb2.py +tesla_fleet_api/tesla/vehicle/proto/errors_pb2.py +tesla_fleet_api/tesla/vehicle/proto/keys_pb2.py +tesla_fleet_api/tesla/vehicle/proto/managed_charging_pb2.py +tesla_fleet_api/tesla/vehicle/proto/session_pb2.py +tesla_fleet_api/tesla/vehicle/proto/signatures_pb2.py +tesla_fleet_api/tesla/vehicle/proto/universal_message_pb2.py +tesla_fleet_api/tesla/vehicle/proto/vcsec_pb2.py +tesla_fleet_api/tesla/vehicle/proto/vehicle_pb2.py tesla_fleet_api/teslemetry/__init__.py tesla_fleet_api/teslemetry/energysite.py tesla_fleet_api/teslemetry/teslemetry.py @@ -43,9 +55,12 @@ tests/test_auto_seat_climate.py tests/test_ble_broadcast_confirmation.py tests/test_ble_broadcast_listeners.py tests/test_ble_charging_commands.py +tests/test_ble_charging_utility_commands.py tests/test_ble_climate_commands.py tests/test_ble_command_verification.py tests/test_ble_confirmation_mode.py +tests/test_ble_connectivity_diagnostics_commands.py +tests/test_ble_destructive_commands.py tests/test_ble_expects_data.py tests/test_ble_keepalive.py tests/test_ble_message_routing.py @@ -53,11 +68,15 @@ tests/test_ble_mocked_closures_locks.py tests/test_ble_mocked_commands.py tests/test_ble_mocked_media_commands.py tests/test_ble_mocked_state_readers.py +tests/test_ble_mocked_state_readers_new.py +tests/test_ble_nav_messaging_media_commands.py tests/test_ble_nav_misc_commands.py +tests/test_ble_niche_commands.py tests/test_ble_optimistic_and_best_effort.py tests/test_ble_pair.py tests/test_ble_reassembling_buffer.py tests/test_ble_send_transport.py +tests/test_ble_ui_unit_preference_commands.py tests/test_ble_unconfirmed_command.py tests/test_ble_write_timeout_router.py tests/test_command_counter_lock.py @@ -68,7 +87,11 @@ tests/test_find_vehicle_scan_filter.py tests/test_firmware_at_least.py tests/test_fleet_auth_refresh.py tests/test_power_mode_commands.py +tests/test_proto_compatibility.py +tests/test_proto_coverage_lock.py tests/test_router.py +tests/test_session_info_authentication.py +tests/test_tariff.py tests/test_tesla_private_key.py tests/test_teslemetry_authorized_clients.py tests/test_teslemetry_gateway_address.py diff --git a/tesla_fleet_api/tariff.py b/tesla_fleet_api/tariff.py index 49d4836..2259f22 100644 --- a/tesla_fleet_api/tariff.py +++ b/tesla_fleet_api/tariff.py @@ -164,7 +164,9 @@ def _resolve_at(tariff: Mapping[str, Any], moment: datetime) -> _Resolved | None today = moment.date() now_mow = _minute_of_week(moment) - buy_season_name, buy_windows = _season_windows(tariff.get("seasons"), today) + buy_season_name, buy_windows, buy_season_dates = _season_windows( + tariff.get("seasons"), today + ) if buy_season_name is None: return None buy_match = _match_window(buy_windows, now_mow) @@ -181,10 +183,11 @@ def _resolve_at(tariff: Mapping[str, Any], moment: datetime) -> _Resolved | None sell_rate = TariffRate(price=None, period_name=None, season_name=None) sell_windows: list[tuple[str, int, int]] = [] + sell_season_dates: tuple[date, date] | None = None sell_tariff = tariff.get("sell_tariff") if isinstance(sell_tariff, dict): sell_tariff = cast("dict[str, Any]", sell_tariff) - sell_season_name, sell_windows = _season_windows( + sell_season_name, sell_windows, sell_season_dates = _season_windows( sell_tariff.get("seasons"), today ) if sell_season_name is not None: @@ -207,11 +210,23 @@ def _resolve_at(tariff: Mapping[str, Any], moment: datetime) -> _Resolved | None since_delta, until_delta = _bracket(now_mow, starts) moment_floor = moment.replace(second=0, microsecond=0) + current_start = moment_floor - timedelta(minutes=since_delta) + next_change = moment_floor + timedelta(minutes=until_delta) + for season_dates in (buy_season_dates, sell_season_dates): + if season_dates is None: + continue + season_start, season_end = ( + datetime.combine(boundary, datetime.min.time(), tzinfo=moment.tzinfo) + for boundary in season_dates + ) + current_start = max(current_start, season_start) + next_change = min(next_change, season_end) + return _Resolved( buy=buy_rate, sell=sell_rate, - current_start=moment_floor - timedelta(minutes=since_delta), - next_change=moment_floor + timedelta(minutes=until_delta), + current_start=current_start, + next_change=next_change, ) @@ -247,14 +262,14 @@ def _season_covers(season: Mapping[str, Any], today: date) -> bool: def _season_windows( seasons: Any, today: date -) -> tuple[str | None, list[tuple[str, int, int]]]: +) -> tuple[str | None, list[tuple[str, int, int]], tuple[date, date] | None]: """Find the season covering ``today`` and expand its period grid. Skips seasons with no ``tou_periods`` (an empty ``{}`` season object is legal and present in real tariffs, e.g. an unused "Winter"). """ if not isinstance(seasons, dict): - return None, [] + return None, [], None for name, season in cast("dict[str, Any]", seasons).items(): if not isinstance(season, dict): continue @@ -263,8 +278,32 @@ def _season_windows( if not isinstance(periods, dict) or not periods: continue if _season_covers(season, today): - return name, _expand_periods(cast("dict[str, Any]", periods)) - return None, [] + return ( + name, + _expand_periods(cast("dict[str, Any]", periods)), + _season_dates(season, today), + ) + return None, [], None + + +def _season_dates(season: Mapping[str, Any], today: date) -> tuple[date, date]: + start_month = _as_int(season.get("fromMonth"), 0) + start_day = _as_int(season.get("fromDay"), 0) + end_month = _as_int(season.get("toMonth"), 0) + end_day = _as_int(season.get("toDay"), 0) + start_fields = (start_month, start_day) + end_fields = (end_month, end_day) + + if start_fields <= end_fields: + start_year = end_year = today.year + elif (today.month, today.day) >= start_fields: + start_year, end_year = today.year, today.year + 1 + else: + start_year, end_year = today.year - 1, today.year + + start = date(start_year, start_month, start_day) + end = date(end_year, end_month, end_day) + timedelta(days=1) + return start, end def _expand_periods(tou_periods: Mapping[str, Any]) -> list[tuple[str, int, int]]: diff --git a/tests/test_tariff.py b/tests/test_tariff.py index cc90891..69f7176 100644 --- a/tests/test_tariff.py +++ b/tests/test_tariff.py @@ -193,6 +193,26 @@ def test_mid_year_selects_non_crossing_season(self): self.assertEqual(result.buy.season_name, "Winter") self.assertEqual(result.buy.price, 0.2) + def test_season_boundary_delimits_period_and_upcoming(self): + now = datetime(2026, 3, 31, 23, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now, horizon_hours=2) + + self.assertIsNotNone(result) + boundary = datetime(2026, 4, 1, tzinfo=TZ) + self.assertEqual(result.next_change, boundary) + self.assertIsNotNone(result.upcoming) + self.assertEqual(result.upcoming[0].end, boundary) + self.assertEqual(result.upcoming[1].start, boundary) + self.assertEqual(result.upcoming[1].buy.season_name, "Winter") + self.assertEqual(result.upcoming[1].buy.price, 0.2) + + def test_season_start_delimits_current_period(self): + now = datetime(2026, 4, 1, 1, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + + self.assertIsNotNone(result) + self.assertEqual(result.current_start, datetime(2026, 4, 1, tzinfo=TZ)) + class DayOfWeekWrapTests(TestCase): """A period spanning Friday -> Monday (a long-weekend rate) must wrap From fb0faafe966861412fb93d132d0a72edf3454392 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 23 Jul 2026 21:08:02 +1000 Subject: [PATCH 3/3] no-mistakes(document): Document tariff resolver and pass lint checks --- AGENTS.md | 2 +- docs/fleet_api_energy_sites.md | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 42bde3c..374d053 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,7 +93,7 @@ Scope flags on `TeslaFleetApi.__init__` control which submodules are instantiate ### Tariff V2 Resolver -`tariff.py` (`get_tariff_periods`, re-exported from the top-level package like `util.py`) is a pure, offline resolver over the raw `tariff_content_v2` object (`response.tariff_content_v2`, or nested under `tou_settings` on the write path - see `EnergySite.time_of_use_settings`/`site_info` in `tesla/energysite.py`). There is no other typed model for this shape in the library; the vehicle protobuf `set_rate_tariff`/`get_rate_tariff` (`vehicle/commands.py`) is a different, unrelated structure. `unwrap_tariff_v2(response)` handles the envelope variants and raises `InvalidResponse` on a malformed/null body, mirroring `_authorized_clients_list` in `teslemetry/energysite.py`. The tariff object carries no timezone - callers must pass a tz-aware `now` already in the site's local zone (`site_info`'s raw `installation_time_zone`, not currently modeled). Internally the resolver represents instants as minute-of-week (`weekday*1440 + hour*60 + minute`) rather than constructing `datetime.replace(hour=...)`, which is what lets it handle real tariffs' `toHour: 24`/`toMinute: 60` (next-midnight) end times without the crash the Home Assistant `teslemetry` integration's `calendar.py` reference hits on the same data. Price lookups use key-presence checks, never truthiness, since a `0.0` sell price is a legitimate value, not a missing one. `tests/test_tariff.py` and `tests/fixtures/tariff_moorinya_v2.json` (a live-shaped 48-half-hour-period capture) cover the edge cases: season year-crossing, day-of-week wrap, midnight-crossing periods, the `toHour: 24` regression, absent `sell_tariff`, missing rates, and naive-`now` rejection. +`tariff.py` provides the pure, offline Tariff V2 resolver. Its user-facing contract and example are documented in `docs/fleet_api_energy_sites.md`; its edge-case invariants are owned by the module docstrings and `tests/test_tariff.py`. ### Release Process diff --git a/docs/fleet_api_energy_sites.md b/docs/fleet_api_energy_sites.md index ace360a..51596ee 100644 --- a/docs/fleet_api_energy_sites.md +++ b/docs/fleet_api_energy_sites.md @@ -345,7 +345,7 @@ async def main(): try: energy_site = api.energySites.create(12345) - time_of_use_settings_response = await energy_site.time_of_use_settings(settings={"tou_settings": {"tariff_content_v2": {}}}) + time_of_use_settings_response = await energy_site.time_of_use_settings(settings={}) print(time_of_use_settings_response) except TeslaFleetError as e: print(e) @@ -353,6 +353,33 @@ async def main(): asyncio.run(main()) ``` +The top-level `get_tariff_periods` helper resolves the current buy and sell +rates from a raw `tariff_content_v2` object without making any API calls. +`unwrap_tariff_v2` accepts the `site_info()` response envelope, the +`tou_settings` write envelope, or a bare tariff object: + +```python +from datetime import datetime +from zoneinfo import ZoneInfo + +from tesla_fleet_api import get_tariff_periods, unwrap_tariff_v2 + +site_info = await energy_site.site_info() +tariff = unwrap_tariff_v2(site_info) +site_timezone = ZoneInfo("") +now = datetime.now(site_timezone) +resolution = get_tariff_periods(tariff, now, horizon_hours=24) +``` + +`now` must be timezone-aware and expressed in the site's local timezone because +the tariff object does not carry its own timezone. A naive `now` raises +`ValueError`. The result contains the current buy and sell rates, the current +period's start, the next change, the currency, and (when `horizon_hours` is +provided) upcoming periods. It returns `None` when no tariff season covers +`now`. Missing rates remain `None`, while a real zero price remains `0.0`. +Malformed or null response envelopes passed to `unwrap_tariff_v2` raise +`InvalidResponse`. + ## Device Commands Energy gateways (Powerwalls, etc.) support gRPC commands sent via `POST /api/1/energy_sites/{id}/command`. These are undocumented Tesla API endpoints that communicate directly with the gateway hardware. All device command methods require the `energy_cmds` scope.