Home Connect add FridgeFreezer switch entities (#122881)

* Home Connect add FridgeFreezer switch entities

* Fix unrelated test

* Implemented requested changes from review

* Move exist_fn check code to setup

* Assign entity_description during init

* Resolve issue with functional testing
This commit is contained in:
Robert Contreras 2024-09-05 11:52:12 -07:00 committed by GitHub
parent d2d01b337d
commit d686b877b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 269 additions and 7 deletions

View File

@ -23,6 +23,13 @@ BSH_OPERATION_STATE_FINISHED = "BSH.Common.EnumType.OperationState.Finished"
COOKING_LIGHTING = "Cooking.Common.Setting.Lighting" COOKING_LIGHTING = "Cooking.Common.Setting.Lighting"
COOKING_LIGHTING_BRIGHTNESS = "Cooking.Common.Setting.LightingBrightness" COOKING_LIGHTING_BRIGHTNESS = "Cooking.Common.Setting.LightingBrightness"
REFRIGERATION_SUPERMODEFREEZER = "Refrigeration.FridgeFreezer.Setting.SuperModeFreezer"
REFRIGERATION_SUPERMODEREFRIGERATOR = (
"Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator"
)
REFRIGERATION_DISPENSER = "Refrigeration.Common.Setting.Dispenser.Enabled"
BSH_AMBIENT_LIGHT_ENABLED = "BSH.Common.Setting.AmbientLightEnabled" BSH_AMBIENT_LIGHT_ENABLED = "BSH.Common.Setting.AmbientLightEnabled"
BSH_AMBIENT_LIGHT_BRIGHTNESS = "BSH.Common.Setting.AmbientLightBrightness" BSH_AMBIENT_LIGHT_BRIGHTNESS = "BSH.Common.Setting.AmbientLightBrightness"
BSH_AMBIENT_LIGHT_COLOR = "BSH.Common.Setting.AmbientLightColor" BSH_AMBIENT_LIGHT_COLOR = "BSH.Common.Setting.AmbientLightColor"

View File

@ -21,5 +21,15 @@
"change_setting": { "change_setting": {
"service": "mdi:cog" "service": "mdi:cog"
} }
},
"entity": {
"switch": {
"refrigeration_dispenser": {
"default": "mdi:snowflake",
"state": {
"off": "mdi:snowflake-off"
}
}
}
} }
} }

View File

@ -1,16 +1,18 @@
"""Provides a switch for Home Connect.""" """Provides a switch for Home Connect."""
from dataclasses import dataclass
import logging import logging
from typing import Any from typing import Any
from homeconnect.api import HomeConnectError from homeconnect.api import HomeConnectError
from homeassistant.components.switch import SwitchEntity from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE, CONF_ENTITIES from homeassistant.const import CONF_DEVICE, CONF_ENTITIES
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .api import ConfigEntryAuth
from .const import ( from .const import (
ATTR_VALUE, ATTR_VALUE,
BSH_ACTIVE_PROGRAM, BSH_ACTIVE_PROGRAM,
@ -19,12 +21,39 @@ from .const import (
BSH_POWER_ON, BSH_POWER_ON,
BSH_POWER_STATE, BSH_POWER_STATE,
DOMAIN, DOMAIN,
REFRIGERATION_DISPENSER,
REFRIGERATION_SUPERMODEFREEZER,
REFRIGERATION_SUPERMODEREFRIGERATOR,
) )
from .entity import HomeConnectEntity from .entity import HomeConnectDevice, HomeConnectEntity
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class HomeConnectSwitchEntityDescription(SwitchEntityDescription):
"""Switch entity description."""
on_key: str
SWITCHES: tuple[HomeConnectSwitchEntityDescription, ...] = (
HomeConnectSwitchEntityDescription(
key="Supermode Freezer",
on_key=REFRIGERATION_SUPERMODEFREEZER,
),
HomeConnectSwitchEntityDescription(
key="Supermode Refrigerator",
on_key=REFRIGERATION_SUPERMODEREFRIGERATOR,
),
HomeConnectSwitchEntityDescription(
key="Dispenser Enabled",
on_key=REFRIGERATION_DISPENSER,
translation_key="refrigeration_dispenser",
),
)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: ConfigEntry,
@ -35,18 +64,87 @@ async def async_setup_entry(
def get_entities(): def get_entities():
"""Get a list of entities.""" """Get a list of entities."""
entities = [] entities = []
hc_api = hass.data[DOMAIN][config_entry.entry_id] hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id]
for device_dict in hc_api.devices: for device_dict in hc_api.devices:
entity_dicts = device_dict.get(CONF_ENTITIES, {}).get("switch", []) entity_dicts = device_dict.get(CONF_ENTITIES, {}).get("switch", [])
entity_list = [HomeConnectProgramSwitch(**d) for d in entity_dicts] entity_list = [HomeConnectProgramSwitch(**d) for d in entity_dicts]
entity_list += [HomeConnectPowerSwitch(device_dict[CONF_DEVICE])] entity_list += [HomeConnectPowerSwitch(device_dict[CONF_DEVICE])]
entity_list += [HomeConnectChildLockSwitch(device_dict[CONF_DEVICE])] entity_list += [HomeConnectChildLockSwitch(device_dict[CONF_DEVICE])]
entities += entity_list # Auto-discover entities
hc_device: HomeConnectDevice = device_dict[CONF_DEVICE]
entities.extend(
HomeConnectSwitch(device=hc_device, entity_description=description)
for description in SWITCHES
if description.on_key in hc_device.appliance.status
)
entities.extend(entity_list)
return entities return entities
async_add_entities(await hass.async_add_executor_job(get_entities), True) async_add_entities(await hass.async_add_executor_job(get_entities), True)
class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
"""Generic switch class for Home Connect Binary Settings."""
entity_description: HomeConnectSwitchEntityDescription
_attr_available: bool = False
def __init__(
self,
device: HomeConnectDevice,
entity_description: HomeConnectSwitchEntityDescription,
) -> None:
"""Initialize the entity."""
self.entity_description = entity_description
super().__init__(device=device, desc=entity_description.key)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on setting."""
_LOGGER.debug("Turning on %s", self.entity_description.key)
try:
await self.hass.async_add_executor_job(
self.device.appliance.set_setting, self.entity_description.on_key, True
)
except HomeConnectError as err:
_LOGGER.error("Error while trying to turn on: %s", err)
self._attr_available = False
return
self._attr_available = True
self.async_entity_update()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off setting."""
_LOGGER.debug("Turning off %s", self.entity_description.key)
try:
await self.hass.async_add_executor_job(
self.device.appliance.set_setting, self.entity_description.on_key, False
)
except HomeConnectError as err:
_LOGGER.error("Error while trying to turn off: %s", err)
self._attr_available = False
return
self._attr_available = True
self.async_entity_update()
async def async_update(self) -> None:
"""Update the switch's status."""
self._attr_is_on = self.device.appliance.status.get(
self.entity_description.on_key, {}
).get(ATTR_VALUE)
self._attr_available = True
_LOGGER.debug(
"Updated %s, new state: %s",
self.entity_description.key,
self._attr_is_on,
)
class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
"""Switch class for Home Connect.""" """Switch class for Home Connect."""

View File

@ -111,5 +111,35 @@
} }
] ]
} }
},
"FridgeFreezer": {
"data": {
"settings": [
{
"key": "Refrigeration.FridgeFreezer.Setting.SuperModeFreezer",
"value": false,
"type": "Boolean",
"constraints": {
"access": "readWrite"
}
},
{
"key": "Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator",
"value": false,
"type": "Boolean",
"constraints": {
"access": "readWrite"
}
},
{
"key": "Refrigeration.Common.Setting.Dispenser.Enabled",
"value": false,
"type": "Boolean",
"constraints": {
"access": "readWrite"
}
}
]
}
} }
} }

View File

@ -9,6 +9,7 @@ import pytest
from requests import HTTPError from requests import HTTPError
import requests_mock import requests_mock
from homeassistant.components.home_connect import SCAN_INTERVAL
from homeassistant.components.home_connect.const import DOMAIN, OAUTH2_TOKEN from homeassistant.components.home_connect.const import DOMAIN, OAUTH2_TOKEN
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -155,14 +156,14 @@ async def test_update_throttle(
# First re-load after 1 minute is not blocked. # First re-load after 1 minute is not blocked.
assert await hass.config_entries.async_unload(config_entry.entry_id) assert await hass.config_entries.async_unload(config_entry.entry_id)
assert config_entry.state == ConfigEntryState.NOT_LOADED assert config_entry.state == ConfigEntryState.NOT_LOADED
freezer.tick(60) freezer.tick(SCAN_INTERVAL.seconds + 0.1)
assert await hass.config_entries.async_setup(config_entry.entry_id) assert await hass.config_entries.async_setup(config_entry.entry_id)
assert get_appliances.call_count == get_appliances_call_count + 1 assert get_appliances.call_count == get_appliances_call_count + 1
# Second re-load is blocked by Throttle. # Second re-load is blocked by Throttle.
assert await hass.config_entries.async_unload(config_entry.entry_id) assert await hass.config_entries.async_unload(config_entry.entry_id)
assert config_entry.state == ConfigEntryState.NOT_LOADED assert config_entry.state == ConfigEntryState.NOT_LOADED
freezer.tick(59) freezer.tick(SCAN_INTERVAL.seconds - 0.1)
assert await hass.config_entries.async_setup(config_entry.entry_id) assert await hass.config_entries.async_setup(config_entry.entry_id)
assert get_appliances.call_count == get_appliances_call_count + 1 assert get_appliances.call_count == get_appliances_call_count + 1

View File

@ -3,7 +3,7 @@
from collections.abc import Awaitable, Callable, Generator from collections.abc import Awaitable, Callable, Generator
from unittest.mock import MagicMock, Mock from unittest.mock import MagicMock, Mock
from homeconnect.api import HomeConnectError from homeconnect.api import HomeConnectAppliance, HomeConnectError
import pytest import pytest
from homeassistant.components.home_connect.const import ( from homeassistant.components.home_connect.const import (
@ -13,10 +13,12 @@ from homeassistant.components.home_connect.const import (
BSH_POWER_OFF, BSH_POWER_OFF,
BSH_POWER_ON, BSH_POWER_ON,
BSH_POWER_STATE, BSH_POWER_STATE,
REFRIGERATION_SUPERMODEFREEZER,
) )
from homeassistant.components.switch import DOMAIN from homeassistant.components.switch import DOMAIN
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF, SERVICE_TURN_OFF,
SERVICE_TURN_ON, SERVICE_TURN_ON,
STATE_OFF, STATE_OFF,
@ -214,3 +216,117 @@ async def test_switch_exception_handling(
DOMAIN, service, {"entity_id": entity_id}, blocking=True DOMAIN, service, {"entity_id": entity_id}, blocking=True
) )
assert getattr(problematic_appliance, mock_attr).call_count == 2 assert getattr(problematic_appliance, mock_attr).call_count == 2
@pytest.mark.parametrize(
("entity_id", "status", "service", "state", "appliance"),
[
(
"switch.fridgefreezer_supermode_freezer",
{REFRIGERATION_SUPERMODEFREEZER: {"value": True}},
SERVICE_TURN_ON,
STATE_ON,
"FridgeFreezer",
),
(
"switch.fridgefreezer_supermode_freezer",
{REFRIGERATION_SUPERMODEFREEZER: {"value": False}},
SERVICE_TURN_OFF,
STATE_OFF,
"FridgeFreezer",
),
],
indirect=["appliance"],
)
async def test_ent_desc_switch_functionality(
entity_id: str,
status: dict,
service: str,
state: str,
bypass_throttle: Generator[None],
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
setup_credentials: None,
appliance: Mock,
get_appliances: MagicMock,
) -> None:
"""Test switch functionality - entity description setup."""
appliance.status.update(
HomeConnectAppliance.json2dict(
load_json_object_fixture("home_connect/settings.json")
.get(appliance.name)
.get("data")
.get("settings")
)
)
get_appliances.return_value = [appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert config_entry.state == ConfigEntryState.LOADED
appliance.status.update(status)
await hass.services.async_call(
DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
assert hass.states.is_state(entity_id, state)
@pytest.mark.parametrize(
("entity_id", "status", "service", "mock_attr", "problematic_appliance"),
[
(
"switch.fridgefreezer_supermode_freezer",
{REFRIGERATION_SUPERMODEFREEZER: {"value": ""}},
SERVICE_TURN_ON,
"set_setting",
"FridgeFreezer",
),
(
"switch.fridgefreezer_supermode_freezer",
{REFRIGERATION_SUPERMODEFREEZER: {"value": ""}},
SERVICE_TURN_OFF,
"set_setting",
"FridgeFreezer",
),
],
indirect=["problematic_appliance"],
)
async def test_ent_desc_switch_exception_handling(
entity_id: str,
status: dict,
service: str,
mock_attr: str,
bypass_throttle: Generator[None],
hass: HomeAssistant,
integration_setup: Callable[[], Awaitable[bool]],
config_entry: MockConfigEntry,
setup_credentials: None,
problematic_appliance: Mock,
get_appliances: MagicMock,
) -> None:
"""Test switch exception handling - entity description setup."""
problematic_appliance.status.update(
HomeConnectAppliance.json2dict(
load_json_object_fixture("home_connect/settings.json")
.get(problematic_appliance.name)
.get("data")
.get("settings")
)
)
get_appliances.return_value = [problematic_appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert config_entry.state == ConfigEntryState.LOADED
# Assert that an exception is called.
with pytest.raises(HomeConnectError):
getattr(problematic_appliance, mock_attr)()
problematic_appliance.status.update(status)
await hass.services.async_call(
DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
assert getattr(problematic_appliance, mock_attr).call_count == 2