diff --git a/.coveragerc b/.coveragerc index fcdcb23809bd..8a742d33de1d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -652,6 +652,7 @@ omit = homeassistant/components/uptimerobot/binary_sensor.py homeassistant/components/uscis/sensor.py homeassistant/components/usps/* + homeassistant/components/vallox/* homeassistant/components/vasttrafik/sensor.py homeassistant/components/velbus/* homeassistant/components/velux/* diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py new file mode 100644 index 000000000000..5e14aedba148 --- /dev/null +++ b/homeassistant/components/vallox/__init__.py @@ -0,0 +1,257 @@ +"""Support for Vallox ventilation units.""" + +from datetime import timedelta +import ipaddress +import logging + +from vallox_websocket_api import PROFILE as VALLOX_PROFILE, Vallox +from vallox_websocket_api.constants import vlxDevConstants +import voluptuous as vol + +from homeassistant.const import CONF_HOST, CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'vallox' +DEFAULT_NAME = 'Vallox' +SIGNAL_VALLOX_STATE_UPDATE = "vallox_state_update" +SCAN_INTERVAL = timedelta(seconds=60) + +# Various metric keys that are reused between profiles. +METRIC_KEY_MODE = 'A_CYC_MODE' +METRIC_KEY_PROFILE_FAN_SPEED_HOME = 'A_CYC_HOME_SPEED_SETTING' +METRIC_KEY_PROFILE_FAN_SPEED_AWAY = 'A_CYC_AWAY_SPEED_SETTING' +METRIC_KEY_PROFILE_FAN_SPEED_BOOST = 'A_CYC_BOOST_SPEED_SETTING' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): vol.All(ipaddress.ip_address, cv.string), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string + }), +}, extra=vol.ALLOW_EXTRA) + +# pylint: disable=no-member +PROFILE_TO_STR_SETTABLE = { + VALLOX_PROFILE.HOME: 'Home', + VALLOX_PROFILE.AWAY: 'Away', + VALLOX_PROFILE.BOOST: 'Boost', + VALLOX_PROFILE.FIREPLACE: 'Fireplace', +} + +STR_TO_PROFILE = {v: k for (k, v) in PROFILE_TO_STR_SETTABLE.items()} + +# pylint: disable=no-member +PROFILE_TO_STR_REPORTABLE = {**{ + VALLOX_PROFILE.NONE: 'None', + VALLOX_PROFILE.EXTRA: 'Extra', +}, **PROFILE_TO_STR_SETTABLE} + +ATTR_PROFILE = 'profile' +ATTR_PROFILE_FAN_SPEED = 'fan_speed' + +SERVICE_SCHEMA_SET_PROFILE = vol.Schema({ + vol.Required(ATTR_PROFILE): + vol.All(cv.string, vol.In(STR_TO_PROFILE)) +}) + +SERVICE_SCHEMA_SET_PROFILE_FAN_SPEED = vol.Schema({ + vol.Required(ATTR_PROFILE_FAN_SPEED): + vol.All(vol.Coerce(int), vol.Clamp(min=0, max=100)) +}) + +SERVICE_SET_PROFILE = 'set_profile' +SERVICE_SET_PROFILE_FAN_SPEED_HOME = 'set_profile_fan_speed_home' +SERVICE_SET_PROFILE_FAN_SPEED_AWAY = 'set_profile_fan_speed_away' +SERVICE_SET_PROFILE_FAN_SPEED_BOOST = 'set_profile_fan_speed_boost' + +SERVICE_TO_METHOD = { + SERVICE_SET_PROFILE: { + 'method': 'async_set_profile', + 'schema': SERVICE_SCHEMA_SET_PROFILE}, + SERVICE_SET_PROFILE_FAN_SPEED_HOME: { + 'method': 'async_set_profile_fan_speed_home', + 'schema': SERVICE_SCHEMA_SET_PROFILE_FAN_SPEED}, + SERVICE_SET_PROFILE_FAN_SPEED_AWAY: { + 'method': 'async_set_profile_fan_speed_away', + 'schema': SERVICE_SCHEMA_SET_PROFILE_FAN_SPEED}, + SERVICE_SET_PROFILE_FAN_SPEED_BOOST: { + 'method': 'async_set_profile_fan_speed_boost', + 'schema': SERVICE_SCHEMA_SET_PROFILE_FAN_SPEED}, +} + +DEFAULT_FAN_SPEED_HOME = 50 +DEFAULT_FAN_SPEED_AWAY = 25 +DEFAULT_FAN_SPEED_BOOST = 65 + + +async def async_setup(hass, config): + """Set up the client and boot the platforms.""" + conf = config[DOMAIN] + host = conf.get(CONF_HOST) + name = conf.get(CONF_NAME) + + client = Vallox(host) + state_proxy = ValloxStateProxy(hass, client) + service_handler = ValloxServiceHandler(client, state_proxy) + + hass.data[DOMAIN] = { + 'client': client, + 'state_proxy': state_proxy, + 'name': name + } + + for vallox_service in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[vallox_service]['schema'] + hass.services.async_register(DOMAIN, vallox_service, + service_handler.async_handle, + schema=schema) + + # Fetch initial state once before bringing up the platforms. + await state_proxy.async_update(None) + + hass.async_create_task( + async_load_platform(hass, 'sensor', DOMAIN, {}, config)) + hass.async_create_task( + async_load_platform(hass, 'fan', DOMAIN, {}, config)) + + async_track_time_interval(hass, state_proxy.async_update, SCAN_INTERVAL) + + return True + + +class ValloxStateProxy: + """Helper class to reduce websocket API calls.""" + + def __init__(self, hass, client): + """Initialize the proxy.""" + self._hass = hass + self._client = client + self._metric_cache = {} + self._profile = None + self._valid = False + + def fetch_metric(self, metric_key): + """Return cached state value.""" + _LOGGER.debug("Fetching metric key: %s", metric_key) + + if not self._valid: + raise OSError("Device state out of sync.") + + if metric_key not in vlxDevConstants.__dict__: + raise KeyError("Unknown metric key: {}".format(metric_key)) + + return self._metric_cache[metric_key] + + def get_profile(self): + """Return cached profile value.""" + _LOGGER.debug("Returning profile") + + if not self._valid: + raise OSError("Device state out of sync.") + + return PROFILE_TO_STR_REPORTABLE[self._profile] + + async def async_update(self, event_time): + """Fetch state update.""" + _LOGGER.debug("Updating Vallox state cache") + + try: + self._metric_cache = await self._hass.async_add_executor_job( + self._client.fetch_metrics) + self._profile = await self._hass.async_add_executor_job( + self._client.get_profile) + self._valid = True + + except OSError as err: + _LOGGER.error("Error during state cache update: %s", err) + self._valid = False + + async_dispatcher_send(self._hass, SIGNAL_VALLOX_STATE_UPDATE) + + +class ValloxServiceHandler: + """Services implementation.""" + + def __init__(self, client, state_proxy): + """Initialize the proxy.""" + self._client = client + self._state_proxy = state_proxy + + async def async_set_profile(self, profile: str = 'Home') -> bool: + """Set the ventilation profile.""" + _LOGGER.debug("Setting ventilation profile to: %s", profile) + + try: + await self._hass.async_add_executor_job( + self._client.set_profile, STR_TO_PROFILE[profile]) + return True + + except OSError as err: + _LOGGER.error("Error setting ventilation profile: %s", err) + return False + + async def async_set_profile_fan_speed_home( + self, fan_speed: int = DEFAULT_FAN_SPEED_HOME) -> bool: + """Set the fan speed in percent for the Home profile.""" + _LOGGER.debug("Setting Home fan speed to: %d%%", fan_speed) + + try: + await self._hass.async_add_executor_job( + self._client.set_values, + {METRIC_KEY_PROFILE_FAN_SPEED_HOME: fan_speed}) + return True + + except OSError as err: + _LOGGER.error("Error setting fan speed for Home profile: %s", err) + return False + + async def async_set_profile_fan_speed_away( + self, fan_speed: int = DEFAULT_FAN_SPEED_AWAY) -> bool: + """Set the fan speed in percent for the Home profile.""" + _LOGGER.debug("Setting Away fan speed to: %d%%", fan_speed) + + try: + await self._hass.async_add_executor_job( + self._client.set_values, + {METRIC_KEY_PROFILE_FAN_SPEED_AWAY: fan_speed}) + return True + + except OSError as err: + _LOGGER.error("Error setting fan speed for Away profile: %s", err) + return False + + async def async_set_profile_fan_speed_boost( + self, fan_speed: int = DEFAULT_FAN_SPEED_BOOST) -> bool: + """Set the fan speed in percent for the Boost profile.""" + _LOGGER.debug("Setting Boost fan speed to: %d%%", fan_speed) + + try: + await self._hass.async_add_executor_job( + self._client.set_values, + {METRIC_KEY_PROFILE_FAN_SPEED_BOOST: fan_speed}) + return True + + except OSError as err: + _LOGGER.error("Error setting fan speed for Boost profile: %s", + err) + return False + + async def async_handle(self, service): + """Dispatch a service call.""" + method = SERVICE_TO_METHOD.get(service.service) + params = {key: value for key, value in service.data.items()} + + if not hasattr(self, method['method']): + _LOGGER.error("Service not implemented: %s", method['method']) + return + + result = await getattr(self, method['method'])(**params) + + # Force state_proxy to refresh device state, so that updates are + # propagated to platforms. + if result: + await self._state_proxy.async_update(None) diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py new file mode 100644 index 000000000000..d45fcd39f437 --- /dev/null +++ b/homeassistant/components/vallox/fan.py @@ -0,0 +1,165 @@ +"""Support for the Vallox ventilation unit fan.""" + +import logging + +from homeassistant.components.fan import FanEntity +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import ( + DOMAIN, METRIC_KEY_MODE, METRIC_KEY_PROFILE_FAN_SPEED_AWAY, + METRIC_KEY_PROFILE_FAN_SPEED_BOOST, METRIC_KEY_PROFILE_FAN_SPEED_HOME, + SIGNAL_VALLOX_STATE_UPDATE) + +_LOGGER = logging.getLogger(__name__) + +# Device attributes +ATTR_PROFILE_FAN_SPEED_HOME = { + 'description': 'fan_speed_home', + 'metric_key': METRIC_KEY_PROFILE_FAN_SPEED_HOME +} +ATTR_PROFILE_FAN_SPEED_AWAY = { + 'description': 'fan_speed_away', + 'metric_key': METRIC_KEY_PROFILE_FAN_SPEED_AWAY +} +ATTR_PROFILE_FAN_SPEED_BOOST = { + 'description': 'fan_speed_boost', + 'metric_key': METRIC_KEY_PROFILE_FAN_SPEED_BOOST +} + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the fan device.""" + if discovery_info is None: + return + + client = hass.data[DOMAIN]['client'] + + await hass.async_add_executor_job( + client.set_settable_address, METRIC_KEY_MODE, int) + + device = ValloxFan(hass.data[DOMAIN]['name'], + client, + hass.data[DOMAIN]['state_proxy']) + + async_add_entities([device], update_before_add=True) + + +class ValloxFan(FanEntity): + """Representation of the fan.""" + + def __init__(self, name, client, state_proxy): + """Initialize the fan.""" + self._name = name + self._client = client + self._state_proxy = state_proxy + self._available = False + self._state = None + self._fan_speed_home = None + self._fan_speed_away = None + self._fan_speed_boost = None + + @property + def should_poll(self): + """Do not poll the device.""" + return False + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def available(self): + """Return if state is known.""" + return self._available + + @property + def is_on(self): + """Return if device is on.""" + return self._state + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + return { + ATTR_PROFILE_FAN_SPEED_HOME['description']: self._fan_speed_home, + ATTR_PROFILE_FAN_SPEED_AWAY['description']: self._fan_speed_away, + ATTR_PROFILE_FAN_SPEED_BOOST['description']: self._fan_speed_boost, + } + + async def async_added_to_hass(self): + """Call to update.""" + async_dispatcher_connect(self.hass, SIGNAL_VALLOX_STATE_UPDATE, + self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + async def async_update(self): + """Fetch state from the device.""" + try: + # Fetch if the whole device is in regular operation state. + mode = self._state_proxy.fetch_metric(METRIC_KEY_MODE) + if mode == 0: + self._state = True + else: + self._state = False + + # Fetch the profile fan speeds. + self._fan_speed_home = int(self._state_proxy.fetch_metric( + ATTR_PROFILE_FAN_SPEED_HOME['metric_key'])) + self._fan_speed_away = int(self._state_proxy.fetch_metric( + ATTR_PROFILE_FAN_SPEED_AWAY['metric_key'])) + self._fan_speed_boost = int(self._state_proxy.fetch_metric( + ATTR_PROFILE_FAN_SPEED_BOOST['metric_key'])) + + self._available = True + + except (OSError, KeyError) as err: + self._available = False + _LOGGER.error("Error updating fan: %s", err) + + async def async_turn_on(self, speed: str = None, **kwargs) -> None: + """Turn the device on.""" + _LOGGER.debug("Turn on: %s", speed) + + # Only the case speed == None equals the GUI toggle switch being + # activated. + if speed is not None: + return + + if self._state is False: + try: + await self.hass.async_add_executor_job( + self._client.set_values, {METRIC_KEY_MODE: 0}) + + # This state change affects other entities like sensors. Force + # an immediate update that can be observed by all parties + # involved. + await self._state_proxy.async_update(None) + + except OSError as err: + self._available = False + _LOGGER.error("Error turning on: %s", err) + else: + _LOGGER.error("Already on") + + async def async_turn_off(self, **kwargs) -> None: + """Turn the device off.""" + if self._state is True: + try: + await self.hass.async_add_executor_job( + self._client.set_values, {METRIC_KEY_MODE: 5}) + + # Same as for turn_on method. + await self._state_proxy.async_update(None) + + except OSError as err: + self._available = False + _LOGGER.error("Error turning off: %s", err) + else: + _LOGGER.error("Already off") diff --git a/homeassistant/components/vallox/manifest.json b/homeassistant/components/vallox/manifest.json new file mode 100644 index 000000000000..117fa634f162 --- /dev/null +++ b/homeassistant/components/vallox/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "vallox", + "name": "Vallox", + "documentation": "https://www.home-assistant.io/components/vallox", + "requirements": [ + "vallox-websocket-api==1.5.2" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py new file mode 100644 index 000000000000..416d068f9bf1 --- /dev/null +++ b/homeassistant/components/vallox/sensor.py @@ -0,0 +1,233 @@ +"""Support for Vallox ventilation unit sensors.""" + +from datetime import datetime, timedelta +import logging + +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, + TEMP_CELSIUS) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from . import DOMAIN, METRIC_KEY_MODE, SIGNAL_VALLOX_STATE_UPDATE + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the sensors.""" + if discovery_info is None: + return + + name = hass.data[DOMAIN]['name'] + state_proxy = hass.data[DOMAIN]['state_proxy'] + + sensors = [ + ValloxProfileSensor( + name="{} Current Profile".format(name), + state_proxy=state_proxy, + device_class=None, + unit_of_measurement=None, + icon='mdi:gauge' + ), + ValloxFanSpeedSensor( + name="{} Fan Speed".format(name), + state_proxy=state_proxy, + metric_key='A_CYC_FAN_SPEED', + device_class=None, + unit_of_measurement='%', + icon='mdi:fan' + ), + ValloxSensor( + name="{} Extract Air".format(name), + state_proxy=state_proxy, + metric_key='A_CYC_TEMP_EXTRACT_AIR', + device_class=DEVICE_CLASS_TEMPERATURE, + unit_of_measurement=TEMP_CELSIUS, + icon=None + ), + ValloxSensor( + name="{} Exhaust Air".format(name), + state_proxy=state_proxy, + metric_key='A_CYC_TEMP_EXHAUST_AIR', + device_class=DEVICE_CLASS_TEMPERATURE, + unit_of_measurement=TEMP_CELSIUS, + icon=None + ), + ValloxSensor( + name="{} Outdoor Air".format(name), + state_proxy=state_proxy, + metric_key='A_CYC_TEMP_OUTDOOR_AIR', + device_class=DEVICE_CLASS_TEMPERATURE, + unit_of_measurement=TEMP_CELSIUS, + icon=None + ), + ValloxSensor( + name="{} Supply Air".format(name), + state_proxy=state_proxy, + metric_key='A_CYC_TEMP_SUPPLY_AIR', + device_class=DEVICE_CLASS_TEMPERATURE, + unit_of_measurement=TEMP_CELSIUS, + icon=None + ), + ValloxSensor( + name="{} Humidity".format(name), + state_proxy=state_proxy, + metric_key='A_CYC_RH_VALUE', + device_class=DEVICE_CLASS_HUMIDITY, + unit_of_measurement='%', + icon=None + ), + ValloxFilterRemainingSensor( + name="{} Remaining Time For Filter".format(name), + state_proxy=state_proxy, + metric_key='A_CYC_REMAINING_TIME_FOR_FILTER', + device_class=DEVICE_CLASS_TIMESTAMP, + unit_of_measurement=None, + icon='mdi:filter' + ), + ] + + async_add_entities(sensors, update_before_add=True) + + +class ValloxSensor(Entity): + """Representation of a Vallox sensor.""" + + def __init__(self, name, state_proxy, metric_key, device_class, + unit_of_measurement, icon) -> None: + """Initialize the Vallox sensor.""" + self._name = name + self._state_proxy = state_proxy + self._metric_key = metric_key + self._device_class = device_class + self._unit_of_measurement = unit_of_measurement + self._icon = icon + self._available = None + self._state = None + + @property + def should_poll(self): + """Do not poll the device.""" + return False + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def available(self): + """Return true when state is known.""" + return self._available + + @property + def state(self): + """Return the state.""" + return self._state + + async def async_added_to_hass(self): + """Call to update.""" + async_dispatcher_connect(self.hass, SIGNAL_VALLOX_STATE_UPDATE, + self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + async def async_update(self): + """Fetch state from the ventilation unit.""" + try: + self._state = self._state_proxy.fetch_metric(self._metric_key) + self._available = True + + except (OSError, KeyError) as err: + self._available = False + _LOGGER.error("Error updating sensor: %s", err) + + +# There seems to be a quirk with respect to the fan speed reporting. The device +# keeps on reporting the last valid fan speed from when the device was in +# regular operation mode, even if it left that state and has been shut off in +# the meantime. +# +# Therefore, first query the overall state of the device, and report zero +# percent fan speed in case it is not in regular operation mode. +class ValloxFanSpeedSensor(ValloxSensor): + """Child class for fan speed reporting.""" + + async def async_update(self): + """Fetch state from the ventilation unit.""" + try: + # If device is in regular operation, continue. + if self._state_proxy.fetch_metric(METRIC_KEY_MODE) == 0: + await super().async_update() + else: + # Report zero percent otherwise. + self._state = 0 + self._available = True + + except (OSError, KeyError) as err: + self._available = False + _LOGGER.error("Error updating sensor: %s", err) + + +class ValloxProfileSensor(ValloxSensor): + """Child class for profile reporting.""" + + def __init__(self, name, state_proxy, device_class, unit_of_measurement, + icon) -> None: + """Initialize the Vallox sensor.""" + super().__init__(name, state_proxy, None, device_class, + unit_of_measurement, icon) + + async def async_update(self): + """Fetch state from the ventilation unit.""" + try: + self._state = self._state_proxy.get_profile() + self._available = True + + except OSError as err: + self._available = False + _LOGGER.error("Error updating sensor: %s", err) + + +class ValloxFilterRemainingSensor(ValloxSensor): + """Child class for filter remaining time reporting.""" + + async def async_update(self): + """Fetch state from the ventilation unit.""" + try: + days_remaining = int( + self._state_proxy.fetch_metric(self._metric_key)) + days_remaining_delta = timedelta(days=days_remaining) + + # Since only a delta of days is received from the device, fix the + # time so the timestamp does not change with every update. + now = datetime.utcnow().replace( + hour=13, minute=0, second=0, microsecond=0) + + self._state = (now + days_remaining_delta).isoformat() + self._available = True + + except (OSError, KeyError) as err: + self._available = False + _LOGGER.error("Error updating sensor: %s", err) diff --git a/homeassistant/components/vallox/services.yaml b/homeassistant/components/vallox/services.yaml new file mode 100644 index 000000000000..ea92e0ca2d9d --- /dev/null +++ b/homeassistant/components/vallox/services.yaml @@ -0,0 +1,29 @@ +set_profile: + description: Set the ventilation profile. + fields: + profile: + description: > + Set to any of: Home, Away, Boost, Fireplace + example: Away + +set_profile_fan_speed_home: + description: Set the fan speed of the Home profile. + fields: + fan_speed: + description: Fan speed in %. Integer, between 0 and 100. + example: 50 + + +set_profile_fan_speed_away: + description: Set the fan speed of the Away profile. + fields: + fan_speed: + description: Fan speed in %. Integer, between 0 and 100. + example: 25 + +set_profile_fan_speed_boost: + description: Set the fan speed of the Boost profile. + fields: + fan_speed: + description: Fan speed in %. Integer, between 0 and 100. + example: 65 diff --git a/requirements_all.txt b/requirements_all.txt index 565269be9c44..510ceeafde1d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1811,6 +1811,9 @@ uscisstatus==0.1.1 # homeassistant.components.uvc uvcclient==0.11.0 +# homeassistant.components.vallox +vallox-websocket-api==1.5.2 + # homeassistant.components.venstar venstarcolortouch==0.7