From b0f3a884f15187269db3362b33eceab68bb81c17 Mon Sep 17 00:00:00 2001 From: Matej Konecny Date: Mon, 11 Mar 2024 11:30:17 +0000 Subject: [PATCH 1/4] Working Switch --- custom_components/sems/__init__.py | 2 +- custom_components/sems/sems_api.py | 58 ++++++++++++++++++++++ custom_components/sems/sensor.py | 3 +- custom_components/sems/switch.py | 80 ++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 custom_components/sems/switch.py diff --git a/custom_components/sems/__init__.py b/custom_components/sems/__init__.py index 5c829e0..0746c28 100644 --- a/custom_components/sems/__init__.py +++ b/custom_components/sems/__init__.py @@ -14,7 +14,7 @@ from .sems_api import SemsApi # For your initial PR, limit it to 1 platform. -PLATFORMS = ["sensor"] +PLATFORMS = ["sensor", "switch"] async def async_setup(hass: HomeAssistant, config: dict): diff --git a/custom_components/sems/sems_api.py b/custom_components/sems/sems_api.py index e181c95..cc5e742 100644 --- a/custom_components/sems/sems_api.py +++ b/custom_components/sems/sems_api.py @@ -10,6 +10,7 @@ # _LoginURL = "https://eu.semsportal.com/api/v2/Common/CrossLogin" _LoginURL = "https://www.semsportal.com/api/v2/Common/CrossLogin" _PowerStationURLPart = "/v2/PowerStation/GetMonitorDetailByPowerstationId" +_GopsURL = "https://www.semsportal.com/api/PowerStation/SaveRemoteControlInverter" _RequestTimeout = 30 # seconds _DefaultHeaders = { @@ -128,6 +129,63 @@ def getData(self, powerStationId, renewToken=False, maxTokenRetries=2): except Exception as exception: _LOGGER.error("Unable to fetch data from SEMS. %s", exception) + def change_status(self, powerStationId, 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), + } + + gopsURL = _GopsURL + _LOGGER.debug( + "Sending GOPS command (%s) for power station id: %s", + gopsURL, + powerStationId, + ) + + data = { + "api":"PowerStation/SaveRemoteControlInverter", + "param": { + "InverterSN":"", + "StationID":powerStationId, + "InverterStatusSettingMark":1, + "InverterStatus":status + } + } + + response = requests.post( + gopsURL, headers=headers, data=data, timeout=_RequestTimeout + ) + if (response.status_code != 200): + # try again and renew token is unsuccessful + _LOGGER.debug( + "GOPS command not successful, retrying with new token, %s retries remaining", + maxTokenRetries, + ) + return + + return + except Exception as exception: + _LOGGER.error("Unable to execute GOPS command. %s", exception) + class OutOfRetries(exceptions.HomeAssistantError): """Error to indicate too many error attempts.""" diff --git a/custom_components/sems/sensor.py b/custom_components/sems/sensor.py index 7729734..5f69eb1 100644 --- a/custom_components/sems/sensor.py +++ b/custom_components/sems/sensor.py @@ -5,6 +5,7 @@ https://github.com/TimSoethout/goodwe-sems-home-assistant """ +from typing import Coroutine from homeassistant.core import HomeAssistant import homeassistant import logging @@ -24,7 +25,6 @@ DEVICE_CLASS_ENERGY, ENERGY_KILO_WATT_HOUR, ) -from homeassistant.helpers.entity import Entity from .const import DOMAIN, CONF_STATION_ID, DEFAULT_SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -567,3 +567,4 @@ async def async_update(self): Only used by the generic entity update service. """ await self.coordinator.async_request_refresh() + diff --git a/custom_components/sems/switch.py b/custom_components/sems/switch.py new file mode 100644 index 0000000..8563611 --- /dev/null +++ b/custom_components/sems/switch.py @@ -0,0 +1,80 @@ +""" +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 typing import Coroutine +from homeassistant.core import HomeAssistant +import homeassistant +import logging + +from datetime import timedelta + +from homeassistant.const import ( + CONF_SCAN_INTERVAL, +) +from homeassistant.helpers.entity import Entity +from .const import DOMAIN, CONF_STATION_ID +from homeassistant.components.switch import SwitchEntity + +_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] + + entities = [SemsSwitch(semsApi, stationId)] + async_add_entities(entities) + +class SemsSwitch(SwitchEntity): + + def __init__(self, api, stationId): + super().__init__() + self.api = api + self.stationId = stationId + _LOGGER.info("Creating SemsSwitch with id %s", self.stationId) + + # def turn_off(self, **kwargs): + # _LOGGER.warn("Off " + self.stationId) + + # def turn_on(self, **kwargs): + # _LOGGER.warn("On " + self.stationId) + + @property + def is_on(self) -> bool: + """Return entity status.""" + return True + + @property + def name(self) -> str: + """Return the name of the switch.""" + return f"Inverter {self.stationId} Switch" + + + @property + def unique_id(self) -> str: + return f"{self.stationId}-switch" + + @property + def device_info(self): + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.stationId) + }, + "name": "Homekit", + "manufacturer": "GoodWe", + } + + def async_turn_off(self, **kwargs): + _LOGGER.warn("Off " + self.stationId) + self.api.change_status(self.stationId, 0) + + def async_turn_on(self, **kwargs): + _LOGGER.warn("On " + self.stationId) + self.api.change_status(self.stationId, 1) From 8ce096a899a233467fbfbc66c9c5db7adfd83707 Mon Sep 17 00:00:00 2001 From: Matej Konecny Date: Mon, 11 Mar 2024 21:08:04 +0000 Subject: [PATCH 2/4] Switch working with the API. Updating the Readme --- README.md | 14 ++++++-- custom_components/sems/sems_api.py | 28 +++++++-------- custom_components/sems/switch.py | 55 ++++++++++++++---------------- 3 files changed, 48 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index f13f9ab..1187217 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,18 @@ Fill in the required configuration and it should find your inverters. Note that changed to `configuration.yaml` are no longer necessary and can be removed. -### Recommended: use visitor account to login with this integration +### Optional: control the invertor power output via the "switch" entity + +It is possible to temporarily pause the energy production via "downtime" functionality available on the invertor. This is exposed as a switch and can be used in your own automations. + +Please note that it is using an undocumented API and can take a few minutes for the invertor to pick up the change. It takes approx 60 seconds to start again when the invertor is in a downtime mode. + +### Recommended: use visitor account if you do not need to control the inverter + +In case you are only reading the inverter stats, you can use a Visitor (read-only) account. Create via the official app, or via the web portal: -Login to www.semsportal.com, go to https://semsportal.com/powerstation/stationInfonew. Create a new visitor account. +Login to www.semsportal.com, go to https://semsportal.com/powerstation/stationInfonew. Create a new visitor account. Login to the visitor account once to accept the EULA. Now you should be able to use it in this component. ### Extra (optional) templates to easy access data as sensors @@ -78,7 +86,7 @@ Replace `$NAME` with your inverter entity id. friendly_name: "Battery power" ``` -Note that `states.sensor.inverter_$NAME.state` contains the power output in `W`. +Note that `states.sensor.inverter_$NAME.state` contains the power output in `W`. ## Screenies diff --git a/custom_components/sems/sems_api.py b/custom_components/sems/sems_api.py index cc5e742..e1b6228 100644 --- a/custom_components/sems/sems_api.py +++ b/custom_components/sems/sems_api.py @@ -10,7 +10,7 @@ # _LoginURL = "https://eu.semsportal.com/api/v2/Common/CrossLogin" _LoginURL = "https://www.semsportal.com/api/v2/Common/CrossLogin" _PowerStationURLPart = "/v2/PowerStation/GetMonitorDetailByPowerstationId" -_GopsURL = "https://www.semsportal.com/api/PowerStation/SaveRemoteControlInverter" +_PowerControlURL = "https://www.semsportal.com/api/PowerStation/SaveRemoteControlInverter" _RequestTimeout = 30 # seconds _DefaultHeaders = { @@ -129,7 +129,7 @@ def getData(self, powerStationId, renewToken=False, maxTokenRetries=2): except Exception as exception: _LOGGER.error("Unable to fetch data from SEMS. %s", exception) - def change_status(self, powerStationId, status, renewToken=False, maxTokenRetries=2): + 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 @@ -154,37 +154,33 @@ def change_status(self, powerStationId, status, renewToken=False, maxTokenRetrie "token": json.dumps(self._token), } - gopsURL = _GopsURL + powerControlURL = _PowerControlURL _LOGGER.debug( - "Sending GOPS command (%s) for power station id: %s", - gopsURL, - powerStationId, + "Sending power control command (%s) for power station id: %s", + powerControlURL, + inverterSn, ) data = { - "api":"PowerStation/SaveRemoteControlInverter", - "param": { - "InverterSN":"", - "StationID":powerStationId, - "InverterStatusSettingMark":1, - "InverterStatus":status - } + "InverterSN": inverterSn, + "InverterStatusSettingMark":1, + "InverterStatus": status } response = requests.post( - gopsURL, headers=headers, data=data, timeout=_RequestTimeout + powerControlURL, headers=headers, data=data, timeout=_RequestTimeout ) if (response.status_code != 200): # try again and renew token is unsuccessful _LOGGER.debug( - "GOPS command not successful, retrying with new token, %s retries remaining", + "Power control command not successful, retrying with new token, %s retries remaining", maxTokenRetries, ) return return except Exception as exception: - _LOGGER.error("Unable to execute GOPS command. %s", exception) + _LOGGER.error("Unable to execute Power control command. %s", exception) class OutOfRetries(exceptions.HomeAssistantError): diff --git a/custom_components/sems/switch.py b/custom_components/sems/switch.py index 8563611..1a4ba60 100644 --- a/custom_components/sems/switch.py +++ b/custom_components/sems/switch.py @@ -1,21 +1,17 @@ """ -Support for power production statistics from GoodWe SEMS API. +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 """ -from typing import Coroutine -from homeassistant.core import HomeAssistant -import homeassistant import logging -from datetime import timedelta - from homeassistant.const import ( CONF_SCAN_INTERVAL, ) from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import UpdateFailed from .const import DOMAIN, CONF_STATION_ID from homeassistant.components.switch import SwitchEntity @@ -23,58 +19,57 @@ 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]) + """Add switches for passed config_entry in HA.""" semsApi = hass.data[DOMAIN][config_entry.entry_id] stationId = config_entry.data[CONF_STATION_ID] - entities = [SemsSwitch(semsApi, stationId)] + try: + result = await hass.async_add_executor_job(semsApi.getData, stationId) + + except Exception as err: + # logging.exception("Something awful happened!") + raise UpdateFailed(f"Error communicating with API: {err}") + + inverters = result["inverter"] + entities = [] + for inverter in inverters: + entities.append(SemsSwitch(semsApi, inverter["invert_full"]["sn"])) + async_add_entities(entities) class SemsSwitch(SwitchEntity): - def __init__(self, api, stationId): + def __init__(self, api, sn): super().__init__() self.api = api - self.stationId = stationId - _LOGGER.info("Creating SemsSwitch with id %s", self.stationId) - - # def turn_off(self, **kwargs): - # _LOGGER.warn("Off " + self.stationId) - - # def turn_on(self, **kwargs): - # _LOGGER.warn("On " + self.stationId) - - @property - def is_on(self) -> bool: - """Return entity status.""" - return True + self.sn = sn + _LOGGER.debug(f"Creating SemsSwitch for Inverter {self.sn}") @property def name(self) -> str: """Return the name of the switch.""" - return f"Inverter {self.stationId} Switch" + return f"Inverter {self.sn} Switch" @property def unique_id(self) -> str: - return f"{self.stationId}-switch" + return f"{self.sn}-switch" @property def device_info(self): return { "identifiers": { # Serial numbers are unique identifiers within a specific domain - (DOMAIN, self.stationId) + (DOMAIN, self.sn) }, "name": "Homekit", "manufacturer": "GoodWe", } def async_turn_off(self, **kwargs): - _LOGGER.warn("Off " + self.stationId) - self.api.change_status(self.stationId, 0) + _LOGGER.debug(f"Inverter {self.sn} set to Off") + self.api.change_status(self.sn, 0) def async_turn_on(self, **kwargs): - _LOGGER.warn("On " + self.stationId) - self.api.change_status(self.stationId, 1) + _LOGGER.debug(f"Inverter {self.sn} set to On") + self.api.change_status(self.sn, 1) From 235b69d2aac280fe3c920f7a38262cb32f3410c9 Mon Sep 17 00:00:00 2001 From: Matej Konecny Date: Tue, 12 Mar 2024 14:22:51 +0000 Subject: [PATCH 3/4] Fixing wrong payload --- custom_components/sems/sems_api.py | 8 ++++---- custom_components/sems/switch.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/custom_components/sems/sems_api.py b/custom_components/sems/sems_api.py index e1b6228..2b0fc70 100644 --- a/custom_components/sems/sems_api.py +++ b/custom_components/sems/sems_api.py @@ -163,16 +163,16 @@ def change_status(self, inverterSn, status, renewToken=False, maxTokenRetries=2) data = { "InverterSN": inverterSn, - "InverterStatusSettingMark":1, - "InverterStatus": status + "InverterStatusSettingMark":"1", + "InverterStatus": str(status) } response = requests.post( - powerControlURL, headers=headers, data=data, timeout=_RequestTimeout + powerControlURL, headers=headers, json=data, timeout=_RequestTimeout ) if (response.status_code != 200): # try again and renew token is unsuccessful - _LOGGER.debug( + _LOGGER.warn( "Power control command not successful, retrying with new token, %s retries remaining", maxTokenRetries, ) diff --git a/custom_components/sems/switch.py b/custom_components/sems/switch.py index 1a4ba60..e1c6203 100644 --- a/custom_components/sems/switch.py +++ b/custom_components/sems/switch.py @@ -68,8 +68,8 @@ def device_info(self): def async_turn_off(self, **kwargs): _LOGGER.debug(f"Inverter {self.sn} set to Off") - self.api.change_status(self.sn, 0) + self.api.change_status(self.sn, 2) def async_turn_on(self, **kwargs): _LOGGER.debug(f"Inverter {self.sn} set to On") - self.api.change_status(self.sn, 1) + self.api.change_status(self.sn, 4) From 3482ba8cad48e02bf475fe41351cd0840b655a9d Mon Sep 17 00:00:00 2001 From: Tim Soethout Date: Wed, 20 Mar 2024 10:47:18 +0100 Subject: [PATCH 4/4] Update major version number --- custom_components/sems/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sems/manifest.json b/custom_components/sems/manifest.json index f75b7dd..0c8c488 100644 --- a/custom_components/sems/manifest.json +++ b/custom_components/sems/manifest.json @@ -8,5 +8,5 @@ "requirements": [], "dependencies": [], "codeowners": ["@TimSoethout"], - "version": "3.7.4" + "version": "5.0.0" }