diff --git a/README.md b/README.md index ee89aad..e8bc4a2 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,62 @@ -# Home Assistant sensor for SENEC.Home V3 +# Home Assistant sensor for SENEC.Home V3 + This fork was created from [mchwalisz/home-assistant-senec](https://gitgub.com/mchwalisz/home-assistant-senec) mainly because I wanted additional fields and some configuration options (like polling interval). Since I own a __SENEC.Home V3 hybrid duo__ I can __only test my adjustments in such a configuration__. -__Use this fork on your own risk!__ +But this does not imply, that this Integration is working only with V3 systems. The Integration should work with +multiple SENEC.Home Systems based on local `lala.cgi` calls. + +Have that said - the __SENEC.Home V4__ will not come with a build-in web server that can be polled from your LAN. So +in order to support V4 this integration is polling (a limited amount of) data from the mein-senec.de web portal. The +__available data is__ (currently) __limited__ (only 13 sensor entities) and will be polled with an interval of 5 minutes. + +__Thanks to [@mstuettgen](https://github.com/mstuettgen) developing the initial SENEC.Home V4 web-access! I hope you +support this repro in the future with possible enhancements for the WEB-API. -__Please note that this integration will _not work_ with Senec V4 systems!__ Senec V4 use a different communication -layer that is not compatible with previous Senec hardware. So if you are a V4 owner you might be in the uncomfortable -situation to develop a own integration from the scratch. [IMHO it's impossible to develop such a integration remotely] +## __Use this fork on your own risk!__ ## Modifications (compared to the original version) in this fork + - Added User accessible configuration option - Added configurable _update interval_ for the sensor data (I use _5_ seconds, without any issue) - Reading DeviceID, DeviceType, BatteryType & Version information +- Added WebAPI access in order to support SENEC.Home V4 + Systems - [thanks too @mstuettgen for the initial work!](https://github.com/mstuettgen/homeassistant-addons/tree/main/senecweb2mqtt) + __and__ for all other SENEC.Home Systems where the total-statistics data have been removed with the latest update by + SENEC (currently with hardcoded polling interval of 5 minutes) + +- Additional Sensors: + - For each MPP1, MPP2, MPP3 [potential (V), current (A) & power (W)] + - For your EnFluRi-Net (Freq, potential, current, power) + - For your EnFluRi-Usage (Freq, potential, current, power) [disabled by default] -- Additional Sensors: - - For each MPP1, MPP2, MPP3 [potential (V), current (A) & power (W)] - - For your EnFluRi-Net (Freq, potential, current, power) - - For your EnFluRi-Usage (Freq, potential, current, power) [disabled by default] - - - Added BatteryCell Details [mainly disabled by default] - - Module [A-D]: Current/Voltage/State of Charge (SoC)/State of Health (SoH)/Cycles - - Cell temperature [1-6] per module [A-D] - - Voltage per cell [1-14] per module [A-D] - - - Added Wallbox Details [disabled by default] - - - If you connect the internal Inverter [in the case of the Duo there are even two (LV & HV)] to your LAN (see - [details below](#inv-lnk)), then you can add these additional instances and directly access the data from the DC-AC - converters + - Added BatteryCell Details [mainly disabled by default] + - Module [A-D]: Current/Voltage/State of Charge (SoC)/State of Health (SoH)/Cycles + - Cell temperature [1-6] per module [A-D] + - Voltage per cell [1-14] per module [A-D] + + - Added Wallbox Details [disabled by default] + + - If you connect the internal Inverter [in the case of the Duo there are even two (LV & HV)] to your LAN (see + [details below](#inv-lnk)), then you can add these additional instances and directly access the data from the + DC-AC + converters - Added Switch(es): - - Added a switch to manually load the battery [state: 'MAN. SAFETY CHARGE' & 'SAFETY CHARGE READY'] (obviously this - will use additional power from grid when your PV inverters will not provide enough power) - - _This switche might sound very foolish - but if you are not subscribed to the (IMHO total overpriced) SENEC-Cloud - electricity tariff __and__ you have been smart and signed up for a dynamic price model (based on the current stock - price) then loading your battery when the price is the lowest during the day might become a smart move (and also - disallow battery usage while the price is average). Specially during the winter!_ - - - EXPERIMENTAL: Added a switch to enable 'storage mode' [state: LITHIUM SAFE MODE DONE'] [disabled by default] - - The functionality of this switch is currently __not known__ - IMHO this will disable the functionality of the PV! - __Please Note, that once enabled and then disable again the system will go into the 'INSULATION TEST' mode__ for a - short while (before returning to normal operation) + - Added a switch to manually load the battery [state: 'MAN. SAFETY CHARGE' & 'SAFETY CHARGE READY'] (obviously this + will use additional power from grid when your PV inverters will not provide enough power) + + _This switche might sound very foolish - but if you are not subscribed to the (IMHO total overpriced) SENEC-Cloud + electricity tariff __and__ you have been smart and signed up for a dynamic price model (based on the current stock + price) then loading your battery when the price is the lowest during the day might become a smart move (and also + disallow battery usage while the price is average). Specially during the winter!_ + + - EXPERIMENTAL: Added a switch to enable 'storage mode' [state: LITHIUM SAFE MODE DONE'] [disabled by default] + + The functionality of this switch is currently __not known__ - IMHO this will disable the functionality of the PV! + __Please Note, that once enabled and then disable again the system will go into the 'INSULATION TEST' mode__ for a + short while (before returning to normal operation) - Modified _battery_charge_power_ & _battery_discharge_power_ so that they will only return data >0 when the system state is matching the corresponding CHARGE or DISCHARGE state (including state variants) @@ -68,14 +81,18 @@ situation to develop a own integration from the scratch. [IMHO it's impossible t ### Manual -- Copy all files from `custom_components/senec/` to `custom_components/senec/` inside your config Home Assistant directory. +- Copy all files from `custom_components/senec/` to `custom_components/senec/` inside your config Home Assistant + directory. - Restart Home Assistant to install all dependencies ### Adding or enabling integration + #### My Home Assistant (2021.3+) + [![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=senec) #### Manual + Add custom integration using the web interface and follow instruction on screen. - Go to `Configuration -> Integrations` and add "Senec" integration @@ -83,11 +100,14 @@ Add custom integration using the web interface and follow instruction on screen. - Provide area where the battery is located + ## Connecting the internal (build in) Senec Inverter Hardware to your LAN and use it in HA + The __SENEC.Home V3 hybrid duo__ have build in two inverters - called LV and HV. This hardware has its own LAN connectors, but they have not been connected during the installation process (I guess by purpose). ### __DO THIS ON YOUR OWN RISK!__ + Nevertheless, when you dismount the front and the right hand side panels you simply can plug in RJ45 LAN cables into both of the inverters LAN connectors and after a short while you should be able to access the web frontends of the inverters via your browser. @@ -96,14 +116,17 @@ _Don't forget to assign fixed IP's to the additional inverter hardware. You can in order to make sure that the inverters will make use of the fixed assigned IP's._ ### Position of SENEC.Inverter V3 LV LAN connector + ![img|160x90](images/inv_lv.png) On the front of the device ### Position of SENEC.Inverter V3 HV LAN connector _(hybrid duo only!)_ + ![img|160x90](images/inv_hv.png) On the right hand side of the device ### Adding Inverter(s) to your HA + Once you have connected the inverter(s) with your LAN you can add another integration entry to your Senec Integration in Home Assistant: @@ -112,7 +135,7 @@ Home Assistant: 3. there you find the '__Add Entry__' button (at the bottom of the '__Integration entries__' list) 4. specify the IP (or hostname) of the inverter you want to add 5. __important:__ assign a name (e.g. _INV_LV_). - + Repeat step 3, 4 & 5 of this procedure, if you have build in two inverters into your Senec.HOME. ## Home Assistant Energy Dashboard diff --git a/STATSENSORS.md b/STATSENSORS.md new file mode 100644 index 0000000..b80ede2 --- /dev/null +++ b/STATSENSORS.md @@ -0,0 +1,3 @@ +# Since SENEC Application v825 no STATISTIC data is provided [No Data for multiple Sensors] + +currently you find all available information here in this issue https://github.com/marq24/ha-senec-v3/issues/4 \ No newline at end of file diff --git a/custom_components/senec/__init__.py b/custom_components/senec/__init__.py index 24f718d..237a37d 100644 --- a/custom_components/senec/__init__.py +++ b/custom_components/senec/__init__.py @@ -6,14 +6,14 @@ from datetime import timedelta from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL, CONF_TYPE +from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL, CONF_TYPE, CONF_USERNAME, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import EntityDescription, Entity from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from custom_components.senec.pysenec_ha import Senec, Inverter +from custom_components.senec.pysenec_ha import Senec, Inverter, MySenecWebPortal from .const import ( DOMAIN, @@ -25,7 +25,8 @@ CONF_DEV_NAME, CONF_DEV_SERIAL, CONF_DEV_VERSION, - CONF_SYSTYPE_INVERTER + CONF_SYSTYPE_INVERTER, + CONF_SYSTYPE_WEB ) _LOGGER = logging.getLogger(__name__) @@ -61,7 +62,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.async_create_task(hass.config_entries.async_forward_entry_setup(entry, platform)) entry.add_update_listener(async_reload_entry) - return True @@ -70,10 +70,14 @@ class SenecDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass, session, entry): """Initialize.""" - self._host = entry.data[CONF_HOST] if CONF_TYPE in entry.data and entry.data[CONF_TYPE] == CONF_SYSTYPE_INVERTER: + self._host = entry.data[CONF_HOST] self.senec = Inverter(self._host, websession=session) + if CONF_TYPE in entry.data and entry.data[CONF_TYPE] == CONF_SYSTYPE_WEB: + self._host = "mein-senec.de" + self.senec = MySenecWebPortal(user=entry.data[CONF_USERNAME], pwd=entry.data[CONF_PASSWORD], websession=session) else: + self._host = entry.data[CONF_HOST] if CONF_USE_HTTPS in entry.data: self._use_https = entry.data[CONF_USE_HTTPS] else: diff --git a/custom_components/senec/binary_sensor.py b/custom_components/senec/binary_sensor.py index 275a621..0eda8e6 100644 --- a/custom_components/senec/binary_sensor.py +++ b/custom_components/senec/binary_sensor.py @@ -9,14 +9,16 @@ from typing import Literal from . import SenecDataUpdateCoordinator, SenecEntity -from .const import DOMAIN, MAIN_BIN_SENSOR_TYPES, CONF_SYSTYPE_INVERTER, ExtBinarySensorEntityDescription +from .const import DOMAIN, MAIN_BIN_SENSOR_TYPES, CONF_SYSTYPE_INVERTER, CONF_SYSTYPE_WEB, ExtBinarySensorEntityDescription _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities): coordinator = hass.data[DOMAIN][config_entry.entry_id] if (CONF_TYPE in config_entry.data and config_entry.data[CONF_TYPE] == CONF_SYSTYPE_INVERTER): - _LOGGER.info("No switches for Inverters...") + _LOGGER.info("No binary_sensors for Inverters...") + if (CONF_TYPE in config_entry.data and config_entry.data[CONF_TYPE] == CONF_SYSTYPE_WEB): + _LOGGER.info("No binary_sensors for WebPortal...") else: entities = [] for description in MAIN_BIN_SENSOR_TYPES: diff --git a/custom_components/senec/config_flow.py b/custom_components/senec/config_flow.py index 40118cf..ac03e80 100644 --- a/custom_components/senec/config_flow.py +++ b/custom_components/senec/config_flow.py @@ -1,14 +1,15 @@ """Config flow for senec integration.""" import logging import voluptuous as vol +from homeassistant.data_entry_flow import FlowResultType -from custom_components.senec.pysenec_ha import Senec +from custom_components.senec.pysenec_ha import Senec, MySenecWebPortal from custom_components.senec.pysenec_ha import Inverter from requests.exceptions import HTTPError, Timeout from aiohttp import ClientResponseError from homeassistant import config_entries -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SCAN_INTERVAL, CONF_TYPE +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SCAN_INTERVAL, CONF_TYPE, CONF_USERNAME, CONF_PASSWORD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import selector @@ -20,12 +21,14 @@ DEFAULT_HOST_INVERTER, DEFAULT_NAME, DEFAULT_NAME_INVERTER, + DEFAULT_NAME_WEB, DEFAULT_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL_SENECV2, SYSTEM_TYPES, SYSTYPE_SENECV2, SYSTYPE_SENECV4, + SYSTYPE_WEBAPI, SYSTYPE_INVERTV3, SYSTEM_MODES, MODE_WEB, @@ -35,6 +38,7 @@ CONF_DEV_TYPE, CONF_DEV_TYPE_INT, CONF_USE_HTTPS, + CONF_SUPPORT_STATS, CONF_SUPPORT_BDC, CONF_DEV_NAME, CONF_DEV_SERIAL, @@ -42,7 +46,7 @@ CONF_SYSTYPE_SENEC, CONF_SYSTYPE_SENEC_V2, CONF_SYSTYPE_INVERTER, - CONF_SYSTYPE_WEB + CONF_SYSTYPE_WEB, DEFAULT_SCAN_INTERVAL_WEB, DEFAULT_USERNAME ) _LOGGER = logging.getLogger(__name__) @@ -91,13 +95,18 @@ async def _test_connection_senec(self, host, use_https): self._device_serial = 'S' + senec_client.device_id self._device_version = senec_client.versions self._use_https = use_https + self._stats_available = senec_client.grid_total_export is not None + + # just for local testing... + #self._stats_available = False + _LOGGER.info( "Successfully connect to SENEC.Home (using https? %s) at %s", use_https, host, ) return True except (OSError, HTTPError, Timeout, ClientResponseError): - #_LOGGER.exception("Please Report @ https://github.com/marq24/ha-senec-v3/issues:") + # _LOGGER.exception("Please Report @ https://github.com/marq24/ha-senec-v3/issues:") self._errors[CONF_HOST] = "cannot_connect" _LOGGER.warning( "Could not connect to SENEC.Home (using https? %s) at %s, check host ip address", @@ -130,14 +139,43 @@ async def _test_connection_inverter(self, host): ) return False + async def _test_connection_webapi(self, user, pwd): + """Check if we can connect to the Senec WEB.""" + self._errors = {} + websession = self.hass.helpers.aiohttp_client.async_get_clientsession() + try: + senec_web_client = MySenecWebPortal(user=user, pwd=pwd, websession=websession) + await senec_web_client.authenticate(doUpdate=True) + await senec_web_client.update_context() + + # TODO: fetch VERSION and other Info from WebApi... + self._device_type = "SENEC WebAPI" + # self._device_type_internal = senec_client.device_type_internal + self._device_type = senec_web_client.product_name + self._device_name = 'SENEC.Num: ' + senec_web_client.senec_num + self._device_serial = senec_web_client.serial_number + self._device_version = senec_web_client.firmwareVersion + _LOGGER.info("Successfully connect to mein-senec.de with '%s'", user) + return True + except (OSError, HTTPError, Timeout, ClientResponseError): + self._errors[CONF_USERNAME] = "login_failed" + _LOGGER.warning("Could not connect to mein-senec.de with '%s', check credentials", user) + return False + async def async_step_user(self, user_input=None): self._errors = {} if user_input is not None: self._selected_system = user_input - if self._selected_system.get(SETUP_SYS_TYPE) == SYSTYPE_SENECV4: + + # SenecV4 - WebONLY Option... + if self._selected_system.get(SETUP_SYS_TYPE) == SYSTYPE_SENECV4 or self._selected_system.get( + SETUP_SYS_TYPE) == SYSTYPE_WEBAPI: return await self.async_step_websetup() + + # Inverter option... if self._selected_system.get(SETUP_SYS_TYPE) == SYSTYPE_INVERTV3: return await self.async_step_system() + else: # return await self.async_step_mode() return await self.async_step_system() @@ -159,36 +197,37 @@ async def async_step_user(self, user_input=None): ) } ), + last_step=False, errors=self._errors, ) - async def async_step_mode(self, user_input=None): - self._errors = {} - if user_input is not None: - if self._selected_system.get(SETUP_SYS_MODE) == MODE_WEB: - return await self.async_step_websetup() - else: - return await self.async_step_system() - else: - user_input = {} - user_input[SETUP_SYS_MODE] = DEFAULT_MODE - - return self.async_show_form( - step_id="mode", - data_schema=vol.Schema( - { - vol.Required(SETUP_SYS_MODE, default=user_input.get(SETUP_SYS_MODE, DEFAULT_MODE)): - selector.SelectSelector( - selector.SelectSelectorConfig( - options=SYSTEM_MODES, - mode=selector.SelectSelectorMode.DROPDOWN, - translation_key=SETUP_SYS_MODE, - ) - ) - } - ), - errors=self._errors, - ) + # async def async_step_mode(self, user_input=None): + # self._errors = {} + # if user_input is not None: + # if self._selected_system.get(SETUP_SYS_MODE) == MODE_WEB: + # return await self.async_step_websetup() + # else: + # return await self.async_step_system() + # else: + # user_input = {} + # user_input[SETUP_SYS_MODE] = DEFAULT_MODE + # + # return self.async_show_form( + # step_id="mode", + # data_schema=vol.Schema( + # { + # vol.Required(SETUP_SYS_MODE, default=user_input.get(SETUP_SYS_MODE, DEFAULT_MODE)): + # selector.SelectSelector( + # selector.SelectSelectorConfig( + # options=SYSTEM_MODES, + # mode=selector.SelectSelectorMode.DROPDOWN, + # translation_key=SETUP_SYS_MODE, + # ) + # ) + # } + # ), + # errors=self._errors, + # ) async def async_step_system(self, user_input=None): """Step when user initializes a integration.""" @@ -201,9 +240,9 @@ async def async_step_system(self, user_input=None): # make sure we just handle host/ip's - removing http/https if host_entry.startswith("http://"): - host_entry = host_entry.replace("http://","") + host_entry = host_entry.replace("http://", "") if host_entry.startswith('https://'): - host_entry = host_entry.replace("https://","") + host_entry = host_entry.replace("https://", "") if self._host_in_configuration_exists(host_entry): self._errors[CONF_HOST] = "already_configured" @@ -230,18 +269,35 @@ async def async_step_system(self, user_input=None): # SENEC.Home stuff else: - if await self._test_connection_senec(host_entry, False) or await self._test_connection_senec(host_entry, True): - return self.async_create_entry(title=name, data={CONF_NAME: name, - CONF_HOST: host_entry, - CONF_USE_HTTPS: self._use_https, - CONF_SCAN_INTERVAL: scan, - CONF_TYPE: CONF_SYSTYPE_SENEC, - CONF_DEV_TYPE_INT: self._device_type_internal, - CONF_DEV_TYPE: self._device_type, - CONF_DEV_NAME: self._device_name, - CONF_DEV_SERIAL: self._device_serial, - CONF_DEV_VERSION: self._device_version - }) + if await self._test_connection_senec(host_entry, False) or await self._test_connection_senec( + host_entry, True): + + a_data = {CONF_NAME: name, + CONF_HOST: host_entry, + CONF_USE_HTTPS: self._use_https, + CONF_SCAN_INTERVAL: scan, + CONF_TYPE: CONF_SYSTYPE_SENEC, + CONF_SUPPORT_STATS: self._stats_available, + CONF_DEV_TYPE_INT: self._device_type_internal, + CONF_DEV_TYPE: self._device_type, + CONF_DEV_NAME: self._device_name, + CONF_DEV_SERIAL: self._device_serial, + CONF_DEV_VERSION: self._device_version + } + + if not self._stats_available: + # we have to show the user, that he should add also WEB-API + _LOGGER.warning("Need WEB-API for full data...") + self._xdata = a_data; + self._xname = name; + return self.async_show_form( + step_id="optional_websetup_required_info", + last_step=False, + errors=self._errors + ) + else: + return self.async_create_entry(title=name, data=a_data) + else: _LOGGER.error( "Could not connect to via http or https to SENEC.Home at %s, check host ip address", @@ -277,35 +333,85 @@ async def async_step_system(self, user_input=None): ): int, } ), + last_step=True, errors=self._errors, ) - async def async_step_import(self, user_input=None): - """Import a config entry.""" - host_entry = user_input.get(CONF_HOST, DEFAULT_HOST) + async def async_step_websetup(self, user_input=None): + self._errors = {} + if user_input is not None: + # set some defaults in case we need to return to the form + name = user_input.get(CONF_NAME, DEFAULT_NAME_WEB) + scan = DEFAULT_SCAN_INTERVAL_WEB + user = user_input.get(CONF_USERNAME, DEFAULT_USERNAME) + pwd = user_input.get(CONF_PASSWORD, "") - if self._host_in_configuration_exists(host_entry): - return self.async_abort(reason="already_configured") - return await self.async_step_user(user_input) + if self._host_in_configuration_exists(user): + self._errors[CONF_USERNAME] = "already_configured" + else: + if await self._test_connection_webapi(user, pwd): + return self.async_create_entry(title=name, data={CONF_NAME: name, + CONF_HOST: user, + CONF_USERNAME: user, + CONF_PASSWORD: pwd, + CONF_SCAN_INTERVAL: scan, + CONF_TYPE: CONF_SYSTYPE_WEB, + CONF_DEV_TYPE_INT: self._device_type_internal, + CONF_DEV_TYPE: self._device_type, + CONF_DEV_NAME: self._device_name, + CONF_DEV_SERIAL: self._device_serial, + CONF_DEV_VERSION: self._device_version + }) + else: + _LOGGER.error("Could not connect to mein-senec.de with User '%s', check credentials", user) + self._errors[CONF_USERNAME] - @staticmethod - @callback - def async_get_options_flow(config_entry): - return SenecOptionsFlowHandler(config_entry) + else: + user_input = {} + user_input[CONF_NAME] = DEFAULT_NAME_WEB + user_input[CONF_USERNAME] = DEFAULT_USERNAME + user_input[CONF_PASSWORD] = "" - async def _show_config_form(self, user_input): # pylint: disable=unused-argument return self.async_show_form( - step_id="user", + step_id="websetup", data_schema=vol.Schema( { - vol.Required(CONF_NAME, default=DEFAULT_NAME): str, - vol.Required(CONF_HOST, default=DEFAULT_HOST): str, - vol.Required(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): int, + vol.Required( + CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME_WEB) + ): str, + vol.Required( + CONF_USERNAME, default=user_input.get(CONF_USERNAME, DEFAULT_USERNAME) + ): str, + vol.Required( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + ): str } ), + last_step=True, errors=self._errors, ) + async def async_step_optional_websetup_required_info(self, user_input=None): + return self.async_create_entry(title=self._xname, data=self._xdata) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + return SenecOptionsFlowHandler(config_entry) + + # async def _show_config_form(self, user_input): # pylint: disable=unused-argument + # return self.async_show_form( + # step_id="user", + # data_schema=vol.Schema( + # { + # vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + # vol.Required(CONF_HOST, default=DEFAULT_HOST): str, + # vol.Required(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): int, + # } + # ), + # errors=self._errors, + # ) + class SenecOptionsFlowHandler(config_entries.OptionsFlow): """Config flow options handler for waterkotte_heatpump.""" @@ -328,24 +434,42 @@ async def async_step_user(self, user_input=None): self.options.update(user_input) return await self._update_options() - dataSchema = vol.Schema( - { - vol.Required( - CONF_NAME, default=self.options.get(CONF_NAME, DEFAULT_NAME), - ): str, - vol.Required( - CONF_HOST, default=self.options.get(CONF_HOST, DEFAULT_HOST), - ): str, # pylint: disable=line-too-long - vol.Required( - CONF_SCAN_INTERVAL, default=self.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), - ): int, # pylint: disable=line-too-long - } - ) - - return self.async_show_form( - step_id="user", - data_schema=dataSchema, - ) + if CONF_TYPE in self.options and self.options[CONF_TYPE] == CONF_SYSTYPE_WEB: + dataSchema = vol.Schema( + { + vol.Required( + CONF_NAME, default=self.options.get(CONF_NAME, DEFAULT_NAME_WEB) + ): str, + vol.Required( + CONF_USERNAME, default=self.options.get(CONF_USERNAME, DEFAULT_USERNAME) + ): str, + vol.Required( + CONF_PASSWORD, default=self.options.get(CONF_PASSWORD, "") + ): str + } + ) + return self.async_show_form( + step_id="websetup", + data_schema=dataSchema, + ) + else: + dataSchema = vol.Schema( + { + vol.Required( + CONF_NAME, default=self.options.get(CONF_NAME, DEFAULT_NAME), + ): str, + vol.Required( + CONF_HOST, default=self.options.get(CONF_HOST, DEFAULT_HOST), + ): str, # pylint: disable=line-too-long + vol.Required( + CONF_SCAN_INTERVAL, default=self.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), + ): int, # pylint: disable=line-too-long + } + ) + return self.async_show_form( + step_id="user", + data_schema=dataSchema, + ) async def _update_options(self): """Update config entry options.""" diff --git a/custom_components/senec/const.py b/custom_components/senec/const.py index 87c2019..387612a 100644 --- a/custom_components/senec/const.py +++ b/custom_components/senec/const.py @@ -22,11 +22,11 @@ DOMAIN: Final = "senec" MANUFACTURE: Final = "SENEC GmbH" SYSTYPE_SENECV4: Final = "systype_senecv4" +SYSTYPE_WEBAPI: Final = "systype_webapi" SYSTYPE_SENECV3: Final = "systype_senecv3" SYSTYPE_SENECV2: Final = "systype_senecv2" SYSTYPE_INVERTV3: Final = "systype_invertv3" -#SYSTEM_TYPES: Final = [SYSTYPE_SENECV3, SYSTYPE_SENECV4, SYSTYPE_SENECV2, SYSTYPE_INVERTV3] -SYSTEM_TYPES: Final = [SYSTYPE_SENECV3, SYSTYPE_SENECV2, SYSTYPE_INVERTV3] +SYSTEM_TYPES: Final = [SYSTYPE_SENECV3, SYSTYPE_SENECV4, SYSTYPE_SENECV2, SYSTYPE_WEBAPI, SYSTYPE_INVERTV3] MODE_WEB: Final = "mode_web" MODE_LOCAL: Final = "mode_local" @@ -39,6 +39,7 @@ CONF_DEV_TYPE_INT: Final = "dtype_int" CONF_USE_HTTPS: Final = "use_https" CONF_SUPPORT_BDC: Final = "has_bdc_support" +CONF_SUPPORT_STATS: Final = "has_statistics" CONF_DEV_NAME: Final = "dname" CONF_DEV_SERIAL: Final = "dserial" CONF_DEV_VERSION: Final = "version" @@ -53,10 +54,14 @@ DEFAULT_MODE = MODE_LOCAL DEFAULT_HOST = "Senec" DEFAULT_HOST_INVERTER = "Inverter" +DEFAULT_USERNAME = "E-Mail" DEFAULT_NAME = "senec" DEFAULT_NAME_INVERTER = "Inverter" +DEFAULT_NAME_WEB = "senec_WEBAPI" DEFAULT_SCAN_INTERVAL = 30 DEFAULT_SCAN_INTERVAL_SENECV2 = 60 +# 5 minutes... [do not spam mein-senec.de] +DEFAULT_SCAN_INTERVAL_WEB = 300 @dataclass class ExtSensorEntityDescription(SensorEntityDescription): @@ -100,6 +105,122 @@ class ExtBinarySensorEntityDescription(BinarySensorEntityDescription): ), ] +WEB_SENSOR_TYPES = [ + + SensorEntityDescription( + key="consumption_total", + name="House consumed", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + icon="mdi:home-import-outline", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="powergenerated_total", + name="Solar generated", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + icon="mdi:solar-power", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="accuimport_total", + name="Battery charged", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + icon="mdi:home-battery", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="accuexport_total", + name="Battery discharged", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + icon="mdi:home-battery-outline", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="gridimport_total", + name="Grid Imported", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + icon="mdi:transmission-tower-import", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="gridexport_total", + name="Grid Exported", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + icon="mdi:transmission-tower-export", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + + #accuimport_today + #accuexport_today + #gridimport_today + #gridexport_today + #powergenerated_today + #consumption_today + + SensorEntityDescription( + key="powergenerated_now", + name="Solar Generated Power", + native_unit_of_measurement=POWER_WATT, + icon="mdi:solar-power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="consumption_now", + name="House Power", + native_unit_of_measurement=POWER_WATT, + icon="mdi:home-import-outline", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="accuimport_now", + name="Battery Charge Power", + native_unit_of_measurement=POWER_WATT, + icon="mdi:home-battery", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="accuexport_now", + name="Battery Discharge Power", + native_unit_of_measurement=POWER_WATT, + icon="mdi:home-battery-outline", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="acculevel_now", + name="Battery Charge Percent", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:home-battery", + # device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="gridimport_now", + name="Grid Imported Power", + native_unit_of_measurement=POWER_WATT, + icon="mdi:transmission-tower-import", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="gridexport_now", + name="Grid Exported Power", + native_unit_of_measurement=POWER_WATT, + icon="mdi:transmission-tower-export", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ) +] + """Supported main unit sensor types.""" MAIN_SENSOR_TYPES = [ ExtSensorEntityDescription( diff --git a/custom_components/senec/manifest.json b/custom_components/senec/manifest.json index 5eb5d4b..f69abdc 100644 --- a/custom_components/senec/manifest.json +++ b/custom_components/senec/manifest.json @@ -3,7 +3,8 @@ "name": "SENEC.Home", "codeowners": [ "@marq24", - "@mchwalisz" + "@mchwalisz", + "@mstuettgen" ], "config_flow": true, "dependencies": [], @@ -11,5 +12,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/marq24/ha-senec-v3/issues", "requirements": [], - "version": "3.0.5" + "version": "3.0.6" } diff --git a/custom_components/senec/pysenec_ha/__init__.py b/custom_components/senec/pysenec_ha/__init__.py index 50b6964..03719fd 100644 --- a/custom_components/senec/pysenec_ha/__init__.py +++ b/custom_components/senec/pysenec_ha/__init__.py @@ -6,6 +6,13 @@ from custom_components.senec.pysenec_ha.constants import SYSTEM_STATE_NAME, SYSTEM_TYPE_NAME, BATT_TYPE_NAME from custom_components.senec.pysenec_ha.util import parse +# required to patch the CookieJar of aiohttp - thanks for nothing! +import contextlib +from http.cookies import BaseCookie, SimpleCookie +from aiohttp.helpers import is_ip_address +from yarl import URL +from typing import Union + # 4: "INITIAL CHARGE", # 5: "MAINTENANCE CHARGE", # 8: "MAN. SAFETY CHARGE", @@ -27,6 +34,7 @@ _LOGGER = logging.getLogger(__name__) + class Senec: """Senec Home Battery Sensor""" @@ -93,13 +101,13 @@ async def read_version(self): "SYS_UPDATE": { "NPU_VER": "", "NPU_IMAGE_VERSION": "" - } + }, + "STATISTIC": {} } async with self.websession.post(self.url, json=form, ssl=False) as res: res.raise_for_status() self._raw = parse(await res.json()) - # print(self._raw) @property def system_state(self) -> str: @@ -852,12 +860,12 @@ def wallbox_energy(self) -> float: def fan_inv_lv(self) -> bool: if hasattr(self, '_raw') and "FAN_SPEED" in self._raw and "INV_LV" in self._raw["FAN_SPEED"]: return self._raw["FAN_SPEED"]["INV_LV"] + @property def fan_inv_hv(self) -> bool: if hasattr(self, '_raw') and "FAN_SPEED" in self._raw and "INV_HV" in self._raw["FAN_SPEED"]: return self._raw["FAN_SPEED"]["INV_HV"] - async def update(self): await self.read_senec_v31() @@ -929,7 +937,7 @@ async def read_senec_v31(self): "L3_CHARGING_CURRENT": "", "EV_CONNECTED": "" }, - "FAN_SPEED":{}, + "FAN_SPEED": {}, } async with self.websession.post(self.url, json=form, ssl=False) as res: @@ -1329,3 +1337,393 @@ def ownconsumedpower(self) -> float: def derating(self) -> float: if (hasattr(self, '_derating')): return self._derating + + +class MySenecWebPortal: + + def __init__(self, user, pwd, websession): + loop = aiohttp.helpers.get_running_loop(websession.loop) + senec_jar = MySenecCookieJar(loop=loop); + if hasattr(websession, "_cookie_jar"): + oldJar = getattr(websession, "_cookie_jar") + senec_jar.update_cookies(oldJar._host_only_cookies) + + self.websession: aiohttp.websession = websession + setattr(self.websession, "_cookie_jar", senec_jar) + + # SENEC API + self._SENEC_USERNAME = user + self._SENEC_PASSWORD = pwd + + # https://documenter.getpostman.com/view/10329335/UVCB9ihW#17e2c6c6-fe5e-4ca9-bc2f-dca997adaf90 + self._SENEC_CLASSIC_AUTH_URL = "https://app-gateway-prod.senecops.com/v1/senec/login" + self._SENEC_CLASSIC_API_OVERVIEW_URL = "https://app-gateway-prod.senecops.com/v1/senec/anlagen" + + self._SENEC_AUTH_URL = "https://mein-senec.de/auth/login" + self._SENEC_API_CONTEXT1_URL = "https://mein-senec.de/endkunde/api/context/getEndkunde" + self._SENEC_API_CONTEXT2_URL = "https://mein-senec.de/endkunde/api/context/getAnlageBasedNavigationViewModel?anlageNummer=0" + self._SENEC_API_OVERVIEW_URL = "https://mein-senec.de/endkunde/api/status/getstatusoverview.php?anlageNummer=0" + self._SENEC_API_URL_START = "https://mein-senec.de/endkunde/api/status/getstatus.php?type=" + self._SENEC_API_URL_END = "&period=all&anlageNummer=0" + + # can be used in all api calls, names come from senec website + self._API_KEYS = [ + "accuimport", # what comes OUT OF the accu + "accuexport", # what goes INTO the accu + "gridimport", # what comes OUT OF the grid + "gridexport", # what goes INTO the grid + "powergenerated", # power produced + "consumption" # power used + ] + + # can only be used in some api calls, names come from senec website + self._API_KEYS_EXTRA = [ + "acculevel" # accu level + ] + + # WEBDATA STORAGE + self._energy_entities = {} + self._power_entities = {} + self._battery_entities = {} + self._isAuthenticated = False + + async def authenticateClassic(self, doUpdate: bool): + auth_payload = { + "username": self._SENEC_USERNAME, + "password": self._SENEC_PASSWORD + } + async with self.websession.post(self._SENEC_CLASSIC_AUTH_URL, json=auth_payload) as res: + res.raise_for_status() + if res.status == 200: + r_json = await res.json() + if "token" in r_json: + self._token = r_json["token"] + self._isAuthenticated = True + _LOGGER.info("Login successful") + if doUpdate: + self.updateClassic() + else: + _LOGGER.warning("Login failed with Code " + str(res.status)) + + async def updateClassic(self): + _LOGGER.debug("***** updateClassic(self) ********") + if self._isAuthenticated: + await self.getSystemOverviewClassic() + else: + await self.authenticateClassic(True) + + async def getSystemOverviewClassic(self): + headers = {"Authorization": self._token} + async with self.websession.get(self._SENEC_CLASSIC_API_OVERVIEW_URL, headers=headers) as res: + res.raise_for_status() + if res.status == 200: + r_json = await res.json() + else: + self._isAuthenticated = False + await self.update() + + async def authenticate(self, doUpdate: bool): + _LOGGER.info("***** authenticate(self) ********") + auth_payload = { + "username": self._SENEC_USERNAME, + "password": self._SENEC_PASSWORD + } + async with self.websession.post(self._SENEC_AUTH_URL, data=auth_payload, max_redirects=20) as res: + res.raise_for_status() + if res.status == 200: + # be gentle reading the complete response... + r_json = await res.text() + self._isAuthenticated = True + _LOGGER.info("Login successful") + if doUpdate: + await self.update() + else: + _LOGGER.warning("Login failed with Code " + str(res.status)) + + async def update(self): + if self._isAuthenticated: + _LOGGER.info("***** update(self) ********") + await self.update_now_kW_stats() + await self.update_full_kWh_stats() + else: + await self.authenticate(doUpdate=True) + + async def update_now_kW_stats(self): + _LOGGER.debug("***** update_now_kW_stats(self) ********") + + # grab NOW and TODAY stats + async with self.websession.get(self._SENEC_API_OVERVIEW_URL) as res: + res.raise_for_status() + if res.status == 200: + r_json = await res.json() + self._raw = parse(r_json) + for key in (self._API_KEYS + self._API_KEYS_EXTRA): + if (key != "acculevel"): + value_now = r_json[key]["now"] + entity_now_name = str(key + "_now") + self._power_entities[entity_now_name] = value_now + + value_today = r_json[key]["today"] + entity_today_name = str(key + "_today") + self._energy_entities[entity_today_name] = value_today + else: + value_now = r_json[key]["now"] + entity_now_name = str(key + "_now") + self._battery_entities[entity_now_name] = value_now + # value_today = r_json[key]["today"] + # entity_today_name = str(key + "_today") + # self._battery_entities[entity_today_name]=value_today + else: + self._isAuthenticated = False + await self.update() + + async def update_full_kWh_stats(self): + # grab TOTAL stats + for key in self._API_KEYS: + api_url = self._SENEC_API_URL_START + key + self._SENEC_API_URL_END + async with self.websession.get(api_url) as res: + res.raise_for_status() + if res.status == 200: + r_json = await res.json() + value = r_json["fullkwh"] + entity_name = str(key + "_total") + self._energy_entities[entity_name] = value + else: + self._isAuthenticated = False + await self.update() + + async def update_context(self): + _LOGGER.debug("***** update_context(self) ********") + if self._isAuthenticated: + await self.update_context_1() + await self.update_context_2() + else: + await self.authenticate(doUpdate=False) + + async def update_context_1(self): + _LOGGER.debug("***** update_context_1(self) ********") + + # grab NOW and TODAY stats + async with self.websession.get(self._SENEC_API_CONTEXT1_URL) as res: + res.raise_for_status() + if res.status == 200: + r_json = await res.json() + # self._raw = parse(r_json) + self._dev_number = r_json["devNumber"] + # anzahlAnlagen + # language + # emailAdresse + # meterReadingVisible + # vorname + # nachname + else: + self._isAuthenticated = False + await self.authenticate(doUpdate=False) + + async def update_context_2(self): + _LOGGER.debug("***** update_context_2(self) ********") + + # grab NOW and TODAY stats + async with self.websession.get(self._SENEC_API_CONTEXT2_URL) as res: + res.raise_for_status() + if res.status == 200: + r_json = await res.json() + self._serial_number = r_json["steuereinheitnummer"] + self._product_name = r_json["produktName"] + self._zone_id = r_json["zoneId"] + else: + self._isAuthenticated = False + await self.authenticate(doUpdate=False) + + @property + def senec_num(self) -> str: + if hasattr(self, '_dev_number'): + return str(self._dev_number) + + @property + def serial_number(self) -> str: + if hasattr(self, '_serial_number'): + return str(self._serial_number) + + @property + def product_name(self) -> str: + if hasattr(self, '_product_name'): + return str(self._product_name) + + @property + def zone_id(self) -> str: + if hasattr(self, '_zone_id'): + return str(self._zone_id) + + @property + def firmwareVersion(self) -> str: + if hasattr(self, '_raw') and "firmwareVersion" in self._raw: + return str(self._raw["firmwareVersion"]) + + @property + def accuimport_today(self) -> float: + if hasattr(self, '_energy_entities') and "accuimport_today" in self._energy_entities: + return self._energy_entities["accuimport_today"] + + @property + def accuexport_today(self) -> float: + if hasattr(self, '_energy_entities') and "accuexport_today" in self._energy_entities: + return self._energy_entities["accuexport_today"] + + @property + def gridimport_today(self) -> float: + if hasattr(self, '_energy_entities') and "gridimport_today" in self._energy_entities: + return self._energy_entities["gridimport_today"] + + @property + def gridexport_today(self) -> float: + if hasattr(self, '_energy_entities') and "gridexport_today" in self._energy_entities: + return self._energy_entities["gridexport_today"] + + @property + def powergenerated_today(self) -> float: + if hasattr(self, '_energy_entities') and "powergenerated_today" in self._energy_entities: + return self._energy_entities["powergenerated_today"] + + @property + def consumption_today(self) -> float: + if hasattr(self, '_energy_entities') and "consumption_today" in self._energy_entities: + return self._energy_entities["consumption_today"] + + @property + def accuimport_total(self) -> float: + if hasattr(self, '_energy_entities') and "accuimport_total" in self._energy_entities: + return self._energy_entities["accuimport_total"] + + @property + def accuexport_total(self) -> float: + if hasattr(self, '_energy_entities') and "accuexport_total" in self._energy_entities: + return self._energy_entities["accuexport_total"] + + @property + def gridimport_total(self) -> float: + if hasattr(self, '_energy_entities') and "gridimport_total" in self._energy_entities: + return self._energy_entities["gridimport_total"] + + @property + def gridexport_total(self) -> float: + if hasattr(self, '_energy_entities') and "gridexport_total" in self._energy_entities: + return self._energy_entities["gridexport_total"] + + @property + def powergenerated_total(self) -> float: + if hasattr(self, '_energy_entities') and "powergenerated_total" in self._energy_entities: + return self._energy_entities["powergenerated_total"] + + @property + def consumption_total(self) -> float: + if hasattr(self, '_energy_entities') and "consumption_total" in self._energy_entities: + return self._energy_entities["consumption_total"] + + @property + def accuimport_now(self) -> float: + if hasattr(self, "_power_entities") and "accuimport_now" in self._power_entities: + return self._power_entities["accuimport_now"] + + @property + def accuexport_now(self) -> float: + if hasattr(self, "_power_entities") and "accuexport_now" in self._power_entities: + return self._power_entities["accuexport_now"] + + @property + def gridimport_now(self) -> float: + if hasattr(self, "_power_entities") and "gridimport_now" in self._power_entities: + return self._power_entities["gridimport_now"] + + @property + def gridexport_now(self) -> float: + if hasattr(self, "_power_entities") and "gridexport_now" in self._power_entities: + return self._power_entities["gridexport_now"] + + @property + def powergenerated_now(self) -> float: + if hasattr(self, "_power_entities") and "powergenerated_now" in self._power_entities: + return self._power_entities["powergenerated_now"] + + @property + def consumption_now(self) -> float: + if hasattr(self, "_power_entities") and "consumption_now" in self._power_entities: + return self._power_entities["consumption_now"] + + @property + def acculevel_now(self) -> int: + if hasattr(self, "_battery_entities") and "acculevel_now" in self._battery_entities: + return self._battery_entities["acculevel_now"] + + +class MySenecCookieJar(aiohttp.CookieJar): + # Overwriting the default 'filter_cookies' impl - since the original will always return the last stored + # matching path... [but we need the 'best' path-matching cookie of our jar!] + def filter_cookies(self, request_url: URL = URL()) -> Union["BaseCookie[str]", "SimpleCookie[str]"]: + """Returns this jar's cookies filtered by their attributes.""" + self._do_expiration() + request_url = URL(request_url) + filtered: Union["SimpleCookie[str]", "BaseCookie[str]"] = ( + SimpleCookie() if self._quote_cookie else BaseCookie() + ) + hostname = request_url.raw_host or "" + request_origin = URL() + with contextlib.suppress(ValueError): + request_origin = request_url.origin() + + is_not_secure = ( + request_url.scheme not in ("https", "wss") + and request_origin not in self._treat_as_secure_origin + ) + + for cookie in self: + name = cookie.key + domain = cookie["domain"] + + # Send shared cookies + if not domain: + filtered[name] = cookie.value + continue + + if not self._unsafe and is_ip_address(hostname): + continue + + if (domain, name) in self._host_only_cookies: + if domain != hostname: + continue + elif not self._is_domain_match(domain, hostname): + continue + + if not self._is_path_match(request_url.path, cookie["path"]): + continue + + if is_not_secure and cookie["secure"]: + continue + + # MARQ24: Removed - since we need to keep the ORIGINAL COOKIE because we need access to the + # path... + + # It's critical we use the Morsel so the coded_value + # (based on cookie version) is preserved + # mrsl_val = cast("Morsel[str]", cookie.get(cookie.key, Morsel())) + # mrsl_val.set(cookie.key, cookie.value, cookie.coded_value) + # filtered[name] = mrsl_val + + # we need to check, if the found cookie is a better match then the + # already existing... + if name in filtered: + existing_cookie = filtered[name] + if len(existing_cookie.get('path')) < len(cookie.get('path')): + filtered[name] = cookie + else: + filtered[name] = cookie + + # MARQ24: + # do we need to convert the cookie finally back into a Morsel...? + # if "JSESSIONID" in filtered: + # cookie = filtered["JSESSIONID"] + # mrsl_val = cast("Morsel[str]", cookie.get(cookie.key, Morsel())) + # mrsl_val.set(cookie.key, cookie.value, cookie.coded_value) + # filtered["JSESSIONID"] = mrsl_val + + return filtered diff --git a/custom_components/senec/sensor.py b/custom_components/senec/sensor.py index b00c67e..c842ce1 100644 --- a/custom_components/senec/sensor.py +++ b/custom_components/senec/sensor.py @@ -8,7 +8,16 @@ from homeassistant.const import CONF_TYPE from . import SenecDataUpdateCoordinator, SenecEntity -from .const import DOMAIN, MAIN_SENSOR_TYPES, INVERTER_SENSOR_TYPES, CONF_SUPPORT_BDC, CONF_SYSTYPE_INVERTER +from .const import ( + DOMAIN, + MAIN_SENSOR_TYPES, + INVERTER_SENSOR_TYPES, + WEB_SENSOR_TYPES, + CONF_SUPPORT_BDC, + CONF_SUPPORT_STATS, + CONF_SYSTYPE_INVERTER, + CONF_SYSTYPE_WEB +) _LOGGER = logging.getLogger(__name__) @@ -30,10 +39,22 @@ async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry, if addEntity: entity = SenecSensor(coordinator, description) entities.append(entity) - else: - for description in MAIN_SENSOR_TYPES: + if CONF_TYPE in config_entry.data and config_entry.data[CONF_TYPE] == CONF_SYSTYPE_WEB: + for description in WEB_SENSOR_TYPES: entity = SenecSensor(coordinator, description) entities.append(entity) + else: + for description in MAIN_SENSOR_TYPES: + addEntity = description.controls is None + if not addEntity: + if 'require_stats_fields' in description.controls: + if CONF_SUPPORT_STATS not in config_entry.data or config_entry.data[CONF_SUPPORT_STATS]: + addEntity = True + else: + addEntity = True + if addEntity: + entity = SenecSensor(coordinator, description) + entities.append(entity) async_add_entities(entities) @@ -64,6 +85,7 @@ def state(self): """Return the current state.""" sensor = self.entity_description.key value = getattr(self.coordinator.senec, sensor) + #_LOGGER.debug( str(sensor)+' '+ str(type(value)) +' '+str(value)) if type(value) != type(False): try: rounded_value = round(float(value), 2) diff --git a/custom_components/senec/switch.py b/custom_components/senec/switch.py index c7f2de1..24c1be8 100644 --- a/custom_components/senec/switch.py +++ b/custom_components/senec/switch.py @@ -9,7 +9,7 @@ from typing import Literal from . import SenecDataUpdateCoordinator, SenecEntity -from .const import DOMAIN, MAIN_SWITCH_TYPES, CONF_SYSTYPE_INVERTER +from .const import DOMAIN, MAIN_SWITCH_TYPES, CONF_SYSTYPE_INVERTER, CONF_SYSTYPE_WEB _LOGGER = logging.getLogger(__name__) @@ -19,6 +19,8 @@ async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry, coordinator = hass.data[DOMAIN][config_entry.entry_id] if (CONF_TYPE in config_entry.data and config_entry.data[CONF_TYPE] == CONF_SYSTYPE_INVERTER): _LOGGER.info("No switches for Inverters...") + if (CONF_TYPE in config_entry.data and config_entry.data[CONF_TYPE] == CONF_SYSTYPE_WEB): + _LOGGER.info("No switches for WebPortal...") else: entities = [] for description in MAIN_SWITCH_TYPES: diff --git a/custom_components/senec/translations/de.json b/custom_components/senec/translations/de.json index 1277c72..4f92294 100644 --- a/custom_components/senec/translations/de.json +++ b/custom_components/senec/translations/de.json @@ -5,7 +5,8 @@ "systype_senecv4": "SENEC.Home V4/SENEC.Home V4 hybrid", "systype_senecv3": "SENEC.Home V3 hybrid/SENEC.Home V3 hybrid duo", "systype_senecv2": "SENEC.Home V2.1 oder älteres System", - "systype_invertv3": "Wechselrichter verbaut im SENEC.Home V3 hybrid/hybrid duo" + "systype_webapi": "WEB.API: mein-senec.de Portal (für alle SENEC.Home Varianten einsetzbar)", + "systype_invertv3": "Interner im SENEC.Home V3 hybrid/hybrid duo verbauter Wechselrichter" } }, "smode": { @@ -20,12 +21,13 @@ "already_configured": "Integration ist bereits eingereichtet" }, "error": { + "login_failed": "Login failed", "cannot_connect": "Keine Verbindung möglich", "unknown": "Unbekannter Fehler" }, "step": { "user": { - "description": "Bitte wähle die Version Deines SENEC.Home Systems das Du einbinden möchtest - Zusätzliche Informationen zur Einrichtung findest Du hier: https://github.com/marq24/ha-senec-v3", + "description": "Bitte wähle die Version Deines SENEC.Home Systems das Du einbinden möchtest.\n\n\nZusätzliche Informationen zur Einrichtung findest Du hier: https://github.com/marq24/ha-senec-v3", "data": { "stype": "System Version wählen" } @@ -43,6 +45,19 @@ "host": "IP oder Hostname des SENEC.Home V3 Systems bzw des Inverters", "scan_interval":"Aktualisierungsintervall in Sekunden" } + }, + "websetup": { + "description": "Bitte gib Deine mein-senec.de-Zugangsdaten ein, um Home Assistant mit Deinem mein-senec.de-Konto zu verbinden.\n\n\nBitte beachte, dass dies derzeit die einzige Option für SENEC.Home V4-Besitzer ist Daten in Home Assistent einzubinden!\n\n\nWenn Du Hilfe benötigst, findest du sie hier: https://github.com/marq24/ha-senec-v3", + "data": { + "name": "Anzeige Name", + "username": "Dein 'mein-senec.de' Benutzername (E-Mail)", + "password": "Dein 'mein-senec.de' Passwort", + "scan_interval":"Aktualisierungsintervall in Sekunden" + } + }, + "optional_websetup_required_info": { + "title": "Bitte beachte!", + "description": "Dein SENEC.Home V2/V3-System hat leider keine Langzeit-Statistik-Daten bereitgestellt (z.b. 'Gesamtmenge Selbst erzeugter Strom').\n\n\nDu hast die Möglichkeit mit dieser Integration einen zusätzlichen 'WEB.API: mein-senec.de Portal' Integrationseintrag hinzuzufügen, um diese fehlenden Daten vom Mein-SENEC.Webportal zu beziehen.\n\n\nDie Verwendung der WebAPI ist aber nur eine Option! Alternativ können diese Daten auch über die Home Assistent 'Riemann-Integral' oder 'PowerCalc' Platformen selbst errechnet werden. mehr hierzu findest Du unter https://github.com/marq24/ha-senec-v3/blob/master/STATSENSORS.md" } } }, @@ -55,6 +70,15 @@ "host": "IP oder Hostname des SENEC.Home V3 Systems bzw des Inverters", "scan_interval":"Aktualisierungsintervall in Sekunden" } + }, + "websetup": { + "description": "MEIN-SENEC.DE Webzugang", + "data": { + "name": "Anzeige Name", + "username": "Dein 'mein-senec.de' Benutzername (E-Mail)", + "password": "Dein 'mein-senec.de' Passwort", + "scan_interval":"Aktualisierungsintervall in Sekunden" + } } } } diff --git a/custom_components/senec/translations/en.json b/custom_components/senec/translations/en.json index d629dad..f419036 100644 --- a/custom_components/senec/translations/en.json +++ b/custom_components/senec/translations/en.json @@ -5,7 +5,8 @@ "systype_senecv4": "SENEC.Home V4/SENEC.Home V4 hybrid", "systype_senecv3": "SENEC.Home V3 hybrid/SENEC.Home V3 hybrid duo", "systype_senecv2": "SENEC.Home V2.1 or older", - "systype_invertv3": "Inverter build into SENEC.Home V3 hybrid/hybrid duo" + "systype_webapi": "WEB.API: mein-senec.de Portal (usable with all SENEC.Home variants)", + "systype_invertv3": "Internal inverter build into SENEC.Home V3 hybrid/hybrid duo" } }, "smode": { @@ -20,12 +21,13 @@ "already_configured": "Integration is already configured" }, "error": { + "login_failed": "Login failed", "cannot_connect": "Failed to connect", "unknown": "Unknown error" }, "step": { "user": { - "description": "Please select the version of your SENEC.Home System you would like to add - If you need help with the configuration have a look at: https://github.com/marq24/ha-senec-v3", + "description": "Please select the version of your SENEC.Home System you would like to add.\n\n\nIf you need help with the configuration have a look at: https://github.com/marq24/ha-senec-v3", "data": { "stype": "Select System type" } @@ -43,6 +45,19 @@ "host": "IP or hostname of the SENEC.Home V3 System OR the Inverter", "scan_interval":"Polling Interval in seconds" } + }, + "websetup": { + "description": "Please provide your mein-senec.de login credentials in order to connect home assistant with your mein-senec.de account.\n\n\nPlease note, that currently this is the only option for SENEC.Home V4 Users - Sorry!\n\n\nIf you need help with the configuration have a look here: https://github.com/marq24/ha-senec-v3", + "data": { + "name": "Display name", + "username": "Your 'mein-senec.de' username", + "password": "Your 'mein-senec.de' password", + "scan_interval":"Polling Interval in seconds" + } + }, + "optional_websetup_required_info": { + "title": "Important !", + "description": "Your SENEC.Home V2/V3 System did not provide any long term statistic data (like total generated solar power).\n\n\nWith this integration you have the option to add an additional 'WEB.API: mein-senec.de Portal' integration entry in order to collect this data from the MySENEC.WebPortal.\n\n\nHowever, using the WebAPI is only one option! Alternatively, this data can also be calculated using the Home Assistant 'Riemann sum integral' or 'PowerCalc' platforms. You can find more about this at https://github.com/marq24/ha-senec-v3/blob/master/STATSENSORS.md" } } }, @@ -55,6 +70,15 @@ "host": "IP or hostname of the SENEC.Home V3 System OR the Inverter", "scan_interval":"Polling Interval in seconds" } + }, + "websetup": { + "description": "MEIN-SENEC.DE WebAccess", + "data": { + "name": "Display name", + "username": "Your 'mein-senec.de' username", + "password": "Your 'mein-senec.de' password", + "scan_interval":"Polling Interval in seconds" + } } } } diff --git a/hacs.json b/hacs.json index 6fef1db..853e4f2 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,5 @@ { - "name": "SENEC.Home V3 sensor", + "name": "SENEC.Home V2.x/V3/V4 Systems", "country": [ "ALL" ],