From 6b5e376cf066ee38b1468597922f3951b15def13 Mon Sep 17 00:00:00 2001 From: Victor Garcia Reolid Date: Wed, 7 Aug 2024 17:29:14 +0200 Subject: [PATCH] feat: add support for FillRateBasedControlTUNES Signed-off-by: Victor Garcia Reolid --- .../components/flexmeasures/__init__.py | 39 ++- .../components/flexmeasures/config_flow.py | 268 ++++++------------ .../components/flexmeasures/control_types.py | 21 +- .../components/flexmeasures/manifest.json | 2 +- .../components/flexmeasures/services.py | 2 +- .../components/flexmeasures/strings.json | 132 ++++++++- .../components/flexmeasures/websockets.py | 86 ++++-- requirements_all.txt | 5 +- requirements_test_all.txt | 5 +- .../flexmeasures/test_config_flow.py | 25 +- .../components/flexmeasures/test_services.py | 8 +- .../flexmeasures/test_websockets.py | 5 +- 12 files changed, 340 insertions(+), 258 deletions(-) diff --git a/homeassistant/components/flexmeasures/__init__.py b/homeassistant/components/flexmeasures/__init__.py index 2dc6dfa68e741c..fd87b2fa37bd21 100644 --- a/homeassistant/components/flexmeasures/__init__.py +++ b/homeassistant/components/flexmeasures/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import fields import logging from flexmeasures_client import FlexMeasuresClient @@ -10,8 +11,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigValidationError from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util.dt import parse_duration from .config_flow import get_host_and_ssl_from_url from .const import DOMAIN, FRBC_CONFIG @@ -39,38 +40,34 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # unsub_options_update_listener = entry.add_update_listener(options_update_listener) # Store a reference to the unsubscribe function to cleanup if an entry is unloaded. # config_data["unsub_options_update_listener"] = unsub_options_update_listener - host, ssl = get_host_and_ssl_from_url(entry.data["url"]) + + host, ssl = get_host_and_ssl_from_url(get_from_option_or_config("url", entry)) client = FlexMeasuresClient( host=host, - email=entry.data["username"], - password=entry.data["password"], + email=get_from_option_or_config("username", entry), + password=get_from_option_or_config("password", entry), ssl=ssl, session=async_get_clientsession(hass), ) - # make dataclass FRBC - # put all the data in the dataclass - # hass.data[DOMAIN] = dataclass - # store config # if shcedule_duration is not set, throw an error - if not entry.data.get("schedule_duration"): - _LOGGER.error("Schedule duration is not set") - return False - FRBC_data = FRBC_Config( - power_sensor_id=get_from_option_or_config("power_sensor", entry), - price_sensor_id=get_from_option_or_config("consumption_price_sensor", entry), - soc_sensor_id=get_from_option_or_config("soc_sensor", entry), - rm_discharge_sensor_id=get_from_option_or_config("rm_discharge_sensor", entry), - schedule_duration=parse_duration( - get_from_option_or_config("schedule_duration", entry) - ), - ) + if get_from_option_or_config("schedule_duration", entry) is None: + raise ConfigValidationError( + message="Schedule duration is not set", exceptions=[] + ) + + frbc_data_dict = {} + + for field in fields(FRBC_Config): + frbc_data_dict[field.name] = get_from_option_or_config(field.name, entry) + + FRBC_data = FRBC_Config(**frbc_data_dict) hass.data[DOMAIN][FRBC_CONFIG] = FRBC_data hass.data[DOMAIN]["fm_client"] = client - hass.http.register_view(WebsocketAPIView()) + hass.http.register_view(WebsocketAPIView(entry)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/flexmeasures/config_flow.py b/homeassistant/components/flexmeasures/config_flow.py index cd8d24c162b74f..53dcb13a0de4c9 100644 --- a/homeassistant/components/flexmeasures/config_flow.py +++ b/homeassistant/components/flexmeasures/config_flow.py @@ -2,34 +2,46 @@ from __future__ import annotations -import logging -from typing import Any +from collections.abc import Mapping +from typing import Any, cast from flexmeasures_client import FlexMeasuresClient +from flexmeasures_client.exceptions import EmailValidationError import voluptuous as vol -from homeassistant import config_entries -from homeassistant.config_entries import ConfigEntry, OptionsFlow -from homeassistant.core import HomeAssistant, callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import async_get_hass from homeassistant.data_entry_flow import FlowResult -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowError, + SchemaFlowFormStep, +) from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - -STEP_USER_DATA_SCHEMA = vol.Schema( +SCHEMA = vol.Schema( { - vol.Required( - "url", description={"suggested_value": "http://localhost:5000"} - ): str, + vol.Required("url", default="https://seita.energy"): str, vol.Required( "username", - description={"suggested_value": "toy-user@flexmeasures.io"}, + default="victor@seita.nl", ): str, - vol.Required("password"): str, - vol.Required("power_sensor", description={"suggested_value": 1}): int, + vol.Required("password", default="8^AT9zw'+D\"Ung,"): str, + vol.Required("power_sensor", default=5): int, + vol.Required("soc_minima_sensor_id", default=5): int, + vol.Required("soc_maxima_sensor_id", default=5): int, + vol.Required("fill_level_sensor_id", default=5): int, + vol.Required("fill_rate_sensor_id", default=5): int, + vol.Required("usage_forecast_sensor_id", default=5): int, + vol.Required("thp_fill_rate_sensor_id", default=5): int, + vol.Required("thp_efficiency_sensor_id", default=5): int, + vol.Required("nes_fill_rate_sensor_id", default=5): int, + vol.Required("nes_efficiency_sensor_id", default=5): int, + vol.Required("rm_discharge_sensor_id", default=5): int, + vol.Required("schedule_duration", default="PT24H"): str, vol.Required( "consumption_price_sensor", description={"suggested_value": 2} ): int, @@ -37,24 +49,40 @@ "production_price_sensor", description={"suggested_value": 2} ): int, vol.Required("soc_sensor", description={"suggested_value": 4}): int, - vol.Required("rm_discharge_sensor", description={"suggested_value": 5}): int, - vol.Required( - "schedule_duration", description={"suggested_value": "PT24H"} - ): str, - vol.Required("soc_unit", description={"suggested_value": "kWh"}): str, - vol.Required("soc_min", description={"suggested_value": 0.001}): float, - vol.Required("soc_max", description={"suggested_value": 0.002}): float, + vol.Required("soc_unit", default="kWh"): str, + vol.Required("soc_min", default=10.1): float, + vol.Required("soc_max", default=1.1): float, } ) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: +def get_host_and_ssl_from_url(url: str) -> tuple[str, bool]: + """Get the host and ssl from the url.""" + if url.startswith("http://"): + ssl = False + host = url.removeprefix("http://") + elif url.startswith("https://"): + ssl = True + host = url.removeprefix("https://") + else: + ssl = True + host = url + + return host, ssl + + +async def validate_input( + handler: SchemaCommonFlowHandler, data: dict[str, Any] +) -> dict: """Validate if the user input allows us to connect. - Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + Data has the keys from CONFIG_SCHEMA with values provided by the user. """ + host, ssl = get_host_and_ssl_from_url(data["url"]) + hass = async_get_hass() + # Currently used here solely for config validation (i.e. not returned to be stored in the config entry) try: client = FlexMeasuresClient( @@ -64,178 +92,68 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, password=data["password"], ssl=ssl, ) + except EmailValidationError as exception: + raise SchemaFlowError("invalid_email") from exception + except Exception as exception: - raise CannotConnect(exception) from exception + raise SchemaFlowError("invalid_auth") from exception + try: await client.get_access_token() except Exception as exception: - raise InvalidAuth(exception) from exception + raise SchemaFlowError("invalid_auth") from exception # Return info that you want to store in the config entry. - return {"title": "FlexMeasures"} - + return data -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Config flow for FlexMeasures integration.""" - VERSION = 1 +CONFIG_FLOW = { + "user": SchemaFlowFormStep(schema=SCHEMA, validate_user_input=validate_input) +} - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the initial step.""" +OPTIONS_FLOW = { + "init": SchemaFlowFormStep(schema=SCHEMA, validate_user_input=validate_input) +} - # Support a single FlexMeasures configuration only - await self.async_set_unique_id(DOMAIN) - self._abort_if_unique_id_configured() - if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) +class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config or options flow for FlexMeasures.""" - errors = {} + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW - try: - info = await validate_input(self.hass, user_input) - except CannotConnect as exception: - errors["base"] = "cannot_connect" + reauth_entry: ConfigEntry - for field in ("url", "email", "password"): - if field in str(exception): - errors[field] = str(exception) + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return "FlexMeasures" - except InvalidAuth: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - return self.async_create_entry(title=info["title"], data=user_input) + async def async_step_reauth(self, user_input=None): + """Dialog that informs the user that reauth is required.""" - # Show form again, showing captured errors - # still do invalid_auth validation error is not yet shown properly - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + self.reauth_entry = cast( + ConfigEntry, + self.hass.config_entries.async_get_entry(self.context["entry_id"]), ) - @staticmethod - @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: - """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return await self.async_step_reauth_confirm() - -class OptionsFlowHandler(config_entries.OptionsFlow): - """Handles options flow for the component.""" - - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize.""" - self.config_entry = config_entry - - async def async_step_init( - self, user_input: dict[str, Any] | None = None + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None ) -> FlowResult: - """Manage the options for the custom component.""" - errors: dict[str, str] = {} - + """Handle re-auth completion.""" if user_input is not None: - try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - # Value of data will be set on the options property of our config_entry instance. - return self.async_create_entry(title=info["title"], data=user_input) - - options_schema = vol.Schema( - { - vol.Required( - "url", default=get_previous_option(self.config_entry, "url") - ): str, - vol.Required( - "username", - default=get_previous_option(self.config_entry, "username"), - ): str, - vol.Required( - "password", - default=get_previous_option(self.config_entry, "password"), - ): str, - vol.Required( - "power_sensor", - default=get_previous_option(self.config_entry, "power_sensor"), - ): int, - vol.Required( - "consumption_price_sensor", - default=get_previous_option( - self.config_entry, "consumption_price_sensor" - ), - ): int, - vol.Required( - "production_price_sensor", - default=get_previous_option( - self.config_entry, "production_price_sensor" - ), - ): int, - vol.Required( - "soc_sensor", - default=get_previous_option(self.config_entry, "soc_sensor"), - ): int, - vol.Required( - "rm_discharge_sensor", - default=get_previous_option( - self.config_entry, "rm_discharge_sensor" - ), - ): int, - vol.Required( - "schedule_duration", - default=get_previous_option(self.config_entry, "schedule_duration"), - ): str, - vol.Required( - "soc_unit", - default=get_previous_option(self.config_entry, "soc_unit"), - ): str, - vol.Required( - "soc_min", - default=get_previous_option(self.config_entry, "soc_min"), - ): float, - vol.Required( - "soc_max", - default=get_previous_option(self.config_entry, "soc_max"), - ): float, - } - ) + self.hass.config_entries.async_update_entry( + self.reauth_entry, data=user_input + ) + # Reload the FlexMeasures config entry otherwise devices will remain unavailable + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + ) + + return self.async_abort(reason="reauth_successful") return self.async_show_form( - step_id="init", data_schema=options_schema, errors=errors + step_id="reauth_confirm", + data_schema=SCHEMA, ) - - -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" - - -def get_previous_option(config: ConfigEntry, option: str): - """Get default from previous options or otherwise from initial config.""" - return config.options.get(option, config.data[option]) - - -def get_host_and_ssl_from_url(url: str) -> tuple[str, bool]: - """Get the host and ssl from the url.""" - if url.startswith("http://"): - ssl = False - host = url.removeprefix("http://") - if url.startswith("https://"): - ssl = True - host = url.removeprefix("https://") - - return host, ssl diff --git a/homeassistant/components/flexmeasures/control_types.py b/homeassistant/components/flexmeasures/control_types.py index 816a32f085c246..18cba8131e723e 100644 --- a/homeassistant/components/flexmeasures/control_types.py +++ b/homeassistant/components/flexmeasures/control_types.py @@ -10,8 +10,23 @@ class FRBC_Config: """Dataclass for FRBC configuration.""" - power_sensor_id: int - price_sensor_id: int - soc_sensor_id: int + soc_minima_sensor_id: int + soc_maxima_sensor_id: int + + consumption_sensor_id: int + production_sensor_id: int + + fill_level_sensor_id: int + fill_rate_sensor_id: int + + usage_forecast_sensor_id: int + + thp_fill_rate_sensor_id: int + thp_efficiency_sensor_id: int + + nes_fill_rate_sensor_id: int + nes_efficiency_sensor_id: int + rm_discharge_sensor_id: int + schedule_duration: timedelta diff --git a/homeassistant/components/flexmeasures/manifest.json b/homeassistant/components/flexmeasures/manifest.json index 52af94881b08c9..d40b3d78530418 100644 --- a/homeassistant/components/flexmeasures/manifest.json +++ b/homeassistant/components/flexmeasures/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/flexmeasures", "iot_class": "cloud_polling", - "requirements": ["flexmeasures-client==0.2.0", "isodate==0.6.1"] + "requirements": ["flexmeasures-client==0.2.1"] } diff --git a/homeassistant/components/flexmeasures/services.py b/homeassistant/components/flexmeasures/services.py index d01023bfb4f12a..b7948bcd590880 100644 --- a/homeassistant/components/flexmeasures/services.py +++ b/homeassistant/components/flexmeasures/services.py @@ -7,8 +7,8 @@ from flexmeasures_client import FlexMeasuresClient from flexmeasures_client.s2.cem import CEM -from flexmeasures_client.s2.python_s2_protocol.common.schemas import ControlType import pandas as pd +from s2python.common import ControlType import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/flexmeasures/strings.json b/homeassistant/components/flexmeasures/strings.json index c0ffd7dfccd62a..de8a2ced92d17d 100644 --- a/homeassistant/components/flexmeasures/strings.json +++ b/homeassistant/components/flexmeasures/strings.json @@ -9,12 +9,20 @@ "power_sensor": "Power sensor", "consumption_price_sensor": "Consumption price sensor", "production_price_sensor": "Production price sensor", - "soc_sensor": "SOC sensor", "rm_discharge_sensor": "Resource Manager Discharge Sensor", "schedule_duration": "Schedule Duration", "soc_unit": "SOC unit", "soc_min": "Minimum SOC", - "soc_max": "Maximum SOC" + "soc_max": "Maximum SOC", + "soc_minima_sensor_id": "soc_minima_sensor_id", + "soc_maxima_sensor_id": "soc_maxima_sensor_id", + "fill_level_sensor_id": "fill_level_sensor_id", + "fill_rate_sensor_id": "fill_rate_sensor_id", + "usage_forecast_sensor_id": "usage_forecast_sensor_id", + "thp_fill_rate_sensor_id": "thp_fill_rate_sensor_id", + "thp_efficiency_sensor_id": "thp_efficiency_sensor_id", + "nes_fill_rate_sensor_id": "nes_fill_rate_sensor_id", + "nes_efficiency_sensor_id": "nes_efficiency_sensor_id" }, "data_description": { "host": "The URL of the FlexMeasures instance.", @@ -23,19 +31,133 @@ "power_sensor": "The sensor that provides the power data.", "consumption_price_sensor": "The sensor that provides the consumption price data.", "production_price_sensor": "The sensor that provides the production price data. Can be the same as the consumption price sensor.", - "soc_sensor": "The sensor that provides the state of charge data.", "rm_discharge_sensor": "The sensor that provides the resource manager discharge data.", "schedule_duration": "The duration of the schedule in minutes.", "soc_unit": "The unit of the state of charge.", "soc_min": "The minimum state of charge.", - "soc_max": "The maximum state of charge." + "soc_max": "The maximum state of charge.", + "soc_minima_sensor_id": "Minimum SOC sensor ID as given by the different FRBC.StorageDescription messages.", + "soc_maxima_sensor_id": "Maximum SOC sensor ID as given by the different FRBC.StorageDescription messages.", + "fill_level_sensor_id": "Fill level sensor ID of the NES heat storage.", + "fill_rate_sensor_id": "Fill rate sensor ID of the active actuator (THP or NES internal resitor).", + "usage_forecast_sensor_id": "Usage Forecast sensor ID.", + "thp_fill_rate_sensor_id": "Fill rate sensor ID of the Tarnoc Heat Pump.", + "thp_efficiency_sensor_id": "Tarnoc heat pump coeffient of performance (Thermal Power / Electrical Power).", + "nes_fill_rate_sensor_id": "Fill rate sensor ID of the Nestore hot water storage internal thermal resistor.", + "nes_efficiency_sensor_id": "Nestore internal resistor coeffient of performance (Thermal Power / Electrical Power)." + } + }, + "reauth_confirm": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "power_sensor": "Power sensor", + "consumption_price_sensor": "Consumption price sensor", + "production_price_sensor": "Production price sensor", + "rm_discharge_sensor": "Resource Manager Discharge Sensor", + "schedule_duration": "Schedule Duration", + "soc_unit": "SOC unit", + "soc_min": "Minimum SOC", + "soc_max": "Maximum SOC", + "soc_minima_sensor_id": "soc_minima_sensor_id", + "soc_maxima_sensor_id": "soc_maxima_sensor_id", + "fill_level_sensor_id": "fill_level_sensor_id", + "fill_rate_sensor_id": "fill_rate_sensor_id", + "usage_forecast_sensor_id": "usage_forecast_sensor_id", + "thp_fill_rate_sensor_id": "thp_fill_rate_sensor_id", + "thp_efficiency_sensor_id": "thp_efficiency_sensor_id", + "nes_fill_rate_sensor_id": "nes_fill_rate_sensor_id", + "nes_efficiency_sensor_id": "nes_efficiency_sensor_id" + }, + "data_description": { + "host": "The URL of the FlexMeasures instance.", + "username": "The email to log in to the FlexMeasures instance.", + "password": "The password to log in to the FlexMeasures instance.", + "power_sensor": "The sensor that provides the power data.", + "consumption_price_sensor": "The sensor that provides the consumption price data.", + "production_price_sensor": "The sensor that provides the production price data. Can be the same as the consumption price sensor.", + "rm_discharge_sensor": "The sensor that provides the resource manager discharge data.", + "schedule_duration": "The duration of the schedule in minutes.", + "soc_unit": "The unit of the state of charge.", + "soc_min": "The minimum state of charge.", + "soc_max": "The maximum state of charge.", + "soc_minima_sensor_id": "Minimum SOC sensor ID as given by the different FRBC.StorageDescription messages.", + "soc_maxima_sensor_id": "Maximum SOC sensor ID as given by the different FRBC.StorageDescription messages.", + "fill_level_sensor_id": "Fill level sensor ID of the NES heat storage.", + "fill_rate_sensor_id": "Fill rate sensor ID of the active actuator (THP or NES internal resitor).", + "usage_forecast_sensor_id": "Usage Forecast sensor ID.", + "thp_fill_rate_sensor_id": "Fill rate sensor ID of the Tarnoc Heat Pump.", + "thp_efficiency_sensor_id": "Tarnoc heat pump coeffient of performance (Thermal Power / Electrical Power).", + "nes_fill_rate_sensor_id": "Fill rate sensor ID of the Nestore hot water storage internal thermal resistor.", + "nes_efficiency_sensor_id": "Nestore internal resistor coeffient of performance (Thermal Power / Electrical Power)." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_email": "Invalid email" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "power_sensor": "Power sensor", + "consumption_price_sensor": "Consumption price sensor", + "production_price_sensor": "Production price sensor", + "rm_discharge_sensor": "Resource Manager Discharge Sensor", + "schedule_duration": "Schedule Duration", + "soc_unit": "SOC unit", + "soc_min": "Minimum SOC", + "soc_max": "Maximum SOC", + "soc_minima_sensor_id": "soc_minima_sensor_id", + "soc_maxima_sensor_id": "soc_maxima_sensor_id", + "fill_level_sensor_id": "fill_level_sensor_id", + "fill_rate_sensor_id": "fill_rate_sensor_id", + "usage_forecast_sensor_id": "usage_forecast_sensor_id", + "thp_fill_rate_sensor_id": "thp_fill_rate_sensor_id", + "thp_efficiency_sensor_id": "thp_efficiency_sensor_id", + "nes_fill_rate_sensor_id": "nes_fill_rate_sensor_id", + "nes_efficiency_sensor_id": "nes_efficiency_sensor_id" + }, + "data_description": { + "host": "The URL of the FlexMeasures instance.", + "username": "The email to log in to the FlexMeasures instance.", + "password": "The password to log in to the FlexMeasures instance.", + "power_sensor": "The sensor that provides the power data.", + "consumption_price_sensor": "The sensor that provides the consumption price data.", + "production_price_sensor": "The sensor that provides the production price data. Can be the same as the consumption price sensor.", + "rm_discharge_sensor": "The sensor that provides the resource manager discharge data.", + "schedule_duration": "The duration of the schedule in minutes.", + "soc_unit": "The unit of the state of charge.", + "soc_min": "The minimum state of charge.", + "soc_max": "The maximum state of charge.", + "soc_minima_sensor_id": "Minimum SOC sensor ID as given by the different FRBC.StorageDescription messages.", + "soc_maxima_sensor_id": "Maximum SOC sensor ID as given by the different FRBC.StorageDescription messages.", + "fill_level_sensor_id": "Fill level sensor ID of the NES heat storage.", + "fill_rate_sensor_id": "Fill rate sensor ID of the active actuator (THP or NES internal resitor).", + "usage_forecast_sensor_id": "Usage Forecast sensor ID.", + "thp_fill_rate_sensor_id": "Fill rate sensor ID of the Tarnoc Heat Pump.", + "thp_efficiency_sensor_id": "Tarnoc heat pump coeffient of performance (Thermal Power / Electrical Power).", + "nes_fill_rate_sensor_id": "Fill rate sensor ID of the Nestore hot water storage internal thermal resistor.", + "nes_efficiency_sensor_id": "Nestore internal resistor coeffient of performance (Thermal Power / Electrical Power)." } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_email": "Invalid email" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" diff --git a/homeassistant/components/flexmeasures/websockets.py b/homeassistant/components/flexmeasures/websockets.py index 70efe84d19c81e..2bc01f4fd13457 100644 --- a/homeassistant/components/flexmeasures/websockets.py +++ b/homeassistant/components/flexmeasures/websockets.py @@ -10,7 +10,11 @@ import aiohttp from aiohttp import web from flexmeasures_client.s2.cem import CEM -from flexmeasures_client.s2.control_types.FRBC.frbc_simple import FRBCSimple +from flexmeasures_client.s2.control_types.FRBC.frbc_tunes import ( + FillRateBasedControlTUNES, +) +from flexmeasures_client.s2.utils import get_unique_id +from s2python.common import EnergyManagementRole, Handshake from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant @@ -28,10 +32,17 @@ class WebsocketAPIView(HomeAssistantView): url: str = WS_VIEW_URI requires_auth: bool = False + def __init__(self, entry) -> None: + """Initialize websocket view.""" + super().__init__() + self.entry = entry + async def get(self, request: web.Request) -> web.WebSocketResponse: """Handle an incoming websocket connection.""" - return await WebSocketHandler(request.app["hass"], request).async_handle() + return await WebSocketHandler( + request.app["hass"], self.entry, request + ).async_handle() class WebSocketAdapter(logging.LoggerAdapter): @@ -49,15 +60,16 @@ class WebSocketHandler: cem: CEM - def __init__(self, hass: HomeAssistant, request: web.Request) -> None: + def __init__(self, hass: HomeAssistant, entry, request: web.Request) -> None: """Initialize an active connection.""" self.hass = hass self.request = request - self.wsock = web.WebSocketResponse(heartbeat=55) + self.entry = entry + self.wsock = web.WebSocketResponse(heartbeat=None) self.cem = CEM(fm_client=hass.data[DOMAIN]["fm_client"]) frbc_data: FRBC_Config = hass.data[DOMAIN]["frbc_config"] - frbc = FRBCSimple(**asdict(frbc_data)) + frbc = FillRateBasedControlTUNES(**asdict(frbc_data)) hass.data[DOMAIN]["cem"] = self.cem self.cem.register_control_type(frbc) @@ -74,28 +86,41 @@ async def _websocket_producer(self): self._logger.debug(message) - await self.wsock.send_json(message) + try: + await self.wsock.send_json(message) + except ConnectionResetError: + await cem.close() async def _websocket_consumer(self): """Process incoming messages.""" cem = self.cem - async for msg in self.wsock: - message = msg.json() - - self._logger.debug(message) - - if msg.type == aiohttp.WSMsgType.TEXT: - if msg.data == "close": - cem.close() - await self.wsock.close() - else: - # process message - # breakpoint() - await cem.handle_message(message) - - elif msg.type == aiohttp.WSMsgType.ERROR: - cem.close() + handshake_message = Handshake( + message_id=get_unique_id(), + role=EnergyManagementRole.CEM, + supported_protocol_versions=["0.1.0"], + ) + await cem.send_message(handshake_message) + try: + async for msg in self.wsock: + message = msg.json() + + self._logger.debug(message) + self._logger.debug(msg.type) + + if msg.type == aiohttp.WSMsgType.TEXT: + if msg.data == "close": + await cem.close() + await self.wsock.close() + else: + await cem.handle_message(message) + + elif msg.type == aiohttp.WSMsgType.ERROR: + await cem.close() + except Exception: # pylint: disable=broad-exception-caught + self.entry.async_start_reauth(self.hass) + finally: + await cem.close() async def async_handle(self) -> web.WebSocketResponse: """Handle a websocket response.""" @@ -103,12 +128,17 @@ async def async_handle(self) -> web.WebSocketResponse: request = self.request wsock = self.wsock - await wsock.prepare(request) + try: + await wsock.prepare(request) - # create "parallel" tasks for the message producer and consumer - await asyncio.gather( - self._websocket_consumer(), - self._websocket_producer(), - ) + # create "parallel" tasks for the message producer and consumer + await asyncio.gather( + self._websocket_consumer(), + self._websocket_producer(), + ) + + except ConnectionResetError: + await self.cem.close() + self.entry.async_start_reauth(self.hass) return wsock diff --git a/requirements_all.txt b/requirements_all.txt index b0d9407ae16d3f..eccd400cf98e25 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -843,7 +843,7 @@ fjaraskupan==2.2.0 flexit_bacnet==2.1.0 # homeassistant.components.flexmeasures -flexmeasures-client==0.2.0 +flexmeasures-client==0.2.1 # homeassistant.components.flipr flipr-api==1.5.0 @@ -1128,9 +1128,6 @@ iperf3==0.1.11 # homeassistant.components.gogogate2 ismartgate==5.0.1 -# homeassistant.components.flexmeasures -isodate==0.6.1 - # homeassistant.components.file_upload janus==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1ef0fb8024c43c..2c20af0e629ac7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -678,7 +678,7 @@ fjaraskupan==2.2.0 flexit_bacnet==2.1.0 # homeassistant.components.flexmeasures -flexmeasures-client==0.2.0 +flexmeasures-client==0.2.1 # homeassistant.components.flipr flipr-api==1.5.0 @@ -900,9 +900,6 @@ intellifire4py==2.2.2 # homeassistant.components.gogogate2 ismartgate==5.0.1 -# homeassistant.components.flexmeasures -isodate==0.6.1 - # homeassistant.components.file_upload janus==1.0.0 diff --git a/tests/components/flexmeasures/test_config_flow.py b/tests/components/flexmeasures/test_config_flow.py index d30c75461b7164..9ce4e6db8c641f 100644 --- a/tests/components/flexmeasures/test_config_flow.py +++ b/tests/components/flexmeasures/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import patch from homeassistant import config_entries, data_entry_flow +from homeassistant.components.flexmeasures.config_flow import SCHEMA from homeassistant.components.flexmeasures.const import DOMAIN from homeassistant.core import HomeAssistant @@ -11,7 +12,7 @@ "url": "http://localhost:5000", "power_sensor": 1, "soc_sensor": 3, - "rm_discharge_sensor": 4, + "rm_discharge_sensor_id": 4, "schedule_duration": "PT24H", "consumption_price_sensor": 2, "production_price_sensor": 2, @@ -28,12 +29,11 @@ async def test_form(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.FlowResultType.FORM assert (result["errors"] == {}) or result["errors"] is None with patch( - "homeassistant.components.flexmeasures.config_flow.validate_input", - return_value={"title": "FlexMeasures"}, + "flexmeasures_client.FlexMeasuresClient.get_access_token", ) as mock_validate_input, patch( "homeassistant.components.flexmeasures.async_setup_entry", return_value=True, @@ -44,9 +44,16 @@ async def test_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result2["title"] == "FlexMeasures" - assert result2["data"] == CONFIG + assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["title"] == "FlexMeasures" - mock_setup_entry.assert_called_once() - mock_validate_input.assert_called_once() + fields = {str(key): key for key in SCHEMA.schema} + + for key, val in result2["options"].items(): + if key in CONFIG: + assert val == CONFIG[key] + else: + assert val == fields[key].default() + + mock_setup_entry.assert_called_once() + mock_validate_input.assert_called_once() diff --git a/tests/components/flexmeasures/test_services.py b/tests/components/flexmeasures/test_services.py index 83567b898b5be6..8955e5dc2b2618 100644 --- a/tests/components/flexmeasures/test_services.py +++ b/tests/components/flexmeasures/test_services.py @@ -3,9 +3,8 @@ from datetime import datetime from unittest.mock import patch -from flexmeasures_client.s2.python_s2_protocol.common.schemas import ControlType -import isodate import pytest +from s2python.common import ControlType from homeassistant.components.flexmeasures.const import ( DOMAIN, @@ -15,6 +14,7 @@ from homeassistant.components.flexmeasures.services import time_ceil from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util +from homeassistant.util.dt import parse_duration @pytest.mark.skip( @@ -58,9 +58,7 @@ async def test_trigger_and_get_schedule( tzinfo = dt_util.get_time_zone(hass.config.time_zone) mocked_FlexmeasuresClient.assert_awaited_with( sensor_id=1, - start=time_ceil( - datetime.now(tz=tzinfo), isodate.parse_duration(RESOLUTION) - ), + start=time_ceil(datetime.now(tz=tzinfo), parse_duration(RESOLUTION)), duration="PT24H", flex_model={ "soc-unit": "kWh", diff --git a/tests/components/flexmeasures/test_websockets.py b/tests/components/flexmeasures/test_websockets.py index 4492bca9d6e550..0749c68775ead0 100644 --- a/tests/components/flexmeasures/test_websockets.py +++ b/tests/components/flexmeasures/test_websockets.py @@ -10,7 +10,7 @@ async def test_producer_consumer( ): """Test websocket connection.""" message = { - "message_id": "1234-1234-1234-1234", + "message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", "role": "RM", "supported_protocol_versions": ["0.1.0"], "message_type": "Handshake", @@ -18,4 +18,5 @@ async def test_producer_consumer( await fm_websocket_client.send_json(message) msg = await fm_websocket_client.receive_json() - assert msg["message_type"] == "HandshakeResponse" + assert msg["message_type"] == "Handshake" + assert msg["role"] == "CEM"