From 012c88268cb85785c14fe908fcda5ec27bdba87f Mon Sep 17 00:00:00 2001 From: Rik Veenboer Date: Sun, 26 Oct 2025 16:06:43 +0100 Subject: [PATCH] snapshot fixed sems hacs component --- .../custom_components/sems/__init__.py | 279 ++++++++ .../custom_components/sems/config_flow.py | 112 ++++ homeassistant/custom_components/sems/const.py | 25 + .../custom_components/sems/manifest.json | 14 + .../custom_components/sems/sems_api.py | 259 ++++++++ .../custom_components/sems/sensor.py | 628 ++++++++++++++++++ .../custom_components/sems/strings.json | 26 + .../custom_components/sems/switch.py | 93 +++ .../sems/translations/en.json | 26 + .../sems/translations/pt.json | 26 + 10 files changed, 1488 insertions(+) create mode 100644 homeassistant/custom_components/sems/__init__.py create mode 100644 homeassistant/custom_components/sems/config_flow.py create mode 100644 homeassistant/custom_components/sems/const.py create mode 100644 homeassistant/custom_components/sems/manifest.json create mode 100644 homeassistant/custom_components/sems/sems_api.py create mode 100644 homeassistant/custom_components/sems/sensor.py create mode 100644 homeassistant/custom_components/sems/strings.json create mode 100644 homeassistant/custom_components/sems/switch.py create mode 100644 homeassistant/custom_components/sems/translations/en.json create mode 100644 homeassistant/custom_components/sems/translations/pt.json diff --git a/homeassistant/custom_components/sems/__init__.py b/homeassistant/custom_components/sems/__init__.py new file mode 100644 index 0000000..4363647 --- /dev/null +++ b/homeassistant/custom_components/sems/__init__.py @@ -0,0 +1,279 @@ +"""The sems integration.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.core import callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.entity_registry import async_migrate_entries + +from .const import ( + CONF_SCAN_INTERVAL, + CONF_STATION_ID, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + PLATFORMS, +) +from .sems_api import SemsApi + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the sems component.""" + # Ensure our name space for storing objects is a known type. A dict is + # common/preferred as it allows a separate instance of your class for each + # instance that has been created in the UI. + hass.data.setdefault(DOMAIN, {}) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up sems from a config entry.""" + semsApi = SemsApi(hass, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) + coordinator = SemsDataUpdateCoordinator(hass, semsApi, entry) + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +# async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +# """Set up this integration using UI.""" +# if hass.data.get(DOMAIN) is None: +# hass.data.setdefault(DOMAIN, {}) +# _LOGGER.info(STARTUP_MESSAGE) + +# username = entry.data.get(CONF_USERNAME) +# password = entry.data.get(CONF_PASSWORD) + +# conn = Connection(username, password) +# client = MyenergiClient(conn) + +# coordinator = MyenergiDataUpdateCoordinator(hass, client=client, entry=entry) +# await coordinator.async_config_entry_first_refresh() + +# hass.data[DOMAIN][entry.entry_id] = coordinator + +# for platform in PLATFORMS: +# if entry.options.get(platform, True): +# coordinator.platforms.append(platform) +# hass.async_add_job( +# hass.config_entries.async_forward_entry_setup(entry, platform) +# ) + +# entry.add_update_listener(async_reload_entry) +# return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +# async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +# """Handle removal of an entry.""" +# coordinator = hass.data[DOMAIN][entry.entry_id] +# unloaded = all( +# await asyncio.gather( +# *[ +# hass.config_entries.async_forward_entry_unload(entry, platform) +# for platform in PLATFORMS +# if platform in coordinator.platforms +# ] +# ) +# ) +# if unloaded: +# hass.data[DOMAIN].pop(entry.entry_id) + +# return unloaded + + +# async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +# """Reload config entry.""" +# await async_unload_entry(hass, entry) +# await async_setup_entry(hass, entry) + + +class SemsDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the API.""" + + def __init__(self, hass: HomeAssistant, semsApi: SemsApi, entry) -> None: + """Initialize.""" + self.semsApi = semsApi + self.platforms = [] + self.stationId = entry.data[CONF_STATION_ID] + self.hass = hass + + update_interval = timedelta( + seconds=entry.data.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ) + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=update_interval, + ) + + async def _async_update_data(self): + """Fetch data from API endpoint. + + This is the place to pre-process the data to lookup tables + so entities can quickly look up their data. + """ + try: + # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # handled by the data update coordinator. + # async with async_timeout.timeout(10): + result = await self.hass.async_add_executor_job( + self.semsApi.getData, self.stationId + ) + _LOGGER.debug("semsApi.getData result: %s", result) + + # _LOGGER.warning("SEMS - Try get getPowerStationIds") + # powerStationIds = await self.hass.async_add_executor_job( + # self.semsApi.getPowerStationIds + # ) + # _LOGGER.warning( + # "SEMS - getPowerStationIds: Found power station IDs: %s", + # powerStationIds, + # ) + + inverters = result["inverter"] + + # found = [] + # _LOGGER.debug("Found inverters: %s", inverters) + data = {} + if inverters is None: + # something went wrong, probably token could not be fetched + raise UpdateFailed( + "Error communicating with API, probably token could not be fetched, see debug logs" + ) + for inverter in inverters: + name = inverter["invert_full"]["name"] + # powerstation_id = inverter["invert_full"]["powerstation_id"] + sn = inverter["invert_full"]["sn"] + _LOGGER.debug("Found inverter attribute %s %s", name, sn) + data[sn] = inverter["invert_full"] + + hasPowerflow = result["hasPowerflow"] + hasEnergeStatisticsCharts = result["hasEnergeStatisticsCharts"] + + if hasPowerflow: + _LOGGER.debug("Found powerflow data") + if hasEnergeStatisticsCharts: + StatisticsCharts = { + f"Charts_{key}": val + for key, val in result["energeStatisticsCharts"].items() + } + StatisticsTotals = { + f"Totals_{key}": val + for key, val in result["energeStatisticsTotals"].items() + } + powerflow = { + **result["powerflow"], + **StatisticsCharts, + **StatisticsTotals, + } + else: + powerflow = result["powerflow"] + + powerflow["sn"] = result["homKit"]["sn"] + + # Goodwe 'Power Meter' (not HomeKit) doesn't have a sn + # Let's put something in, otherwise we can't see the data. + if powerflow["sn"] is None: + powerflow["sn"] = "GW-HOMEKIT-NO-SERIAL" + + # _LOGGER.debug("homeKit sn: %s", result["homKit"]["sn"]) + # This seems more accurate than the Chart_sum + powerflow["all_time_generation"] = result["kpi"]["total_power"] + + data["homeKit"] = powerflow + + _LOGGER.debug("Resulting data: %s", data) + return data + # except ApiError as err: + except Exception as err: + # logging.exception("Something awful happened!") + raise UpdateFailed(f"Error communicating with API: {err}") from err + + +# # migrate to _power ids for inverter entry +# async def async_migrate_entry(hass, config_entry): +# """Migrate old entry.""" +# _LOGGER.debug( +# "Migrating configuration from version %s.%s", +# config_entry.version, +# config_entry.minor_version, +# ) + +# if config_entry.version < 7: +# # get existing entities for device +# semsApi = SemsApi( +# hass, config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD] +# ) +# coordinator = SemsDataUpdateCoordinator(hass, semsApi, config_entry) +# await coordinator.async_config_entry_first_refresh() + +# _LOGGER.debug(f"found inverter {coordinator.data}") + +# for idx, ent in enumerate(coordinator.data): +# _LOGGER.debug("Found inverter: %s", ent) + +# old_unique_id = f"{ent}" +# new_unique_id = f"{old_unique_id}-XXX" +# _LOGGER.debug( +# "Old unique id: %s; new unique id: %s", old_unique_id, new_unique_id +# ) + +# @callback +# def update_unique_id(entity_entry): +# """Update unique ID of entity entry.""" +# return { +# "new_unique_id": entity_entry.unique_id.replace( +# old_unique_id, new_unique_id +# ) +# } + +# if old_unique_id != new_unique_id: +# await async_migrate_entries( +# hass, config_entry.entry_id, update_unique_id +# ) + +# hass.config_entries.async_update_entry( +# config_entry, unique_id=new_unique_id +# ) +# # version = 7 +# _LOGGER.info( +# "Migrated unique id from %s to %s", old_unique_id, new_unique_id +# ) + +# _LOGGER.info( +# "Migration from version %s.%s successful", +# config_entry.version, +# config_entry.minor_version, +# ) + +# return True diff --git a/homeassistant/custom_components/sems/config_flow.py b/homeassistant/custom_components/sems/config_flow.py new file mode 100644 index 0000000..66e93ea --- /dev/null +++ b/homeassistant/custom_components/sems/config_flow.py @@ -0,0 +1,112 @@ +"""Config flow for sems integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN, SEMS_CONFIG_SCHEMA, CONF_STATION_ID +from .sems_api import SemsApi + +from homeassistant.const import ( + CONF_PASSWORD, + CONF_USERNAME, +) + +_LOGGER = logging.getLogger(__name__) + + +def mask_password(user_input: dict[str, Any]) -> dict[str, Any]: + """Mask password in user input for logging.""" + masked_input = user_input.copy() + if CONF_PASSWORD in masked_input: + masked_input[CONF_PASSWORD] = "" + return masked_input + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + + _LOGGER.debug( + "SEMS - Start validation config flow user input, with input data: %s", + mask_password(data), + ) + api = SemsApi(hass, data[CONF_USERNAME], data[CONF_PASSWORD]) + + authenticated = await hass.async_add_executor_job(api.test_authentication) + # If you cannot connect: + # throw CannotConnect + # If the authentication is wrong: + # InvalidAuth + if not authenticated: + raise InvalidAuth + + # If optional station ID is not provided, query the SEMS API for the first found + if CONF_STATION_ID not in data: + _LOGGER.debug( + "SEMS - No station ID provided, query SEMS API, using first found" + ) + powerStationId = await hass.async_add_executor_job(api.getPowerStationIds) + _LOGGER.debug("SEMS - Found power station IDs: %s", powerStationId) + + data[CONF_STATION_ID] = powerStationId + + # Return info that you want to store in the config entry. + _LOGGER.debug("SEMS - validate_input Returning data: %s", mask_password(data)) + return data + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for sems.""" + + _LOGGER.debug("SEMS - new config flow") + + VERSION = 1 + # CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> dict[str, Any]: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form(step_id="user", data_schema=SEMS_CONFIG_SCHEMA) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + _LOGGER.debug( + "Creating config entry for %s with data: %s", + info[CONF_STATION_ID], + mask_password(info), + ) + return self.async_create_entry( + title=f"Inverter {info[CONF_STATION_ID]}", data=info + ) + + return self.async_show_form( + step_id="user", data_schema=SEMS_CONFIG_SCHEMA, errors=errors + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/custom_components/sems/const.py b/homeassistant/custom_components/sems/const.py new file mode 100644 index 0000000..43b4daa --- /dev/null +++ b/homeassistant/custom_components/sems/const.py @@ -0,0 +1,25 @@ +"""Constants for the SEMS integration.""" + +import voluptuous as vol + +from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME + +DOMAIN = "sems" + +PLATFORMS = ["sensor", "switch"] + +CONF_STATION_ID = "powerstation_id" + +DEFAULT_SCAN_INTERVAL = 60 # timedelta(seconds=60) + +# Validation of the user's configuration +SEMS_CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_STATION_ID): str, + vol.Optional( + CONF_SCAN_INTERVAL, description={"suggested_value": 60} + ): int, # , default=DEFAULT_SCAN_INTERVAL + } +) diff --git a/homeassistant/custom_components/sems/manifest.json b/homeassistant/custom_components/sems/manifest.json new file mode 100644 index 0000000..28f6431 --- /dev/null +++ b/homeassistant/custom_components/sems/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "sems", + "name": "GoodWe SEMS API", + "codeowners": [ + "@TimSoethout" + ], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/TimSoethout/goodwe-sems-home-assistant", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/TimSoethout/goodwe-sems-home-assistant/issues", + "requirements": [], + "version": "7.4.1" +} \ No newline at end of file diff --git a/homeassistant/custom_components/sems/sems_api.py b/homeassistant/custom_components/sems/sems_api.py new file mode 100644 index 0000000..b86b9c8 --- /dev/null +++ b/homeassistant/custom_components/sems/sems_api.py @@ -0,0 +1,259 @@ +import json +import logging + +import requests + +from homeassistant import exceptions + +_LOGGER = logging.getLogger(__name__) + +_LoginURL = "https://www.semsportal.com/api/v2/Common/CrossLogin" +_GetPowerStationIdByOwnerURLPart = "/PowerStation/GetPowerStationIdByOwner" +_PowerStationURLPart = "/v3/PowerStation/GetMonitorDetailByPowerstationId" +# _PowerControlURL = ( +# "https://www.semsportal.com/api/PowerStation/SaveRemoteControlInverter" +# ) +_PowerControlURLPart = "/PowerStation/SaveRemoteControlInverter" +_RequestTimeout = 30 # seconds + +_DefaultHeaders = { + "Content-Type": "application/json", + "Accept": "application/json", + "token": '{"version":"","client":"ios","language":"en"}', +} + + +class SemsApi: + """Interface to the SEMS API.""" + + def __init__(self, hass, username, password): + """Init dummy hub.""" + self._hass = hass + self._username = username + self._password = password + self._token = None + + def test_authentication(self) -> bool: + """Test if we can authenticate with the host.""" + try: + self._token = self.getLoginToken(self._username, self._password) + return self._token is not None + except Exception as exception: + _LOGGER.exception("SEMS Authentication exception: %s", exception) + return False + + def getLoginToken(self, userName, password): + """Get the login token for the SEMS API.""" + try: + # Get our Authentication Token from SEMS Portal API + _LOGGER.debug("SEMS - Getting API token") + + # Prepare Login Data to retrieve Authentication Token + # Dict won't work here somehow, so this magic string creation must do. + login_data = '{"account":"' + userName + '","pwd":"' + password + '"}' + # login_data = {"account": userName, "pwd": password} + + # Make POST request to retrieve Authentication Token from SEMS API + login_response = requests.post( + _LoginURL, + headers=_DefaultHeaders, + data=login_data, + timeout=_RequestTimeout, + ) + _LOGGER.debug("Login Response: %s", login_response) + # _LOGGER.debug("Login Response text: %s", login_response.text) + + login_response.raise_for_status() + + # Process response as JSON + jsonResponse = login_response.json() # json.loads(login_response.text) + # _LOGGER.debug("Login JSON response %s", jsonResponse) + # Get all the details from our response, needed to make the next POST request (the one that really fetches the data) + # Also store the api url send with the authentication request for later use + tokenDict = jsonResponse["data"] + tokenDict["api"] = jsonResponse["api"] + + _LOGGER.debug("SEMS - API Token received: %s", tokenDict) + return tokenDict + except Exception as exception: + _LOGGER.error("Unable to fetch login token from SEMS API. %s", exception) + return None + + def getPowerStationIds(self, renewToken=False, maxTokenRetries=2) -> str: + """Get the power station ids from the SEMS API.""" + try: + # Get the status of our SEMS Power Station + _LOGGER.debug( + "SEMS - getPowerStationIds Making Power Station Status API Call" + ) + if maxTokenRetries <= 0: + _LOGGER.info( + "SEMS - Maximum token fetch tries reached, aborting for now" + ) + raise OutOfRetries + if self._token is None or renewToken: + _LOGGER.debug( + "API token not set (%s) or new token requested (%s), fetching", + self._token, + renewToken, + ) + self._token = self.getLoginToken(self._username, self._password) + + # Prepare Power Station status Headers + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "token": json.dumps(self._token), + } + + getPowerStationIdUrl = self._token["api"] + _GetPowerStationIdByOwnerURLPart + _LOGGER.debug( + "Querying SEMS API (%s) for power station ids by owner", + getPowerStationIdUrl, + ) + + response = requests.post( + getPowerStationIdUrl, + headers=headers, + # data=data, + timeout=_RequestTimeout, + ) + jsonResponse = response.json() + _LOGGER.debug("Response: %s", jsonResponse) + # try again and renew token is unsuccessful + if ( + jsonResponse["msg"] not in ["Successful", "操作成功"] + or jsonResponse["data"] is None + ): + _LOGGER.debug( + "GetPowerStationIdByOwner Query not successful (%s), retrying with new token, %s retries remaining", + jsonResponse["msg"], + maxTokenRetries, + ) + return self.getPowerStationIds( + True, maxTokenRetries=maxTokenRetries - 1 + ) + + return jsonResponse["data"] + except Exception as exception: + _LOGGER.error( + "Unable to fetch power station Ids from SEMS Api. %s", exception + ) + + def getData(self, powerStationId, renewToken=False, maxTokenRetries=2) -> dict: + """Get the latest data from the SEMS API and updates the state.""" + try: + # Get the status of our SEMS Power Station + _LOGGER.debug("SEMS - Making Power Station Status API Call") + if maxTokenRetries <= 0: + _LOGGER.info( + "SEMS - Maximum token fetch tries reached, aborting for now" + ) + raise OutOfRetries + if self._token is None or renewToken: + _LOGGER.debug( + "API token not set (%s) or new token requested (%s), fetching", + self._token, + renewToken, + ) + self._token = self.getLoginToken(self._username, self._password) + + # Prepare Power Station status Headers + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "token": json.dumps(self._token), + } + + powerStationURL = self._token["api"] + _PowerStationURLPart + _LOGGER.debug( + "Querying SEMS API (%s) for power station id: %s", + powerStationURL, + powerStationId, + ) + + data = '{"powerStationId":"' + powerStationId + '"}' + + response = requests.post( + powerStationURL, headers=headers, data=data, timeout=_RequestTimeout + ) + jsonResponse = response.json() + _LOGGER.debug("Response: %s", jsonResponse) + # try again and renew token is unsuccessful + if ( + jsonResponse["msg"] not in ["success", "操作成功"] + or jsonResponse["data"] is None + ): + _LOGGER.debug( + "Query not successful (%s), retrying with new token, %s retries remaining", + jsonResponse["msg"], + maxTokenRetries, + ) + return self.getData( + powerStationId, True, maxTokenRetries=maxTokenRetries - 1 + ) + + return jsonResponse["data"] + except Exception as exception: + _LOGGER.error("Unable to fetch data from SEMS. %s", exception) + return {} + + def change_status(self, inverterSn, status, renewToken=False, maxTokenRetries=2): + """Schedule the downtime of the station""" + try: + # Get the status of our SEMS Power Station + _LOGGER.debug("SEMS - Making Power Station Status API Call") + if maxTokenRetries <= 0: + _LOGGER.info( + "SEMS - Maximum token fetch tries reached, aborting for now" + ) + raise OutOfRetries + if self._token is None or renewToken: + _LOGGER.debug( + "API token not set (%s) or new token requested (%s), fetching", + self._token, + renewToken, + ) + self._token = self.getLoginToken(self._username, self._password) + + # Prepare Power Station status Headers + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "token": json.dumps(self._token), + } + + powerControlURL = self._token["api"] + _PowerControlURLPart + # powerControlURL = _PowerControlURL + _LOGGER.debug( + "Sending power control command (%s) for power station id: %s", + powerControlURL, + inverterSn, + ) + + data = { + "InverterSN": inverterSn, + "InverterStatusSettingMark": "1", + "InverterStatus": str(status), + } + + response = requests.post( + powerControlURL, headers=headers, json=data, timeout=_RequestTimeout + ) + if response.status_code != 200: + # try again and renew token is unsuccessful + _LOGGER.warning( + "Power control command not successful, retrying with new token, %s retries remaining", + maxTokenRetries, + ) + return self.change_status( + inverterSn, status, True, maxTokenRetries=maxTokenRetries - 1 + ) + + return + except Exception as exception: + _LOGGER.error("Unable to execute Power control command. %s", exception) + + +class OutOfRetries(exceptions.HomeAssistantError): + """Error to indicate too many error attempts.""" diff --git a/homeassistant/custom_components/sems/sensor.py b/homeassistant/custom_components/sems/sensor.py new file mode 100644 index 0000000..6155f5a --- /dev/null +++ b/homeassistant/custom_components/sems/sensor.py @@ -0,0 +1,628 @@ +"""Support for power production statistics from GoodWe SEMS API. + +For more details about this platform, please refer to the documentation at +https://github.com/TimSoethout/goodwe-sems-home-assistant +""" + +from datetime import timedelta +import logging +# from typing import Coroutine + +import homeassistant +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import CONF_SCAN_INTERVAL, UnitOfEnergy, UnitOfPower, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import CONF_STATION_ID, DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add sensors for passed config_entry in HA.""" + # _LOGGER.debug("hass.data[DOMAIN] %s", hass.data[DOMAIN]) + # semsApi = hass.data[DOMAIN][config_entry.entry_id] + # stationId = config_entry.data[CONF_STATION_ID] + + # _LOGGER.debug("config_entry %s", config_entry.data) + # update_interval = timedelta( + # seconds=config_entry.data.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + # ) + + # async def async_update_data(): + # """Fetch data from API endpoint. + + # This is the place to pre-process the data to lookup tables + # so entities can quickly look up their data. + # """ + # try: + # # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # # handled by the data update coordinator. + # # async with async_timeout.timeout(10): + # result = await hass.async_add_executor_job(semsApi.getData, stationId) + # _LOGGER.debug("Resulting result: %s", result) + + # inverters = result["inverter"] + + # # found = [] + # # _LOGGER.debug("Found inverters: %s", inverters) + # data = {} + # if inverters is None: + # # something went wrong, probably token could not be fetched + # raise UpdateFailed( + # "Error communicating with API, probably token could not be fetched, see debug logs" + # ) + # for inverter in inverters: + # name = inverter["invert_full"]["name"] + # # powerstation_id = inverter["invert_full"]["powerstation_id"] + # sn = inverter["invert_full"]["sn"] + # _LOGGER.debug("Found inverter attribute %s %s", name, sn) + # data[sn] = inverter["invert_full"] + + # hasPowerflow = result["hasPowerflow"] + # hasEnergeStatisticsCharts = result["hasEnergeStatisticsCharts"] + + # if hasPowerflow: + # if hasEnergeStatisticsCharts: + # StatisticsCharts = { + # f"Charts_{key}": val + # for key, val in result["energeStatisticsCharts"].items() + # } + # StatisticsTotals = { + # f"Totals_{key}": val + # for key, val in result["energeStatisticsTotals"].items() + # } + # powerflow = { + # **result["powerflow"], + # **StatisticsCharts, + # **StatisticsTotals, + # } + # else: + # powerflow = result["powerflow"] + + # powerflow["sn"] = result["homKit"]["sn"] + + # # Goodwe 'Power Meter' (not HomeKit) doesn't have a sn + # # Let's put something in, otherwise we can't see the data. + # if powerflow["sn"] is None: + # powerflow["sn"] = "GW-HOMEKIT-NO-SERIAL" + + # #_LOGGER.debug("homeKit sn: %s", result["homKit"]["sn"]) + # # This seems more accurate than the Chart_sum + # powerflow["all_time_generation"] = result["kpi"]["total_power"] + + # data["homeKit"] = powerflow + + # # _LOGGER.debug("Resulting data: %s", data) + # return data + # # except ApiError as err: + # except Exception as err: + # # logging.exception("Something awful happened!") + # raise UpdateFailed(f"Error communicating with API: {err}") from err + + # coordinator = DataUpdateCoordinator( + # hass, + # _LOGGER, + # # Name of the data. For logging purposes. + # name="SEMS API", + # update_method=async_update_data, + # # Polling interval. Will only be polled if there are subscribers. + # update_interval=update_interval, + # ) + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + # + # Fetch initial data so we have data when entities subscribe + # + # If the refresh fails, async_config_entry_first_refresh will + # raise ConfigEntryNotReady and setup will try again later + # + # If you do not want to retry setup on failure, use + # coordinator.async_refresh() instead + # + await coordinator.async_config_entry_first_refresh() + + # _LOGGER.debug("Initial coordinator data: %s", coordinator.data) + + for _idx, ent in enumerate(coordinator.data): + _migrate_to_new_unique_id(hass, ent) + + async_add_entities( + SemsSensor(coordinator, ent) + for idx, ent in enumerate(coordinator.data) + # Don't make SemsSensor for homeKit, since it is not an inverter; unsure how this could work before... + if ent != "homeKit" + ) + async_add_entities( + SemsStatisticsSensor(coordinator, ent) + for idx, ent in enumerate(coordinator.data) + # Don't make SemsStatisticsSensor for homeKit, since it is not an inverter; unsure how this could work before... + if ent != "homeKit" + ) + async_add_entities( + SemsPowerflowSensor(coordinator, ent) + for idx, ent in enumerate(coordinator.data) + if ent == "homeKit" + ) + async_add_entities( + SemsTotalImportSensor(coordinator, ent) + for idx, ent in enumerate(coordinator.data) + if ent == "homeKit" + ) + async_add_entities( + SemsTotalExportSensor(coordinator, ent) + for idx, ent in enumerate(coordinator.data) + if ent == "homeKit" + ) + + +# Migrate old power sensor unique ids to new unique ids (with `-power`) +def _migrate_to_new_unique_id(hass: HomeAssistant, sn: str) -> None: + """Migrate old unique ids to new unique ids.""" + ent_reg = entity_registry.async_get(hass) + + old_unique_id = sn + new_unique_id = f"{old_unique_id}-power" + _LOGGER.debug("Old unique id: %s; new unique id: %s", old_unique_id, new_unique_id) + entity_id = ent_reg.async_get_entity_id(Platform.SENSOR, DOMAIN, old_unique_id) + _LOGGER.debug("Entity ID: %s", entity_id) + if entity_id is not None: + try: + ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) + except ValueError: + _LOGGER.warning( + "Skip migration of id [%s] to [%s] because it already exists", + old_unique_id, + new_unique_id, + ) + else: + _LOGGER.info( + "Migrating unique_id from [%s] to [%s]", + old_unique_id, + new_unique_id, + ) + + +class SemsSensor(CoordinatorEntity, SensorEntity): + """SemsSensor using CoordinatorEntity. + + The CoordinatorEntity class provides: + should_poll + async_update + async_added_to_hass + available + """ + + # Sensor has name determined by device class (e.g. Inverter 123456 Power) + _attr_has_entity_name = True + + def __init__(self, coordinator, sn) -> None: + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + self.coordinator = coordinator + self.sn = sn + self._attr_unique_id = f"{self.coordinator.data[self.sn]['sn']}-power" + _LOGGER.debug("Creating SemsSensor with id %s", self.sn) + self._attr_unique_id = f"{self.coordinator.data[self.sn]['sn']}-power" + _LOGGER.debug( + "Creating SemsSensor with id %s and data %s", + self.sn, + self.coordinator.data[self.sn], + ) + + _attr_device_class = SensorDeviceClass.POWER + _attr_native_unit_of_measurement = UnitOfPower.WATT + _attr_should_poll = False + + @property + def native_value(self): + """Return the value reported by the sensor.""" + data = self.coordinator.data[self.sn] + return data["pac"] if data["status"] == 1 else 0 + + def _statusText(self, status) -> str: + labels = {-1: "Offline", 0: "Waiting", 1: "Normal", 2: "Fault"} + return labels.get(status, "Unknown") + + # For backwards compatibility + @property + def extra_state_attributes(self): + """Return the state attributes of the monitored installation.""" + data = self.coordinator.data[self.sn] + attributes = {k: v for k, v in data.items() if k is not None and v is not None} + attributes["statusText"] = self._statusText(data["status"]) + return attributes + + @property + def is_on(self) -> bool: + """Return entity status.""" + return self.coordinator.data[self.sn]["status"] == 1 + + @property + def available(self): + """Return if entity is available.""" + return self.coordinator.last_update_success + + @property + def device_info(self) -> DeviceInfo: + """Return device information.""" + return DeviceInfo( + identifiers={(DOMAIN, self.sn)}, + name=f"Inverter {self.coordinator.data[self.sn]['name']}", + manufacturer="GoodWe", + model=self.extra_state_attributes.get("model_type", "unknown"), + sw_version=self.extra_state_attributes.get("firmwareversion", "unknown"), + configuration_url=f"https://semsportal.com/PowerStation/PowerStatusSnMin/{self.coordinator.data[self.sn]['powerstation_id']}", + ) + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update the entity. + + Only used by the generic entity update service. + """ + await self.coordinator.async_request_refresh() + + +class SemsStatisticsSensor(CoordinatorEntity, SensorEntity): + """Sensor in kWh to enable HA statistics, in the end usable in the power component.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator, sn) -> None: + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + self.coordinator = coordinator + self.sn = sn + _LOGGER.debug("Creating SemsStatisticsSensor with id %s", self.sn) + _LOGGER.debug( + "Creating SemsSensor with id %s and data %s", + self.sn, + self.coordinator.data[self.sn], + ) + + @property + def device_class(self): + return SensorDeviceClass.ENERGY + + @property + def unit_of_measurement(self): + return UnitOfEnergy.KILO_WATT_HOUR + + # @property + # def name(self) -> str: + # """Return the name of the sensor.""" + # return f"Inverter {self.coordinator.data[self.sn]['name']} Energy" + + @property + def unique_id(self) -> str: + return f"{self.coordinator.data[self.sn]['sn']}-energy" + + @property + def state(self): + """Return the state of the device.""" + _LOGGER.debug( + "SemsStatisticsSensor state, coordinator data: %s", self.coordinator.data + ) + _LOGGER.debug("SemsStatisticsSensor self.sn: %s", self.sn) + _LOGGER.debug( + "SemsStatisticsSensor state, self data: %s", self.coordinator.data[self.sn] + ) + data = self.coordinator.data[self.sn] + return data["etotal"] + + @property + def should_poll(self) -> bool: + """No need to poll. Coordinator notifies entity of updates.""" + return False + + @property + def device_info(self): + # _LOGGER.debug("self.device_state_attributes: %s", self.device_state_attributes) + data = self.coordinator.data[self.sn] + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.sn) + }, + # "name": self.name, + "manufacturer": "GoodWe", + "model": data.get("model_type", "unknown"), + "sw_version": data.get("firmwareversion", "unknown"), + # "via_device": (DOMAIN, self.api.bridgeid), + } + + @property + def state_class(self): + """used by Metered entities / Long Term Statistics""" + return SensorStateClass.TOTAL_INCREASING + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update the entity. + + Only used by the generic entity update service. + """ + await self.coordinator.async_request_refresh() + + +class SemsTotalImportSensor(CoordinatorEntity, SensorEntity): + """Sensor in kWh to enable HA statistics, in the end usable in the power component.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator, sn): + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + self.coordinator = coordinator + self.sn = sn + _LOGGER.debug("Creating SemsStatisticsSensor with id %s", self.sn) + + @property + def device_class(self): + return SensorDeviceClass.ENERGY + + @property + def unit_of_measurement(self): + return UnitOfEnergy.KILO_WATT_HOUR + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return "HomeKit Import" + + @property + def unique_id(self) -> str: + return f"{self.coordinator.data[self.sn]['sn']}-import-energy" + + @property + def state(self): + """Return the state of the device.""" + data = self.coordinator.data[self.sn] + return data["Charts_buy"] + + def statusText(self, status) -> str: + labels = {-1: "Offline", 0: "Waiting", 1: "Normal", 2: "Fault"} + return labels[status] if status in labels else "Unknown" + + @property + def should_poll(self) -> bool: + """No need to poll. Coordinator notifies entity of updates.""" + return False + + @property + def device_info(self): + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.sn) + }, + "name": "Homekit", + "manufacturer": "GoodWe", + } + + @property + def state_class(self): + """used by Metered entities / Long Term Statistics""" + return SensorStateClass.TOTAL_INCREASING + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update the entity. + + Only used by the generic entity update service. + """ + await self.coordinator.async_request_refresh() + + +class SemsTotalExportSensor(CoordinatorEntity, SensorEntity): + """Sensor in kWh to enable HA statistics, in the end usable in the power component.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator, sn): + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + self.coordinator = coordinator + self.sn = sn + _LOGGER.debug("Creating SemsStatisticsSensor with id %s", self.sn) + + @property + def device_class(self): + return SensorDeviceClass.ENERGY + + @property + def unit_of_measurement(self): + return UnitOfEnergy.KILO_WATT_HOUR + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return "HomeKit Export" + + @property + def unique_id(self) -> str: + return f"{self.coordinator.data[self.sn]['sn']}-export-energy" + + @property + def state(self): + """Return the state of the device.""" + data = self.coordinator.data[self.sn] + return data["Charts_sell"] + + def statusText(self, status) -> str: + labels = {-1: "Offline", 0: "Waiting", 1: "Normal", 2: "Fault"} + return labels[status] if status in labels else "Unknown" + + @property + def should_poll(self) -> bool: + """No need to poll. Coordinator notifies entity of updates.""" + return False + + @property + def device_info(self): + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.sn) + }, + "name": "Homekit", + "manufacturer": "GoodWe", + } + + @property + def state_class(self): + """used by Metered entities / Long Term Statistics""" + return SensorStateClass.TOTAL_INCREASING + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update the entity. + + Only used by the generic entity update service. + """ + await self.coordinator.async_request_refresh() + + +class SemsPowerflowSensor(CoordinatorEntity, SensorEntity): + """SemsPowerflowSensor using CoordinatorEntity. + + The CoordinatorEntity class provides: + should_poll + async_update + async_added_to_hass + available + """ + + _attr_has_entity_name = True + + def __init__(self, coordinator, sn): + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + self.coordinator = coordinator + self.sn = sn + + @property + def device_class(self): + return SensorDeviceClass.POWER_FACTOR + + @property + def unit_of_measurement(self): + return UnitOfPower.WATT + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return f"HomeKit {self.coordinator.data[self.sn]['sn']}" + + @property + def unique_id(self) -> str: + return f"{self.coordinator.data[self.sn]['sn']}-homekit" + + @property + def state(self): + """Return the state of the device.""" + data = self.coordinator.data[self.sn] + load = data["load"] + + if load: + load = load.replace("(W)", "") + + return load if data["gridStatus"] == 1 else 0 + + def statusText(self, status) -> str: + labels = {-1: "Offline", 0: "Waiting", 1: "Normal", 2: "Fault"} + return labels[status] if status in labels else "Unknown" + + # For backwards compatibility + @property + def extra_state_attributes(self): + """Return the state attributes of the monitored installation.""" + data = self.coordinator.data[self.sn] + + attributes = {k: v for k, v in data.items() if k is not None and v is not None} + + attributes["pv"] = data["pv"].replace("(W)", "") + attributes["bettery"] = data["bettery"].replace("(W)", "") + attributes["load"] = data["load"].replace("(W)", "") + attributes["grid"] = data["grid"].replace("(W)", "") + + attributes["statusText"] = self.statusText(data["gridStatus"]) + + if data["loadStatus"] == -1: + attributes["PowerFlowDirection"] = "Export %s" % data["grid"] + if data["loadStatus"] == 1: + attributes["PowerFlowDirection"] = "Import %s" % data["grid"] + + return attributes + + @property + def is_on(self) -> bool: + """Return entity status.""" + self.coordinator.data[self.sn]["gridStatus"] == 1 + + @property + def should_poll(self) -> bool: + """No need to poll. Coordinator notifies entity of updates.""" + return False + + @property + def available(self): + """Return if entity is available.""" + return self.coordinator.last_update_success + + @property + def device_info(self): + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.sn) + }, + "name": "Homekit", + "manufacturer": "GoodWe", + } + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update the entity. + + Only used by the generic entity update service. + """ + await self.coordinator.async_request_refresh() diff --git a/homeassistant/custom_components/sems/strings.json b/homeassistant/custom_components/sems/strings.json new file mode 100644 index 0000000..1f31b30 --- /dev/null +++ b/homeassistant/custom_components/sems/strings.json @@ -0,0 +1,26 @@ +{ + "title": "GoodWe SEMS PV API", + "config": { + "step": { + "user": { + "data": { + "powerstation_id": "[%key:common::config_flow::data::powerstation_id%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "scan_interval": "[%key:common::config_flow::data::scan_interval%]" + }, + "data_description": { + "powerstation_id": "[%key:common::config_flow::data_description::powerstation_id%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/custom_components/sems/switch.py b/homeassistant/custom_components/sems/switch.py new file mode 100644 index 0000000..3177549 --- /dev/null +++ b/homeassistant/custom_components/sems/switch.py @@ -0,0 +1,93 @@ +"""Support for switch controlling an output of a GoodWe SEMS inverter. + +For more details about this platform, please refer to the documentation at +https://github.com/TimSoethout/goodwe-sems-home-assistant +""" + +import logging + +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +from homeassistant.core import HomeAssistant + + +async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entities): + """Add switches for passed config_entry in HA.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + # stationId = config_entry.data[CONF_STATION_ID] + + # coordinator.data should contain a dictionary of inverters, with as data `invert_full` + # Don't make switches for homekit, since it is not an inverter + async_add_entities( + SemsStatusSwitch(coordinator, ent) + for idx, ent in enumerate(coordinator.data) + if ent != "homeKit" + ) + + +class SemsStatusSwitch(CoordinatorEntity, SwitchEntity): + """SemsStatusSwitch using CoordinatorEntity. + + The CoordinatorEntity class provides: + should_poll + async_update + async_added_to_hass + available + """ + + # Sensor has device name (e.g. Inverter 123456 Power) + _attr_has_entity_name = True + # _attr_name = None + + def __init__(self, coordinator, sn) -> None: + """Initialize the SemsStatusSwitch. + + Args: + coordinator: The data update coordinator for managing updates. + sn: The serial number of the inverter. + + """ + _LOGGER.debug("Try create SemsStatusSwitch for Inverter %s", sn) + super().__init__(coordinator, context=sn) + self.coordinator = coordinator + self.sn = sn + self._attr_device_info = DeviceInfo( + identifiers={ + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.sn) + }, + # Commented out for now, since not all inverter entries have a name; could be related to creating too much switch devices, also for non-inverters such as homekit. + # name=f"Inverter {self.coordinator.data[self.sn]['name']}", + ) + self._attr_unique_id = f"{self.sn}-switch" + # somehow needed, no default naming + self._attr_name = "Switch" + self._attr_device_class = SwitchDeviceClass.OUTLET + _LOGGER.debug("Creating SemsStatusSwitch for Inverter %s", self.sn) + + @property + def is_on(self) -> bool: + """Return entity status.""" + _LOGGER.debug("coordinator.data[sn]: %s", self.coordinator.data[self.sn]) + return self.coordinator.data[self.sn]["status"] == 1 + + async def async_turn_off(self, **kwargs): + """Turn off the inverter.""" + _LOGGER.debug("Inverter %s set to Off", self.sn) + await self.hass.async_add_executor_job( + self.coordinator.semsApi.change_status, self.sn, 2 + ) + + async def async_turn_on(self, **kwargs): + """Turn on the inverter.""" + _LOGGER.debug("Inverter %s set to On", self.sn) + await self.hass.async_add_executor_job( + self.coordinator.semsApi.change_status, self.sn, 4 + ) diff --git a/homeassistant/custom_components/sems/translations/en.json b/homeassistant/custom_components/sems/translations/en.json new file mode 100644 index 0000000..78aec4b --- /dev/null +++ b/homeassistant/custom_components/sems/translations/en.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "powerstation_id": "Power Station ID", + "password": "Password", + "username": "Username", + "scan_interval": "Update Interval (seconds)" + }, + "data_description": { + "powerstation_id": "If empty, will query SEMS API and take first" + } + } + } + }, + "title": "GoodWe SEMS PV API" +} \ No newline at end of file diff --git a/homeassistant/custom_components/sems/translations/pt.json b/homeassistant/custom_components/sems/translations/pt.json new file mode 100644 index 0000000..858dd99 --- /dev/null +++ b/homeassistant/custom_components/sems/translations/pt.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Dispositivo já configurado" + }, + "error": { + "cannot_connect": "Falha na ligação", + "invalid_auth": "Autenticação Inválida", + "unknown": "Erro inesperado" + }, + "step": { + "user": { + "data": { + "powerstation_id": "ID da Planta", + "password": "Password", + "username": "Nome de Utilizador", + "scan_interval": "Intervalo de Atualização (segundos)" + }, + "data_description": { + "powerstation_id": "Se vazio, irá consultar a API SEMS e usar o primeiro" + } + } + } + }, + "title": "GoodWe SEMS PV API" +} \ No newline at end of file