Add Escea fireplace integration (#56039)

Co-authored-by: Teemu R. <tpr@iki.fi>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Laz 2022-08-08 19:18:42 +09:30 committed by GitHub
parent bcc2be344a
commit a1d5a4bc79
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 554 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -99,6 +99,7 @@ FLOWS = {
"enphase_envoy",
"environment_canada",
"epson",
"escea",
"esphome",
"evil_genius_labs",
"ezviz",

View File

@ -426,6 +426,7 @@ HOMEKIT = {
"C105X": "roku",
"C135X": "roku",
"EB-*": "ecobee",
"Escea": "escea",
"HHKBridge*": "hive",
"Healty Home Coach": "netatmo",
"Iota": "abode",

View File

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

View File

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

View File

@ -0,0 +1 @@
"""Escea tests."""

View File

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