From 05a3b610ffb1c8741338665ff457bcc4feb08b32 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Sun, 11 Sep 2016 12:18:53 -0600 Subject: [PATCH] Add ISY programs and support for all device types (#3082) * ISY Lock, Binary Sensor, Cover devices, Sensors and Fan support * Support for ISY Programs --- .../components/binary_sensor/isy994.py | 76 ++++ homeassistant/components/cover/isy994.py | 109 ++++++ homeassistant/components/fan/isy994.py | 120 ++++++ homeassistant/components/isy994.py | 343 +++++++++------- homeassistant/components/light/isy994.py | 90 +++-- homeassistant/components/lock/isy994.py | 123 ++++++ homeassistant/components/sensor/isy994.py | 370 ++++++++++++++---- homeassistant/components/switch/isy994.py | 145 ++++--- requirements_all.txt | 2 +- 9 files changed, 1057 insertions(+), 321 deletions(-) create mode 100644 homeassistant/components/binary_sensor/isy994.py create mode 100644 homeassistant/components/cover/isy994.py create mode 100644 homeassistant/components/fan/isy994.py create mode 100644 homeassistant/components/lock/isy994.py diff --git a/homeassistant/components/binary_sensor/isy994.py b/homeassistant/components/binary_sensor/isy994.py new file mode 100644 index 00000000000..d845f4de9f7 --- /dev/null +++ b/homeassistant/components/binary_sensor/isy994.py @@ -0,0 +1,76 @@ +""" +Support for ISY994 binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.isy994/ +""" +import logging +from typing import Callable # noqa + +from homeassistant.components.binary_sensor import BinarySensorDevice, DOMAIN +import homeassistant.components.isy994 as isy +from homeassistant.const import STATE_ON, STATE_OFF +from homeassistant.helpers.typing import ConfigType + + +_LOGGER = logging.getLogger(__name__) + +VALUE_TO_STATE = { + False: STATE_OFF, + True: STATE_ON, +} + +UOM = ['2', '78'] +STATES = [STATE_OFF, STATE_ON, 'true', 'false'] + + +# pylint: disable=unused-argument +def setup_platform(hass, config: ConfigType, + add_devices: Callable[[list], None], discovery_info=None): + """Setup the ISY994 binary sensor platform.""" + if isy.ISY is None or not isy.ISY.connected: + _LOGGER.error('A connection has not been made to the ISY controller.') + return False + + devices = [] + + for node in isy.filter_nodes(isy.SENSOR_NODES, units=UOM, + states=STATES): + devices.append(ISYBinarySensorDevice(node)) + + for program in isy.PROGRAMS.get(DOMAIN, []): + try: + status = program[isy.KEY_STATUS] + except (KeyError, AssertionError): + pass + else: + devices.append(ISYBinarySensorProgram(program.name, status)) + + add_devices(devices) + + +class ISYBinarySensorDevice(isy.ISYDevice, BinarySensorDevice): + """Representation of an ISY994 binary sensor device.""" + + def __init__(self, node) -> None: + """Initialize the ISY994 binary sensor device.""" + isy.ISYDevice.__init__(self, node) + + @property + def is_on(self) -> bool: + """Get whether the ISY994 binary sensor device is on.""" + return bool(self.state) + + +class ISYBinarySensorProgram(ISYBinarySensorDevice): + """Representation of an ISY994 binary sensor program.""" + + def __init__(self, name, node) -> None: + """Initialize the ISY994 binary sensor program.""" + ISYBinarySensorDevice.__init__(self, node) + self._name = name + + @property + def is_on(self): + """Get whether the ISY994 binary sensor program is on.""" + return bool(self.value) diff --git a/homeassistant/components/cover/isy994.py b/homeassistant/components/cover/isy994.py new file mode 100644 index 00000000000..def3ef009c7 --- /dev/null +++ b/homeassistant/components/cover/isy994.py @@ -0,0 +1,109 @@ +""" +Support for ISY994 covers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.isy994/ +""" +import logging +from typing import Callable # noqa + +from homeassistant.components.cover import CoverDevice, DOMAIN +import homeassistant.components.isy994 as isy +from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN +from homeassistant.helpers.typing import ConfigType + + +_LOGGER = logging.getLogger(__name__) + +VALUE_TO_STATE = { + 0: STATE_CLOSED, + 101: STATE_UNKNOWN, +} + +UOM = ['97'] +STATES = [STATE_OPEN, STATE_CLOSED, 'closing', 'opening'] + + +# pylint: disable=unused-argument +def setup_platform(hass, config: ConfigType, + add_devices: Callable[[list], None], discovery_info=None): + """Setup the ISY994 cover platform.""" + if isy.ISY is None or not isy.ISY.connected: + _LOGGER.error('A connection has not been made to the ISY controller.') + return False + + devices = [] + + for node in isy.filter_nodes(isy.NODES, units=UOM, + states=STATES): + devices.append(ISYCoverDevice(node)) + + for program in isy.PROGRAMS.get(DOMAIN, []): + try: + status = program[isy.KEY_STATUS] + actions = program[isy.KEY_ACTIONS] + assert actions.dtype == 'program', 'Not a program' + except (KeyError, AssertionError): + pass + else: + devices.append(ISYCoverProgram(program.name, status, actions)) + + add_devices(devices) + + +class ISYCoverDevice(isy.ISYDevice, CoverDevice): + """Representation of an ISY994 cover device.""" + + def __init__(self, node: object): + """Initialize the ISY994 cover device.""" + isy.ISYDevice.__init__(self, node) + + @property + def current_cover_position(self) -> int: + """Get the current cover position.""" + return sorted((0, self.value, 100))[1] + + @property + def is_closed(self) -> bool: + """Get whether the ISY994 cover device is closed.""" + return self.state == STATE_CLOSED + + @property + def state(self) -> str: + """Get the state of the ISY994 cover device.""" + return VALUE_TO_STATE.get(self.value, STATE_OPEN) + + def open_cover(self, **kwargs) -> None: + """Send the open cover command to the ISY994 cover device.""" + if not self._node.on(val=100): + _LOGGER.error('Unable to open the cover') + + def close_cover(self, **kwargs) -> None: + """Send the close cover command to the ISY994 cover device.""" + if not self._node.off(): + _LOGGER.error('Unable to close the cover') + + +class ISYCoverProgram(ISYCoverDevice): + """Representation of an ISY994 cover program.""" + + def __init__(self, name: str, node: object, actions: object) -> None: + """Initialize the ISY994 cover program.""" + ISYCoverDevice.__init__(self, node) + self._name = name + self._actions = actions + + @property + def state(self) -> str: + """Get the state of the ISY994 cover program.""" + return STATE_CLOSED if bool(self.value) else STATE_OPEN + + def open_cover(self, **kwargs) -> None: + """Send the open cover command to the ISY994 cover program.""" + if not self._actions.runThen(): + _LOGGER.error('Unable to open the cover') + + def close_cover(self, **kwargs) -> None: + """Send the close cover command to the ISY994 cover program.""" + if not self._actions.runElse(): + _LOGGER.error('Unable to close the cover') diff --git a/homeassistant/components/fan/isy994.py b/homeassistant/components/fan/isy994.py new file mode 100644 index 00000000000..2deb938d337 --- /dev/null +++ b/homeassistant/components/fan/isy994.py @@ -0,0 +1,120 @@ +""" +Support for ISY994 fans. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/fan.isy994/ +""" +import logging +from typing import Callable + +from homeassistant.components.fan import (FanEntity, DOMAIN, SPEED_OFF, + SPEED_LOW, SPEED_MED, + SPEED_HIGH) +import homeassistant.components.isy994 as isy +from homeassistant.const import STATE_UNKNOWN, STATE_ON, STATE_OFF +from homeassistant.helpers.typing import ConfigType + +_LOGGER = logging.getLogger(__name__) + +VALUE_TO_STATE = { + 0: SPEED_OFF, + 63: SPEED_LOW, + 64: SPEED_LOW, + 190: SPEED_MED, + 191: SPEED_MED, + 255: SPEED_HIGH, +} + +STATE_TO_VALUE = {} +for key in VALUE_TO_STATE: + STATE_TO_VALUE[VALUE_TO_STATE[key]] = key + +STATES = [SPEED_OFF, SPEED_LOW, SPEED_MED, SPEED_HIGH] + + +# pylint: disable=unused-argument +def setup_platform(hass, config: ConfigType, + add_devices: Callable[[list], None], discovery_info=None): + """Setup the ISY994 fan platform.""" + if isy.ISY is None or not isy.ISY.connected: + _LOGGER.error('A connection has not been made to the ISY controller.') + return False + + devices = [] + + for node in isy.filter_nodes(isy.NODES, states=STATES): + devices.append(ISYFanDevice(node)) + + for program in isy.PROGRAMS.get(DOMAIN, []): + try: + status = program[isy.KEY_STATUS] + actions = program[isy.KEY_ACTIONS] + assert actions.dtype == 'program', 'Not a program' + except (KeyError, AssertionError): + pass + else: + devices.append(ISYFanProgram(program.name, status, actions)) + + add_devices(devices) + + +class ISYFanDevice(isy.ISYDevice, FanEntity): + """Representation of an ISY994 fan device.""" + + def __init__(self, node) -> None: + """Initialize the ISY994 fan device.""" + isy.ISYDevice.__init__(self, node) + self.speed = self.state + + @property + def state(self) -> str: + """Get the state of the ISY994 fan device.""" + return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) + + def set_speed(self, speed: str) -> None: + """Send the set speed command to the ISY994 fan device.""" + if not self._node.on(val=STATE_TO_VALUE.get(speed, 0)): + _LOGGER.debug('Unable to set fan speed') + else: + self.speed = self.state + + def turn_on(self, speed: str=None, **kwargs) -> None: + """Send the turn on command to the ISY994 fan device.""" + self.set_speed(speed) + + def turn_off(self, **kwargs) -> None: + """Send the turn off command to the ISY994 fan device.""" + if not self._node.off(): + _LOGGER.debug('Unable to set fan speed') + else: + self.speed = self.state + + +class ISYFanProgram(ISYFanDevice): + """Representation of an ISY994 fan program.""" + + def __init__(self, name: str, node, actions) -> None: + """Initialize the ISY994 fan program.""" + ISYFanDevice.__init__(self, node) + self._name = name + self._actions = actions + self.speed = STATE_ON if self.is_on else STATE_OFF + + @property + def state(self) -> str: + """Get the state of the ISY994 fan program.""" + return STATE_ON if bool(self.value) else STATE_OFF + + def turn_off(self, **kwargs) -> None: + """Send the turn on command to ISY994 fan program.""" + if not self._actions.runThen(): + _LOGGER.error('Unable to open the cover') + else: + self.speed = STATE_ON if self.is_on else STATE_OFF + + def turn_on(self, **kwargs) -> None: + """Send the turn off command to ISY994 fan program.""" + if not self._actions.runElse(): + _LOGGER.error('Unable to close the cover') + else: + self.speed = STATE_ON if self.is_on else STATE_OFF diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index be964ebef7c..379712fa989 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -6,43 +6,150 @@ https://home-assistant.io/components/isy994/ """ import logging from urllib.parse import urlparse +import voluptuous as vol +from homeassistant.core import HomeAssistant # noqa from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP) -from homeassistant.helpers import validate_config, discovery -from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers import discovery, config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType, Dict # noqa + DOMAIN = "isy994" -REQUIREMENTS = ['PyISY==1.0.6'] +REQUIREMENTS = ['PyISY==1.0.7'] ISY = None -SENSOR_STRING = 'Sensor' -HIDDEN_STRING = '{HIDE ME}' +DEFAULT_SENSOR_STRING = 'sensor' +DEFAULT_HIDDEN_STRING = '{HIDE ME}' CONF_TLS_VER = 'tls' +CONF_HIDDEN_STRING = 'hidden_string' +CONF_SENSOR_STRING = 'sensor_string' +KEY_MY_PROGRAMS = 'My Programs' +KEY_FOLDER = 'folder' +KEY_ACTIONS = 'actions' +KEY_STATUS = 'status' _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.url, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_TLS_VER): vol.Coerce(float), + vol.Optional(CONF_HIDDEN_STRING, + default=DEFAULT_HIDDEN_STRING): cv.string, + vol.Optional(CONF_SENSOR_STRING, + default=DEFAULT_SENSOR_STRING): cv.string + }) +}, extra=vol.ALLOW_EXTRA) -def setup(hass, config): - """Setup ISY994 component. +SENSOR_NODES = [] +NODES = [] +GROUPS = [] +PROGRAMS = {} - This will automatically import associated lights, switches, and sensors. - """ - import PyISY +PYISY = None - # pylint: disable=global-statement - # check for required values in configuration file - if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, - _LOGGER): - return False +HIDDEN_STRING = DEFAULT_HIDDEN_STRING - # Pull and parse standard configuration. - user = config[DOMAIN][CONF_USERNAME] - password = config[DOMAIN][CONF_PASSWORD] - host = urlparse(config[DOMAIN][CONF_HOST]) +SUPPORTED_DOMAINS = ['binary_sensor', 'cover', 'fan', 'light', 'lock', + 'sensor', 'switch'] + + +def filter_nodes(nodes: list, units: list=None, states: list=None) -> list: + """Filter a list of ISY nodes based on the units and states provided.""" + filtered_nodes = [] + units = units if units else [] + states = states if states else [] + for node in nodes: + match_unit = False + match_state = True + for uom in node.uom: + if uom in units: + match_unit = True + continue + elif uom not in states: + match_state = False + + if match_unit: + continue + + if match_unit or match_state: + filtered_nodes.append(node) + + return filtered_nodes + + +def _categorize_nodes(hidden_identifier: str, sensor_identifier: str) -> None: + """Categorize the ISY994 nodes.""" + global SENSOR_NODES + global NODES + global GROUPS + + SENSOR_NODES = [] + NODES = [] + GROUPS = [] + + for (path, node) in ISY.nodes: + hidden = hidden_identifier in path or hidden_identifier in node.name + if hidden: + node.name += hidden_identifier + if sensor_identifier in path or sensor_identifier in node.name: + SENSOR_NODES.append(node) + elif isinstance(node, PYISY.Nodes.Node): # pylint: disable=no-member + NODES.append(node) + elif isinstance(node, PYISY.Nodes.Group): # pylint: disable=no-member + GROUPS.append(node) + + +def _categorize_programs() -> None: + """Categorize the ISY994 programs.""" + global PROGRAMS + + PROGRAMS = {} + + for component in SUPPORTED_DOMAINS: + try: + folder = ISY.programs[KEY_MY_PROGRAMS]['HA.' + component] + except KeyError: + pass + else: + for dtype, _, node_id in folder.children: + if dtype is KEY_FOLDER: + program = folder[node_id] + try: + node = program[KEY_STATUS].leaf + assert node.dtype == 'program', 'Not a program' + except (KeyError, AssertionError): + pass + else: + if component not in PROGRAMS: + PROGRAMS[component] = [] + PROGRAMS[component].append(program) + + +# pylint: disable=too-many-locals +def setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the ISY 994 platform.""" + isy_config = config.get(DOMAIN) + + user = isy_config.get(CONF_USERNAME) + password = isy_config.get(CONF_PASSWORD) + tls_version = isy_config.get(CONF_TLS_VER) + host = urlparse(isy_config.get(CONF_HOST)) + port = host.port addr = host.geturl() + hidden_identifier = isy_config.get(CONF_HIDDEN_STRING, + DEFAULT_HIDDEN_STRING) + sensor_identifier = isy_config.get(CONF_SENSOR_STRING, + DEFAULT_SENSOR_STRING) + + global HIDDEN_STRING + HIDDEN_STRING = hidden_identifier + if host.scheme == 'http': addr = addr.replace('http://', '') https = False @@ -50,169 +157,125 @@ def setup(hass, config): addr = addr.replace('https://', '') https = True else: - _LOGGER.error('isy994 host value in configuration file is invalid.') + _LOGGER.error('isy994 host value in configuration is invalid.') return False - port = host.port + addr = addr.replace(':{}'.format(port), '') - # Pull and parse optional configuration. - global SENSOR_STRING - global HIDDEN_STRING - SENSOR_STRING = str(config[DOMAIN].get('sensor_string', SENSOR_STRING)) - HIDDEN_STRING = str(config[DOMAIN].get('hidden_string', HIDDEN_STRING)) - tls_version = config[DOMAIN].get(CONF_TLS_VER, None) + import PyISY + + global PYISY + PYISY = PyISY # Connect to ISY controller. global ISY - ISY = PyISY.ISY(addr, port, user, password, use_https=https, - tls_ver=tls_version, log=_LOGGER) + ISY = PyISY.ISY(addr, port, username=user, password=password, + use_https=https, tls_ver=tls_version, log=_LOGGER) if not ISY.connected: return False + _categorize_nodes(hidden_identifier, sensor_identifier) + + _categorize_programs() + # Listen for HA stop to disconnect. hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop) # Load platforms for the devices in the ISY controller that we support. - for component in ('sensor', 'light', 'switch'): + for component in SUPPORTED_DOMAINS: discovery.load_platform(hass, component, DOMAIN, {}, config) ISY.auto_update = True return True -def stop(event): - """Cleanup the ISY subscription.""" +# pylint: disable=unused-argument +def stop(event: object) -> None: + """Stop ISY auto updates.""" ISY.auto_update = False -class ISYDeviceABC(ToggleEntity): - """An abstract Class for an ISY device.""" +class ISYDevice(Entity): + """Representation of an ISY994 device.""" _attrs = {} - _onattrs = [] - _states = [] - _dtype = None - _domain = None - _name = None + _domain = None # type: str + _name = None # type: str - def __init__(self, node): - """Initialize the device.""" - # setup properties - self.node = node + def __init__(self, node) -> None: + """Initialize the insteon device.""" + self._node = node - # track changes - self._change_handler = self.node.status. \ - subscribe('changed', self.on_update) + self._change_handler = self._node.status.subscribe('changed', + self.on_update) - def __del__(self): - """Cleanup subscriptions because it is the right thing to do.""" + def __del__(self) -> None: + """Cleanup the subscriptions.""" self._change_handler.unsubscribe() + # pylint: disable=unused-argument + def on_update(self, event: object) -> None: + """Handle the update event from the ISY994 Node.""" + self.update_ha_state() + @property - def domain(self): - """Return the domain of the entity.""" + def domain(self) -> str: + """Get the domain of the device.""" return self._domain @property - def dtype(self): - """Return the data type of the entity (binary or analog).""" - if self._dtype in ['analog', 'binary']: - return self._dtype - return 'binary' if self.unit_of_measurement is None else 'analog' - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def value(self): - """Return the unclean value from the controller.""" + def unique_id(self) -> str: + """Get the unique identifier of the device.""" # pylint: disable=protected-access - return self.node.status._val + return self._node._id @property - def state_attributes(self): - """Return the state attributes for the node.""" - attr = {} - for name, prop in self._attrs.items(): - attr[name] = getattr(self, prop) - attr = self._attr_filter(attr) - return attr - - def _attr_filter(self, attr): - """A Placeholder for attribute filters.""" - # pylint: disable=no-self-use - return attr - - @property - def unique_id(self): - """Return the ID of this ISY sensor.""" - # pylint: disable=protected-access - return self.node._id - - @property - def raw_name(self): - """Return the unclean node name.""" + def raw_name(self) -> str: + """Get the raw name of the device.""" return str(self._name) \ - if self._name is not None else str(self.node.name) + if self._name is not None else str(self._node.name) @property - def name(self): - """Return the cleaned name of the node.""" + def name(self) -> str: + """Get the name of the device.""" return self.raw_name.replace(HIDDEN_STRING, '').strip() \ .replace('_', ' ') @property - def hidden(self): - """Suggestion if the entity should be hidden from UIs.""" + def should_poll(self) -> bool: + """No polling required since we're using the subscription.""" + return False + + @property + def value(self) -> object: + """Get the current value of the device.""" + # pylint: disable=protected-access + return self._node.status._val + + @property + def state_attributes(self) -> Dict: + """Get the state attributes for the device.""" + attr = {} + if hasattr(self._node, 'aux_properties'): + for name, val in self._node.aux_properties.items(): + attr[name] = '{} {}'.format(val.get('value'), val.get('uom')) + return attr + + @property + def hidden(self) -> bool: + """Get whether the device should be hidden from the UI.""" return HIDDEN_STRING in self.raw_name - def update(self): - """Update state of the sensor.""" - # ISY objects are automatically updated by the ISY's event stream + @property + def unit_of_measurement(self) -> str: + """Get the device unit of measure.""" + return None + + def _attr_filter(self, attr: str) -> str: + """Filter the attribute.""" + # pylint: disable=no-self-use + return attr + + def update(self) -> None: + """Perform an update for the device.""" pass - - def on_update(self, event): - """Handle the update received event.""" - self.update_ha_state() - - @property - def is_on(self): - """Return a boolean response if the node is on.""" - return bool(self.value) - - @property - def is_open(self): - """Return boolean response if the node is open. On = Open.""" - return self.is_on - - @property - def state(self): - """Return the state of the node.""" - if len(self._states) > 0: - return self._states[0] if self.is_on else self._states[1] - return self.value - - def turn_on(self, **kwargs): - """Turn the device on.""" - if self.domain is not 'sensor': - attrs = [kwargs.get(name) for name in self._onattrs] - self.node.on(*attrs) - else: - _LOGGER.error('ISY cannot turn on sensors.') - - def turn_off(self, **kwargs): - """Turn the device off.""" - if self.domain is not 'sensor': - self.node.off() - else: - _LOGGER.error('ISY cannot turn off sensors.') - - @property - def unit_of_measurement(self): - """Return the defined units of measurement or None.""" - try: - return self.node.units - except AttributeError: - return None diff --git a/homeassistant/components/light/isy994.py b/homeassistant/components/light/isy994.py index 031fa7debb6..fe7abbc26e8 100644 --- a/homeassistant/components/light/isy994.py +++ b/homeassistant/components/light/isy994.py @@ -2,58 +2,68 @@ Support for ISY994 lights. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/isy994/ +https://home-assistant.io/components/light.isy994/ """ import logging +from typing import Callable -from homeassistant.components.isy994 import ( - HIDDEN_STRING, ISY, SENSOR_STRING, ISYDeviceABC) -from homeassistant.components.light import (ATTR_BRIGHTNESS, - ATTR_SUPPORTED_FEATURES, - SUPPORT_BRIGHTNESS) -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.components.light import Light +import homeassistant.components.isy994 as isy +from homeassistant.const import STATE_ON, STATE_OFF, STATE_UNKNOWN +from homeassistant.helpers.typing import ConfigType -SUPPORT_ISY994 = SUPPORT_BRIGHTNESS +_LOGGER = logging.getLogger(__name__) + +VALUE_TO_STATE = { + False: STATE_OFF, + True: STATE_ON, +} + +UOM = ['2', '78'] +STATES = [STATE_OFF, STATE_ON, 'true', 'false'] -def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the ISY994 platform.""" - logger = logging.getLogger(__name__) - devs = [] - - if ISY is None or not ISY.connected: - logger.error('A connection has not been made to the ISY controller.') +# pylint: disable=unused-argument +def setup_platform(hass, config: ConfigType, + add_devices: Callable[[list], None], discovery_info=None): + """Set up the ISY994 light platform.""" + if isy.ISY is None or not isy.ISY.connected: + _LOGGER.error('A connection has not been made to the ISY controller.') return False - # Import dimmable nodes - for (path, node) in ISY.nodes: - if node.dimmable and SENSOR_STRING not in node.name: - if HIDDEN_STRING in path: - node.name += HIDDEN_STRING - devs.append(ISYLightDevice(node)) + devices = [] - add_devices(devs) + for node in isy.filter_nodes(isy.NODES, units=UOM, + states=STATES): + if node.dimmable: + devices.append(ISYLightDevice(node)) + + add_devices(devices) -class ISYLightDevice(ISYDeviceABC): - """Representation of a ISY light.""" +class ISYLightDevice(isy.ISYDevice, Light): + """Representation of an ISY994 light devie.""" - _domain = 'light' - _dtype = 'analog' - _attrs = { - ATTR_BRIGHTNESS: 'value', - ATTR_SUPPORTED_FEATURES: 'supported_features', - } - _onattrs = [ATTR_BRIGHTNESS] - _states = [STATE_ON, STATE_OFF] + def __init__(self, node: object) -> None: + """Initialize the ISY994 light device.""" + isy.ISYDevice.__init__(self, node) @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_ISY994 + def is_on(self) -> bool: + """Get whether the ISY994 light is on.""" + return self.state == STATE_ON - def _attr_filter(self, attr): - """Filter brightness out of entity while off.""" - if ATTR_BRIGHTNESS in attr and not self.is_on: - del attr[ATTR_BRIGHTNESS] - return attr + @property + def state(self) -> str: + """Get the state of the ISY994 light.""" + return VALUE_TO_STATE.get(bool(self.value), STATE_UNKNOWN) + + def turn_off(self, **kwargs) -> None: + """Send the turn off command to the ISY994 light device.""" + if not self._node.fastOff(): + _LOGGER.debug('Unable to turn on light.') + + def turn_on(self, brightness=100, **kwargs) -> None: + """Send the turn on command to the ISY994 light device.""" + if not self._node.on(val=brightness): + _LOGGER.debug('Unable to turn on light.') diff --git a/homeassistant/components/lock/isy994.py b/homeassistant/components/lock/isy994.py new file mode 100644 index 00000000000..d7e921a16e5 --- /dev/null +++ b/homeassistant/components/lock/isy994.py @@ -0,0 +1,123 @@ +""" +Support for ISY994 locks. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.isy994/ +""" +import logging +from typing import Callable # noqa + +from homeassistant.components.lock import LockDevice, DOMAIN +import homeassistant.components.isy994 as isy +from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN +from homeassistant.helpers.typing import ConfigType + +_LOGGER = logging.getLogger(__name__) + +VALUE_TO_STATE = { + 0: STATE_UNLOCKED, + 100: STATE_LOCKED +} + +UOM = ['11'] +STATES = [STATE_LOCKED, STATE_UNLOCKED] + + +# pylint: disable=unused-argument +def setup_platform(hass, config: ConfigType, + add_devices: Callable[[list], None], discovery_info=None): + """Set up the ISY994 lock platform.""" + if isy.ISY is None or not isy.ISY.connected: + _LOGGER.error('A connection has not been made to the ISY controller.') + return False + + devices = [] + + for node in isy.filter_nodes(isy.NODES, units=UOM, + states=STATES): + devices.append(ISYLockDevice(node)) + + for program in isy.PROGRAMS.get(DOMAIN, []): + try: + status = program[isy.KEY_STATUS] + actions = program[isy.KEY_ACTIONS] + assert actions.dtype == 'program', 'Not a program' + except (KeyError, AssertionError): + pass + else: + devices.append(ISYLockProgram(program.name, status, actions)) + + add_devices(devices) + + +class ISYLockDevice(isy.ISYDevice, LockDevice): + """Representation of an ISY994 lock device.""" + + def __init__(self, node) -> None: + """Initialize the ISY994 lock device.""" + isy.ISYDevice.__init__(self, node) + self._conn = node.parent.parent.conn + + @property + def is_locked(self) -> bool: + """Get whether the lock is in locked state.""" + return self.state == STATE_LOCKED + + @property + def state(self) -> str: + """Get the state of the lock.""" + return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) + + def lock(self, **kwargs) -> None: + """Send the lock command to the ISY994 device.""" + # Hack until PyISY is updated + req_url = self._conn.compileURL(['nodes', self.unique_id, 'cmd', + 'SECMD', '1']) + response = self._conn.request(req_url) + + if response is None: + _LOGGER.error('Unable to lock device') + + self._node.update(0.5) + + def unlock(self, **kwargs) -> None: + """Send the unlock command to the ISY994 device.""" + # Hack until PyISY is updated + req_url = self._conn.compileURL(['nodes', self.unique_id, 'cmd', + 'SECMD', '0']) + response = self._conn.request(req_url) + + if response is None: + _LOGGER.error('Unable to lock device') + + self._node.update(0.5) + + +class ISYLockProgram(ISYLockDevice): + """Representation of a ISY lock program.""" + + def __init__(self, name: str, node, actions) -> None: + """Initialize the lock.""" + ISYLockDevice.__init__(self, node) + self._name = name + self._actions = actions + + @property + def is_locked(self) -> bool: + """Return true if the device is locked.""" + return bool(self.value) + + @property + def state(self) -> str: + """Return the state of the lock.""" + return STATE_LOCKED if self.is_locked else STATE_UNLOCKED + + def lock(self, **kwargs) -> None: + """Lock the device.""" + if not self._actions.runThen(): + _LOGGER.error('Unable to lock device') + + def unlock(self, **kwargs) -> None: + """Unlock the device.""" + if not self._actions.runElse(): + _LOGGER.error('Unable to unlock device') diff --git a/homeassistant/components/sensor/isy994.py b/homeassistant/components/sensor/isy994.py index 237a1228d6c..df3ae9ed7ba 100644 --- a/homeassistant/components/sensor/isy994.py +++ b/homeassistant/components/sensor/isy994.py @@ -1,95 +1,311 @@ """ -Support for ISY994 sensors. +Support for ISY994 binary sensors. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/isy994/ +https://home-assistant.io/components/binary_sensor.isy994/ """ import logging +from typing import Callable # noqa -from homeassistant.components.isy994 import ( - HIDDEN_STRING, ISY, SENSOR_STRING, ISYDeviceABC) -from homeassistant.const import ( - STATE_CLOSED, STATE_HOME, STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN) +import homeassistant.components.isy994 as isy +from homeassistant.const import (TEMP_CELSIUS, TEMP_FAHRENHEIT, STATE_OFF, + STATE_ON) +from homeassistant.helpers.typing import ConfigType -DEFAULT_HIDDEN_WEATHER = ['Temperature_High', 'Temperature_Low', 'Feels_Like', - 'Temperature_Average', 'Pressure', 'Dew_Point', - 'Gust_Speed', 'Evapotranspiration', - 'Irrigation_Requirement', 'Water_Deficit_Yesterday', - 'Elevation', 'Average_Temperature_Tomorrow', - 'High_Temperature_Tomorrow', - 'Low_Temperature_Tomorrow', 'Humidity_Tomorrow', - 'Wind_Speed_Tomorrow', 'Gust_Speed_Tomorrow', - 'Rain_Tomorrow', 'Snow_Tomorrow', - 'Forecast_Average_Temperature', - 'Forecast_High_Temperature', - 'Forecast_Low_Temperature', 'Forecast_Humidity', - 'Forecast_Rain', 'Forecast_Snow'] +_LOGGER = logging.getLogger(__name__) + +UOM_FRIENDLY_NAME = { + '1': 'amp', + '3': 'btu/h', + '4': TEMP_CELSIUS, + '5': 'cm', + '6': 'ft³', + '7': 'ft³/min', + '8': 'm³', + '9': 'day', + '10': 'days', + '12': 'dB', + '13': 'dB A', + '14': '°', + '16': 'macroseismic', + '17': TEMP_FAHRENHEIT, + '18': 'ft', + '19': 'hour', + '20': 'hours', + '21': 'abs. humidity (%)', + '22': 'rel. humidity (%)', + '23': 'inHg', + '24': 'in/hr', + '25': 'index', + '26': 'K', + '27': 'keyword', + '28': 'kg', + '29': 'kV', + '30': 'kW', + '31': 'kPa', + '32': 'KPH', + '33': 'kWH', + '34': 'liedu', + '35': 'l', + '36': 'lux', + '37': 'mercalli', + '38': 'm', + '39': 'm³/hr', + '40': 'm/s', + '41': 'mA', + '42': 'ms', + '43': 'mV', + '44': 'min', + '45': 'min', + '46': 'mm/hr', + '47': 'month', + '48': 'MPH', + '49': 'm/s', + '50': 'ohm', + '51': '%', + '52': 'lb', + '53': 'power factor', + '54': 'ppm', + '55': 'pulse count', + '57': 's', + '58': 's', + '59': 'seimens/m', + '60': 'body wave magnitude scale', + '61': 'Ricter scale', + '62': 'moment magnitude scale', + '63': 'surface wave magnitude scale', + '64': 'shindo', + '65': 'SML', + '69': 'gal', + '71': 'UV index', + '72': 'V', + '73': 'W', + '74': 'W/m²', + '75': 'weekday', + '76': 'Wind Direction (°)', + '77': 'year', + '82': 'mm', + '83': 'km', + '85': 'ohm', + '86': 'kOhm', + '87': 'm³/m³', + '88': 'Water activity', + '89': 'RPM', + '90': 'Hz', + '91': '° (Relative to North)', + '92': '° (Relative to South)', +} + +UOM_TO_STATES = { + '11': { + '0': 'unlocked', + '100': 'locked', + '102': 'jammed', + }, + '15': { + '1': 'master code changed', + '2': 'tamper code entry limit', + '3': 'escutcheon removed', + '4': 'key/manually locked', + '5': 'locked by touch', + '6': 'key/manually unlocked', + '7': 'remote locking jammed bolt', + '8': 'remotely locked', + '9': 'remotely unlocked', + '10': 'deadbolt jammed', + '11': 'battery too low to operate', + '12': 'critical low battery', + '13': 'low battery', + '14': 'automatically locked', + '15': 'automatic locking jammed bolt', + '16': 'remotely power cycled', + '17': 'lock handling complete', + '19': 'user deleted', + '20': 'user added', + '21': 'duplicate pin', + '22': 'jammed bolt by locking with keypad', + '23': 'locked by keypad', + '24': 'unlocked by keypad', + '25': 'keypad attempt outside schedule', + '26': 'hardware failure', + '27': 'factory reset' + }, + '66': { + '0': 'idle', + '1': 'heating', + '2': 'cooling', + '3': 'fan only', + '4': 'pending heat', + '5': 'pending cool', + '6': 'vent', + '7': 'aux heat', + '8': '2nd stage heating', + '9': '2nd stage cooling', + '10': '2nd stage aux heat', + '11': '3rd stage aux heat' + }, + '67': { + '0': 'off', + '1': 'heat', + '2': 'cool', + '3': 'auto', + '4': 'aux/emergency heat', + '5': 'resume', + '6': 'fan only', + '7': 'furnace', + '8': 'dry air', + '9': 'moist air', + '10': 'auto changeover', + '11': 'energy save heat', + '12': 'energy save cool', + '13': 'away' + }, + '68': { + '0': 'auto', + '1': 'on', + '2': 'auto high', + '3': 'high', + '4': 'auto medium', + '5': 'medium', + '6': 'circulation', + '7': 'humidity circulation' + }, + '93': { + '1': 'power applied', + '2': 'ac mains disconnected', + '3': 'ac mains reconnected', + '4': 'surge detection', + '5': 'volt drop or drift', + '6': 'over current detected', + '7': 'over voltage detected', + '8': 'over load detected', + '9': 'load error', + '10': 'replace battery soon', + '11': 'replace battery now', + '12': 'battery is charging', + '13': 'battery is fully charged', + '14': 'charge battery soon', + '15': 'charge battery now' + }, + '94': { + '1': 'program started', + '2': 'program in progress', + '3': 'program completed', + '4': 'replace main filter', + '5': 'failure to set target temperature', + '6': 'supplying water', + '7': 'water supply failure', + '8': 'boiling', + '9': 'boiling failure', + '10': 'washing', + '11': 'washing failure', + '12': 'rinsing', + '13': 'rinsing failure', + '14': 'draining', + '15': 'draining failure', + '16': 'spinning', + '17': 'spinning failure', + '18': 'drying', + '19': 'drying failure', + '20': 'fan failure', + '21': 'compressor failure' + }, + '95': { + '1': 'leaving bed', + '2': 'sitting on bed', + '3': 'lying on bed', + '4': 'posture changed', + '5': 'sitting on edge of bed' + }, + '96': { + '1': 'clean', + '2': 'slightly polluted', + '3': 'moderately polluted', + '4': 'highly polluted' + }, + '97': { + '0': 'closed', + '100': 'open', + '102': 'stopped', + '103': 'closing', + '104': 'opening' + } +} + +BINARY_UOM = ['2', '78'] -def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the ISY994 platform.""" - # pylint: disable=protected-access - logger = logging.getLogger(__name__) - devs = [] - # Verify connection - if ISY is None or not ISY.connected: - logger.error('A connection has not been made to the ISY controller.') +# pylint: disable=unused-argument +def setup_platform(hass, config: ConfigType, + add_devices: Callable[[list], None], discovery_info=None): + """Setup the ISY994 sensor platform.""" + if isy.ISY is None or not isy.ISY.connected: + _LOGGER.error('A connection has not been made to the ISY controller.') return False - # Import weather - if ISY.climate is not None: - for prop in ISY.climate._id2name: - if prop is not None: - prefix = HIDDEN_STRING \ - if prop in DEFAULT_HIDDEN_WEATHER else '' - node = WeatherPseudoNode('ISY.weather.' + prop, prefix + prop, - getattr(ISY.climate, prop), - getattr(ISY.climate, prop + '_units')) - devs.append(ISYSensorDevice(node)) + devices = [] - # Import sensor nodes - for (path, node) in ISY.nodes: - if SENSOR_STRING in node.name: - if HIDDEN_STRING in path: - node.name += HIDDEN_STRING - devs.append(ISYSensorDevice(node, [STATE_ON, STATE_OFF])) + for node in isy.SENSOR_NODES: + if (len(node.uom) == 0 or node.uom[0] not in BINARY_UOM) and \ + STATE_OFF not in node.uom and STATE_ON not in node.uom: + _LOGGER.debug('LOADING %s', node.name) + devices.append(ISYSensorDevice(node)) - # Import sensor programs - for (folder_name, states) in ( - ('HA.locations', [STATE_HOME, STATE_NOT_HOME]), - ('HA.sensors', [STATE_OPEN, STATE_CLOSED]), - ('HA.states', [STATE_ON, STATE_OFF])): - try: - folder = ISY.programs['My Programs'][folder_name] - except KeyError: - # folder does not exist - pass + add_devices(devices) + + +class ISYSensorDevice(isy.ISYDevice): + """Representation of an ISY994 sensor device.""" + + def __init__(self, node) -> None: + """Initialize the ISY994 sensor device.""" + isy.ISYDevice.__init__(self, node) + + @property + def raw_unit_of_measurement(self) -> str: + """Get the raw unit of measurement for the ISY994 sensor device.""" + if len(self._node.uom) == 1: + if self._node.uom[0] in UOM_FRIENDLY_NAME: + friendly_name = UOM_FRIENDLY_NAME.get(self._node.uom[0]) + if friendly_name == TEMP_CELSIUS or \ + friendly_name == TEMP_FAHRENHEIT: + friendly_name = self.hass.config.units.temperature_unit + return friendly_name + else: + return self._node.uom[0] else: - for _, _, node_id in folder.children: - node = folder[node_id].leaf - devs.append(ISYSensorDevice(node, states)) + return None - add_devices(devs) + @property + def state(self) -> str: + """Get the state of the ISY994 sensor device.""" + if len(self._node.uom) == 1: + if self._node.uom[0] in UOM_TO_STATES: + states = UOM_TO_STATES.get(self._node.uom[0]) + if self.value in states: + return states.get(self.value) + elif self._node.prec and self._node.prec != [0]: + str_val = str(self.value) + int_prec = int(self._node.prec) + decimal_part = str_val[-int_prec:] + whole_part = str_val[:len(str_val) - int_prec] + val = float('{}.{}'.format(whole_part, decimal_part)) + raw_units = self.raw_unit_of_measurement + if raw_units in ( + TEMP_CELSIUS, TEMP_FAHRENHEIT): + val = self.hass.config.units.temperature(val, raw_units) + return str(val) + else: + return self.value -class WeatherPseudoNode(object): - """This class allows weather variable to act as regular nodes.""" + return None - # pylint: disable=too-few-public-methods - def __init__(self, device_id, name, status, units=None): - """Initialize the sensor.""" - self._id = device_id - self.name = name - self.status = status - self.units = units - - -class ISYSensorDevice(ISYDeviceABC): - """Representation of an ISY sensor.""" - - _domain = 'sensor' - - def __init__(self, node, states=None): - """Initialize the device.""" - super().__init__(node) - self._states = states or [] + @property + def unit_of_measurement(self) -> str: + """Get the unit of measurement for the ISY994 sensor device.""" + raw_units = self.raw_unit_of_measurement + if raw_units in (TEMP_FAHRENHEIT, TEMP_CELSIUS): + return self.hass.config.units.temperature_unit + else: + return raw_units diff --git a/homeassistant/components/switch/isy994.py b/homeassistant/components/switch/isy994.py index 004c82b8ad0..b930bedc2c7 100644 --- a/homeassistant/components/switch/isy994.py +++ b/homeassistant/components/switch/isy994.py @@ -2,87 +2,106 @@ Support for ISY994 switches. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/isy994/ +https://home-assistant.io/components/switch.isy994/ """ import logging +from typing import Callable # noqa -from homeassistant.components.isy994 import ( - HIDDEN_STRING, ISY, SENSOR_STRING, ISYDeviceABC) -from homeassistant.const import STATE_OFF, STATE_ON # STATE_OPEN, STATE_CLOSED +from homeassistant.components.switch import SwitchDevice, DOMAIN +import homeassistant.components.isy994 as isy +from homeassistant.const import STATE_ON, STATE_OFF, STATE_UNKNOWN +from homeassistant.helpers.typing import ConfigType # noqa + +_LOGGER = logging.getLogger(__name__) + +VALUE_TO_STATE = { + False: STATE_OFF, + True: STATE_ON, +} + +UOM = ['2', '78'] +STATES = [STATE_OFF, STATE_ON, 'true', 'false'] -# The frontend doesn't seem to fully support the open and closed states yet. -# Once it does, the HA.doors programs should report open and closed instead of -# off and on. It appears that on should be open and off should be closed. - - -def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the ISY994 platform.""" - # pylint: disable=too-many-locals - logger = logging.getLogger(__name__) - devs = [] - # verify connection - if ISY is None or not ISY.connected: - logger.error('A connection has not been made to the ISY controller.') +# pylint: disable=unused-argument +def setup_platform(hass, config: ConfigType, + add_devices: Callable[[list], None], discovery_info=None): + """Set up the ISY994 switch platform.""" + if isy.ISY is None or not isy.ISY.connected: + _LOGGER.error('A connection has not been made to the ISY controller.') return False - # Import not dimmable nodes and groups - for (path, node) in ISY.nodes: - if not node.dimmable and SENSOR_STRING not in node.name: - if HIDDEN_STRING in path: - node.name += HIDDEN_STRING - devs.append(ISYSwitchDevice(node)) + devices = [] - # Import ISY doors programs - for folder_name, states in (('HA.doors', [STATE_ON, STATE_OFF]), - ('HA.switches', [STATE_ON, STATE_OFF])): + for node in isy.filter_nodes(isy.NODES, units=UOM, + states=STATES): + if not node.dimmable: + devices.append(ISYSwitchDevice(node)) + + for node in isy.GROUPS: + devices.append(ISYSwitchDevice(node)) + + for program in isy.PROGRAMS.get(DOMAIN, []): try: - folder = ISY.programs['My Programs'][folder_name] - except KeyError: - # HA.doors folder does not exist + status = program[isy.KEY_STATUS] + actions = program[isy.KEY_ACTIONS] + assert actions.dtype == 'program', 'Not a program' + except (KeyError, AssertionError): pass else: - for dtype, name, node_id in folder.children: - if dtype is 'folder': - custom_switch = folder[node_id] - try: - actions = custom_switch['actions'].leaf - assert actions.dtype == 'program', 'Not a program' - node = custom_switch['status'].leaf - except (KeyError, AssertionError): - pass - else: - devs.append(ISYProgramDevice(name, node, actions, - states)) + devices.append(ISYSwitchProgram(program.name, status, actions)) - add_devices(devs) + add_devices(devices) -class ISYSwitchDevice(ISYDeviceABC): - """Representation of an ISY switch.""" +class ISYSwitchDevice(isy.ISYDevice, SwitchDevice): + """Representation of an ISY994 switch device.""" - _domain = 'switch' - _dtype = 'binary' - _states = [STATE_ON, STATE_OFF] + def __init__(self, node) -> None: + """Initialize the ISY994 switch device.""" + isy.ISYDevice.__init__(self, node) + + @property + def is_on(self) -> bool: + """Get whether the ISY994 device is in the on state.""" + return self.state == STATE_ON + + @property + def state(self) -> str: + """Get the state of the ISY994 device.""" + return VALUE_TO_STATE.get(bool(self.value), STATE_UNKNOWN) + + def turn_off(self, **kwargs) -> None: + """Send the turn on command to the ISY994 switch.""" + if not self._node.off(): + _LOGGER.debug('Unable to turn on switch.') + + def turn_on(self, **kwargs) -> None: + """Send the turn off command to the ISY994 switch.""" + if not self._node.on(): + _LOGGER.debug('Unable to turn on switch.') -class ISYProgramDevice(ISYSwitchDevice): - """Representation of an ISY door.""" +class ISYSwitchProgram(ISYSwitchDevice): + """A representation of an ISY994 program switch.""" - _domain = 'switch' - _dtype = 'binary' - - def __init__(self, name, node, actions, states): - """Initialize the switch.""" - super().__init__(node) - self._states = states + def __init__(self, name: str, node, actions) -> None: + """Initialize the ISY994 switch program.""" + ISYSwitchDevice.__init__(self, node) self._name = name - self.action_node = actions + self._actions = actions - def turn_on(self, **kwargs): - """Turn the device on/close the device.""" - self.action_node.runThen() + @property + def is_on(self) -> bool: + """Get whether the ISY994 switch program is on.""" + return bool(self.value) - def turn_off(self, **kwargs): - """Turn the device off/open the device.""" - self.action_node.runElse() + def turn_on(self, **kwargs) -> None: + """Send the turn on command to the ISY994 switch program.""" + if not self._actions.runThen(): + _LOGGER.error('Unable to turn on switch') + + def turn_off(self, **kwargs) -> None: + """Send the turn off command to the ISY994 switch program.""" + if not self._actions.runElse(): + _LOGGER.error('Unable to turn off switch') diff --git a/requirements_all.txt b/requirements_all.txt index 61d221ba7fb..731f8c25439 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -8,7 +8,7 @@ voluptuous==0.9.2 typing>=3,<4 # homeassistant.components.isy994 -PyISY==1.0.6 +PyISY==1.0.7 # homeassistant.components.notify.html5 PyJWT==1.4.2