diff --git a/.coveragerc b/.coveragerc index 5068574df78c7d862457686b19f29d3ea10cffa4..9d839cdb5f6195af040effda4c4f315c847020a7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -309,6 +309,9 @@ omit = homeassistant/components/epson/media_player.py homeassistant/components/epsonworkforce/sensor.py homeassistant/components/eq3btsmart/climate.py + homeassistant/components/escea/climate.py + homeassistant/components/escea/discovery.py + homeassistant/components/escea/__init__.py homeassistant/components/esphome/__init__.py homeassistant/components/esphome/binary_sensor.py homeassistant/components/esphome/button.py diff --git a/CODEOWNERS b/CODEOWNERS index e10a8a0b26c2ceb9fa2f49279394f6044a707c21..906119e900667a59aaa4790fc8db837f2e85a45d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -309,6 +309,8 @@ build.json @home-assistant/supervisor /tests/components/epson/ @pszafer /homeassistant/components/epsonworkforce/ @ThaStealth /homeassistant/components/eq3btsmart/ @rytilahti +/homeassistant/components/escea/ @lazdavila +/tests/components/escea/ @lazdavila /homeassistant/components/esphome/ @OttoWinter @jesserockz /tests/components/esphome/ @OttoWinter @jesserockz /homeassistant/components/evil_genius_labs/ @balloob diff --git a/homeassistant/components/escea/__init__.py b/homeassistant/components/escea/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..95e6765fa959a415ea553f42676d2bac1ae02546 --- /dev/null +++ b/homeassistant/components/escea/__init__.py @@ -0,0 +1,22 @@ +"""Platform for the Escea fireplace.""" + +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .discovery import async_start_discovery_service, async_stop_discovery_service + +PLATFORMS = [CLIMATE_DOMAIN] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + await async_start_discovery_service(hass) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload the config entry and stop discovery process.""" + await async_stop_discovery_service(hass) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/escea/climate.py b/homeassistant/components/escea/climate.py new file mode 100644 index 0000000000000000000000000000000000000000..7bd7d54353cbbc91f17707a668508ab373b4f51e --- /dev/null +++ b/homeassistant/components/escea/climate.py @@ -0,0 +1,221 @@ +"""Support for the Escea Fireplace.""" +from __future__ import annotations + +from collections.abc import Coroutine +import logging +from typing import Any + +from pescea import Controller + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, TEMP_CELSIUS +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + DATA_DISCOVERY_SERVICE, + DISPATCH_CONTROLLER_DISCONNECTED, + DISPATCH_CONTROLLER_DISCOVERED, + DISPATCH_CONTROLLER_RECONNECTED, + DISPATCH_CONTROLLER_UPDATE, + DOMAIN, + ESCEA_FIREPLACE, + ESCEA_MANUFACTURER, + ICON, +) + +_LOGGER = logging.getLogger(__name__) + +_ESCEA_FAN_TO_HA = { + Controller.Fan.FLAME_EFFECT: FAN_LOW, + Controller.Fan.FAN_BOOST: FAN_HIGH, + Controller.Fan.AUTO: FAN_AUTO, +} +_HA_FAN_TO_ESCEA = {v: k for k, v in _ESCEA_FAN_TO_HA.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize an Escea Controller.""" + discovery_service = hass.data[DATA_DISCOVERY_SERVICE] + + @callback + def init_controller(ctrl: Controller) -> None: + """Register the controller device.""" + + _LOGGER.debug("Controller UID=%s discovered", ctrl.device_uid) + + entity = ControllerEntity(ctrl) + async_add_entities([entity]) + + # create any components not yet created + for controller in discovery_service.controllers.values(): + init_controller(controller) + + # connect to register any further components + config_entry.async_on_unload( + async_dispatcher_connect(hass, DISPATCH_CONTROLLER_DISCOVERED, init_controller) + ) + + +class ControllerEntity(ClimateEntity): + """Representation of Escea Controller.""" + + _attr_fan_modes = list(_HA_FAN_TO_ESCEA) + _attr_has_entity_name = True + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] + _attr_icon = ICON + _attr_precision = PRECISION_WHOLE + _attr_should_poll = False + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ) + _attr_target_temperature_step = PRECISION_WHOLE + _attr_temperature_unit = TEMP_CELSIUS + + def __init__(self, controller: Controller) -> None: + """Initialise ControllerDevice.""" + self._controller = controller + + self._attr_min_temp = controller.min_temp + self._attr_max_temp = controller.max_temp + + self._attr_unique_id = controller.device_uid + + # temporary assignment to get past mypy checker + unique_id: str = controller.device_uid + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer=ESCEA_MANUFACTURER, + name=ESCEA_FIREPLACE, + ) + + self._attr_available = True + + async def async_added_to_hass(self) -> None: + """Call on adding to hass. + + Registers for connect/disconnect/update events + """ + + @callback + def controller_disconnected(ctrl: Controller, ex: Exception) -> None: + """Disconnected from controller.""" + if ctrl is not self._controller: + return + self.set_available(False, ex) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, DISPATCH_CONTROLLER_DISCONNECTED, controller_disconnected + ) + ) + + @callback + def controller_reconnected(ctrl: Controller) -> None: + """Reconnected to controller.""" + if ctrl is not self._controller: + return + self.set_available(True) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, DISPATCH_CONTROLLER_RECONNECTED, controller_reconnected + ) + ) + + @callback + def controller_update(ctrl: Controller) -> None: + """Handle controller data updates.""" + if ctrl is not self._controller: + return + self.async_write_ha_state() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, DISPATCH_CONTROLLER_UPDATE, controller_update + ) + ) + + @callback + def set_available(self, available: bool, ex: Exception = None) -> None: + """Set availability for the controller.""" + if self._attr_available == available: + return + + if available: + _LOGGER.debug("Reconnected controller %s ", self._controller.device_uid) + else: + _LOGGER.debug( + "Controller %s disconnected due to exception: %s", + self._controller.device_uid, + ex, + ) + + self._attr_available = available + self.async_write_ha_state() + + @property + def hvac_mode(self) -> HVACMode: + """Return current operation ie. heat, cool, idle.""" + return HVACMode.HEAT if self._controller.is_on else HVACMode.OFF + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self._controller.current_temp + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + return self._controller.desired_temp + + @property + def fan_mode(self) -> str | None: + """Return the fan setting.""" + return _ESCEA_FAN_TO_HA[self._controller.fan] + + async def wrap_and_catch(self, coro: Coroutine) -> None: + """Catch any connection errors and set unavailable.""" + try: + await coro + except ConnectionError as ex: + self.set_available(False, ex) + else: + self.set_available(True) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temp = kwargs.get(ATTR_TEMPERATURE) + if temp is not None: + await self.wrap_and_catch(self._controller.set_desired_temp(temp)) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + await self.wrap_and_catch(self._controller.set_fan(_HA_FAN_TO_ESCEA[fan_mode])) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target operation mode.""" + await self.wrap_and_catch(self._controller.set_on(hvac_mode == HVACMode.HEAT)) + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + await self.wrap_and_catch(self._controller.set_on(True)) + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + await self.wrap_and_catch(self._controller.set_on(False)) diff --git a/homeassistant/components/escea/config_flow.py b/homeassistant/components/escea/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..5d0dfea1157aeecfded57e933d0502dab34f1df3 --- /dev/null +++ b/homeassistant/components/escea/config_flow.py @@ -0,0 +1,52 @@ +"""Config flow for escea.""" +import asyncio +from contextlib import suppress +import logging + +from async_timeout import timeout + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_entry_flow +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ( + DISPATCH_CONTROLLER_DISCOVERED, + DOMAIN, + ESCEA_FIREPLACE, + TIMEOUT_DISCOVERY, +) +from .discovery import async_start_discovery_service, async_stop_discovery_service + +_LOGGER = logging.getLogger(__name__) + + +async def _async_has_devices(hass: HomeAssistant) -> bool: + + controller_ready = asyncio.Event() + + @callback + def dispatch_discovered(_): + controller_ready.set() + + remove_handler = async_dispatcher_connect( + hass, DISPATCH_CONTROLLER_DISCOVERED, dispatch_discovered + ) + + discovery_service = await async_start_discovery_service(hass) + + with suppress(asyncio.TimeoutError): + async with timeout(TIMEOUT_DISCOVERY): + await controller_ready.wait() + + remove_handler() + + if not discovery_service.controllers: + await async_stop_discovery_service(hass) + _LOGGER.debug("No controllers found") + return False + + _LOGGER.debug("Controllers %s", discovery_service.controllers) + return True + + +config_entry_flow.register_discovery_flow(DOMAIN, ESCEA_FIREPLACE, _async_has_devices) diff --git a/homeassistant/components/escea/const.py b/homeassistant/components/escea/const.py new file mode 100644 index 0000000000000000000000000000000000000000..c35e77e27198812384f0410abf2cc1c83a6acb3e --- /dev/null +++ b/homeassistant/components/escea/const.py @@ -0,0 +1,15 @@ +"""Constants used by the escea component.""" + +DOMAIN = "escea" +ESCEA_MANUFACTURER = "Escea" +ESCEA_FIREPLACE = "Escea Fireplace" +ICON = "mdi:fire" + +DATA_DISCOVERY_SERVICE = "escea_discovery" + +DISPATCH_CONTROLLER_DISCOVERED = "escea_controller_discovered" +DISPATCH_CONTROLLER_DISCONNECTED = "escea_controller_disconnected" +DISPATCH_CONTROLLER_RECONNECTED = "escea_controller_reconnected" +DISPATCH_CONTROLLER_UPDATE = "escea_controller_update" + +TIMEOUT_DISCOVERY = 20 diff --git a/homeassistant/components/escea/discovery.py b/homeassistant/components/escea/discovery.py new file mode 100644 index 0000000000000000000000000000000000000000..0d7f3024bfc2254f297d6a73bb4825d583073003 --- /dev/null +++ b/homeassistant/components/escea/discovery.py @@ -0,0 +1,75 @@ +"""Internal discovery service for Escea Fireplace.""" +from __future__ import annotations + +from pescea import ( + AbstractDiscoveryService, + Controller, + Listener, + discovery_service as pescea_discovery_service, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + DATA_DISCOVERY_SERVICE, + DISPATCH_CONTROLLER_DISCONNECTED, + DISPATCH_CONTROLLER_DISCOVERED, + DISPATCH_CONTROLLER_RECONNECTED, + DISPATCH_CONTROLLER_UPDATE, +) + + +class DiscoveryServiceListener(Listener): + """Discovery data and interfacing with pescea library.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialise discovery service.""" + super().__init__() + self.hass = hass + + # Listener interface + def controller_discovered(self, ctrl: Controller) -> None: + """Handle new controller discoverery.""" + async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_DISCOVERED, ctrl) + + def controller_disconnected(self, ctrl: Controller, ex: Exception) -> None: + """On disconnect from controller.""" + async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_DISCONNECTED, ctrl, ex) + + def controller_reconnected(self, ctrl: Controller) -> None: + """On reconnect to controller.""" + async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_RECONNECTED, ctrl) + + def controller_update(self, ctrl: Controller) -> None: + """System update message is received from the controller.""" + async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_UPDATE, ctrl) + + +async def async_start_discovery_service( + hass: HomeAssistant, +) -> AbstractDiscoveryService: + """Set up the pescea internal discovery.""" + discovery_service = hass.data.get(DATA_DISCOVERY_SERVICE) + if discovery_service: + # Already started + return discovery_service + + # discovery local services + listener = DiscoveryServiceListener(hass) + discovery_service = pescea_discovery_service(listener) + hass.data[DATA_DISCOVERY_SERVICE] = discovery_service + + await discovery_service.start_discovery() + + return discovery_service + + +async def async_stop_discovery_service(hass: HomeAssistant) -> None: + """Stop the discovery service.""" + discovery_service = hass.data.get(DATA_DISCOVERY_SERVICE) + if not discovery_service: + return + + await discovery_service.close() + del hass.data[DATA_DISCOVERY_SERVICE] diff --git a/homeassistant/components/escea/manifest.json b/homeassistant/components/escea/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..4feb28e982ca948b7c91f20c35bd9fe28ac76b3f --- /dev/null +++ b/homeassistant/components/escea/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "escea", + "name": "Escea", + "documentation": "https://www.home-assistant.io/integrations/escea", + "codeowners": ["@lazdavila"], + "requirements": ["pescea==1.0.12"], + "config_flow": true, + "homekit": { + "models": ["Escea"] + }, + "iot_class": "local_push" +} diff --git a/homeassistant/components/escea/strings.json b/homeassistant/components/escea/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..8744e74fd3f341282b10ed0d43985a46986feef5 --- /dev/null +++ b/homeassistant/components/escea/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "confirm": { + "description": "Do you want to set up an Escea fireplace?" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + } +} diff --git a/homeassistant/components/escea/translations/en.json b/homeassistant/components/escea/translations/en.json new file mode 100644 index 0000000000000000000000000000000000000000..7d4e7b6fa8ba49e01f04d6ccc9fb7507ef6b59fc --- /dev/null +++ b/homeassistant/components/escea/translations/en.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "No devices found on the network", + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "step": { + "confirm": { + "description": "Do you want to set up an Escea fireplace?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 327781c25620f3f97ced2b11defcbe521f6f18b2..597cb10fd7f8670cb97a3f57c15d70171337d2ef 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -99,6 +99,7 @@ FLOWS = { "enphase_envoy", "environment_canada", "epson", + "escea", "esphome", "evil_genius_labs", "ezviz", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 5284eef02a7146fab7a2b07f9b26f02bc70eb4a0..d59d37f4579ba1807d53cb474abfb35a09e1923e 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -426,6 +426,7 @@ HOMEKIT = { "C105X": "roku", "C135X": "roku", "EB-*": "ecobee", + "Escea": "escea", "HHKBridge*": "hive", "Healty Home Coach": "netatmo", "Iota": "abode", diff --git a/requirements_all.txt b/requirements_all.txt index 3a755023ac7ad97e8da3a515fd207e1eb8da48a4..ddf22e985c70944680d84a8080834d4286a88db5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1227,6 +1227,9 @@ peco==0.0.29 # homeassistant.components.pencom pencompy==0.0.3 +# homeassistant.components.escea +pescea==1.0.12 + # homeassistant.components.aruba # homeassistant.components.cisco_ios # homeassistant.components.pandora diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7211bb3f08afac3d1961311e1b3608ec999e67a1..a9c556032825d9bbf9ddb8b05fc9cc436403744e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -854,6 +854,9 @@ pdunehd==1.3.2 # homeassistant.components.peco peco==0.0.29 +# homeassistant.components.escea +pescea==1.0.12 + # homeassistant.components.aruba # homeassistant.components.cisco_ios # homeassistant.components.pandora diff --git a/tests/components/escea/__init__.py b/tests/components/escea/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b31616f7e584efb62bbf41eb9b14e35aee3e256f --- /dev/null +++ b/tests/components/escea/__init__.py @@ -0,0 +1 @@ +"""Escea tests.""" diff --git a/tests/components/escea/test_config_flow.py b/tests/components/escea/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..c4a5b323d221bc5d0890545dd513e80079a25847 --- /dev/null +++ b/tests/components/escea/test_config_flow.py @@ -0,0 +1,117 @@ +"""Tests for Escea.""" + +from collections.abc import Callable, Coroutine +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.escea.const import DOMAIN, ESCEA_FIREPLACE +from homeassistant.components.escea.discovery import DiscoveryServiceListener +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="mock_discovery_service") +def mock_discovery_service_fixture() -> AsyncMock: + """Mock discovery service.""" + discovery_service = AsyncMock() + discovery_service.controllers = {} + return discovery_service + + +@pytest.fixture(name="mock_controller") +def mock_controller_fixture() -> MagicMock: + """Mock controller.""" + controller = MagicMock() + return controller + + +def _mock_start_discovery( + discovery_service: MagicMock, controller: MagicMock +) -> Callable[[], Coroutine[None, None, None]]: + """Mock start discovery service.""" + + async def do_discovered() -> None: + """Call the listener callback.""" + listener: DiscoveryServiceListener = discovery_service.call_args[0][0] + listener.controller_discovered(controller) + + return do_discovered + + +async def test_not_found( + hass: HomeAssistant, mock_discovery_service: MagicMock +) -> None: + """Test not finding any Escea controllers.""" + + with patch( + "homeassistant.components.escea.discovery.pescea_discovery_service" + ) as discovery_service, patch( + "homeassistant.components.escea.config_flow.TIMEOUT_DISCOVERY", 0 + ): + discovery_service.return_value = mock_discovery_service + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Confirmation form + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "no_devices_found" + assert discovery_service.return_value.close.call_count == 1 + + +async def test_found( + hass: HomeAssistant, mock_controller: MagicMock, mock_discovery_service: AsyncMock +) -> None: + """Test finding an Escea controller.""" + mock_discovery_service.controllers["test-uid"] = mock_controller + + with patch( + "homeassistant.components.escea.async_setup_entry", + return_value=True, + ) as mock_setup, patch( + "homeassistant.components.escea.discovery.pescea_discovery_service" + ) as discovery_service: + discovery_service.return_value = mock_discovery_service + mock_discovery_service.start_discovery.side_effect = _mock_start_discovery( + discovery_service, mock_controller + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Confirmation form + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert mock_setup.call_count == 1 + + +async def test_single_instance_allowed(hass: HomeAssistant) -> None: + """Test single instance allowed.""" + config_entry = MockConfigEntry(domain=DOMAIN, title=ESCEA_FIREPLACE) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.escea.discovery.pescea_discovery_service" + ) as discovery_service: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + assert discovery_service.call_count == 0