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>
This commit is contained in:
Tobias Sauerwein 2024-01-14 11:47:20 +01:00 committed by GitHub
parent acbc2350d0
commit f808c2ff14
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 356 additions and 6 deletions

View file

@ -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"

View file

@ -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:

View file

@ -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)

View file

@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyatmo"],
"requirements": ["pyatmo==8.0.2"]
"requirements": ["pyatmo==8.0.3"]
}

View file

@ -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

View file

@ -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

View file

@ -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": [

View file

@ -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": [

View file

@ -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({

View file

@ -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',

View file

@ -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',
})
# ---

View file

@ -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,

View file

@ -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",
}
]
}
)