diff --git a/.coveragerc b/.coveragerc index a218c812df43f28944b3d7ce1446db6dc9a79c3c..14a731498b9b6cbe080719b8892a439c9b6e93da 100644 --- a/.coveragerc +++ b/.coveragerc @@ -609,6 +609,7 @@ omit = homeassistant/components/saj/sensor.py homeassistant/components/salt/device_tracker.py homeassistant/components/satel_integra/* + homeassistant/components/schluter/* homeassistant/components/scrape/sensor.py homeassistant/components/scsgate/* homeassistant/components/scsgate/cover.py diff --git a/CODEOWNERS b/CODEOWNERS index 1fda4d6f44b35b3b5f18e8a5be8f0c3339fbc0ab..62e6f0d8e6683b35c039b23ae1b27f2cbd144741 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -313,6 +313,7 @@ homeassistant/components/saj/* @fredericvl homeassistant/components/salt/* @bjornorri homeassistant/components/samsungtv/* @escoand homeassistant/components/scene/* @home-assistant/core +homeassistant/components/schluter/* @prairieapps homeassistant/components/scrape/* @fabaff homeassistant/components/script/* @home-assistant/core homeassistant/components/search/* @home-assistant/core diff --git a/homeassistant/components/schluter/__init__.py b/homeassistant/components/schluter/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9a78730d775f416d5ff27f43bd7c04a600e1b8ca --- /dev/null +++ b/homeassistant/components/schluter/__init__.py @@ -0,0 +1,73 @@ +"""The Schluter DITRA-HEAT integration.""" +import logging + +from requests import RequestException, Session +from schluter.api import Api +from schluter.authenticator import AuthenticationState, Authenticator +import voluptuous as vol + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +DATA_SCHLUTER_SESSION = "schluter_session" +DATA_SCHLUTER_API = "schluter_api" +SCHLUTER_CONFIG_FILE = ".schluter.conf" +API_TIMEOUT = 10 + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(DOMAIN): vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the Schluter component.""" + _LOGGER.debug("Starting setup of schluter") + + conf = config[DOMAIN] + api_http_session = Session() + api = Api(timeout=API_TIMEOUT, http_session=api_http_session) + + authenticator = Authenticator( + api, + conf.get(CONF_USERNAME), + conf.get(CONF_PASSWORD), + session_id_cache_file=hass.config.path(SCHLUTER_CONFIG_FILE), + ) + + authentication = None + try: + authentication = authenticator.authenticate() + except RequestException as ex: + _LOGGER.error("Unable to connect to Schluter service: %s", ex) + return + + state = authentication.state + + if state == AuthenticationState.AUTHENTICATED: + hass.data[DOMAIN] = { + DATA_SCHLUTER_API: api, + DATA_SCHLUTER_SESSION: authentication.session_id, + } + discovery.load_platform(hass, "climate", DOMAIN, {}, config) + return True + if state == AuthenticationState.BAD_PASSWORD: + _LOGGER.error("Invalid password provided") + return False + if state == AuthenticationState.BAD_EMAIL: + _LOGGER.error("Invalid email provided") + return False + + _LOGGER.error("Unknown set up error: %s", state) + return False diff --git a/homeassistant/components/schluter/climate.py b/homeassistant/components/schluter/climate.py new file mode 100644 index 0000000000000000000000000000000000000000..99dc5b0d495ca42d2873583b5020b8d066d34322 --- /dev/null +++ b/homeassistant/components/schluter/climate.py @@ -0,0 +1,169 @@ +"""Support for Schluter thermostats.""" +import logging + +from requests import RequestException +import voluptuous as vol + +from homeassistant.components.climate import ( + PLATFORM_SCHEMA, + SCAN_INTERVAL, + ClimateDevice, +) +from homeassistant.components.climate.const import ( + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + HVAC_MODE_HEAT, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, CONF_SCAN_INTERVAL +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from . import DATA_SCHLUTER_API, DATA_SCHLUTER_SESSION, DOMAIN + +_LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_SCAN_INTERVAL): vol.All(vol.Coerce(int), vol.Range(min=1))} +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Schluter thermostats.""" + if discovery_info is None: + return + session_id = hass.data[DOMAIN][DATA_SCHLUTER_SESSION] + api = hass.data[DOMAIN][DATA_SCHLUTER_API] + temp_unit = hass.config.units.temperature_unit + + async def async_update_data(): + try: + thermostats = await hass.async_add_executor_job( + api.get_thermostats, session_id + ) + except RequestException as err: + raise UpdateFailed(f"Error communicating with Schluter API: {err}") + + if thermostats is None: + return {} + + return {thermo.serial_number: thermo for thermo in thermostats} + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="schluter", + update_method=async_update_data, + update_interval=SCAN_INTERVAL, + ) + + await coordinator.async_refresh() + + async_add_entities( + SchluterThermostat(coordinator, serial_number, temp_unit, api, session_id) + for serial_number, thermostat in coordinator.data.items() + ) + + +class SchluterThermostat(ClimateDevice): + """Representation of a Schluter thermostat.""" + + def __init__(self, coordinator, serial_number, temp_unit, api, session_id): + """Initialize the thermostat.""" + self._unit = temp_unit + self._coordinator = coordinator + self._serial_number = serial_number + self._api = api + self._session_id = session_id + self._support_flags = SUPPORT_TARGET_TEMPERATURE + + @property + def available(self): + """Return if thermostat is available.""" + return self._coordinator.last_update_success + + @property + def should_poll(self): + """Return if platform should poll.""" + return False + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._support_flags + + @property + def unique_id(self): + """Return unique ID for this device.""" + return self._serial_number + + @property + def name(self): + """Return the name of the thermostat.""" + return self._coordinator.data[self._serial_number].name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self._unit + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._coordinator.data[self._serial_number].temperature + + @property + def hvac_mode(self): + """Return current mode. Only heat available for floor thermostat.""" + return HVAC_MODE_HEAT + + @property + def hvac_action(self): + """Return current operation. Can only be heating or idle.""" + return ( + CURRENT_HVAC_HEAT + if self._coordinator.data[self._serial_number].is_heating + else CURRENT_HVAC_IDLE + ) + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._coordinator.data[self._serial_number].set_point_temp + + @property + def hvac_modes(self): + """List of available operation modes.""" + return [HVAC_MODE_HEAT] + + @property + def min_temp(self): + """Identify min_temp in Schluter API.""" + return self._coordinator.data[self._serial_number].min_temp + + @property + def max_temp(self): + """Identify max_temp in Schluter API.""" + return self._coordinator.data[self._serial_number].max_temp + + async def async_set_hvac_mode(self, hvac_mode): + """Mode is always heating, so do nothing.""" + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + target_temp = None + target_temp = kwargs.get(ATTR_TEMPERATURE) + serial_number = self._coordinator.data[self._serial_number].serial_number + _LOGGER.debug("Setting thermostat temperature: %s", target_temp) + + try: + if target_temp is not None: + self._api.set_temperature(self._session_id, serial_number, target_temp) + except RequestException as ex: + _LOGGER.error("An error occurred while setting temperature: %s", ex) + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self._coordinator.async_add_listener(self.async_write_ha_state) + + async def async_will_remove_from_hass(self): + """When entity will be removed from hass.""" + self._coordinator.async_remove_listener(self.async_write_ha_state) diff --git a/homeassistant/components/schluter/const.py b/homeassistant/components/schluter/const.py new file mode 100644 index 0000000000000000000000000000000000000000..e09c8cf66a91641ae09a827a9dd8d9cab1b67b4a --- /dev/null +++ b/homeassistant/components/schluter/const.py @@ -0,0 +1,3 @@ +"""Constants for the Schluter DITRA-HEAT integration.""" + +DOMAIN = "schluter" diff --git a/homeassistant/components/schluter/manifest.json b/homeassistant/components/schluter/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..1a7cebcf06af0166f512e212c02c930cfec921cc --- /dev/null +++ b/homeassistant/components/schluter/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "schluter", + "name": "Schluter", + "documentation": "https://www.home-assistant.io/integrations/schluter", + "requirements": ["py-schluter==0.1.7"], + "dependencies": [], + "codeowners": ["@prairieapps"] +} diff --git a/requirements_all.txt b/requirements_all.txt index d1d6e2447eda585843abe5488975d3617be41ba3..f0ba017f5740ae59572d0c4c3d6911ed07360625 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1111,6 +1111,9 @@ py-cpuinfo==5.0.0 # homeassistant.components.melissa py-melissa-climate==2.0.0 +# homeassistant.components.schluter +py-schluter==0.1.7 + # homeassistant.components.synology py-synology==0.2.0