"""Tibber home""" from __future__ import annotations import asyncio import base64 import datetime as dt import logging from typing import TYPE_CHECKING, Any from gql import gql from gql.transport.exceptions import TransportError from .const import RESOLUTION_DAILY, RESOLUTION_HOURLY, RESOLUTION_MONTHLY, RESOLUTION_WEEKLY from .gql_queries import ( HISTORIC_DATA, HISTORIC_DATA_DATE, HISTORIC_PRICE, LIVE_SUBSCRIBE, UPDATE_INFO_PRICE, ) if TYPE_CHECKING: from collections.abc import Callable from . import Tibber _LOGGER = logging.getLogger(__name__) MIN_IN_HOUR: int = 60 MIN_IN_QUARTER: int = 15 MIN_REQUESTED_CONSUMPTION_HOURS: int = 3 class HourlyData: """Holds hourly data for consumption or production.""" def __init__(self, production: bool = False) -> None: self.is_production: bool = production self.month_energy: float | None = None self.month_money: float | None = None self.peak_hour: float | None = None self.peak_hour_time: dt.datetime | None = None self.last_data_timestamp: dt.datetime | None = None self.data: list[dict[Any, Any]] = [] @property def direction_name(self) -> str: """Return the direction name.""" if self.is_production: return "production" return "consumption" @property def money_name(self) -> str: """Return the money name.""" if self.is_production: return "profit" return "cost" class TibberHome: """Instance of Tibber home.""" def __init__(self, home_id: str, tibber_control: Tibber) -> None: """Initialize the Tibber home class. :param home_id: The ID of the home. :param tibber_control: The Tibber instance associated with this instance of TibberHome. """ self._tibber_control = tibber_control self._home_id: str = home_id self.price_total: dict[str, float] = {} self._rt_power: list[tuple[dt.datetime, float]] = [] self.info: dict[str, dict[Any, Any]] = {} self.last_data_timestamp: dt.datetime | None = None self._hourly_consumption_data: HourlyData = HourlyData() self._hourly_production_data: HourlyData = HourlyData(production=True) self._last_rt_data_received: dt.datetime = dt.datetime.now(tz=dt.UTC) self._rt_listener: None | asyncio.Task[Any] = None self._resubscribe_task: None | asyncio.Task[Any] = None self._rt_callback: Callable[..., Any] | None = None self._rt_stopped: bool = True self._has_real_time_consumption: None | bool = None self._real_time_consumption_suggested_disabled: dt.datetime | None = None def _merge_hourly_data( self, existing_data: list[dict[Any, Any]], new_data: list[dict[Any, Any]], ) -> list[dict[Any, Any]]: """Merge hourly payloads by timestamp and prefer the newest entry.""" merged_by_timestamp: dict[str, dict[Any, Any]] = {} for entry in existing_data: if (timestamp := entry.get("from")) and isinstance(timestamp, str): merged_by_timestamp[timestamp] = entry for entry in new_data: if (timestamp := entry.get("from")) and isinstance(timestamp, str): merged_by_timestamp[timestamp] = entry return list(merged_by_timestamp.values()) async def _fetch_data(self, hourly_data: HourlyData) -> None: """Update hourly consumption or production data asynchronously.""" now = dt.datetime.now(tz=dt.UTC) local_now = now.astimezone(self._tibber_control.time_zone) n_hours = 60 * 24 if ( not hourly_data.data or hourly_data.last_data_timestamp is None or dt.datetime.fromisoformat(hourly_data.data[0]["from"]) < now - dt.timedelta(hours=n_hours + 24) ): hourly_data.data = [] else: time_diff = now - hourly_data.last_data_timestamp seconds_diff = time_diff.total_seconds() n_hours = int(seconds_diff / 3600) if n_hours < 1: return n_hours = max(MIN_REQUESTED_CONSUMPTION_HOURS, int(n_hours)) data = await self.get_historic_data( n_hours, resolution=RESOLUTION_HOURLY, production=hourly_data.is_production, ) if not data: _LOGGER.error("Could not find %s data.", hourly_data.direction_name) return if not hourly_data.data: hourly_data.data = data else: hourly_data.data = self._merge_hourly_data(hourly_data.data, data) _month_energy = 0 _month_money = 0 _month_hour_max_month_hour_energy = 0 _month_hour_max_month_hour: dt.datetime | None = None for node in hourly_data.data: _time = dt.datetime.fromisoformat(node["from"]) if _time.month != local_now.month or _time.year != local_now.year: continue if (energy := node.get(hourly_data.direction_name)) is None: continue if ( hourly_data.last_data_timestamp is None or _time + dt.timedelta(hours=1) > hourly_data.last_data_timestamp ): hourly_data.last_data_timestamp = _time + dt.timedelta(hours=1) if energy > _month_hour_max_month_hour_energy: _month_hour_max_month_hour_energy = energy _month_hour_max_month_hour = _time _month_energy += energy if node.get(hourly_data.money_name) is not None: _month_money += node[hourly_data.money_name] hourly_data.month_energy = round(_month_energy, 2) hourly_data.month_money = round(_month_money, 2) hourly_data.peak_hour = round(_month_hour_max_month_hour_energy, 2) hourly_data.peak_hour_time = _month_hour_max_month_hour async def fetch_consumption_data(self) -> None: """Update consumption info asynchronously.""" return await self._fetch_data(self._hourly_consumption_data) async def fetch_production_data(self) -> None: """Update consumption info asynchronously.""" return await self._fetch_data(self._hourly_production_data) @property def month_cons(self) -> float | None: """Get consumption for current month.""" return self._hourly_consumption_data.month_energy @property def month_cost(self) -> float | None: """Get total cost for current month.""" return self._hourly_consumption_data.month_money @property def peak_hour(self) -> float | None: """Get consumption during peak hour for the current month.""" return self._hourly_consumption_data.peak_hour @property def peak_hour_time(self) -> dt.datetime | None: """Get the time for the peak consumption during the current month.""" return self._hourly_consumption_data.peak_hour_time @property def last_cons_data_timestamp(self) -> dt.datetime | None: """Get last consumption data timestampt.""" return self._hourly_consumption_data.last_data_timestamp @property def hourly_consumption_data(self) -> list[dict[Any, Any]]: """Get consumption data for the last 30 days.""" return self._hourly_consumption_data.data @property def hourly_production_data(self) -> list[dict[Any, Any]]: """Get production data for the last 30 days.""" return self._hourly_production_data.data async def update_info(self) -> None: """Update home info and the current price info asynchronously.""" await self.update_info_and_price_info() async def update_info_and_price_info(self) -> None: """Update home info and all price info asynchronously.""" if not (data := await self._tibber_control.execute(UPDATE_INFO_PRICE % self._home_id)): _LOGGER.error("Could not get the data.") return self.info = data self._update_has_real_time_consumption() # Handle inactive homes where currentSubscription might be None # Access currentSubscription, handle missing keys try: viewer = self.info["viewer"] except KeyError as err: _LOGGER.error("Missing 'viewer' key in info for home %s: %s", self._home_id, err) self.price_total = {} return try: home = viewer["home"] except KeyError as err: _LOGGER.error("Missing 'home' key in viewer for home %s: %s", self._home_id, err) self.price_total = {} return current_subscription = home.get("currentSubscription") if current_subscription is None: _LOGGER.debug("No active subscription for home %s", self._home_id) self.price_total = {} return price_info = current_subscription.get("priceInfo") if price_info is None: _LOGGER.debug("No price info available for home %s", self._home_id) self.price_total = {} return today = price_info.get("today", []) tomorrow = price_info.get("tomorrow", []) try: self.price_total = {item["startsAt"]: item["total"] for item in today + tomorrow} except (KeyError, TypeError) as err: _LOGGER.error("Malformed price info data for home %s: %s", self._home_id, err) self.price_total = {} def _update_has_real_time_consumption(self) -> None: try: _has_real_time_consumption = self.info["viewer"]["home"]["features"]["realTimeConsumptionEnabled"] except (KeyError, TypeError): self._has_real_time_consumption = None return if self._has_real_time_consumption is None: self._has_real_time_consumption = _has_real_time_consumption return if self._has_real_time_consumption is True and _has_real_time_consumption is False: now = dt.datetime.now(tz=dt.UTC) if self._real_time_consumption_suggested_disabled is None: self._real_time_consumption_suggested_disabled = now self._has_real_time_consumption = None elif now - self._real_time_consumption_suggested_disabled > dt.timedelta(hours=1): self._real_time_consumption_suggested_disabled = None self._has_real_time_consumption = False else: self._has_real_time_consumption = None return if _has_real_time_consumption is True: self._real_time_consumption_suggested_disabled = None self._has_real_time_consumption = _has_real_time_consumption @property def home_id(self) -> str: """Return home id.""" return self._home_id @property def has_active_subscription(self) -> bool: """Return home id.""" try: sub = self.info["viewer"]["home"]["currentSubscription"]["status"] except (KeyError, TypeError): return False return sub in [ "running", "awaiting market", "awaiting time restriction", "awaiting termination", ] @property def has_real_time_consumption(self) -> None | bool: """Return home id.""" return self._has_real_time_consumption @property def has_production(self) -> bool: """Return true if the home has a production metering point.""" try: return bool(self.info["viewer"]["home"]["meteringPointData"]["productionEan"]) except (KeyError, TypeError): return False @property def address1(self) -> str: """Return the home adress1.""" try: return self.info["viewer"]["home"]["address"]["address1"] except (KeyError, TypeError): _LOGGER.error("Could not find address1.") return "" @property def consumption_unit(self) -> str: """Return the consumption unit.""" return "kWh" @property def currency(self) -> str: """Return the currency.""" try: return self.info["viewer"]["home"]["currentSubscription"]["priceInfo"]["current"]["currency"] except (KeyError, TypeError, IndexError): _LOGGER.error("Could not find currency.") return "" @property def country(self) -> str: """Return the country.""" try: return self.info["viewer"]["home"]["address"]["country"] except (KeyError, TypeError): _LOGGER.error("Could not find country.") return "" @property def name(self) -> str: """Return the name.""" try: return self.info["viewer"]["home"]["appNickname"] except (KeyError, TypeError): return self.info["viewer"]["home"]["address"].get("address1", "") @property def price_unit(self) -> str: """Return the price unit (e.g. NOK/kWh).""" if not self.currency or not self.consumption_unit: _LOGGER.error("Could not find price_unit.") return "" return self.currency + "/" + self.consumption_unit def current_price_rank(self, price_total: dict[str, float], price_time: dt.datetime | None) -> float | None: """Gets the rank (0-1) of how expensive the current price is compared to the other prices today.""" # No price -> no rank if price_time is None: return None # Map price_total to a list of tuples (datetime, float) price_items_typed: list[tuple[dt.datetime, float]] = [ ( dt.datetime.fromisoformat(time).astimezone(self._tibber_control.time_zone), price, ) for time, price in price_total.items() ] # Filter out prices not from today, sort by price prices_today_sorted = sorted( [item for item in price_items_typed if item[0].date() == price_time.date()], key=lambda x: x[1], ) # Find the rank of the current price for idx, item in enumerate(prices_today_sorted, start=1): if item[0] == price_time: return idx / len(prices_today_sorted) return None def current_price_data(self) -> tuple[float | None, dt.datetime | None, float | None]: """Get current price.""" now = dt.datetime.now(self._tibber_control.time_zone) for key, price_total in self.price_total.items(): price_time = dt.datetime.fromisoformat(key).astimezone(self._tibber_control.time_zone) time_diff = (now - price_time).total_seconds() / MIN_IN_HOUR if 0 <= time_diff < MIN_IN_QUARTER: price_rank = self.current_price_rank(self.price_total, price_time) return round(price_total, 3), price_time, price_rank return None, None, None async def rt_subscribe(self, callback: Callable[..., Any]) -> None: # noqa: PLR0915 """Connect to Tibber and subscribe to Tibber real time subscription. :param callback: The function to call when data is received. """ def _add_extra_data(data: dict[str, Any]) -> dict[str, Any]: live_data = data["data"]["liveMeasurement"] _timestamp = dt.datetime.fromisoformat(live_data["timestamp"]).astimezone(self._tibber_control.time_zone) while self._rt_power and self._rt_power[0][0] < _timestamp - dt.timedelta(minutes=5): self._rt_power.pop(0) self._rt_power.append((_timestamp, live_data["power"] / 1000)) if "lastMeterProduction" in live_data: live_data["lastMeterProduction"] = max(0, live_data["lastMeterProduction"] or 0) if ( (power_production := live_data.get("powerProduction")) and power_production > 0 and live_data.get("power") is None ): live_data["power"] = 0 if live_data.get("power", 0) > 0 and live_data.get("powerProduction") is None: live_data["powerProduction"] = 0 current_hour = live_data["accumulatedConsumptionLastHour"] if current_hour is not None: power = sum(p[1] for p in self._rt_power) / len(self._rt_power) live_data["estimatedHourConsumption"] = round( current_hour + power * (3600 - (_timestamp.minute * 60 + _timestamp.second)) / 3600, 3, ) if self._hourly_consumption_data.peak_hour and current_hour > self._hourly_consumption_data.peak_hour: self._hourly_consumption_data.peak_hour = round(current_hour, 2) self._hourly_consumption_data.peak_hour_time = _timestamp return data def _handle_subscription_data(_data: dict[str, Any]) -> None: data = {"data": _data} try: data = _add_extra_data(data) except KeyError as err: _LOGGER.debug("Missing expected key in rt_subscribe data, skipping enrichment: %s", err) except Exception: _LOGGER.exception("Error processing rt_subscribe data") self._last_rt_data_received = dt.datetime.now(tz=dt.UTC) try: callback(data) except Exception: _LOGGER.exception("Error in rt_subscribe callback") return _LOGGER.debug( "Data received for %s: %s", self.home_id, data, ) async def _start() -> None: """Subscribe to Tibber.""" for _ in range(30): if self._rt_stopped: _LOGGER.debug("Stopping rt_subscribe") return if self._tibber_control.realtime.subscription_running: break _LOGGER.debug("Waiting for rt_connect") await asyncio.sleep(1) else: _LOGGER.error("rt not running") return try: session = self._tibber_control.realtime.session if session is None or not hasattr(session, "subscribe"): _LOGGER.error("Session is not connected or does not support subscribe method") return async for _data in session.subscribe( gql(LIVE_SUBSCRIBE % self.home_id), ): _handle_subscription_data(_data) if self._rt_stopped or not self._tibber_control.realtime.subscription_running: _LOGGER.debug("Stopping rt_subscribe loop") return except TransportError: _LOGGER.exception("Error in rt_subscribe") self._schedule_resubscribe() except Exception: _LOGGER.exception("Unexpected error in rt_subscribe") self._rt_callback = callback self._tibber_control.realtime.add_home(self) await self._tibber_control.realtime.connect() self._rt_stopped = False self._rt_listener = asyncio.create_task(_start()) def _schedule_resubscribe(self) -> None: """Schedule a resubscribe after a subscription failure.""" if self._rt_stopped or self._rt_callback is None: return if self._resubscribe_task is not None and not self._resubscribe_task.done(): return self._resubscribe_task = asyncio.create_task(self._delayed_resubscribe()) async def _delayed_resubscribe(self) -> None: """Delay before retrying the realtime subscription.""" task = asyncio.current_task() try: await asyncio.sleep(1) if self._rt_stopped or self._rt_callback is None: return await self.rt_resubscribe() except asyncio.CancelledError: raise except Exception: _LOGGER.exception("Error in delayed rt_resubscribe") finally: if self._resubscribe_task is task: self._resubscribe_task = None async def rt_resubscribe(self) -> None: """Resubscribe to Tibber data.""" self.rt_unsubscribe() _LOGGER.debug("Resubscribe, %s", self.home_id) await asyncio.gather( *[ self.update_info(), self._tibber_control.update_info(), ], return_exceptions=False, ) if self._rt_callback is None: _LOGGER.warning("No callback set for rt_resubscribe") return await self.rt_subscribe(self._rt_callback) def rt_unsubscribe(self) -> None: """Unsubscribe to Tibber data.""" _LOGGER.debug("Unsubscribe, %s", self.home_id) self._rt_stopped = True if self._resubscribe_task is not None and self._resubscribe_task is not asyncio.current_task(): self._resubscribe_task.cancel() self._resubscribe_task = None if self._rt_listener is None: return self._rt_listener.cancel() self._rt_listener = None @property def rt_subscription_running(self) -> bool: """Is real time subscription running.""" if not self._tibber_control.realtime.subscription_running: return False return not self._last_rt_data_received < dt.datetime.now(tz=dt.UTC) - dt.timedelta(seconds=60) async def get_historic_data( self, n_data: int, resolution: str = RESOLUTION_HOURLY, production: bool = False, ) -> list[dict[str, Any]]: """Get historic data. :param n_data: The number of nodes to get from history. e.g. 5 would give 5 nodes and resolution = hourly would give the 5 last hours of historic data :param resolution: The resolution of the data. Can be HOURLY, DAILY, WEEKLY, MONTHLY or ANNUAL :param production: True to get production data instead of consumption """ cons_or_prod_str = "production" if production else "consumption" res: list[dict[str, Any]] = [] if resolution == RESOLUTION_HOURLY: max_n_data = 24 * 31 elif resolution == RESOLUTION_DAILY: max_n_data = 31 elif resolution == RESOLUTION_WEEKLY: max_n_data = 52 elif resolution == RESOLUTION_MONTHLY: max_n_data = 12 else: max_n_data = 1 cursor = "" for _ in range(n_data // max_n_data + 1): _n_data = min(max_n_data, n_data - len(res)) query = HISTORIC_DATA.format( self.home_id, cons_or_prod_str, resolution, _n_data, "profit" if production else "totalCost cost", cursor, ) if not (data := await self._tibber_control.execute(query)): _LOGGER.error("Could not get the data.") continue data = data["viewer"]["home"][cons_or_prod_str] if data is None: continue res.extend(data["nodes"]) if len(res) >= n_data: break cursor = data["pageInfo"]["startCursor"] if cursor is None: break return res async def get_historic_data_date( self, date_from: dt.datetime, n_data: int, resolution: str = RESOLUTION_HOURLY, production: bool = False, ) -> list[dict[str, Any]]: """Get historic data. :param date_from: The start-date to get the data from :param n_data: The number of nodes to get from history. e.g. 5 would give 5 nodes and resolution = hourly would give the 5 last hours of historic data. If 0 the set month-days will be calculated to the end of the month. :param resolution: The resolution of the data. Can be HOURLY, DAILY, WEEKLY, MONTHLY or ANNUAL :param production: True to get production data instead of consumption """ date_from_base64 = base64.b64encode(date_from.strftime("%Y-%m-%d").encode()).decode("utf-8") if n_data == 0: # Calculate the number of days to the end of the month from the given date n_data = (date_from.replace(day=1, month=date_from.month + 1) - date_from).days cons_or_prod_str = "production" if production else "consumption" query = HISTORIC_DATA_DATE.format( self.home_id, cons_or_prod_str, resolution, n_data, date_from_base64, "profit production productionUnit" if production else "cost consumption consumptionUnit", ) if not (data := await self._tibber_control.execute(query, timeout=30)): _LOGGER.error("Could not get the data.") return [] data = data["viewer"]["home"][cons_or_prod_str] if data is None: return [] return data["nodes"] async def get_historic_price_data( self, resolution: str = RESOLUTION_HOURLY, ) -> list[dict[Any, Any]] | None: """Get historic price data. :param resolution: The resolution of the data. Can be HOURLY, DAILY, WEEKLY, MONTHLY or ANNUAL """ resolution = resolution.lower() query = HISTORIC_PRICE.format( self.home_id, resolution, ) if not (data := await self._tibber_control.execute(query)): _LOGGER.error("Could not get the price data.") return None return data["viewer"]["home"]["currentSubscription"]["priceRating"][resolution]["entries"] def current_attributes(self) -> dict[str, float]: """Get current attributes.""" max_price = 0.0 min_price = 10000.0 sum_price = 0.0 off_peak_1 = 0.0 peak = 0.0 off_peak_2 = 0.0 num1 = 0.0 num0 = 0.0 num2 = 0.0 num = 0.0 now = dt.datetime.now(self._tibber_control.time_zone) for key, _price_total in self.price_total.items(): price_time = dt.datetime.fromisoformat(key).astimezone(self._tibber_control.time_zone) price_total = round(_price_total, 3) if now.date() == price_time.date(): max_price = max(max_price, price_total) min_price = min(min_price, price_total) if price_time.hour < 8: # noqa: PLR2004 off_peak_1 += price_total num1 += 1 elif price_time.hour < 20: # noqa: PLR2004 peak += price_total num0 += 1 else: off_peak_2 += price_total num2 += 1 num += 1 sum_price += price_total attr = {} attr["max_price"] = max_price attr["avg_price"] = round(sum_price / num, 3) if num > 0 else 0 attr["min_price"] = min_price attr["off_peak_1"] = round(off_peak_1 / num1, 3) if num1 > 0 else 0 attr["peak"] = round(peak / num0, 3) if num0 > 0 else 0 attr["off_peak_2"] = round(off_peak_2 / num2, 3) if num2 > 0 else 0 return attr