diff --git a/custom_components/saj_esolar/__init__.py b/custom_components/saj_esolar/__init__.py index 7acbc9a..5b6a484 100644 --- a/custom_components/saj_esolar/__init__.py +++ b/custom_components/saj_esolar/__init__.py @@ -1,19 +1,16 @@ -"""The eSolar SAJ (and their resellers) component.""" +"""The SAJ eSolar component — powered by pysaj-elekeeper.""" + +from __future__ import annotations import logging from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .api import EsolarApiClient, ESolarConfiguration, EsolarProvider +from .api import EsolarApiClient from .const import ( CONF_PASSWORD, - CONF_PLANT_ID, - CONF_PROVIDER_DOMAIN, - CONF_PROVIDER_PATH, - CONF_PROVIDER_USE_SSL, - CONF_PROVIDER_VERIFY_SSL, - CONF_SENSORS, + CONF_PLANT_UID, CONF_USERNAME, DOMAIN, ) @@ -21,48 +18,36 @@ _LOGGER = logging.getLogger(__name__) -# our platform only has sensors, no switches PLATFORMS: list[str] = ["sensor"] async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities=None ) -> bool: - """Setup integration config entry.""" + """Set up the SAJ eSolar config entry.""" _LOGGER.debug("Setting up eSolar entry: %s", entry.entry_id) - configEntry = entry.data - - provider = EsolarProvider( - configEntry.get(CONF_PROVIDER_DOMAIN), - configEntry.get(CONF_PROVIDER_PATH), - configEntry.get(CONF_PROVIDER_USE_SSL, True), - configEntry.get(CONF_PROVIDER_VERIFY_SSL, True), + config = entry.data + + api = EsolarApiClient( + hass, + username=config[CONF_USERNAME], + password=config[CONF_PASSWORD], + plant_uid=config.get(CONF_PLANT_UID) or None, + base_url=config.get("base_url", "https://eop.saj-electric.com"), ) - esolarConfig = ESolarConfiguration( - configEntry.get(CONF_USERNAME), - configEntry.get(CONF_PASSWORD), - configEntry.get(CONF_SENSORS), - configEntry.get(CONF_PLANT_ID), - provider, - ) - - api = EsolarApiClient(hass, esolarConfig) coordinator = EsolarDataUpdateCoordinator(hass, api) - # Perform the first refresh await coordinator.async_config_entry_first_refresh() - # Store coordinator hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator - # Forward setup to platforms await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a configEntry entry.""" + """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) @@ -70,6 +55,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload a configEntry entry.""" + """Reload a config entry.""" await async_unload_entry(hass, entry) await async_setup_entry(hass, entry) diff --git a/custom_components/saj_esolar/api.py b/custom_components/saj_esolar/api.py index 69f2bfb..7a2d27e 100644 --- a/custom_components/saj_esolar/api.py +++ b/custom_components/saj_esolar/api.py @@ -1,503 +1,66 @@ -"""API client for interacting with the eSolar site.""" +"""API adapter — wraps elekeeper.SajClient for use inside the coordinator.""" + +from __future__ import annotations -import calendar -from datetime import UTC, date, datetime, timedelta import logging -from ssl import SSLCertVerificationError -import aiohttp +import httpx from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.httpx_client import get_async_client -from .const import DEVICE_TYPES +from elekeeper import PlantOverview, SajApiError, SajAuthError, SajClient _LOGGER = logging.getLogger(__name__) -UNKNOWN_AUTH_ERROR = "Authentication failed for unknown reasons" -AUTHENTICATION_FAILED = "Authentication failed, please check your credentials" - - -def _add_months(sourcedate, months): - month = sourcedate.month - 1 + months - year = sourcedate.year + month // 12 - month = month % 12 + 1 - day = min(sourcedate.day, calendar.monthrange(year, month)[1]) - return date(year, month, day) - - -def _add_years(d, years): - try: - return d.replace(year=d.year + years) - except ValueError: - return d + (date(d.year + years, 1, 1) - date(d.year, 1, 1)) - - -class EsolarProvider: - """Handless the information of the url of a particular esolar provider (e.g. saj, greenheiss).""" - - def __init__( - self, host: str, path: str, use_https: bool = True, verify_ssl: bool = True - ) -> None: - """Initialize the sensor with the specified host, path, and protocol. - - Args: - host (str): The hostname or IP address of the target device. - path (str): The API or resource path to access on the device. - use_https (str): if https should be used (defaults true). - verify_ssl (bool): Whether to verify SSL certificates. Default to true. - """ - self.host = host - self.path = path - self.protocol = "https" if use_https else "http" - self.verify_ssl = verify_ssl - - def getBaseDomain(self): - """Returns the domain part of the provider url.""" - return f"{self.protocol}://{self.host}" - - def getBaseUrl(self): - """Returns the base path of the application. - - It is mostly 'cloud' or 'saj'. but might change depending on the provider - """ - return f"{self.getBaseDomain()}/{self.path}" - - def getLoginUrl(self): - """The url where users can login.""" - return f"{self.getBaseUrl()}/login" - - def getVerifySSL(self): - """Return if SSL should be verified.""" - return self.verify_ssl - - -class ESolarConfiguration: - """Holds configuration for the integration.""" - - def __init__( - self, - username: str, - password: str, - sensors: str, - plant_id: int, - provider: EsolarProvider, - ) -> None: - """Initialize the data object.""" - - self.provider = provider - self.username = username - self.password = password - self.sensors = sensors - self.plant_id = plant_id - self._data = None - class ApiError(HomeAssistantError): - """Custom exception for API errors.""" + """Raised when a non-auth API call fails.""" class ApiAuthError(HomeAssistantError): - """Custom exception for API authentication errors.""" + """Raised when authentication fails.""" class EsolarApiClient: - """API client for interacting with the eSolar site.""" + """Thin adapter that exposes a single `fetch_data()` coroutine returning a PlantOverview.""" def __init__( self, hass: HomeAssistant, - config: ESolarConfiguration, + username: str, + password: str, + plant_uid: str | None = None, + base_url: str = "https://eop.saj-electric.com", ) -> None: - """Initialize the API client. - - Args: - hass: homeassistant object - config: the configuration of the integration. - """ - - self.config = config - self.provider = config.provider - self.username = config.username - self.password = config.password - self._session = async_create_clientsession( - hass, verify_ssl=self.provider.getVerifySSL() - ) # some providers have SSL chains not accepted by hass - - async def fetch_data(self): - """Fetch and aggregate data from the eSolar site.""" - data = {} + self._username = username + self._password = password + self._plant_uid = plant_uid + self._client = SajClient( + base_url=base_url, + client=get_async_client(hass), + ) + + async def fetch_data(self) -> PlantOverview: + """Login and return a fully-populated PlantOverview.""" try: - today = date.today() - clientDate = today.strftime("%Y-%m-%d") - # Make sure we have a valid session cookie - await self.verifyLogin() - - headers = { - "Connection": "keep-alive", - "sec-ch-ua": '" Not;A Brand";v="99", "Google Chrome";v="91", "Chromium";v="91"', - "Accept": "application/json, text/javascript, */*; q=0.01", - "DNT": "1", - "X-Requested-With": "XMLHttpRequest", - "sec-ch-ua-mobile": "?0", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.106 Safari/537.36", - "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", - "Origin": self.provider.host, - "Sec-Fetch-Site": "same-origin", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Dest": "empty", - "Referer": f"{self.provider.getBaseUrl()}/monitor/home/index", - "Accept-Language": "nl-NL,nl;q=0.9,en-US;q=0.8,en;q=0.7", - } - # Get list of plants from esolar to get configured plant - url_userPlantList = ( - f"{self.provider.getBaseUrl()}/monitor/site/getUserPlantList" - ) - - payload_userPlantList = f"pageNo=&pageSize=&orderByIndex=&officeId=&clientDate={clientDate}&runningState=&selectInputType=1&plantName=&deviceSn=&type=&countryCode=&isRename=&isTimeError=&systemPowerLeast=&systemPowerMost=" - response_userPlantList = await self._session.post( - url_userPlantList, headers=headers, data=payload_userPlantList - ) - - if response_userPlantList.status != 200: - _LOGGER.error( - "%s returned %s", - response_userPlantList.url, - response_userPlantList.status, - ) - raise ApiError("Error fetching plant info from eSolar API") - - plantListInfo = await response_userPlantList.json() - plantuid = plantListInfo["plantList"][self.config.plant_id]["plantuid"] - - # Get the details of the configuredd plant - url_plantDetail = ( - f"{self.provider.getBaseUrl()}/monitor/site/getPlantDetailInfo" - ) - payload_plantDetail = f"plantuid={plantuid}&clientDate={clientDate}" - - response_plantDetail = await self._session.post( - url_plantDetail, headers=headers, data=payload_plantDetail - ) - - if response_plantDetail.status != 200: - _LOGGER.error( - "%s returned %s", - response_plantDetail.url, - response_plantDetail.status, - ) - raise ApiError("Error fetching plant details from eSolar API") - - plantDetails = await response_plantDetail.json() - _LOGGER.debug("plantDetails: %s", plantDetails) - plantDetails.update(plantListInfo) - - # device info. provide info on hardware devices. - # some of it is required for subsequent requests. - url_devicesInfo = ( - f"{self.provider.getBaseUrl()}/cloudMonitor/device/findDevicePageList" - ) - payload_devicesInfo = f"officeId=&pageNo=&pageSize=&orderName=1&orderType=2&plantuid={plantuid}&deviceStatus=&localDate=&localMonth=" - response_deviceInfo = await self._session.post( - url_devicesInfo, headers=headers, data=payload_devicesInfo - ) - if response_deviceInfo.status != 200: - _LOGGER.error( - "%s returned %s", - response_deviceInfo.url, - response_deviceInfo.status, - ) - raise ApiError("Error fetching device info from eSolar API") - - data_devicesInfo = await response_deviceInfo.json() - plantDetails.update(data_devicesInfo) - if self.config.sensors == "h1": - deviceSnArr = next( - ( - item["devicesn"] - for item in plantDetails["list"] - if item["type"] == DEVICE_TYPES["Battery"] - ), - plantDetails["plantDetail"]["snList"][0], - ) - else: - deviceSnArr = plantDetails["plantDetail"]["snList"][0] - - # getPlantDetailChart2 - previousChartDay = today - timedelta(days=1) - nextChartDay = today + timedelta(days=1) - chartDay = today.strftime("%Y-%m-%d") - previousChartMonth = _add_months(today, -1).strftime("%Y-%m") - nextChartMonth = _add_months(today, 1).strftime("%Y-%m") - chartMonth = today.strftime("%Y-%m") - previousChartYear = _add_years(today, -1).strftime("%Y") - nextChartYear = _add_years(today, 1).strftime("%Y") - chartYear = today.strftime("%Y") - epochmilliseconds = int(datetime.now(UTC).timestamp() * 1000) - elecDevicesn = deviceSnArr if self.config.sensors == "h1" else "" - url_plantDetailChart2 = f"{self.provider.getBaseUrl()}/monitor/site/getPlantDetailChart2?plantuid={plantuid}&chartDateType=1&energyType=0&clientDate={clientDate}&deviceSnArr={deviceSnArr}&chartCountType=2&previousChartDay={previousChartDay}&nextChartDay={nextChartDay}&chartDay={chartDay}&previousChartMonth={previousChartMonth}&nextChartMonth={nextChartMonth}&chartMonth={chartMonth}&previousChartYear={previousChartYear}&nextChartYear={nextChartYear}&chartYear={chartYear}&elecDevicesn={elecDevicesn}&_={epochmilliseconds}" - response_PlantDetailChart2 = await self._session.post( - url_plantDetailChart2, headers=headers - ) - - if response_PlantDetailChart2.status != 200: - _LOGGER.error( - "%s returned %s", - response_PlantDetailChart2.url, - response_PlantDetailChart2.status, - ) - raise ApiError("Error fetching plant charts from eSolar API") - - plantcharts = await response_PlantDetailChart2.json() - plantDetails.update(plantcharts) - - # H1 Module - if self.config.sensors == "h1": - # getStoreOrAcDevicePowerInfo - url_getStoreOrAcDevicePowerInfo = f"{self.provider.getBaseUrl()}/monitor/site/getStoreOrAcDevicePowerInfo?plantuid=&devicesn={deviceSnArr}&_={epochmilliseconds}" - - response_getStoreOrAcDevicePowerInfo = await self._session.post( - url_getStoreOrAcDevicePowerInfo, headers=headers - ) - - if response_getStoreOrAcDevicePowerInfo.status != 200: - _LOGGER.error( - "%s returned %s", - response_getStoreOrAcDevicePowerInfo.url, - response_getStoreOrAcDevicePowerInfo.status, - ) - raise ApiError("Error fetching H1 module data from eSolar API") - - result_getStoreOrAcDevicePowerInfo = ( - await response_getStoreOrAcDevicePowerInfo.json() - ) - plantDetails.update(result_getStoreOrAcDevicePowerInfo) - _LOGGER.debug( - "result_getStoreOrAcDevicePowerInfo: %s", - result_getStoreOrAcDevicePowerInfo, - ) - - elif self.config.sensors == "None": - data = plantDetails - else: - # Data = plantdetails - data = plantDetails - - # Sec module - if self.config.sensors == "saj_sec": - # getPlantMeterModuleList - url_module = f"{self.provider.getBaseUrl()}/cloudmonitor/plantMeterModule/getPlantMeterModuleList" - - payload_module = f"pageNo=&pageSize=&plantUid={plantuid}" - - response_module = await self._session.post( - url_module, headers=headers, data=payload_module - ) - - if response_module.status != 200: - _LOGGER.error( - "%s returned %s", response_module.url, response_module.status - ) - raise ApiError("Error fetching module list from eSolar API") - - getPlantMeterModuleList = await response_module.json() - - temp_getPlantMeterModuleList = {} - temp_getPlantMeterModuleList["getPlantMeterModuleList"] = ( - getPlantMeterModuleList - ) - - plantDetails.update(temp_getPlantMeterModuleList) - - moduleSn = plantDetails["getPlantMeterModuleList"]["moduleList"][0][ - "moduleSn" - ] - - # -Debug- Sec module serial number - _LOGGER.debug(moduleSn) - - # findDevicePageList - url_findDevicePageList = f"{self.provider.getBaseUrl()}/cloudMonitor/device/findDevicePageList" - - payload_findDevicePageList = f"officeId=1&pageNo=&pageSize=&orderName=1&orderType=2&plantuid={plantuid}&deviceStatus=&localDate={chartMonth}&localMonth={chartMonth}" - - response_findDevicePageList = await self._session.post( - url_findDevicePageList, - headers=headers, - data=payload_findDevicePageList, - ) - - if response_findDevicePageList.status != 200: - _LOGGER.error( - "%s returned %s", - response_findDevicePageList.url, - response_findDevicePageList.status, - ) - raise ApiError("Error fetching device list from eSolar API") - - findDevicePageList = await response_findDevicePageList.json() - - temp_findDevicePageList = {} - temp_findDevicePageList["findDevicePageList"] = findDevicePageList - - plantDetails.update(temp_findDevicePageList) - - # getPlantMeterDetailInfo - url_getPlantMeterDetailInfo = ( - f"{self.provider.getBaseUrl()}/monitor/site/getPlantMeterDetailInfo" - ) - - payload_getPlantMeterDetailInfo = ( - f"plantuid={plantuid}&clientDate={clientDate}" - ) - - response_getPlantMeterDetailInfo = await self._session.post( - url_getPlantMeterDetailInfo, - headers=headers, - data=payload_getPlantMeterDetailInfo, - ) - - if response_getPlantMeterDetailInfo.status != 200: - _LOGGER.error( - "%s returned %s", - response_getPlantMeterDetailInfo.url, - response_getPlantMeterDetailInfo.status, - ) - raise ApiError("Error fetching meter details from eSolar API") - - getPlantMeterDetailInfo = await response_getPlantMeterDetailInfo.json() - - temp_getPlantMeterDetailInfo = {} - temp_getPlantMeterDetailInfo["getPlantMeterDetailInfo"] = ( - getPlantMeterDetailInfo - ) - - plantDetails.update(temp_getPlantMeterDetailInfo) - - # getPlantMeterEnergyPreviewInfo - url_getPlantMeterEnergyPreviewInfo = f"{self.provider.getBaseUrl()}/monitor/site/getPlantMeterEnergyPreviewInfo?plantuid={plantuid}&moduleSn={moduleSn}&_={epochmilliseconds}" - - response_getPlantMeterEnergyPreviewInfo = await self._session.get( - url_getPlantMeterEnergyPreviewInfo, headers=headers - ) - - if response_getPlantMeterEnergyPreviewInfo.status != 200: - _LOGGER.error( - "%s returned %s", - response_getPlantMeterEnergyPreviewInfo.url, - response_getPlantMeterEnergyPreviewInfo.status, - ) - raise ApiError("Error fetching energy preview from eSolar API") - - getPlantMeterEnergyPreviewInfo = ( - await response_getPlantMeterEnergyPreviewInfo.json() - ) - - temp_getPlantMeterEnergyPreviewInfo = {} - temp_getPlantMeterEnergyPreviewInfo[ - "getPlantMeterEnergyPreviewInfo" - ] = getPlantMeterEnergyPreviewInfo - - plantDetails.update(temp_getPlantMeterEnergyPreviewInfo) - - # Get Sec Meter details - url_getPlantMeterChartData = f"{self.provider.getBaseUrl()}/monitor/site/getPlantMeterChartData?plantuid={plantuid}&chartDateType=1&energyType=0&clientDate={clientDate}&deviceSnArr=&chartCountType=2&previousChartDay={previousChartDay}&nextChartDay={nextChartDay}&chartDay={chartDay}&previousChartMonth={previousChartMonth}&nextChartMonth={nextChartMonth}&chartMonth={chartMonth}&previousChartYear={previousChartYear}&nextChartYear={nextChartYear}&chartYear={chartYear}&moduleSn={moduleSn}&_={epochmilliseconds}" - - response_getPlantMeterChartData = await self._session.post( - url_getPlantMeterChartData, headers=headers - ) - - if response_getPlantMeterChartData.status != 200: - _LOGGER.error( - "%s returned %s", - response_getPlantMeterChartData.url, - response_getPlantMeterChartData.status, - ) - raise ApiError("Error fetching meter chart data from eSolar API") - - getPlantMeterChartData = await response_getPlantMeterChartData.json() - - temp_getPlantMeterChartData = {} - temp_getPlantMeterChartData["getPlantMeterChartData"] = ( - getPlantMeterChartData - ) - - plantDetails.update(temp_getPlantMeterChartData) - - # Data = plantdetails including Sec module - data = plantDetails - elif self.config.sensors == "None": - data = plantDetails - else: - # Data = plantdetails Wtihout Sec module - data = plantDetails - - # Error logging - except aiohttp.ClientError as err: - _LOGGER.exception("Cannot poll eSolar using url: %s", err) - raise ApiError("Cannot poll eSolar using url") from err - except TimeoutError as err: - _LOGGER.exception("Timeout error occurred while polling eSolar") - raise ApiError("Timeout error occurred while polling eSolar ") from err - return data - - async def verifyLogin(self) -> None: - """Verify login to eSolar site. - - The eSolar site uses a form based login that returns a redirect and sets a cookie on success. - The cookie is managed for us by the aiohttp session. So subsequent calls using the same session will be authenticated. - """ - url = self.provider.getLoginUrl() - _LOGGER.debug("Trying to login on: %s", url) - - payload = { - "lang": "en", - "username": self.username, - "password": self.password, - "rememberMe": "true", - } - headers_login = { - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", - "Accept-Encoding": "gzip, deflate, br", - "Accept-Language": "nl-NL,nl;q=0.9,en-US;q=0.8,en;q=0.7", - "Cache-Control": "max-age=0", - "Connection": "keep-alive", - "Content-Type": "application/x-www-form-urlencoded", - "Cookie": "org.springframework.web.servlet.i18n.CookieLocaleResolver.LOCALE=en; op:_lang=en", - "DNT": "1", - "Host": self.provider.host, - "Origin": self.provider.host, - "Referer": self.provider.getLoginUrl(), - "sec-ch-ua": '" Not;A Brand";v="99", "Google Chrome";v="91", "Chromium";v="91"', - "sec-ch-ua-mobile": "?0", - "Sec-Fetch-Dest": "document", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "same-origin", - "Sec-Fetch-User": "?1", - "Upgrade-Insecure-Requests": "1", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36", - } - # do not allow redirects, because a successful login returns a 302 to the dashboard + await self._client.login(self._username, self._password) + return await self._client.get_plant_overview(self._plant_uid) + except SajAuthError as err: + raise ApiAuthError(str(err)) from err + except SajApiError as err: + raise ApiError(str(err)) from err + except httpx.HTTPError as err: + raise ApiError(f"Network error: {err}") from err + + async def verify_login(self) -> None: + """Validate credentials by attempting a login (used by config flow).""" try: - response = await self._session.post( - url, headers=headers_login, data=payload, allow_redirects=False - ) - _LOGGER.debug("login response: %s, %s", response.status, response.text) - except SSLCertVerificationError as err: - _LOGGER.exception("SSL Certificate error:") - raise ApiError("SSL Certificate error") from err - - # the login url is a html page so a bad credential returns a 200 with the same page - # a good login returns a 302/303 to the dashboard - _LOGGER.debug("%s returned %s", response.url, response.status) - if response.status in {200, 401, 403}: - raise ApiAuthError(AUTHENTICATION_FAILED) - if response.status in {302, 303} and "Location" in response.headers: - _LOGGER.debug( - "apparently successfull redirect %s, redirecting to %s", - response.status, - response.headers["Location"], - ) - return - raise ApiAuthError(UNKNOWN_AUTH_ERROR) + await self._client.login(self._username, self._password) + except SajAuthError as err: + raise ApiAuthError(str(err)) from err + except SajApiError as err: + raise ApiError(str(err)) from err + except httpx.HTTPError as err: + raise ApiError(f"Network error: {err}") from err diff --git a/custom_components/saj_esolar/config_flow.py b/custom_components/saj_esolar/config_flow.py index db14c99..621aa1a 100644 --- a/custom_components/saj_esolar/config_flow.py +++ b/custom_components/saj_esolar/config_flow.py @@ -1,4 +1,6 @@ -"""Config flow for SAJ ESolar integration.""" +"""Config flow for SAJ eSolar integration — powered by pysaj-elekeeper.""" + +from __future__ import annotations import logging @@ -8,118 +10,70 @@ from homeassistant.data_entry_flow import section import homeassistant.helpers.config_validation as cv -from .api import ( - UNKNOWN_AUTH_ERROR, - ApiAuthError, - ApiError, - EsolarApiClient, - ESolarConfiguration, - EsolarProvider, -) +from .api import ApiAuthError, ApiError, EsolarApiClient from .const import ( - CONF_LEGACY_VERIFY_SSL, CONF_PASSWORD, - CONF_PLANT_ID, - CONF_PROVIDER_DOMAIN, - CONF_PROVIDER_PATH, - CONF_PROVIDER_USE_SSL, - CONF_PROVIDER_VERIFY_SSL, - CONF_SENSORS, + CONF_PLANT_UID, CONF_USERNAME, DOMAIN, ) -DEFAULT_PROVIDER_DOMAIN = "greenheiss-portal.saj-electric.com" -DEFAULT_PROVIDER_PATH = "cloud" -DEFAULT_PROVIDER_SSL = True -SENSOR_CHOICES = ["h1", "saj_sec"] +DEFAULT_BASE_URL = "https://eop.saj-electric.com" _LOGGER = logging.getLogger(__name__) class EsolarGreenheissFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for SAJ ESolar.""" + """Handle a config flow for SAJ eSolar.""" VERSION = 1 async def async_step_user(self, user_input=None): - """Handle the initial step from the user.""" + """Handle the initial step.""" existing_entries = self._async_current_entries() - if existing_entries: - # Abort if any entry exists return self.async_abort(reason="already_configured") errors = {} if user_input is not None: config = self._flatten_section(user_input) - # remove / from the path in case the user added it. - if config[CONF_PROVIDER_PATH] is not None: - config[CONF_PROVIDER_PATH] = config[CONF_PROVIDER_PATH].strip("/") try: - provider = EsolarProvider( - config[CONF_PROVIDER_DOMAIN], - config[CONF_PROVIDER_PATH], - config[CONF_PROVIDER_USE_SSL], - config[CONF_PROVIDER_VERIFY_SSL], - ) - meterData = ESolarConfiguration( - config[CONF_USERNAME], - config[CONF_PASSWORD], - [], - 0, - provider, + api = EsolarApiClient( + self.hass, + username=config[CONF_USERNAME], + password=config[CONF_PASSWORD], + plant_uid=config.get(CONF_PLANT_UID) or None, + base_url=config.get("base_url", DEFAULT_BASE_URL), ) - api = EsolarApiClient(self.hass, meterData) - await api.verifyLogin() + await api.verify_login() except ApiAuthError as err: - _LOGGER.error("Authentication error") - errors["base"] = str(err) + _LOGGER.error("Authentication error: %s", err) + errors["base"] = "invalid_auth" except ApiError as err: - _LOGGER.error("API error") - errors["base"] = str(err) + _LOGGER.error("API error: %s", err) + errors["base"] = "cannot_connect" except Exception: _LOGGER.exception("Unexpected exception") - errors["base"] = UNKNOWN_AUTH_ERROR - - if len(errors) == 0: - # all good - uuid = self._getId(config) - # TODO:the id is username@provider_domain. - # this might cause issues if we allow to reconfigure the username or - # allow multiples configurations or plants - # maybe using the plantid would be better? - await self.async_set_unique_id(uuid) + errors["base"] = "unknown" + + if not errors: + unique_id = config[CONF_USERNAME] + await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() - return self.async_create_entry( - title=uuid, - data=config, - ) + return self.async_create_entry(title=unique_id, data=config) - # Define the form schema schema = vol.Schema( { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, - vol.Optional("sensors", default=SENSOR_CHOICES[1]): vol.In( - SENSOR_CHOICES - ), vol.Required("advanced"): section( vol.Schema( { - vol.Optional( - CONF_PROVIDER_DOMAIN, default=DEFAULT_PROVIDER_DOMAIN - ): str, - vol.Optional( - CONF_PROVIDER_PATH, default=DEFAULT_PROVIDER_PATH - ): str, - vol.Optional(CONF_PLANT_ID, default=0): cv.positive_int, - vol.Required(CONF_PROVIDER_USE_SSL, default=True): bool, - vol.Required(CONF_PROVIDER_VERIFY_SSL, default=True): bool, + vol.Optional(CONF_PLANT_UID, default=""): str, + vol.Optional("base_url", default=DEFAULT_BASE_URL): str, } ), - # Whether or not the section is initially collapsed (default = False) {"collapsed": True}, ), } @@ -128,7 +82,7 @@ async def async_step_user(self, user_input=None): return self.async_show_form(step_id="user", data_schema=schema, errors=errors) async def async_step_reauth(self, user_input=None): - """Handle re-authentication of an existing config entry.""" + """Handle re-authentication.""" errors = {} entry_id = self.context.get("entry_id") entry = self.hass.config_entries.async_get_entry(entry_id) @@ -136,91 +90,55 @@ async def async_step_reauth(self, user_input=None): if user_input is not None: new_data = {**entry.data, **user_input} try: - provider = EsolarProvider( - new_data.get(CONF_PROVIDER_DOMAIN, DEFAULT_PROVIDER_DOMAIN), - new_data.get(CONF_PROVIDER_PATH, DEFAULT_PROVIDER_PATH), - new_data.get(CONF_PROVIDER_USE_SSL, True), - new_data.get(CONF_PROVIDER_VERIFY_SSL, DEFAULT_PROVIDER_SSL), + api = EsolarApiClient( + self.hass, + username=new_data[CONF_USERNAME], + password=new_data[CONF_PASSWORD], + plant_uid=new_data.get(CONF_PLANT_UID) or None, + base_url=new_data.get("base_url", DEFAULT_BASE_URL), ) - config = ESolarConfiguration( - new_data[CONF_USERNAME], - new_data[CONF_PASSWORD], - SENSOR_CHOICES[-1], - 0, - provider, - ) - api = EsolarApiClient(self.hass, config) - await api.verifyLogin() - except ApiAuthError as err: - _LOGGER.exception("Authentication error") - errors["base"] = str(err) - except ApiError as err: - _LOGGER.exception("API error") - errors["base"] = str(err) + await api.verify_login() + except ApiAuthError: + errors["base"] = "invalid_auth" + except ApiError: + errors["base"] = "cannot_connect" except Exception: _LOGGER.exception("Unexpected exception") - errors["base"] = UNKNOWN_AUTH_ERROR + errors["base"] = "unknown" if not errors: - # Update the existing config entry - new_data = {**entry.data, **user_input} self.hass.config_entries.async_update_entry(entry, data=new_data) - # reload await self.hass.config_entries.async_reload(entry_id) return self.async_abort(reason="reauth_successful") - # only password. changing username would likely break energy dashboard schema = vol.Schema( { vol.Required(CONF_PASSWORD, default=entry.data.get(CONF_PASSWORD)): str, } ) - - placeholders = { - "username": entry.data[CONF_USERNAME], - "provider_domain": entry.data.get(CONF_PROVIDER_DOMAIN), - } - return self.async_show_form( step_id="reauth", data_schema=schema, - description_placeholders=placeholders, + description_placeholders={"username": entry.data[CONF_USERNAME]}, errors=errors, ) async def async_step_import(self, import_config: dict): """Import a config entry from legacy YAML.""" - # Use username as stable unique_id - uuid = self._getId(import_config) - await self.async_set_unique_id(uuid) + unique_id = import_config[CONF_USERNAME] + await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() - - data = { - CONF_USERNAME: import_config[CONF_USERNAME], - CONF_PASSWORD: import_config[CONF_PASSWORD], - CONF_PLANT_ID: import_config.get(CONF_PLANT_ID, 0), - CONF_SENSORS: import_config.get(CONF_SENSORS, SENSOR_CHOICES[1]), - CONF_PROVIDER_DOMAIN: import_config.get( - CONF_PROVIDER_DOMAIN, DEFAULT_PROVIDER_DOMAIN - ), - CONF_PROVIDER_PATH: import_config.get( - CONF_PROVIDER_PATH, DEFAULT_PROVIDER_PATH - ), - CONF_PROVIDER_USE_SSL: import_config.get(CONF_PROVIDER_USE_SSL, True), - # migrate the old provider_ssl property to a better named property - CONF_PROVIDER_VERIFY_SSL: import_config.get(CONF_LEGACY_VERIFY_SSL, True), - } return self.async_create_entry( - title=uuid, - data=data, + title=unique_id, + data={ + CONF_USERNAME: import_config[CONF_USERNAME], + CONF_PASSWORD: import_config[CONF_PASSWORD], + CONF_PLANT_UID: import_config.get(CONF_PLANT_UID, ""), + "base_url": DEFAULT_BASE_URL, + }, ) - def _getId(self, dictionary) -> str: - """Generate a unique ID for the config entry.""" - return dictionary[CONF_USERNAME] + "@" + dictionary[CONF_PROVIDER_DOMAIN] - def _flatten_section(self, user_input: dict) -> dict: - """Flatten sectioned config flow data into a single dict.""" flattened = {} for key, value in user_input.items(): if isinstance(value, dict): diff --git a/custom_components/saj_esolar/const.py b/custom_components/saj_esolar/const.py index 872ab46..ebc0c93 100644 --- a/custom_components/saj_esolar/const.py +++ b/custom_components/saj_esolar/const.py @@ -1,21 +1,33 @@ -"""Constants for the SAJ Esolar integration.""" +"""Constants for the SAJ eSolar integration.""" -from typing import Final +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Final from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower +from homeassistant.const import ( + PERCENTAGE, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfTemperature, +) DOMAIN = "saj_esolar" -# configuration entry data +# configuration entry keys CONF_USERNAME = "username" CONF_PASSWORD = "password" CONF_SENSORS = "sensors" CONF_PLANT_ID = "plant_id" +CONF_PLANT_UID = "plant_uid" CONF_PROVIDER_DOMAIN = "provider_domain" CONF_PROVIDER_PATH = "provider_path" CONF_PROVIDER_USE_SSL = "provider_use_ssl" @@ -23,476 +35,568 @@ CONF_LEGACY_VERIFY_SSL = "provider_ssl" CONF_RESOURCES = "resources" -DEVICE_TYPES = { - "Inverter": 0, - "Meter": 1, # TODO: Pending to confirm - "Battery": 2, - 0: "Inverter", - 1: "Meter", # TODO: Pending to confirm - 2: "Battery", -} + +@dataclass(frozen=True) +class SajSensorDescription(SensorEntityDescription): + """SensorEntityDescription extended with a PlantOverview accessor.""" + + value_fn: Callable[[Any], Any] | None = None +# Sensor lists kept for YAML migration compat BASIC_SENSORS = [ - "nowPower", - "runningState", - "devOnlineNum", - "todayElectricity", - "monthElectricity", - "yearElectricity", - "totalElectricity", - "todayGridIncome", - "income", - "lastUploadTime", - "totalPlantTreeNum", - "totalReduceCo2", - "plantuid", - "plantname", - "currency", - "address", - "isOnline", - "status", - "peakPower", - "systemPower", - "pvElec", - "useElec", - "buyElec", - "sellElec", - "buyRate", - "sellRate", - "selfUseRate", - "selfConsumedRate1", - "selfConsumedRate2", - "selfConsumedEnergy1", - "selfConsumedEnergy2", - "plantTreeNum", - "reduceCo2", + "nowPower", "runningState", "devOnlineNum", "todayElectricity", + "monthElectricity", "yearElectricity", "totalElectricity", + "todayGridIncome", "income", "lastUploadTime", "totalPlantTreeNum", + "totalReduceCo2", "plantuid", "plantname", "currency", "address", + "isOnline", "status", "peakPower", "systemPower", + "pvElec", "useElec", "buyElec", "sellElec", "buyRate", "sellRate", + "selfUseRate", "selfConsumedRate1", "selfConsumedRate2", + "selfConsumedEnergy1", "selfConsumedEnergy2", "plantTreeNum", "reduceCo2", ] SAJ_SENSORS = [ *BASIC_SENSORS, - "totalPvEnergy", - "totalLoadEnergy", # Energy -> Grid consumption - "totalBuyEnergy", - "totalSellEnergy", # Energy -> Return to grid - "gridLoadPower", # Power imported from the grid - "solarLoadPower", # Solar power being currently self-consumed - "homeLoadPower", # Total power being consumed by the plant (the home) - "exportPower", # Power being exported to the grid - "powerFlow", #for energy dashboard 2025.12. positive for using power from the grid, negative for exporting to the grid - + "totalPvEnergy", "totalLoadEnergy", "totalBuyEnergy", "totalSellEnergy", + "gridLoadPower", "solarLoadPower", "homeLoadPower", "exportPower", "powerFlow", ] H1_SENSORS = [ *BASIC_SENSORS, - "totalBuyElec", # Energy -> Grid consumption - "totalConsumpElec", - "totalSellElec", # Energy -> Return to grid - "chargeElec", # Energy -> Home Battery Storage -> Energy going in to the battery (kWh) - "dischargeElec", # Energy -> Home Battery Storage -> Energy coming out of the battery (kWh) - "isStorageAlarm", - "batCapcity", - "batCurr", - "batEnergyPercent", - "batteryDirection", - "batteryPower", - "gridDirection", - "gridPower", - "h1Online", - "outPower", - "outPutDirection", - "pvDirection", - "pvPower", - "solarPower", + "totalBuyElec", "totalConsumpElec", "totalSellElec", + "chargeElec", "dischargeElec", + "isStorageAlarm", "batCapcity", "batCurr", "batEnergyPercent", + "batteryDirection", "batteryPower", "gridDirection", "gridPower", + "h1Online", "outPower", "outPutDirection", "pvDirection", "pvPower", "solarPower", ] -# all sensors supported and their description for hass -SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( - SensorEntityDescription( + +SENSOR_TYPES: Final[tuple[SajSensorDescription, ...]] = ( + # ── Live power ────────────────────────────────────────────────────────── + SajSensorDescription( key="nowPower", name="nowPower", icon="mdi:solar-power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda o: o.pv_power_w, ), - SensorEntityDescription( - key="runningState", - name="runningState", - icon="mdi:solar-panel", - ), - SensorEntityDescription( - key="devOnlineNum", - name="devOnlineNum", - icon="mdi:solar-panel", - ), - SensorEntityDescription( - key="todayElectricity", - name="todayElectricity", + SajSensorDescription( + key="pvPower", + name="pvPower", icon="mdi:solar-panel-large", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda o: o.pv_power_w, ), - SensorEntityDescription( - key="monthElectricity", - name="monthElectricity", + SajSensorDescription( + key="solarPower", + name="solarPower", icon="mdi:solar-panel-large", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda o: o.pv_power_w, ), - SensorEntityDescription( - key="yearElectricity", - name="yearElectricity", - icon="mdi:solar-panel-large", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, + SajSensorDescription( + key="homeLoadPower", + name="homeLoadPower", + icon="mdi:home-lightning-bolt-outline", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda o: o.load_power_w, ), - SensorEntityDescription( - key="totalElectricity", - name="totalElectricity", - icon="mdi:solar-panel-large", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, + SajSensorDescription( + key="outPower", + name="outPower", + icon="mdi:home-lightning-bolt-outline", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda o: o.load_power_w, ), - SensorEntityDescription( - key="selfUseRate", - name="selfUseRate", - icon="mdi:solar-panel", - native_unit_of_measurement=PERCENTAGE, + SajSensorDescription( + key="totalLoadPower", + name="totalLoadPower", + icon="mdi:home-lightning-bolt-outline", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda o: o.load_power_w, ), - SensorEntityDescription( - key="totalBuyElec", - name="totalBuyElec", - icon="mdi:solar-panel", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, + SajSensorDescription( + key="gridPower", + name="gridPower", + icon="mdi:transmission-tower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda o: o.grid_power_w, ), - SensorEntityDescription( - key="totalConsumpElec", - name="totalConsumpElec", - icon="mdi:solar-panel", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, + SajSensorDescription( + key="totalGridPower", + name="totalGridPower", + icon="mdi:transmission-tower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda o: o.grid_power_w, ), - SensorEntityDescription( - key="totalSellElec", - name="totalSellElec", - icon="mdi:solar-panel", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, + SajSensorDescription( + key="gridLoadPower", + name="gridLoadPower", + icon="mdi:transmission-tower-import", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda o: ( + o.grid_power_w if o.grid_power_w is not None and o.grid_power_w > 0 else 0.0 + ), ), - SensorEntityDescription( - key="todayGridIncome", - name="todayGridIncome", - icon="mdi:currency-eur", + SajSensorDescription( + key="exportPower", + name="exportPower", + icon="mdi:transmission-tower-export", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda o: ( + abs(o.grid_power_w) if o.grid_power_w is not None and o.grid_power_w < 0 else 0.0 + ), ), - SensorEntityDescription( - key="income", - name="income", - icon="mdi:currency-eur", + SajSensorDescription( + key="solarLoadPower", + name="solarLoadPower", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda o: o.pv_power_w, ), - SensorEntityDescription( - key="lastUploadTime", - name="lastUploadTime", - icon="mdi:timer-sand", + SajSensorDescription( + key="totalPvgenPower", + name="totalPvgenPower", + icon="mdi:solar-panel", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda o: o.pv_power_w, ), - SensorEntityDescription( - key="totalPlantTreeNum", - name="totalPlantTreeNum", - icon="mdi:tree", + SajSensorDescription( + key="powerFlow", + name="Power Flow", + icon="mdi:transmission-tower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + # positive = importing, negative = exporting (matches original beta logic) + value_fn=lambda o: o.grid_power_w, ), - SensorEntityDescription( - key="totalReduceCo2", - name="totalReduceCo2", - icon="mdi:molecule-co2", + # ── Battery live ──────────────────────────────────────────────────────── + SajSensorDescription( + key="batteryPower", + name="batteryPower", + icon="mdi:battery-charging", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda o: o.battery_power_w, ), - SensorEntityDescription( - key="plantuid", - name="plantuid", - icon="mdi:api", + SajSensorDescription( + key="batteryDirection", + name="batteryDirection", + icon="mdi:battery-sync", + value_fn=lambda o: o.battery_direction, ), - SensorEntityDescription( - key="plantname", - name="plantname", - icon="mdi:api", + SajSensorDescription( + key="gridDirection", + name="gridDirection", + icon="mdi:transmission-tower", + value_fn=lambda o: o.grid_direction, ), - SensorEntityDescription( - key="currency", - name="currency", - icon="mdi:solar-panel", + SajSensorDescription( + key="pvDirection", + name="pvDirection", + icon="mdi:solar-power-variant", + value_fn=lambda o: ( + "Exporting" if o.pv_power_w is not None and o.pv_power_w > 0 else "Standby" + ), ), - SensorEntityDescription( - key="address", - name="address", - icon="mdi:solar-panel", + SajSensorDescription( + key="outPutDirection", + name="outPutDirection", + icon="mdi:home-lightning-bolt-outline", + value_fn=lambda o: ( + "Exporting" if o.load_power_w is not None and o.load_power_w > 0 else "Standby" + ), ), - SensorEntityDescription( - key="isOnline", - name="isOnline", - icon="mdi:api", + # ── Battery state ──────────────────────────────────────────────────────── + SajSensorDescription( + key="batEnergyPercent", + name="batEnergyPercent", + icon="mdi:battery-heart-variant", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda o: o.battery_soc_percent, ), - SensorEntityDescription( - key="status", - name="status", - icon="mdi:api", + SajSensorDescription( + key="batCapcity", + name="batCapcity", + icon="mdi:battery-check", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda o: o.battery_soh_percent, ), - SensorEntityDescription( - key="peakPower", - name="peakPower", - icon="mdi:solar-panel", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, + SajSensorDescription( + key="batCurr", + name="batCurr", + icon="mdi:current-dc", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda o: o.battery_current_a, + ), + SajSensorDescription( + key="batVoltage", + name="batVoltage", + icon="mdi:lightning-bolt", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda o: o.battery_voltage_v, + ), + SajSensorDescription( + key="batTemperature", + name="batTemperature", + icon="mdi:thermometer", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda o: o.battery_temperature, ), - SensorEntityDescription( - key="systemPower", - name="systemPower", - icon="mdi:solar-panel", - native_unit_of_measurement=UnitOfPower.KILO_WATT, - device_class=SensorDeviceClass.POWER, + SajSensorDescription( + key="isStorageAlarm", + name="isStorageAlarm", + icon="mdi:battery-alert", + value_fn=lambda o: None, # not exposed by Elekeeper API — kept for compat + ), + SajSensorDescription( + key="h1Online", + name="h1Online", + icon="mdi:connection", + value_fn=lambda o: None, # not exposed by Elekeeper API — kept for compat ), - SensorEntityDescription( + # ── Today energy ───────────────────────────────────────────────────────── + SajSensorDescription( + key="todayElectricity", + name="todayElectricity", + icon="mdi:solar-panel-large", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda o: o.today_pv_energy_kwh, + ), + SajSensorDescription( key="pvElec", name="pvElec", icon="mdi:solar-panel-large", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + value_fn=lambda o: o.today_pv_energy_kwh, ), - SensorEntityDescription( + SajSensorDescription( key="useElec", name="useElec", - icon="mdi:solar-panel-large", + icon="mdi:home-lightning-bolt-outline", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda o: o.today_load_energy_kwh, ), - SensorEntityDescription( + SajSensorDescription( key="buyElec", name="buyElec", - icon="mdi:solar-panel-large", + icon="mdi:transmission-tower-import", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda o: o.today_grid_import_kwh, ), - SensorEntityDescription( + SajSensorDescription( key="sellElec", name="sellElec", - icon="mdi:solar-panel-large", + icon="mdi:transmission-tower-export", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda o: o.today_grid_export_kwh, ), - SensorEntityDescription( - key="buyRate", - name="buyRate", - icon="mdi:solar-panel", - native_unit_of_measurement=PERCENTAGE, - ), - SensorEntityDescription( - key="sellRate", - name="sellRate", - icon="mdi:solar-panel", - native_unit_of_measurement=PERCENTAGE, - ), - SensorEntityDescription( - key="selfConsumedRate1", - name="selfConsumedRate1", - icon="mdi:solar-panel", - native_unit_of_measurement=PERCENTAGE, - ), - SensorEntityDescription( - key="selfConsumedRate2", - name="selfConsumedRate2", - icon="mdi:solar-panel", - native_unit_of_measurement=PERCENTAGE, + SajSensorDescription( + key="chargeElec", + name="chargeElec", + icon="mdi:battery-arrow-up", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda o: o.today_battery_charge_kwh, ), - SensorEntityDescription( - key="selfConsumedEnergy1", - name="selfConsumedEnergy1", - icon="mdi:solar-panel-large", + SajSensorDescription( + key="dischargeElec", + name="dischargeElec", + icon="mdi:battery-arrow-down", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda o: o.today_battery_discharge_kwh, ), - SensorEntityDescription( - key="selfConsumedEnergy2", - name="selfConsumedEnergy2", + # ── Total / lifetime energy ─────────────────────────────────────────────── + SajSensorDescription( + key="totalElectricity", + name="totalElectricity", icon="mdi:solar-panel-large", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda o: o.total_pv_energy_kwh, ), - SensorEntityDescription( - key="plantTreeNum", - name="plantTreeNum", - icon="mdi:tree", - ), - SensorEntityDescription( - key="reduceCo2", - name="reduceCo2", - icon="mdi:molecule-co2", - ), - SensorEntityDescription( - key="gridLoadPower", - name="gridLoadPower", - icon="mdi:transmission-tower-import", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), - SensorEntityDescription( - key="solarLoadPower", - name="solarLoadPower", - icon="mdi:solar-power", - native_unit_of_measurement=UnitOfPower.WATT, - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.POWER, - ), - SensorEntityDescription( - key="homeLoadPower", - name="homeLoadPower", - icon="mdi:home-lightning-bolt-outline", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), - SensorEntityDescription( - key="exportPower", - name="exportPower", - icon="mdi:transmission-tower-export", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), - SensorEntityDescription( + SajSensorDescription( key="totalPvEnergy", name="totalPvEnergy", icon="mdi:solar-panel-large", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda o: o.total_pv_energy_kwh, ), - SensorEntityDescription( + SajSensorDescription( key="totalLoadEnergy", name="totalLoadEnergy", - icon="mdi:solar-panel-large", + icon="mdi:home-lightning-bolt-outline", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda o: o.total_load_energy_kwh, + ), + SajSensorDescription( + key="totalConsumpElec", + name="totalConsumpElec", + icon="mdi:home-lightning-bolt-outline", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + value_fn=lambda o: o.total_load_energy_kwh, + ), + SajSensorDescription( + key="totalBuyElec", + name="totalBuyElec", + icon="mdi:transmission-tower-import", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda o: o.total_grid_import_kwh, ), - SensorEntityDescription( + SajSensorDescription( key="totalBuyEnergy", name="totalBuyEnergy", - icon="mdi:solar-panel-large", + icon="mdi:transmission-tower-import", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda o: o.total_grid_import_kwh, + ), + SajSensorDescription( + key="totalSellElec", + name="totalSellElec", + icon="mdi:transmission-tower-export", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda o: o.total_grid_export_kwh, ), - SensorEntityDescription( + SajSensorDescription( key="totalSellEnergy", name="totalSellEnergy", - icon="mdi:solar-panel-large", + icon="mdi:transmission-tower-export", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda o: o.total_grid_export_kwh, ), - # h1 - SensorEntityDescription( - key="batCapcity", - name="batCapcity", + # ── Monthly / yearly (not exposed by Elekeeper — kept for compat) ───────── + SajSensorDescription( + key="monthElectricity", + name="monthElectricity", icon="mdi:solar-panel-large", - native_unit_of_measurement="A⋅h", - ), - SensorEntityDescription( - key="isStorageAlarm", - name="isStorageAlarm", - icon="mdi:alarm", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + value_fn=lambda o: None, ), - SensorEntityDescription( - key="batCurr", - name="batCurr", + SajSensorDescription( + key="yearElectricity", + name="yearElectricity", icon="mdi:solar-panel-large", - native_unit_of_measurement="A⋅h", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + value_fn=lambda o: None, ), - SensorEntityDescription( - key="batEnergyPercent", - name="batEnergyPercent", - icon="mdi:solar-panel-large", - native_unit_of_measurement=PERCENTAGE, + # ── Plant metadata ──────────────────────────────────────────────────────── + SajSensorDescription( + key="runningState", + name="runningState", + icon="mdi:state-machine", + value_fn=lambda o: o.mode, ), - SensorEntityDescription( - key="batteryDirection", - name="batteryDirection", - icon="mdi:solar-panel-large", + SajSensorDescription( + key="devOnlineNum", + name="devOnlineNum", + icon="mdi:devices", + value_fn=lambda o: len(o.devices) if o.devices else None, ), - SensorEntityDescription( - key="batteryPower", - name="batteryPower", - icon="mdi:solar-panel-large", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, + SajSensorDescription( + key="lastUploadTime", + name="lastUploadTime", + icon="mdi:timer-sand", + value_fn=lambda o: o.updated_at, ), - SensorEntityDescription( - key="gridPower", - name="gridPower", - icon="mdi:solar-panel-large", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, + SajSensorDescription( + key="plantuid", + name="plantuid", + icon="mdi:api", + value_fn=lambda o: o.uid, ), - SensorEntityDescription( - key="gridDirection", name="gridDirection", icon="mdi:solar-panel-large" + SajSensorDescription( + key="plantname", + name="plantname", + icon="mdi:solar-power-variant-outline", + value_fn=lambda o: o.name, ), - SensorEntityDescription( - key="h1Online", - name="h1Online", - icon="mdi:solar-panel-large", + SajSensorDescription( + key="isOnline", + name="isOnline", + icon="mdi:connection", + value_fn=lambda o: o.mode is not None, ), - SensorEntityDescription( - key="outPower", - name="outPower", - icon="mdi:solar-panel-large", + SajSensorDescription( + key="status", + name="status", + icon="mdi:list-status", + value_fn=lambda o: o.mode, + ), + # ── Fields not available in Elekeeper — kept as None for compat ─────────── + SajSensorDescription( + key="todayGridIncome", + name="todayGridIncome", + icon="mdi:currency-eur", + value_fn=lambda o: None, + ), + SajSensorDescription( + key="income", + name="income", + icon="mdi:currency-eur", + value_fn=lambda o: None, + ), + SajSensorDescription( + key="totalPlantTreeNum", + name="totalPlantTreeNum", + icon="mdi:tree", + value_fn=lambda o: None, + ), + SajSensorDescription( + key="totalReduceCo2", + name="totalReduceCo2", + icon="mdi:molecule-co2", + value_fn=lambda o: None, + ), + SajSensorDescription( + key="plantTreeNum", + name="plantTreeNum", + icon="mdi:tree", + value_fn=lambda o: None, + ), + SajSensorDescription( + key="reduceCo2", + name="reduceCo2", + icon="mdi:molecule-co2", + value_fn=lambda o: None, + ), + SajSensorDescription( + key="currency", + name="currency", + icon="mdi:currency-eur", + value_fn=lambda o: None, + ), + SajSensorDescription( + key="address", + name="address", + icon="mdi:map-marker", + value_fn=lambda o: None, + ), + SajSensorDescription( + key="peakPower", + name="peakPower", + icon="mdi:solar-panel", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda o: None, ), - SensorEntityDescription( - key="outPutDirection", - name="outPutDirection", - icon="mdi:solar-panel-large", + SajSensorDescription( + key="systemPower", + name="systemPower", + icon="mdi:solar-panel", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + value_fn=lambda o: None, ), - SensorEntityDescription( - key="pvPower", - name="pvPower", - icon="mdi:solar-panel-large", + SajSensorDescription( + key="buyRate", + name="buyRate", + icon="mdi:transmission-tower-import", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda o: None, ), - SensorEntityDescription( - key="solarPower", - name="solarPower", - icon="mdi:solar-panel-large", + SajSensorDescription( + key="sellRate", + name="sellRate", + icon="mdi:transmission-tower-export", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda o: None, ), - SensorEntityDescription( - key="chargeElec", - name="chargeElec", + SajSensorDescription( + key="selfUseRate", + name="selfUseRate", + icon="mdi:home-lightning-bolt-outline", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda o: None, + ), + SajSensorDescription( + key="selfConsumedRate1", + name="selfConsumedRate1", + icon="mdi:home-lightning-bolt-outline", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda o: None, + ), + SajSensorDescription( + key="selfConsumedRate2", + name="selfConsumedRate2", + icon="mdi:home-lightning-bolt-outline", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda o: None, + ), + SajSensorDescription( + key="selfConsumedEnergy1", + name="selfConsumedEnergy1", icon="mdi:solar-panel-large", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda o: None, ), - SensorEntityDescription( - key="dischargeElec", - name="dischargeElec", + SajSensorDescription( + key="selfConsumedEnergy2", + name="selfConsumedEnergy2", icon="mdi:solar-panel-large", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda o: None, ), - SensorEntityDescription( - key="powerFlow", - name="Power Flow", - icon="mdi:transmission-tower", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.WATT, -) ) diff --git a/custom_components/saj_esolar/coordinator.py b/custom_components/saj_esolar/coordinator.py index 67f435d..d065e70 100644 --- a/custom_components/saj_esolar/coordinator.py +++ b/custom_components/saj_esolar/coordinator.py @@ -1,5 +1,6 @@ """Coordinator for eSolar integration.""" +from __future__ import annotations import asyncio from datetime import timedelta import logging @@ -7,17 +8,18 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from elekeeper import PlantOverview from .api import ApiAuthError, ApiError, EsolarApiClient _LOGGER = logging.getLogger(__name__) -class EsolarDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching eSolar data from the API at regular intervals.""" +class EsolarDataUpdateCoordinator(DataUpdateCoordinator[PlantOverview]): + """Fetch PlantOverview from the Elekeeper API at regular intervals.""" def __init__(self, hass: HomeAssistant, api_client: EsolarApiClient) -> None: - """Initialize the esolar coordinator.""" + """Initialize the coordinator.""" super().__init__( hass, _LOGGER, @@ -27,20 +29,16 @@ def __init__(self, hass: HomeAssistant, api_client: EsolarApiClient) -> None: ) self.api_client = api_client - async def _async_update_data(self): - """Fetch data from API endpoint.""" + async def _async_update_data(self) -> PlantOverview: + """Fetch data from the Elekeeper API.""" _LOGGER.debug("Coordinator async_update_data called") try: - # Note: asyncio.TimeoutError and aiohttp.ClientError are already - # handled by the data update coordinator. - async with asyncio.timeout(10): + async with asyncio.timeout(60): return await self.api_client.fetch_data() except ApiAuthError as err: raise ConfigEntryAuthFailed from err except ApiError as err: - raise UpdateFailed("Error communicating with API") from err + raise UpdateFailed(f"Error communicating with API: {err}") from err except Exception as err: - _LOGGER.exception("Unexpected Exception") - raise UpdateFailed("Unknown Error. Please see logs") from err - - + _LOGGER.exception("Unexpected exception in coordinator") + raise UpdateFailed(f"Unknown error: {err}") from err diff --git a/custom_components/saj_esolar/manifest.json b/custom_components/saj_esolar/manifest.json index 49f3b9e..5895c9d 100644 --- a/custom_components/saj_esolar/manifest.json +++ b/custom_components/saj_esolar/manifest.json @@ -1,12 +1,12 @@ { "domain": "saj_esolar", - "name": "SAJ esolar Portal", - "version": "1.6.1", - "requirements": [], + "name": "SAJ eSolar (Elekeeper)", + "version": "2.0.0", + "requirements": ["pysaj-elekeeper>=0.0.10"], "dependencies": [], - "issue_tracker": "https://github.com/santiagozky/SAJeSolar/issues", - "documentation": "https://github.com/santiagozky/SAJeSolar/", - "codeowners": ["@santiagozky"], + "issue_tracker": "https://github.com/jaesbit/SAJeSolar/issues", + "documentation": "https://github.com/jaesbit/SAJeSolar/", + "codeowners": ["@jaesbit"], "homeassistant": "2024.6.0", "iot_class": "cloud_polling", "integration_type": "hub", diff --git a/custom_components/saj_esolar/sensor.py b/custom_components/saj_esolar/sensor.py index 8e8dee7..10305df 100644 --- a/custom_components/saj_esolar/sensor.py +++ b/custom_components/saj_esolar/sensor.py @@ -1,12 +1,14 @@ -"""Sensor class for the esolar entities. +"""Sensor class for the SAJ eSolar entities — powered by pysaj-elekeeper.""" -This Sensor will read the private api of the eSolar portal at https://greenheiss-portal.saj-electric.com -""" +from __future__ import annotations from collections.abc import Callable from datetime import date, datetime import logging -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final + +if TYPE_CHECKING: + from elekeeper import PlantOverview import voluptuous as vol @@ -17,10 +19,10 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity - from .const import ( CONF_LEGACY_VERIFY_SSL, CONF_PASSWORD, + CONF_PLANT_UID, CONF_PROVIDER_DOMAIN, CONF_PROVIDER_PATH, CONF_PROVIDER_USE_SSL, @@ -37,36 +39,23 @@ _LOGGER = logging.getLogger(__name__) -DEVICE_TYPES = { - "Inverter": 0, - "Meter": 1, # TODO: Pending to confirm - "Battery": 2, - 0: "Inverter", - 1: "Meter", # TODO: Pending to confirm - 2: "Battery", -} - SENSOR_PREFIX = "esolar " # do not change -def _toPercentage(value: str) -> str: - return float(value.strip("%")) - - # Migration from old saj_esolar yaml PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( { vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_RESOURCES, default=[*SAJ_SENSORS, *H1_SENSORS]): vol.All( # type: ignore + vol.Required(CONF_RESOURCES, default=[*SAJ_SENSORS, *H1_SENSORS]): vol.All( cv.ensure_list, - [vol.In([*SAJ_SENSORS, *H1_SENSORS])], # type: ignore + [vol.In([*SAJ_SENSORS, *H1_SENSORS])], ), - vol.Optional(CONF_SENSORS, default="None"): cv.string, # type: ignore - vol.Optional(CONF_PLANT_ID, default=0): cv.positive_int, # type: ignore - vol.Optional(CONF_PROVIDER_DOMAIN, default="fop.saj-electric.com"): cv.string, - vol.Optional(CONF_PROVIDER_PATH, default="saj"): cv.string, - vol.Optional(CONF_PROVIDER_USE_SSL, default="True"): cv.boolean, + vol.Optional(CONF_SENSORS, default="None"): cv.string, + vol.Optional(CONF_PLANT_ID, default=0): cv.positive_int, + vol.Optional(CONF_PROVIDER_DOMAIN, default="eop.saj-electric.com"): cv.string, + vol.Optional(CONF_PROVIDER_PATH, default="dev-api"): cv.string, + vol.Optional(CONF_PROVIDER_USE_SSL, default=True): cv.boolean, vol.Optional(CONF_LEGACY_VERIFY_SSL, default=True): cv.boolean, } ) @@ -78,8 +67,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= "YAML configuration for 'saj_esolar' is deprecated. " "Please remove it from configuration.yaml after migration" ) - _LOGGER.debug("Imported configuration: %s", config) - # Forward the YAML data into the new config flow hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, @@ -93,351 +80,48 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable ): """Set up the eSolar sensors from a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - config = entry.data - entities = [] - if config[CONF_SENSORS] == "h1": - sensorlist = H1_SENSORS - else: - sensorlist = SAJ_SENSORS + coordinator: EsolarDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - for description in SENSOR_TYPES: - if description.key in sensorlist: - sensor = SAJeSolarMeterSensor( - coordinator, - description, - config.get(CONF_SENSORS), - config.get(CONF_PLANT_ID), - ) - entities.append(sensor) + entities = [ + SAJeSolarMeterSensor(coordinator, description) + for description in SENSOR_TYPES + ] async_add_entities(entities, True) return True -class SAJeSolarMeterSensor(CoordinatorEntity, SensorEntity): - """Collecting data and return sensor entity.""" +class SAJeSolarMeterSensor(CoordinatorEntity[EsolarDataUpdateCoordinator], SensorEntity): + """A single SAJ eSolar sensor reading from a PlantOverview.""" def __init__( self, coordinator: EsolarDataUpdateCoordinator, description: SensorEntityDescription, - sensors: str, - plant_id: int, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) _LOGGER.debug("Initializing esolar sensor: %s", description.key) self.entity_description = description - self._state = None - self.sensors = sensors - self.plant_id = plant_id - self._type = self.entity_description.key - self._attr_icon = self.entity_description.icon - self._attr_name = f"{SENSOR_PREFIX}{self.entity_description.name}" - self._attr_state_class = self.entity_description.state_class - self._attr_native_unit_of_measurement = ( - self.entity_description.native_unit_of_measurement - ) - self._attr_device_class = self.entity_description.device_class - self._attr_unique_id = f"{SENSOR_PREFIX}_{self._type}" - - self._discovery = False - self._dev_id = {} + self._attr_name = f"{SENSOR_PREFIX}{description.name}" + self._attr_unique_id = f"{SENSOR_PREFIX}_{description.key}" + self._attr_icon = description.icon + self._attr_state_class = description.state_class + self._attr_native_unit_of_measurement = description.native_unit_of_measurement + self._attr_device_class = description.device_class @property def native_value(self) -> StateType | date | datetime: - """Handle updated data from the coordinator.""" - energy = self.coordinator.data - if energy is None: - _LOGGER.ERROR("Did not found data for eSolar") + """Return the sensor value from the PlantOverview.""" + overview: PlantOverview | None = self.coordinator.data + if overview is None: return None - # find the data in the data from the coordinator based on the type of sensor this is. - self.match_basic_cases(energy) - ## SAJ h1 - # I dont have an H1, so this is untested - if self.sensors == "h1": - self.match_h1_sensors(energy) - - #### Sec module Sensors: - if self.sensors == "saj_sec": - self.match_sec_sensors(energy) - - # -Debug- adding sensor - _LOGGER.debug("Device: %s , State %s", self._type, self._state) - if self._state is None: - _LOGGER.warn("Status of entity could not be found: %s",self._type) - return self._state - def match_basic_cases(self, energy: dict): - """Matches the basic cases common for h1 and sec.""" - match self._type: - case "devOnlineNum": - self._state = _get_value_from_deep( - energy, - ["plantDetail", self._type], - bool, - ) - case "runningState": - self._state = _get_value_from_deep( - energy, - ["plantDetail", self._type], - lambda x: bool(int(x)), - ) - case n if n in ( - "nowPower", - "todayElectricity", - "monthElectricity", - "yearElectricity", - "totalElectricity", - "todayGridIncome", - "income", - "totalBuyElec", - "totalConsumpElec", - "totalSellElec", - "totalPlantTreeNum", - "totalReduceCo2", - ): - self._state = _get_value_from_deep( - energy, - ["plantDetail", self._type], - float, - ) - case "selfUseRate": - self._state = _get_value_from_deep( - energy, - ["plantDetail", self._type], - _toPercentage, - ) - case "lastUploadTime": - self._state = _get_value_from_deep( - energy, - ["plantDetail", self._type], - lambda x: x, # as-is - ) - case n if n in ( - "currency", - "plantuid", - "plantname", - "address", - ): - if self._type in energy["plantList"][self.plant_id]: - value = energy.get("plantList", {})[self.plant_id].get(self._type) - self._state = value if value is not None else None - # there is a typo (systemPower vs systempower). It was present in the original integration. keeping for consistency - case "systemPower": - if "systempower" in energy["plantList"][self.plant_id]: - value = energy.get("plantList", {})[self.plant_id].get( - "systempower" - ) - self._state = value if value is not None else None - case "isOnline": - if "isOnline" in energy["plantList"][self.plant_id]: - value = energy.get("plantList", {})[self.plant_id].get(self._type) - self._state = value.upper() == "Y" if value is not None else "N" - case "peakPower": - if "peakPower" in energy: - if energy["peakPower"] is not None: - self._state = float(energy["peakPower"]) - case "status": - if "status" in energy: - if energy["status"] is not None: - self._state = energy["status"] - - def match_h1_sensors(self, energy: dict): - match self._type: - # float cases - case n if n in ( - "chargeElec", - "dischargeElec", - "buyElec", - "sellElec", - "pvElec", - "selfConsumedEnergy1", - "selfConsumedEnergy2", - "useElec", - ): - self._state = _get_value_from_deep( - energy, - ["viewBean", self._type], - float, - ) - # percentage cases - case n if n in ( - "buyRate", - "selfConsumedRate1", - "selfConsumedRate2", - "sellRate", - ): - self._state = _get_value_from_deep( - energy, - ["viewBean", self._type], - _toPercentage, - ) - # storeDevicePower - # float cases - case n if n in ( - "batCapcity", - "batCurr", - "batEnergyPercent", - "batteryPower", - "gridPower", - "pvPower", - "solarPower", - "outPower", - ): - self._state = _get_value_from_deep( - energy, - ["storeDevicePower", self._type], - float, - ) - case "isStorageAlarm": - self._state = _get_value_from_deep( - energy, - ["storeDevicePower", self._type], - int, - ) - case "batteryDirection": - if "batteryDirection" in energy["storeDevicePower"]: - if energy["storeDevicePower"]["batteryDirection"] is not None: - if energy["storeDevicePower"]["batteryDirection"] == 0: - self._state = "Standby" - elif energy["storeDevicePower"]["batteryDirection"] == 1: - self._state = "Discharging" - elif energy["storeDevicePower"]["batteryDirection"] == -1: - self._state = "Charging" - else: - self._state = f"Unknown: {energy['storeDevicePower']['batteryDirection']}" - - case "gridDirection": - if "gridDirection" in energy["storeDevicePower"]: - if energy["storeDevicePower"]["gridDirection"] is not None: - if energy["storeDevicePower"]["gridDirection"] == 1: - self._state = "Exporting" - elif energy["storeDevicePower"]["gridDirection"] == -1: - self._state = "Importing" - else: - self._state = energy["storeDevicePower"]["gridDirection"] - _LOGGER.error( - "Grid Direction unknown value: %s", - self._state, - ) - - case "h1Online": - if "isOnline" in energy["storeDevicePower"]: - if energy["storeDevicePower"]["isOnline"] is not None: - self._state = bool(int(energy["storeDevicePower"]["isOnline"])) - case "outPutDirection": - if "outPutDirection" in energy["storeDevicePower"]: - if energy["storeDevicePower"]["outPutDirection"] is not None: - if energy["storeDevicePower"]["outPutDirection"] == 1: - self._state = "Exporting" - elif energy["storeDevicePower"]["outPutDirection"] == -1: - self._state = "Importing" - else: - self._state = energy["storeDevicePower"]["outPutDirection"] - _LOGGER.error( - "Value for outPut Direction unknown: %s", - self._state, - ) - case "pvDirection": - if "pvDirection" in energy["storeDevicePower"]: - if energy["storeDevicePower"]["pvDirection"] is not None: - if energy["storeDevicePower"]["pvDirection"] == 1: - self._state = "Exporting" - elif energy["storeDevicePower"]["pvDirection"] == -1: - self._state = "Importing" - else: - self._state = energy["storeDevicePower"]["pvDirection"] - _LOGGER.error( - "Value for pv Direction unknown: %s", - self._state, - ) - - def match_sec_sensors(self, energy: dict): - """Matches data for sec module sensors.""" - # getPlantMeterChartData - viewBeam - match self._type: - # float cases - case n if n in ( - "pvElec", - "useElec", - "buyElec", - "sellElec", - "selfConsumedEnergy1", - "selfConsumedEnergy2", - "reduceCo2", - "plantTreeNum", - ): - self._state = _get_value_from_deep( - energy, - ["getPlantMeterChartData", "viewBean", self._type], - float, - ) - # percentage cases - case n if n in ( - "buyRate", - "sellRate", - "selfConsumedRate1", - "selfConsumedRate2", - ): - self._state = _get_value_from_deep( - energy, - ["getPlantMeterChartData", "viewBean", self._type], - _toPercentage, - ) - case "homeLoadPower": - self._state = _get_value_meter_chart(energy,1) - case "solarLoadPower": - self._state = _get_value_meter_chart(energy,2) - case "exportPower": - self._state = _get_value_meter_chart(energy,3) - case "gridLoadPower": - self._state =_get_value_meter_chart(energy,4) - case "powerFlow": - exportPower= _get_value_meter_chart(energy,3) or 0.0 - gridLoadPower= _get_value_meter_chart(energy,4) or 0.0 - self._state=gridLoadPower-exportPower - # getPlantMeterDetailInfo - case "selfUseRate": - self._state = _get_value_from_deep( - energy, - ["getPlantMeterDetailInfo", "plantDetail", self._type], - _toPercentage, - ) - case n if n in ( - "totalPvEnergy", - "totalLoadEnergy", - "totalBuyEnergy", - "totalSellEnergy", - ): - self._state = _get_value_from_deep( - energy, - ["getPlantMeterDetailInfo", "plantDetail", self._type], - float, - ) - -def _get_value_meter_chart(data: dict, index: int): - data_count_list = data.get("getPlantMeterChartData", {}).get("dataCountList", {} ) - - # Safely extract the value - value = None - if len(data_count_list) > index: - entry = data_count_list[index] - if isinstance(entry, list) and entry: - value = entry[-1] - return float(value) if value is not None else None - -def _get_value_from_deep( - dictionary: dict, keys: list[str], lambda_funct: Callable[[Any], Any] = lambda x: x -) -> Any: - """Access a nested object in dictionary with a list of keys. + desc = self.entity_description + if not hasattr(desc, "value_fn") or desc.value_fn is None: + return None - Receives a lambda to transform the value to the right type. - """ - current = dictionary - for key in keys: - if isinstance(current, dict) and key in current: - current = current[key] - else: + try: + return desc.value_fn(overview) + except Exception: # noqa: BLE001 + _LOGGER.debug("Could not read value for sensor %s", desc.key) return None - return lambda_funct(current)