From 5a9bb972d02b77693d15d987cf69b246a96cce3f Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Fri, 24 Apr 2026 05:34:47 +0800 Subject: [PATCH 1/3] Add sensor description for Lock state in Switchbot Cloud (#168607) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- .../components/switchbot_cloud/const.py | 22 ++++++++++++ .../components/switchbot_cloud/sensor.py | 25 +++++++++++-- .../components/switchbot_cloud/strings.json | 12 +++++++ tests/components/switchbot_cloud/__init__.py | 8 +++++ tests/components/switchbot_cloud/test_lock.py | 36 ++++++++++++++++--- .../components/switchbot_cloud/test_sensor.py | 2 ++ 6 files changed, 97 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index 15e958b4777431..809a289a3539fb 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -61,3 +61,25 @@ class Humidifier2Mode(Enum): def get_modes(cls) -> list[str]: """Return a list of available humidifier2 modes as lowercase strings.""" return [mode.name.lower() for mode in cls] + + +class SwitchbotCloudDeviceLockState(Enum): + """Lock State.""" + + LOCKED = "locked" + UNLOCKED = "unlocked" + LOCKING = "locking" + UNLOCKING = "unlocking" + JAMMED = "jammed" + LATCH_BOLT_LOCKED = "latchBoltLocked" + HALF_LOCKED = "halfLocked" + + @classmethod + def get_states(cls) -> list[SwitchbotCloudDeviceLockState]: + """Get lock states.""" + return list(cls) + + @classmethod + def get_values(cls) -> list[str]: + """Get lock value.""" + return [mode.value for mode in cls] diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index b6497572266aad..2b3d7c3e8f248e 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -26,7 +26,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitchbotCloudConfigEntry -from .const import DOMAIN +from .const import DOMAIN, SwitchbotCloudDeviceLockState from .coordinator import SwitchBotCoordinator from .entity import SwitchBotCloudEntity @@ -47,6 +47,8 @@ RELAY_SWITCH_2PM_SENSOR_TYPE_CURRENT = "ElectricCurrent" RELAY_SWITCH_2PM_SENSOR_TYPE_ELECTRICITY = "UsedElectricity" +LOCK_SENSOR_TYPE_LOCK_STATE = "lockState" + @dataclass(frozen=True, kw_only=True) class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): @@ -165,6 +167,21 @@ class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, ) + +LOCK_SENSOR_TYPE_LOCK_STATE_DESCRIPTION = SwitchbotCloudSensorEntityDescription( + key=LOCK_SENSOR_TYPE_LOCK_STATE, + device_class=SensorDeviceClass.ENUM, + translation_key="lock_state", + options=[ + value.name.lower() for value in SwitchbotCloudDeviceLockState.get_states() + ], + value_fn=lambda value: ( + SwitchbotCloudDeviceLockState(value).name.lower() + if value in SwitchbotCloudDeviceLockState.get_values() + else None + ), +) + SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { "Bot": (BATTERY_DESCRIPTION,), "Battery Circulator Fan": (BATTERY_DESCRIPTION,), @@ -224,7 +241,10 @@ class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): "Smart Lock": (BATTERY_DESCRIPTION,), "Smart Lock Lite": (BATTERY_DESCRIPTION,), "Smart Lock Pro": (BATTERY_DESCRIPTION,), - "Smart Lock Ultra": (BATTERY_DESCRIPTION,), + "Smart Lock Ultra": ( + BATTERY_DESCRIPTION, + LOCK_SENSOR_TYPE_LOCK_STATE_DESCRIPTION, + ), "Smart Lock Vision": (BATTERY_DESCRIPTION,), "Smart Lock Vision Pro": (BATTERY_DESCRIPTION,), "Lock Vision": (BATTERY_DESCRIPTION,), @@ -314,7 +334,6 @@ def _set_attributes(self) -> None: if not self.coordinator.data: return value = self.coordinator.data.get(self.entity_description.key) - self._attr_native_value = self.entity_description.value_fn(value) diff --git a/homeassistant/components/switchbot_cloud/strings.json b/homeassistant/components/switchbot_cloud/strings.json index 6883efff030620..2bd4ff41bcc248 100644 --- a/homeassistant/components/switchbot_cloud/strings.json +++ b/homeassistant/components/switchbot_cloud/strings.json @@ -76,6 +76,18 @@ "sensor": { "light_level": { "name": "Light level" + }, + "lock_state": { + "name": "Lock state", + "state": { + "half_locked": "Half locked", + "jammed": "Jammed", + "latch_bolt_locked": "Latch bolt locked", + "locked": "[%key:common::state::locked%]", + "locking": "Locking", + "unlocked": "[%key:common::state::unlocked%]", + "unlocking": "Unlocking" + } } } } diff --git a/tests/components/switchbot_cloud/__init__.py b/tests/components/switchbot_cloud/__init__.py index 6ad74cf9cb448f..43cb93cc4fba6c 100644 --- a/tests/components/switchbot_cloud/__init__.py +++ b/tests/components/switchbot_cloud/__init__.py @@ -113,3 +113,11 @@ async def configure_integration(hass: HomeAssistant) -> MockConfigEntry: deviceType="Humidifier2", hubDeviceId="test-hub-id", ) + +LOCK_ULTRA_INFO = Device( + version="V1.0", + deviceId="lock-id-1", + deviceName="Lock Ultra", + deviceType="Smart Lock Ultra", + hubDeviceId="test-hub-id", +) diff --git a/tests/components/switchbot_cloud/test_lock.py b/tests/components/switchbot_cloud/test_lock.py index dfafda4110f5de..91ddb124dc03ef 100644 --- a/tests/components/switchbot_cloud/test_lock.py +++ b/tests/components/switchbot_cloud/test_lock.py @@ -2,6 +2,7 @@ from unittest.mock import patch +import pytest from switchbot_api import Device from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState @@ -18,14 +19,28 @@ from . import configure_integration -async def test_lock(hass: HomeAssistant, mock_list_devices, mock_get_status) -> None: +@pytest.mark.parametrize( + ("device_info", "test_index"), + [ + ("Smart Lock", 0), + ("Smart Lock Lite", 1), + ("Smart Lock Pro", 2), + ("Smart Lock Ultra", 3), + ("Lock Vision", 4), + ("Lock Vision Pro", 5), + ("Smart Lock Pro Wifi", 6), + ], +) +async def test_lock( + hass: HomeAssistant, mock_list_devices, mock_get_status, device_info, test_index +) -> None: """Test locking and unlocking.""" mock_list_devices.return_value = [ Device( version="V1.0", deviceId="lock-id-1", deviceName="lock-1", - deviceType="Smart Lock", + deviceType=device_info, hubDeviceId="test-hub-id", ), ] @@ -52,16 +67,27 @@ async def test_lock(hass: HomeAssistant, mock_list_devices, mock_get_status) -> assert hass.states.get(lock_id).state == LockState.LOCKED +@pytest.mark.parametrize( + ("device_info", "test_index"), + [ + ("Smart Lock", 0), + ("Smart Lock Pro", 1), + ("Smart Lock Ultra", 2), + ("Lock Vision", 3), + ("Lock Vision Pro", 4), + ("Smart Lock Pro Wifi", 5), + ], +) async def test_lock_open( - hass: HomeAssistant, mock_list_devices, mock_get_status + hass: HomeAssistant, mock_list_devices, mock_get_status, device_info, test_index ) -> None: - """Test lock open.""" + """Test locking and unlocking.""" mock_list_devices.return_value = [ Device( version="V1.0", deviceId="lock-id-1", deviceName="lock-1", - deviceType="Smart Lock Pro", + deviceType=device_info, hubDeviceId="test-hub-id", ), ] diff --git a/tests/components/switchbot_cloud/test_sensor.py b/tests/components/switchbot_cloud/test_sensor.py index 78eec135ed5247..38d03c64e0aab4 100644 --- a/tests/components/switchbot_cloud/test_sensor.py +++ b/tests/components/switchbot_cloud/test_sensor.py @@ -14,6 +14,7 @@ from . import ( CONTACT_SENSOR_INFO, HUB3_INFO, + LOCK_ULTRA_INFO, METER_INFO, MOTION_SENSOR_INFO, WATER_DETECTOR_INFO, @@ -32,6 +33,7 @@ (HUB3_INFO, 3), (MOTION_SENSOR_INFO, 4), (WATER_DETECTOR_INFO, 5), + (LOCK_ULTRA_INFO, 5), ], ) async def test_meter( From 7de684d47b78c701f103481d94cb7d2a5e5170b1 Mon Sep 17 00:00:00 2001 From: Tomer <57483589+tomer-w@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:35:30 +0300 Subject: [PATCH 2/3] Victron GX: Add reconfiguration flow (#168997) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot --- .../components/victron_gx/config_flow.py | 54 +++++++ .../components/victron_gx/quality_scale.yaml | 2 +- .../components/victron_gx/strings.json | 18 +++ .../components/victron_gx/test_config_flow.py | 136 ++++++++++++++++++ 4 files changed, 209 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/victron_gx/config_flow.py b/homeassistant/components/victron_gx/config_flow.py index e58c27fa37008d..eb30ae1bbd3c6f 100644 --- a/homeassistant/components/victron_gx/config_flow.py +++ b/homeassistant/components/victron_gx/config_flow.py @@ -284,6 +284,60 @@ async def async_step_ssdp_auth( description_placeholders={CONF_HOST: self.hostname}, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of a Victron GX device.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + data = { + **reconfigure_entry.data, + **user_input, + } + if CONF_USERNAME in user_input: + data[CONF_USERNAME] = user_input[CONF_USERNAME] or None + if CONF_PASSWORD in user_input: + data[CONF_PASSWORD] = user_input[CONF_PASSWORD] or None + try: + installation_id = await validate_input(data) + except AuthenticationError: + errors["base"] = "invalid_auth" + except CannotConnectError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during reconfiguration") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(installation_id) + self._abort_if_unique_id_mismatch(reason="different_device") + return self.async_update_reload_and_abort( + reconfigure_entry, + title=ENTRY_TITLE_FORMAT.format( + installation_id=installation_id, + host=user_input[CONF_HOST], + port=user_input[CONF_PORT], + ), + data_updates=data, + ) + + suggested_values = { + CONF_HOST: reconfigure_entry.data[CONF_HOST], + CONF_PORT: reconfigure_entry.data[CONF_PORT], + CONF_USERNAME: reconfigure_entry.data.get(CONF_USERNAME), + CONF_SSL: reconfigure_entry.data.get(CONF_SSL, False), + } + if user_input is not None: + suggested_values.update(user_input) + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, suggested_values + ), + errors=errors, + ) + async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult: """Handle reauthentication.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/victron_gx/quality_scale.yaml b/homeassistant/components/victron_gx/quality_scale.yaml index fd608a85b32e11..03df94c76148b5 100644 --- a/homeassistant/components/victron_gx/quality_scale.yaml +++ b/homeassistant/components/victron_gx/quality_scale.yaml @@ -62,7 +62,7 @@ rules: status: exempt comment: | Not relevant. - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: done diff --git a/homeassistant/components/victron_gx/strings.json b/homeassistant/components/victron_gx/strings.json index 3bc8eef314b14a..e3d9a9f4472e18 100644 --- a/homeassistant/components/victron_gx/strings.json +++ b/homeassistant/components/victron_gx/strings.json @@ -100,7 +100,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "different_device": "The device at this address is different from the originally configured device.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { @@ -123,6 +125,22 @@ "description": "Please re-authenticate with {host}.", "title": "[%key:common::config_flow::title::reauth%]" }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "[%key:component::victron_gx::config::step::user::data_description::host%]", + "password": "[%key:component::victron_gx::config::step::user::data_description::password%]", + "port": "[%key:component::victron_gx::config::step::user::data_description::port%]", + "ssl": "[%key:component::victron_gx::config::step::user::data_description::ssl%]", + "username": "[%key:component::victron_gx::config::step::user::data_description::username%]" + } + }, "ssdp_auth": { "data": { "password": "[%key:common::config_flow::data::password%]", diff --git a/tests/components/victron_gx/test_config_flow.py b/tests/components/victron_gx/test_config_flow.py index 1eadc6c11009a3..cd0d0a909ae0b5 100644 --- a/tests/components/victron_gx/test_config_flow.py +++ b/tests/components/victron_gx/test_config_flow.py @@ -695,3 +695,139 @@ async def test_reauth_flow_error_and_recover( assert mock_config_entry.data[CONF_USERNAME] == "new-user" assert mock_config_entry.data[CONF_PASSWORD] == "new-password" assert mock_config_entry.data[CONF_SSL] is True + + +@pytest.mark.usefixtures("mock_victron_hub") +async def test_reconfigure_flow_success( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test successful reconfiguration updates data and title.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.200", + CONF_PORT: 8883, + CONF_USERNAME: "new-user", + CONF_PASSWORD: "new-password", + CONF_SSL: True, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_HOST] == "192.168.1.200" + assert mock_config_entry.data[CONF_PORT] == 8883 + assert mock_config_entry.data[CONF_USERNAME] == "new-user" + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" + assert mock_config_entry.data[CONF_SSL] is True + assert mock_config_entry.data[CONF_INSTALLATION_ID] == MOCK_INSTALLATION_ID + assert mock_config_entry.data[CONF_SERIAL] == MOCK_SERIAL + assert mock_config_entry.data[CONF_MODEL] == MOCK_MODEL + assert mock_config_entry.title == "Victron OS 123 (192.168.1.200:8883)" + + +@pytest.mark.usefixtures("mock_victron_hub") +async def test_reconfigure_flow_clears_credentials( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reconfigure clears credentials when submitted empty.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_HOST, + CONF_PORT: DEFAULT_PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_USERNAME] is None + assert mock_config_entry.data[CONF_PASSWORD] is None + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (AuthenticationError("Invalid credentials"), "invalid_auth"), + (CannotConnectError("Cannot connect"), "cannot_connect"), + (Exception("Unexpected error"), "unknown"), + ], +) +async def test_reconfigure_flow_error_and_recover( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_victron_hub: MagicMock, + exception: Exception, + error: str, +) -> None: + """Test reconfigure handles errors and allows recovery.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + mock_victron_hub.return_value.connect.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_HOST, + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + # Recover from error + mock_victron_hub.return_value.connect.side_effect = None + mock_victron_hub.return_value.installation_id = MOCK_INSTALLATION_ID + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_HOST, + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_flow_different_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_victron_hub: MagicMock, +) -> None: + """Test reconfigure aborts when device identity changes.""" + mock_config_entry.add_to_hass(hass) + + mock_victron_hub.return_value.installation_id = "different_installation_id" + + result = await mock_config_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.200", + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "different_device" + # Entry should be unchanged + assert mock_config_entry.data[CONF_HOST] == MOCK_HOST From 32b9a212943f181ff5865090cf601b48b58eea27 Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Fri, 24 Apr 2026 05:18:55 +0200 Subject: [PATCH 3/3] Bump python-duco-client to 0.3.6 (#169020) --- homeassistant/components/duco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/duco/snapshots/test_diagnostics.ambr | 4 ++++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/duco/manifest.json b/homeassistant/components/duco/manifest.json index d41eaf939c6a1d..4936b2d3abf10d 100644 --- a/homeassistant/components/duco/manifest.json +++ b/homeassistant/components/duco/manifest.json @@ -13,7 +13,7 @@ "iot_class": "local_polling", "loggers": ["duco"], "quality_scale": "platinum", - "requirements": ["python-duco-client==0.3.4"], + "requirements": ["python-duco-client==0.3.6"], "zeroconf": [ { "name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*", diff --git a/requirements_all.txt b/requirements_all.txt index ef272b2cbd6430..0a0c300813e34c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2578,7 +2578,7 @@ python-digitalocean==1.13.2 python-dropbox-api==0.1.3 # homeassistant.components.duco -python-duco-client==0.3.4 +python-duco-client==0.3.6 # homeassistant.components.ecobee python-ecobee-api==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 53f6b1fe071b75..33900534a18de0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2204,7 +2204,7 @@ python-citybikes==0.3.3 python-dropbox-api==0.1.3 # homeassistant.components.duco -python-duco-client==0.3.4 +python-duco-client==0.3.6 # homeassistant.components.ecobee python-ecobee-api==0.3.2 diff --git a/tests/components/duco/snapshots/test_diagnostics.ambr b/tests/components/duco/snapshots/test_diagnostics.ambr index 8fda39207b7a8c..fa1cea8cfc4eb5 100644 --- a/tests/components/duco/snapshots/test_diagnostics.ambr +++ b/tests/components/duco/snapshots/test_diagnostics.ambr @@ -45,6 +45,7 @@ 'iaq_co2': None, 'iaq_rh': None, 'rh': None, + 'temp': None, }), 'ventilation': dict({ 'flow_lvl_tgt': 0, @@ -70,6 +71,7 @@ 'iaq_co2': None, 'iaq_rh': 85, 'rh': 42.0, + 'temp': None, }), 'ventilation': dict({ 'flow_lvl_tgt': None, @@ -95,6 +97,7 @@ 'iaq_co2': 80, 'iaq_rh': None, 'rh': None, + 'temp': None, }), 'ventilation': dict({ 'flow_lvl_tgt': None, @@ -120,6 +123,7 @@ 'iaq_co2': None, 'iaq_rh': 90, 'rh': 61.0, + 'temp': None, }), 'ventilation': dict({ 'flow_lvl_tgt': None,