From 3beb87c54d6c6f927bde75d204b30cfa06268705 Mon Sep 17 00:00:00 2001 From: David McNett Date: Tue, 21 Feb 2017 01:53:39 -0600 Subject: [PATCH] New component 'insteon_plm' and related platforms (#6104) * Connect to PLM and process simple protocol callbacks * Baseline commit * Connect to PLM and process simple protocol callbacks * Baseline commit * Connection working again * Async add devices is working via callback now * Beginning to interface with PLM library for control and state * Deal with brightness in 255 levels with library * Change sub names to match API changes * Remove PLM-level update callback * Support dimmable based on underlying PLM device attributes * Expand to non-light platforms * Stubs for turn on and off * Current version of Python library * Amend to use switch device attributes * Use asyncio endpoints for control * Add logging line * Bump module version to 0.7.1 * Auto-load platforms, display device info/attributes * Unify method name for getting a device attribute * Require Current version of insteonplm module * Import the component function in each platform in the balloob-recommend manner * For consistency, handle switch state as onlevel just like lights * Use level 0xff for on state, even with binary switches Observing the behavior of a 2477S switch, it looks like even the non-dimmable devices use 0x00 and 0xff for off/on respectively. I was using 0x01 for on previously, but that yields unnecessary state change callbacks when message traffic ends up flipping the onlevel from 0xff to 0x01 or 0x01 to 0xff. * Use sensorstate attribute for sensor onoff * Move new device callback to devices attribute * Add support for platform override on a device * Bump version of insteonplm module * Default overrides is an empty list * Avoid calling private methods when doing common attributes * Remove unused CONF_DEBUG for now * flake8 and pylint code cleanup * Move get_component to local function where it is needed * Update to include insteonplm module. * New files for insteon_plm component * Legitimate class doctring instead of stub * Docstring changes. * Style changes as requested by @SEJeff * Changes requested by @pvizeli * Add @callback decorator to callback functions * Opportunistic platform loading triggered by qualifying device detection Instead of loading all the constituent platforms that comprise the insteon_plm component, instead we defer and wait until we receive a callback for a device that requires the platform. --- .coveragerc | 3 + .../components/binary_sensor/insteon_plm.py | 87 +++++++++++++ homeassistant/components/insteon_plm.py | 117 +++++++++++++++++ homeassistant/components/light/insteon_plm.py | 119 ++++++++++++++++++ .../components/switch/insteon_plm.py | 97 ++++++++++++++ requirements_all.txt | 3 + 6 files changed, 426 insertions(+) create mode 100644 homeassistant/components/binary_sensor/insteon_plm.py create mode 100644 homeassistant/components/insteon_plm.py create mode 100644 homeassistant/components/light/insteon_plm.py create mode 100644 homeassistant/components/switch/insteon_plm.py diff --git a/.coveragerc b/.coveragerc index 72d6460d858..ec649517113 100644 --- a/.coveragerc +++ b/.coveragerc @@ -41,6 +41,9 @@ omit = homeassistant/components/insteon_local.py homeassistant/components/*/insteon_local.py + homeassistant/components/insteon_plm.py + homeassistant/components/*/insteon_plm.py + homeassistant/components/ios.py homeassistant/components/*/ios.py diff --git a/homeassistant/components/binary_sensor/insteon_plm.py b/homeassistant/components/binary_sensor/insteon_plm.py new file mode 100644 index 00000000000..f6c8d9edbd1 --- /dev/null +++ b/homeassistant/components/binary_sensor/insteon_plm.py @@ -0,0 +1,87 @@ +""" +Support for INSTEON dimmers via PowerLinc Modem. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/insteon_plm/ +""" +import logging +import asyncio + +from homeassistant.core import callback +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.loader import get_component + +DEPENDENCIES = ['insteon_plm'] + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the INSTEON PLM device class for the hass platform.""" + plm = hass.data['insteon_plm'] + + device_list = [] + for device in discovery_info: + name = device.get('address') + address = device.get('address_hex') + + _LOGGER.info('Registered %s with binary_sensor platform.', name) + + device_list.append( + InsteonPLMBinarySensorDevice(hass, plm, address, name) + ) + + hass.async_add_job(async_add_devices(device_list)) + + +class InsteonPLMBinarySensorDevice(BinarySensorDevice): + """A Class for an Insteon device.""" + + def __init__(self, hass, plm, address, name): + """Initialize the binarysensor.""" + self._hass = hass + self._plm = plm.protocol + self._address = address + self._name = name + + self._plm.add_update_callback( + self.async_binarysensor_update, {'address': self._address}) + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def address(self): + """Return the the address of the node.""" + return self._address + + @property + def name(self): + """Return the the name of the node.""" + return self._name + + @property + def is_on(self): + """Return the boolean response if the node is on.""" + sensorstate = self._plm.get_device_attr(self._address, 'sensorstate') + _LOGGER.info('sensor state for %s is %s', self._address, sensorstate) + return bool(sensorstate) + + @property + def device_state_attributes(self): + """Provide attributes for display on device card.""" + insteon_plm = get_component('insteon_plm') + return insteon_plm.common_attributes(self) + + def get_attr(self, key): + """Return specified attribute for this device.""" + return self._plm.get_device_attr(self.address, key) + + @callback + def async_binarysensor_update(self, message): + """Receive notification from transport that new data exists.""" + _LOGGER.info('Received update calback from PLM for %s', self._address) + self._hass.async_add_job(self.async_update_ha_state()) diff --git a/homeassistant/components/insteon_plm.py b/homeassistant/components/insteon_plm.py new file mode 100644 index 00000000000..d786de7ac8a --- /dev/null +++ b/homeassistant/components/insteon_plm.py @@ -0,0 +1,117 @@ +""" +Support for INSTEON PowerLinc Modem. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/insteon_plm/ +""" +import logging +import asyncio + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import ( + CONF_PORT, EVENT_HOMEASSISTANT_STOP) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import discovery + +REQUIREMENTS = ['insteonplm==0.7.4'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'insteon_plm' + +CONF_OVERRIDE = 'device_override' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_PORT): cv.string, + vol.Optional(CONF_OVERRIDE, default=[]): vol.All( + cv.ensure_list_csv, vol.Length(min=1)) + }) +}, extra=vol.ALLOW_EXTRA) + +PLM_PLATFORMS = { + 'binary_sensor': ['binary_sensor'], + 'light': ['light'], + 'switch': ['switch'], +} + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up our connection to the PLM.""" + import insteonplm + + conf = config[DOMAIN] + port = conf.get(CONF_PORT) + overrides = conf.get(CONF_OVERRIDE) + + @callback + def async_plm_new_device(device): + """New device detected from transport to be delegated to platform.""" + name = device.get('address') + address = device.get('address_hex') + capabilities = device.get('capabilities', []) + + _LOGGER.info('New INSTEON PLM device: %s (%s) %r', + name, address, capabilities) + + loadlist = [] + for platform in PLM_PLATFORMS: + caplist = PLM_PLATFORMS.get(platform) + for key in capabilities: + if key in caplist: + loadlist.append(platform) + + loadlist = sorted(set(loadlist)) + + for loadplatform in loadlist: + hass.async_add_job( + discovery.async_load_platform( + hass, loadplatform, DOMAIN, discovered=[device], + hass_config=config)) + + _LOGGER.info('Looking for PLM on %s', port) + plm = yield from insteonplm.Connection.create(device=port, loop=hass.loop) + + for device in overrides: + # + # Override the device default capabilities for a specific address + # + plm.protocol.devices.add_override( + device['address'], 'capabilities', [device['platform']]) + + hass.data['insteon_plm'] = plm + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, plm.close) + + plm.protocol.devices.add_device_callback(async_plm_new_device, {}) + + return True + + +def common_attributes(entity): + """Return the device state attributes.""" + attributes = {} + attributekeys = { + 'address': 'INSTEON Address', + 'description': 'Description', + 'model': 'Model', + 'cat': 'Cagegory', + 'subcat': 'Subcategory', + 'firmware': 'Firmware', + 'product_key': 'Product Key' + } + + hexkeys = ['cat', 'subcat', 'firmware'] + + for key in attributekeys: + name = attributekeys[key] + val = entity.get_attr(key) + if val is not None: + if key in hexkeys: + attributes[name] = hex(int(val)) + else: + attributes[name] = val + return attributes diff --git a/homeassistant/components/light/insteon_plm.py b/homeassistant/components/light/insteon_plm.py new file mode 100644 index 00000000000..2d043a9f985 --- /dev/null +++ b/homeassistant/components/light/insteon_plm.py @@ -0,0 +1,119 @@ +""" +Support for INSTEON lights via PowerLinc Modem. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/insteon_plm/ +""" +import logging +import asyncio + +from homeassistant.core import callback +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) +from homeassistant.loader import get_component + +DEPENDENCIES = ['insteon_plm'] + +MAX_BRIGHTNESS = 255 + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the INSTEON PLM device class for the hass platform.""" + plm = hass.data['insteon_plm'] + + device_list = [] + for device in discovery_info: + name = device.get('address') + address = device.get('address_hex') + dimmable = bool('dimmable' in device.get('capabilities')) + + _LOGGER.info('Registered %s with light platform.', name) + + device_list.append( + InsteonPLMDimmerDevice(hass, plm, address, name, dimmable) + ) + + hass.async_add_job(async_add_devices(device_list)) + + +class InsteonPLMDimmerDevice(Light): + """A Class for an Insteon device.""" + + def __init__(self, hass, plm, address, name, dimmable): + """Initialize the light.""" + self._hass = hass + self._plm = plm.protocol + self._address = address + self._name = name + self._dimmable = dimmable + + self._plm.add_update_callback( + self.async_light_update, {'address': self._address}) + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def address(self): + """Return the the address of the node.""" + return self._address + + @property + def name(self): + """Return the the name of the node.""" + return self._name + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + onlevel = self._plm.get_device_attr(self._address, 'onlevel') + _LOGGER.debug('on level for %s is %s', self._address, onlevel) + return int(onlevel) + + @property + def is_on(self): + """Return the boolean response if the node is on.""" + onlevel = self._plm.get_device_attr(self._address, 'onlevel') + _LOGGER.debug('on level for %s is %s', self._address, onlevel) + return bool(onlevel) + + @property + def supported_features(self): + """Flag supported features.""" + if self._dimmable: + return SUPPORT_BRIGHTNESS + + @property + def device_state_attributes(self): + """Provide attributes for display on device card.""" + insteon_plm = get_component('insteon_plm') + return insteon_plm.common_attributes(self) + + def get_attr(self, key): + """Return specified attribute for this device.""" + return self._plm.get_device_attr(self.address, key) + + @callback + def async_light_update(self, message): + """Receive notification from transport that new data exists.""" + _LOGGER.info('Received update calback from PLM for %s', self._address) + self._hass.async_add_job(self.async_update_ha_state()) + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn device on.""" + if ATTR_BRIGHTNESS in kwargs: + brightness = int(kwargs[ATTR_BRIGHTNESS]) + else: + brightness = MAX_BRIGHTNESS + self._plm.turn_on(self._address, brightness=brightness) + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn device off.""" + self._plm.turn_off(self._address) diff --git a/homeassistant/components/switch/insteon_plm.py b/homeassistant/components/switch/insteon_plm.py new file mode 100644 index 00000000000..646e6d4416b --- /dev/null +++ b/homeassistant/components/switch/insteon_plm.py @@ -0,0 +1,97 @@ +""" +Support for INSTEON dimmers via PowerLinc Modem. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/insteon_plm/ +""" +import logging +import asyncio + +from homeassistant.core import callback +from homeassistant.components.switch import (SwitchDevice) +from homeassistant.loader import get_component + +DEPENDENCIES = ['insteon_plm'] + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the INSTEON PLM device class for the hass platform.""" + plm = hass.data['insteon_plm'] + + device_list = [] + for device in discovery_info: + name = device.get('address') + address = device.get('address_hex') + + _LOGGER.info('Registered %s with switch platform.', name) + + device_list.append( + InsteonPLMSwitchDevice(hass, plm, address, name) + ) + + hass.async_add_job(async_add_devices(device_list)) + + +class InsteonPLMSwitchDevice(SwitchDevice): + """A Class for an Insteon device.""" + + def __init__(self, hass, plm, address, name): + """Initialize the switch.""" + self._hass = hass + self._plm = plm.protocol + self._address = address + self._name = name + + self._plm.add_update_callback( + self.async_switch_update, {'address': self._address}) + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def address(self): + """Return the the address of the node.""" + return self._address + + @property + def name(self): + """Return the the name of the node.""" + return self._name + + @property + def is_on(self): + """Return the boolean response if the node is on.""" + onlevel = self._plm.get_device_attr(self._address, 'onlevel') + _LOGGER.debug('on level for %s is %s', self._address, onlevel) + return bool(onlevel) + + @property + def device_state_attributes(self): + """Provide attributes for display on device card.""" + insteon_plm = get_component('insteon_plm') + return insteon_plm.common_attributes(self) + + def get_attr(self, key): + """Return specified attribute for this device.""" + return self._plm.get_device_attr(self.address, key) + + @callback + def async_switch_update(self, message): + """Receive notification from transport that new data exists.""" + _LOGGER.info('Received update calback from PLM for %s', self._address) + self._hass.async_add_job(self.async_update_ha_state()) + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn device on.""" + self._plm.turn_on(self._address) + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn device off.""" + self._plm.turn_off(self._address) diff --git a/requirements_all.txt b/requirements_all.txt index 83f8ec3a7f3..c99b836a079 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -297,6 +297,9 @@ insteon_hub==0.4.5 # homeassistant.components.insteon_local insteonlocal==0.39 +# homeassistant.components.insteon_plm +insteonplm==0.7.4 + # homeassistant.components.media_player.kodi jsonrpc-async==0.4