Huge ISY994 platform cleanup, fixes support for 5.0.10 firmware (#11243)

* Huge ISY994 platform cleanup, fixes support for 5.0.10 firmware

# * No more globals - store on hass.data
# * Parent ISY994 component handles categorizing nodes in to Hass components, rather than each individual domain filtering all nodes themselves
# * Remove hidden string, replace with ignore string. Hidden should be done via the customize block; ignore fully prevents the node from getting a Hass entity
# * Removed a few unused methods in the ISYDevice class
# * Cleaned up the hostname parsing
# * Removed broken logic in the fan Program component. It was setting properties that have no setters
# * Added the missing SUPPORTED_FEATURES to the fan component to indicate that it can set speed
# * Added better error handling and a log warning when an ISY994 program entity fails to initialize
# * Cleaned up a few instances of unecessarily complicated logic paths, and other cases of unnecessary logic that is already handled by base classes

* Use `super()` instead of explicit base class calls

* Move `hass` argument to first position

* Use str.format instead of string addition

* Move program structure building and validation to component

Removes the need for a bunch of duplicate exception handling in each individual platform

* Fix climate nodes, fix climate names, add config to disable climate

Sensor platform was crashing when the ISY reported climate nodes. Logic has been fixed. Also added a config option to prevent climate sensors from getting imported from the ISY. Also replace the underscore from climate node names with spaces so they default to friendly names.

* Space missing in error message

* Fix string comparison to use `==`

* Explicitly check for attributes rather than catch AttributeError

Also removes two stray debug lines

* Remove null checks on hass.data, as they are always null at this point
This commit is contained in:
Greg Laabs 2017-12-26 00:26:37 -08:00 committed by Pascal Vizeli
parent a59b02b6b4
commit d687bc073e
8 changed files with 358 additions and 348 deletions

View File

@ -12,7 +12,8 @@ from typing import Callable # noqa
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.components.binary_sensor import BinarySensorDevice, DOMAIN from homeassistant.components.binary_sensor import BinarySensorDevice, DOMAIN
import homeassistant.components.isy994 as isy from homeassistant.components.isy994 import (ISY994_NODES, ISY994_PROGRAMS,
ISYDevice)
from homeassistant.const import STATE_ON, STATE_OFF from homeassistant.const import STATE_ON, STATE_OFF
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.event import async_track_point_in_utc_time
@ -20,9 +21,6 @@ from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
UOM = ['2', '78']
STATES = [STATE_OFF, STATE_ON, 'true', 'false']
ISY_DEVICE_TYPES = { ISY_DEVICE_TYPES = {
'moisture': ['16.8', '16.13', '16.14'], 'moisture': ['16.8', '16.13', '16.14'],
'opening': ['16.9', '16.6', '16.7', '16.2', '16.17', '16.20', '16.21'], 'opening': ['16.9', '16.6', '16.7', '16.2', '16.17', '16.20', '16.21'],
@ -34,16 +32,11 @@ ISY_DEVICE_TYPES = {
def setup_platform(hass, config: ConfigType, def setup_platform(hass, config: ConfigType,
add_devices: Callable[[list], None], discovery_info=None): add_devices: Callable[[list], None], discovery_info=None):
"""Set up the ISY994 binary sensor platform.""" """Set up 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 = [] devices = []
devices_by_nid = {} devices_by_nid = {}
child_nodes = [] child_nodes = []
for node in isy.filter_nodes(isy.SENSOR_NODES, units=UOM, for node in hass.data[ISY994_NODES][DOMAIN]:
states=STATES):
if node.parent_node is None: if node.parent_node is None:
device = ISYBinarySensorDevice(node) device = ISYBinarySensorDevice(node)
devices.append(device) devices.append(device)
@ -80,13 +73,8 @@ def setup_platform(hass, config: ConfigType,
device = ISYBinarySensorDevice(node) device = ISYBinarySensorDevice(node)
devices.append(device) devices.append(device)
for program in isy.PROGRAMS.get(DOMAIN, []): for name, status, _ in hass.data[ISY994_PROGRAMS][DOMAIN]:
try: devices.append(ISYBinarySensorProgram(name, status))
status = program[isy.KEY_STATUS]
except (KeyError, AssertionError):
pass
else:
devices.append(ISYBinarySensorProgram(program.name, status))
add_devices(devices) add_devices(devices)
@ -111,7 +99,7 @@ def _is_val_unknown(val):
return val == -1*float('inf') return val == -1*float('inf')
class ISYBinarySensorDevice(isy.ISYDevice, BinarySensorDevice): class ISYBinarySensorDevice(ISYDevice, BinarySensorDevice):
"""Representation of an ISY994 binary sensor device. """Representation of an ISY994 binary sensor device.
Often times, a single device is represented by multiple nodes in the ISY, Often times, a single device is represented by multiple nodes in the ISY,
@ -251,7 +239,7 @@ class ISYBinarySensorDevice(isy.ISYDevice, BinarySensorDevice):
return self._device_class_from_type return self._device_class_from_type
class ISYBinarySensorHeartbeat(isy.ISYDevice, BinarySensorDevice): class ISYBinarySensorHeartbeat(ISYDevice, BinarySensorDevice):
"""Representation of the battery state of an ISY994 sensor.""" """Representation of the battery state of an ISY994 sensor."""
def __init__(self, node, parent_device) -> None: def __init__(self, node, parent_device) -> None:
@ -354,7 +342,7 @@ class ISYBinarySensorHeartbeat(isy.ISYDevice, BinarySensorDevice):
return attr return attr
class ISYBinarySensorProgram(isy.ISYDevice, BinarySensorDevice): class ISYBinarySensorProgram(ISYDevice, BinarySensorDevice):
"""Representation of an ISY994 binary sensor program. """Representation of an ISY994 binary sensor program.
This does not need all of the subnode logic in the device version of binary This does not need all of the subnode logic in the device version of binary

View File

@ -8,8 +8,10 @@ import logging
from typing import Callable # noqa from typing import Callable # noqa
from homeassistant.components.cover import CoverDevice, DOMAIN from homeassistant.components.cover import CoverDevice, DOMAIN
import homeassistant.components.isy994 as isy from homeassistant.components.isy994 import (ISY994_NODES, ISY994_PROGRAMS,
from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN ISYDevice)
from homeassistant.const import (
STATE_OPEN, STATE_CLOSED, STATE_OPENING, STATE_CLOSING, STATE_UNKNOWN)
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -17,44 +19,32 @@ _LOGGER = logging.getLogger(__name__)
VALUE_TO_STATE = { VALUE_TO_STATE = {
0: STATE_CLOSED, 0: STATE_CLOSED,
101: STATE_UNKNOWN, 101: STATE_UNKNOWN,
102: 'stopped',
103: STATE_CLOSING,
104: STATE_OPENING
} }
UOM = ['97']
STATES = [STATE_OPEN, STATE_CLOSED, 'closing', 'opening', 'stopped']
# pylint: disable=unused-argument # pylint: disable=unused-argument
def setup_platform(hass, config: ConfigType, def setup_platform(hass, config: ConfigType,
add_devices: Callable[[list], None], discovery_info=None): add_devices: Callable[[list], None], discovery_info=None):
"""Set up the ISY994 cover platform.""" """Set up 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 = [] devices = []
for node in hass.data[ISY994_NODES][DOMAIN]:
for node in isy.filter_nodes(isy.NODES, units=UOM, states=STATES):
devices.append(ISYCoverDevice(node)) devices.append(ISYCoverDevice(node))
for program in isy.PROGRAMS.get(DOMAIN, []): for name, status, actions in hass.data[ISY994_PROGRAMS][DOMAIN]:
try: devices.append(ISYCoverProgram(name, status, actions))
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) add_devices(devices)
class ISYCoverDevice(isy.ISYDevice, CoverDevice): class ISYCoverDevice(ISYDevice, CoverDevice):
"""Representation of an ISY994 cover device.""" """Representation of an ISY994 cover device."""
def __init__(self, node: object): def __init__(self, node: object):
"""Initialize the ISY994 cover device.""" """Initialize the ISY994 cover device."""
isy.ISYDevice.__init__(self, node) super().__init__(node)
@property @property
def current_cover_position(self) -> int: def current_cover_position(self) -> int:
@ -90,7 +80,7 @@ class ISYCoverProgram(ISYCoverDevice):
def __init__(self, name: str, node: object, actions: object) -> None: def __init__(self, name: str, node: object, actions: object) -> None:
"""Initialize the ISY994 cover program.""" """Initialize the ISY994 cover program."""
ISYCoverDevice.__init__(self, node) super().__init__(node)
self._name = name self._name = name
self._actions = actions self._actions = actions

View File

@ -9,18 +9,13 @@ from typing import Callable
from homeassistant.components.fan import (FanEntity, DOMAIN, SPEED_OFF, from homeassistant.components.fan import (FanEntity, DOMAIN, SPEED_OFF,
SPEED_LOW, SPEED_MEDIUM, SPEED_LOW, SPEED_MEDIUM,
SPEED_HIGH) SPEED_HIGH, SUPPORT_SET_SPEED)
import homeassistant.components.isy994 as isy from homeassistant.components.isy994 import (ISY994_NODES, ISY994_PROGRAMS,
from homeassistant.const import STATE_ON, STATE_OFF ISYDevice)
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Define term used for medium speed. This must be set as the fan component uses
# 'medium' which the ISY does not understand
ISY_SPEED_MEDIUM = 'med'
VALUE_TO_STATE = { VALUE_TO_STATE = {
0: SPEED_OFF, 0: SPEED_OFF,
63: SPEED_LOW, 63: SPEED_LOW,
@ -34,41 +29,28 @@ STATE_TO_VALUE = {}
for key in VALUE_TO_STATE: for key in VALUE_TO_STATE:
STATE_TO_VALUE[VALUE_TO_STATE[key]] = key STATE_TO_VALUE[VALUE_TO_STATE[key]] = key
STATES = [SPEED_OFF, SPEED_LOW, ISY_SPEED_MEDIUM, SPEED_HIGH]
# pylint: disable=unused-argument # pylint: disable=unused-argument
def setup_platform(hass, config: ConfigType, def setup_platform(hass, config: ConfigType,
add_devices: Callable[[list], None], discovery_info=None): add_devices: Callable[[list], None], discovery_info=None):
"""Set up the ISY994 fan platform.""" """Set up 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 = [] devices = []
for node in isy.filter_nodes(isy.NODES, states=STATES): for node in hass.data[ISY994_NODES][DOMAIN]:
devices.append(ISYFanDevice(node)) devices.append(ISYFanDevice(node))
for program in isy.PROGRAMS.get(DOMAIN, []): for name, status, actions in hass.data[ISY994_PROGRAMS][DOMAIN]:
try: devices.append(ISYFanProgram(name, status, actions))
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) add_devices(devices)
class ISYFanDevice(isy.ISYDevice, FanEntity): class ISYFanDevice(ISYDevice, FanEntity):
"""Representation of an ISY994 fan device.""" """Representation of an ISY994 fan device."""
def __init__(self, node) -> None: def __init__(self, node) -> None:
"""Initialize the ISY994 fan device.""" """Initialize the ISY994 fan device."""
isy.ISYDevice.__init__(self, node) super().__init__(node)
@property @property
def speed(self) -> str: def speed(self) -> str:
@ -76,7 +58,7 @@ class ISYFanDevice(isy.ISYDevice, FanEntity):
return VALUE_TO_STATE.get(self.value) return VALUE_TO_STATE.get(self.value)
@property @property
def is_on(self) -> str: def is_on(self) -> bool:
"""Get if the fan is on.""" """Get if the fan is on."""
return self.value != 0 return self.value != 0
@ -97,32 +79,32 @@ class ISYFanDevice(isy.ISYDevice, FanEntity):
"""Get the list of available speeds.""" """Get the list of available speeds."""
return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]
@property
def supported_features(self) -> int:
"""Flag supported features."""
return SUPPORT_SET_SPEED
class ISYFanProgram(ISYFanDevice): class ISYFanProgram(ISYFanDevice):
"""Representation of an ISY994 fan program.""" """Representation of an ISY994 fan program."""
def __init__(self, name: str, node, actions) -> None: def __init__(self, name: str, node, actions) -> None:
"""Initialize the ISY994 fan program.""" """Initialize the ISY994 fan program."""
ISYFanDevice.__init__(self, node) super().__init__(node)
self._name = name self._name = name
self._actions = actions 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: def turn_off(self, **kwargs) -> None:
"""Send the turn on command to ISY994 fan program.""" """Send the turn on command to ISY994 fan program."""
if not self._actions.runThen(): if not self._actions.runThen():
_LOGGER.error("Unable to turn off the fan") _LOGGER.error("Unable to turn off the fan")
else:
self.speed = STATE_ON if self.is_on else STATE_OFF
def turn_on(self, **kwargs) -> None: def turn_on(self, **kwargs) -> None:
"""Send the turn off command to ISY994 fan program.""" """Send the turn off command to ISY994 fan program."""
if not self._actions.runElse(): if not self._actions.runElse():
_LOGGER.error("Unable to turn on the fan") _LOGGER.error("Unable to turn on the fan")
else:
self.speed = STATE_ON if self.is_on else STATE_OFF @property
def supported_features(self) -> int:
"""Flag supported features."""
return 0

View File

@ -24,15 +24,14 @@ _LOGGER = logging.getLogger(__name__)
DOMAIN = 'isy994' DOMAIN = 'isy994'
CONF_HIDDEN_STRING = 'hidden_string' CONF_IGNORE_STRING = 'ignore_string'
CONF_SENSOR_STRING = 'sensor_string' CONF_SENSOR_STRING = 'sensor_string'
CONF_ENABLE_CLIMATE = 'enable_climate'
CONF_TLS_VER = 'tls' CONF_TLS_VER = 'tls'
DEFAULT_HIDDEN_STRING = '{HIDE ME}' DEFAULT_IGNORE_STRING = '{IGNORE ME}'
DEFAULT_SENSOR_STRING = 'sensor' DEFAULT_SENSOR_STRING = 'sensor'
ISY = None
KEY_ACTIONS = 'actions' KEY_ACTIONS = 'actions'
KEY_FOLDER = 'folder' KEY_FOLDER = 'folder'
KEY_MY_PROGRAMS = 'My Programs' KEY_MY_PROGRAMS = 'My Programs'
@ -44,190 +43,344 @@ CONFIG_SCHEMA = vol.Schema({
vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_TLS_VER): vol.Coerce(float), vol.Optional(CONF_TLS_VER): vol.Coerce(float),
vol.Optional(CONF_HIDDEN_STRING, vol.Optional(CONF_IGNORE_STRING,
default=DEFAULT_HIDDEN_STRING): cv.string, default=DEFAULT_IGNORE_STRING): cv.string,
vol.Optional(CONF_SENSOR_STRING, vol.Optional(CONF_SENSOR_STRING,
default=DEFAULT_SENSOR_STRING): cv.string default=DEFAULT_SENSOR_STRING): cv.string,
vol.Optional(CONF_ENABLE_CLIMATE,
default=True): cv.boolean
}) })
}, extra=vol.ALLOW_EXTRA) }, extra=vol.ALLOW_EXTRA)
SENSOR_NODES = [] # Do not use the Hass consts for the states here - we're matching exact API
WEATHER_NODES = [] # responses, not using them for Hass states
NODES = [] NODE_FILTERS = {
GROUPS = [] 'binary_sensor': {
PROGRAMS = {} 'uom': [],
'states': [],
'node_def_id': ['BinaryAlarm'],
'insteon_type': ['16.'] # Does a startswith() match; include the dot
},
'sensor': {
# This is just a more-readable way of including MOST uoms between 1-100
# (Remember that range() is non-inclusive of the stop value)
'uom': (['1'] +
list(map(str, range(3, 11))) +
list(map(str, range(12, 51))) +
list(map(str, range(52, 66))) +
list(map(str, range(69, 78))) +
['79'] +
list(map(str, range(82, 97)))),
'states': [],
'node_def_id': ['IMETER_SOLO'],
'insteon_type': ['9.0.', '9.7.']
},
'lock': {
'uom': ['11'],
'states': ['locked', 'unlocked'],
'node_def_id': ['DoorLock'],
'insteon_type': ['15.']
},
'fan': {
'uom': [],
'states': ['on', 'off', 'low', 'medium', 'high'],
'node_def_id': ['FanLincMotor'],
'insteon_type': ['1.46.']
},
'cover': {
'uom': ['97'],
'states': ['open', 'closed', 'closing', 'opening', 'stopped'],
'node_def_id': [],
'insteon_type': []
},
'light': {
'uom': ['51'],
'states': ['on', 'off', '%'],
'node_def_id': ['DimmerLampSwitch', 'DimmerLampSwitch_ADV',
'DimmerSwitchOnly', 'DimmerSwitchOnly_ADV',
'DimmerLampOnly', 'BallastRelayLampSwitch',
'BallastRelayLampSwitch_ADV', 'RelayLampSwitch',
'RemoteLinc2', 'RemoteLinc2_ADV'],
'insteon_type': ['1.']
},
'switch': {
'uom': ['2', '78'],
'states': ['on', 'off'],
'node_def_id': ['OnOffControl', 'RelayLampSwitch',
'RelayLampSwitch_ADV', 'RelaySwitchOnlyPlusQuery',
'RelaySwitchOnlyPlusQuery_ADV', 'RelayLampOnly',
'RelayLampOnly_ADV', 'KeypadButton',
'KeypadButton_ADV', 'EZRAIN_Input', 'EZRAIN_Output',
'EZIO2x4_Input', 'EZIO2x4_Input_ADV', 'BinaryControl',
'BinaryControl_ADV', 'AlertModuleSiren',
'AlertModuleSiren_ADV', 'AlertModuleArmed', 'Siren',
'Siren_ADV'],
'insteon_type': ['2.', '9.10.', '9.11.']
}
}
PYISY = None SUPPORTED_DOMAINS = ['binary_sensor', 'sensor', 'lock', 'fan', 'cover',
'light', 'switch']
SUPPORTED_PROGRAM_DOMAINS = ['binary_sensor', 'lock', 'fan', 'cover', 'switch']
HIDDEN_STRING = DEFAULT_HIDDEN_STRING # ISY Scenes are more like Swithes than Hass Scenes
# (they can turn off, and report their state)
SUPPORTED_DOMAINS = ['binary_sensor', 'cover', 'fan', 'light', 'lock', SCENE_DOMAIN = 'switch'
'sensor', 'switch']
ISY994_NODES = "isy994_nodes"
ISY994_WEATHER = "isy994_weather"
ISY994_PROGRAMS = "isy994_programs"
WeatherNode = namedtuple('WeatherNode', ('status', 'name', 'uom')) WeatherNode = namedtuple('WeatherNode', ('status', 'name', 'uom'))
def filter_nodes(nodes: list, units: list=None, states: list=None) -> list: def _check_for_node_def(hass: HomeAssistant, node,
"""Filter a list of ISY nodes based on the units and states provided.""" single_domain: str=None) -> bool:
filtered_nodes = [] """Check if the node matches the node_def_id for any domains.
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: This is only present on the 5.0 ISY firmware, and is the most reliable
continue way to determine a device's type.
"""
if match_unit or match_state: if not hasattr(node, 'node_def_id') or node.node_def_id is None:
filtered_nodes.append(node) # Node doesn't have a node_def (pre 5.0 firmware most likely)
return filtered_nodes
def _is_node_a_sensor(node, path: str, sensor_identifier: str) -> bool:
"""Determine if the given node is a sensor."""
if not isinstance(node, PYISY.Nodes.Node):
return False return False
node_def_id = node.node_def_id
domains = SUPPORTED_DOMAINS if not single_domain else [single_domain]
for domain in domains:
if node_def_id in NODE_FILTERS[domain]['node_def_id']:
hass.data[ISY994_NODES][domain].append(node)
return True
return False
def _check_for_insteon_type(hass: HomeAssistant, node,
single_domain: str=None) -> bool:
"""Check if the node matches the Insteon type for any domains.
This is for (presumably) every version of the ISY firmware, but only
works for Insteon device. "Node Server" (v5+) and Z-Wave and others will
not have a type.
"""
if not hasattr(node, 'type') or node.type is None:
# Node doesn't have a type (non-Insteon device most likely)
return False
device_type = node.type
domains = SUPPORTED_DOMAINS if not single_domain else [single_domain]
for domain in domains:
if any([device_type.startswith(t) for t in
set(NODE_FILTERS[domain]['insteon_type'])]):
hass.data[ISY994_NODES][domain].append(node)
return True
return False
def _check_for_uom_id(hass: HomeAssistant, node,
single_domain: str=None, uom_list: list=None) -> bool:
"""Check if a node's uom matches any of the domains uom filter.
This is used for versions of the ISY firmware that report uoms as a single
ID. We can often infer what type of device it is by that ID.
"""
if not hasattr(node, 'uom') or node.uom is None:
# Node doesn't have a uom (Scenes for example)
return False
node_uom = set(map(str.lower, node.uom))
if uom_list:
if node_uom.intersection(NODE_FILTERS[single_domain]['uom']):
hass.data[ISY994_NODES][single_domain].append(node)
return True
else:
domains = SUPPORTED_DOMAINS if not single_domain else [single_domain]
for domain in domains:
if node_uom.intersection(NODE_FILTERS[domain]['uom']):
hass.data[ISY994_NODES][domain].append(node)
return True
return False
def _check_for_states_in_uom(hass: HomeAssistant, node,
single_domain: str=None,
states_list: list=None) -> bool:
"""Check if a list of uoms matches two possible filters.
This is for versions of the ISY firmware that report uoms as a list of all
possible "human readable" states. This filter passes if all of the possible
states fit inside the given filter.
"""
if not hasattr(node, 'uom') or node.uom is None:
# Node doesn't have a uom (Scenes for example)
return False
node_uom = set(map(str.lower, node.uom))
if states_list:
if node_uom == set(states_list):
hass.data[ISY994_NODES][single_domain].append(node)
return True
else:
domains = SUPPORTED_DOMAINS if not single_domain else [single_domain]
for domain in domains:
if node_uom == set(NODE_FILTERS[domain]['states']):
hass.data[ISY994_NODES][domain].append(node)
return True
return False
def _is_sensor_a_binary_sensor(hass: HomeAssistant, node) -> bool:
"""Determine if the given sensor node should be a binary_sensor."""
if _check_for_node_def(hass, node, single_domain='binary_sensor'):
return True
if _check_for_insteon_type(hass, node, single_domain='binary_sensor'):
return True
# For the next two checks, we're providing our own set of uoms that
# represent on/off devices. This is because we can only depend on these
# checks in the context of already knowing that this is definitely a
# sensor device.
if _check_for_uom_id(hass, node, single_domain='binary_sensor',
uom_list=['2', '78']):
return True
if _check_for_states_in_uom(hass, node, single_domain='binary_sensor',
states_list=['on', 'off']):
return True
return False
def _categorize_nodes(hass: HomeAssistant, nodes, ignore_identifier: str,
sensor_identifier: str)-> None:
"""Sort the nodes to their proper domains."""
# pylint: disable=no-member
for (path, node) in nodes:
ignored = ignore_identifier in path or ignore_identifier in node.name
if ignored:
# Don't import this node as a device at all
continue
from PyISY.Nodes import Group
if isinstance(node, Group):
hass.data[ISY994_NODES][SCENE_DOMAIN].append(node)
continue
if sensor_identifier in path or sensor_identifier in node.name: if sensor_identifier in path or sensor_identifier in node.name:
return True # User has specified to treat this as a sensor. First we need to
# determine if it should be a binary_sensor.
# This method is most reliable but only works on 5.x firmware if _is_sensor_a_binary_sensor(hass, node):
try: continue
if node.node_def_id == 'BinaryAlarm':
return True
except AttributeError:
pass
# This method works on all firmwares, but only for Insteon devices
try:
device_type = node.type
except AttributeError:
# Node has no type; most likely not an Insteon device
pass
else: else:
split_type = device_type.split('.') hass.data[ISY994_NODES]['sensor'].append(node)
return split_type[0] == '16' # 16 represents Insteon binary sensors continue
return False # We have a bunch of different methods for determining the device type,
# each of which works with different ISY firmware versions or device
# family. The order here is important, from most reliable to least.
if _check_for_node_def(hass, node):
continue
if _check_for_insteon_type(hass, node):
continue
if _check_for_uom_id(hass, node):
continue
if _check_for_states_in_uom(hass, node):
continue
def _categorize_nodes(hidden_identifier: str, sensor_identifier: str) -> None: def _categorize_programs(hass: HomeAssistant, programs: dict) -> None:
"""Categorize the ISY994 nodes."""
global SENSOR_NODES
global NODES
global GROUPS
SENSOR_NODES = []
NODES = []
GROUPS = []
# pylint: disable=no-member
for (path, node) in ISY.nodes:
hidden = hidden_identifier in path or hidden_identifier in node.name
if hidden:
node.name += hidden_identifier
if _is_node_a_sensor(node, path, sensor_identifier):
SENSOR_NODES.append(node)
elif isinstance(node, PYISY.Nodes.Node):
NODES.append(node)
elif isinstance(node, PYISY.Nodes.Group):
GROUPS.append(node)
def _categorize_programs() -> None:
"""Categorize the ISY994 programs.""" """Categorize the ISY994 programs."""
global PROGRAMS for domain in SUPPORTED_PROGRAM_DOMAINS:
PROGRAMS = {}
for component in SUPPORTED_DOMAINS:
try: try:
folder = ISY.programs[KEY_MY_PROGRAMS]['HA.' + component] folder = programs[KEY_MY_PROGRAMS]['HA.{}'.format(domain)]
except KeyError: except KeyError:
pass pass
else: else:
for dtype, _, node_id in folder.children: for dtype, _, node_id in folder.children:
if dtype is KEY_FOLDER: if dtype == KEY_FOLDER:
program = folder[node_id] entity_folder = folder[node_id]
try: try:
node = program[KEY_STATUS].leaf status = entity_folder[KEY_STATUS]
assert node.dtype == 'program', 'Not a program' assert status.dtype == 'program', 'Not a program'
except (KeyError, AssertionError): if domain != 'binary_sensor':
pass actions = entity_folder[KEY_ACTIONS]
assert actions.dtype == 'program', 'Not a program'
else: else:
if component not in PROGRAMS: actions = None
PROGRAMS[component] = [] except (AttributeError, KeyError, AssertionError):
PROGRAMS[component].append(program) _LOGGER.warning("Program entity '%s' not loaded due "
"to invalid folder structure.",
entity_folder.name)
continue
entity = (entity_folder.name, status, actions)
hass.data[ISY994_PROGRAMS][domain].append(entity)
def _categorize_weather() -> None: def _categorize_weather(hass: HomeAssistant, climate) -> None:
"""Categorize the ISY994 weather data.""" """Categorize the ISY994 weather data."""
global WEATHER_NODES climate_attrs = dir(climate)
weather_nodes = [WeatherNode(getattr(climate, attr),
climate_attrs = dir(ISY.climate) attr.replace('_', ' '),
WEATHER_NODES = [WeatherNode(getattr(ISY.climate, attr), attr, getattr(climate, '{}_units'.format(attr)))
getattr(ISY.climate, attr + '_units'))
for attr in climate_attrs for attr in climate_attrs
if attr + '_units' in climate_attrs] if '{}_units'.format(attr) in climate_attrs]
hass.data[ISY994_WEATHER].extend(weather_nodes)
def setup(hass: HomeAssistant, config: ConfigType) -> bool: def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the ISY 994 platform.""" """Set up the ISY 994 platform."""
hass.data[ISY994_NODES] = {}
for domain in SUPPORTED_DOMAINS:
hass.data[ISY994_NODES][domain] = []
hass.data[ISY994_WEATHER] = []
hass.data[ISY994_PROGRAMS] = {}
for domain in SUPPORTED_DOMAINS:
hass.data[ISY994_PROGRAMS][domain] = []
isy_config = config.get(DOMAIN) isy_config = config.get(DOMAIN)
user = isy_config.get(CONF_USERNAME) user = isy_config.get(CONF_USERNAME)
password = isy_config.get(CONF_PASSWORD) password = isy_config.get(CONF_PASSWORD)
tls_version = isy_config.get(CONF_TLS_VER) tls_version = isy_config.get(CONF_TLS_VER)
host = urlparse(isy_config.get(CONF_HOST)) host = urlparse(isy_config.get(CONF_HOST))
port = host.port ignore_identifier = isy_config.get(CONF_IGNORE_STRING)
addr = host.geturl() sensor_identifier = isy_config.get(CONF_SENSOR_STRING)
hidden_identifier = isy_config.get( enable_climate = isy_config.get(CONF_ENABLE_CLIMATE)
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': if host.scheme == 'http':
addr = addr.replace('http://', '')
https = False https = False
port = host.port or 80
elif host.scheme == 'https': elif host.scheme == 'https':
addr = addr.replace('https://', '')
https = True https = True
port = host.port or 443
else: else:
_LOGGER.error("isy994 host value in configuration is invalid") _LOGGER.error("isy994 host value in configuration is invalid")
return False return False
addr = addr.replace(':{}'.format(port), '')
import PyISY import PyISY
global PYISY
PYISY = PyISY
# Connect to ISY controller. # Connect to ISY controller.
global ISY isy = PyISY.ISY(host.hostname, port, username=user, password=password,
ISY = PyISY.ISY(addr, port, username=user, password=password,
use_https=https, tls_ver=tls_version, log=_LOGGER) use_https=https, tls_ver=tls_version, log=_LOGGER)
if not ISY.connected: if not isy.connected:
return False return False
_categorize_nodes(hidden_identifier, sensor_identifier) _categorize_nodes(hass, isy.nodes, ignore_identifier, sensor_identifier)
_categorize_programs(hass, isy.programs)
_categorize_programs() if enable_climate and isy.configuration.get('Weather Information'):
_categorize_weather(hass, isy.climate)
if ISY.configuration.get('Weather Information'): def stop(event: object) -> None:
_categorize_weather() """Stop ISY auto updates."""
isy.auto_update = False
# Listen for HA stop to disconnect. # Listen for HA stop to disconnect.
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop) hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop)
@ -236,21 +389,14 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
for component in SUPPORTED_DOMAINS: for component in SUPPORTED_DOMAINS:
discovery.load_platform(hass, component, DOMAIN, {}, config) discovery.load_platform(hass, component, DOMAIN, {}, config)
ISY.auto_update = True isy.auto_update = True
return True return True
# pylint: disable=unused-argument
def stop(event: object) -> None:
"""Stop ISY auto updates."""
ISY.auto_update = False
class ISYDevice(Entity): class ISYDevice(Entity):
"""Representation of an ISY994 device.""" """Representation of an ISY994 device."""
_attrs = {} _attrs = {}
_domain = None # type: str
_name = None # type: str _name = None # type: str
def __init__(self, node) -> None: def __init__(self, node) -> None:
@ -281,28 +427,16 @@ class ISYDevice(Entity):
'control': event 'control': event
}) })
@property
def domain(self) -> str:
"""Get the domain of the device."""
return self._domain
@property @property
def unique_id(self) -> str: def unique_id(self) -> str:
"""Get the unique identifier of the device.""" """Get the unique identifier of the device."""
# pylint: disable=protected-access # pylint: disable=protected-access
return self._node._id return self._node._id
@property
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)
@property @property
def name(self) -> str: def name(self) -> str:
"""Get the name of the device.""" """Get the name of the device."""
return self.raw_name.replace(HIDDEN_STRING, '').strip() \ return self._name or str(self._node.name)
.replace('_', ' ')
@property @property
def should_poll(self) -> bool: def should_poll(self) -> bool:
@ -310,7 +444,7 @@ class ISYDevice(Entity):
return False return False
@property @property
def value(self) -> object: def value(self) -> int:
"""Get the current value of the device.""" """Get the current value of the device."""
# pylint: disable=protected-access # pylint: disable=protected-access
return self._node.status._val return self._node.status._val
@ -338,22 +472,3 @@ class ISYDevice(Entity):
for name, val in self._node.aux_properties.items(): for name, val in self._node.aux_properties.items():
attr[name] = '{} {}'.format(val.get('value'), val.get('uom')) attr[name] = '{} {}'.format(val.get('value'), val.get('uom'))
return attr return attr
@property
def hidden(self) -> bool:
"""Get whether the device should be hidden from the UI."""
return HIDDEN_STRING in self.raw_name
@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

View File

@ -8,40 +8,30 @@ import logging
from typing import Callable from typing import Callable
from homeassistant.components.light import ( from homeassistant.components.light import (
Light, SUPPORT_BRIGHTNESS) Light, SUPPORT_BRIGHTNESS, DOMAIN)
import homeassistant.components.isy994 as isy from homeassistant.components.isy994 import ISY994_NODES, ISYDevice
from homeassistant.const import STATE_ON, STATE_OFF
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
UOM = ['2', '51', '78']
STATES = [STATE_OFF, STATE_ON, 'true', 'false', '%']
# pylint: disable=unused-argument # pylint: disable=unused-argument
def setup_platform(hass, config: ConfigType, def setup_platform(hass, config: ConfigType,
add_devices: Callable[[list], None], discovery_info=None): add_devices: Callable[[list], None], discovery_info=None):
"""Set up the ISY994 light platform.""" """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
devices = [] devices = []
for node in hass.data[ISY994_NODES][DOMAIN]:
for node in isy.filter_nodes(isy.NODES, units=UOM, states=STATES):
if node.dimmable or '51' in node.uom:
devices.append(ISYLightDevice(node)) devices.append(ISYLightDevice(node))
add_devices(devices) add_devices(devices)
class ISYLightDevice(isy.ISYDevice, Light): class ISYLightDevice(ISYDevice, Light):
"""Representation of an ISY994 light devie.""" """Representation of an ISY994 light devie."""
def __init__(self, node: object) -> None: def __init__(self, node: object) -> None:
"""Initialize the ISY994 light device.""" """Initialize the ISY994 light device."""
isy.ISYDevice.__init__(self, node) super().__init__(node)
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:

View File

@ -8,7 +8,8 @@ import logging
from typing import Callable # noqa from typing import Callable # noqa
from homeassistant.components.lock import LockDevice, DOMAIN from homeassistant.components.lock import LockDevice, DOMAIN
import homeassistant.components.isy994 as isy from homeassistant.components.isy994 import (ISY994_NODES, ISY994_PROGRAMS,
ISYDevice)
from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
@ -19,43 +20,27 @@ VALUE_TO_STATE = {
100: STATE_LOCKED 100: STATE_LOCKED
} }
UOM = ['11']
STATES = [STATE_LOCKED, STATE_UNLOCKED]
# pylint: disable=unused-argument # pylint: disable=unused-argument
def setup_platform(hass, config: ConfigType, def setup_platform(hass, config: ConfigType,
add_devices: Callable[[list], None], discovery_info=None): add_devices: Callable[[list], None], discovery_info=None):
"""Set up the ISY994 lock platform.""" """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 = [] devices = []
for node in hass.data[ISY994_NODES][DOMAIN]:
for node in isy.filter_nodes(isy.NODES, units=UOM,
states=STATES):
devices.append(ISYLockDevice(node)) devices.append(ISYLockDevice(node))
for program in isy.PROGRAMS.get(DOMAIN, []): for name, status, actions in hass.data[ISY994_PROGRAMS][DOMAIN]:
try: devices.append(ISYLockProgram(name, status, actions))
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) add_devices(devices)
class ISYLockDevice(isy.ISYDevice, LockDevice): class ISYLockDevice(ISYDevice, LockDevice):
"""Representation of an ISY994 lock device.""" """Representation of an ISY994 lock device."""
def __init__(self, node) -> None: def __init__(self, node) -> None:
"""Initialize the ISY994 lock device.""" """Initialize the ISY994 lock device."""
isy.ISYDevice.__init__(self, node) super().__init__(node)
self._conn = node.parent.parent.conn self._conn = node.parent.parent.conn
@property @property
@ -101,7 +86,7 @@ class ISYLockProgram(ISYLockDevice):
def __init__(self, name: str, node, actions) -> None: def __init__(self, name: str, node, actions) -> None:
"""Initialize the lock.""" """Initialize the lock."""
ISYLockDevice.__init__(self, node) super().__init__(node)
self._name = name self._name = name
self._actions = actions self._actions = actions

View File

@ -7,9 +7,11 @@ https://home-assistant.io/components/sensor.isy994/
import logging import logging
from typing import Callable # noqa from typing import Callable # noqa
import homeassistant.components.isy994 as isy from homeassistant.components.sensor import DOMAIN
from homeassistant.components.isy994 import (ISY994_NODES, ISY994_WEATHER,
ISYDevice)
from homeassistant.const import ( from homeassistant.const import (
TEMP_CELSIUS, TEMP_FAHRENHEIT, STATE_OFF, STATE_ON, UNIT_UV_INDEX) TEMP_CELSIUS, TEMP_FAHRENHEIT, UNIT_UV_INDEX)
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -232,37 +234,29 @@ UOM_TO_STATES = {
} }
} }
BINARY_UOM = ['2', '78']
# pylint: disable=unused-argument # pylint: disable=unused-argument
def setup_platform(hass, config: ConfigType, def setup_platform(hass, config: ConfigType,
add_devices: Callable[[list], None], discovery_info=None): add_devices: Callable[[list], None], discovery_info=None):
"""Set up the ISY994 sensor platform.""" """Set up 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
devices = [] devices = []
for node in isy.SENSOR_NODES: for node in hass.data[ISY994_NODES][DOMAIN]:
if (not node.uom 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) _LOGGER.debug("Loading %s", node.name)
devices.append(ISYSensorDevice(node)) devices.append(ISYSensorDevice(node))
for node in isy.WEATHER_NODES: for node in hass.data[ISY994_WEATHER]:
devices.append(ISYWeatherDevice(node)) devices.append(ISYWeatherDevice(node))
add_devices(devices) add_devices(devices)
class ISYSensorDevice(isy.ISYDevice): class ISYSensorDevice(ISYDevice):
"""Representation of an ISY994 sensor device.""" """Representation of an ISY994 sensor device."""
def __init__(self, node) -> None: def __init__(self, node) -> None:
"""Initialize the ISY994 sensor device.""" """Initialize the ISY994 sensor device."""
isy.ISYDevice.__init__(self, node) super().__init__(node)
@property @property
def raw_unit_of_measurement(self) -> str: def raw_unit_of_measurement(self) -> str:
@ -316,14 +310,12 @@ class ISYSensorDevice(isy.ISYDevice):
return raw_units return raw_units
class ISYWeatherDevice(isy.ISYDevice): class ISYWeatherDevice(ISYDevice):
"""Representation of an ISY994 weather device.""" """Representation of an ISY994 weather device."""
_domain = 'sensor'
def __init__(self, node) -> None: def __init__(self, node) -> None:
"""Initialize the ISY994 weather device.""" """Initialize the ISY994 weather device."""
isy.ISYDevice.__init__(self, node) super().__init__(node)
@property @property
def unique_id(self) -> str: def unique_id(self) -> str:

View File

@ -8,71 +8,39 @@ import logging
from typing import Callable # noqa from typing import Callable # noqa
from homeassistant.components.switch import SwitchDevice, DOMAIN from homeassistant.components.switch import SwitchDevice, DOMAIN
import homeassistant.components.isy994 as isy from homeassistant.components.isy994 import (ISY994_NODES, ISY994_PROGRAMS,
from homeassistant.const import STATE_ON, STATE_OFF, STATE_UNKNOWN ISYDevice)
from homeassistant.helpers.typing import ConfigType # noqa from homeassistant.helpers.typing import ConfigType # noqa
_LOGGER = logging.getLogger(__name__) _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 # pylint: disable=unused-argument
def setup_platform(hass, config: ConfigType, def setup_platform(hass, config: ConfigType,
add_devices: Callable[[list], None], discovery_info=None): add_devices: Callable[[list], None], discovery_info=None):
"""Set up the ISY994 switch platform.""" """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
devices = [] devices = []
for node in hass.data[ISY994_NODES][DOMAIN]:
for node in isy.filter_nodes(isy.NODES, units=UOM,
states=STATES):
if not node.dimmable: if not node.dimmable:
devices.append(ISYSwitchDevice(node)) devices.append(ISYSwitchDevice(node))
for node in isy.GROUPS: for name, status, actions in hass.data[ISY994_PROGRAMS][DOMAIN]:
devices.append(ISYSwitchDevice(node)) devices.append(ISYSwitchProgram(name, status, actions))
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(ISYSwitchProgram(program.name, status, actions))
add_devices(devices) add_devices(devices)
class ISYSwitchDevice(isy.ISYDevice, SwitchDevice): class ISYSwitchDevice(ISYDevice, SwitchDevice):
"""Representation of an ISY994 switch device.""" """Representation of an ISY994 switch device."""
def __init__(self, node) -> None: def __init__(self, node) -> None:
"""Initialize the ISY994 switch device.""" """Initialize the ISY994 switch device."""
isy.ISYDevice.__init__(self, node) super().__init__(node)
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Get whether the ISY994 device is in the on state.""" """Get whether the ISY994 device is in the on state."""
return self.state == STATE_ON return bool(self.value)
@property
def state(self) -> str:
"""Get the state of the ISY994 device."""
if self.is_unknown():
return None
else:
return VALUE_TO_STATE.get(bool(self.value), STATE_UNKNOWN)
def turn_off(self, **kwargs) -> None: def turn_off(self, **kwargs) -> None:
"""Send the turn on command to the ISY994 switch.""" """Send the turn on command to the ISY994 switch."""
@ -90,7 +58,7 @@ class ISYSwitchProgram(ISYSwitchDevice):
def __init__(self, name: str, node, actions) -> None: def __init__(self, name: str, node, actions) -> None:
"""Initialize the ISY994 switch program.""" """Initialize the ISY994 switch program."""
ISYSwitchDevice.__init__(self, node) super().__init__(node)
self._name = name self._name = name
self._actions = actions self._actions = actions