diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index c38f573..75e2956 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -1,4 +1,4 @@ -name: Validate +name: Validate with HACS on: push: diff --git a/README.md b/README.md index 40a1264..4840ff3 100644 --- a/README.md +++ b/README.md @@ -3,36 +3,233 @@ Homeassistant integration to show many stats 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) +[![Validate with HACS](https://github.com/weltmeyer/ha_sonnenbatterie/actions/workflows/validate.yaml/badge.svg)](https://github.com/weltmeyer/ha_sonnenbatterie/actions/workflows/validate.yaml) + +## Installation +Easiest way to install is to add this repository via [HACS](https://hacs.xyz). ## Tested working with * eco 8.03 9010 ND * eco 8.0 DE 9010 ND * sonnenBatterie 10 performance -## Won't work with older Batteries +### Won't work with older Batteries * ex. model 9.2 eco from 2014 not working -## Important: ### -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. +## Sensors +The main focus of the integration is to provide a comprehensive set of sensors +for your SonnenBatterie. Right after installation the most relevant sensors +are already activated. + +> [!TIP] +> If you want to dive deeper, just head over to your Sonnenbatterie device +> settings, click on "Entities" and enable the ones you're interested in. + + +## Actions +Since version 2025.01.01 this integration also supports actions you can use to +set some variables that influence the behaviour of your SonnenBatterie. + +Currently supported actions are: + +### `set_operating_mode(mode=)` +- Sets the operating mode of your SonnenBatterie. +- Supported values for `` are: + - `"manual"` + - `"automatic"` + - `"timeofuse"` + +##### Code snippet +``` yaml +action: sonnenbatterie.set_operating_mode +data: + mode: "automatic" +``` + +##### Response +An `int` representing the mode that has been set: +- 1: `manual` +- 2: `automatic` +- 10: `timeofuse` + +### `charge_battery(power=)` +> [!IMPORTANT] +> Requires the SonnenBatterie to be in `manual` or `auto`mode to have any +> effect. +> +> **Disables power delivery from the battery to local consumers!** + +- Sets your battery to charge with `` watts +- Disables discharging to support local consumers while charging +- Supported values for `` are: + - min. power = 0 (0 = disable functionality) + - max. power = value of your battery's `inverter_max_power` value. + + The integration tries to determine the upper limit automatically + and caps the input if a higher value than supported by the battery + is given + +##### Code snippet +``` yaml +action: sonnenbatterie.charge_battery +data: + power: 0 +``` + +##### Response +A `bool` value, either `True` if setting the value was successful or `False` +otherwise. + +### `discharge_battery(power=)` +> [!IMPORTANT] +> Requires the SonnenBatterie to be in `manual` or `auto`mode to have any +> effect. +> +> **Enables power delivery from the battery to local consumers and may result +> in sending power to the network if local demand is lower than the value +> given!** + +- Sets your battery to discharge with `` watts +- Disables charging of the battery while active +- Supported values for `` are: + - min. power = 0 (0 = disable functionality) + - max. power = value of your battery's `inverter_max_power` value. + + The integration tries to determine the upper limit automatically + and caps the input if a higher value than supported by the battery + is given + +##### Code snippet +``` yaml +action: sonnenbatterie.discharge_battery +data: + power: 0 +``` -### 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: +##### Response +A `bool` value, either `True` if setting the value was successful or `False` +otherwise. -If you feel that your Sonnenbatterie **should** provide one or more of those -you can enable the "debug_mode" from +### `set_battery_reserve(value=)` + +- Sets the percentage of energy that should be left in the battery +- `` can be in the range from 0 - 100 + +##### Code snippet +``` yaml +action: sonnenbatterie.set_battery_reserve +data: + value: 10 +``` + +##### Response +An integer representing the current value of "battery reserve" + +### `set_config_item(item=, value=)` +- Allows to set some selected configuration variables of the SonnenBattery. +- Currently supported `` values: + - "EM_OperatingMode" + - allowed values: + - `manual` + - `automatic` + - `timeofuse` + - _prefer [`set_operating_mode`](.#set_operatingmode)) over this_ + - "EM_ToU_Schedule" + - set a scheulde for charging in ToU mode + - accepts JSON array as string of the format + ``` json + [ { "start": "10:00", + "stop": "11:00", + "threshold_p_mac": 10000 + }, + ... + ] + ``` + - time ranges **must not** overlap + - since there are only times, the schedules stay active if not deleted by + sending an empty array (`"[]"`) + - _prefer [`set_tou_schedule`](.#set_tou_schedule) over this_ + - "EM_USOC" + - set the battery reserve in percent (0 - 100) + - accepts *a string* representing the value, like `"15"` for 15% reserve + - _prefer [`set_battery_reserve`](.#set_battery_reserve) over this_ + +##### Code snippet +``` yaml +action: sonnenbatterie.set_config_item +data: + item: "EM_USOC" + value: "10" +``` +##### Response +``` json +{'EM_USOC': '10'} +``` + +### `set_tou_schedule(schedule=)` + +> [!IMPORTANT] +> The SonnenBatterie must be in `timeofuse` operating mode for any +> submitted schedule to take effekt. + +- Sets the shedule entries for the "Time of Use" operating mode +- The value for the schedule is a JSON array **in string format** +- The format is: + ``` json + [ { "start": "10:00", + "stop": "11:00", + "threshold_p_mac": 10000 + }, + ... + ] + ``` +- time ranges **must not** overlap +- since there are only times, the schedules stay active if not deleted by + sending an empty array (`"[]"`) + +##### Code snippet +``` yaml +action: sonnenbatterie.set_tou_schedule_string +data: + schedule: '[{"start":"10:00", "stop":"10:00", "threshold_p_max": 20000}]' +``` + +##### Result +``` json +{ + "schedule": '[{"start": "10:00", "stop": "10:00", "threshold_p_max": 20000}]' +} +``` + +### `get_tou_schedule()` +- Retrieves the current schedule as stored in your SonnenBatterie + +##### Code snippet +``` yaml +action: sonnenbatterie.get_tou_schedule +data: {} +``` + +##### Result +``` yaml +schedule: "[{\"start\":\"10:00\", \"stop\":\"10:00\", \"threshold_p_max\": 20000}]" +``` + +## Problems and/or unused/unavailable sensors +Depending on the software on and the operating mode of your Sonnenbatterie some +sonsors may not be available. The integration does its best to collect as many +values as possible. + +If you feel that your Sonnenbatterie doesn't provide a sensor you think it +should, you can enable a "Debug Mode" from _Settings -> Devices & Services -> Integrations -> Sonnenbatterie -> (...) -> Reconfigure_ -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. +Then restart HomeAssistant and watch the logs. +You'll get the full data that's returned by your Sonnenbatterie there. +Please put those logs along with the setting you want monitored into +[a new issue](https://github.com/weltmeyer/ha_sonnenbatterie/issues). -## Install -Easiest way to install is to add this repository via [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 a5b1a96..eb9a50d 100644 --- a/custom_components/sonnenbatterie/__init__.py +++ b/custom_components/sonnenbatterie/__init__.py @@ -2,34 +2,307 @@ import json +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - Platform + Platform, + ATTR_DEVICE_ID, + CONF_IP_ADDRESS, + CONF_PASSWORD, + CONF_USERNAME, ) +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, SupportsResponse, +) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import ( + async_get as dr_async_get, +) +from homeassistant.util.read_only_dict import ReadOnlyDict +from sonnenbatterie import AsyncSonnenBatterie +from timeofuse import TimeofUseSchedule from .const import * - # 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 +SB_OPERATING_MODES = { + "manual": 1, + "automatic": 2, + "expansion": 6, + "timeofuse": 10 +} + + +SCHEMA_SET_BATTERY_RESERVE = vol.Schema( + { + **cv.ENTITY_SERVICE_FIELDS, + vol.Required(CONF_SERVICE_VALUE): vol.Range(min=0, max=100) + } +) + +SCHEMA_SET_CONFIG_ITEM = vol.Schema( + { + **cv.ENTITY_SERVICE_FIELDS, + vol.Required(CONF_SERVICE_ITEM, default=""): vol.In(CONF_CONFIG_ITEMS), + vol.Required(CONF_SERVICE_VALUE, default=""): str + } +) + +SCHEMA_SET_OPERATING_MODE = vol.Schema( + { + **cv.ENTITY_SERVICE_FIELDS, + vol.Required(CONF_SERVICE_MODE, default="automatic"): vol.In(CONF_OPERATING_MODES), + } +) + +SCHEMA_SET_TOU_SCHEDULE_STRING = vol.Schema( + { + **cv.ENTITY_SERVICE_FIELDS, + vol.Required(CONF_SERVICE_SCHEDULE): cv.string_with_no_html, + } +) + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + LOGGER.debug(f"setup_entry: {config_entry.data}\n{config_entry.entry_id}") + ip_address = config_entry.data[CONF_IP_ADDRESS] + password = config_entry.data[CONF_PASSWORD] + username = config_entry.data[CONF_USERNAME] + + sb = AsyncSonnenBatterie(username, password, ip_address) + await sb.login() + inverter_power = int((await sb.get_batterysystem())['battery_system']['system']['inverter_capacity']) + await sb.logout() + + # noinspection PyPep8Naming + SCHEMA_CHARGE_BATTERY = vol.Schema( + { + **cv.ENTITY_SERVICE_FIELDS, + vol.Required(CONF_CHARGE_WATT): vol.Range(min=0, max=inverter_power), + } + ) + + # Set up base data in hass object + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][config_entry.entry_id] = {} + hass.data[DOMAIN][config_entry.entry_id][CONF_IP_ADDRESS] = ip_address + hass.data[DOMAIN][config_entry.entry_id][CONF_USERNAME] = username + hass.data[DOMAIN][config_entry.entry_id][CONF_PASSWORD] = password -async def async_setup_entry(hass, config_entry): - LOGGER.debug("setup_entry: " + json.dumps(dict(config_entry.data))) await hass.config_entries.async_forward_entry_setups(config_entry, [ Platform.SENSOR ]) # 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 + # config_entry.async_on_unload(config_entry.add_update_listener(async_reload_entry)) + + def _get_sb_connection(call_data: ReadOnlyDict) -> AsyncSonnenBatterie: + LOGGER.debug(f"_get_sb_connection: {call_data}") + if ATTR_DEVICE_ID in call_data: + # no idea why, but sometimes it's a list and other times a str + if isinstance(call_data[ATTR_DEVICE_ID], list): + device_id = call_data[ATTR_DEVICE_ID][0] + else: + device_id = call_data[ATTR_DEVICE_ID] + device_registry = dr_async_get(hass) + if not (device_entry := device_registry.async_get(device_id)): + raise HomeAssistantError(f"No device found for device_id: {device_id}") + if not (sb_config := hass.data[DOMAIN][device_entry.primary_config_entry]): + raise HomeAssistantError(f"Unable to find config for device_id: {device_id} ({device_entry.name})") + if not (sb_config.get(CONF_USERNAME) and sb_config.get(CONF_PASSWORD) and sb_config.get(CONF_IP_ADDRESS)): + raise HomeAssistantError(f"Invalid config for device_id: {device_id} ({sb_config}). Please report an issue at {SONNENBATTERIE_ISSUE_URL}.") + return AsyncSonnenBatterie(sb_config.get(CONF_USERNAME), sb_config.get(CONF_PASSWORD), sb_config.get(CONF_IP_ADDRESS)) + else: + return sb + + # service definitions + async def charge_battery(call: ServiceCall) -> ServiceResponse: + power = int(call.data.get(CONF_CHARGE_WATT)) + # Make sure we have an sb2 object + sb_conn = _get_sb_connection(call.data) + await sb_conn.login() + response = await sb_conn.sb2.charge_battery(power) + await sb_conn.logout() + return { + "charge": response, + } + async def discharge_battery(call: ServiceCall) -> ServiceResponse: + power = int(call.data.get(CONF_CHARGE_WATT)) + sb_conn = _get_sb_connection(call.data) + await sb_conn.login() + response = await sb_conn.sb2.discharge_battery(power) + await sb_conn.logout() + return { + "discharge": response, + } -async def async_reload_entry(hass, entry): - """Reload config entry.""" - await async_unload_entry(hass, entry) - await async_setup_entry(hass, entry) + async def set_battery_reserve(call: ServiceCall) -> ServiceResponse: + value = call.data.get(CONF_SERVICE_VALUE) + sb_conn = _get_sb_connection(call.data) + await sb_conn.login() + response = int((await sb_conn.sb2.set_battery_reserve(value))["EM_USOC"]) + await sb_conn.logout() + return { + "battery_reserve": response, + } + + async def set_config_item(call: ServiceCall) -> ServiceResponse: + item = call.data.get(CONF_SERVICE_ITEM) + value = call.data.get(CONF_SERVICE_VALUE) + sb_conn = _get_sb_connection(call.data) + await sb_conn.login() + response = await sb_conn.sb2.set_config_item(item, value) + await sb_conn.logout() + return { + "response": response, + } + + async def set_operating_mode(call: ServiceCall) -> ServiceResponse: + mode = SB_OPERATING_MODES.get(call.data.get('mode')) + sb_conn = _get_sb_connection(call.data) + await sb_conn.login() + response = await sb_conn.set_operating_mode(mode) + await sb_conn.logout() + return { + "mode": response, + } + + async def set_tou_schedule(call: ServiceCall) -> ServiceResponse: + schedule = call.data.get(CONF_SERVICE_SCHEDULE) + try: + json_schedule = json.loads(schedule) + except ValueError as e: + raise HomeAssistantError(f"Schedule is not a valid JSON string: '{schedule}'") from e + + tou = TimeofUseSchedule() + try: + tou.load_tou_schedule_from_json(json_schedule) + except ValueError as e: + raise HomeAssistantError(f"Schedule is not a valid schedule: '{schedule}'") from e + except TypeError as t: + raise HomeAssistantError(f"Schedule is not a valid schedule: '{schedule}'") from t + + sb_conn = _get_sb_connection(call.data) + await sb_conn.login() + response = await sb_conn.sb2.set_tou_schedule_string(schedule) + await sb_conn.logout() + return { + "schedule": response["EM_ToU_Schedule"], + } + + # noinspection PyUnusedLocal + async def get_tou_schedule(call: ServiceCall) -> ServiceResponse: + sb_conn = _get_sb_connection(call.data) + await sb_conn.login() + response = await sb_conn.sb2.get_tou_schedule_string() + await sb_conn.logout() + return { + "schedule": response, + } + + # noinspection PyUnusedLocal + async def get_battery_reserve(call: ServiceCall) -> ServiceResponse: + sb_conn = _get_sb_connection(call.data) + await sb_conn.login() + response = await sb_conn.sb2.get_battery_reserve() + await sb_conn.logout() + return { + "backup_reserve": response, + } + + async def get_operating_mode(call: ServiceCall) -> ServiceResponse: + sb_conn = _get_sb_connection(call.data) + await sb_conn.login() + response = await sb_conn.sb2.get_operating_mode() + await sb_conn.logout() + return { + "operating_mode": response, + } + + # service registration + hass.services.async_register( + DOMAIN, + "charge_battery", + charge_battery, + schema=SCHEMA_CHARGE_BATTERY, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + "discharge_battery", + discharge_battery, + schema=SCHEMA_CHARGE_BATTERY, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + "set_battery_reserve", + set_battery_reserve, + schema=SCHEMA_SET_BATTERY_RESERVE, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + "set_config_item", + set_config_item, + schema=SCHEMA_SET_CONFIG_ITEM, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + "set_operating_mode", + set_operating_mode, + schema=SCHEMA_SET_OPERATING_MODE, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + "set_tou_schedule", + set_tou_schedule, + schema=SCHEMA_SET_TOU_SCHEDULE_STRING, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + "get_tou_schedule", + get_tou_schedule, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + "get_battery_reserve", + get_battery_reserve, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + "get_operating_mode", + get_operating_mode, + supports_response=SupportsResponse.ONLY, + ) + + # Done setting up the entry + return True +# rustydust_241230: no longer needed +# async def async_reload_entry(hass, entry): +# """Reload config entry.""" +# await async_unload_entry(hass, entry) +# await async_setup_entry(hass, entry) # rustydust_241227: this doesn't seem to be needed # async def update_listener(hass, entry): @@ -41,4 +314,5 @@ async def async_reload_entry(hass, entry): async def async_unload_entry(hass, entry): """Handle removal of an entry.""" + LOGGER.debug(f"Unloading config entry: {entry}") 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 6f7a3fd..7838bcd 100644 --- a/custom_components/sonnenbatterie/config_flow.py +++ b/custom_components/sonnenbatterie/config_flow.py @@ -1,5 +1,5 @@ # pylint: disable=no-name-in-module -from sonnenbatterie import sonnenbatterie +from sonnenbatterie import AsyncSonnenBatterie # pylint: enable=no-name-in-module import traceback @@ -25,10 +25,10 @@ class SonnenbatterieFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONFIG_SCHEMA_USER = vol.Schema( { - vol.Required(CONF_IP_ADDRESS, default="127.0.0.1"): str, + vol.Required(CONF_IP_ADDRESS, default="192.168.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(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): int, vol.Optional(ATTR_SONNEN_DEBUG, default=DEFAULT_SONNEN_DEBUG): cv.boolean, } ) @@ -52,15 +52,21 @@ async def async_step_user(self, user_input=None): LOGGER.error(f"Unable to connect to sonnenbatterie: {e}") return self._show_form({"base": "connection_error"}) + # async is a fickly beast ... + sb_serial = await my_serial + unique_id = f"{DOMAIN}-{sb_serial}" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( - title=f"{user_input[CONF_IP_ADDRESS]} ({my_serial})", + title=f"{user_input[CONF_IP_ADDRESS]} ({sb_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, + CONF_SERIAL_NUMBER: sb_serial, }, ) @@ -98,6 +104,7 @@ async def async_step_reconfigure(self, user_input): ) if user_input is not None: + LOGGER.debug(f"Reconfiguring {entry}") if entry.data.get(CONF_SERIAL_NUMBER): await self.async_set_unique_id(entry.data[CONF_SERIAL_NUMBER]) self._abort_if_unique_id_configured() @@ -119,10 +126,12 @@ async def async_step_reconfigure(self, user_input): errors={"base": "connection_error"}, ) + # async is a fickly beast ... + sb_serial = await my_serial return self.async_update_reload_and_abort( self._get_reconfigure_entry(), data_updates=user_input, - title=f"{user_input[CONF_IP_ADDRESS]} ({my_serial})", + title=f"{user_input[CONF_IP_ADDRESS]} ({sb_serial})", ) @@ -133,9 +142,12 @@ async def async_step_reconfigure(self, 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") + async def _internal_setup(_username, _password, _ipaddress): + sb_test = AsyncSonnenBatterie(_username, _password, _ipaddress) + await sb_test.login() + result = (await sb_test.get_systemdata()).get("DE_Ticket_Number", "Unknown") + await sb_test.logout() + return result @callback def _show_form(self, errors=None): diff --git a/custom_components/sonnenbatterie/const.py b/custom_components/sonnenbatterie/const.py index a679449..81f4d68 100644 --- a/custom_components/sonnenbatterie/const.py +++ b/custom_components/sonnenbatterie/const.py @@ -1,5 +1,7 @@ import logging +SONNENBATTERIE_ISSUE_URL = "https://github.com/weltmeyer/ha_sonnenbatterie/issues" + CONF_SERIAL_NUMBER = "serial_number" ATTR_SONNEN_DEBUG = "sonnenbatterie_debug" @@ -10,6 +12,25 @@ LOGGER = logging.getLogger(__package__) +""" Limited to those that can be changed safely """ +CONF_CONFIG_ITEMS = [ + "EM_OperatingMode", + "EM_ToU_Schedule", + "EM_USOC" +] + +CONF_OPERATING_MODES = [ + "manual", + "automatic", + "timeofuse" +] + +CONF_CHARGE_WATT = "power" +CONF_SERVICE_ITEM = "item" +CONF_SERVICE_MODE = "mode" +CONF_SERVICE_SCHEDULE = "schedule" +CONF_SERVICE_VALUE = "value" + # rustydust_241227: doesn't seem to be used anywhere # def flatten_obj(prefix, seperator, obj): # result = {} diff --git a/custom_components/sonnenbatterie/coordinator.py b/custom_components/sonnenbatterie/coordinator.py index f606e69..cdf4532 100644 --- a/custom_components/sonnenbatterie/coordinator.py +++ b/custom_components/sonnenbatterie/coordinator.py @@ -9,20 +9,17 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from sonnenbatterie import sonnenbatterie +from sonnenbatterie import AsyncSonnenBatterie from .const import DOMAIN, LOGGER, logging -_LOGGER = logging.getLogger(__name__) - - class SonnenBatterieCoordinator(DataUpdateCoordinator): """The SonnenBatterieCoordinator class.""" def __init__( self, hass: HomeAssistant, - sb_inst: sonnenbatterie, + sb_inst: AsyncSonnenBatterie, update_interval_seconds: int, ip_address, debug_mode, @@ -31,7 +28,7 @@ def __init__( """Initialize my coordinator.""" super().__init__( hass, - _LOGGER, + LOGGER, # Name of the data. For logging purposes. name=f"sonnenbatterie-{device_id}", # Polling interval. Will only be polled if there are subscribers. @@ -45,7 +42,7 @@ def __init__( self.stopped = False - self.sbInst: sonnenbatterie = sb_inst + self.sbInst: AsyncSonnenBatterie = sb_inst self.meterSensors = {} self.update_interval_seconds = update_interval_seconds self.ip_address = ip_address @@ -85,31 +82,29 @@ async def _async_update_data(self): try: # ignore errors here, may be transient result = await self.hass.async_add_executor_job(self.sbInst.get_battery) - self.latestData["battery"] = result + self.latestData["battery"] = await result result = await self.hass.async_add_executor_job(self.sbInst.get_batterysystem) - self.latestData["battery_system"] = result + self.latestData["battery_system"] = await result result = await self.hass.async_add_executor_job(self.sbInst.get_inverter) - self.latestData["inverter"] = result + self.latestData["inverter"] = await result result = await self.hass.async_add_executor_job(self.sbInst.get_powermeter) - self.latestData["powermeter"] = result + self.latestData["powermeter"] = await result result = await self.hass.async_add_executor_job(self.sbInst.get_status) - self.latestData["status"] = result + self.latestData["status"] = await result result = await self.hass.async_add_executor_job(self.sbInst.get_systemdata) - self.latestData["system_data"] = result + self.latestData["system_data"] = await result except Exception as ex: - if self.debug: - e = traceback.format_exc() - LOGGER.error(e) if self._last_error is not None: + LOGGER.info(traceback.format_exc() + " ... might be maintenance window") elapsed = time() - self._last_error - if elapsed > timedelta(seconds=180).total_seconds(): - LOGGER.warning(f"Unable to connecto to Sonnenbatteries at {self.ip_address} for {elapsed} seconds. Please check! [{ex}]") + if elapsed > 180: + LOGGER.error(f"Unable to connecto to Sonnenbatteries at {self.ip_address} for {elapsed} seconds. Please check! [{ex}]") else: self._last_error = time() @@ -141,9 +136,7 @@ async def _async_update_data(self): """ some manually calculated values """ batt_module_capacity = int( - self.latestData["battery_system"]["battery_system"]["system"][ - "storage_capacity_per_module" - ] + self.latestData["battery_system"]["battery_system"]["system"]["storage_capacity_per_module"] ) batt_module_count = int(self.latestData["battery_system"]["modules"]) diff --git a/custom_components/sonnenbatterie/manifest.json b/custom_components/sonnenbatterie/manifest.json index b5edf52..50bd714 100644 --- a/custom_components/sonnenbatterie/manifest.json +++ b/custom_components/sonnenbatterie/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://github.com/weltmeyer/ha_sonnenbatterie", "iot_class": "local_polling", "issue_tracker": "https://github.com/weltmeyer/ha_sonnenbatterie/issues", - "requirements": ["requests","sonnenbatterie>=0.3.0"], - "version": "2024.12.02" + "requirements": ["requests","sonnenbatterie>=0.5.2"], + "version": "2025.01.01" } diff --git a/custom_components/sonnenbatterie/sensor.py b/custom_components/sonnenbatterie/sensor.py index feae883..994a801 100644 --- a/custom_components/sonnenbatterie/sensor.py +++ b/custom_components/sonnenbatterie/sensor.py @@ -15,7 +15,7 @@ from homeassistant.helpers.typing import StateType from .coordinator import SonnenBatterieCoordinator -from sonnenbatterie import sonnenbatterie +from sonnenbatterie import AsyncSonnenBatterie from .const import ( ATTR_SONNEN_DEBUG, DEFAULT_SCAN_INTERVAL, @@ -29,9 +29,6 @@ generate_powermeter_sensors, ) -_LOGGER = logging.getLogger(__name__) - - # rustydust_241227: this doesn't seem to be used anywhere # async def async_unload_entry(hass, entry): # """Unload a config entry.""" @@ -42,7 +39,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the sensor platform.""" - LOGGER.info("SETUP_ENTRY") + LOGGER.debug("SETUP_ENTRY") username = config_entry.data.get(CONF_USERNAME) password = config_entry.data.get(CONF_PASSWORD) ip_address = config_entry.data.get(CONF_IP_ADDRESS) @@ -50,11 +47,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): debug_mode = config_entry.data.get(ATTR_SONNEN_DEBUG) sonnen_inst = await hass.async_add_executor_job( - sonnenbatterie, username, password, ip_address + AsyncSonnenBatterie, username, password, ip_address ) update_interval_seconds = update_interval_seconds or DEFAULT_SCAN_INTERVAL - LOGGER.info("{0} - UPDATEINTERVAL: {1}".format(DOMAIN, update_interval_seconds)) + LOGGER.debug(f"{DOMAIN} - UPDATEINTERVAL: {update_interval_seconds}") """ The Coordinator is called from HA for updates from API """ coordinator = SonnenBatterieCoordinator( @@ -82,7 +79,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for description in generate_powermeter_sensors(_coordinator=coordinator) ) - LOGGER.info("Init done") + LOGGER.debug("Init done") return True diff --git a/custom_components/sonnenbatterie/services.yaml b/custom_components/sonnenbatterie/services.yaml new file mode 100644 index 0000000..1ff6d88 --- /dev/null +++ b/custom_components/sonnenbatterie/services.yaml @@ -0,0 +1,100 @@ +set_operating_mode: + fields: + device_id: + required: true + selector: + device: + integration: sonnenbatterie + mode: + required: true + example: "timeofuse" + default: "automatic" + selector: + text: +charge_battery: + fields: + device_id: + required: true + selector: + device: + integration: sonnenbatterie + power: + required: true + example: "1000" + selector: + number: +discharge_battery: + fields: + device_id: + required: true + selector: + device: + integration: sonnenbatterie + power: + required: true + example: "1000" + selector: + number: +set_battery_reserve: + fields: + device_id: + required: true + selector: + device: + integration: sonnenbatterie + value: + required: true + selector: + number: + min: 0 + max: 100 +set_config_item: + fields: + device_id: + required: true + selector: + device: + integration: sonnenbatterie + item: + required: true + example: "EM_USOC" + selector: + text: + value: + required: true + example: "15" + selector: + text: +set_tou_schedule: + fields: + device_id: + required: true + selector: + device: + integration: sonnenbatterie + schedule: + required: true + example: "[{\"start\":\"10:00\", \"stop\":\"11:00\", \"threshold_p_max\": 20000 }]" + selector: + text: +get_tou_schedule: + fields: + device_id: + required: true + selector: + device: + integration: sonnenbatterie +get_battery_reserve: + fields: + device_id: + required: true + selector: + device: + integration: sonnenbatterie +get_operating_mode: + fields: + device_id: + required: true + selector: + device: + integration: sonnenbatterie diff --git a/custom_components/sonnenbatterie/translations/de.json b/custom_components/sonnenbatterie/translations/de.json index a5da7e0..a357219 100644 --- a/custom_components/sonnenbatterie/translations/de.json +++ b/custom_components/sonnenbatterie/translations/de.json @@ -149,5 +149,141 @@ "name": "Spannungswandler UPV 2" } } + }, + "services": { + "set_operating_mode": { + "name": "Setze Sonnenbatterie-Betriebsmodus", + "description": "Setzt den Betriebsmodus der Sonnenbatterie ('manual', 'automatic', 'timeofuse')", + "fields": { + "device_id": { + "description": "HomeAssistant ID des Geräts", + "name": "Device ID", + "example": "1234567890" + }, + "mode": { + "description": "Der zu setzende Betriebsmodus", + "name": "Betriebsmodus", + "example": "automatic" + } + } + }, + "charge_battery": { + "name": "Sonnenbatterie laden", + "description": "Erzwingt das Laden der Sonnenbatterie mit der angegebenen Leistung", + "fields": { + "device_id": { + "description": "HomeAssistant ID des Geräts", + "name": "Device ID", + "example": "1234567890" + }, + "power": { + "name": "Lade-Leistung in Watt", + "description": "Leistung, mit der die Sonnenbatterie geladen werden soll", + "example": "1000" + } + } + }, + "discharge_battery": { + "name": "Sonnenbatterie entladen", + "description": "Erzwingt die Abgabe von Leistung der Sonnenbatterie an Verbraucher", + "fields": { + "device_id": { + "description": "HomeAssistant ID des Geräts", + "name": "Device ID", + "example": "1234567890" + }, + "power": { + "name": "Entlade-Leistung in Watt", + "description": "Leistung, die aus der Sonnebatterie an Verbraucher abgegehen wird", + "example": "1234" + } + } + }, + "set_battery_reserve": { + "name": "Reservekapazität setzen", + "description": "Setzt die von der Sonnebatterie zurückgehaltenene Kapazität in Prozent", + "fields": { + "device_id": { + "description": "HomeAssistant ID des Geräts", + "name": "Device ID", + "example": "1234567890" + }, + "value": { + "name": "Zurückgehaltene Kapazität", + "description": "Kapazität in Prozent, die die Sonnenbatterie auf jeden Fall reservieren soll", + "example": "10" + } + } + }, + "set_config_item": { + "name": "Setzen eines Konfigurationswerts der Sonnenbatterie", + "description": "Erlaubt das Setzen einzelner Konfigurationswerte und Betriebsparameter der Sonnenbatterie", + "fields": { + "device_id": { + "description": "HomeAssistant ID des Geräts", + "name": "Device ID", + "example": "1234567890" + }, + "item": { + "name": "Zu setzender Parameter", + "description": "Der Name des zu setzenden Parameters der Sonnenbatterie", + "example": "EM_USOC" + }, + "value": { + "name": "Wert des zu setzenden Parameters", + "description": "Der zu setzende Wert, kann je nach Parameter ein Text, eine Zahl oder ein JSON-String sein", + "example": "10 (int) oder \"[]\" (leerer JSON-String)" + } + } + }, + "set_tou_schedule": { + "name": "Ladefenster festlegen", + "description": "Erlaubt das Setzen von Ladenfenstern im zeitgesteuerten Betriebsmodus", + "fields": { + "device_id": { + "description": "HomeAssistant ID des Geräts", + "name": "Device ID", + "example": "1234567890" + }, + "schedule": { + "name": "Zeitfenster (JSON-Array)", + "description": "Ein oder mehrere Zeitfenster-Angaben als JSON-Array im String-Format", + "example": "\"[{\"start\":\"10:00\", \"stop\":\"11:00\", \"threshold_p_max\": 20000}]\"" + } + } + }, + "get_tou_schedule": { + "name": "Auslesen der Lade-Zeitfenster", + "description": "Liefert die aktuell in der Sonnebatterie hinterlegten Zeitfenster für das Laden im zeitgesteuerten Modus", + "fields": { + "device_id": { + "description": "HomeAssistant ID des Geräts", + "name": "Device ID", + "example": "1234567890" + } + } + }, + "get_battery_reserve": { + "name": "Auslesen der Batterie-Reserve", + "description": "Liefert die von der Sonnebatterie als Reserve zurückgehaltene Kapazität in Prozent", + "fields": { + "device_id": { + "description": "HomeAssistant ID des Geräts", + "name": "Device ID", + "example": "1234567890" + } + } + }, + "get_operating_mode": { + "name": "Betriebsmodus auslesen", + "description": "Liefert den aktuellen Betriebsmodus der Sonnebatterie in numerischer Form", + "fields": { + "device_id": { + "description": "HomeAssistant ID des Geräts", + "name": "Device ID", + "example": "1234567890" + } + } + } } } diff --git a/custom_components/sonnenbatterie/translations/en.json b/custom_components/sonnenbatterie/translations/en.json index 5081da7..a90eeac 100644 --- a/custom_components/sonnenbatterie/translations/en.json +++ b/custom_components/sonnenbatterie/translations/en.json @@ -149,5 +149,141 @@ "name": "Inverter UPV 2" } } - } + }, + "services": { + "set_operating_mode": { + "name": "Set operating mode", + "description": "Sets the operating mode of the Sonnenbatterie", + "fields": { + "device_id": { + "description": "HomeAssistant Id of the target device", + "name": "Device Id", + "example": "1234567890" + }, + "mode": { + "description": "Operating mode to set ('manual', 'automatic', 'timeofuse')", + "name": "Operating mode", + "example": "automatic" + } + } + }, + "charge_battery": { + "name": "Charge battery", + "description": "Forces the charging of the Sonnenbatterie with the specified power in W", + "fields": { + "device_id": { + "description": "HomeAssistant Id of the target device", + "name": "Device Id", + "example": "1234567890" + }, + "power": { + "name": "Charging power", + "description": "Power to charge the Sonnenbatterie with", + "example": "1000" + } + } + }, + "discharge_battery": { + "name": "Discharge battery", + "description": "Forces the discharge of specified power from the Sonnenbatterie", + "fields": { + "device_id": { + "description": "HomeAssistant Id of the target device", + "name": "Device Id", + "example": "1234567890" + }, + "power": { + "name": "Discharging power", + "description": "Power to discharge the Sonnenbatterie with", + "example": "1234" + } + } + }, + "set_battery_reserve": { + "name": "Set backup capacity", + "description": "Sets the backup capacity the Sonnenbatterie keeps back in percent of the total", + "fields": { + "device_id": { + "description": "HomeAssistant Id of the target device", + "name": "Device Id", + "example": "1234567890" + }, + "value": { + "name": "Kept back capacity", + "description": "Precentage of the total capacity that should be kept back", + "example": "10" + } + } + }, + "set_config_item": { + "name": "Set a config parameter", + "description": "Allows changing of some of the Sonnenbatterie's operating paramters", + "fields": { + "device_id": { + "description": "HomeAssistant Id of the target device", + "name": "Device Id", + "example": "1234567890" + }, + "item": { + "name": "Parameter name", + "description": "Name of the parameter that should be set", + "example": "EM_USOC" + }, + "value": { + "name": "Parameter value", + "description": "The value the parameter should be set to. Can be an integer or a string, depending on the parameter", + "example": "10 (int) or \"[]\" (empty JSON string)" + } + } + }, + "set_tou_schedule": { + "name": "Set charging schedule(s)", + "description": "Allows to set charging schedules that are honored when the Sonnenbattery is in Time-of-Use mode", + "fields": { + "device_id": { + "description": "HomeAssistant Id of the target device", + "name": "Device Id", + "example": "1234567890" + }, + "schedule": { + "name": "Charging window(s)", + "description": "One or more charging windows as string containing a JSON array", + "example": "\"[{\"start\":\"10:00\", \"stop\":\"11:00\", \"threshold_p_max\": 20000}]\"" + } + } + }, + "get_tou_schedule": { + "name": "Get charging schedule", + "description": "Returns the charging schedules currently stored in the Sonnenbatterie's configuration", + "fields": { + "device_id": { + "description": "HomeAssistant Id of the target device", + "name": "Device Id", + "example": "1234567890" + } + } + }, + "get_battery_reserve": { + "name": "Get battery reserve", + "description": "Returns the percentage of capacity the Sonnebatterie keeps back.", + "fields": { + "device_id": { + "description": "HomeAssistant ID of the target device", + "name": "Device ID", + "example": "1234567890" + } + } + }, + "get_operating_mode": { + "name": "Get operating mode", + "description": "Returns the current operating mode of the SonnenBatterie in numeric form", + "fields": { + "device_id": { + "description": "HomeAssistant ID of the target device", + "name": "Device ID", + "example": "1234567890" + } + } + } +} }