From ab9b71848026d696e55f1b4d9266aae1e65d5360 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Tue, 10 Dec 2024 13:25:39 +0100 Subject: [PATCH 01/11] Try reconfigure --- custom_components/plugwise/config_flow.py | 52 +++++++++++++++++++++++ custom_components/plugwise/strings.json | 11 ++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/custom_components/plugwise/config_flow.py b/custom_components/plugwise/config_flow.py index 2cfcaca03..f0beced19 100644 --- a/custom_components/plugwise/config_flow.py +++ b/custom_components/plugwise/config_flow.py @@ -41,6 +41,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import TextSelector from .const import ( ANNA_WITH_ADAM, @@ -252,6 +253,57 @@ def async_get_options_flow( return PlugwiseOptionsFlowHandler(config_entry) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + if user_input: + 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" + else: + await self.async_set_unique_id( + api.smile_hostname or api.gateway_id, raise_on_progress=False + ) + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=user_input, + ) + reconfigure_entry = self._get_reconfigure_entry() + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, + default=reconfigure_entry.data.get(CONF_HOST), + ): TextSelector(), + vol.Required( + CONF_PORT, + default=reconfigure_entry.data.get(CONF_PORT), + ): TextSelector(), + } + ), + description_placeholders={ + "title": reconfigure_entry.title, + }, + errors=errors, + ) + + # pw-beta - change the scan-interval via CONFIGURE # pw-beta - add homekit emulation via CONFIGURE # pw-beta - change the frontend refresh interval via CONFIGURE diff --git a/custom_components/plugwise/strings.json b/custom_components/plugwise/strings.json index 813af6977..20cfdbc8c 100644 --- a/custom_components/plugwise/strings.json +++ b/custom_components/plugwise/strings.json @@ -18,14 +18,21 @@ }, "config": { "step": { + "reconfigure": { + "description": "Update configuration for {title}.", + "data": { + "host": "[%key:common::config_flow::data::ip%]", + "port": "[%key:common::config_flow::data::port%]" + } + }, "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" + "host": "[%key:common::config_flow::data::ip%]", + "port": "[%key:common::config_flow::data::port%]" } } }, From 8281db8ad778236dfc35db2d6892e0f53efe3167 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Tue, 10 Dec 2024 17:06:56 +0100 Subject: [PATCH 02/11] Rework CRai suggestion --- custom_components/plugwise/config_flow.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/custom_components/plugwise/config_flow.py b/custom_components/plugwise/config_flow.py index f0beced19..3a30ff335 100644 --- a/custom_components/plugwise/config_flow.py +++ b/custom_components/plugwise/config_flow.py @@ -294,7 +294,15 @@ async def async_step_reconfigure( vol.Required( CONF_PORT, default=reconfigure_entry.data.get(CONF_PORT), - ): TextSelector(), + ): vol.Coerce(int), + vol.Required( + CONF_PASSWORD, + default=reconfigure_entry.data.get(CONF_PASSWORD), + ): str, + vol.Required( + CONF_USERNAME, + default=reconfigure_entry.data.get(CONF_USERNAME), + ): vol.In({SMILE: FLOW_SMILE, STRETCH: FLOW_STRETCH}) } ), description_placeholders={ From c627839644782d38171fbe0bf379de9d0f225fb6 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Tue, 10 Dec 2024 23:35:40 +0100 Subject: [PATCH 03/11] Rework most of the current upstream into catchup --- custom_components/plugwise/config_flow.py | 21 +- custom_components/plugwise/entity.py | 5 - custom_components/plugwise/quality_scale.yaml | 85 +++ custom_components/plugwise/strings.json | 4 +- .../plugwise/translations/en.json | 601 +++++++++--------- tests/components/plugwise/conftest.py | 12 +- tests/components/plugwise/test_config_flow.py | 83 ++- 7 files changed, 495 insertions(+), 316 deletions(-) create mode 100644 custom_components/plugwise/quality_scale.yaml diff --git a/custom_components/plugwise/config_flow.py b/custom_components/plugwise/config_flow.py index 3a30ff335..2d0ffe63f 100644 --- a/custom_components/plugwise/config_flow.py +++ b/custom_components/plugwise/config_flow.py @@ -244,14 +244,6 @@ async def async_step_user( self._abort_if_unique_id_configured() return self.async_create_entry(title=api.smile_name, data=user_input) - @staticmethod - @callback - def async_get_options_flow( - config_entry: PlugwiseConfigEntry, - ) -> PlugwiseOptionsFlowHandler: # pw-beta options - """Get the options flow for this handler.""" - return PlugwiseOptionsFlowHandler(config_entry) - async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None @@ -277,7 +269,7 @@ async def async_step_reconfigure( await self.async_set_unique_id( api.smile_hostname or api.gateway_id, raise_on_progress=False ) - self._abort_if_unique_id_mismatch(reason="wrong_device") + self._abort_if_unique_id_mismatch(reason="not_the_same_smile") return self.async_update_reload_and_abort( self._get_reconfigure_entry(), data_updates=user_input, @@ -302,7 +294,7 @@ async def async_step_reconfigure( vol.Required( CONF_USERNAME, default=reconfigure_entry.data.get(CONF_USERNAME), - ): vol.In({SMILE: FLOW_SMILE, STRETCH: FLOW_STRETCH}) + ): vol.In({SMILE: FLOW_SMILE, STRETCH: FLOW_STRETCH}), } ), description_placeholders={ @@ -312,6 +304,15 @@ async def async_step_reconfigure( ) + @staticmethod + @callback + def async_get_options_flow( + config_entry: PlugwiseConfigEntry, + ) -> PlugwiseOptionsFlowHandler: # pw-beta options + """Get the options flow for this handler.""" + return PlugwiseOptionsFlowHandler(config_entry) + + # pw-beta - change the scan-interval via CONFIGURE # pw-beta - add homekit emulation via CONFIGURE # pw-beta - change the frontend refresh interval via CONFIGURE 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/quality_scale.yaml b/custom_components/plugwise/quality_scale.yaml new file mode 100644 index 000000000..2a5fc4f33 --- /dev/null +++ b/custom_components/plugwise/quality_scale.yaml @@ -0,0 +1,85 @@ +rules: + ## Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: done + dependency-transparency: done + action-setup: + status: exempt + comment: Plugwise integration has no custom actions + common-modules: done + docs-high-level-description: + status: todo + comment: Rewrite top section, docs PR prepared waiting for 36087 merge + docs-installation-instructions: + status: todo + comment: Docs PR 36087 + docs-removal-instructions: done + docs-actions: done + brands: done + ## Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: done + reauthentication-flow: + status: exempt + comment: The hubs have a hardcoded `Smile ID` printed on the sticker used as password, it can not be changed + parallel-updates: + status: todo + comment: Using coordinator, but required due to mutable platform + test-coverage: done + integration-owner: done + docs-installation-parameters: + status: todo + comment: Docs PR 36087 (partial) + todo rewrite generically (PR prepared) + docs-configuration-parameters: + status: exempt + comment: Plugwise has no options flow + ## Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: done + stale-devices: done + diagnostics: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + dynamic-devices: done + discovery-update-info: done + repair-issues: + status: exempt + comment: This integration does not have repairs + docs-use-cases: + status: todo + comment: Check for completeness, PR prepared waiting for 36087 merge + docs-supported-devices: + status: todo + comment: The list is there but could be improved for readability, PR prepared waiting for 36087 merge + docs-supported-functions: + status: todo + comment: Check for completeness, PR prepared waiting for 36087 merge + docs-data-update: done + docs-known-limitations: + status: todo + comment: Partial in 36087 but could be more elaborate + docs-troubleshooting: + status: todo + comment: Check for completeness, PR prepared waiting for 36087 merge + docs-examples: + status: todo + comment: Check for completeness, PR prepared waiting for 36087 merge + ## Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/custom_components/plugwise/strings.json b/custom_components/plugwise/strings.json index 20cfdbc8c..23f45b4a0 100644 --- a/custom_components/plugwise/strings.json +++ b/custom_components/plugwise/strings.json @@ -49,7 +49,9 @@ }, "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": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "entity": { diff --git a/custom_components/plugwise/translations/en.json b/custom_components/plugwise/translations/en.json index 813af6977..8e1f3c737 100644 --- a/custom_components/plugwise/translations/en.json +++ b/custom_components/plugwise/translations/en.json @@ -1,305 +1,314 @@ { - "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": "Re-configuration was 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" + }, + "description": "Update configuration for {title}." + }, + "user": { + "data": { + "host": "IP address", + "password": "ID", + "port": "Port", + "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" + }, + "low_battery": { + "name": "Battery state" + }, + "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" - } - }, - "select_schedule": { - "name": "Thermostat schedule", - "state": { - "off": "Off" + "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" + } } - } - }, - "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" - } }, - "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/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..507f13eeb 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" +MOCK_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 = MOCK_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" From 7da6e21c46018b32f8a37a15c0bdad21e305fa40 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Wed, 11 Dec 2024 21:01:55 +0100 Subject: [PATCH 04/11] Blunt commit (missing translations) --- custom_components/plugwise/binary_sensor.py | 4 +- custom_components/plugwise/button.py | 2 +- custom_components/plugwise/climate.py | 2 +- custom_components/plugwise/config_flow.py | 151 +++++++++--------- custom_components/plugwise/coordinator.py | 24 ++- custom_components/plugwise/number.py | 2 +- custom_components/plugwise/select.py | 2 +- custom_components/plugwise/sensor.py | 1 + custom_components/plugwise/strings.json | 23 ++- custom_components/plugwise/switch.py | 2 +- custom_components/plugwise/util.py | 11 +- tests/components/plugwise/test_config_flow.py | 4 +- tests/components/plugwise/test_number.py | 17 ++ tests/components/plugwise/test_select.py | 18 +++ 14 files changed, 165 insertions(+), 98 deletions(-) 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 2d0ffe63f..a1c342674 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 +from types import MappingProxyType from typing import Any, Self from plugwise import Smile @@ -104,6 +105,19 @@ def base_schema( ) +def reconfigure_schema(reconfigure_data: MappingProxyType[str, Any]) -> vol.Schema: + """Generate reconfigure schema for gateways.""" + return vol.Schema( + { + vol.Required( + CONF_HOST, default=reconfigure_data.get(CONF_HOST) + ): TextSelector(), + vol.Required( + CONF_PORT, default=reconfigure_data.get(CONF_PORT) + ): vol.Coerce(int), + } + ) + async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> Smile: """Validate whether the user input allows us to connect to the gateway. @@ -198,105 +212,86 @@ def is_matching(self, other_flow: Self) -> bool: return False + + async def _verify_connection( + self, user_input: dict[str, Any] + ) -> tuple[Smile | None, dict[str, str]]: + """Verify gateway connection helper function user and reconfiguration steps.""" + errors: dict[str, str] = {} + + 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" + else: + await self.async_set_unique_id( + api.smile_hostname or api.gateway_id, raise_on_progress=False + ) + return (api, errors) + return (None, errors) + + 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 - ) - self._abort_if_unique_id_configured() - return self.async_create_entry(title=api.smile_name, data=user_input) + 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 self._verify_connection(user_input) + if not errors and api: + 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=base_schema(self.discovery_info), + errors=errors, + ) 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: - 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" - else: - await self.async_set_unique_id( - api.smile_hostname or api.gateway_id, raise_on_progress=False - ) + # Redefine ingest existing username and password + user_input = { + CONF_HOST: user_input.get(CONF_HOST), + CONF_PORT: user_input.get(CONF_PORT), + CONF_USERNAME: reconfigure_entry.data.get(CONF_USERNAME), + CONF_PASSWORD: reconfigure_entry.data.get(CONF_PASSWORD), + } + + _, errors = await self._verify_connection(user_input) + if not errors: self._abort_if_unique_id_mismatch(reason="not_the_same_smile") return self.async_update_reload_and_abort( self._get_reconfigure_entry(), data_updates=user_input, ) - reconfigure_entry = self._get_reconfigure_entry() + return self.async_show_form( step_id="reconfigure", - data_schema=vol.Schema( - { - vol.Required( - CONF_HOST, - default=reconfigure_entry.data.get(CONF_HOST), - ): TextSelector(), - vol.Required( - CONF_PORT, - default=reconfigure_entry.data.get(CONF_PORT), - ): vol.Coerce(int), - vol.Required( - CONF_PASSWORD, - default=reconfigure_entry.data.get(CONF_PASSWORD), - ): str, - vol.Required( - CONF_USERNAME, - default=reconfigure_entry.data.get(CONF_USERNAME), - ): vol.In({SMILE: FLOW_SMILE, STRETCH: FLOW_STRETCH}), - } - ), + data_schema=reconfigure_schema(reconfigure_entry.data), description_placeholders={ "title": reconfigure_entry.title, }, 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/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 23f45b4a0..b1cc801a3 100644 --- a/custom_components/plugwise/strings.json +++ b/custom_components/plugwise/strings.json @@ -56,9 +56,6 @@ }, "entity": { "binary_sensor": { - "low_battery": { - "name": "Battery state" - }, "compressor_state": { "name": "Compressor state" }, @@ -305,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/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/test_config_flow.py b/tests/components/plugwise/test_config_flow.py index 507f13eeb..c3d50953e 100644 --- a/tests/components/plugwise/test_config_flow.py +++ b/tests/components/plugwise/test_config_flow.py @@ -44,7 +44,7 @@ TEST_PORT = 81 TEST_USERNAME = "smile" TEST_USERNAME2 = "stretch" -MOCK_SMILE_ID = "smile12345" +TEST_SMILE_ID = "smile12345" TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( ip_address=TEST_HOST, @@ -504,7 +504,7 @@ async def test_reconfigure_flow_other_smile( mock_config_entry: MockConfigEntry, ) -> None: """Test reconfigure flow aborts on other Smile ID.""" - mock_smile_adam.smile_hostname = MOCK_SMILE_ID + mock_smile_adam.smile_hostname = TEST_SMILE_ID result = await _start_reconfigure_flow(hass, mock_config_entry, TEST_HOST) 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..36e4217e4 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,20 @@ 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") + + +async def test_adam_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, + ) From b6a33f58d5ac96af2e2d1a5b9fb99a0c1958b9c8 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Thu, 12 Dec 2024 08:05:27 +0100 Subject: [PATCH 05/11] Revert to hardcoded strings --- custom_components/plugwise/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/custom_components/plugwise/strings.json b/custom_components/plugwise/strings.json index b1cc801a3..fca1df7fa 100644 --- a/custom_components/plugwise/strings.json +++ b/custom_components/plugwise/strings.json @@ -21,8 +21,8 @@ "reconfigure": { "description": "Update configuration for {title}.", "data": { - "host": "[%key:common::config_flow::data::ip%]", - "port": "[%key:common::config_flow::data::port%]" + "host": "IP-address", + "port": "Port number" } }, "user": { @@ -31,8 +31,8 @@ "data": { "password": "ID", "username": "Username", - "host": "[%key:common::config_flow::data::ip%]", - "port": "[%key:common::config_flow::data::port%]" + "host": "IP-address", + "port": "Port number" } } }, @@ -51,7 +51,7 @@ "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": "[%key:common::config_flow::abort::reconfigure_successful%]" + "reconfigure_successful": "Reconfiguration successful" } }, "entity": { From 746210fbd1ef08589595cdffe6cc1912c6fcb73a Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Fri, 13 Dec 2024 10:34:37 +0100 Subject: [PATCH 06/11] Rework from core --- custom_components/plugwise/config_flow.py | 56 +++++++++---------- .../plugwise/translations/en.json | 33 ++++++++--- tests/components/plugwise/test_select.py | 31 +++++----- 3 files changed, 66 insertions(+), 54 deletions(-) diff --git a/custom_components/plugwise/config_flow.py b/custom_components/plugwise/config_flow.py index a1c342674..5ff1130a0 100644 --- a/custom_components/plugwise/config_flow.py +++ b/custom_components/plugwise/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations from copy import deepcopy -from types import MappingProxyType from typing import Any, Self from plugwise import Smile @@ -42,7 +41,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.selector import TextSelector from .const import ( ANNA_WITH_ADAM, @@ -73,8 +71,15 @@ # Upstream basically the whole file (excluding the pw-beta options) +SMILE_RECONF_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + } +) + -def base_schema( +def SMILE_USER_SCHEMA( cf_input: ZeroconfServiceInfo | dict[str, Any] | None, ) -> vol.Schema: """Generate base schema for gateways.""" @@ -105,23 +110,10 @@ def base_schema( ) -def reconfigure_schema(reconfigure_data: MappingProxyType[str, Any]) -> vol.Schema: - """Generate reconfigure schema for gateways.""" - return vol.Schema( - { - vol.Required( - CONF_HOST, default=reconfigure_data.get(CONF_HOST) - ): TextSelector(), - vol.Required( - CONF_PORT, default=reconfigure_data.get(CONF_PORT) - ): vol.Coerce(int), - } - ) - 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( @@ -216,7 +208,7 @@ def is_matching(self, other_flow: Self) -> bool: async def _verify_connection( self, user_input: dict[str, Any] ) -> tuple[Smile | None, dict[str, str]]: - """Verify gateway connection helper function user and reconfiguration steps.""" + """Verify and return the gateway connection using helper function.""" errors: dict[str, str] = {} try: @@ -234,9 +226,6 @@ async def _verify_connection( except Exception: # noqa: BLE001 errors[CONF_BASE] = "unknown" else: - await self.async_set_unique_id( - api.smile_hostname or api.gateway_id, raise_on_progress=False - ) return (api, errors) return (None, errors) @@ -255,12 +244,15 @@ async def async_step_user( api, errors = await self._verify_connection(user_input) if not errors and 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=base_schema(self.discovery_info), + data_schema=SMILE_USER_SCHEMA(self.discovery_info), errors=errors, ) @@ -274,27 +266,31 @@ async def async_step_reconfigure( if user_input: # Redefine ingest existing username and password - user_input = { + full_input = { CONF_HOST: user_input.get(CONF_HOST), CONF_PORT: user_input.get(CONF_PORT), CONF_USERNAME: reconfigure_entry.data.get(CONF_USERNAME), CONF_PASSWORD: reconfigure_entry.data.get(CONF_PASSWORD), } - _, errors = await self._verify_connection(user_input) - if not errors: + api, errors = await self._verify_connection(full_input) + if not errors and 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( self._get_reconfigure_entry(), - data_updates=user_input, + data_updates=full_input, ) return self.async_show_form( step_id="reconfigure", - data_schema=reconfigure_schema(reconfigure_entry.data), - description_placeholders={ - "title": reconfigure_entry.title, - }, + 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, ) diff --git a/custom_components/plugwise/translations/en.json b/custom_components/plugwise/translations/en.json index 8e1f3c737..46734d066 100644 --- a/custom_components/plugwise/translations/en.json +++ b/custom_components/plugwise/translations/en.json @@ -4,7 +4,7 @@ "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": "Re-configuration was successful" + "reconfigure_successful": "Reconfiguration successful" }, "error": { "cannot_connect": "Failed to connect", @@ -20,16 +20,16 @@ "step": { "reconfigure": { "data": { - "host": "IP address", - "port": "Port" + "host": "IP-address", + "port": "Port number" }, "description": "Update configuration for {title}." }, "user": { "data": { - "host": "IP address", + "host": "IP-address", "password": "ID", - "port": "Port", + "port": "Port number", "username": "Username" }, "description": "Enter your Plugwise device: (setup can take up to 90s)", @@ -57,9 +57,6 @@ "heating_state": { "name": "Heating" }, - "low_battery": { - "name": "Battery state" - }, "plugwise_notification": { "name": "Plugwise notification" }, @@ -288,6 +285,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" + } + }, "options": { "step": { "init": { diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index 36e4217e4..de605d9f0 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -11,7 +11,6 @@ ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError from tests.common import MockConfigEntry @@ -89,18 +88,18 @@ async def test_legacy_anna_select_entities( assert not hass.states.get("select.anna_thermostat_schedule") -async def test_adam_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, - ) +#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, +# ) From 352e2c874b9b4843a3e7e39b8bdbc3efa0ca60c9 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sun, 22 Dec 2024 15:53:30 +0100 Subject: [PATCH 07/11] Rework from core --- custom_components/plugwise/config_flow.py | 74 ++++++++++++----------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/custom_components/plugwise/config_flow.py b/custom_components/plugwise/config_flow.py index 5ff1130a0..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,25 +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, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, } ) -def SMILE_USER_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} @@ -127,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.""" @@ -205,31 +232,6 @@ def is_matching(self, other_flow: Self) -> bool: return False - async def _verify_connection( - self, user_input: dict[str, Any] - ) -> tuple[Smile | None, dict[str, str]]: - """Verify and return the gateway connection using helper function.""" - errors: dict[str, str] = {} - - 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" - else: - return (api, errors) - return (None, errors) - - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -242,8 +244,8 @@ async def async_step_user( user_input[CONF_PORT] = self.discovery_info.port user_input[CONF_USERNAME] = self._username - api, errors = await self._verify_connection(user_input) - if not errors and api: + 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 ) @@ -252,7 +254,7 @@ async def async_step_user( return self.async_show_form( step_id=SOURCE_USER, - data_schema=SMILE_USER_SCHEMA(self.discovery_info), + data_schema=smile_user_schema(self.discovery_info), errors=errors, ) @@ -268,19 +270,19 @@ async def async_step_reconfigure( # Redefine ingest existing username and password full_input = { CONF_HOST: user_input.get(CONF_HOST), - CONF_PORT: user_input.get(CONF_PORT), + 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 self._verify_connection(full_input) - if not errors and api: + 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( - self._get_reconfigure_entry(), + reconfigure_entry, data_updates=full_input, ) From 35dbb21b984ac8c7fff813bed5fe8ab310455e14 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 23 Dec 2024 09:27:38 +0100 Subject: [PATCH 08/11] Test_select: fix chosen_env error --- tests/components/plugwise/test_select.py | 32 +++++++++++++----------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index de605d9f0..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 @@ -88,18 +89,19 @@ async def test_legacy_anna_select_entities( assert not hass.states.get("select.anna_thermostat_schedule") -#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, -# ) +@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, + ) From aebc3b0d46d05b95e04018910db8d10e08a36eff Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Mon, 23 Dec 2024 11:50:12 +0100 Subject: [PATCH 09/11] Remove quality scale --- custom_components/plugwise/quality_scale.yaml | 85 ------------------- 1 file changed, 85 deletions(-) delete mode 100644 custom_components/plugwise/quality_scale.yaml diff --git a/custom_components/plugwise/quality_scale.yaml b/custom_components/plugwise/quality_scale.yaml deleted file mode 100644 index 2a5fc4f33..000000000 --- a/custom_components/plugwise/quality_scale.yaml +++ /dev/null @@ -1,85 +0,0 @@ -rules: - ## Bronze - config-flow: done - test-before-configure: done - unique-config-entry: done - config-flow-test-coverage: done - runtime-data: done - test-before-setup: done - appropriate-polling: done - entity-unique-id: done - has-entity-name: done - entity-event-setup: done - dependency-transparency: done - action-setup: - status: exempt - comment: Plugwise integration has no custom actions - common-modules: done - docs-high-level-description: - status: todo - comment: Rewrite top section, docs PR prepared waiting for 36087 merge - docs-installation-instructions: - status: todo - comment: Docs PR 36087 - docs-removal-instructions: done - docs-actions: done - brands: done - ## Silver - config-entry-unloading: done - log-when-unavailable: done - entity-unavailable: done - action-exceptions: done - reauthentication-flow: - status: exempt - comment: The hubs have a hardcoded `Smile ID` printed on the sticker used as password, it can not be changed - parallel-updates: - status: todo - comment: Using coordinator, but required due to mutable platform - test-coverage: done - integration-owner: done - docs-installation-parameters: - status: todo - comment: Docs PR 36087 (partial) + todo rewrite generically (PR prepared) - docs-configuration-parameters: - status: exempt - comment: Plugwise has no options flow - ## Gold - entity-translations: done - entity-device-class: done - devices: done - entity-category: done - entity-disabled-by-default: done - discovery: done - stale-devices: done - diagnostics: done - exception-translations: done - icon-translations: done - reconfiguration-flow: done - dynamic-devices: done - discovery-update-info: done - repair-issues: - status: exempt - comment: This integration does not have repairs - docs-use-cases: - status: todo - comment: Check for completeness, PR prepared waiting for 36087 merge - docs-supported-devices: - status: todo - comment: The list is there but could be improved for readability, PR prepared waiting for 36087 merge - docs-supported-functions: - status: todo - comment: Check for completeness, PR prepared waiting for 36087 merge - docs-data-update: done - docs-known-limitations: - status: todo - comment: Partial in 36087 but could be more elaborate - docs-troubleshooting: - status: todo - comment: Check for completeness, PR prepared waiting for 36087 merge - docs-examples: - status: todo - comment: Check for completeness, PR prepared waiting for 36087 merge - ## Platinum - async-dependency: done - inject-websession: done - strict-typing: done From 94544cd6c1066bd6fdd950c2a27f9adba4b5cbcc Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Mon, 23 Dec 2024 13:15:18 +0100 Subject: [PATCH 10/11] Bump changelog and version --- CHANGELOG.md | 7 ++++++- custom_components/plugwise/manifest.json | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) 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/manifest.json b/custom_components/plugwise/manifest.json index 8086dd9bc..dcb3e5562 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.5a0", "zeroconf": ["_plugwise._tcp.local."] } From 36f6c6833c74105b9b5e37390687425e10c22adb Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Mon, 23 Dec 2024 13:22:57 +0100 Subject: [PATCH 11/11] Bump for release --- custom_components/plugwise/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/plugwise/manifest.json b/custom_components/plugwise/manifest.json index dcb3e5562..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.5a0", + "version": "0.55.5", "zeroconf": ["_plugwise._tcp.local."] }