New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add integration for Vallox Ventilation Units #24660
Changes from 1 commit
6440f8a
1480ff1
7b74fd2
fd147c5
91f1c9d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,252 @@ | ||
"""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, CONF_SENSORS | ||
from homeassistant.helpers import discovery | ||
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.""" | ||
if DOMAIN not in config: | ||
return True | ||
|
||
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)) | ||
|
||
return True | ||
|
||
|
||
class ValloxStateProxy: | ||
"""Helper class to reduce websocket API calls.""" | ||
|
||
def __init__(self, hass, client): | ||
self._hass = hass | ||
self._client = client | ||
self._metric_cache = {} | ||
self._profile = None | ||
self._valid = False | ||
|
||
async_track_time_interval(self._hass, self.async_update, SCAN_INTERVAL) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don't want side effects in init method. Please move this outside the class or into a dedicated setup method. |
||
|
||
def fetch_metric(self, metric_key): | ||
"""Return cached state value.""" | ||
_LOGGER.debug("Fetching metric key: %s", metric_key) | ||
|
||
if not self._valid: | ||
raise IOError("Device state out of sync.") | ||
MartinHjelmare marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
if not metric_key in vlxDevConstants.__dict__.keys(): | ||
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.") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don't end logging messages with period. |
||
if not self._valid: | ||
raise IOError("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 = self._client.fetch_metrics() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If I'm reading the vallox library correctly, this does non asyncio I/O and we're in a coroutine here. That's not allowed. Coroutines will be executed in the event loop which should never block. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider switching affected methods to regular sync functions. This might mean we have to use the home assistant sync api. The alternative is to schedule I/O on the executor thread pool with |
||
self._profile = self._client.get_profile() | ||
self._valid = True | ||
|
||
except IOError as io_err: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can except OSError instead. IOError is an alias of OSError. https://docs.python.org/3/library/exceptions.html#IOError
Python 3.6.7 (default, Oct 22 2018, 11:32:17)
Type 'copyright', 'credits' or 'license' for more information
IPython 7.3.0 -- An enhanced Interactive Python. Type '?' for help.
In [1]: try:
...: raise IOError
...: except OSError:
...: print("Caught the IOError")
...:
Caught the IOError
In [2]: |
||
_LOGGER.error("Error during state cache update: %s", io_err) | ||
self._valid = False | ||
|
||
async_dispatcher_send(self._hass, SIGNAL_VALLOX_STATE_UPDATE) | ||
|
||
|
||
class ValloxServiceHandler: | ||
"""Services implementation.""" | ||
|
||
def __init__(self, client, state_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: | ||
self._client.set_profile(STR_TO_PROFILE[profile]) | ||
return True | ||
|
||
except IOError as io_err: | ||
_LOGGER.error("Error setting ventilation profile: %s", io_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: | ||
self._client.set_values( | ||
{METRIC_KEY_PROFILE_FAN_SPEED_HOME: fan_speed}) | ||
return True | ||
|
||
except IOError as io_err: | ||
_LOGGER.error("Error setting fan speed for Home profile: %s", | ||
io_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: | ||
self._client.set_values( | ||
{METRIC_KEY_PROFILE_FAN_SPEED_AWAY: fan_speed}) | ||
return True | ||
|
||
except IOError as io_err: | ||
_LOGGER.error("Error setting fan speed for Away profile: %s", | ||
io_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: | ||
self._client.set_values( | ||
{METRIC_KEY_PROFILE_FAN_SPEED_BOOST: fan_speed}) | ||
return True | ||
|
||
except IOError as io_err: | ||
_LOGGER.error("Error setting fan speed for Boost profile: %s", | ||
io_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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can't happen. There is no network discovery and we're not using config entries here yet.