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