From f808c2ff14d953adbe38f76c2dbe92e8cc41e7ae Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein <cgtobi@users.noreply.github.com> Date: Sun, 14 Jan 2024 11:47:20 +0100 Subject: [PATCH] Add Netatmo fan platform (#107989) * Add fan platform to support NLLF centralized ventilation devices * Update tests/components/netatmo/test_fan.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update tests/components/netatmo/test_fan.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update tests/components/netatmo/test_fan.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update tests/components/netatmo/test_fan.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * add snapshots * update snapshot * fix docstring * address comment --------- Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> --- homeassistant/components/netatmo/const.py | 1 + .../components/netatmo/data_handler.py | 2 + homeassistant/components/netatmo/fan.py | 87 +++++++++++++++++++ .../components/netatmo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../netatmo/fixtures/homesdata.json | 24 ++++- .../homestatus_91763b24c43d3e344f424e8b.json | 22 +++++ .../netatmo/snapshots/test_cover.ambr | 47 ++++++++++ .../netatmo/snapshots/test_diagnostics.ambr | 19 ++++ .../netatmo/snapshots/test_fan.ambr | 56 ++++++++++++ .../netatmo/snapshots/test_init.ambr | 28 ++++++ tests/components/netatmo/test_fan.py | 70 +++++++++++++++ 13 files changed, 356 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/netatmo/fan.py create mode 100644 tests/components/netatmo/snapshots/test_fan.ambr create mode 100644 tests/components/netatmo/test_fan.py diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 3fe456dd657..416c5668eae 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -42,6 +42,7 @@ NETATMO_CREATE_CAMERA = "netatmo_create_camera" NETATMO_CREATE_CAMERA_LIGHT = "netatmo_create_camera_light" NETATMO_CREATE_CLIMATE = "netatmo_create_climate" NETATMO_CREATE_COVER = "netatmo_create_cover" +NETATMO_CREATE_FAN = "netatmo_create_fan" NETATMO_CREATE_LIGHT = "netatmo_create_light" NETATMO_CREATE_ROOM_SENSOR = "netatmo_create_room_sensor" NETATMO_CREATE_SELECT = "netatmo_create_select" diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index d132fc16c7d..bfc77a09548 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -37,6 +37,7 @@ from .const import ( NETATMO_CREATE_CAMERA_LIGHT, NETATMO_CREATE_CLIMATE, NETATMO_CREATE_COVER, + NETATMO_CREATE_FAN, NETATMO_CREATE_LIGHT, NETATMO_CREATE_ROOM_SENSOR, NETATMO_CREATE_SELECT, @@ -356,6 +357,7 @@ class NetatmoDataHandler: NETATMO_CREATE_SENSOR, ], NetatmoDeviceCategory.meter: [NETATMO_CREATE_SENSOR], + NetatmoDeviceCategory.fan: [NETATMO_CREATE_FAN], } for module in home.modules.values(): if not module.device_category: diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py new file mode 100644 index 00000000000..8f22861a249 --- /dev/null +++ b/homeassistant/components/netatmo/fan.py @@ -0,0 +1,87 @@ +"""Support for Netatmo/Bubendorff fans.""" +from __future__ import annotations + +import logging +from typing import Final, cast + +from pyatmo import modules as NaModules + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CONF_URL_CONTROL, NETATMO_CREATE_FAN +from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice +from .entity import NetatmoBaseEntity + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PERCENTAGE: Final = 50 + +PRESET_MAPPING = {"slow": 1, "fast": 2} +PRESETS = {v: k for k, v in PRESET_MAPPING.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Netatmo fan platform.""" + + @callback + def _create_entity(netatmo_device: NetatmoDevice) -> None: + entity = NetatmoFan(netatmo_device) + _LOGGER.debug("Adding cover %s", entity) + async_add_entities([entity]) + + entry.async_on_unload( + async_dispatcher_connect(hass, NETATMO_CREATE_FAN, _create_entity) + ) + + +class NetatmoFan(NetatmoBaseEntity, FanEntity): + """Representation of a Netatmo fan.""" + + _attr_preset_modes = ["slow", "fast"] + _attr_supported_features = FanEntityFeature.PRESET_MODE + + def __init__(self, netatmo_device: NetatmoDevice) -> None: + """Initialize of Netatmo fan.""" + super().__init__(netatmo_device.data_handler) + + self._fan = cast(NaModules.Fan, netatmo_device.device) + + self._id = self._fan.entity_id + self._attr_name = self._device_name = self._fan.name + self._model = self._fan.device_type + self._config_url = CONF_URL_CONTROL + + self._home_id = self._fan.home.entity_id + + self._signal_name = f"{HOME}-{self._home_id}" + self._publishers.extend( + [ + { + "name": HOME, + "home_id": self._home_id, + SIGNAL_NAME: self._signal_name, + }, + ] + ) + + self._attr_unique_id = f"{self._id}-{self._model}" + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + await self._fan.async_set_fan_speed(PRESET_MAPPING[preset_mode]) + + @callback + def async_update_callback(self) -> None: + """Update the entity's state.""" + if self._fan.fan_speed is None: + self._attr_preset_mode = None + return + self._attr_preset_mode = PRESETS.get(self._fan.fan_speed) diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index aee63e60016..98734bcb742 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==8.0.2"] + "requirements": ["pyatmo==8.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index ab4e508862d..16d382a2b28 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1654,7 +1654,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.0.2 +pyatmo==8.0.3 # homeassistant.components.apple_tv pyatv==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d828a65d21c..28da76730a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1274,7 +1274,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.0.2 +pyatmo==8.0.3 # homeassistant.components.apple_tv pyatv==0.14.3 diff --git a/tests/components/netatmo/fixtures/homesdata.json b/tests/components/netatmo/fixtures/homesdata.json index 6b24a7f8f9d..ccc71dc6b41 100644 --- a/tests/components/netatmo/fixtures/homesdata.json +++ b/tests/components/netatmo/fixtures/homesdata.json @@ -23,7 +23,8 @@ "12:34:56:00:f1:62", "12:34:56:10:f1:66", "12:34:56:00:e3:9b", - "0009999992" + "0009999992", + "0009999993" ] }, { @@ -174,7 +175,7 @@ "name": "module iDiamant", "setup_date": 1562262465, "room_id": "222452125", - "modules_bridged": ["0009999992"] + "modules_bridged": ["0009999992", "0009999993"] }, { "id": "0009999992", @@ -184,6 +185,14 @@ "room_id": "3688132631", "bridge": "12:34:56:30:d5:d4" }, + { + "id": "0009999993", + "type": "NBO", + "name": "Bubendorff blind", + "setup_date": 1594132017, + "room_id": "3688132631", + "bridge": "12:34:56:30:d5:d4" + }, { "id": "12:34:56:80:bb:26", "type": "NAMain", @@ -310,7 +319,8 @@ "12:34:56:80:00:c3:69:3c", "12:34:56:00:00:a1:4c:da", "12:34:56:00:01:01:01:a1", - "00:11:22:33:00:11:45:fe" + "00:11:22:33:00:11:45:fe", + "12:34:56:00:01:01:01:b1" ] }, { @@ -466,6 +476,14 @@ "setup_date": 1598367404, "room_id": "1002003001", "bridge": "12:34:56:80:60:40" + }, + { + "id": "12:34:56:00:01:01:01:b1", + "type": "NLLF", + "name": "Centralized ventilation controler", + "setup_date": 1598367504, + "room_id": "1002003001", + "bridge": "12:34:56:80:60:40" } ], "schedules": [ diff --git a/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json b/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json index 736d70be11c..998cd7155b3 100644 --- a/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json +++ b/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json @@ -139,6 +139,18 @@ "reachable": true, "bridge": "12:34:56:30:d5:d4" }, + { + "id": "0009999993", + "type": "NBO", + "current_position": 0, + "target_position": 0, + "target_position:step": 100, + "firmware_revision": 22, + "rf_strength": 0, + "last_seen": 1671395511, + "reachable": true, + "bridge": "12:34:56:30:d5:d4" + }, { "id": "12:34:56:00:86:99", "type": "NACamDoorTag", @@ -276,6 +288,16 @@ "power": 0, "reachable": true, "bridge": "12:34:56:80:60:40" + }, + { + "id": "12:34:56:00:01:01:01:b1", + "type": "NLLF", + "firmware_revision": 60, + "last_seen": 1657086949, + "power": 11, + "reachable": true, + "bridge": "12:34:56:80:60:40", + "fan_speed": 1 } ], "rooms": [ diff --git a/tests/components/netatmo/snapshots/test_cover.ambr b/tests/components/netatmo/snapshots/test_cover.ambr index 58871b397e2..c83ae61b4c2 100644 --- a/tests/components/netatmo/snapshots/test_cover.ambr +++ b/tests/components/netatmo/snapshots/test_cover.ambr @@ -1,4 +1,51 @@ # serializer version: 1 +# name: test_entity[cover.bubendorff_blind-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.bubendorff_blind', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'name': None, + 'options': dict({ + }), + 'original_device_class': <CoverDeviceClass.SHUTTER: 'shutter'>, + 'original_icon': None, + 'original_name': 'Bubendorff blind', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': <CoverEntityFeature: 15>, + 'translation_key': None, + 'unique_id': '0009999993-DeviceType.NBO', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[cover.bubendorff_blind-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'current_position': 0, + 'device_class': 'shutter', + 'friendly_name': 'Bubendorff blind', + 'supported_features': <CoverEntityFeature: 15>, + }), + 'context': <ANY>, + 'entity_id': 'cover.bubendorff_blind', + 'last_changed': <ANY>, + 'last_updated': <ANY>, + 'state': 'closed', + }) +# --- # name: test_entity[cover.entrance_blinds-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/netatmo/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr index f1c54901445..8ce00279b83 100644 --- a/tests/components/netatmo/snapshots/test_diagnostics.ambr +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -111,6 +111,7 @@ 'id': '12:34:56:30:d5:d4', 'modules_bridged': list([ '0009999992', + '0009999993', ]), 'name': '**REDACTED**', 'room_id': '222452125', @@ -125,6 +126,14 @@ 'setup_date': 1578551339, 'type': 'NBR', }), + dict({ + 'bridge': '12:34:56:30:d5:d4', + 'id': '0009999993', + 'name': '**REDACTED**', + 'room_id': '3688132631', + 'setup_date': 1594132017, + 'type': 'NBO', + }), dict({ 'alarm_config': dict({ 'default_alarm': list([ @@ -248,6 +257,7 @@ '12:34:56:00:00:a1:4c:da', '12:34:56:00:01:01:01:a1', '00:11:22:33:00:11:45:fe', + '12:34:56:00:01:01:01:b1', ]), 'name': '**REDACTED**', 'room_id': '1310352496', @@ -408,6 +418,14 @@ 'setup_date': 1598367404, 'type': 'NLFN', }), + dict({ + 'bridge': '12:34:56:80:60:40', + 'id': '12:34:56:00:01:01:01:b1', + 'name': '**REDACTED**', + 'room_id': '1002003001', + 'setup_date': 1598367504, + 'type': 'NLLF', + }), ]), 'name': '**REDACTED**', 'persons': list([ @@ -443,6 +461,7 @@ '12:34:56:10:f1:66', '12:34:56:00:e3:9b', '0009999992', + '0009999993', ]), 'name': '**REDACTED**', 'type': 'custom', diff --git a/tests/components/netatmo/snapshots/test_fan.ambr b/tests/components/netatmo/snapshots/test_fan.ambr new file mode 100644 index 00000000000..3b94257d983 --- /dev/null +++ b/tests/components/netatmo/snapshots/test_fan.ambr @@ -0,0 +1,56 @@ +# serializer version: 1 +# name: test_entity[fan.centralized_ventilation_controler-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'slow', + 'fast', + ]), + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.centralized_ventilation_controler', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Centralized ventilation controler', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': <FanEntityFeature: 8>, + 'translation_key': None, + 'unique_id': '12:34:56:00:01:01:01:b1-DeviceType.NLLF', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[fan.centralized_ventilation_controler-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Centralized ventilation controler', + 'preset_mode': 'slow', + 'preset_modes': list([ + 'slow', + 'fast', + ]), + 'supported_features': <FanEntityFeature: 8>, + }), + 'context': <ANY>, + 'entity_id': 'fan.centralized_ventilation_controler', + 'last_changed': <ANY>, + 'last_updated': <ANY>, + 'state': 'on', + }) +# --- diff --git a/tests/components/netatmo/snapshots/test_init.ambr b/tests/components/netatmo/snapshots/test_init.ambr index 0c9e2d00f55..589d888936b 100644 --- a/tests/components/netatmo/snapshots/test_init.ambr +++ b/tests/components/netatmo/snapshots/test_init.ambr @@ -27,6 +27,34 @@ 'via_device_id': None, }) # --- +# name: test_devices[netatmo-0009999993] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': <ANY>, + 'configuration_url': 'https://home.netatmo.com/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': <ANY>, + 'identifiers': set({ + tuple( + 'netatmo', + '0009999993', + ), + }), + 'is_new': False, + 'manufacturer': 'Bubbendorf', + 'model': 'Orientable Shutter', + 'name': 'Bubendorff blind', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[netatmo-00:11:22:33:00:11:45:fe] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/netatmo/test_fan.py b/tests/components/netatmo/test_fan.py new file mode 100644 index 00000000000..72dd579af67 --- /dev/null +++ b/tests/components/netatmo/test_fan.py @@ -0,0 +1,70 @@ +"""The tests for Netatmo fan.""" +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.fan import ( + ATTR_PRESET_MODE, + DOMAIN as FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from .common import selected_platforms, snapshot_platform_entities + +from tests.common import MockConfigEntry + + +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.FAN, + entity_registry, + snapshot, + ) + + +async def test_switch_setup_and_services( + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock +) -> None: + """Test setup and services.""" + with selected_platforms([Platform.FAN]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + fan_entity = "fan.centralized_ventilation_controler" + + assert hass.states.get(fan_entity).state == "on" + assert hass.states.get(fan_entity).attributes[ATTR_PRESET_MODE] == "slow" + + # Test turning switch on + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: fan_entity, ATTR_PRESET_MODE: "fast"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once_with( + { + "modules": [ + { + "id": "12:34:56:00:01:01:01:b1", + "fan_speed": 2, + "bridge": "12:34:56:80:60:40", + } + ] + } + ) -- GitLab