Add Atag One thermostat integration (#32361)

* add atag integration

* ignore

* generated

* Update .gitignore

* requirements update

* update coveragerc

* Revert "update coveragerc"

* make entity_types more readable

* add DOMAIN to listener

* entity name

* Use DataUpdateCoordinator

* fix translations

* enable preset_modes

* fix water_heater

* update coveragerc

* remove scan_interval

Co-Authored-By: J. Nick Koston <nick@koston.org>

* Apply suggestions from code review

Co-Authored-By: Martin Hjelmare <marhje52@gmail.com>

* fixes review remarks

* fix flake8 errors

* ensure correct HVACmode

* add away mode

* use write_ha_state instead of refresh

* remove OFF support, add Fahrenheit

* rename test_config_flow.py

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
MatsNl 2020-04-22 18:09:51 +02:00 committed by GitHub
parent 4a08c65205
commit ccc3ce81f9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 644 additions and 0 deletions

View file

@ -50,6 +50,10 @@ omit =
homeassistant/components/arwn/sensor.py homeassistant/components/arwn/sensor.py
homeassistant/components/asterisk_cdr/mailbox.py homeassistant/components/asterisk_cdr/mailbox.py
homeassistant/components/asterisk_mbox/* homeassistant/components/asterisk_mbox/*
homeassistant/components/atag/__init__.py
homeassistant/components/atag/climate.py
homeassistant/components/atag/sensor.py
homeassistant/components/atag/water_heater.py
homeassistant/components/aten_pe/* homeassistant/components/aten_pe/*
homeassistant/components/atome/* homeassistant/components/atome/*
homeassistant/components/aurora_abb_powerone/sensor.py homeassistant/components/aurora_abb_powerone/sensor.py

View file

@ -35,6 +35,7 @@ homeassistant/components/arduino/* @fabaff
homeassistant/components/arest/* @fabaff homeassistant/components/arest/* @fabaff
homeassistant/components/arris_tg2492lg/* @vanbalken homeassistant/components/arris_tg2492lg/* @vanbalken
homeassistant/components/asuswrt/* @kennedyshead homeassistant/components/asuswrt/* @kennedyshead
homeassistant/components/atag/* @MatsNL
homeassistant/components/aten_pe/* @mtdcr homeassistant/components/aten_pe/* @mtdcr
homeassistant/components/atome/* @baqs homeassistant/components/atome/* @baqs
homeassistant/components/august/* @bdraco homeassistant/components/august/* @bdraco

View file

@ -0,0 +1,20 @@
{
"title": "Atag",
"config": {
"step": {
"user": {
"title": "Connect to the device",
"data": {
"host": "Host",
"port": "Port (10000)"
}
}
},
"error": {
"connection_error": "Failed to connect, please try again"
},
"abort": {
"already_configured": "Only one Atag device can be added to Home Assistant"
}
}
}

View file

@ -0,0 +1,259 @@
"""The ATAG Integration."""
from datetime import timedelta
import logging
import async_timeout
from pyatag import AtagDataStore, AtagException
from homeassistant.components.climate import DOMAIN as CLIMATE
from homeassistant.components.sensor import DOMAIN as SENSOR
from homeassistant.components.water_heater import DOMAIN as WATER_HEATER
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_ICON,
ATTR_ID,
ATTR_MODE,
ATTR_NAME,
ATTR_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_TEMPERATURE,
PRESSURE_BAR,
TEMP_CELSIUS,
)
from homeassistant.core import HomeAssistant, asyncio
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
_LOGGER = logging.getLogger(__name__)
DOMAIN = "atag"
DATA_LISTENER = f"{DOMAIN}_listener"
SIGNAL_UPDATE_ATAG = f"{DOMAIN}_update"
PLATFORMS = [CLIMATE, WATER_HEATER, SENSOR]
HOUR = "h"
FIRE = "fire"
PERCENTAGE = "%"
ICONS = {
TEMP_CELSIUS: "mdi:thermometer",
PRESSURE_BAR: "mdi:gauge",
FIRE: "mdi:fire",
ATTR_MODE: "mdi:settings",
}
ENTITY_TYPES = {
SENSOR: [
{
ATTR_NAME: "Outside Temperature",
ATTR_ID: "outside_temp",
ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS,
ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
ATTR_ICON: ICONS[TEMP_CELSIUS],
},
{
ATTR_NAME: "Average Outside Temperature",
ATTR_ID: "tout_avg",
ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS,
ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
ATTR_ICON: ICONS[TEMP_CELSIUS],
},
{
ATTR_NAME: "Weather Status",
ATTR_ID: "weather_status",
ATTR_UNIT_OF_MEASUREMENT: None,
ATTR_DEVICE_CLASS: None,
ATTR_ICON: None,
},
{
ATTR_NAME: "CH Water Pressure",
ATTR_ID: "ch_water_pres",
ATTR_UNIT_OF_MEASUREMENT: PRESSURE_BAR,
ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE,
ATTR_ICON: ICONS[PRESSURE_BAR],
},
{
ATTR_NAME: "CH Water Temperature",
ATTR_ID: "ch_water_temp",
ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS,
ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
ATTR_ICON: ICONS[TEMP_CELSIUS],
},
{
ATTR_NAME: "CH Return Temperature",
ATTR_ID: "ch_return_temp",
ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS,
ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
ATTR_ICON: ICONS[TEMP_CELSIUS],
},
{
ATTR_NAME: "Burning Hours",
ATTR_ID: "burning_hours",
ATTR_UNIT_OF_MEASUREMENT: HOUR,
ATTR_DEVICE_CLASS: None,
ATTR_ICON: ICONS[FIRE],
},
{
ATTR_NAME: "Flame",
ATTR_ID: "rel_mod_level",
ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE,
ATTR_DEVICE_CLASS: None,
ATTR_ICON: ICONS[FIRE],
},
],
CLIMATE: {
ATTR_NAME: DOMAIN.title(),
ATTR_ID: CLIMATE,
ATTR_UNIT_OF_MEASUREMENT: None,
ATTR_DEVICE_CLASS: None,
ATTR_ICON: None,
},
WATER_HEATER: {
ATTR_NAME: DOMAIN.title(),
ATTR_ID: WATER_HEATER,
ATTR_UNIT_OF_MEASUREMENT: None,
ATTR_DEVICE_CLASS: None,
ATTR_ICON: None,
},
}
async def async_setup(hass: HomeAssistant, config):
"""Set up the Atag component."""
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Atag integration from a config entry."""
session = async_get_clientsession(hass)
coordinator = AtagDataUpdateCoordinator(hass, session, entry)
await coordinator.async_refresh()
if not coordinator.last_update_success:
raise ConfigEntryNotReady
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator
for platform in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, platform)
)
return True
class AtagDataUpdateCoordinator(DataUpdateCoordinator):
"""Define an object to hold Atag data."""
def __init__(self, hass, session, entry):
"""Initialize."""
self.atag = AtagDataStore(session, paired=True, **entry.data)
super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=timedelta(seconds=30)
)
async def _async_update_data(self):
"""Update data via library."""
with async_timeout.timeout(20):
try:
await self.atag.async_update()
except (AtagException) as error:
raise UpdateFailed(error)
return self.atag.sensordata
async def async_unload_entry(hass, entry):
"""Unload Atag config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
)
)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
class AtagEntity(Entity):
"""Defines a base Atag entity."""
def __init__(self, coordinator: AtagDataUpdateCoordinator, atag_type: dict) -> None:
"""Initialize the Atag entity."""
self.coordinator = coordinator
self._id = atag_type[ATTR_ID]
self._name = atag_type[ATTR_NAME]
self._icon = atag_type[ATTR_ICON]
self._unit = atag_type[ATTR_UNIT_OF_MEASUREMENT]
self._class = atag_type[ATTR_DEVICE_CLASS]
@property
def device_info(self) -> dict:
"""Return info for device registry."""
device = self.coordinator.atag.device
version = self.coordinator.atag.apiversion
return {
"identifiers": {(DOMAIN, device)},
ATTR_NAME: "Atag Thermostat",
"model": "Atag One",
"sw_version": version,
"manufacturer": "Atag",
}
@property
def name(self) -> str:
"""Return the name of the entity."""
return self._name
@property
def icon(self) -> str:
"""Return the mdi icon of the entity."""
self._icon = (
self.coordinator.data.get(self._id, {}).get(ATTR_ICON) or self._icon
)
return self._icon
@property
def should_poll(self) -> bool:
"""Return the polling requirement of the entity."""
return False
@property
def unit_of_measurement(self):
"""Return the unit of measurement of this entity, if any."""
return self._unit
@property
def device_class(self):
"""Return the device class."""
return self._class
@property
def available(self):
"""Return True if entity is available."""
return self.coordinator.last_update_success
@property
def unique_id(self):
"""Return a unique ID to use for this entity."""
return f"{self.coordinator.atag.device}-{self._id}"
async def async_added_to_hass(self):
"""Connect to dispatcher listening for entity data notifications."""
self.async_on_remove(
self.coordinator.async_add_listener(self.async_write_ha_state)
)
async def async_update(self):
"""Update Atag entity."""
await self.coordinator.async_request_refresh()

View file

@ -0,0 +1,106 @@
"""Initialization of ATAG One climate platform."""
from typing import List, Optional
from homeassistant.components.climate import ClimateDevice
from homeassistant.components.climate.const import (
CURRENT_HVAC_HEAT,
CURRENT_HVAC_IDLE,
HVAC_MODE_AUTO,
HVAC_MODE_HEAT,
PRESET_AWAY,
PRESET_BOOST,
SUPPORT_PRESET_MODE,
SUPPORT_TARGET_TEMPERATURE,
)
from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT
from . import CLIMATE, DOMAIN, ENTITY_TYPES, AtagEntity
PRESET_SCHEDULE = "Auto"
PRESET_MANUAL = "Manual"
PRESET_EXTEND = "Extend"
SUPPORT_PRESET = [
PRESET_MANUAL,
PRESET_SCHEDULE,
PRESET_EXTEND,
PRESET_AWAY,
PRESET_BOOST,
]
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE
HVAC_MODES = [HVAC_MODE_AUTO, HVAC_MODE_HEAT]
async def async_setup_entry(hass, entry, async_add_entities):
"""Load a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([AtagThermostat(coordinator, ENTITY_TYPES[CLIMATE])])
class AtagThermostat(AtagEntity, ClimateDevice):
"""Atag climate device."""
@property
def supported_features(self):
"""Return the list of supported features."""
return SUPPORT_FLAGS
@property
def hvac_mode(self) -> Optional[str]:
"""Return hvac operation ie. heat, cool mode."""
if self.coordinator.atag.hvac_mode in HVAC_MODES:
return self.coordinator.atag.hvac_mode
return None
@property
def hvac_modes(self) -> List[str]:
"""Return the list of available hvac operation modes."""
return HVAC_MODES
@property
def hvac_action(self) -> Optional[str]:
"""Return the current running hvac operation."""
if self.coordinator.atag.cv_status:
return CURRENT_HVAC_HEAT
return CURRENT_HVAC_IDLE
@property
def temperature_unit(self):
"""Return the unit of measurement."""
if self.coordinator.atag.temp_unit in [TEMP_CELSIUS, TEMP_FAHRENHEIT]:
return self.coordinator.atag.temp_unit
return None
@property
def current_temperature(self) -> Optional[float]:
"""Return the current temperature."""
return self.coordinator.atag.temperature
@property
def target_temperature(self) -> Optional[float]:
"""Return the temperature we try to reach."""
return self.coordinator.atag.target_temperature
@property
def preset_mode(self) -> Optional[str]:
"""Return the current preset mode, e.g., auto, manual, fireplace, extend, etc."""
return self.coordinator.atag.hold_mode
@property
def preset_modes(self) -> Optional[List[str]]:
"""Return a list of available preset modes."""
return SUPPORT_PRESET
async def async_set_temperature(self, **kwargs) -> None:
"""Set new target temperature."""
await self.coordinator.atag.set_temp(kwargs.get(ATTR_TEMPERATURE))
self.async_write_ha_state()
async def async_set_hvac_mode(self, hvac_mode: str) -> None:
"""Set new target hvac mode."""
await self.coordinator.atag.set_hvac_mode(hvac_mode)
self.async_write_ha_state()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
await self.coordinator.atag.set_hold_mode(preset_mode)
self.async_write_ha_state()

View file

@ -0,0 +1,50 @@
"""Config flow for the Atag component."""
from pyatag import DEFAULT_PORT, AtagDataStore, AtagException
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from . import DOMAIN # pylint: disable=unused-import
DATA_SCHEMA = {
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.Coerce(int),
}
class AtagConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for Atag."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
if self._async_current_entries():
return self.async_abort(reason="already_configured")
if not user_input:
return await self._show_form()
session = async_get_clientsession(self.hass)
try:
atag = AtagDataStore(session, **user_input)
await atag.async_check_pair_status()
except AtagException:
return await self._show_form({"base": "connection_error"})
user_input.update({CONF_DEVICE: atag.device})
return self.async_create_entry(title=atag.device, data=user_input)
@callback
async def _show_form(self, errors=None):
"""Show the form to the user."""
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(DATA_SCHEMA),
errors=errors if errors else {},
)

View file

@ -0,0 +1,8 @@
{
"domain": "atag",
"name": "Atag",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/atag/",
"requirements": ["pyatag==0.2.18"],
"codeowners": ["@MatsNL"]
}

View file

@ -0,0 +1,22 @@
"""Initialization of ATAG One sensor platform."""
from homeassistant.const import ATTR_STATE
from . import DOMAIN, ENTITY_TYPES, SENSOR, AtagEntity
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Initialize sensor platform from config entry."""
coordinator = hass.data[DOMAIN][config_entry.entry_id]
entities = []
for sensor in ENTITY_TYPES[SENSOR]:
entities.append(AtagSensor(coordinator, sensor))
async_add_entities(entities)
class AtagSensor(AtagEntity):
"""Representation of a AtagOne Sensor."""
@property
def state(self):
"""Return the state of the sensor."""
return self.coordinator.data[self._id][ATTR_STATE]

View file

@ -0,0 +1,20 @@
{
"title": "Atag",
"config": {
"step": {
"user": {
"title": "Connect to the device",
"data": {
"host": "Host",
"port": "Port (10000)"
}
}
},
"error": {
"connection_error": "Failed to connect, please try again"
},
"abort": {
"already_configured": "Only one Atag device can be added to Home Assistant"
}
}
}

View file

@ -0,0 +1,70 @@
"""ATAG water heater component."""
from homeassistant.components.water_heater import (
ATTR_TEMPERATURE,
STATE_ECO,
STATE_PERFORMANCE,
WaterHeaterDevice,
)
from homeassistant.const import STATE_OFF, TEMP_CELSIUS
from . import DOMAIN, ENTITY_TYPES, WATER_HEATER, AtagEntity
SUPPORT_FLAGS_HEATER = 0
OPERATION_LIST = [STATE_OFF, STATE_ECO, STATE_PERFORMANCE]
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Initialize DHW device from config entry."""
coordinator = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities([AtagWaterHeater(coordinator, ENTITY_TYPES[WATER_HEATER])])
class AtagWaterHeater(AtagEntity, WaterHeaterDevice):
"""Representation of an ATAG water heater."""
@property
def supported_features(self):
"""Return the list of supported features."""
return SUPPORT_FLAGS_HEATER
@property
def temperature_unit(self):
"""Return the unit of measurement."""
return TEMP_CELSIUS
@property
def current_temperature(self):
"""Return the current temperature."""
return self.coordinator.atag.dhw_temperature
@property
def current_operation(self):
"""Return current operation."""
if self.coordinator.atag.dhw_status:
return STATE_PERFORMANCE
return STATE_OFF
@property
def operation_list(self):
"""List of available operation modes."""
return OPERATION_LIST
async def set_temperature(self, **kwargs):
"""Set new target temperature."""
if await self.coordinator.atag.dhw_set_temp(kwargs.get(ATTR_TEMPERATURE)):
self.async_write_ha_state()
@property
def target_temperature(self):
"""Return the setpoint if water demand, otherwise return base temp (comfort level)."""
return self.coordinator.atag.dhw_target_temperature
@property
def max_temp(self):
"""Return the maximum temperature."""
return self.coordinator.atag.dhw_max_temp
@property
def min_temp(self):
"""Return the minimum temperature."""
return self.coordinator.atag.dhw_min_temp

View file

@ -13,6 +13,7 @@ FLOWS = [
"almond", "almond",
"ambiclimate", "ambiclimate",
"ambient_station", "ambient_station",
"atag",
"august", "august",
"axis", "axis",
"braviatv", "braviatv",

View file

@ -1181,6 +1181,9 @@ pyalmond==0.0.2
# homeassistant.components.arlo # homeassistant.components.arlo
pyarlo==0.2.3 pyarlo==0.2.3
# homeassistant.components.atag
pyatag==0.2.18
# homeassistant.components.netatmo # homeassistant.components.netatmo
pyatmo==3.3.0 pyatmo==3.3.0

View file

@ -478,6 +478,9 @@ pyalmond==0.0.2
# homeassistant.components.arlo # homeassistant.components.arlo
pyarlo==0.2.3 pyarlo==0.2.3
# homeassistant.components.atag
pyatag==0.2.18
# homeassistant.components.netatmo # homeassistant.components.netatmo
pyatmo==3.3.0 pyatmo==3.3.0

View file

@ -0,0 +1 @@
"""Tests for the Atag component."""

View file

@ -0,0 +1,76 @@
"""Tests for the Atag config flow."""
from unittest.mock import PropertyMock
from asynctest import patch
from pyatag import AtagException
from homeassistant import config_entries, data_entry_flow
from homeassistant.components.atag import DOMAIN
from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT
from tests.common import MockConfigEntry
FIXTURE_USER_INPUT = {
CONF_HOST: "127.0.0.1",
CONF_PORT: 10000,
}
FIXTURE_COMPLETE_ENTRY = FIXTURE_USER_INPUT.copy()
FIXTURE_COMPLETE_ENTRY[CONF_DEVICE] = "device_identifier"
async def test_show_form(hass):
"""Test that the form is served with no input."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
async def test_one_config_allowed(hass):
"""Test that only one Atag configuration is allowed."""
MockConfigEntry(domain="atag", data=FIXTURE_USER_INPUT).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
async def test_connection_error(hass):
"""Test we show user form on Atag connection error."""
with patch(
"homeassistant.components.atag.config_flow.AtagDataStore.async_check_pair_status",
side_effect=AtagException(),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data=FIXTURE_USER_INPUT,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "connection_error"}
async def test_full_flow_implementation(hass):
"""Test registering an integration and finishing flow works."""
with patch(
"homeassistant.components.atag.AtagDataStore.async_check_pair_status",
), patch(
"homeassistant.components.atag.AtagDataStore.device",
new_callable=PropertyMock(return_value="device_identifier"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data=FIXTURE_USER_INPUT,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == FIXTURE_COMPLETE_ENTRY[CONF_DEVICE]
assert result["data"] == FIXTURE_COMPLETE_ENTRY