From 2e54967a6da0b42d97f95157ca540a9d0ed12f66 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Tue, 10 Sep 2024 22:49:45 +0200 Subject: [PATCH] Add select platform to opentherm_gw (#125585) * * Add select platform to opentherm_gw * Add tests for select entities * Address capitalization feedback * Add initial state on startup and status update support * Wrap lambdas in parentheses --- .../components/opentherm_gw/__init__.py | 1 + .../components/opentherm_gw/select.py | 148 ++++++++++++++++++ .../components/opentherm_gw/strings.json | 16 ++ tests/components/opentherm_gw/test_select.py | 120 ++++++++++++++ 4 files changed, 285 insertions(+) create mode 100644 homeassistant/components/opentherm_gw/select.py create mode 100644 tests/components/opentherm_gw/test_select.py diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index d5dae367959..5ce9d808b21 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -96,6 +96,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/opentherm_gw/select.py b/homeassistant/components/opentherm_gw/select.py new file mode 100644 index 00000000000..49878d6d839 --- /dev/null +++ b/homeassistant/components/opentherm_gw/select.py @@ -0,0 +1,148 @@ +"""Support for OpenTherm Gateway select entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from enum import IntEnum, StrEnum +from functools import partial + +from pyotgw.vars import OTGW_GPIO_A, OTGW_GPIO_B + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID, EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import OpenThermGatewayHub +from .const import ( + DATA_GATEWAYS, + DATA_OPENTHERM_GW, + GATEWAY_DEVICE_DESCRIPTION, + OpenThermDataSource, +) +from .entity import OpenThermEntityDescription, OpenThermStatusEntity + + +class OpenThermSelectGPIOMode(StrEnum): + """OpenTherm Gateway GPIO modes.""" + + INPUT = "input" + GROUND = "ground" + VCC = "vcc" + LED_E = "led_e" + LED_F = "led_f" + HOME = "home" + AWAY = "away" + DS1820 = "ds1820" + DHW_BLOCK = "dhw_block" + + +class PyotgwGPIOMode(IntEnum): + """pyotgw GPIO modes.""" + + INPUT = 0 + GROUND = 1 + VCC = 2 + LED_E = 3 + LED_F = 4 + HOME = 5 + AWAY = 6 + DS1820 = 7 + DHW_BLOCK = 8 + + +async def set_gpio_mode( + gpio_id: str, gw_hub: OpenThermGatewayHub, mode: str +) -> OpenThermSelectGPIOMode | None: + """Set gpio mode, return selected option or None.""" + value = await gw_hub.gateway.set_gpio_mode( + gpio_id, PyotgwGPIOMode[OpenThermSelectGPIOMode(mode).name] + ) + return ( + OpenThermSelectGPIOMode[PyotgwGPIOMode(value).name] + if value in PyotgwGPIOMode + else None + ) + + +@dataclass(frozen=True, kw_only=True) +class OpenThermSelectEntityDescription( + OpenThermEntityDescription, SelectEntityDescription +): + """Describes an opentherm_gw select entity.""" + + select_action: Callable[[OpenThermGatewayHub, str], Awaitable] + convert_pyotgw_state_to_ha_state: Callable + + +SELECT_DESCRIPTIONS: tuple[OpenThermSelectEntityDescription, ...] = ( + OpenThermSelectEntityDescription( + key=OTGW_GPIO_A, + translation_key="gpio_mode_n", + translation_placeholders={"gpio_id": "A"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, + options=[ + mode + for mode in OpenThermSelectGPIOMode + if mode != OpenThermSelectGPIOMode.DS1820 + ], + select_action=partial(set_gpio_mode, "A"), + convert_pyotgw_state_to_ha_state=( + lambda state: OpenThermSelectGPIOMode[PyotgwGPIOMode(state).name] + if state in PyotgwGPIOMode + else None + ), + ), + OpenThermSelectEntityDescription( + key=OTGW_GPIO_B, + translation_key="gpio_mode_n", + translation_placeholders={"gpio_id": "B"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, + options=list(OpenThermSelectGPIOMode), + select_action=partial(set_gpio_mode, "B"), + convert_pyotgw_state_to_ha_state=( + lambda state: OpenThermSelectGPIOMode[PyotgwGPIOMode(state).name] + if state in PyotgwGPIOMode + else None + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the OpenTherm Gateway select entities.""" + gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] + + async_add_entities( + OpenThermSelect(gw_hub, description) for description in SELECT_DESCRIPTIONS + ) + + +class OpenThermSelect(OpenThermStatusEntity, SelectEntity): + """Represent an OpenTherm Gateway select.""" + + _attr_current_option = None + _attr_entity_category = EntityCategory.CONFIG + entity_description: OpenThermSelectEntityDescription + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + new_option = await self.entity_description.select_action(self._gateway, option) + if new_option is not None: + self._attr_current_option = new_option + self.async_write_ha_state() + + @callback + def receive_report(self, status: dict[OpenThermDataSource, dict]) -> None: + """Handle status updates from the component.""" + state = status[self.entity_description.device_description.data_source].get( + self.entity_description.key + ) + self._attr_current_option = ( + self.entity_description.convert_pyotgw_state_to_ha_state(state) + ) + self.async_write_ha_state() diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index f0573db0531..e4d72ad8fb5 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -158,6 +158,22 @@ "name": "Programmed change has priority over override" } }, + "select": { + "gpio_mode_n": { + "name": "GPIO {gpio_id} mode", + "state": { + "input": "Input", + "ground": "Ground", + "vcc": "Vcc (5V)", + "led_e": "LED E", + "led_f": "LED F", + "home": "Home", + "away": "Away", + "ds1820": "DS1820", + "dhw_block": "Block hot water" + } + } + }, "sensor": { "control_setpoint_n": { "name": "Control setpoint {circuit_number}" diff --git a/tests/components/opentherm_gw/test_select.py b/tests/components/opentherm_gw/test_select.py new file mode 100644 index 00000000000..e0c4630b036 --- /dev/null +++ b/tests/components/opentherm_gw/test_select.py @@ -0,0 +1,120 @@ +"""Test opentherm_gw select entities.""" + +from unittest.mock import AsyncMock, MagicMock + +from pyotgw.vars import OTGW_GPIO_A, OTGW_GPIO_B +import pytest + +from homeassistant.components.opentherm_gw import DOMAIN as OPENTHERM_DOMAIN +from homeassistant.components.opentherm_gw.const import ( + DATA_GATEWAYS, + DATA_OPENTHERM_GW, + OpenThermDeviceIdentifier, +) +from homeassistant.components.opentherm_gw.select import ( + OpenThermSelectGPIOMode, + PyotgwGPIOMode, +) +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("entity_key", "gpio_id"), + [ + (OTGW_GPIO_A, "A"), + (OTGW_GPIO_B, "B"), + ], +) +async def test_gpio_mode_select( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_pyotgw: MagicMock, + entity_key: str, + gpio_id: str, +) -> None: + """Test GPIO mode selector.""" + + mock_pyotgw.return_value.set_gpio_mode = AsyncMock(return_value=PyotgwGPIOMode.VCC) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + select_entity_id := entity_registry.async_get_entity_id( + SELECT_DOMAIN, + OPENTHERM_DOMAIN, + f"{mock_config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.GATEWAY}-{entity_key}", + ) + ) is not None + assert hass.states.get(select_entity_id).state == STATE_UNKNOWN + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: select_entity_id, ATTR_OPTION: OpenThermSelectGPIOMode.VCC}, + blocking=True, + ) + assert hass.states.get(select_entity_id).state == OpenThermSelectGPIOMode.VCC + + mock_pyotgw.return_value.set_gpio_mode.assert_awaited_once_with( + gpio_id, PyotgwGPIOMode.VCC.value + ) + + +@pytest.mark.parametrize( + ("entity_key"), + [ + (OTGW_GPIO_A), + (OTGW_GPIO_B), + ], +) +async def test_gpio_mode_state_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_pyotgw: MagicMock, + entity_key: str, +) -> None: + """Test GPIO mode selector.""" + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + select_entity_id := entity_registry.async_get_entity_id( + SELECT_DOMAIN, + OPENTHERM_DOMAIN, + f"{mock_config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.GATEWAY}-{entity_key}", + ) + ) is not None + assert hass.states.get(select_entity_id).state == STATE_UNKNOWN + + gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][ + mock_config_entry.data[CONF_ID] + ] + async_dispatcher_send( + hass, + gw_hub.update_signal, + { + OpenThermDeviceIdentifier.BOILER: {}, + OpenThermDeviceIdentifier.GATEWAY: {entity_key: 4}, + OpenThermDeviceIdentifier.THERMOSTAT: {}, + }, + ) + await hass.async_block_till_done() + + assert hass.states.get(select_entity_id).state == OpenThermSelectGPIOMode.LED_F