Add config flow for Nuheat (#32885)
* Modernize nuheat for new climate platform * Home Assistant state now mirrors the state displayed at mynewheat.com * Remove off mode as the device does not implement and setting was not implemented anyways * Implement missing set_hvac_mode for nuheat * Now shows as unavailable when offline * Add a unique id (serial number) * Fix hvac_mode as it was really implementing hvac_action * Presets now map to the open api spec published at https://api.mynuheat.com/swagger/ * ThermostatModel: scheduleMode * Revert test cleanup as it leaves files behind. Its going to be more invasive to modernize the tests so it will have to come in a new pr * Config flow for nuheat * codeowners * Add an import test as well * remove debug
This commit is contained in:
parent
fa60e9b03b
commit
b09a9fc81a
14 changed files with 683 additions and 273 deletions
|
@ -253,6 +253,7 @@ homeassistant/components/notify/* @home-assistant/core
|
|||
homeassistant/components/notion/* @bachya
|
||||
homeassistant/components/nsw_fuel_station/* @nickw444
|
||||
homeassistant/components/nsw_rural_fire_service_feed/* @exxamalte
|
||||
homeassistant/components/nuheat/* @bdraco
|
||||
homeassistant/components/nuki/* @pvizeli
|
||||
homeassistant/components/nws/* @MatthewFlamm
|
||||
homeassistant/components/nzbget/* @chriscla
|
||||
|
|
25
homeassistant/components/nuheat/.translations/en.json
Normal file
25
homeassistant/components/nuheat/.translations/en.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"config" : {
|
||||
"error" : {
|
||||
"unknown" : "Unexpected error",
|
||||
"cannot_connect" : "Failed to connect, please try again",
|
||||
"invalid_auth" : "Invalid authentication",
|
||||
"invalid_thermostat" : "The thermostat serial number is invalid."
|
||||
},
|
||||
"title" : "NuHeat",
|
||||
"abort" : {
|
||||
"already_configured" : "The thermostat is already configured"
|
||||
},
|
||||
"step" : {
|
||||
"user" : {
|
||||
"title" : "Connect to the NuHeat",
|
||||
"description": "You will need to obtain your thermostat’s numeric serial number or ID by logging into https://MyNuHeat.com and selecting your thermostat(s).",
|
||||
"data" : {
|
||||
"username" : "Username",
|
||||
"password" : "Password",
|
||||
"serial_number" : "Serial number of the thermostat."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,16 +1,21 @@
|
|||
"""Support for NuHeat thermostats."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import nuheat
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.helpers import config_validation as cv, discovery
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import CONF_SERIAL_NUMBER, DOMAIN, PLATFORMS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "nuheat"
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
|
@ -27,16 +32,81 @@ CONFIG_SCHEMA = vol.Schema(
|
|||
)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Set up the NuHeat thermostat component."""
|
||||
conf = config[DOMAIN]
|
||||
username = conf.get(CONF_USERNAME)
|
||||
password = conf.get(CONF_PASSWORD)
|
||||
devices = conf.get(CONF_DEVICES)
|
||||
async def async_setup(hass: HomeAssistant, config: dict):
|
||||
"""Set up the NuHeat component."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
conf = config.get(DOMAIN)
|
||||
if not conf:
|
||||
return True
|
||||
|
||||
for serial_number in conf[CONF_DEVICES]:
|
||||
# Since the api currently doesn't permit fetching the serial numbers
|
||||
# and they have to be specified we create a separate config entry for
|
||||
# each serial number. This won't increase the number of http
|
||||
# requests as each thermostat has to be updated anyways.
|
||||
# This also allows us to validate that the entered valid serial
|
||||
# numbers and do not end up with a config entry where half of the
|
||||
# devices work.
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data={
|
||||
CONF_USERNAME: conf[CONF_USERNAME],
|
||||
CONF_PASSWORD: conf[CONF_PASSWORD],
|
||||
CONF_SERIAL_NUMBER: serial_number,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Set up NuHeat from a config entry."""
|
||||
|
||||
conf = entry.data
|
||||
|
||||
username = conf[CONF_USERNAME]
|
||||
password = conf[CONF_PASSWORD]
|
||||
serial_number = conf[CONF_SERIAL_NUMBER]
|
||||
|
||||
api = nuheat.NuHeat(username, password)
|
||||
api.authenticate()
|
||||
hass.data[DOMAIN] = (api, devices)
|
||||
|
||||
discovery.load_platform(hass, "climate", DOMAIN, {}, config)
|
||||
try:
|
||||
await hass.async_add_executor_job(api.authenticate)
|
||||
except requests.exceptions.Timeout:
|
||||
raise ConfigEntryNotReady
|
||||
except requests.exceptions.HTTPError as ex:
|
||||
if ex.request.status_code > 400 and ex.request.status_code < 500:
|
||||
_LOGGER.error("Failed to login to nuheat: %s", ex)
|
||||
return False
|
||||
raise ConfigEntryNotReady
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
_LOGGER.error("Failed to login to nuheat: %s", ex)
|
||||
return False
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = (api, serial_number)
|
||||
|
||||
for component in PLATFORMS:
|
||||
hass.async_create_task(
|
||||
hass.config_entries.async_forward_entry_setup(entry, component)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Unload a 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
|
||||
|
|
|
@ -3,7 +3,6 @@ from datetime import timedelta
|
|||
import logging
|
||||
|
||||
from nuheat.config import SCHEDULE_HOLD, SCHEDULE_RUN, SCHEDULE_TEMPORARY_HOLD
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate import ClimateDevice
|
||||
from homeassistant.components.climate.const import (
|
||||
|
@ -14,16 +13,10 @@ from homeassistant.components.climate.const import (
|
|||
SUPPORT_PRESET_MODE,
|
||||
SUPPORT_TARGET_TEMPERATURE,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_TEMPERATURE,
|
||||
TEMP_CELSIUS,
|
||||
TEMP_FAHRENHEIT,
|
||||
)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from . import DOMAIN
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -49,55 +42,29 @@ SCHEDULE_MODE_TO_PRESET_MODE_MAP = {
|
|||
value: key for key, value in PRESET_MODE_TO_SCHEDULE_MODE_MAP.items()
|
||||
}
|
||||
|
||||
SERVICE_RESUME_PROGRAM = "resume_program"
|
||||
|
||||
RESUME_PROGRAM_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids})
|
||||
|
||||
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up the NuHeat thermostat(s)."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
api, serial_number = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
temperature_unit = hass.config.units.temperature_unit
|
||||
api, serial_numbers = hass.data[DOMAIN]
|
||||
thermostats = [
|
||||
NuHeatThermostat(api, serial_number, temperature_unit)
|
||||
for serial_number in serial_numbers
|
||||
]
|
||||
add_entities(thermostats, True)
|
||||
thermostat = await hass.async_add_executor_job(api.get_thermostat, serial_number)
|
||||
entity = NuHeatThermostat(thermostat, temperature_unit)
|
||||
|
||||
def resume_program_set_service(service):
|
||||
"""Resume the program on the target thermostats."""
|
||||
entity_id = service.data.get(ATTR_ENTITY_ID)
|
||||
if entity_id:
|
||||
target_thermostats = [
|
||||
device for device in thermostats if device.entity_id in entity_id
|
||||
]
|
||||
else:
|
||||
target_thermostats = thermostats
|
||||
# No longer need a service as set_hvac_mode to auto does this
|
||||
# since climate 1.0 has been implemented
|
||||
|
||||
for thermostat in target_thermostats:
|
||||
thermostat.resume_program()
|
||||
|
||||
thermostat.schedule_update_ha_state(True)
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN,
|
||||
SERVICE_RESUME_PROGRAM,
|
||||
resume_program_set_service,
|
||||
schema=RESUME_PROGRAM_SCHEMA,
|
||||
)
|
||||
async_add_entities([entity], True)
|
||||
|
||||
|
||||
class NuHeatThermostat(ClimateDevice):
|
||||
"""Representation of a NuHeat Thermostat."""
|
||||
|
||||
def __init__(self, api, serial_number, temperature_unit):
|
||||
def __init__(self, thermostat, temperature_unit):
|
||||
"""Initialize the thermostat."""
|
||||
self._thermostat = api.get_thermostat(serial_number)
|
||||
self._thermostat = thermostat
|
||||
self._temperature_unit = temperature_unit
|
||||
self._force_update = False
|
||||
|
||||
|
@ -140,8 +107,9 @@ class NuHeatThermostat(ClimateDevice):
|
|||
def set_hvac_mode(self, hvac_mode):
|
||||
"""Set the system mode."""
|
||||
|
||||
# This is the same as what res
|
||||
if hvac_mode == HVAC_MODE_AUTO:
|
||||
self._thermostat.schedule_mode = SCHEDULE_RUN
|
||||
self._thermostat.resume_schedule()
|
||||
elif hvac_mode == HVAC_MODE_HEAT:
|
||||
self._thermostat.schedule_mode = SCHEDULE_HOLD
|
||||
|
||||
|
@ -251,3 +219,12 @@ class NuHeatThermostat(ClimateDevice):
|
|||
def _throttled_update(self, **kwargs):
|
||||
"""Get the latest state from the thermostat with a throttle."""
|
||||
self._thermostat.get_data()
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return the device_info of the device."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self._thermostat.serial_number)},
|
||||
"name": self._thermostat.room,
|
||||
"manufacturer": MANUFACTURER,
|
||||
}
|
||||
|
|
104
homeassistant/components/nuheat/config_flow.py
Normal file
104
homeassistant/components/nuheat/config_flow.py
Normal file
|
@ -0,0 +1,104 @@
|
|||
"""Config flow for NuHeat integration."""
|
||||
import logging
|
||||
|
||||
import nuheat
|
||||
import requests.exceptions
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries, core, exceptions
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
|
||||
from .const import CONF_SERIAL_NUMBER
|
||||
from .const import DOMAIN # pylint:disable=unused-import
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(CONF_SERIAL_NUMBER): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: core.HomeAssistant, data):
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
api = nuheat.NuHeat(data[CONF_USERNAME], data[CONF_PASSWORD])
|
||||
|
||||
try:
|
||||
await hass.async_add_executor_job(api.authenticate)
|
||||
except requests.exceptions.Timeout:
|
||||
raise CannotConnect
|
||||
except requests.exceptions.HTTPError as ex:
|
||||
if ex.request.status_code > 400 and ex.request.status_code < 500:
|
||||
raise InvalidAuth
|
||||
raise CannotConnect
|
||||
#
|
||||
# The underlying module throws a generic exception on login failure
|
||||
#
|
||||
except Exception: # pylint: disable=broad-except
|
||||
raise InvalidAuth
|
||||
|
||||
try:
|
||||
thermostat = await hass.async_add_executor_job(
|
||||
api.get_thermostat, data[CONF_SERIAL_NUMBER]
|
||||
)
|
||||
except requests.exceptions.HTTPError:
|
||||
raise InvalidThermostat
|
||||
|
||||
return {"title": thermostat.room, "serial_number": thermostat.serial_number}
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for NuHeat."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
try:
|
||||
info = await validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except InvalidThermostat:
|
||||
errors["base"] = "invalid_thermostat"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
if "base" not in errors:
|
||||
await self.async_set_unique_id(info["serial_number"])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=info["title"], data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_import(self, user_input):
|
||||
"""Handle import."""
|
||||
await self.async_set_unique_id(user_input[CONF_SERIAL_NUMBER])
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return await self.async_step_user(user_input)
|
||||
|
||||
|
||||
class CannotConnect(exceptions.HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class InvalidAuth(exceptions.HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
|
||||
|
||||
class InvalidThermostat(exceptions.HomeAssistantError):
|
||||
"""Error to indicate there is invalid thermostat."""
|
9
homeassistant/components/nuheat/const.py
Normal file
9
homeassistant/components/nuheat/const.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
"""Constants for NuHeat thermostats."""
|
||||
|
||||
DOMAIN = "nuheat"
|
||||
|
||||
PLATFORMS = ["climate"]
|
||||
|
||||
CONF_SERIAL_NUMBER = "serial_number"
|
||||
|
||||
MANUFACTURER = "NuHeat"
|
|
@ -1,8 +1,9 @@
|
|||
{
|
||||
"domain": "nuheat",
|
||||
"name": "NuHeat",
|
||||
"documentation": "https://www.home-assistant.io/integrations/nuheat",
|
||||
"requirements": ["nuheat==0.3.0"],
|
||||
"dependencies": [],
|
||||
"codeowners": []
|
||||
"domain": "nuheat",
|
||||
"name": "NuHeat",
|
||||
"documentation": "https://www.home-assistant.io/integrations/nuheat",
|
||||
"requirements": ["nuheat==0.3.0"],
|
||||
"dependencies": [],
|
||||
"codeowners": ["@bdraco"],
|
||||
"config_flow": true
|
||||
}
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
resume_program:
|
||||
description: Resume the programmed schedule.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of entities to change.
|
||||
example: 'climate.kitchen'
|
25
homeassistant/components/nuheat/strings.json
Normal file
25
homeassistant/components/nuheat/strings.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"config" : {
|
||||
"error" : {
|
||||
"unknown" : "Unexpected error",
|
||||
"cannot_connect" : "Failed to connect, please try again",
|
||||
"invalid_auth" : "Invalid authentication",
|
||||
"invalid_thermostat" : "The thermostat serial number is invalid."
|
||||
},
|
||||
"title" : "NuHeat",
|
||||
"abort" : {
|
||||
"already_configured" : "The thermostat is already configured"
|
||||
},
|
||||
"step" : {
|
||||
"user" : {
|
||||
"title" : "Connect to the NuHeat",
|
||||
"description": "You will need to obtain your thermostat’s numeric serial number or ID by logging into https://MyNuHeat.com and selecting your thermostat(s).",
|
||||
"data" : {
|
||||
"username" : "Username",
|
||||
"password" : "Password",
|
||||
"serial_number" : "Serial number of the thermostat."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -77,6 +77,7 @@ FLOWS = [
|
|||
"netatmo",
|
||||
"nexia",
|
||||
"notion",
|
||||
"nuheat",
|
||||
"opentherm_gw",
|
||||
"openuv",
|
||||
"owntracks",
|
||||
|
|
121
tests/components/nuheat/mocks.py
Normal file
121
tests/components/nuheat/mocks.py
Normal file
|
@ -0,0 +1,121 @@
|
|||
"""The test for the NuHeat thermostat module."""
|
||||
from asynctest.mock import MagicMock, Mock
|
||||
from nuheat.config import SCHEDULE_HOLD, SCHEDULE_RUN, SCHEDULE_TEMPORARY_HOLD
|
||||
|
||||
from homeassistant.components.nuheat.const import DOMAIN
|
||||
from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_USERNAME
|
||||
|
||||
|
||||
def _get_mock_thermostat_run():
|
||||
serial_number = "12345"
|
||||
thermostat = Mock(
|
||||
serial_number=serial_number,
|
||||
room="Master bathroom",
|
||||
online=True,
|
||||
heating=True,
|
||||
temperature=2222,
|
||||
celsius=22,
|
||||
fahrenheit=72,
|
||||
max_celsius=69,
|
||||
max_fahrenheit=157,
|
||||
min_celsius=5,
|
||||
min_fahrenheit=41,
|
||||
schedule_mode=SCHEDULE_RUN,
|
||||
target_celsius=22,
|
||||
target_fahrenheit=72,
|
||||
)
|
||||
|
||||
thermostat.get_data = Mock()
|
||||
thermostat.resume_schedule = Mock()
|
||||
thermostat.schedule_mode = Mock()
|
||||
return thermostat
|
||||
|
||||
|
||||
def _get_mock_thermostat_schedule_hold_unavailable():
|
||||
serial_number = "876"
|
||||
thermostat = Mock(
|
||||
serial_number=serial_number,
|
||||
room="Guest bathroom",
|
||||
online=False,
|
||||
heating=False,
|
||||
temperature=12,
|
||||
celsius=12,
|
||||
fahrenheit=102,
|
||||
max_celsius=99,
|
||||
max_fahrenheit=357,
|
||||
min_celsius=9,
|
||||
min_fahrenheit=21,
|
||||
schedule_mode=SCHEDULE_HOLD,
|
||||
target_celsius=23,
|
||||
target_fahrenheit=79,
|
||||
)
|
||||
|
||||
thermostat.get_data = Mock()
|
||||
thermostat.resume_schedule = Mock()
|
||||
thermostat.schedule_mode = Mock()
|
||||
return thermostat
|
||||
|
||||
|
||||
def _get_mock_thermostat_schedule_hold_available():
|
||||
serial_number = "876"
|
||||
thermostat = Mock(
|
||||
serial_number=serial_number,
|
||||
room="Available bathroom",
|
||||
online=True,
|
||||
heating=False,
|
||||
temperature=12,
|
||||
celsius=12,
|
||||
fahrenheit=102,
|
||||
max_celsius=99,
|
||||
max_fahrenheit=357,
|
||||
min_celsius=9,
|
||||
min_fahrenheit=21,
|
||||
schedule_mode=SCHEDULE_HOLD,
|
||||
target_celsius=23,
|
||||
target_fahrenheit=79,
|
||||
)
|
||||
|
||||
thermostat.get_data = Mock()
|
||||
thermostat.resume_schedule = Mock()
|
||||
thermostat.schedule_mode = Mock()
|
||||
return thermostat
|
||||
|
||||
|
||||
def _get_mock_thermostat_schedule_temporary_hold():
|
||||
serial_number = "999"
|
||||
thermostat = Mock(
|
||||
serial_number=serial_number,
|
||||
room="Temp bathroom",
|
||||
online=True,
|
||||
heating=False,
|
||||
temperature=14,
|
||||
celsius=13,
|
||||
fahrenheit=202,
|
||||
max_celsius=39,
|
||||
max_fahrenheit=357,
|
||||
min_celsius=3,
|
||||
min_fahrenheit=31,
|
||||
schedule_mode=SCHEDULE_TEMPORARY_HOLD,
|
||||
target_celsius=43,
|
||||
target_fahrenheit=99,
|
||||
)
|
||||
|
||||
thermostat.get_data = Mock()
|
||||
thermostat.resume_schedule = Mock()
|
||||
thermostat.schedule_mode = Mock()
|
||||
return thermostat
|
||||
|
||||
|
||||
def _get_mock_nuheat(authenticate=None, get_thermostat=None):
|
||||
nuheat_mock = MagicMock()
|
||||
type(nuheat_mock).authenticate = MagicMock()
|
||||
type(nuheat_mock).get_thermostat = MagicMock(return_value=get_thermostat)
|
||||
|
||||
return nuheat_mock
|
||||
|
||||
|
||||
def _mock_get_config():
|
||||
"""Return a default nuheat config."""
|
||||
return {
|
||||
DOMAIN: {CONF_USERNAME: "me", CONF_PASSWORD: "secret", CONF_DEVICES: [12345]}
|
||||
}
|
|
@ -1,194 +1,133 @@
|
|||
"""The test for the NuHeat thermostat module."""
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
from asynctest.mock import patch
|
||||
|
||||
from homeassistant.components.climate.const import (
|
||||
HVAC_MODE_AUTO,
|
||||
HVAC_MODE_HEAT,
|
||||
SUPPORT_PRESET_MODE,
|
||||
SUPPORT_TARGET_TEMPERATURE,
|
||||
from homeassistant.components.nuheat.const import DOMAIN
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .mocks import (
|
||||
_get_mock_nuheat,
|
||||
_get_mock_thermostat_run,
|
||||
_get_mock_thermostat_schedule_hold_available,
|
||||
_get_mock_thermostat_schedule_hold_unavailable,
|
||||
_get_mock_thermostat_schedule_temporary_hold,
|
||||
_mock_get_config,
|
||||
)
|
||||
import homeassistant.components.nuheat.climate as nuheat
|
||||
from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
|
||||
|
||||
from tests.common import get_test_home_assistant
|
||||
|
||||
SCHEDULE_HOLD = 3
|
||||
SCHEDULE_RUN = 1
|
||||
SCHEDULE_TEMPORARY_HOLD = 2
|
||||
|
||||
|
||||
class TestNuHeat(unittest.TestCase):
|
||||
"""Tests for NuHeat climate."""
|
||||
async def test_climate_thermostat_run(hass):
|
||||
"""Test a thermostat with the schedule running."""
|
||||
mock_thermostat = _get_mock_thermostat_run()
|
||||
mock_nuheat = _get_mock_nuheat(get_thermostat=mock_thermostat)
|
||||
|
||||
# pylint: disable=protected-access, no-self-use
|
||||
with patch(
|
||||
"homeassistant.components.nuheat.nuheat.NuHeat", return_value=mock_nuheat,
|
||||
):
|
||||
assert await async_setup_component(hass, DOMAIN, _mock_get_config())
|
||||
await hass.async_block_till_done()
|
||||
|
||||
def setUp(self): # pylint: disable=invalid-name
|
||||
"""Set up test variables."""
|
||||
serial_number = "12345"
|
||||
temperature_unit = "F"
|
||||
state = hass.states.get("climate.master_bathroom")
|
||||
assert state.state == "auto"
|
||||
expected_attributes = {
|
||||
"current_temperature": 22.2,
|
||||
"friendly_name": "Master bathroom",
|
||||
"hvac_action": "heating",
|
||||
"hvac_modes": ["auto", "heat"],
|
||||
"max_temp": 69.4,
|
||||
"min_temp": 5.0,
|
||||
"preset_mode": "Run Schedule",
|
||||
"preset_modes": ["Run Schedule", "Temporary Hold", "Permanent Hold"],
|
||||
"supported_features": 17,
|
||||
"temperature": 22.2,
|
||||
}
|
||||
# Only test for a subset of attributes in case
|
||||
# HA changes the implementation and a new one appears
|
||||
assert all(item in state.attributes.items() for item in expected_attributes.items())
|
||||
|
||||
thermostat = Mock(
|
||||
serial_number=serial_number,
|
||||
room="Master bathroom",
|
||||
online=True,
|
||||
heating=True,
|
||||
temperature=2222,
|
||||
celsius=22,
|
||||
fahrenheit=72,
|
||||
max_celsius=69,
|
||||
max_fahrenheit=157,
|
||||
min_celsius=5,
|
||||
min_fahrenheit=41,
|
||||
schedule_mode=SCHEDULE_RUN,
|
||||
target_celsius=22,
|
||||
target_fahrenheit=72,
|
||||
)
|
||||
|
||||
thermostat.get_data = Mock()
|
||||
thermostat.resume_schedule = Mock()
|
||||
async def test_climate_thermostat_schedule_hold_unavailable(hass):
|
||||
"""Test a thermostat with the schedule hold that is offline."""
|
||||
mock_thermostat = _get_mock_thermostat_schedule_hold_unavailable()
|
||||
mock_nuheat = _get_mock_nuheat(get_thermostat=mock_thermostat)
|
||||
|
||||
self.api = Mock()
|
||||
self.api.get_thermostat.return_value = thermostat
|
||||
with patch(
|
||||
"homeassistant.components.nuheat.nuheat.NuHeat", return_value=mock_nuheat,
|
||||
):
|
||||
assert await async_setup_component(hass, DOMAIN, _mock_get_config())
|
||||
await hass.async_block_till_done()
|
||||
|
||||
self.hass = get_test_home_assistant()
|
||||
self.thermostat = nuheat.NuHeatThermostat(
|
||||
self.api, serial_number, temperature_unit
|
||||
)
|
||||
state = hass.states.get("climate.guest_bathroom")
|
||||
|
||||
def tearDown(self): # pylint: disable=invalid-name
|
||||
"""Stop hass."""
|
||||
self.hass.stop()
|
||||
assert state.state == "unavailable"
|
||||
expected_attributes = {
|
||||
"friendly_name": "Guest bathroom",
|
||||
"hvac_modes": ["auto", "heat"],
|
||||
"max_temp": 180.6,
|
||||
"min_temp": -6.1,
|
||||
"preset_modes": ["Run Schedule", "Temporary Hold", "Permanent Hold"],
|
||||
"supported_features": 17,
|
||||
}
|
||||
# Only test for a subset of attributes in case
|
||||
# HA changes the implementation and a new one appears
|
||||
assert all(item in state.attributes.items() for item in expected_attributes.items())
|
||||
|
||||
@patch("homeassistant.components.nuheat.climate.NuHeatThermostat")
|
||||
def test_setup_platform(self, mocked_thermostat):
|
||||
"""Test setup_platform."""
|
||||
mocked_thermostat.return_value = self.thermostat
|
||||
thermostat = mocked_thermostat(self.api, "12345", "F")
|
||||
thermostats = [thermostat]
|
||||
|
||||
self.hass.data[nuheat.DOMAIN] = (self.api, ["12345"])
|
||||
async def test_climate_thermostat_schedule_hold_available(hass):
|
||||
"""Test a thermostat with the schedule hold that is online."""
|
||||
mock_thermostat = _get_mock_thermostat_schedule_hold_available()
|
||||
mock_nuheat = _get_mock_nuheat(get_thermostat=mock_thermostat)
|
||||
|
||||
config = {}
|
||||
add_entities = Mock()
|
||||
discovery_info = {}
|
||||
with patch(
|
||||
"homeassistant.components.nuheat.nuheat.NuHeat", return_value=mock_nuheat,
|
||||
):
|
||||
assert await async_setup_component(hass, DOMAIN, _mock_get_config())
|
||||
await hass.async_block_till_done()
|
||||
|
||||
nuheat.setup_platform(self.hass, config, add_entities, discovery_info)
|
||||
add_entities.assert_called_once_with(thermostats, True)
|
||||
state = hass.states.get("climate.available_bathroom")
|
||||
|
||||
@patch("homeassistant.components.nuheat.climate.NuHeatThermostat")
|
||||
def test_resume_program_service(self, mocked_thermostat):
|
||||
"""Test resume program service."""
|
||||
mocked_thermostat.return_value = self.thermostat
|
||||
thermostat = mocked_thermostat(self.api, "12345", "F")
|
||||
thermostat.resume_program = Mock()
|
||||
thermostat.schedule_update_ha_state = Mock()
|
||||
thermostat.entity_id = "climate.master_bathroom"
|
||||
assert state.state == "auto"
|
||||
expected_attributes = {
|
||||
"current_temperature": 38.9,
|
||||
"friendly_name": "Available bathroom",
|
||||
"hvac_action": "idle",
|
||||
"hvac_modes": ["auto", "heat"],
|
||||
"max_temp": 180.6,
|
||||
"min_temp": -6.1,
|
||||
"preset_mode": "Run Schedule",
|
||||
"preset_modes": ["Run Schedule", "Temporary Hold", "Permanent Hold"],
|
||||
"supported_features": 17,
|
||||
"temperature": 26.1,
|
||||
}
|
||||
# Only test for a subset of attributes in case
|
||||
# HA changes the implementation and a new one appears
|
||||
assert all(item in state.attributes.items() for item in expected_attributes.items())
|
||||
|
||||
self.hass.data[nuheat.DOMAIN] = (self.api, ["12345"])
|
||||
nuheat.setup_platform(self.hass, {}, Mock(), {})
|
||||
|
||||
# Explicit entity
|
||||
self.hass.services.call(
|
||||
nuheat.DOMAIN,
|
||||
nuheat.SERVICE_RESUME_PROGRAM,
|
||||
{"entity_id": "climate.master_bathroom"},
|
||||
True,
|
||||
)
|
||||
async def test_climate_thermostat_schedule_temporary_hold(hass):
|
||||
"""Test a thermostat with the temporary schedule hold that is online."""
|
||||
mock_thermostat = _get_mock_thermostat_schedule_temporary_hold()
|
||||
mock_nuheat = _get_mock_nuheat(get_thermostat=mock_thermostat)
|
||||
|
||||
thermostat.resume_program.assert_called_with()
|
||||
thermostat.schedule_update_ha_state.assert_called_with(True)
|
||||
with patch(
|
||||
"homeassistant.components.nuheat.nuheat.NuHeat", return_value=mock_nuheat,
|
||||
):
|
||||
assert await async_setup_component(hass, DOMAIN, _mock_get_config())
|
||||
await hass.async_block_till_done()
|
||||
|
||||
thermostat.resume_program.reset_mock()
|
||||
thermostat.schedule_update_ha_state.reset_mock()
|
||||
state = hass.states.get("climate.temp_bathroom")
|
||||
|
||||
# All entities
|
||||
self.hass.services.call(nuheat.DOMAIN, nuheat.SERVICE_RESUME_PROGRAM, {}, True)
|
||||
|
||||
thermostat.resume_program.assert_called_with()
|
||||
thermostat.schedule_update_ha_state.assert_called_with(True)
|
||||
|
||||
def test_name(self):
|
||||
"""Test name property."""
|
||||
assert self.thermostat.name == "Master bathroom"
|
||||
|
||||
def test_supported_features(self):
|
||||
"""Test name property."""
|
||||
features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE
|
||||
assert self.thermostat.supported_features == features
|
||||
|
||||
def test_temperature_unit(self):
|
||||
"""Test temperature unit."""
|
||||
assert self.thermostat.temperature_unit == TEMP_FAHRENHEIT
|
||||
self.thermostat._temperature_unit = "C"
|
||||
assert self.thermostat.temperature_unit == TEMP_CELSIUS
|
||||
|
||||
def test_current_temperature(self):
|
||||
"""Test current temperature."""
|
||||
assert self.thermostat.current_temperature == 72
|
||||
self.thermostat._temperature_unit = "C"
|
||||
assert self.thermostat.current_temperature == 22
|
||||
|
||||
def test_current_operation(self):
|
||||
"""Test requested mode."""
|
||||
assert self.thermostat.hvac_mode == HVAC_MODE_AUTO
|
||||
|
||||
def test_min_temp(self):
|
||||
"""Test min temp."""
|
||||
assert self.thermostat.min_temp == 41
|
||||
self.thermostat._temperature_unit = "C"
|
||||
assert self.thermostat.min_temp == 5
|
||||
|
||||
def test_max_temp(self):
|
||||
"""Test max temp."""
|
||||
assert self.thermostat.max_temp == 157
|
||||
self.thermostat._temperature_unit = "C"
|
||||
assert self.thermostat.max_temp == 69
|
||||
|
||||
def test_target_temperature(self):
|
||||
"""Test target temperature."""
|
||||
assert self.thermostat.target_temperature == 72
|
||||
self.thermostat._temperature_unit = "C"
|
||||
assert self.thermostat.target_temperature == 22
|
||||
|
||||
def test_operation_list(self):
|
||||
"""Test the operation list."""
|
||||
assert self.thermostat.hvac_modes == [HVAC_MODE_AUTO, HVAC_MODE_HEAT]
|
||||
|
||||
def test_resume_program(self):
|
||||
"""Test resume schedule."""
|
||||
self.thermostat.resume_program()
|
||||
self.thermostat._thermostat.resume_schedule.assert_called_once_with()
|
||||
assert self.thermostat._force_update
|
||||
|
||||
def test_set_temperature(self):
|
||||
"""Test set temperature."""
|
||||
self.thermostat.set_temperature(temperature=85)
|
||||
assert self.thermostat._thermostat.target_fahrenheit == 85
|
||||
assert self.thermostat._force_update
|
||||
|
||||
self.thermostat._temperature_unit = "C"
|
||||
self.thermostat.set_temperature(temperature=23)
|
||||
assert self.thermostat._thermostat.target_celsius == 23
|
||||
assert self.thermostat._force_update
|
||||
|
||||
@patch.object(nuheat.NuHeatThermostat, "_throttled_update")
|
||||
def test_update_without_throttle(self, throttled_update):
|
||||
"""Test update without throttle."""
|
||||
self.thermostat._force_update = True
|
||||
self.thermostat.update()
|
||||
throttled_update.assert_called_once_with(no_throttle=True)
|
||||
assert not self.thermostat._force_update
|
||||
|
||||
@patch.object(nuheat.NuHeatThermostat, "_throttled_update")
|
||||
def test_update_with_throttle(self, throttled_update):
|
||||
"""Test update with throttle."""
|
||||
self.thermostat._force_update = False
|
||||
self.thermostat.update()
|
||||
throttled_update.assert_called_once_with()
|
||||
assert not self.thermostat._force_update
|
||||
|
||||
def test_throttled_update(self):
|
||||
"""Test update with throttle."""
|
||||
self.thermostat._throttled_update()
|
||||
self.thermostat._thermostat.get_data.assert_called_once_with()
|
||||
assert state.state == "auto"
|
||||
expected_attributes = {
|
||||
"current_temperature": 94.4,
|
||||
"friendly_name": "Temp bathroom",
|
||||
"hvac_action": "idle",
|
||||
"hvac_modes": ["auto", "heat"],
|
||||
"max_temp": 180.6,
|
||||
"min_temp": -0.6,
|
||||
"preset_mode": "Run Schedule",
|
||||
"preset_modes": ["Run Schedule", "Temporary Hold", "Permanent Hold"],
|
||||
"supported_features": 17,
|
||||
"temperature": 37.2,
|
||||
}
|
||||
# Only test for a subset of attributes in case
|
||||
# HA changes the implementation and a new one appears
|
||||
assert all(item in state.attributes.items() for item in expected_attributes.items())
|
||||
|
|
163
tests/components/nuheat/test_config_flow.py
Normal file
163
tests/components/nuheat/test_config_flow.py
Normal file
|
@ -0,0 +1,163 @@
|
|||
"""Test the NuHeat config flow."""
|
||||
from asynctest import patch
|
||||
import requests
|
||||
|
||||
from homeassistant import config_entries, setup
|
||||
from homeassistant.components.nuheat.const import CONF_SERIAL_NUMBER, DOMAIN
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
|
||||
from .mocks import _get_mock_thermostat_run
|
||||
|
||||
|
||||
async def test_form_user(hass):
|
||||
"""Test we get the form with user source."""
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
mock_thermostat = _get_mock_thermostat_run()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.nuheat.config_flow.nuheat.NuHeat.authenticate",
|
||||
return_value=True,
|
||||
), patch(
|
||||
"homeassistant.components.nuheat.config_flow.nuheat.NuHeat.get_thermostat",
|
||||
return_value=mock_thermostat,
|
||||
), patch(
|
||||
"homeassistant.components.nuheat.async_setup", return_value=True
|
||||
) as mock_setup, patch(
|
||||
"homeassistant.components.nuheat.async_setup_entry", return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_SERIAL_NUMBER: "12345",
|
||||
CONF_USERNAME: "test-username",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] == "create_entry"
|
||||
assert result2["title"] == "Master bathroom"
|
||||
assert result2["data"] == {
|
||||
CONF_SERIAL_NUMBER: "12345",
|
||||
CONF_USERNAME: "test-username",
|
||||
CONF_PASSWORD: "test-password",
|
||||
}
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_import(hass):
|
||||
"""Test we get the form with import source."""
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
|
||||
mock_thermostat = _get_mock_thermostat_run()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.nuheat.config_flow.nuheat.NuHeat.authenticate",
|
||||
return_value=True,
|
||||
), patch(
|
||||
"homeassistant.components.nuheat.config_flow.nuheat.NuHeat.get_thermostat",
|
||||
return_value=mock_thermostat,
|
||||
), patch(
|
||||
"homeassistant.components.nuheat.async_setup", return_value=True
|
||||
) as mock_setup, patch(
|
||||
"homeassistant.components.nuheat.async_setup_entry", return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data={
|
||||
CONF_SERIAL_NUMBER: "12345",
|
||||
CONF_USERNAME: "test-username",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["title"] == "Master bathroom"
|
||||
assert result["data"] == {
|
||||
CONF_SERIAL_NUMBER: "12345",
|
||||
CONF_USERNAME: "test-username",
|
||||
CONF_PASSWORD: "test-password",
|
||||
}
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_invalid_auth(hass):
|
||||
"""Test we handle invalid auth."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.nuheat.config_flow.nuheat.NuHeat.authenticate",
|
||||
side_effect=Exception,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_SERIAL_NUMBER: "12345",
|
||||
CONF_USERNAME: "test-username",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] == "form"
|
||||
assert result2["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
|
||||
async def test_form_invalid_thermostat(hass):
|
||||
"""Test we handle invalid thermostats."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.nuheat.config_flow.nuheat.NuHeat.authenticate",
|
||||
return_value=True,
|
||||
), patch(
|
||||
"homeassistant.components.nuheat.config_flow.nuheat.NuHeat.get_thermostat",
|
||||
side_effect=requests.exceptions.HTTPError,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_SERIAL_NUMBER: "12345",
|
||||
CONF_USERNAME: "test-username",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] == "form"
|
||||
assert result2["errors"] == {"base": "invalid_thermostat"}
|
||||
|
||||
|
||||
async def test_form_cannot_connect(hass):
|
||||
"""Test we handle cannot connect error."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.nuheat.config_flow.nuheat.NuHeat.authenticate",
|
||||
side_effect=requests.exceptions.Timeout,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_SERIAL_NUMBER: "12345",
|
||||
CONF_USERNAME: "test-username",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] == "form"
|
||||
assert result2["errors"] == {"base": "cannot_connect"}
|
|
@ -1,43 +1,23 @@
|
|||
"""NuHeat component tests."""
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.components import nuheat
|
||||
from homeassistant.components.nuheat.const import DOMAIN
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockDependency, get_test_home_assistant
|
||||
from .mocks import _get_mock_nuheat
|
||||
|
||||
VALID_CONFIG = {
|
||||
"nuheat": {"username": "warm", "password": "feet", "devices": "thermostat123"}
|
||||
}
|
||||
INVALID_CONFIG = {"nuheat": {"username": "warm", "password": "feet"}}
|
||||
|
||||
|
||||
class TestNuHeat(unittest.TestCase):
|
||||
"""Test the NuHeat component."""
|
||||
async def test_init_success(hass):
|
||||
"""Test that we can setup with valid config."""
|
||||
mock_nuheat = _get_mock_nuheat()
|
||||
|
||||
def setUp(self): # pylint: disable=invalid-name
|
||||
"""Initialize the values for this test class."""
|
||||
self.hass = get_test_home_assistant()
|
||||
self.config = VALID_CONFIG
|
||||
|
||||
def tearDown(self): # pylint: disable=invalid-name
|
||||
"""Teardown this test class. Stop hass."""
|
||||
self.hass.stop()
|
||||
|
||||
@MockDependency("nuheat")
|
||||
@patch("homeassistant.helpers.discovery.load_platform")
|
||||
def test_setup(self, mocked_nuheat, mocked_load):
|
||||
"""Test setting up the NuHeat component."""
|
||||
with patch.object(nuheat, "nuheat", mocked_nuheat):
|
||||
nuheat.setup(self.hass, self.config)
|
||||
|
||||
mocked_nuheat.NuHeat.assert_called_with("warm", "feet")
|
||||
assert nuheat.DOMAIN in self.hass.data
|
||||
assert len(self.hass.data[nuheat.DOMAIN]) == 2
|
||||
assert isinstance(
|
||||
self.hass.data[nuheat.DOMAIN][0], type(mocked_nuheat.NuHeat())
|
||||
)
|
||||
assert self.hass.data[nuheat.DOMAIN][1] == "thermostat123"
|
||||
|
||||
mocked_load.assert_called_with(
|
||||
self.hass, "climate", nuheat.DOMAIN, {}, self.config
|
||||
)
|
||||
with patch(
|
||||
"homeassistant.components.nuheat.nuheat.NuHeat", return_value=mock_nuheat,
|
||||
):
|
||||
assert await async_setup_component(hass, DOMAIN, VALID_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
|
|
Loading…
Add table
Reference in a new issue