From 00db8e7cdd60c1c36397d8f1426d75d568184450 Mon Sep 17 00:00:00 2001 From: stefan Date: Fri, 27 Dec 2024 00:38:07 +0100 Subject: [PATCH] Configuration overhaul - Moved "options" to basic configuration - `scan_interval` and `debug_mode`can now be set under the (...) menu using the "Reconfigure" option - Made configuration editable by providing `async_setup_reconfigure` function (see above) - Added serial number to device in integration overview - Disabled `OptionsFlowHandler` class since there are no more options ;) - Disabled some functions that were never called - Reworked config reload to be compatible with HA 2025.1 onward - Added/removed translations where appropriate - Removed unused constants - Fixed a bug that caused the sensors to be read every second thereby causing unnecessary load Signed-off-by: stefan --- README.md | 30 +-- custom_components/sonnenbatterie/__init__.py | 42 ++- .../sonnenbatterie/config_flow.py | 249 +++++++++++------- custom_components/sonnenbatterie/const.py | 52 ++-- .../sonnenbatterie/coordinator.py | 2 +- custom_components/sonnenbatterie/sensor.py | 44 ++-- .../sonnenbatterie/translations/de.json | 23 +- .../sonnenbatterie/translations/en.json | 21 +- 8 files changed, 248 insertions(+), 215 deletions(-) diff --git a/README.md b/README.md index 6caf349..52349c4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # ha_sonnenbatterie Homeassistant integration to show many stats of Sonnenbatterie - -Should work with current versions of Sonnenbatterie. +that should work with current versions of Sonnenbatterie. [![Validate with hassfest](https://github.com/weltmeyer/ha_sonnenbatterie/actions/workflows/hassfest.yaml/badge.svg)](https://github.com/weltmeyer/ha_sonnenbatterie/actions/workflows/hassfest.yaml) @@ -14,37 +13,26 @@ Should work with current versions of Sonnenbatterie. * ex. model 9.2 eco from 2014 not working ## Important: ### -Set the update interval in the Integration Settings. Default is 10 seconds, which may kill your recorder database +Set the update interval in the Integration Settings. Default is 30 seconds, don't +go below 10 seconds otherwise you might encounter an exploding recorder database. -### Unused/Unavailable sensors +### Problems and/or Unused/Unavailable sensors Depending on the software on and the oparting mode of your Sonnenbatterie some values may not be available. The integration does it's best to detect the absence of these values. If a value isn't returned by your Sonnenbatterie you will see entries like the following in your log: -``` -… WARNING (Thread-1 (watcher)) [custom_components.sonnenbatterie] No 'ppv2' in inverter -> sensor disabled -… WARNING (Thread-1 (watcher)) [custom_components.sonnenbatterie] No 'ipv' in inverter -> sensor disabled -… WARNING (Thread-1 (watcher)) [custom_components.sonnenbatterie] No 'ipv2' in inverter -> sensor disabled -… WARNING (Thread-1 (watcher)) [custom_components.sonnenbatterie] No 'upv' in inverter -> sensor disabled -… WARNING (Thread-1 (watcher)) [custom_components.sonnenbatterie] No 'upv2' in inverter -> sensor disabled -``` - -Those aren't errors! There's nothing to worry about. This just tells you that -your Sonnenbatterie doesn't provide these values. - If you feel that your Sonnenbatterie **should** provide one or more of those you can enable the "debug_mode" from -_Settings -> Devices & Services -> Integrations -> Sonnenbatterie_ +_Settings -> Devices & Services -> Integrations -> Sonnenbatterie -> (...) -> Reconfigure_ -Just enable the "Debug mode" and restart your HomeAssistant instance. You'll get -the full data that's returned by your Sonnenbatterie in the logs. Please put those -logs along with the setting you want monitored into a new issue. +Just enable the "Debug mode" and watch the logs of your HomeAssistant instance. +You'll get the full data that's returned by your Sonnenbatterie in the logs. +Please put those logs along with the setting you want monitored into a new issue. ## Install -Easiest way is to add this repository to hacs. - +Easiest way is to add this repository is to use [HACS](https://hacs.xyz). ## Screenshots :) ![image](https://user-images.githubusercontent.com/1668465/78452159-ed2d7d80-7689-11ea-9e30-3a66ecc2372a.png) diff --git a/custom_components/sonnenbatterie/__init__.py b/custom_components/sonnenbatterie/__init__.py index 39837c0..a5b1a96 100644 --- a/custom_components/sonnenbatterie/__init__.py +++ b/custom_components/sonnenbatterie/__init__.py @@ -1,48 +1,44 @@ """The Sonnenbatterie integration.""" -from .const import * import json -# from homeassistant.helpers.entity import Entity from homeassistant.const import ( - CONF_SCAN_INTERVAL, Platform ) +from .const import * -async def async_setup(hass, config): - hass.data.setdefault(DOMAIN, {}) - """Set up a skeleton component.""" - # if DOMAIN not in config: - # hass.states.async_set('sonnenbatterie.test', 'Works!') - # return True - # hass.states.async_set('sonnenbatterie.test', 'Works!') - return True +# rustydust_241227: this doesn't seem to be needed - kept until we're sure ;) +# async def async_setup(hass, config): +# """Set up a skeleton component.""" +# hass.data.setdefault(DOMAIN, {}) +# return True async def async_setup_entry(hass, config_entry): - LOGGER.info("setup_entry: " + json.dumps(dict(config_entry.data))) - + LOGGER.debug("setup_entry: " + json.dumps(dict(config_entry.data))) await hass.config_entries.async_forward_entry_setups(config_entry, [ Platform.SENSOR ]) - config_entry.add_update_listener(update_listener) + # rustydust_241227: this doesn't seem to be needed + # config_entry.add_update_listener(update_listener) config_entry.async_on_unload(config_entry.add_update_listener(async_reload_entry)) return True async def async_reload_entry(hass, entry): - """Reload config entry.""" - await async_unload_entry(hass, entry) - await async_setup_entry(hass, entry) + """Reload config entry.""" + await async_unload_entry(hass, entry) + await async_setup_entry(hass, entry) -async def update_listener(hass, entry): - LOGGER.info("Update listener" + json.dumps(dict(entry.options))) - hass.data[DOMAIN][entry.entry_id]["monitor"].update_interval_seconds = ( - entry.options.get(CONF_SCAN_INTERVAL) - ) +# rustydust_241227: this doesn't seem to be needed +# async def update_listener(hass, entry): +# LOGGER.warning("Update listener" + json.dumps(dict(entry.options))) +# # hass.data[DOMAIN][entry.entry_id]["monitor"].update_interval_seconds = ( +# # entry.options.get(CONF_SCAN_INTERVAL) +# # ) async def async_unload_entry(hass, entry): """Handle removal of an entry.""" - return await hass.config_entries.async_forward_entry_unload(entry, "sensor") + return await hass.config_entries.async_forward_entry_unload(entry, Platform.SENSOR) diff --git a/custom_components/sonnenbatterie/config_flow.py b/custom_components/sonnenbatterie/config_flow.py index b8385a5..3371739 100644 --- a/custom_components/sonnenbatterie/config_flow.py +++ b/custom_components/sonnenbatterie/config_flow.py @@ -4,125 +4,188 @@ # pylint: enable=no-name-in-module import traceback -# import logging -# import voluptuous as vol -from homeassistant import config_entries, core +from homeassistant import config_entries +from homeassistant.const import ( + CONF_IP_ADDRESS, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv # pylint: disable=unused-wildcard-import -from .const import * # +from .const import * # pylint: enable=unused-wildcard-import import voluptuous as vol -from homeassistant.const import ( - CONF_PASSWORD, - CONF_USERNAME, - CONF_IP_ADDRESS, - CONF_SCAN_INTERVAL, -) class SonnenbatterieFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): - def __init__(self): - """Initialize.""" - self.data_schema = CONFIG_SCHEMA_A + + CONFIG_SCHEMA_USER = vol.Schema( + { + vol.Required(CONF_IP_ADDRESS, default="127.0.0.1"): str, + vol.Required(CONF_USERNAME): vol.In(["User", "Installer"]), + vol.Required(CONF_PASSWORD, default="sonnenUser3552"): str, + vol.Required(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): cv.positive_int, + vol.Optional(ATTR_SONNEN_DEBUG, default=DEFAULT_SONNEN_DEBUG): cv.boolean, + } + ) async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" - # if self._async_current_entries(): - # return self.async_abort(reason="single_instance_allowed") - - if not user_input: - return self._show_form() - - username = user_input[CONF_USERNAME] - password = user_input[CONF_PASSWORD] - ipaddress = user_input[CONF_IP_ADDRESS] - - try: - - def _internal_setup(_username, _password, _ipaddress): - return sonnenbatterie(_username, _password, _ipaddress) + if user_input is not None: - sonnen_inst = await self.hass.async_add_executor_job( - _internal_setup, username, password, ipaddress + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + ipaddress = user_input[CONF_IP_ADDRESS] + + # noinspection PyBroadException + try: + my_serial = await self.hass.async_add_executor_job( + self._internal_setup, username, password, ipaddress + ) + + except: + e = traceback.format_exc() + LOGGER.error(f"Unable to connect to sonnenbatterie: {e}") + return self._show_form({"base": "connection_error"}) + + return self.async_create_entry( + title=f"{user_input[CONF_IP_ADDRESS]} ({my_serial})", + data={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_IP_ADDRESS: ipaddress, + CONF_SCAN_INTERVAL: user_input[CONF_SCAN_INTERVAL], + ATTR_SONNEN_DEBUG: user_input[ATTR_SONNEN_DEBUG], + CONF_SERIAL_NUMBER: my_serial, + }, ) - # sonnenbatterie(username,password,ipaddress) - # await self.hass.async_add_executor_job( - # Abode, username, password, True, True, True, cache - # ) - - except: - e = traceback.format_exc() - LOGGER.error("Unable to connect to sonnenbatterie: %s", e) - # if ex.errcode == 400: - # return self._show_form({"base": "invalid_credentials"}) - return self._show_form({"base": "connection_error"}) - - return self.async_create_entry( - title=user_input[CONF_IP_ADDRESS], - data={ - CONF_USERNAME: username, - CONF_PASSWORD: password, - CONF_IP_ADDRESS: ipaddress, - }, - ) - @callback - def _show_form(self, errors=None): - """Show the form to the user.""" return self.async_show_form( step_id="user", - data_schema=self.data_schema, - errors=errors if errors else {}, + data_schema=self.CONFIG_SCHEMA_USER, + last_step=True, ) - async def async_step_import(self, import_config): - """Import a config entry from configuration.yaml.""" - # if self._async_current_entries(): - # LOGGER.warning("Only one configuration of abode is allowed.") - # return self.async_abort(reason="single_instance_allowed") + async def async_step_reconfigure(self, user_input): + entry = self._get_reconfigure_entry() + schema_reconf = vol.Schema( + { + vol.Required( + CONF_IP_ADDRESS, + default = entry.data[CONF_IP_ADDRESS] or None + ): str, + vol.Required( + CONF_USERNAME, + default = entry.data[CONF_USERNAME] or "User" + ): vol.In(["User", "Installer"]), + vol.Required( + CONF_PASSWORD, + default = entry.data[CONF_PASSWORD] or "" + ): str, + vol.Required( + CONF_SCAN_INTERVAL, + default=entry.data[CONF_SCAN_INTERVAL] or DEFAULT_SCAN_INTERVAL + ): cv.positive_int, + vol.Optional( + ATTR_SONNEN_DEBUG, + default=entry.data[ATTR_SONNEN_DEBUG] or DEFAULT_SONNEN_DEBUG) + : cv.boolean, + } + ) - return await self.async_step_user(import_config) + if user_input is not None: + await self.async_set_unique_id(entry.data[CONF_SERIAL_NUMBER]) + self._abort_if_unique_id_configured() + # noinspection PyBroadException + try: + my_serial = await self.hass.async_add_executor_job( + self._internal_setup, + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + user_input[CONF_IP_ADDRESS] + ) + + except: + e = traceback.format_exc() + LOGGER.error(f"Unable to connect to sonnenbatterie: {e}") + return self.async_show_form( + step_id="reconfigure", + data_schema=schema_reconf, + errors={"base": "connection_error"}, + ) + + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=user_input, + title=f"{user_input[CONF_IP_ADDRESS]} ({my_serial})", + ) - @staticmethod - @callback - def async_get_options_flow(config_entry): - return OptionsFlowHandler(config_entry) + return self.async_show_form( + step_id = "reconfigure", + data_schema = schema_reconf, + ) -class OptionsFlowHandler(config_entries.OptionsFlow): - def __init__(self, config_entry): - """Initialize options flow.""" - self.config_entry = config_entry - self.options = dict(config_entry.options) - async def async_step_init(self, user_input=None): - """Manage the options.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) + @staticmethod + def _internal_setup(_username, _password, _ipaddress): + sb_test = sonnenbatterie(_username, _password, _ipaddress) + return sb_test.get_systemdata().get("DE_Ticket_Number", "Unknown") + @callback + def _show_form(self, errors=None): + """Show the form to the user.""" return self.async_show_form( - step_id="init", - data_schema=vol.Schema( - { - vol.Optional( - CONF_SCAN_INTERVAL, - default=self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - ): cv.positive_int, - vol.Optional( - ATTR_SONNEN_DEBUG, - default=self.config_entry.options.get( - ATTR_SONNEN_DEBUG, DEFAULT_SONNEN_DEBUG - ), - ): bool, - } - ), + step_id="user", + data_schema=self.CONFIG_SCHEMA_USER, + errors=errors if errors else {}, ) - async def _update_options(self): - """Update config entry options.""" - return self.async_create_entry(title="", data=self.options) +"""" rustydust_241226: disabled since all the options have now been moved +to the main data of the Sonnebatterie entry. The code below is kept in place +just in case we want to add in options again. +""" +# @staticmethod +# @callback +# def async_get_options_flow(config_entry): +# return OptionsFlowHandler(config_entry) +# +# +# class OptionsFlowHandler(config_entries.OptionsFlow): +# def __init__(self, config_entry): +# """Initialize options flow.""" +# self._config_entry = config_entry +# self.options = dict(config_entry.options) +# +# async def async_step_init(self, user_input=None): +# """Manage the options.""" +# if user_input is not None: +# return self.async_create_entry(title="", data=user_input) +# +# return self.async_show_form( +# step_id="init", +# data_schema=vol.Schema( +# { +# vol.Optional( +# CONF_SCAN_INTERVAL, +# default=self._config_entry.data.get( +# CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL +# ), +# ): cv.positive_int, +# vol.Optional( +# ATTR_SONNEN_DEBUG, +# default=self._config_entry.data.get( +# ATTR_SONNEN_DEBUG, DEFAULT_SONNEN_DEBUG +# ), +# ): bool, +# } +# ), +# ) +# +# async def _update_options(self): +# """Update config entry options.""" +# return self.async_create_entry(title="", data=self.options) diff --git a/custom_components/sonnenbatterie/const.py b/custom_components/sonnenbatterie/const.py index d13ba0e..a679449 100644 --- a/custom_components/sonnenbatterie/const.py +++ b/custom_components/sonnenbatterie/const.py @@ -1,44 +1,24 @@ import logging -import voluptuous as vol -from homeassistant.const import ( - CONF_PASSWORD, - CONF_USERNAME, - CONF_IP_ADDRESS, - CONF_SCAN_INTERVAL, -) - -LOGGER = logging.getLogger(__package__) +CONF_SERIAL_NUMBER = "serial_number" +ATTR_SONNEN_DEBUG = "sonnenbatterie_debug" DOMAIN = "sonnenbatterie" -DEFAULT_SCAN_INTERVAL = 30 - -CONFIG_SCHEMA_A = vol.Schema( - { - vol.Required(CONF_USERNAME): vol.In(["User", "Installer"]), - vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_IP_ADDRESS): str, - } -) -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: CONFIG_SCHEMA_A}, - extra=vol.ALLOW_EXTRA, -) - -ATTR_SONNEN_DEBUG = "sonnenbatterie_debug" +DEFAULT_SCAN_INTERVAL = 30 DEFAULT_SONNEN_DEBUG = False -PLATFORMS = ["sensor"] +LOGGER = logging.getLogger(__package__) -def flatten_obj(prefix, seperator, obj): - result = {} - for field in obj: - val = obj[field] - val_prefix = prefix + seperator + field - if type(val) is dict: - sub = flatten_obj(val_prefix, seperator, val) - result.update(sub) - else: - result[val_prefix] = val - return result +# rustydust_241227: doesn't seem to be used anywhere +# def flatten_obj(prefix, seperator, obj): +# result = {} +# for field in obj: +# val = obj[field] +# val_prefix = prefix + seperator + field +# if type(val) is dict: +# sub = flatten_obj(val_prefix, seperator, val) +# result.update(sub) +# else: +# result[val_prefix] = val +# return result diff --git a/custom_components/sonnenbatterie/coordinator.py b/custom_components/sonnenbatterie/coordinator.py index 9af57da..f606e69 100644 --- a/custom_components/sonnenbatterie/coordinator.py +++ b/custom_components/sonnenbatterie/coordinator.py @@ -66,6 +66,7 @@ def __init__( def device_info(self) -> DeviceInfo: system_data = self.latestData["system_data"] + # noinspection HttpUrlsUsage return DeviceInfo( identifiers={(DOMAIN, self.device_id)}, configuration_url=f"http://{self.ip_address}/", @@ -122,7 +123,6 @@ async def _async_update_data(self): for index, dictIndex in enumerate(self.latestData["powermeter"]): new_powermeters.append(self.latestData["powermeter"][dictIndex]) self.latestData["powermeter"] = new_powermeters - # LOGGER.warning("ReRead powermeter as it returned wrong from battery.") except: e = traceback.format_exc() LOGGER.error(e) diff --git a/custom_components/sonnenbatterie/sensor.py b/custom_components/sonnenbatterie/sensor.py index 863e6f3..feae883 100644 --- a/custom_components/sonnenbatterie/sensor.py +++ b/custom_components/sonnenbatterie/sensor.py @@ -1,20 +1,23 @@ +from homeassistant.config_entries import ConfigEntryState from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, - DataUpdateCoordinator, ) from homeassistant.components.sensor import ( SensorEntity, ) +from homeassistant.const import ( + CONF_IP_ADDRESS, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) + from homeassistant.helpers.typing import StateType from .coordinator import SonnenBatterieCoordinator from sonnenbatterie import sonnenbatterie from .const import ( ATTR_SONNEN_DEBUG, - CONF_IP_ADDRESS, - CONF_PASSWORD, - CONF_SCAN_INTERVAL, - CONF_USERNAME, DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, @@ -29,43 +32,44 @@ _LOGGER = logging.getLogger(__name__) -async def async_unload_entry(hass, entry): - """Unload a config entry.""" - ## we dont have anything special going on.. unload should just work, right? - ##bridge = hass.data[DOMAIN].pop(entry.data['host']) - return +# rustydust_241227: this doesn't seem to be used anywhere +# async def async_unload_entry(hass, entry): +# """Unload a config entry.""" +# ## we dont have anything special going on.. unload should just work, right? +# ##bridge = hass.data[DOMAIN].pop(entry.data['host']) +# return async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the sensor platform.""" LOGGER.info("SETUP_ENTRY") - # await async_setup_reload_service(hass, DOMAIN, PLATFORMS) username = config_entry.data.get(CONF_USERNAME) password = config_entry.data.get(CONF_PASSWORD) ip_address = config_entry.data.get(CONF_IP_ADDRESS) - update_interval_seconds = config_entry.options.get(CONF_SCAN_INTERVAL) - debug_mode = config_entry.options.get(ATTR_SONNEN_DEBUG) - - def _internal_setup(_username, _password, _ip_address): - return sonnenbatterie(_username, _password, _ip_address) + update_interval_seconds = config_entry.data.get(CONF_SCAN_INTERVAL) + debug_mode = config_entry.data.get(ATTR_SONNEN_DEBUG) - sonnenInst = await hass.async_add_executor_job( - _internal_setup, username, password, ip_address + sonnen_inst = await hass.async_add_executor_job( + sonnenbatterie, username, password, ip_address ) + update_interval_seconds = update_interval_seconds or DEFAULT_SCAN_INTERVAL LOGGER.info("{0} - UPDATEINTERVAL: {1}".format(DOMAIN, update_interval_seconds)) """ The Coordinator is called from HA for updates from API """ coordinator = SonnenBatterieCoordinator( hass, - sonnenInst, + sonnen_inst, update_interval_seconds, ip_address, debug_mode, config_entry.entry_id, ) - await coordinator.async_config_entry_first_refresh() + if config_entry.state == ConfigEntryState.SETUP_IN_PROGRESS: + await coordinator.async_config_entry_first_refresh() + else: + await coordinator.async_refresh() async_add_entities( SonnenbatterieSensor(coordinator=coordinator, entity_description=description) diff --git a/custom_components/sonnenbatterie/translations/de.json b/custom_components/sonnenbatterie/translations/de.json index c7f141c..a5da7e0 100644 --- a/custom_components/sonnenbatterie/translations/de.json +++ b/custom_components/sonnenbatterie/translations/de.json @@ -14,21 +14,22 @@ "password": "Passwort", "username": "Benutzer", "ip_address": "IP-Adresse", - "scan_interval": "Aktualisierungsinterval (Sekunden)" + "scan_interval": "Aktualisierungsinterval (Sekunden)", + "sonnenbatterie_debug": "Debug-Modus (mehr Logs)" }, - "title": "Gib deine Sonnenbatterie-Anmeldeinformationen ein", + "title": "Sonnenbatterie-Konfiguration", "description": "Dein Passwort findest du normalerweise an der Seite deiner Sonnenbatterie in der Nähe des Hauptschalters." - } - - } - }, - "options": { - "step":{ - "init":{ - "data":{ + }, + "reconfigure": { + "data": { + "password": "Passwort", + "username": "Benutzer", + "ip_address": "IP-Adresse", "scan_interval": "Aktualisierungsinterval (Sekunden)", "sonnenbatterie_debug": "Debug-Modus (mehr Logs)" - } + }, + "title": "Sonnenbatterie-Konfiguration", + "description": "Dein Passwort findest du normalerweise an der Seite deiner Sonnenbatterie in der Nähe des Hauptschalters." } } }, diff --git a/custom_components/sonnenbatterie/translations/en.json b/custom_components/sonnenbatterie/translations/en.json index f41bba7..5081da7 100644 --- a/custom_components/sonnenbatterie/translations/en.json +++ b/custom_components/sonnenbatterie/translations/en.json @@ -14,21 +14,22 @@ "password": "Password", "username": "User", "ip_address": "IP-Address", - "scan_interval": "Update interval (seconds)" + "scan_interval": "Update interval (seconds)", + "sonnenbatterie_debug": "Debug mode (more log entries)" }, - "title": "Enter your Sonnenbatterie-login information", + "title": "Configure your Sonnenbatterie", "description": "Normally you find your password on the side of your sonnenbatterie near the main switch." - } - - } - }, - "options": { - "step": { - "init": { + }, + "reconfigure": { "data": { + "password": "Password", + "username": "User", + "ip_address": "IP-Address", "scan_interval": "Update interval (seconds)", "sonnenbatterie_debug": "Debug mode (more log entries)" - } + }, + "title": "Configure your Sonnenbatterie", + "description": "Normally you find your password on the side of your sonnenbatterie near the main switch." } } },