From 09a44a6a301a6dec9845b268d54f1f8457bef212 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 12 Sep 2025 08:05:01 +0200 Subject: [PATCH 01/17] Fix spelling of "H.265" encoding standard in `reolink` (#152130) --- homeassistant/components/reolink/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index b0a54c1dd5d44..cdb10b7c687a6 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -50,7 +50,7 @@ "protocol": "Protocol" }, "data_description": { - "protocol": "Streaming protocol to use for the camera entities. RTSP supports 4K streams (h265 encoding) while RTMP and FLV do not. FLV is the least demanding on the camera." + "protocol": "Streaming protocol to use for the camera entities. RTSP supports 4K streams (H.265 encoding) while RTMP and FLV do not. FLV is the least demanding on the camera." } } } From 4c1364dfd10965e935f3ea0c63750f1cff4977ec Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 12 Sep 2025 09:32:17 +0200 Subject: [PATCH 02/17] Fix wrong type annotation in exposed_entities (#152142) --- homeassistant/components/homeassistant/exposed_entities.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index b7e420dedde52..135e6cdd3763b 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -406,7 +406,7 @@ def ws_expose_entity( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Expose an entity to an assistant.""" - entity_ids: str = msg["entity_ids"] + entity_ids: list[str] = msg["entity_ids"] if blocked := next( ( From 1ef90180cc7b5b936e6aa0fd81a8849e9eca2395 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Fri, 12 Sep 2025 16:05:15 +0800 Subject: [PATCH 03/17] Add plug mini eu for switchbot integration (#151130) --- .../components/switchbot/__init__.py | 2 + homeassistant/components/switchbot/const.py | 4 + tests/components/switchbot/__init__.py | 24 ++++++ tests/components/switchbot/test_sensor.py | 75 +++++++++++++++++++ tests/components/switchbot/test_switch.py | 51 ++++++++++++- 5 files changed, 155 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 08df5dc50f085..f5e587f0d9c65 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -97,6 +97,7 @@ SupportedModels.STRIP_LIGHT_3.value: [Platform.LIGHT, Platform.SENSOR], SupportedModels.RGBICWW_FLOOR_LAMP.value: [Platform.LIGHT, Platform.SENSOR], SupportedModels.RGBICWW_STRIP_LIGHT.value: [Platform.LIGHT, Platform.SENSOR], + SupportedModels.PLUG_MINI_EU.value: [Platform.SWITCH, Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, @@ -127,6 +128,7 @@ SupportedModels.STRIP_LIGHT_3.value: switchbot.SwitchbotStripLight3, SupportedModels.RGBICWW_FLOOR_LAMP.value: switchbot.SwitchbotRgbicLight, SupportedModels.RGBICWW_STRIP_LIGHT.value: switchbot.SwitchbotRgbicLight, + SupportedModels.PLUG_MINI_EU.value: switchbot.SwitchbotRelaySwitch, } diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 5cdb3d9dd4e63..549a602c3ff56 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -53,6 +53,7 @@ class SupportedModels(StrEnum): STRIP_LIGHT_3 = "strip_light_3" RGBICWW_STRIP_LIGHT = "rgbicww_strip_light" RGBICWW_FLOOR_LAMP = "rgbicww_floor_lamp" + PLUG_MINI_EU = "plug_mini_eu" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -85,6 +86,7 @@ class SupportedModels(StrEnum): SwitchbotModel.STRIP_LIGHT_3: SupportedModels.STRIP_LIGHT_3, SwitchbotModel.RGBICWW_STRIP_LIGHT: SupportedModels.RGBICWW_STRIP_LIGHT, SwitchbotModel.RGBICWW_FLOOR_LAMP: SupportedModels.RGBICWW_FLOOR_LAMP, + SwitchbotModel.PLUG_MINI_EU: SupportedModels.PLUG_MINI_EU, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -118,6 +120,7 @@ class SupportedModels(StrEnum): SwitchbotModel.STRIP_LIGHT_3, SwitchbotModel.RGBICWW_STRIP_LIGHT, SwitchbotModel.RGBICWW_FLOOR_LAMP, + SwitchbotModel.PLUG_MINI_EU, } ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[ @@ -136,6 +139,7 @@ class SupportedModels(StrEnum): SwitchbotModel.STRIP_LIGHT_3: switchbot.SwitchbotStripLight3, SwitchbotModel.RGBICWW_STRIP_LIGHT: switchbot.SwitchbotRgbicLight, SwitchbotModel.RGBICWW_FLOOR_LAMP: switchbot.SwitchbotRgbicLight, + SwitchbotModel.PLUG_MINI_EU: switchbot.SwitchbotRelaySwitch, } HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = { diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 184ec1a9ae3ae..0cbab0f13bd36 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -1056,3 +1056,27 @@ def make_advertisement( connectable=True, tx_power=-127, ) + +PLUG_MINI_EU_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Plug Mini (EU)", + manufacturer_data={ + 2409: b"\x94\xa9\x90T\x85^?\xa1\x00\x00\x04\xe6\x00\x00\x00\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"?\x00\x00\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Plug Mini (EU)", + manufacturer_data={ + 2409: b"\x94\xa9\x90T\x85^?\xa1\x00\x00\x04\xe6\x00\x00\x00\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"?\x00\x00\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Plug Mini (EU)"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index 645eb5d1ab31c..c9c28b7d94eb9 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -28,6 +28,7 @@ HUB3_SERVICE_INFO, HUBMINI_MATTER_SERVICE_INFO, LEAK_SERVICE_INFO, + PLUG_MINI_EU_SERVICE_INFO, REMOTE_SERVICE_INFO, WOHAND_SERVICE_INFO, WOHUB2_SERVICE_INFO, @@ -542,3 +543,77 @@ async def test_evaporative_humidifier_sensor(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_plug_mini_eu_sensor(hass: HomeAssistant) -> None: + """Test setting up creates the plug mini eu sensor.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, PLUG_MINI_EU_SERVICE_INFO) + + with patch( + "homeassistant.components.switchbot.switch.switchbot.SwitchbotRelaySwitch.get_basic_info", + new=AsyncMock( + return_value={ + "power": 500, + "current": 0.5, + "voltage": 230, + "energy": 0.4, + } + ), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "plug_mini_eu", + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + }, + unique_id="aabbccddeeaa", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 5 + + power_sensor = hass.states.get("sensor.test_name_power") + power_sensor_attrs = power_sensor.attributes + assert power_sensor.state == "500" + assert power_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Power" + assert power_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "W" + assert power_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + voltage_sensor = hass.states.get("sensor.test_name_voltage") + voltage_sensor_attrs = voltage_sensor.attributes + assert voltage_sensor.state == "230" + assert voltage_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Voltage" + assert voltage_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "V" + assert voltage_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + current_sensor = hass.states.get("sensor.test_name_current") + current_sensor_attrs = current_sensor.attributes + assert current_sensor.state == "0.5" + assert current_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Current" + assert current_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "A" + assert current_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + energy_sensor = hass.states.get("sensor.test_name_energy") + energy_sensor_attrs = energy_sensor.attributes + assert energy_sensor.state == "0.4" + assert energy_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Energy" + assert energy_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "kWh" + assert energy_sensor_attrs[ATTR_STATE_CLASS] == "total_increasing" + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + assert rssi_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/switchbot/test_switch.py b/tests/components/switchbot/test_switch.py index be28b2a02a851..c3740eb8b8e76 100644 --- a/tests/components/switchbot/test_switch.py +++ b/tests/components/switchbot/test_switch.py @@ -6,6 +6,7 @@ import pytest from switchbot.devices.device import SwitchbotOperationError +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -16,7 +17,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError -from . import WOHAND_SERVICE_INFO +from . import PLUG_MINI_EU_SERVICE_INFO, WOHAND_SERVICE_INFO from tests.common import MockConfigEntry, mock_restore_cache from tests.components.bluetooth import inject_bluetooth_service_info @@ -103,3 +104,51 @@ async def test_exception_handling_switch( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +@pytest.mark.parametrize( + ("sensor_type", "service_info"), + [ + ("plug_mini_eu", PLUG_MINI_EU_SERVICE_INFO), + ], +) +@pytest.mark.parametrize( + ("service", "mock_method"), + [ + (SERVICE_TURN_ON, "turn_on"), + (SERVICE_TURN_OFF, "turn_off"), + ], +) +async def test_relay_switch_control( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + sensor_type: str, + service_info: BluetoothServiceInfoBleak, + service: str, + mock_method: str, +) -> None: + """Test Relay Switch control.""" + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_encrypted_factory(sensor_type=sensor_type) + entry.add_to_hass(hass) + + mocked_instance = AsyncMock(return_value=True) + with patch.multiple( + "homeassistant.components.switchbot.switch.switchbot.SwitchbotRelaySwitch", + update=AsyncMock(return_value=None), + **{mock_method: mocked_instance}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "switch.test_name" + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once() From 2c3456177eef062ef9b8b5872f5670700994a99a Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Fri, 12 Sep 2025 16:08:01 +0800 Subject: [PATCH 04/17] Add humidifier support for switchbot cloud integration (#149039) --- .../components/switchbot_cloud/__init__.py | 15 ++ .../components/switchbot_cloud/const.py | 24 ++ .../components/switchbot_cloud/humidifier.py | 155 ++++++++++++ .../components/switchbot_cloud/icons.json | 16 ++ .../components/switchbot_cloud/sensor.py | 1 + .../components/switchbot_cloud/strings.json | 16 ++ tests/components/switchbot_cloud/__init__.py | 17 +- .../switchbot_cloud/fixtures/status.json | 19 ++ .../snapshots/test_humidifier.ambr | 145 ++++++++++++ .../switchbot_cloud/test_humidifier.py | 221 ++++++++++++++++++ 10 files changed, 628 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/switchbot_cloud/humidifier.py create mode 100644 tests/components/switchbot_cloud/snapshots/test_humidifier.ambr create mode 100644 tests/components/switchbot_cloud/test_humidifier.py diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 0beba5dfc14d5..536273df28f26 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -31,6 +31,7 @@ Platform.CLIMATE, Platform.COVER, Platform.FAN, + Platform.HUMIDIFIER, Platform.LIGHT, Platform.LOCK, Platform.SENSOR, @@ -57,6 +58,7 @@ class SwitchbotDevices: locks: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) fans: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) lights: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) + humidifiers: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) @dataclass @@ -255,6 +257,19 @@ async def make_device_data( ) devices_data.lights.append((device, coordinator)) + if isinstance(device, Device) and device.device_type == "Humidifier2": + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.humidifiers.append((device, coordinator)) + + if isinstance(device, Device) and device.device_type == "Humidifier": + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.humidifiers.append((device, coordinator)) + devices_data.sensors.append((device, coordinator)) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SwitchBot via API from a config entry.""" diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index 23a212075c4ec..4f70e5f594b2a 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -20,6 +20,12 @@ AFTER_COMMAND_REFRESH = 5 COVER_ENTITY_AFTER_COMMAND_REFRESH = 10 +HUMIDITY_LEVELS = { + 34: 101, # Low humidity mode + 67: 102, # Medium humidity mode + 100: 103, # High humidity mode +} + class AirPurifierMode(Enum): """Air Purifier Modes.""" @@ -33,3 +39,21 @@ class AirPurifierMode(Enum): def get_modes(cls) -> list[str]: """Return a list of available air purifier modes as lowercase strings.""" return [mode.name.lower() for mode in cls] + + +class Humidifier2Mode(Enum): + """Enumerates the available modes for a SwitchBot humidifier2.""" + + HIGH = 1 + MEDIUM = 2 + LOW = 3 + QUIET = 4 + TARGET_HUMIDITY = 5 + SLEEP = 6 + AUTO = 7 + DRYING_FILTER = 8 + + @classmethod + def get_modes(cls) -> list[str]: + """Return a list of available humidifier2 modes as lowercase strings.""" + return [mode.name.lower() for mode in cls] diff --git a/homeassistant/components/switchbot_cloud/humidifier.py b/homeassistant/components/switchbot_cloud/humidifier.py new file mode 100644 index 0000000000000..dc4824bd89010 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/humidifier.py @@ -0,0 +1,155 @@ +"""Support for Switchbot humidifier.""" + +import asyncio +from typing import Any + +from switchbot_api import CommonCommands, HumidifierCommands, HumidifierV2Commands + +from homeassistant.components.humidifier import ( + MODE_AUTO, + MODE_NORMAL, + HumidifierDeviceClass, + HumidifierEntity, + HumidifierEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SwitchbotCloudData +from .const import AFTER_COMMAND_REFRESH, DOMAIN, HUMIDITY_LEVELS, Humidifier2Mode +from .entity import SwitchBotCloudEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Switchbot based on a config entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + SwitchBotHumidifier(data.api, device, coordinator) + if device.device_type == "Humidifier" + else SwitchBotEvaporativeHumidifier(data.api, device, coordinator) + for device, coordinator in data.devices.humidifiers + ) + + +class SwitchBotHumidifier(SwitchBotCloudEntity, HumidifierEntity): + """Representation of a Switchbot humidifier.""" + + _attr_supported_features = HumidifierEntityFeature.MODES + _attr_device_class = HumidifierDeviceClass.HUMIDIFIER + _attr_available_modes = [MODE_NORMAL, MODE_AUTO] + _attr_min_humidity = 1 + _attr_translation_key = "humidifier" + _attr_name = None + _attr_target_humidity = 50 + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + if coord_data := self.coordinator.data: + self._attr_is_on = coord_data.get("power") == STATE_ON + self._attr_mode = MODE_AUTO if coord_data.get("auto") else MODE_NORMAL + self._attr_current_humidity = coord_data.get("humidity") + + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + self.target_humidity, parameters = self._map_humidity_to_supported_level( + humidity + ) + await self.send_api_command( + HumidifierCommands.SET_MODE, parameters=str(parameters) + ) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_set_mode(self, mode: str) -> None: + """Set new target humidity.""" + if mode == MODE_AUTO: + await self.send_api_command(HumidifierCommands.SET_MODE, parameters=mode) + else: + await self.send_api_command( + HumidifierCommands.SET_MODE, parameters=str(102) + ) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await self.send_api_command(CommonCommands.ON) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.send_api_command(CommonCommands.OFF) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + def _map_humidity_to_supported_level(self, humidity: int) -> tuple[int, int]: + """Map any humidity to the closest supported level and its parameter.""" + if humidity <= 34: + return 34, HUMIDITY_LEVELS[34] + if humidity <= 67: + return 67, HUMIDITY_LEVELS[67] + return 100, HUMIDITY_LEVELS[100] + + +class SwitchBotEvaporativeHumidifier(SwitchBotCloudEntity, HumidifierEntity): + """Representation of a Switchbot humidifier v2.""" + + _attr_supported_features = HumidifierEntityFeature.MODES + _attr_device_class = HumidifierDeviceClass.HUMIDIFIER + _attr_available_modes = Humidifier2Mode.get_modes() + _attr_translation_key = "evaporative_humidifier" + _attr_name = None + _attr_target_humidity = 50 + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + if coord_data := self.coordinator.data: + self._attr_is_on = coord_data.get("power") == STATE_ON + self._attr_mode = ( + Humidifier2Mode(coord_data.get("mode")).name.lower() + if coord_data.get("mode") is not None + else None + ) + self._attr_current_humidity = ( + coord_data.get("humidity") + if coord_data.get("humidity") != 127 + else None + ) + + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + assert self.coordinator.data is not None + self._attr_target_humidity = humidity + params = {"mode": self.coordinator.data["mode"], "humidity": humidity} + await self.send_api_command(HumidifierV2Commands.SET_MODE, parameters=params) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_set_mode(self, mode: str) -> None: + """Set new target mode.""" + assert self.coordinator.data is not None + params = {"mode": Humidifier2Mode[mode.upper()].value} + await self.send_api_command(HumidifierV2Commands.SET_MODE, parameters=params) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await self.send_api_command(CommonCommands.ON) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.send_api_command(CommonCommands.OFF) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/switchbot_cloud/icons.json b/homeassistant/components/switchbot_cloud/icons.json index 2a468d40a5d37..c7624d3f83d1f 100644 --- a/homeassistant/components/switchbot_cloud/icons.json +++ b/homeassistant/components/switchbot_cloud/icons.json @@ -34,6 +34,22 @@ "10": "mdi:brightness-7" } } + }, + "humidifier": { + "evaporative_humidifier": { + "state_attributes": { + "mode": { + "state": { + "high": "mdi:water-plus", + "medium": "mdi:water", + "low": "mdi:water-outline", + "quiet": "mdi:volume-off", + "target_humidity": "mdi:target", + "drying_filter": "mdi:water-remove" + } + } + } + } } } } diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index d5ff5b0e8e729..b2d375573efeb 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -160,6 +160,7 @@ "Motion Sensor": (BATTERY_DESCRIPTION,), "Contact Sensor": (BATTERY_DESCRIPTION,), "Water Detector": (BATTERY_DESCRIPTION,), + "Humidifier": (TEMPERATURE_DESCRIPTION,), } diff --git a/homeassistant/components/switchbot_cloud/strings.json b/homeassistant/components/switchbot_cloud/strings.json index 7ab6ff0679221..928e2e1e01b54 100644 --- a/homeassistant/components/switchbot_cloud/strings.json +++ b/homeassistant/components/switchbot_cloud/strings.json @@ -36,6 +36,22 @@ "light_level": { "name": "Light level" } + }, + "humidifier": { + "evaporative_humidifier": { + "state_attributes": { + "mode": { + "state": { + "high": "[%key:common::state::high%]", + "medium": "[%key:common::state::medium%]", + "low": "[%key:common::state::low%]", + "quiet": "Quiet", + "target_humidity": "Target humidity", + "drying_filter": "Drying filter" + } + } + } + } } } } diff --git a/tests/components/switchbot_cloud/__init__.py b/tests/components/switchbot_cloud/__init__.py index 397c62d32c15d..2fd82faa3b812 100644 --- a/tests/components/switchbot_cloud/__init__.py +++ b/tests/components/switchbot_cloud/__init__.py @@ -41,7 +41,6 @@ async def configure_integration(hass: HomeAssistant) -> MockConfigEntry: hubDeviceId="test-hub-id", ) - METER_INFO = Device( version="V1.0", deviceId="meter-id-1", @@ -81,3 +80,19 @@ async def configure_integration(hass: HomeAssistant) -> MockConfigEntry: deviceType="Water Detector", hubDeviceId="test-hub-id", ) + +HUMIDIFIER_INFO = Device( + version="V1.0", + deviceId="humidifier-id-1", + deviceName="humidifier-1", + deviceType="Humidifier", + hubDeviceId="test-hub-id", +) + +HUMIDIFIER2_INFO = Device( + version="V1.0", + deviceId="humidifier2-id-1", + deviceName="humidifier2-1", + deviceType="Humidifier2", + hubDeviceId="test-hub-id", +) diff --git a/tests/components/switchbot_cloud/fixtures/status.json b/tests/components/switchbot_cloud/fixtures/status.json index 87eae6cc93e90..16b56d386ecdd 100644 --- a/tests/components/switchbot_cloud/fixtures/status.json +++ b/tests/components/switchbot_cloud/fixtures/status.json @@ -44,5 +44,24 @@ "deviceId": "water-detector-id", "deviceType": "Water Detector", "hubDeviceId": "test-hub-id" + }, + { + "version": "V1.8", + "power": "on", + "auto": false, + "humidity": 50, + "temperature": 24.3, + "deviceId": "test-id-1", + "deviceType": "Humidifier", + "hubDeviceId": "test-hub-id" + }, + { + "version": "V1.0", + "power": "on", + "mode": 1, + "humidity": 50, + "deviceId": "test-id-1", + "deviceType": "Humidifier2", + "hubDeviceId": "test-hub-id" } ] diff --git a/tests/components/switchbot_cloud/snapshots/test_humidifier.ambr b/tests/components/switchbot_cloud/snapshots/test_humidifier.ambr new file mode 100644 index 0000000000000..d369b0f3b48d0 --- /dev/null +++ b/tests/components/switchbot_cloud/snapshots/test_humidifier.ambr @@ -0,0 +1,145 @@ +# serializer version: 1 +# name: test_humidifier[device_info0-6][humidifier.humidifier_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'max_humidity': 100, + 'min_humidity': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.humidifier_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'humidifier', + 'unique_id': 'humidifier-id-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_humidifier[device_info0-6][humidifier.humidifier_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'current_humidity': 50, + 'device_class': 'humidifier', + 'friendly_name': 'humidifier-1', + 'humidity': 50, + 'max_humidity': 100, + 'min_humidity': 1, + 'mode': 'normal', + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.humidifier_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_humidifier[device_info1-7][humidifier.humidifier2_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'high', + 'medium', + 'low', + 'quiet', + 'target_humidity', + 'sleep', + 'auto', + 'drying_filter', + ]), + 'max_humidity': 100, + 'min_humidity': 0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.humidifier2_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'evaporative_humidifier', + 'unique_id': 'humidifier2-id-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_humidifier[device_info1-7][humidifier.humidifier2_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'available_modes': list([ + 'high', + 'medium', + 'low', + 'quiet', + 'target_humidity', + 'sleep', + 'auto', + 'drying_filter', + ]), + 'current_humidity': 50, + 'device_class': 'humidifier', + 'friendly_name': 'humidifier2-1', + 'humidity': 50, + 'max_humidity': 100, + 'min_humidity': 0, + 'mode': 'high', + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.humidifier2_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/switchbot_cloud/test_humidifier.py b/tests/components/switchbot_cloud/test_humidifier.py new file mode 100644 index 0000000000000..7b4b3caa065a7 --- /dev/null +++ b/tests/components/switchbot_cloud/test_humidifier.py @@ -0,0 +1,221 @@ +"""Test for the switchbot_cloud humidifiers.""" + +from unittest.mock import patch + +import pytest +import switchbot_api +from switchbot_api import Device +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN +from homeassistant.components.switchbot_cloud import SwitchBotAPI +from homeassistant.components.switchbot_cloud.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import HUMIDIFIER2_INFO, HUMIDIFIER_INFO, configure_integration + +from tests.common import async_load_json_array_fixture, snapshot_platform + + +@pytest.mark.parametrize( + ("device_info", "index"), + [ + (HUMIDIFIER_INFO, 6), + (HUMIDIFIER2_INFO, 7), + ], +) +async def test_humidifier( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_list_devices, + mock_get_status, + device_info: Device, + index: int, +) -> None: + """Test humidifier sensors.""" + + mock_list_devices.return_value = [device_info] + json_data = await async_load_json_array_fixture(hass, "status.json", DOMAIN) + mock_get_status.return_value = json_data[index] + + with patch( + "homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.HUMIDIFIER] + ): + entry = await configure_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize( + ("service", "service_data", "expected_call_args"), + [ + ( + "turn_on", + {}, + ( + "humidifier-id-1", + switchbot_api.CommonCommands.ON, + "command", + "default", + ), + ), + ( + "turn_off", + {}, + ( + "humidifier-id-1", + switchbot_api.CommonCommands.OFF, + "command", + "default", + ), + ), + ( + "set_humidity", + {"humidity": 15}, + ( + "humidifier-id-1", + switchbot_api.HumidifierCommands.SET_MODE, + "command", + "101", + ), + ), + ( + "set_humidity", + {"humidity": 60}, + ( + "humidifier-id-1", + switchbot_api.HumidifierCommands.SET_MODE, + "command", + "102", + ), + ), + ( + "set_humidity", + {"humidity": 80}, + ( + "humidifier-id-1", + switchbot_api.HumidifierCommands.SET_MODE, + "command", + "103", + ), + ), + ( + "set_mode", + {"mode": "auto"}, + ( + "humidifier-id-1", + switchbot_api.HumidifierCommands.SET_MODE, + "command", + "auto", + ), + ), + ( + "set_mode", + {"mode": "normal"}, + ( + "humidifier-id-1", + switchbot_api.HumidifierCommands.SET_MODE, + "command", + "102", + ), + ), + ], +) +async def test_humidifier_controller( + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + service: str, + service_data: dict, + expected_call_args: tuple, +) -> None: + """Test controlling the humidifier with mocked delay.""" + mock_list_devices.return_value = [HUMIDIFIER_INFO] + mock_get_status.return_value = {"power": "OFF", "mode": 2} + + await configure_integration(hass) + humidifier_id = "humidifier.humidifier_1" + + with patch.object(SwitchBotAPI, "send_command") as mocked_send_command: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: humidifier_id}, + blocking=True, + ) + + mocked_send_command.assert_awaited_once_with(*expected_call_args) + + +@pytest.mark.parametrize( + ("service", "service_data", "expected_call_args"), + [ + ( + "turn_on", + {}, + ( + "humidifier2-id-1", + switchbot_api.CommonCommands.ON, + "command", + "default", + ), + ), + ( + "turn_off", + {}, + ( + "humidifier2-id-1", + switchbot_api.CommonCommands.OFF, + "command", + "default", + ), + ), + ( + "set_humidity", + {"humidity": 50}, + ( + "humidifier2-id-1", + switchbot_api.HumidifierV2Commands.SET_MODE, + "command", + {"mode": 2, "humidity": 50}, + ), + ), + ( + "set_mode", + {"mode": "auto"}, + ( + "humidifier2-id-1", + switchbot_api.HumidifierV2Commands.SET_MODE, + "command", + {"mode": 7}, + ), + ), + ], +) +async def test_humidifier2_controller( + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + service: str, + service_data: dict, + expected_call_args: tuple, +) -> None: + """Test controlling the humidifier2 with mocked delay.""" + mock_list_devices.return_value = [HUMIDIFIER2_INFO] + mock_get_status.return_value = {"power": "off", "mode": 2} + + await configure_integration(hass) + humidifier_id = "humidifier.humidifier2_1" + + with patch.object(SwitchBotAPI, "send_command") as mocked_send_command: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: humidifier_id}, + blocking=True, + ) + + mocked_send_command.assert_awaited_once_with(*expected_call_args) From 299cc5e40cea88864852de994fdb28d2cd90c6d2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 12 Sep 2025 10:18:12 +0200 Subject: [PATCH 05/17] Fix sentence-casing of "CPU temperature" in `fritz` (#152149) --- homeassistant/components/fritz/strings.json | 2 +- tests/components/fritz/snapshots/test_sensor.ambr | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 87ee28196ad1e..5ff8dd37d332b 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -176,7 +176,7 @@ "name": "Max connection upload throughput" }, "cpu_temperature": { - "name": "CPU Temperature" + "name": "CPU temperature" } } }, diff --git a/tests/components/fritz/snapshots/test_sensor.ambr b/tests/components/fritz/snapshots/test_sensor.ambr index ac437b28d9959..ae3bf6d68895d 100644 --- a/tests/components/fritz/snapshots/test_sensor.ambr +++ b/tests/components/fritz/snapshots/test_sensor.ambr @@ -855,7 +855,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'CPU Temperature', + 'original_name': 'CPU temperature', 'platform': 'fritz', 'previous_unique_id': None, 'suggested_object_id': None, @@ -869,7 +869,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Mock Title CPU Temperature', + 'friendly_name': 'Mock Title CPU temperature', 'state_class': , 'unit_of_measurement': , }), From 68d987f8660f4fe1e94418e86e4797bbb80c6208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 12 Sep 2025 10:18:58 +0200 Subject: [PATCH 06/17] Bump hass-nabucasa from 1.1.0 to 1.1.1 (#152147) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 43cdf17740a8a..0625054869d88 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==1.1.0"], + "requirements": ["hass-nabucasa==1.1.1"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8ac50fa3a22d2..dee918c3f66ce 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==5.6.2 -hass-nabucasa==1.1.0 +hass-nabucasa==1.1.1 hassil==3.2.0 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250903.3 diff --git a/pyproject.toml b/pyproject.toml index eefeb59d5a3c9..007cda7fad47c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==1.1.0", + "hass-nabucasa==1.1.1", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 05d4cc0fc928a..8ba1d7be73634 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.3 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==1.1.0 +hass-nabucasa==1.1.1 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index ac5102e60184d..d875524f13e0c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1140,7 +1140,7 @@ habiticalib==0.4.5 habluetooth==5.6.2 # homeassistant.components.cloud -hass-nabucasa==1.1.0 +hass-nabucasa==1.1.1 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e9f0735703e80..531a4fee32763 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1001,7 +1001,7 @@ habiticalib==0.4.5 habluetooth==5.6.2 # homeassistant.components.cloud -hass-nabucasa==1.1.0 +hass-nabucasa==1.1.1 # homeassistant.components.assist_satellite # homeassistant.components.conversation From 1d214ae12068f3cf2c635dfc9dc270723e9a56d5 Mon Sep 17 00:00:00 2001 From: Jeremy Cook <8317651+jm-cook@users.noreply.github.com> Date: Fri, 12 Sep 2025 10:19:29 +0200 Subject: [PATCH 07/17] For the met integration Increase the hourly forecast limit to 48 hours in coordinator. (#150486) --- homeassistant/components/met/coordinator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/met/coordinator.py b/homeassistant/components/met/coordinator.py index 8b6243d9dafe9..b2c43cb1361a6 100644 --- a/homeassistant/components/met/coordinator.py +++ b/homeassistant/components/met/coordinator.py @@ -83,7 +83,9 @@ async def fetch_data(self) -> Self: self.current_weather_data = self._weather_data.get_current_weather() time_zone = dt_util.get_default_time_zone() self.daily_forecast = self._weather_data.get_forecast(time_zone, False, 0) - self.hourly_forecast = self._weather_data.get_forecast(time_zone, True) + self.hourly_forecast = self._weather_data.get_forecast( + time_zone, True, range_stop=49 + ) return self From 64ba43703c3787a24524703af81f582d9fdea40a Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Fri, 12 Sep 2025 10:43:45 +0200 Subject: [PATCH 08/17] Fix KNX Light - individual color initialisation from UI config (#151815) --- homeassistant/components/knx/light.py | 14 +++++-- .../knx/storage/entity_store_schema.py | 10 ++--- .../knx/snapshots/test_websocket.ambr | 38 +++++++++---------- 3 files changed, 34 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 1ab6883a4371c..bd54e5f75d98c 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -285,13 +285,19 @@ def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight group_address_switch_green_state=conf.get_state_and_passive( CONF_COLOR, CONF_GA_GREEN_SWITCH ), - group_address_brightness_green=conf.get_write(CONF_GA_GREEN_BRIGHTNESS), + group_address_brightness_green=conf.get_write( + CONF_COLOR, CONF_GA_GREEN_BRIGHTNESS + ), group_address_brightness_green_state=conf.get_state_and_passive( CONF_COLOR, CONF_GA_GREEN_BRIGHTNESS ), - group_address_switch_blue=conf.get_write(CONF_GA_BLUE_SWITCH), - group_address_switch_blue_state=conf.get_state_and_passive(CONF_GA_BLUE_SWITCH), - group_address_brightness_blue=conf.get_write(CONF_GA_BLUE_BRIGHTNESS), + group_address_switch_blue=conf.get_write(CONF_COLOR, CONF_GA_BLUE_SWITCH), + group_address_switch_blue_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_BLUE_SWITCH + ), + group_address_brightness_blue=conf.get_write( + CONF_COLOR, CONF_GA_BLUE_BRIGHTNESS + ), group_address_brightness_blue_state=conf.get_state_and_passive( CONF_COLOR, CONF_GA_BLUE_BRIGHTNESS ), diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index fe0dbf31b6bb7..21252e35f3a7a 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -240,19 +240,19 @@ class LightColorMode(StrEnum): write_required=True, valid_dpt="5.001" ), "section_blue": KNXSectionFlat(), - vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector( - write_required=True, valid_dpt="5.001" - ), vol.Optional(CONF_GA_BLUE_SWITCH): GASelector( write_required=False, valid_dpt="1" ), - "section_white": KNXSectionFlat(), - vol.Optional(CONF_GA_WHITE_BRIGHTNESS): GASelector( + vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector( write_required=True, valid_dpt="5.001" ), + "section_white": KNXSectionFlat(), vol.Optional(CONF_GA_WHITE_SWITCH): GASelector( write_required=False, valid_dpt="1" ), + vol.Optional(CONF_GA_WHITE_BRIGHTNESS): GASelector( + write_required=True, valid_dpt="5.001" + ), }, ), GroupSelectOption( diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index b99196c8769b0..6dc651195aeb5 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -575,7 +575,8 @@ 'type': 'knx_section_flat', }), dict({ - 'name': 'ga_blue_brightness', + 'name': 'ga_blue_switch', + 'optional': True, 'options': dict({ 'passive': True, 'state': dict({ @@ -583,20 +584,19 @@ }), 'validDPTs': list([ dict({ - 'main': 5, - 'sub': 1, + 'main': 1, + 'sub': None, }), ]), 'write': dict({ - 'required': True, + 'required': False, }), }), - 'required': True, + 'required': False, 'type': 'knx_group_address', }), dict({ - 'name': 'ga_blue_switch', - 'optional': True, + 'name': 'ga_blue_brightness', 'options': dict({ 'passive': True, 'state': dict({ @@ -604,15 +604,15 @@ }), 'validDPTs': list([ dict({ - 'main': 1, - 'sub': None, + 'main': 5, + 'sub': 1, }), ]), 'write': dict({ - 'required': False, + 'required': True, }), }), - 'required': False, + 'required': True, 'type': 'knx_group_address', }), dict({ @@ -622,7 +622,7 @@ 'type': 'knx_section_flat', }), dict({ - 'name': 'ga_white_brightness', + 'name': 'ga_white_switch', 'optional': True, 'options': dict({ 'passive': True, @@ -631,19 +631,19 @@ }), 'validDPTs': list([ dict({ - 'main': 5, - 'sub': 1, + 'main': 1, + 'sub': None, }), ]), 'write': dict({ - 'required': True, + 'required': False, }), }), 'required': False, 'type': 'knx_group_address', }), dict({ - 'name': 'ga_white_switch', + 'name': 'ga_white_brightness', 'optional': True, 'options': dict({ 'passive': True, @@ -652,12 +652,12 @@ }), 'validDPTs': list([ dict({ - 'main': 1, - 'sub': None, + 'main': 5, + 'sub': 1, }), ]), 'write': dict({ - 'required': False, + 'required': True, }), }), 'required': False, From e438b11afbbafbb66eb78e01fbda2891db7a8ad0 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 12 Sep 2025 10:44:22 +0200 Subject: [PATCH 09/17] Use `native_visibility` property instead of `visibility` for OpenWeatherMap weather entity (#151867) --- homeassistant/components/openweathermap/weather.py | 2 +- tests/components/openweathermap/snapshots/test_weather.ambr | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index f182b083b9068..56f44fa46fb7e 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -181,7 +181,7 @@ def wind_bearing(self) -> float | str | None: return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_WIND_BEARING) @property - def visibility(self) -> float | str | None: + def native_visibility(self) -> float | None: """Return visibility.""" return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_VISIBILITY_DISTANCE) diff --git a/tests/components/openweathermap/snapshots/test_weather.ambr b/tests/components/openweathermap/snapshots/test_weather.ambr index 073715c87ec68..be3db7bc59434 100644 --- a/tests/components/openweathermap/snapshots/test_weather.ambr +++ b/tests/components/openweathermap/snapshots/test_weather.ambr @@ -72,6 +72,7 @@ 'pressure_unit': , 'temperature': 6.8, 'temperature_unit': , + 'visibility': 10.0, 'visibility_unit': , 'wind_bearing': 199, 'wind_gust_speed': 42.52, @@ -136,6 +137,7 @@ 'supported_features': , 'temperature': 6.8, 'temperature_unit': , + 'visibility': 10.0, 'visibility_unit': , 'wind_bearing': 199, 'wind_gust_speed': 42.52, @@ -200,6 +202,7 @@ 'supported_features': , 'temperature': 6.8, 'temperature_unit': , + 'visibility': 10.0, 'visibility_unit': , 'wind_bearing': 199, 'wind_gust_speed': 42.52, From 8003a49571f39be25d345e350f4a07a128ad75b7 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 12 Sep 2025 04:44:38 -0400 Subject: [PATCH 10/17] Add guest mode switch to Teslemetry (#151550) --- .../components/teslemetry/icons.json | 3 + .../components/teslemetry/strings.json | 3 + homeassistant/components/teslemetry/switch.py | 77 ++++++++++++------- 3 files changed, 56 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index f50f5a75f70c3..46b63fc2c7337 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -752,6 +752,9 @@ }, "vehicle_state_valet_mode": { "default": "mdi:speedometer-slow" + }, + "guest_mode_enabled": { + "default": "mdi:account-group" } } }, diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 510e2b45a02c1..b78f2d00f60ff 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -1084,6 +1084,9 @@ }, "vehicle_state_valet_mode": { "name": "Valet mode" + }, + "guest_mode_enabled": { + "name": "Guest mode" } }, "update": { diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index aae973cf315b5..c0ad058ee2c47 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -4,7 +4,6 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass -from itertools import chain from typing import Any from tesla_fleet_api.const import AutoSeat, Scope @@ -38,6 +37,7 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): """Describes Teslemetry Switch entity.""" + polling: bool = False on_func: Callable[[Vehicle], Awaitable[dict[str, Any]]] off_func: Callable[[Vehicle], Awaitable[dict[str, Any]]] scopes: list[Scope] @@ -53,6 +53,7 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( TeslemetrySwitchEntityDescription( key="vehicle_state_sentry_mode", + polling=True, streaming_listener=lambda vehicle, callback: vehicle.listen_SentryMode( lambda value: callback(None if value is None else value != "Off") ), @@ -62,6 +63,7 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): ), TeslemetrySwitchEntityDescription( key="vehicle_state_valet_mode", + polling=True, streaming_listener=lambda vehicle, value: vehicle.listen_ValetModeEnabled( value ), @@ -72,6 +74,7 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): ), TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_left", + polling=True, streaming_listener=lambda vehicle, callback: vehicle.listen_AutoSeatClimateLeft( callback ), @@ -85,6 +88,7 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): ), TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_right", + polling=True, streaming_listener=lambda vehicle, callback: vehicle.listen_AutoSeatClimateRight(callback), on_func=lambda api: api.remote_auto_seat_climate_request( @@ -97,6 +101,7 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): ), TeslemetrySwitchEntityDescription( key="climate_state_auto_steering_wheel_heat", + polling=True, streaming_listener=lambda vehicle, callback: vehicle.listen_HvacSteeringWheelHeatAuto(callback), on_func=lambda api: api.remote_auto_steering_wheel_heat_climate_request( @@ -109,6 +114,7 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): ), TeslemetrySwitchEntityDescription( key="climate_state_defrost_mode", + polling=True, streaming_listener=lambda vehicle, callback: vehicle.listen_DefrostMode( lambda value: callback(None if value is None else value != "Off") ), @@ -120,6 +126,7 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): ), TeslemetrySwitchEntityDescription( key="charge_state_charging_state", + polling=True, unique_id="charge_state_user_charge_enable_request", value_func=lambda state: state in {"Starting", "Charging"}, streaming_listener=lambda vehicle, callback: vehicle.listen_DetailedChargeState( @@ -131,6 +138,17 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): off_func=lambda api: api.charge_stop(), scopes=[Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS], ), + TeslemetrySwitchEntityDescription( + key="guest_mode_enabled", + polling=False, + unique_id="guest_mode_enabled", + streaming_listener=lambda vehicle, callback: vehicle.listen_GuestModeEnabled( + callback + ), + on_func=lambda api: api.guest_mode(True), + off_func=lambda api: api.guest_mode(False), + scopes=[Scope.VEHICLE_CMDS], + ), ) @@ -141,35 +159,40 @@ async def async_setup_entry( ) -> None: """Set up the Teslemetry Switch platform from a config entry.""" - async_add_entities( - chain( - ( - TeslemetryVehiclePollingVehicleSwitchEntity( - vehicle, description, entry.runtime_data.scopes + entities: list[SwitchEntity] = [] + + for vehicle in entry.runtime_data.vehicles: + for description in VEHICLE_DESCRIPTIONS: + if vehicle.poll or vehicle.firmware < description.streaming_firmware: + if description.polling: + entities.append( + TeslemetryVehiclePollingVehicleSwitchEntity( + vehicle, description, entry.runtime_data.scopes + ) + ) + else: + entities.append( + TeslemetryStreamingVehicleSwitchEntity( + vehicle, description, entry.runtime_data.scopes + ) ) - if vehicle.poll or vehicle.firmware < description.streaming_firmware - else TeslemetryStreamingVehicleSwitchEntity( - vehicle, description, entry.runtime_data.scopes - ) - for vehicle in entry.runtime_data.vehicles - for description in VEHICLE_DESCRIPTIONS - ), - ( - TeslemetryChargeFromGridSwitchEntity( - energysite, - entry.runtime_data.scopes, - ) - for energysite in entry.runtime_data.energysites - if energysite.info_coordinator.data.get("components_battery") - and energysite.info_coordinator.data.get("components_solar") - ), - ( - TeslemetryStormModeSwitchEntity(energysite, entry.runtime_data.scopes) - for energysite in entry.runtime_data.energysites - if energysite.info_coordinator.data.get("components_storm_mode_capable") - ), + + entities.extend( + TeslemetryChargeFromGridSwitchEntity( + energysite, + entry.runtime_data.scopes, ) + for energysite in entry.runtime_data.energysites + if energysite.info_coordinator.data.get("components_battery") + and energysite.info_coordinator.data.get("components_solar") ) + entities.extend( + TeslemetryStormModeSwitchEntity(energysite, entry.runtime_data.scopes) + for energysite in entry.runtime_data.energysites + if energysite.info_coordinator.data.get("components_storm_mode_capable") + ) + + async_add_entities(entities) class TeslemetryVehicleSwitchEntity(TeslemetryRootEntity, SwitchEntity): From ee506e6c1446d89ad7484b3a4ae2d147bdb7f829 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Fri, 12 Sep 2025 12:02:39 +0300 Subject: [PATCH 11/17] Implement thinking content for Gemini (#150347) Co-authored-by: Joost Lekkerkerker --- .../entity.py | 191 +++++++++++++++--- .../snapshots/test_conversation.ambr | 66 ++++++ .../test_conversation.py | 47 ++++- .../test_tts.py | 2 + 4 files changed, 275 insertions(+), 31 deletions(-) create mode 100644 tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index cbb493e29b883..45ef4aad2d44d 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -3,12 +3,13 @@ from __future__ import annotations import asyncio +import base64 import codecs from collections.abc import AsyncGenerator, AsyncIterator, Callable -from dataclasses import replace +from dataclasses import dataclass, replace import mimetypes from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Literal, cast from google.genai import Client from google.genai.errors import APIError, ClientError @@ -27,6 +28,7 @@ PartUnionDict, SafetySetting, Schema, + ThinkingConfig, Tool, ToolListUnion, ) @@ -201,6 +203,30 @@ def _create_google_tool_response_content( ) +@dataclass(slots=True) +class PartDetails: + """Additional data for a content part.""" + + part_type: Literal["text", "thought", "function_call"] + """The part type for which this data is relevant for.""" + + index: int + """Start position or number of the tool.""" + + length: int = 0 + """Length of the relevant data.""" + + thought_signature: str | None = None + """Base64 encoded thought signature, if available.""" + + +@dataclass(slots=True) +class ContentDetails: + """Native data for AssistantContent.""" + + part_details: list[PartDetails] + + def _convert_content( content: ( conversation.UserContent @@ -209,32 +235,91 @@ def _convert_content( ), ) -> Content: """Convert HA content to Google content.""" - if content.role != "assistant" or not content.tool_calls: - role = "model" if content.role == "assistant" else content.role + if content.role != "assistant": return Content( - role=role, - parts=[ - Part.from_text(text=content.content if content.content else ""), - ], + role=content.role, + parts=[Part.from_text(text=content.content if content.content else "")], ) # Handle the Assistant content with tool calls. assert type(content) is conversation.AssistantContent parts: list[Part] = [] + part_details: list[PartDetails] = ( + content.native.part_details + if isinstance(content.native, ContentDetails) + else [] + ) + details: PartDetails | None = None if content.content: - parts.append(Part.from_text(text=content.content)) + index = 0 + for details in part_details: + if details.part_type == "text": + if index < details.index: + parts.append( + Part.from_text(text=content.content[index : details.index]) + ) + index = details.index + parts.append( + Part.from_text( + text=content.content[index : index + details.length], + ) + ) + if details.thought_signature: + parts[-1].thought_signature = base64.b64decode( + details.thought_signature + ) + index += details.length + if index < len(content.content): + parts.append(Part.from_text(text=content.content[index:])) + + if content.thinking_content: + index = 0 + for details in part_details: + if details.part_type == "thought": + if index < details.index: + parts.append( + Part.from_text( + text=content.thinking_content[index : details.index] + ) + ) + parts[-1].thought = True + index = details.index + parts.append( + Part.from_text( + text=content.thinking_content[index : index + details.length], + ) + ) + parts[-1].thought = True + if details.thought_signature: + parts[-1].thought_signature = base64.b64decode( + details.thought_signature + ) + index += details.length + if index < len(content.thinking_content): + parts.append(Part.from_text(text=content.thinking_content[index:])) + parts[-1].thought = True if content.tool_calls: - parts.extend( - [ + for index, tool_call in enumerate(content.tool_calls): + parts.append( Part.from_function_call( name=tool_call.tool_name, args=_escape_decode(tool_call.tool_args), ) - for tool_call in content.tool_calls - ] - ) + ) + if details := next( + ( + d + for d in part_details + if d.part_type == "function_call" and d.index == index + ), + None, + ): + if details.thought_signature: + parts[-1].thought_signature = base64.b64decode( + details.thought_signature + ) return Content(role="model", parts=parts) @@ -243,14 +328,20 @@ async def _transform_stream( result: AsyncIterator[GenerateContentResponse], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: new_message = True + part_details: list[PartDetails] = [] try: async for response in result: LOGGER.debug("Received response chunk: %s", response) - chunk: conversation.AssistantContentDeltaDict = {} if new_message: - chunk["role"] = "assistant" + if part_details: + yield {"native": ContentDetails(part_details=part_details)} + part_details = [] + yield {"role": "assistant"} new_message = False + content_index = 0 + thinking_content_index = 0 + tool_call_index = 0 # According to the API docs, this would mean no candidate is returned, so we can safely throw an error here. if response.prompt_feedback or not response.candidates: @@ -284,23 +375,62 @@ async def _transform_stream( else [] ) - content = "".join([part.text for part in response_parts if part.text]) - tool_calls = [] for part in response_parts: - if not part.function_call: - continue - tool_call = part.function_call - tool_name = tool_call.name if tool_call.name else "" - tool_args = _escape_decode(tool_call.args) - tool_calls.append( - llm.ToolInput(tool_name=tool_name, tool_args=tool_args) - ) + chunk: conversation.AssistantContentDeltaDict = {} + + if part.text: + if part.thought: + chunk["thinking_content"] = part.text + if part.thought_signature: + part_details.append( + PartDetails( + part_type="thought", + index=thinking_content_index, + length=len(part.text), + thought_signature=base64.b64encode( + part.thought_signature + ).decode("utf-8"), + ) + ) + thinking_content_index += len(part.text) + else: + chunk["content"] = part.text + if part.thought_signature: + part_details.append( + PartDetails( + part_type="text", + index=content_index, + length=len(part.text), + thought_signature=base64.b64encode( + part.thought_signature + ).decode("utf-8"), + ) + ) + content_index += len(part.text) + + if part.function_call: + tool_call = part.function_call + tool_name = tool_call.name if tool_call.name else "" + tool_args = _escape_decode(tool_call.args) + chunk["tool_calls"] = [ + llm.ToolInput(tool_name=tool_name, tool_args=tool_args) + ] + if part.thought_signature: + part_details.append( + PartDetails( + part_type="function_call", + index=tool_call_index, + thought_signature=base64.b64encode( + part.thought_signature + ).decode("utf-8"), + ) + ) + + yield chunk - if tool_calls: - chunk["tool_calls"] = tool_calls + if part_details: + yield {"native": ContentDetails(part_details=part_details)} - chunk["content"] = content - yield chunk except ( APIError, ValueError, @@ -522,6 +652,7 @@ def create_generate_content_config(self) -> GenerateContentConfig: ), ), ], + thinking_config=ThinkingConfig(include_thoughts=True), ) diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr new file mode 100644 index 0000000000000..c8b1dd93be474 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -0,0 +1,66 @@ +# serializer version: 1 +# name: test_function_call + list([ + Content( + parts=[ + Part( + text='Please call the test function' + ), + ], + role='user' + ), + Content( + parts=[ + Part( + text='Hi there!', + thought_signature=b'_thought_signature_2' + ), + Part( + text='The user asked me to call a function', + thought=True, + thought_signature=b'_thought_signature_1' + ), + Part( + function_call=FunctionCall( + args={ + 'param1': [ + 'test_value', + "param1's value", + ], + 'param2': 2.7 + }, + name='test_tool' + ), + thought_signature=b'_thought_signature_3' + ), + ], + role='model' + ), + Content( + parts=[ + Part( + function_response=FunctionResponse( + name='test_tool', + response={ + 'result': 'Test response' + } + ) + ), + ], + role='user' + ), + Content( + parts=[ + Part( + text="I've called the ", + thought_signature=b'_thought_signature_4' + ), + Part( + text='test function with the provided parameters.', + thought_signature=b'_thought_signature_5' + ), + ], + role='model' + ), + ]) +# --- diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index ab8c10e933be8..9085e90f6342e 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -5,6 +5,7 @@ from freezegun import freeze_time from google.genai.types import GenerateContentResponse import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation from homeassistant.components.conversation import UserContent @@ -80,6 +81,7 @@ async def test_function_call( mock_config_entry_with_assist: MockConfigEntry, mock_chat_log: MockChatLog, # noqa: F811 mock_send_message_stream: AsyncMock, + snapshot: SnapshotAssertion, ) -> None: """Test function calling.""" agent_id = "conversation.google_ai_conversation" @@ -93,9 +95,15 @@ async def test_function_call( { "content": { "parts": [ + { + "text": "The user asked me to call a function", + "thought": True, + "thought_signature": b"_thought_signature_1", + }, { "text": "Hi there!", - } + "thought_signature": b"_thought_signature_2", + }, ], "role": "model", } @@ -118,6 +126,7 @@ async def test_function_call( "param2": 2.7, }, }, + "thought_signature": b"_thought_signature_3", } ], "role": "model", @@ -136,6 +145,7 @@ async def test_function_call( "parts": [ { "text": "I've called the ", + "thought_signature": b"_thought_signature_4", } ], "role": "model", @@ -150,6 +160,25 @@ async def test_function_call( "parts": [ { "text": "test function with the provided parameters.", + "thought_signature": b"_thought_signature_5", + } + ], + "role": "model", + }, + "finish_reason": "STOP", + } + ], + ), + ], + # Follow-up response + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [ + { + "text": "You are welcome!", } ], "role": "model", @@ -205,6 +234,22 @@ async def test_function_call( "video_metadata": None, } + # Test history conversion for multi-turn conversation + with patch( + "google.genai.chats.AsyncChats.create", return_value=AsyncMock() + ) as mock_create: + mock_create.return_value.send_message_stream = mock_send_message_stream + await conversation.async_converse( + hass, + "Thank you!", + mock_chat_log.conversation_id, + context, + agent_id=agent_id, + device_id="test_device", + ) + + assert mock_create.call_args[1].get("history") == snapshot + @pytest.mark.usefixtures("mock_init_component") @pytest.mark.usefixtures("mock_ulid_tools") diff --git a/tests/components/google_generative_ai_conversation/test_tts.py b/tests/components/google_generative_ai_conversation/test_tts.py index 87fc4fe8a7689..271a209f79f33 100644 --- a/tests/components/google_generative_ai_conversation/test_tts.py +++ b/tests/components/google_generative_ai_conversation/test_tts.py @@ -208,6 +208,7 @@ async def test_tts_service_speak( threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, ), ], + thinking_config=types.ThinkingConfig(include_thoughts=True), ), ) @@ -276,5 +277,6 @@ async def test_tts_service_speak_error( threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, ), ], + thinking_config=types.ThinkingConfig(include_thoughts=True), ), ) From 2b61601fd7f3dc4039dc5dae4128164a84b12c0b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 12 Sep 2025 05:03:01 -0400 Subject: [PATCH 12/17] Remove the host from the AI Task generated image URL (#151887) --- homeassistant/components/ai_task/task.py | 3 +-- tests/components/ai_task/test_task.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index 372ac650add04..5cd57395d9dbf 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -19,7 +19,6 @@ from homeassistant.helpers import llm from homeassistant.helpers.chat_session import ChatSession, async_get_chat_session from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.network import get_url from homeassistant.util import RE_SANITIZE_FILENAME, slugify from .const import ( @@ -249,7 +248,7 @@ def _purge_image(filename: str, now: datetime) -> None: if IMAGE_EXPIRY_TIME > 0: async_call_later(hass, IMAGE_EXPIRY_TIME, partial(_purge_image, filename)) - service_result["url"] = get_url(hass) + async_sign_path( + service_result["url"] = async_sign_path( hass, f"/api/{DOMAIN}/images/{filename}", timedelta(seconds=IMAGE_EXPIRY_TIME or 1800), diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index 780f44b7e5012..bc8bff4e632b3 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -286,7 +286,7 @@ async def test_generate_image( assert "image_data" not in result assert result["media_source_id"].startswith("media-source://ai_task/images/") assert result["media_source_id"].endswith("_test_task.png") - assert result["url"].startswith("http://10.10.10.10:8123/api/ai_task/images/") + assert result["url"].startswith("/api/ai_task/images/") assert result["url"].count("_test_task.png?authSig=") == 1 assert result["mime_type"] == "image/png" assert result["model"] == "mock_model" From 207c848438a6b10aa402377c21a42442f19e5730 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Sep 2025 04:08:51 -0500 Subject: [PATCH 13/17] Improve SwitchBot device discovery when Bluetooth adapter is in passive mode (#152074) --- .../components/switchbot/config_flow.py | 92 ++- .../components/switchbot/strings.json | 18 + .../components/switchbot/test_config_flow.py | 576 ++++++++++++++++-- 3 files changed, 642 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index b207440d796c3..5c856bc216c84 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -11,12 +11,15 @@ SwitchbotApiError, SwitchbotAuthenticationError, SwitchbotModel, + fetch_cloud_devices, parse_advertisement_data, ) import voluptuous as vol from homeassistant.components.bluetooth import ( + BluetoothScanningMode, BluetoothServiceInfoBleak, + async_current_scanners, async_discovered_service_info, ) from homeassistant.config_entries import ( @@ -87,6 +90,8 @@ def __init__(self) -> None: """Initialize the config flow.""" self._discovered_adv: SwitchBotAdvertisement | None = None self._discovered_advs: dict[str, SwitchBotAdvertisement] = {} + self._cloud_username: str | None = None + self._cloud_password: str | None = None async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak @@ -176,9 +181,17 @@ async def async_step_encrypted_auth( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the SwitchBot API auth step.""" - errors = {} + errors: dict[str, str] = {} assert self._discovered_adv is not None - description_placeholders = {} + description_placeholders: dict[str, str] = {} + + # If we have saved credentials from cloud login, try them first + if user_input is None and self._cloud_username and self._cloud_password: + user_input = { + CONF_USERNAME: self._cloud_username, + CONF_PASSWORD: self._cloud_password, + } + if user_input is not None: model: SwitchbotModel = self._discovered_adv.data["modelName"] cls = ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS[model] @@ -200,6 +213,9 @@ async def async_step_encrypted_auth( _LOGGER.debug("Authentication failed: %s", ex, exc_info=True) errors = {"base": "auth_failed"} description_placeholders = {"error_detail": str(ex)} + # Clear saved credentials if auth failed + self._cloud_username = None + self._cloud_password = None else: return await self.async_step_encrypted_key(key_details) @@ -239,7 +255,7 @@ async def async_step_encrypted_key( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the encryption key step.""" - errors = {} + errors: dict[str, str] = {} assert self._discovered_adv is not None if user_input is not None: model: SwitchbotModel = self._discovered_adv.data["modelName"] @@ -308,7 +324,73 @@ async def _async_set_device(self, discovery: SwitchBotAdvertisement) -> None: async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the user step to pick discovered device.""" + """Handle the user step to choose cloud login or direct discovery.""" + # Check if all scanners are in active mode + # If so, skip the menu and go directly to device selection + scanners = async_current_scanners(self.hass) + if scanners and all( + scanner.current_mode == BluetoothScanningMode.ACTIVE for scanner in scanners + ): + # All scanners are active, skip the menu + return await self.async_step_select_device() + + return self.async_show_menu( + step_id="user", + menu_options=["cloud_login", "select_device"], + ) + + async def async_step_cloud_login( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the cloud login step.""" + errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} + + if user_input is not None: + try: + await fetch_cloud_devices( + async_get_clientsession(self.hass), + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + except (SwitchbotApiError, SwitchbotAccountConnectionError) as ex: + _LOGGER.debug( + "Failed to connect to SwitchBot API: %s", ex, exc_info=True + ) + raise AbortFlow( + "api_error", description_placeholders={"error_detail": str(ex)} + ) from ex + except SwitchbotAuthenticationError as ex: + _LOGGER.debug("Authentication failed: %s", ex, exc_info=True) + errors = {"base": "auth_failed"} + description_placeholders = {"error_detail": str(ex)} + else: + # Save credentials temporarily for the duration of this flow + # to avoid re-prompting if encrypted device auth is needed + # These will be discarded when the flow completes + self._cloud_username = user_input[CONF_USERNAME] + self._cloud_password = user_input[CONF_PASSWORD] + return await self.async_step_select_device() + + user_input = user_input or {} + return self.async_show_form( + step_id="cloud_login", + errors=errors, + data_schema=vol.Schema( + { + vol.Required( + CONF_USERNAME, default=user_input.get(CONF_USERNAME) + ): str, + vol.Required(CONF_PASSWORD): str, + } + ), + description_placeholders=description_placeholders, + ) + + async def async_step_select_device( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the step to pick discovered device.""" errors: dict[str, str] = {} device_adv: SwitchBotAdvertisement | None = None if user_input is not None: @@ -333,7 +415,7 @@ async def async_step_user( return await self.async_step_confirm() return self.async_show_form( - step_id="user", + step_id="select_device", data_schema=vol.Schema( { vol.Required(CONF_ADDRESS): vol.In( diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 961204ee88d9e..b2e2d2dc4b19f 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -3,6 +3,24 @@ "flow_title": "{name} ({address})", "step": { "user": { + "description": "One or more of your Bluetooth adapters is using passive scanning, which may not discover all SwitchBot devices. Would you like to sign in to your SwitchBot account to download device information and automate discovery? If you're not sure, we recommend signing in.", + "menu_options": { + "cloud_login": "Sign in to SwitchBot account", + "select_device": "Continue without signing in" + } + }, + "cloud_login": { + "description": "Please provide your SwitchBot app username and password. This data won't be saved and is only used to retrieve device model information to automate discovery. Usernames and passwords are case-sensitive.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::switchbot::config::step::encrypted_auth::data_description::username%]", + "password": "[%key:component::switchbot::config::step::encrypted_auth::data_description::password%]" + } + }, + "select_device": { "data": { "address": "MAC address" }, diff --git a/tests/components/switchbot/test_config_flow.py b/tests/components/switchbot/test_config_flow.py index 1038bd318f580..7ad08d5a7a7e3 100644 --- a/tests/components/switchbot/test_config_flow.py +++ b/tests/components/switchbot/test_config_flow.py @@ -1,9 +1,12 @@ """Test the switchbot config flow.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import Mock, patch +import pytest from switchbot import SwitchbotAccountConnectionError, SwitchbotAuthenticationError +from homeassistant.components.bluetooth import BluetoothScanningMode from homeassistant.components.switchbot.const import ( CONF_ENCRYPTION_KEY, CONF_KEY_ID, @@ -41,6 +44,30 @@ DOMAIN = "switchbot" +@pytest.fixture +def mock_scanners_all_active() -> Generator[None]: + """Mock all scanners as active mode.""" + mock_scanner = Mock() + mock_scanner.current_mode = BluetoothScanningMode.ACTIVE + with patch( + "homeassistant.components.switchbot.config_flow.async_current_scanners", + return_value=[mock_scanner], + ): + yield + + +@pytest.fixture +def mock_scanners_all_passive() -> Generator[None]: + """Mock all scanners as passive mode.""" + mock_scanner = Mock() + mock_scanner.current_mode = BluetoothScanningMode.PASSIVE + with patch( + "homeassistant.components.switchbot.config_flow.async_current_scanners", + return_value=[mock_scanner], + ): + yield + + async def test_bluetooth_discovery(hass: HomeAssistant) -> None: """Test discovery via bluetooth with a valid device.""" result = await hass.config_entries.flow.async_init( @@ -248,15 +275,23 @@ async def test_async_step_bluetooth_not_connectable(hass: HomeAssistant) -> None assert result["reason"] == "not_supported" +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wohand(hass: HomeAssistant) -> None: """Test the user initiated form with password and valid mac.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOHAND_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" @@ -279,6 +314,7 @@ async def test_user_setup_wohand(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wohand_already_configured(hass: HomeAssistant) -> None: """Test the user initiated form with password and valid mac.""" entry = MockConfigEntry( @@ -292,29 +328,46 @@ async def test_user_setup_wohand_already_configured(hass: HomeAssistant) -> None unique_id="aabbccddeeff", ) entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOHAND_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_devices_found" +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wohand_replaces_ignored(hass: HomeAssistant) -> None: """Test setting up a switchbot replaces an ignored entry.""" entry = MockConfigEntry( domain=DOMAIN, data={}, unique_id="aabbccddeeff", source=SOURCE_IGNORE ) entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOHAND_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" @@ -336,15 +389,23 @@ async def test_user_setup_wohand_replaces_ignored(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wocurtain(hass: HomeAssistant) -> None: """Test the user initiated form with password and valid mac.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOCURTAIN_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" @@ -367,9 +428,16 @@ async def test_user_setup_wocurtain(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wocurtain_or_bot(hass: HomeAssistant) -> None: """Test the user initiated form with valid address.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[ @@ -379,11 +447,12 @@ async def test_user_setup_wocurtain_or_bot(hass: HomeAssistant) -> None: WOHAND_SERVICE_INFO_NOT_CONNECTABLE, ], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "select_device" assert result["errors"] == {} with patch_async_setup_entry() as mock_setup_entry: @@ -403,9 +472,16 @@ async def test_user_setup_wocurtain_or_bot(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wocurtain_or_bot_with_password(hass: HomeAssistant) -> None: """Test the user initiated form and valid address and a bot with a password.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[ @@ -414,11 +490,12 @@ async def test_user_setup_wocurtain_or_bot_with_password(hass: HomeAssistant) -> WOHAND_SERVICE_INFO_NOT_CONNECTABLE, ], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "select_device" assert result["errors"] == {} result2 = await hass.config_entries.flow.async_configure( @@ -447,15 +524,23 @@ async def test_user_setup_wocurtain_or_bot_with_password(hass: HomeAssistant) -> assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_single_bot_with_password(hass: HomeAssistant) -> None: """Test the user initiated form for a bot with a password.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOHAND_ENCRYPTED_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "password" @@ -479,15 +564,23 @@ async def test_user_setup_single_bot_with_password(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_woencrypted_key(hass: HomeAssistant) -> None: """Test the user initiated form for a lock.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOLOCK_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "encrypted_choose_method" @@ -545,15 +638,23 @@ async def test_user_setup_woencrypted_key(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_woencrypted_auth(hass: HomeAssistant) -> None: """Test the user initiated form for a lock.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOLOCK_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "encrypted_choose_method" @@ -618,17 +719,25 @@ async def test_user_setup_woencrypted_auth(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_woencrypted_auth_switchbot_api_down( hass: HomeAssistant, ) -> None: """Test the user initiated form for a lock when the switchbot api is down.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOLOCK_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "encrypted_choose_method" @@ -658,9 +767,16 @@ async def test_user_setup_woencrypted_auth_switchbot_api_down( assert result["description_placeholders"] == {"error_detail": "Switchbot API down"} +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wolock_or_bot(hass: HomeAssistant) -> None: """Test the user initiated form for a lock.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[ @@ -668,11 +784,12 @@ async def test_user_setup_wolock_or_bot(hass: HomeAssistant) -> None: WOHAND_SERVICE_ALT_ADDRESS_INFO, ], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "select_device" assert result["errors"] == {} result = await hass.config_entries.flow.async_configure( @@ -719,14 +836,22 @@ async def test_user_setup_wolock_or_bot(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_wosensor(hass: HomeAssistant) -> None: """Test the user initiated form with password and valid mac.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOSENSORTH_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" @@ -749,19 +874,236 @@ async def test_user_setup_wosensor(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") +async def test_user_cloud_login(hass: HomeAssistant) -> None: + """Test the cloud login flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "cloud_login"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_login" + + # Test successful cloud login + with ( + patch( + "homeassistant.components.switchbot.config_flow.fetch_cloud_devices", + return_value=None, + ), + patch( + "homeassistant.components.switchbot.config_flow.async_discovered_service_info", + return_value=[WOHAND_SERVICE_INFO], + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "testpass", + }, + ) + + # Should proceed to device selection with single device, so go to confirm + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + # Confirm device setup + with patch_async_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Bot EEFF" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_SENSOR_TYPE: "bot", + } + + +@pytest.mark.usefixtures("mock_scanners_all_passive") +async def test_user_cloud_login_auth_failed(hass: HomeAssistant) -> None: + """Test the cloud login flow with authentication failure.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "cloud_login"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_login" + + # Test authentication failure + with patch( + "homeassistant.components.switchbot.config_flow.fetch_cloud_devices", + side_effect=SwitchbotAuthenticationError("Invalid credentials"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "wrongpass", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_login" + assert result["errors"] == {"base": "auth_failed"} + assert "Invalid credentials" in result["description_placeholders"]["error_detail"] + + +@pytest.mark.usefixtures("mock_scanners_all_passive") +async def test_user_cloud_login_api_error(hass: HomeAssistant) -> None: + """Test the cloud login flow with API error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "cloud_login"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_login" + + # Test API connection error + with patch( + "homeassistant.components.switchbot.config_flow.fetch_cloud_devices", + side_effect=SwitchbotAccountConnectionError("API is down"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "testpass", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "api_error" + assert result["description_placeholders"] == {"error_detail": "API is down"} + + +@pytest.mark.usefixtures("mock_scanners_all_passive") +async def test_user_cloud_login_then_encrypted_device(hass: HomeAssistant) -> None: + """Test cloud login followed by encrypted device setup using saved credentials.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "cloud_login"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_login" + + with ( + patch( + "homeassistant.components.switchbot.config_flow.fetch_cloud_devices", + return_value=None, + ), + patch( + "homeassistant.components.switchbot.config_flow.async_discovered_service_info", + return_value=[WOLOCK_SERVICE_INFO], + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "testpass", + }, + ) + + # Should go to encrypted device choice menu + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "encrypted_choose_method" + + # Choose encrypted auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "encrypted_auth"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encrypted_auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + None, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encrypted_auth" + + with ( + patch_async_setup_entry() as mock_setup_entry, + patch( + "switchbot.SwitchbotLock.async_retrieve_encryption_key", + return_value={ + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + }, + ), + patch("switchbot.SwitchbotLock.verify_encryption_key", return_value=True), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "testpass", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Lock EEFF" + assert result["data"] == { + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + CONF_SENSOR_TYPE: "lock", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_no_devices(hass: HomeAssistant) -> None: """Test the user initiated form with password and valid mac.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_devices_found" +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_async_step_user_takes_precedence_over_discovery( hass: HomeAssistant, ) -> None: @@ -774,13 +1116,20 @@ async def test_async_step_user_takes_precedence_over_discovery( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WOCURTAIN_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.FORM @@ -928,15 +1277,23 @@ async def test_options_flow_lock_pro(hass: HomeAssistant) -> None: assert entry.options[CONF_LOCK_NIGHTLATCH] is True +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_worelay_switch_1pm_key(hass: HomeAssistant) -> None: """Test the user initiated form for a relay switch 1pm.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WORELAY_SWITCH_1PM_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "encrypted_choose_method" @@ -976,15 +1333,23 @@ async def test_user_setup_worelay_switch_1pm_key(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_worelay_switch_1pm_auth(hass: HomeAssistant) -> None: """Test the user initiated form for a relay switch 1pm.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WORELAY_SWITCH_1PM_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "encrypted_choose_method" @@ -1048,17 +1413,25 @@ async def test_user_setup_worelay_switch_1pm_auth(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_scanners_all_passive") async def test_user_setup_worelay_switch_1pm_auth_switchbot_api_down( hass: HomeAssistant, ) -> None: """Test the user initiated form for a relay switch 1pm when the switchbot api is down.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + with patch( "homeassistant.components.switchbot.config_flow.async_discovered_service_info", return_value=[WORELAY_SWITCH_1PM_SERVICE_INFO], ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "select_device"}, ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "encrypted_choose_method" @@ -1086,3 +1459,128 @@ async def test_user_setup_worelay_switch_1pm_auth_switchbot_api_down( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "api_error" assert result["description_placeholders"] == {"error_detail": "Switchbot API down"} + + +@pytest.mark.usefixtures("mock_scanners_all_active") +async def test_user_skip_menu_when_all_scanners_active(hass: HomeAssistant) -> None: + """Test that menu is skipped when all scanners are in active mode.""" + with ( + patch( + "homeassistant.components.switchbot.config_flow.async_discovered_service_info", + return_value=[WOHAND_SERVICE_INFO], + ), + patch_async_setup_entry() as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + # Should skip menu and go directly to select_device -> confirm + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Bot EEFF" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_SENSOR_TYPE: "bot", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_show_menu_when_passive_scanner_present(hass: HomeAssistant) -> None: + """Test that menu is shown when any scanner is in passive mode.""" + mock_scanner_active = Mock() + mock_scanner_active.current_mode = BluetoothScanningMode.ACTIVE + mock_scanner_passive = Mock() + mock_scanner_passive.current_mode = BluetoothScanningMode.PASSIVE + + with ( + patch( + "homeassistant.components.switchbot.config_flow.async_current_scanners", + return_value=[mock_scanner_active, mock_scanner_passive], + ), + patch( + "homeassistant.components.switchbot.config_flow.async_discovered_service_info", + return_value=[WOHAND_SERVICE_INFO], + ), + patch_async_setup_entry() as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + # Should show menu since not all scanners are active + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + assert set(result["menu_options"]) == {"cloud_login", "select_device"} + + # Choose select_device from menu + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "select_device"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + # Confirm the device + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Bot EEFF" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_SENSOR_TYPE: "bot", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_show_menu_when_no_scanners(hass: HomeAssistant) -> None: + """Test that menu is shown when no scanners are available.""" + with ( + patch( + "homeassistant.components.switchbot.config_flow.async_current_scanners", + return_value=[], + ), + patch( + "homeassistant.components.switchbot.config_flow.async_discovered_service_info", + return_value=[WOHAND_SERVICE_INFO], + ), + patch_async_setup_entry() as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + # Should show menu when no scanners are available + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + assert set(result["menu_options"]) == {"cloud_login", "select_device"} + + # Choose select_device from menu + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "select_device"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + # Confirm the device + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Bot EEFF" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_SENSOR_TYPE: "bot", + } + assert len(mock_setup_entry.mock_calls) == 1 From 8412581be4e61dcfb145865bae3395ece2fae76a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:10:49 +0200 Subject: [PATCH 14/17] Implement snapshot-testing for Plugwise climate platform (#151070) --- .../plugwise/snapshots/test_climate.ambr | 826 ++++++++++++++++++ tests/components/plugwise/test_climate.py | 346 +++----- 2 files changed, 966 insertions(+), 206 deletions(-) create mode 100644 tests/components/plugwise/snapshots/test_climate.ambr diff --git a/tests/components/plugwise/snapshots/test_climate.ambr b/tests/components/plugwise/snapshots/test_climate.ambr new file mode 100644 index 0000000000000..0edb29fabda38 --- /dev/null +++ b/tests/components/plugwise/snapshots/test_climate.ambr @@ -0,0 +1,826 @@ +# serializer version: 1 +# name: test_adam_2_climate_snapshot[platforms0-False-m_adam_heating][climate.bathroom-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_modes': list([ + 'no_frost', + 'asleep', + 'vacation', + 'home', + 'away', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bathroom', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': 'f871b8c4d63549319221e294e4f88074-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_2_climate_snapshot[platforms0-False-m_adam_heating][climate.bathroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 17.9, + 'friendly_name': 'Bathroom', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_mode': 'home', + 'preset_modes': list([ + 'no_frost', + 'asleep', + 'vacation', + 'home', + 'away', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 15.0, + }), + 'context': , + 'entity_id': 'climate.bathroom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_adam_2_climate_snapshot[platforms0-False-m_adam_heating][climate.living_room-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 1.0, + 'preset_modes': list([ + 'no_frost', + 'asleep', + 'vacation', + 'home', + 'away', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.living_room', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': 'f2bf9048bef64cc5b6d5110154e33c81-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_2_climate_snapshot[platforms0-False-m_adam_heating][climate.living_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.1, + 'friendly_name': 'Living room', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 1.0, + 'preset_mode': 'home', + 'preset_modes': list([ + 'no_frost', + 'asleep', + 'vacation', + 'home', + 'away', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.living_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.badkamer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.badkamer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '08963fec7c53423ca5680aa4cb502c63-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.badkamer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 18.9, + 'friendly_name': 'Badkamer', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_mode': 'away', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 14.0, + }), + 'context': , + 'entity_id': 'climate.badkamer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.bios-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bios', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '12493538af164a409c6a1c79e38afe1c-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.bios-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 16.5, + 'friendly_name': 'Bios', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_mode': 'away', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 13.0, + }), + 'context': , + 'entity_id': 'climate.bios', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.garage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.garage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '446ac08dd04d4eff8ac57489757b7314-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.garage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 15.6, + 'friendly_name': 'Garage', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_mode': 'no_frost', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 5.5, + }), + 'context': , + 'entity_id': 'climate.garage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.jessie-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.jessie', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '82fa13f017d240daa0d0ea1775420f24-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.jessie-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 17.2, + 'friendly_name': 'Jessie', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_mode': 'asleep', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 15.0, + }), + 'context': , + 'entity_id': 'climate.jessie', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.woonkamer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.woonkamer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': 'c50f167537524366a5af7aa3942feb1e-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_climate_snapshot[platforms0][climate.woonkamer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.9, + 'friendly_name': 'Woonkamer', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 0.0, + 'preset_mode': 'home', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 21.5, + }), + 'context': , + 'entity_id': 'climate.woonkamer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_anna_2_climate_snapshot[platforms0-True-m_anna_heatpump_cooling][climate.anna-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 4.0, + 'preset_modes': list([ + 'no_frost', + 'home', + 'away', + 'asleep', + 'vacation', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.anna', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_2_climate_snapshot[platforms0-True-m_anna_heatpump_cooling][climate.anna-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 26.3, + 'friendly_name': 'Anna', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 4.0, + 'preset_mode': 'home', + 'preset_modes': list([ + 'no_frost', + 'home', + 'away', + 'asleep', + 'vacation', + ]), + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 20.5, + 'target_temp_step': 0.1, + }), + 'context': , + 'entity_id': 'climate.anna', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_anna_3_climate_snapshot[platforms0-True-m_anna_heatpump_idle][climate.anna-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 4.0, + 'preset_modes': list([ + 'no_frost', + 'home', + 'away', + 'asleep', + 'vacation', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.anna', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_3_climate_snapshot[platforms0-True-m_anna_heatpump_idle][climate.anna-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 23.0, + 'friendly_name': 'Anna', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 4.0, + 'preset_mode': 'home', + 'preset_modes': list([ + 'no_frost', + 'home', + 'away', + 'asleep', + 'vacation', + ]), + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 20.5, + 'target_temp_step': 0.1, + }), + 'context': , + 'entity_id': 'climate.anna', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_anna_climate_snapshot[platforms0-True-anna_heatpump_heating][climate.anna-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 4.0, + 'preset_modes': list([ + 'no_frost', + 'home', + 'away', + 'asleep', + 'vacation', + ]), + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.anna', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'plugwise', + 'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_climate_snapshot[platforms0-True-anna_heatpump_heating][climate.anna-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.3, + 'friendly_name': 'Anna', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 4.0, + 'preset_mode': 'home', + 'preset_modes': list([ + 'no_frost', + 'home', + 'away', + 'asleep', + 'vacation', + ]), + 'supported_features': , + 'target_temp_high': 30.0, + 'target_temp_low': 20.5, + 'target_temp_step': 0.1, + }), + 'context': , + 'entity_id': 'climate.anna', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index b8554f9a5cc49..084eaa63d286c 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -6,180 +6,46 @@ from freezegun.api import FrozenDateTimeFactory from plugwise.exceptions import PlugwiseError import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( - ATTR_CURRENT_TEMPERATURE, ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, - ATTR_MAX_TEMP, - ATTR_MIN_TEMP, ATTR_PRESET_MODE, - ATTR_PRESET_MODES, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - ATTR_TARGET_TEMP_STEP, DOMAIN as CLIMATE_DOMAIN, PRESET_AWAY, - PRESET_HOME, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, HVACAction, HVACMode, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - ATTR_TEMPERATURE, -) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform HA_PLUGWISE_SMILE_ASYNC_UPDATE = ( "homeassistant.components.plugwise.coordinator.Smile.async_update" ) -async def test_adam_climate_entity_attributes( - hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry -) -> None: - """Test creation of adam climate device environment.""" - state = hass.states.get("climate.woonkamer") - assert state - assert state.state == HVACMode.AUTO - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.AUTO, HVACMode.HEAT] - assert ATTR_PRESET_MODES in state.attributes - assert "no_frost" in state.attributes[ATTR_PRESET_MODES] - assert PRESET_HOME in state.attributes[ATTR_PRESET_MODES] - assert state.attributes[ATTR_PRESET_MODE] == PRESET_HOME - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.9 - assert state.attributes[ATTR_SUPPORTED_FEATURES] == 17 - assert state.attributes[ATTR_TEMPERATURE] == 21.5 - assert state.attributes[ATTR_MIN_TEMP] == 0.0 - assert state.attributes[ATTR_MAX_TEMP] == 35.0 - assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 - - state = hass.states.get("climate.jessie") - assert state - assert state.state == HVACMode.AUTO - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.AUTO, HVACMode.HEAT] - assert ATTR_PRESET_MODES in state.attributes - assert "no_frost" in state.attributes[ATTR_PRESET_MODES] - assert PRESET_HOME in state.attributes[ATTR_PRESET_MODES] - assert state.attributes[ATTR_PRESET_MODE] == "asleep" - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 17.2 - assert state.attributes[ATTR_TEMPERATURE] == 15.0 - assert state.attributes[ATTR_MIN_TEMP] == 0.0 - assert state.attributes[ATTR_MAX_TEMP] == 35.0 - assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 - - -@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) -@pytest.mark.parametrize("cooling_present", [False], indirect=True) -async def test_adam_2_climate_entity_attributes( - hass: HomeAssistant, - mock_smile_adam_heat_cool: MagicMock, - init_integration: MockConfigEntry, -) -> None: - """Test creation of adam climate device environment.""" - state = hass.states.get("climate.living_room") - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.PREHEATING - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.OFF, - HVACMode.AUTO, - HVACMode.HEAT, - ] - - state = hass.states.get("climate.bathroom") - assert state - assert state.state == HVACMode.AUTO - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.OFF, - HVACMode.AUTO, - HVACMode.HEAT, - ] - - -@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) -@pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_adam_3_climate_entity_attributes( +@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_climate_snapshot( hass: HomeAssistant, - mock_smile_adam_heat_cool: MagicMock, - init_integration: MockConfigEntry, - freezer: FrozenDateTimeFactory, -) -> None: - """Test creation of adam climate device environment.""" - state = hass.states.get("climate.living_room") - assert state - assert state.state == HVACMode.COOL - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.OFF, - HVACMode.AUTO, - HVACMode.COOL, - ] - data = mock_smile_adam_heat_cool.async_update.return_value - data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "heating" - data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.HEATING - data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = False - data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = True - with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): - freezer.tick(timedelta(minutes=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - state = hass.states.get("climate.living_room") - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.OFF, - HVACMode.AUTO, - HVACMode.HEAT, - ] - - data = mock_smile_adam_heat_cool.async_update.return_value - data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "cooling" - data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.COOLING - data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = True - data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = False - with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): - freezer.tick(timedelta(minutes=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - state = hass.states.get("climate.living_room") - assert state - assert state.state == HVACMode.COOL - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.OFF, - HVACMode.AUTO, - HVACMode.COOL, - ] - - -async def test_adam_climate_adjust_negative_testing( - hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry + mock_smile_adam: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test PlugwiseError exception.""" - mock_smile_adam.set_temperature.side_effect = PlugwiseError - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.woonkamer", ATTR_TEMPERATURE: 25}, - blocking=True, - ) + """Test Adam climate snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) async def test_adam_climate_entity_climate_changes( @@ -257,6 +123,95 @@ async def test_adam_climate_entity_climate_changes( ) +async def test_adam_climate_adjust_negative_testing( + hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test PlugwiseError exception.""" + mock_smile_adam.set_temperature.side_effect = PlugwiseError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.woonkamer", ATTR_TEMPERATURE: 25}, + blocking=True, + ) + + +@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [False], indirect=True) +@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_2_climate_snapshot( + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Adam 2 climate snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) +async def test_adam_3_climate_entity_attributes( + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + init_integration: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test creation of adam climate device environment.""" + state = hass.states.get("climate.living_room") + assert state + assert state.state == HVACMode.COOL + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + ] + data = mock_smile_adam_heat_cool.async_update.return_value + data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "heating" + data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.HEATING + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = False + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = True + with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("climate.living_room") + assert state + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.HEAT, + ] + + data = mock_smile_adam_heat_cool.async_update.return_value + data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "cooling" + data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.COOLING + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = True + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = False + with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("climate.living_room") + assert state + assert state.state == HVACMode.COOL + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + ] + + async def test_adam_climate_off_mode_change( hass: HomeAssistant, mock_smile_adam_jip: MagicMock, @@ -313,68 +268,17 @@ async def test_adam_climate_off_mode_change( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_anna_climate_entity_attributes( - hass: HomeAssistant, - mock_smile_anna: MagicMock, - init_integration: MockConfigEntry, -) -> None: - """Test creation of anna climate device environment.""" - state = hass.states.get("climate.anna") - assert state - assert state.state == HVACMode.AUTO - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.AUTO, HVACMode.HEAT_COOL] - - assert "no_frost" in state.attributes[ATTR_PRESET_MODES] - assert PRESET_HOME in state.attributes[ATTR_PRESET_MODES] - - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 19.3 - assert state.attributes[ATTR_PRESET_MODE] == PRESET_HOME - assert state.attributes[ATTR_SUPPORTED_FEATURES] == 18 - assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 30 - assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20.5 - assert state.attributes[ATTR_MIN_TEMP] == 4 - assert state.attributes[ATTR_MAX_TEMP] == 30 - assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 - - -@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_cooling"], indirect=True) -@pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_anna_2_climate_entity_attributes( - hass: HomeAssistant, - mock_smile_anna: MagicMock, - init_integration: MockConfigEntry, -) -> None: - """Test creation of anna climate device environment.""" - state = hass.states.get("climate.anna") - assert state - assert state.state == HVACMode.AUTO - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.AUTO, - HVACMode.HEAT_COOL, - ] - assert state.attributes[ATTR_SUPPORTED_FEATURES] == 18 - assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 30 - assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20.5 - - -@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_idle"], indirect=True) -@pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_anna_3_climate_entity_attributes( +@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_anna_climate_snapshot( hass: HomeAssistant, mock_smile_anna: MagicMock, - init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test creation of anna climate device environment.""" - state = hass.states.get("climate.anna") - assert state - assert state.state == HVACMode.AUTO - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.AUTO, - HVACMode.HEAT_COOL, - ] + """Test Anna climate snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @@ -446,3 +350,33 @@ async def test_anna_climate_entity_climate_changes( state = hass.states.get("climate.anna") assert state.state == HVACMode.HEAT_COOL assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT_COOL] + + +@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_cooling"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) +@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_anna_2_climate_snapshot( + hass: HomeAssistant, + mock_smile_anna: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Anna 2 climate snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_idle"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) +@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_anna_3_climate_snapshot( + hass: HomeAssistant, + mock_smile_anna: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Anna 3 climate snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) From 8263ea4a4a14e281c32000fbb0e6801fb857b68e Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:14:01 +0200 Subject: [PATCH 15/17] Don't try to connect after exiting loop in ntfy (#152011) --- homeassistant/components/ntfy/event.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/ntfy/event.py b/homeassistant/components/ntfy/event.py index 8075e051ba4e8..ecb081f0beb1a 100644 --- a/homeassistant/components/ntfy/event.py +++ b/homeassistant/components/ntfy/event.py @@ -146,20 +146,20 @@ async def ws_connect(self) -> None: ) self._attr_available = False finally: - if self._ws is None or self._ws.done(): - self._ws = self.config_entry.async_create_background_task( - self.hass, - target=self.ntfy.subscribe( - topics=[self.topic], - callback=self._async_handle_event, - title=self.subentry.data.get(CONF_TITLE), - message=self.subentry.data.get(CONF_MESSAGE), - priority=self.subentry.data.get(CONF_PRIORITY), - tags=self.subentry.data.get(CONF_TAGS), - ), - name="ntfy_websocket", - ) self.async_write_ha_state() + if self._ws is None or self._ws.done(): + self._ws = self.config_entry.async_create_background_task( + self.hass, + target=self.ntfy.subscribe( + topics=[self.topic], + callback=self._async_handle_event, + title=self.subentry.data.get(CONF_TITLE), + message=self.subentry.data.get(CONF_MESSAGE), + priority=self.subentry.data.get(CONF_PRIORITY), + tags=self.subentry.data.get(CONF_TAGS), + ), + name="ntfy_websocket", + ) await asyncio.sleep(RECONNECT_INTERVAL) @property From baf43827249406b797f7fe3456fa74da8dfdadf7 Mon Sep 17 00:00:00 2001 From: Andrea Turri Date: Fri, 12 Sep 2025 11:17:10 +0200 Subject: [PATCH 16/17] Miele consumption sensors consistent behavior with RestoreSensor (#151098) --- homeassistant/components/miele/sensor.py | 59 +++++++++++++++++ .../miele/snapshots/test_sensor.ambr | 16 ++--- tests/components/miele/test_sensor.py | 64 +++++++++++++++++++ 3 files changed, 131 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index 8e4b903d0b3fb..0c157e42656bd 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -270,6 +270,7 @@ class MieleSensorDefinition: device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, entity_category=EntityCategory.DIAGNOSTIC, ), ), @@ -307,6 +308,7 @@ class MieleSensorDefinition: device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfVolume.LITERS, + suggested_display_precision=0, entity_category=EntityCategory.DIAGNOSTIC, ), ), @@ -618,6 +620,8 @@ def _get_entity_class(definition: MieleSensorDefinition) -> type[MieleSensor]: "state_elapsed_time": MieleTimeSensor, "state_remaining_time": MieleTimeSensor, "state_start_time": MieleTimeSensor, + "current_energy_consumption": MieleConsumptionSensor, + "current_water_consumption": MieleConsumptionSensor, }.get(definition.description.key, MieleSensor) def _is_entity_registered(unique_id: str) -> bool: @@ -924,3 +928,58 @@ def _update_last_value(self) -> None: # otherwise, cache value and return it else: self._last_value = current_value + + +class MieleConsumptionSensor(MieleRestorableSensor): + """Representation of consumption sensors keeping state from cache.""" + + _is_reporting: bool = False + + def _update_last_value(self) -> None: + """Update the last value of the sensor.""" + current_value = self.entity_description.value_fn(self.device) + current_status = StateStatus(self.device.state_status) + last_value = ( + float(cast(str, self._last_value)) + if self._last_value is not None and self._last_value != STATE_UNKNOWN + else 0 + ) + + # force unknown when appliance is not able to report consumption + if current_status in ( + StateStatus.ON, + StateStatus.OFF, + StateStatus.PROGRAMMED, + StateStatus.WAITING_TO_START, + StateStatus.IDLE, + StateStatus.SERVICE, + ): + self._is_reporting = False + self._last_value = None + + # appliance might report the last value for consumption of previous cycle and it will report 0 + # only after a while, so it is necessary to force 0 until we see the 0 value coming from API, unless + # we already saw a valid value in this cycle from cache + elif ( + current_status in (StateStatus.IN_USE, StateStatus.PAUSE) + and not self._is_reporting + and last_value > 0 + ): + self._last_value = current_value + self._is_reporting = True + + elif ( + current_status in (StateStatus.IN_USE, StateStatus.PAUSE) + and not self._is_reporting + and current_value is not None + and cast(int, current_value) > 0 + ): + self._last_value = 0 + + # keep value when program ends + elif current_status == StateStatus.PROGRAM_ENDED: + pass + + else: + self._last_value = current_value + self._is_reporting = True diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index 9bb68f1d5ae9b..641ab1759526d 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -3904,7 +3904,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 1, }), }), 'original_device_class': , @@ -3932,7 +3932,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensor_states[platforms0][sensor.washing_machine_energy_forecast-entry] @@ -4501,7 +4501,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -4529,7 +4529,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensor_states[platforms0][sensor.washing_machine_water_forecast-entry] @@ -6050,7 +6050,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 1, }), }), 'original_device_class': , @@ -6078,7 +6078,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensor_states_api_push[platforms0][sensor.washing_machine_energy_forecast-entry] @@ -6647,7 +6647,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -6675,7 +6675,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensor_states_api_push[platforms0][sensor.washing_machine_water_forecast-entry] diff --git a/tests/components/miele/test_sensor.py b/tests/components/miele/test_sensor.py index 69aacd95e62b5..d8c054683fa87 100644 --- a/tests/components/miele/test_sensor.py +++ b/tests/components/miele/test_sensor.py @@ -315,6 +315,13 @@ async def test_laundry_wash_scenario( check_sensor_state(hass, "sensor.washing_machine_remaining_time", "unknown", step) # OFF -> elapsed forced to unknown (some devices continue reporting last value of last cycle) check_sensor_state(hass, "sensor.washing_machine_elapsed_time", "unknown", step) + # consumption sensors have to report "unknown" when the device is not working + check_sensor_state( + hass, "sensor.washing_machine_energy_consumption", "unknown", step + ) + check_sensor_state( + hass, "sensor.washing_machine_water_consumption", "unknown", step + ) # Simulate program started device_fixture["DummyWasher"]["state"]["status"]["value_raw"] = 5 @@ -337,10 +344,41 @@ async def test_laundry_wash_scenario( device_fixture["DummyWasher"]["state"]["elapsedTime"][1] = 12 device_fixture["DummyWasher"]["state"]["spinningSpeed"]["value_raw"] = 1200 device_fixture["DummyWasher"]["state"]["spinningSpeed"]["value_localized"] = "1200" + device_fixture["DummyWasher"]["state"]["ecoFeedback"] = { + "currentEnergyConsumption": { + "value": 0.9, + "unit": "kWh", + }, + "currentWaterConsumption": { + "value": 52, + "unit": "l", + }, + } freezer.tick(timedelta(seconds=130)) async_fire_time_changed(hass) await hass.async_block_till_done() + + # at this point, appliance is working, but it started reporting a value from last cycle, so it is forced to 0 + check_sensor_state(hass, "sensor.washing_machine_energy_consumption", "0", step) + check_sensor_state(hass, "sensor.washing_machine_water_consumption", "0", step) + + # intermediate step, only to report new consumption values + device_fixture["DummyWasher"]["state"]["ecoFeedback"] = { + "currentEnergyConsumption": { + "value": 0.0, + "unit": "kWh", + }, + "currentWaterConsumption": { + "value": 0, + "unit": "l", + }, + } + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + step += 1 check_sensor_state(hass, "sensor.washing_machine", "in_use", step) @@ -351,6 +389,28 @@ async def test_laundry_wash_scenario( # IN_USE -> elapsed, remaining time from API (normal case) check_sensor_state(hass, "sensor.washing_machine_remaining_time", "105", step) check_sensor_state(hass, "sensor.washing_machine_elapsed_time", "12", step) + check_sensor_state(hass, "sensor.washing_machine_energy_consumption", "0.0", step) + check_sensor_state(hass, "sensor.washing_machine_water_consumption", "0", step) + + # intermediate step, only to report new consumption values + device_fixture["DummyWasher"]["state"]["ecoFeedback"] = { + "currentEnergyConsumption": { + "value": 0.1, + "unit": "kWh", + }, + "currentWaterConsumption": { + "value": 7, + "unit": "l", + }, + } + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # at this point, it starts reporting value from API + check_sensor_state(hass, "sensor.washing_machine_energy_consumption", "0.1", step) + check_sensor_state(hass, "sensor.washing_machine_water_consumption", "7", step) # Simulate rinse hold phase device_fixture["DummyWasher"]["state"]["status"]["value_raw"] = 11 @@ -389,6 +449,7 @@ async def test_laundry_wash_scenario( device_fixture["DummyWasher"]["state"]["remainingTime"][1] = 0 device_fixture["DummyWasher"]["state"]["elapsedTime"][0] = 0 device_fixture["DummyWasher"]["state"]["elapsedTime"][1] = 0 + device_fixture["DummyWasher"]["state"]["ecoFeedback"] = None freezer.tick(timedelta(seconds=130)) async_fire_time_changed(hass) @@ -406,6 +467,9 @@ async def test_laundry_wash_scenario( check_sensor_state(hass, "sensor.washing_machine_remaining_time", "0", step) # PROGRAM_ENDED -> elapsed time kept from last program (some devices immediately go to 0) check_sensor_state(hass, "sensor.washing_machine_elapsed_time", "109", step) + # consumption values now are reporting last known value, API might start reporting null object + check_sensor_state(hass, "sensor.washing_machine_energy_consumption", "0.1", step) + check_sensor_state(hass, "sensor.washing_machine_water_consumption", "7", step) # Simulate when door is opened after program ended device_fixture["DummyWasher"]["state"]["status"]["value_raw"] = 3 From 4c22264b13bf4f7428ab9e911d58725dee512c78 Mon Sep 17 00:00:00 2001 From: ekobres Date: Fri, 12 Sep 2025 05:24:45 -0400 Subject: [PATCH 17/17] =?UTF-8?q?Add=20support=20for=20`inH=E2=82=82O`=20p?= =?UTF-8?q?ressure=20unit=20(#148289)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Erik Montnemery --- homeassistant/components/number/const.py | 1 + homeassistant/components/sensor/const.py | 1 + homeassistant/const.py | 1 + homeassistant/util/unit_conversion.py | 3 +++ homeassistant/util/unit_system.py | 2 ++ tests/components/sensor/test_recorder.py | 8 ++++---- tests/util/test_unit_conversion.py | 11 +++++++++++ 7 files changed, 23 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index a9333212fa456..9a227102ad755 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -328,6 +328,7 @@ class NumberDeviceClass(StrEnum): - `Pa`, `hPa`, `kPa` - `inHg` - `psi` + - `inH₂O` """ REACTIVE_ENERGY = "reactive_energy" diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 12d9595d05985..7d21b68019d03 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -361,6 +361,7 @@ class SensorDeviceClass(StrEnum): - `Pa`, `hPa`, `kPa` - `inHg` - `psi` + - `inH₂O` """ REACTIVE_ENERGY = "reactive_energy" diff --git a/homeassistant/const.py b/homeassistant/const.py index d61945e2ef82c..913ef5e177f89 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -749,6 +749,7 @@ class UnitOfPressure(StrEnum): MBAR = "mbar" MMHG = "mmHg" INHG = "inHg" + INH2O = "inH₂O" PSI = "psi" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 1bd40a12d3d96..5502163472def 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -82,6 +82,7 @@ # Pressure conversion constants _STANDARD_GRAVITY = 9.80665 _MERCURY_DENSITY = 13.5951 +_INH2O_TO_PA = 249.0889083333348 # 1 inH₂O = 249.0889083333348 Pa at 4°C # Volume conversion constants _L_TO_CUBIC_METER = 0.001 # 1 L = 0.001 m³ @@ -435,6 +436,7 @@ class PressureConverter(BaseUnitConverter): UnitOfPressure.MBAR: 1 / 100, UnitOfPressure.INHG: 1 / (_IN_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY), + UnitOfPressure.INH2O: 1 / _INH2O_TO_PA, UnitOfPressure.PSI: 1 / 6894.757, UnitOfPressure.MMHG: 1 / (_MM_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY), @@ -447,6 +449,7 @@ class PressureConverter(BaseUnitConverter): UnitOfPressure.CBAR, UnitOfPressure.MBAR, UnitOfPressure.INHG, + UnitOfPressure.INH2O, UnitOfPressure.PSI, UnitOfPressure.MMHG, } diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index 934cd6d4b693c..d86beb8b7e7cc 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -296,6 +296,7 @@ def _deprecated_unit_system(value: str) -> str: # Convert non-metric pressure ("pressure", UnitOfPressure.PSI): UnitOfPressure.KPA, ("pressure", UnitOfPressure.INHG): UnitOfPressure.HPA, + ("pressure", UnitOfPressure.INH2O): UnitOfPressure.KPA, # Convert non-metric speeds except knots to km/h ("speed", UnitOfSpeed.FEET_PER_SECOND): UnitOfSpeed.KILOMETERS_PER_HOUR, ("speed", UnitOfSpeed.INCHES_PER_SECOND): UnitOfSpeed.MILLIMETERS_PER_SECOND, @@ -379,6 +380,7 @@ def _deprecated_unit_system(value: str) -> str: ("pressure", UnitOfPressure.HPA): UnitOfPressure.PSI, ("pressure", UnitOfPressure.KPA): UnitOfPressure.PSI, ("pressure", UnitOfPressure.MMHG): UnitOfPressure.INHG, + ("pressure", UnitOfPressure.INH2O): UnitOfPressure.PSI, # Convert non-USCS speeds, except knots, to mph ("speed", UnitOfSpeed.METERS_PER_SECOND): UnitOfSpeed.MILES_PER_HOUR, ("speed", UnitOfSpeed.MILLIMETERS_PER_SECOND): UnitOfSpeed.INCHES_PER_SECOND, diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 645c4754a7bd8..df38a246a7a95 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -4957,14 +4957,14 @@ def set_state(entity_id, state, **kwargs): PRESSURE_SENSOR_ATTRIBUTES, "psi", "bar", - "Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi", + "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", ), ( METRIC_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "Pa", "bar", - "Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi", + "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", ), ], ) @@ -5175,14 +5175,14 @@ async def test_validate_statistics_unit_ignore_device_class( PRESSURE_SENSOR_ATTRIBUTES, "psi", "bar", - "Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi", + "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", ), ( METRIC_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "Pa", "bar", - "Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi", + "Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi", ), ( METRIC_SYSTEM, diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 3fe0078aabfa3..c25a40f5fc0e9 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -672,12 +672,21 @@ (1000, UnitOfPressure.HPA, 100, UnitOfPressure.KPA), (1000, UnitOfPressure.HPA, 1000, UnitOfPressure.MBAR), (1000, UnitOfPressure.HPA, 100, UnitOfPressure.CBAR), + (1000, UnitOfPressure.HPA, 401.46307866177, UnitOfPressure.INH2O), (100, UnitOfPressure.KPA, 14.5037743897, UnitOfPressure.PSI), (100, UnitOfPressure.KPA, 29.5299801647, UnitOfPressure.INHG), (100, UnitOfPressure.KPA, 100000, UnitOfPressure.PA), (100, UnitOfPressure.KPA, 1000, UnitOfPressure.HPA), (100, UnitOfPressure.KPA, 1000, UnitOfPressure.MBAR), (100, UnitOfPressure.KPA, 100, UnitOfPressure.CBAR), + (100, UnitOfPressure.INH2O, 3.6127291827353996, UnitOfPressure.PSI), + (100, UnitOfPressure.INH2O, 186.83201548767, UnitOfPressure.MMHG), + (100, UnitOfPressure.INH2O, 7.3555912463681, UnitOfPressure.INHG), + (100, UnitOfPressure.INH2O, 24908.890833333, UnitOfPressure.PA), + (100, UnitOfPressure.INH2O, 249.08890833333, UnitOfPressure.HPA), + (100, UnitOfPressure.INH2O, 249.08890833333, UnitOfPressure.MBAR), + (100, UnitOfPressure.INH2O, 24.908890833333, UnitOfPressure.KPA), + (100, UnitOfPressure.INH2O, 24.908890833333, UnitOfPressure.CBAR), (30, UnitOfPressure.INHG, 14.7346266155, UnitOfPressure.PSI), (30, UnitOfPressure.INHG, 101.59167, UnitOfPressure.KPA), (30, UnitOfPressure.INHG, 1015.9167, UnitOfPressure.HPA), @@ -685,6 +694,7 @@ (30, UnitOfPressure.INHG, 1015.9167, UnitOfPressure.MBAR), (30, UnitOfPressure.INHG, 101.59167, UnitOfPressure.CBAR), (30, UnitOfPressure.INHG, 762, UnitOfPressure.MMHG), + (30, UnitOfPressure.INHG, 407.85300589959, UnitOfPressure.INH2O), (30, UnitOfPressure.MMHG, 0.580103, UnitOfPressure.PSI), (30, UnitOfPressure.MMHG, 3.99967, UnitOfPressure.KPA), (30, UnitOfPressure.MMHG, 39.9967, UnitOfPressure.HPA), @@ -692,6 +702,7 @@ (30, UnitOfPressure.MMHG, 39.9967, UnitOfPressure.MBAR), (30, UnitOfPressure.MMHG, 3.99967, UnitOfPressure.CBAR), (30, UnitOfPressure.MMHG, 1.181102, UnitOfPressure.INHG), + (30, UnitOfPressure.MMHG, 16.0572051431838, UnitOfPressure.INH2O), (5, UnitOfPressure.BAR, 72.51887, UnitOfPressure.PSI), ], ReactiveEnergyConverter: [