"""Code to handle the Plenticore API.""" from __future__ import annotations import asyncio from collections import defaultdict from datetime import datetime, timedelta import logging from aiohttp.client_exceptions import ClientError from kostal.plenticore import PlenticoreApiClient, PlenticoreAuthenticationException from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN _LOGGER = logging.getLogger(__name__) class Plenticore: """Manages the Plenticore API.""" def __init__(self, hass, config_entry): """Create a new plenticore manager instance.""" self.hass = hass self.config_entry = config_entry self._client = None self._shutdown_remove_listener = None self.device_info = {} @property def host(self) -> str: """Return the host of the Plenticore inverter.""" return self.config_entry.data[CONF_HOST] @property def client(self) -> PlenticoreApiClient: """Return the Plenticore API client.""" return self._client async def async_setup(self) -> bool: """Set up Plenticore API client.""" self._client = PlenticoreApiClient( async_get_clientsession(self.hass), host=self.host ) try: await self._client.login(self.config_entry.data[CONF_PASSWORD]) except PlenticoreAuthenticationException as err: _LOGGER.error( "Authentication exception connecting to %s: %s", self.host, err ) return False except (ClientError, asyncio.TimeoutError) as err: _LOGGER.error("Error connecting to %s", self.host) raise ConfigEntryNotReady from err else: _LOGGER.debug("Log-in successfully to %s", self.host) self._shutdown_remove_listener = self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, self._async_shutdown ) # get some device meta data settings = await self._client.get_setting_values( { "devices:local": [ "Properties:SerialNo", "Branding:ProductName1", "Branding:ProductName2", "Properties:VersionIOC", "Properties:VersionMC", ], "scb:network": ["Hostname"], } ) device_local = settings["devices:local"] prod1 = device_local["Branding:ProductName1"] prod2 = device_local["Branding:ProductName2"] self.device_info = { "identifiers": {(DOMAIN, device_local["Properties:SerialNo"])}, "manufacturer": "Kostal", "model": f"{prod1} {prod2}", "name": settings["scb:network"]["Hostname"], "sw_version": f'IOC: {device_local["Properties:VersionIOC"]}' + f' MC: {device_local["Properties:VersionMC"]}', } return True async def _async_shutdown(self, event): """Call from Homeassistant shutdown event.""" # unset remove listener otherwise calling it would raise an exception self._shutdown_remove_listener = None await self.async_unload() async def async_unload(self) -> None: """Unload the Plenticore API client.""" if self._shutdown_remove_listener: self._shutdown_remove_listener() await self._client.logout() self._client = None _LOGGER.debug("Logged out from %s", self.host) class PlenticoreUpdateCoordinator(DataUpdateCoordinator): """Base implementation of DataUpdateCoordinator for Plenticore data.""" def __init__( self, hass: HomeAssistant, logger: logging.Logger, name: str, update_inverval: timedelta, plenticore: Plenticore, ) -> None: """Create a new update coordinator for plenticore data.""" super().__init__( hass=hass, logger=logger, name=name, update_interval=update_inverval, ) # data ids to poll self._fetch = defaultdict(list) self._plenticore = plenticore def start_fetch_data(self, module_id: str, data_id: str) -> None: """Start fetching the given data (module-id and data-id).""" self._fetch[module_id].append(data_id) # Force an update of all data. Multiple refresh calls # are ignored by the debouncer. async def force_refresh(event_time: datetime) -> None: await self.async_request_refresh() async_call_later(self.hass, 2, force_refresh) def stop_fetch_data(self, module_id: str, data_id: str) -> None: """Stop fetching the given data (module-id and data-id).""" self._fetch[module_id].remove(data_id) class ProcessDataUpdateCoordinator(PlenticoreUpdateCoordinator): """Implementation of PlenticoreUpdateCoordinator for process data.""" async def _async_update_data(self) -> dict[str, dict[str, str]]: client = self._plenticore.client if not self._fetch or client is None: return {} _LOGGER.debug("Fetching %s for %s", self.name, self._fetch) fetched_data = await client.get_process_data_values(self._fetch) return { module_id: { process_data.id: process_data.value for process_data in fetched_data[module_id] } for module_id in fetched_data } class SettingDataUpdateCoordinator(PlenticoreUpdateCoordinator): """Implementation of PlenticoreUpdateCoordinator for settings data.""" async def _async_update_data(self) -> dict[str, dict[str, str]]: client = self._plenticore.client if not self._fetch or client is None: return {} _LOGGER.debug("Fetching %s for %s", self.name, self._fetch) fetched_data = await client.get_setting_values(self._fetch) return fetched_data class PlenticoreDataFormatter: """Provides method to format values of process or settings data.""" INVERTER_STATES = { 0: "Off", 1: "Init", 2: "IsoMEas", 3: "GridCheck", 4: "StartUp", 6: "FeedIn", 7: "Throttled", 8: "ExtSwitchOff", 9: "Update", 10: "Standby", 11: "GridSync", 12: "GridPreCheck", 13: "GridSwitchOff", 14: "Overheating", 15: "Shutdown", 16: "ImproperDcVoltage", 17: "ESB", } EM_STATES = { 0: "Idle", 1: "n/a", 2: "Emergency Battery Charge", 4: "n/a", 8: "Winter Mode Step 1", 16: "Winter Mode Step 2", } @classmethod def get_method(cls, name: str) -> callable: """Return a callable formatter of the given name.""" return getattr(cls, name) @staticmethod def format_round(state: str) -> int | str: """Return the given state value as rounded integer.""" try: return round(float(state)) except (TypeError, ValueError): return state @staticmethod def format_energy(state: str) -> float | str: """Return the given state value as energy value, scaled to kWh.""" try: return round(float(state) / 1000, 1) except (TypeError, ValueError): return state @staticmethod def format_inverter_state(state: str) -> str: """Return a readable string of the inverter state.""" try: value = int(state) except (TypeError, ValueError): return state return PlenticoreDataFormatter.INVERTER_STATES.get(value) @staticmethod def format_em_manager_state(state: str) -> str: """Return a readable state of the energy manager.""" try: value = int(state) except (TypeError, ValueError): return state return PlenticoreDataFormatter.EM_STATES.get(value)