diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bc6b6cdf..18168cae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,13 @@ Versions from 0.40 and up -## Ongoing +## v0.55.5 +- Rework quality improvements from Core Quality Scale + +## v0.55.4 + +- Link to plugwise [v1.6.4](https://github.com/plugwise/python-plugwise/releases/tag/v1.6.4) - Internal: Adjustments in the CI process ## v0.55.3 diff --git a/custom_components/plugwise/binary_sensor.py b/custom_components/plugwise/binary_sensor.py index 452b10d67..0964be39b 100644 --- a/custom_components/plugwise/binary_sensor.py +++ b/custom_components/plugwise/binary_sensor.py @@ -40,7 +40,8 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity -PARALLEL_UPDATES = 0 # Upstream +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 @dataclass(frozen=True) @@ -54,7 +55,6 @@ class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription): PLUGWISE_BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( PlugwiseBinarySensorEntityDescription( key=BATTERY_STATE, - translation_key=BATTERY_STATE, device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, ), diff --git a/custom_components/plugwise/button.py b/custom_components/plugwise/button.py index 8049f611a..28311f911 100644 --- a/custom_components/plugwise/button.py +++ b/custom_components/plugwise/button.py @@ -17,7 +17,7 @@ from .entity import PlugwiseEntity from .util import plugwise_command -PARALLEL_UPDATES = 0 # Upstream +PARALLEL_UPDATES = 0 async def async_setup_entry( diff --git a/custom_components/plugwise/climate.py b/custom_components/plugwise/climate.py index 0b40158f3..8f96f8c6c 100644 --- a/custom_components/plugwise/climate.py +++ b/custom_components/plugwise/climate.py @@ -56,7 +56,7 @@ from .entity import PlugwiseEntity from .util import plugwise_command -PARALLEL_UPDATES = 0 # Upstream +PARALLEL_UPDATES = 0 async def async_setup_entry( diff --git a/custom_components/plugwise/config_flow.py b/custom_components/plugwise/config_flow.py index 2cfcaca03..050f79d2d 100644 --- a/custom_components/plugwise/config_flow.py +++ b/custom_components/plugwise/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from copy import deepcopy +import logging from typing import Any, Self from plugwise import Smile @@ -69,18 +70,25 @@ type PlugwiseConfigEntry = ConfigEntry[PlugwiseDataUpdateCoordinator] +_LOGGER = logging.getLogger(__name__) + # Upstream basically the whole file (excluding the pw-beta options) +SMILE_RECONF_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + -def base_schema( - cf_input: ZeroconfServiceInfo | dict[str, Any] | None, -) -> vol.Schema: +def smile_user_schema(cf_input: ZeroconfServiceInfo | dict[str, Any] | None) -> vol.Schema: """Generate base schema for gateways.""" if not cf_input: # no discovery- or user-input available return vol.Schema( { vol.Required(CONF_HOST): str, vol.Required(CONF_PASSWORD): str, + # Port under investigation for removal (hence not added in #132878) vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, vol.Required(CONF_USERNAME, default=SMILE): vol.In( {SMILE: FLOW_SMILE, STRETCH: FLOW_STRETCH} @@ -106,7 +114,7 @@ def base_schema( async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> Smile: """Validate whether the user input allows us to connect to the gateway. - Data has the keys from base_schema() with values provided by the user. + Data has the keys from the schema with values provided by the user. """ websession = async_get_clientsession(hass, verify_ssl=False) api = Smile( @@ -120,6 +128,32 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> Smile: return api +async def verify_connection( + hass: HomeAssistant, user_input: dict[str, Any] +) -> tuple[Smile | None, dict[str, str]]: + """Verify and return the gateway connection or an error.""" + errors: dict[str, str] = {} + + try: + return (await validate_input(hass, user_input), errors) + except ConnectionFailedError: + errors[CONF_BASE] = "cannot_connect" + except InvalidAuthentication: + errors[CONF_BASE] = "invalid_auth" + except InvalidSetupError: + errors[CONF_BASE] = "invalid_setup" + except (InvalidXMLError, ResponseError): + errors[CONF_BASE] = "response_error" + except UnsupportedDeviceError: + errors[CONF_BASE] = "unsupported" + except Exception: # noqa: BLE001 + _LOGGER.exception( + "Unknown exception while verifying connection with your Plugwise Smile" + ) + errors[CONF_BASE] = "unknown" + return (None, errors) + + class PlugwiseConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Plugwise Smile.""" @@ -197,51 +231,71 @@ def is_matching(self, other_flow: Self) -> bool: return False + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step when using network/gateway setups.""" errors: dict[str, str] = {} - if not user_input: - return self.async_show_form( - step_id=SOURCE_USER, - data_schema=base_schema(self.discovery_info), - errors=errors, - ) - - if self.discovery_info: - user_input[CONF_HOST] = self.discovery_info.host - user_input[CONF_PORT] = self.discovery_info.port - user_input[CONF_USERNAME] = self._username - - try: - api = await validate_input(self.hass, user_input) - except ConnectionFailedError: - errors[CONF_BASE] = "cannot_connect" - except InvalidAuthentication: - errors[CONF_BASE] = "invalid_auth" - except InvalidSetupError: - errors[CONF_BASE] = "invalid_setup" - except (InvalidXMLError, ResponseError): - errors[CONF_BASE] = "response_error" - except UnsupportedDeviceError: - errors[CONF_BASE] = "unsupported" - except Exception: # noqa: BLE001 - errors[CONF_BASE] = "unknown" - - if errors: - return self.async_show_form( - step_id=SOURCE_USER, - data_schema=base_schema(user_input), - errors=errors, - ) - - await self.async_set_unique_id( - api.smile_hostname or api.gateway_id, raise_on_progress=False + if user_input is not None: + if self.discovery_info: + user_input[CONF_HOST] = self.discovery_info.host + user_input[CONF_PORT] = self.discovery_info.port + user_input[CONF_USERNAME] = self._username + + api, errors = await verify_connection(self.hass, user_input) + if api: + await self.async_set_unique_id( + api.smile_hostname or api.gateway_id, raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=api.smile_name, data=user_input) + + return self.async_show_form( + step_id=SOURCE_USER, + data_schema=smile_user_schema(self.discovery_info), + errors=errors, ) - self._abort_if_unique_id_configured() - return self.async_create_entry(title=api.smile_name, data=user_input) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + + reconfigure_entry = self._get_reconfigure_entry() + + if user_input: + # Redefine ingest existing username and password + full_input = { + CONF_HOST: user_input.get(CONF_HOST), + CONF_PORT: reconfigure_entry.data.get(CONF_PORT), + CONF_USERNAME: reconfigure_entry.data.get(CONF_USERNAME), + CONF_PASSWORD: reconfigure_entry.data.get(CONF_PASSWORD), + } + + api, errors = await verify_connection(self.hass, full_input) + if api: + await self.async_set_unique_id( + api.smile_hostname or api.gateway_id, raise_on_progress=False + ) + self._abort_if_unique_id_mismatch(reason="not_the_same_smile") + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates=full_input, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=SMILE_RECONF_SCHEMA, + suggested_values=reconfigure_entry.data, + ), + description_placeholders={"title": reconfigure_entry.title}, + errors=errors, + ) + @staticmethod @callback diff --git a/custom_components/plugwise/coordinator.py b/custom_components/plugwise/coordinator.py index 22aa78abc..27f0ac088 100644 --- a/custom_components/plugwise/coordinator.py +++ b/custom_components/plugwise/coordinator.py @@ -97,17 +97,31 @@ async def _async_update_data(self) -> PlugwiseData: await self._connect() data = await self.api.async_update() except ConnectionFailedError as err: - raise UpdateFailed("Failed to connect") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="failed_to_connect", + ) from err except InvalidAuthentication as err: - raise ConfigEntryError("Authentication failed") from err + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="authentication_failed", + ) from err except (InvalidXMLError, ResponseError) as err: + # pwbeta TODO; we had {err} in the text, but not upstream, do we want this? raise UpdateFailed( - f"Invalid XML data or error from Plugwise device: {err}" + translation_domain=DOMAIN, + translation_key="invalid_xml_data", ) from err except PlugwiseError as err: - raise UpdateFailed("Data incomplete or missing") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="data_incomplete_or_missing", + ) from err except UnsupportedDeviceError as err: - raise ConfigEntryError("Device with unsupported firmware") from err + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="unsupported_firmware", + ) from err else: LOGGER.debug(f"{self.api.smile_name} data: %s", data) await self.async_add_remove_devices(data, self.config_entry) diff --git a/custom_components/plugwise/entity.py b/custom_components/plugwise/entity.py index 446c3603a..c0d848cb4 100644 --- a/custom_components/plugwise/entity.py +++ b/custom_components/plugwise/entity.py @@ -93,8 +93,3 @@ def available(self) -> bool: def device(self) -> GwEntityData: """Return data for this device.""" return self.coordinator.data.devices[self._dev_id] - - async def async_added_to_hass(self) -> None: - """Subscribe to updates.""" - self._handle_coordinator_update() - await super().async_added_to_hass() diff --git a/custom_components/plugwise/manifest.json b/custom_components/plugwise/manifest.json index 8086dd9bc..74ca1ef75 100644 --- a/custom_components/plugwise/manifest.json +++ b/custom_components/plugwise/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["plugwise"], "requirements": ["plugwise==1.6.4"], - "version": "0.55.4", + "version": "0.55.5", "zeroconf": ["_plugwise._tcp.local."] } diff --git a/custom_components/plugwise/number.py b/custom_components/plugwise/number.py index 79288acd9..992a13ffc 100644 --- a/custom_components/plugwise/number.py +++ b/custom_components/plugwise/number.py @@ -31,7 +31,7 @@ from .entity import PlugwiseEntity from .util import plugwise_command -PARALLEL_UPDATES = 0 # Upstream +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) diff --git a/custom_components/plugwise/select.py b/custom_components/plugwise/select.py index ebc291314..8cc7aa2f7 100644 --- a/custom_components/plugwise/select.py +++ b/custom_components/plugwise/select.py @@ -33,7 +33,7 @@ from .entity import PlugwiseEntity from .util import plugwise_command -PARALLEL_UPDATES = 0 # Upstream +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) diff --git a/custom_components/plugwise/sensor.py b/custom_components/plugwise/sensor.py index 156d3ac1f..17daf457d 100644 --- a/custom_components/plugwise/sensor.py +++ b/custom_components/plugwise/sensor.py @@ -83,6 +83,7 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity +# Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 diff --git a/custom_components/plugwise/strings.json b/custom_components/plugwise/strings.json index 813af6977..fca1df7fa 100644 --- a/custom_components/plugwise/strings.json +++ b/custom_components/plugwise/strings.json @@ -18,6 +18,13 @@ }, "config": { "step": { + "reconfigure": { + "description": "Update configuration for {title}.", + "data": { + "host": "IP-address", + "port": "Port number" + } + }, "user": { "title": "Set up Plugwise Adam/Smile/Stretch", "description": "Enter your Plugwise device: (setup can take up to 90s)", @@ -42,14 +49,13 @@ }, "abort": { "already_configured": "This device is already configured", - "anna_with_adam": "Both Anna and Adam detected. Add your Adam instead of your Anna" + "anna_with_adam": "Both Anna and Adam detected. Add your Adam instead of your Anna", + "not_the_same_smile": "The configured Smile ID does not match the Smile ID on the requested IP address.", + "reconfigure_successful": "Reconfiguration successful" } }, "entity": { "binary_sensor": { - "low_battery": { - "name": "Battery state" - }, "compressor_state": { "name": "Compressor state" }, @@ -296,6 +302,26 @@ } } }, + "exceptions": { + "authentication_failed": { + "message": "Invalid authentication" + }, + "data_incomplete_or_missing": { + "message": "Data incomplete or missing." + }, + "error_communicating_with_api": { + "message": "Error communicating with API: {error}." + }, + "failed_to_connect": { + "message": "Failed to connect" + }, + "invalid_xml_data": { + "message": "Invalid XML data, or error indication received from the Plugwise Adam/Smile/Stretch" + }, + "unsupported_firmware": { + "message": "Device with unsupported firmware" + } + }, "services": { "delete_notification": { "name": "Delete Plugwise notification", diff --git a/custom_components/plugwise/switch.py b/custom_components/plugwise/switch.py index d56a7889f..edb97d488 100644 --- a/custom_components/plugwise/switch.py +++ b/custom_components/plugwise/switch.py @@ -32,7 +32,7 @@ from .entity import PlugwiseEntity from .util import plugwise_command -PARALLEL_UPDATES = 0 # Upstream +PARALLEL_UPDATES = 0 @dataclass(frozen=True) diff --git a/custom_components/plugwise/translations/en.json b/custom_components/plugwise/translations/en.json index 813af6977..46734d066 100644 --- a/custom_components/plugwise/translations/en.json +++ b/custom_components/plugwise/translations/en.json @@ -1,305 +1,331 @@ { - "options": { - "step": { - "none": { - "title": "No Options available", - "description": "This Integration does not provide any Options" - }, - "init": { - "description": "Adjust Smile/Stretch Options", - "data": { - "cooling_on": "Anna: cooling-mode is on", - "scan_interval": "Scan Interval (seconds) *) beta-only option", - "homekit_emulation": "Homekit emulation (i.e. on hvac_off => Away) *) beta-only option", - "refresh_interval": "Frontend refresh-time (1.5 - 5 seconds) *) beta-only option" - } - } - } - }, - "config": { - "step": { - "user": { - "title": "Set up Plugwise Adam/Smile/Stretch", - "description": "Enter your Plugwise device: (setup can take up to 90s)", - "data": { - "password": "ID", - "username": "Username", - "host": "IP-address", - "port": "Port number" + "config": { + "abort": { + "already_configured": "This device is already configured", + "anna_with_adam": "Both Anna and Adam detected. Add your Adam instead of your Anna", + "not_the_same_smile": "The configured Smile ID does not match the Smile ID on the requested IP address.", + "reconfigure_successful": "Reconfiguration successful" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Authentication failed", + "invalid_setup": "Add your Adam instead of your Anna, see the documentation", + "network_down": "Plugwise Zigbee network is down", + "network_timeout": "Network communication timeout", + "response_error": "Invalid XML data, or error indication received", + "stick_init": "Initialization of Plugwise USB-stick failed", + "unknown": "Unknown error!", + "unsupported": "Device with unsupported firmware" + }, + "step": { + "reconfigure": { + "data": { + "host": "IP-address", + "port": "Port number" + }, + "description": "Update configuration for {title}." + }, + "user": { + "data": { + "host": "IP-address", + "password": "ID", + "port": "Port number", + "username": "Username" + }, + "description": "Enter your Plugwise device: (setup can take up to 90s)", + "title": "Set up Plugwise Adam/Smile/Stretch" + } } - } - }, - "error": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Authentication failed", - "invalid_setup": "Add your Adam instead of your Anna, see the documentation", - "network_down": "Plugwise Zigbee network is down", - "network_timeout": "Network communication timeout", - "response_error": "Invalid XML data, or error indication received", - "stick_init": "Initialization of Plugwise USB-stick failed", - "unknown": "Unknown error!", - "unsupported": "Device with unsupported firmware" - }, - "abort": { - "already_configured": "This device is already configured", - "anna_with_adam": "Both Anna and Adam detected. Add your Adam instead of your Anna" - } - }, - "entity": { - "binary_sensor": { - "low_battery": { - "name": "Battery state" - }, - "compressor_state": { - "name": "Compressor state" - }, - "cooling_enabled": { - "name": "Cooling enabled" - }, - "dhw_state": { - "name": "DHW state" - }, - "flame_state": { - "name": "Flame state" - }, - "heating_state": { - "name": "Heating" - }, - "cooling_state": { - "name": "Cooling" - }, - "secondary_boiler_state": { - "name": "Secondary boiler state" - }, - "plugwise_notification": { - "name": "Plugwise notification" - } }, - "button": { - "reboot": { - "name": "Reboot" - } - }, - "climate": { - "plugwise": { - "state_attributes": { - "preset_mode": { - "state": { - "asleep": "Night", - "away": "Away", - "home": "Home", - "no_frost": "Anti-frost", - "vacation": "Vacation" + "entity": { + "binary_sensor": { + "compressor_state": { + "name": "Compressor state" + }, + "cooling_enabled": { + "name": "Cooling enabled" + }, + "cooling_state": { + "name": "Cooling" + }, + "dhw_state": { + "name": "DHW state" + }, + "flame_state": { + "name": "Flame state" + }, + "heating_state": { + "name": "Heating" + }, + "plugwise_notification": { + "name": "Plugwise notification" + }, + "secondary_boiler_state": { + "name": "Secondary boiler state" + } + }, + "button": { + "reboot": { + "name": "Reboot" + } + }, + "climate": { + "plugwise": { + "state_attributes": { + "preset_mode": { + "state": { + "asleep": "Night", + "away": "Away", + "home": "Home", + "no_frost": "Anti-frost", + "vacation": "Vacation" + } + } + } + } + }, + "number": { + "max_dhw_temperature": { + "name": "Domestic hot water setpoint" + }, + "maximum_boiler_temperature": { + "name": "Maximum boiler temperature setpoint" + }, + "temperature_offset": { + "name": "Temperature offset" + } + }, + "select": { + "dhw_mode": { + "name": "DHW mode", + "state": { + "auto": "Auto", + "boost": "Boost", + "comfort": "Comfort", + "off": "Off" + } + }, + "gateway_mode": { + "name": "Gateway mode", + "state": { + "away": "Pause", + "full": "Normal", + "vacation": "Vacation" + } + }, + "regulation_mode": { + "name": "Regulation mode", + "state": { + "bleeding_cold": "Bleeding cold", + "bleeding_hot": "Bleeding hot", + "cooling": "Cooling", + "heating": "Heating", + "off": "Off" + } + }, + "select_schedule": { + "name": "Thermostat schedule", + "state": { + "off": "Off" + } + } + }, + "sensor": { + "cooling_setpoint": { + "name": "Cooling setpoint" + }, + "dhw_temperature": { + "name": "DHW temperature" + }, + "domestic_hot_water_setpoint": { + "name": "DHW setpoint" + }, + "electricity_consumed": { + "name": "Electricity consumed" + }, + "electricity_consumed_interval": { + "name": "Electricity consumed interval" + }, + "electricity_consumed_off_peak_cumulative": { + "name": "Electricity consumed off peak cumulative" + }, + "electricity_consumed_off_peak_interval": { + "name": "Electricity consumed off peak interval" + }, + "electricity_consumed_off_peak_point": { + "name": "Electricity consumed off peak point" + }, + "electricity_consumed_peak_cumulative": { + "name": "Electricity consumed peak cumulative" + }, + "electricity_consumed_peak_interval": { + "name": "Electricity consumed peak interval" + }, + "electricity_consumed_peak_point": { + "name": "Electricity consumed peak point" + }, + "electricity_consumed_point": { + "name": "Electricity consumed point" + }, + "electricity_phase_one_consumed": { + "name": "Electricity phase one consumed" + }, + "electricity_phase_one_produced": { + "name": "Electricity phase one produced" + }, + "electricity_phase_three_consumed": { + "name": "Electricity phase three consumed" + }, + "electricity_phase_three_produced": { + "name": "Electricity phase three produced" + }, + "electricity_phase_two_consumed": { + "name": "Electricity phase two consumed" + }, + "electricity_phase_two_produced": { + "name": "Electricity phase two produced" + }, + "electricity_produced": { + "name": "Electricity produced" + }, + "electricity_produced_interval": { + "name": "Electricity produced interval" + }, + "electricity_produced_off_peak_cumulative": { + "name": "Electricity produced off peak cumulative" + }, + "electricity_produced_off_peak_interval": { + "name": "Electricity produced off peak interval" + }, + "electricity_produced_off_peak_point": { + "name": "Electricity produced off peak point" + }, + "electricity_produced_peak_cumulative": { + "name": "Electricity produced peak cumulative" + }, + "electricity_produced_peak_interval": { + "name": "Electricity produced peak interval" + }, + "electricity_produced_peak_point": { + "name": "Electricity produced peak point" + }, + "electricity_produced_point": { + "name": "Electricity produced point" + }, + "gas_consumed_cumulative": { + "name": "Gas consumed cumulative" + }, + "gas_consumed_interval": { + "name": "Gas consumed interval" + }, + "heating_setpoint": { + "name": "Heating setpoint" + }, + "intended_boiler_temperature": { + "name": "Intended boiler temperature" + }, + "maximum_boiler_temperature": { + "name": "Maximum boiler temperature setpoint" + }, + "modulation_level": { + "name": "Modulation level" + }, + "net_electricity_cumulative": { + "name": "Net electricity cumulative" + }, + "net_electricity_point": { + "name": "Net electricity point" + }, + "outdoor_air_temperature": { + "name": "Outdoor air temperature" + }, + "outdoor_temperature": { + "name": "Outdoor temperature" + }, + "return_temperature": { + "name": "Return temperature" + }, + "setpoint": { + "name": "Setpoint" + }, + "temperature_difference": { + "name": "Temperature difference" + }, + "valve_position": { + "name": "Valve position" + }, + "voltage_phase_one": { + "name": "Voltage phase one" + }, + "voltage_phase_three": { + "name": "Voltage phase three" + }, + "voltage_phase_two": { + "name": "Voltage phase two" + }, + "water_pressure": { + "name": "Water pressure" + }, + "water_temperature": { + "name": "Water temperature" + } + }, + "switch": { + "cooling_ena_switch": { + "name": "Cooling" + }, + "dhw_cm_switch": { + "name": "DHW comfort mode" + }, + "lock": { + "name": "Lock" + }, + "relay": { + "name": "Relay" } - } } - } - }, - "number": { - "maximum_boiler_temperature": { - "name": "Maximum boiler temperature setpoint" - }, - "max_dhw_temperature": { - "name": "Domestic hot water setpoint" - }, - "temperature_offset": { - "name": "Temperature offset" - } }, - "select": { - "dhw_mode": { - "name": "DHW mode", - "state": { - "auto": "Auto", - "boost": "Boost", - "comfort": "Comfort", - "off": "Off" - } - }, - "regulation_mode": { - "name": "Regulation mode", - "state": { - "bleeding_cold": "Bleeding cold", - "bleeding_hot": "Bleeding hot", - "cooling": "Cooling", - "heating": "Heating", - "off": "Off" - } - }, - "gateway_mode": { - "name": "Gateway mode", - "state": { - "away": "Pause", - "full": "Normal", - "vacation": "Vacation" + "exceptions": { + "authentication_failed": { + "message": "Invalid authentication" + }, + "data_incomplete_or_missing": { + "message": "Data incomplete or missing." + }, + "error_communicating_with_api": { + "message": "Error communicating with API: {error}." + }, + "failed_to_connect": { + "message": "Failed to connect" + }, + "invalid_xml_data": { + "message": "Invalid XML data, or error indication received from the Plugwise Adam/Smile/Stretch" + }, + "unsupported_firmware": { + "message": "Device with unsupported firmware" } - }, - "select_schedule": { - "name": "Thermostat schedule", - "state": { - "off": "Off" - } - } }, - "sensor": { - "setpoint": { - "name": "Setpoint" - }, - "cooling_setpoint": { - "name": "Cooling setpoint" - }, - "heating_setpoint": { - "name": "Heating setpoint" - }, - "intended_boiler_temperature": { - "name": "Intended boiler temperature" - }, - "temperature_difference": { - "name": "Temperature difference" - }, - "outdoor_temperature": { - "name": "Outdoor temperature" - }, - "outdoor_air_temperature": { - "name": "Outdoor air temperature" - }, - "water_temperature": { - "name": "Water temperature" - }, - "return_temperature": { - "name": "Return temperature" - }, - "electricity_consumed": { - "name": "Electricity consumed" - }, - "electricity_produced": { - "name": "Electricity produced" - }, - "electricity_consumed_point": { - "name": "Electricity consumed point" - }, - "electricity_produced_point": { - "name": "Electricity produced point" - }, - "electricity_consumed_interval": { - "name": "Electricity consumed interval" - }, - "electricity_consumed_peak_interval": { - "name": "Electricity consumed peak interval" - }, - "electricity_consumed_off_peak_interval": { - "name": "Electricity consumed off peak interval" - }, - "electricity_produced_interval": { - "name": "Electricity produced interval" - }, - "electricity_produced_peak_interval": { - "name": "Electricity produced peak interval" - }, - "electricity_produced_off_peak_interval": { - "name": "Electricity produced off peak interval" - }, - "electricity_consumed_off_peak_point": { - "name": "Electricity consumed off peak point" - }, - "electricity_consumed_peak_point": { - "name": "Electricity consumed peak point" - }, - "electricity_consumed_off_peak_cumulative": { - "name": "Electricity consumed off peak cumulative" - }, - "electricity_consumed_peak_cumulative": { - "name": "Electricity consumed peak cumulative" - }, - "electricity_produced_off_peak_point": { - "name": "Electricity produced off peak point" - }, - "electricity_produced_peak_point": { - "name": "Electricity produced peak point" - }, - "electricity_produced_off_peak_cumulative": { - "name": "Electricity produced off peak cumulative" - }, - "electricity_produced_peak_cumulative": { - "name": "Electricity produced peak cumulative" - }, - "electricity_phase_one_consumed": { - "name": "Electricity phase one consumed" - }, - "electricity_phase_two_consumed": { - "name": "Electricity phase two consumed" - }, - "electricity_phase_three_consumed": { - "name": "Electricity phase three consumed" - }, - "electricity_phase_one_produced": { - "name": "Electricity phase one produced" - }, - "electricity_phase_two_produced": { - "name": "Electricity phase two produced" - }, - "electricity_phase_three_produced": { - "name": "Electricity phase three produced" - }, - "voltage_phase_one": { - "name": "Voltage phase one" - }, - "voltage_phase_two": { - "name": "Voltage phase two" - }, - "voltage_phase_three": { - "name": "Voltage phase three" - }, - "gas_consumed_interval": { - "name": "Gas consumed interval" - }, - "gas_consumed_cumulative": { - "name": "Gas consumed cumulative" - }, - "net_electricity_point": { - "name": "Net electricity point" - }, - "net_electricity_cumulative": { - "name": "Net electricity cumulative" - }, - "modulation_level": { - "name": "Modulation level" - }, - "valve_position": { - "name": "Valve position" - }, - "water_pressure": { - "name": "Water pressure" - }, - "dhw_temperature": { - "name": "DHW temperature" - }, - "domestic_hot_water_setpoint": { - "name": "DHW setpoint" - }, - "maximum_boiler_temperature": { - "name": "Maximum boiler temperature setpoint" - } + "options": { + "step": { + "init": { + "data": { + "cooling_on": "Anna: cooling-mode is on", + "homekit_emulation": "Homekit emulation (i.e. on hvac_off => Away) *) beta-only option", + "refresh_interval": "Frontend refresh-time (1.5 - 5 seconds) *) beta-only option", + "scan_interval": "Scan Interval (seconds) *) beta-only option" + }, + "description": "Adjust Smile/Stretch Options" + }, + "none": { + "description": "This Integration does not provide any Options", + "title": "No Options available" + } + } }, - "switch": { - "cooling_ena_switch": { - "name": "Cooling" - }, - "dhw_cm_switch": { - "name": "DHW comfort mode" - }, - "lock": { - "name": "Lock" - }, - "relay": { - "name": "Relay" - } - } - }, - "services": { - "delete_notification": { - "name": "Delete Plugwise notification", - "description": "Deletes a Plugwise Notification" + "services": { + "delete_notification": { + "description": "Deletes a Plugwise Notification", + "name": "Delete Plugwise notification" + } } - } -} +} \ No newline at end of file diff --git a/custom_components/plugwise/util.py b/custom_components/plugwise/util.py index d945e58a9..f03355641 100644 --- a/custom_components/plugwise/util.py +++ b/custom_components/plugwise/util.py @@ -9,6 +9,7 @@ from homeassistant.exceptions import HomeAssistantError +from .const import DOMAIN from .entity import PlugwiseEntity # For reference: @@ -31,10 +32,14 @@ async def handler( ) -> _R: try: return await func(self, *args, **kwargs) - except PlugwiseException as error: + except PlugwiseException as err: raise HomeAssistantError( - f"Error communicating with API: {error}" - ) from error + translation_domain=DOMAIN, + translation_key="error_communicating_with_api", + translation_placeholders={ + "error": str(err), + }, + ) from err finally: await self.coordinator.async_request_refresh() diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index ccda62ffa..c36019551 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -98,9 +98,15 @@ def mock_smile_adam() -> Generator[MagicMock]: """Create a Mock Adam type for testing.""" chosen_env = "m_adam_multiple_devices_per_zone" all_data = _read_json(chosen_env, "all_data") - with patch( - "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: + with ( + patch( + "homeassistant.components.plugwise.coordinator.Smile", autospec=True + ) as smile_mock, + patch( + "homeassistant.components.plugwise.config_flow.Smile", + new=smile_mock, + ), + ): smile = smile_mock.return_value smile.async_update.return_value = PlugwiseData( diff --git a/tests/components/plugwise/test_config_flow.py b/tests/components/plugwise/test_config_flow.py index 4d1c79bb7..c3d50953e 100644 --- a/tests/components/plugwise/test_config_flow.py +++ b/tests/components/plugwise/test_config_flow.py @@ -20,7 +20,7 @@ DOMAIN, ) from homeassistant.components.zeroconf import ZeroconfServiceInfo -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigFlowResult from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -44,6 +44,8 @@ TEST_PORT = 81 TEST_USERNAME = "smile" TEST_USERNAME2 = "stretch" +TEST_SMILE_ID = "smile12345" + TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( ip_address=TEST_HOST, ip_addresses=[TEST_HOST], @@ -458,3 +460,82 @@ async def test_options_flow_thermo( CONF_REFRESH_INTERVAL: 3.0, CONF_SCAN_INTERVAL: 60, } + + +async def _start_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + host_ip: str, +) -> ConfigFlowResult: + """Initialize a reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + reconfigure_result = await mock_config_entry.start_reconfigure_flow(hass) + + assert reconfigure_result["type"] is FlowResultType.FORM + assert reconfigure_result["step_id"] == "reconfigure" + + return await hass.config_entries.flow.async_configure( + reconfigure_result["flow_id"], {CONF_HOST: host_ip} + ) + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_smile_adam: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + result = await _start_reconfigure_flow(hass, mock_config_entry, TEST_HOST) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry + assert entry.data.get(CONF_HOST) == TEST_HOST + + +async def test_reconfigure_flow_other_smile( + hass: HomeAssistant, + mock_smile_adam: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow aborts on other Smile ID.""" + mock_smile_adam.smile_hostname = TEST_SMILE_ID + + result = await _start_reconfigure_flow(hass, mock_config_entry, TEST_HOST) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "not_the_same_smile" + + +@pytest.mark.parametrize( + ("side_effect", "reason"), + [ + (ConnectionFailedError, "cannot_connect"), + (InvalidAuthentication, "invalid_auth"), + (InvalidSetupError, "invalid_setup"), + (InvalidXMLError, "response_error"), + (RuntimeError, "unknown"), + (UnsupportedDeviceError, "unsupported"), + ], +) +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_smile_adam: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + reason: str, +) -> None: + """Test we handle each reconfigure exception error.""" + + mock_smile_adam.connect.side_effect = side_effect + + result = await _start_reconfigure_flow(hass, mock_config_entry, TEST_HOST) + + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": reason} + assert result.get("step_id") == "reconfigure" diff --git a/tests/components/plugwise/test_number.py b/tests/components/plugwise/test_number.py index 71ca481a6..0108908ae 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -11,6 +11,7 @@ ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from tests.common import MockConfigEntry @@ -97,3 +98,19 @@ async def test_adam_temperature_offset_change( mock_smile_adam.set_number.assert_called_with( "6a3bf693d05e48e0b460c815a4fdd09d", "temperature_offset", 1.0 ) + + +async def test_adam_temperature_offset_out_of_bounds_change( + hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test changing of the temperature_offset number beyond limits.""" + with pytest.raises(ServiceValidationError, match="valid range"): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.zone_thermostat_jessie_temperature_offset", + ATTR_VALUE: 3.0, + }, + blocking=True, + ) diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index 497ee9bcd..67d321f70 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -11,6 +11,7 @@ ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from tests.common import MockConfigEntry @@ -86,3 +87,21 @@ async def test_legacy_anna_select_entities( ) -> None: """Test not creating a select-entity for a legacy Anna without a thermostat-schedule.""" assert not hass.states.get("select.anna_thermostat_schedule") + + +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +async def test_anna_select_unavailable_regulation_mode( + hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test a regulation_mode non-available preset.""" + + with pytest.raises(ServiceValidationError, match="valid options"): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.anna_thermostat_schedule", + ATTR_OPTION: "freezing", + }, + blocking=True, + )