mirror of
https://github.com/home-assistant/core.git
synced 2025-07-16 17:57:11 +00:00
Migrate ISY994 to PyISY v2 (#35338)
* Remove unnecessary pylint exceptions * Move up some change to binary_sensors and switch. Fix program d.s.a's. * ISY994 Basic support for PyISYv2 - Bare minimum changes to be able to support PyISYv2. - Renaming imports and functions to new names. - Use necessary constants from module. - **BREAKING CHANGE** Remove ISY Climate Module support. - Climate module was retired on 3/30/2020: [UDI Annoucement](https://www.universal-devices.com/byebyeclimatemodule/) - **BREAKING CHANGE** Device State Attributes use NodeProperty - Some attributes names and types will have changed as part of the changes to PyISY. If a user relied on a device state attribute for a given entity, they should check that it is still there and formatted the same. In general, *more* state attributes should be getting picked up now that the underlying changes have been made. - **BREAKING CHANGE** `isy994_control` event changes (using NodeProperty) - Control events now return an object with additional information. Control events are now parsed to the friendly names and will need to be updated in automations. Remove cast * PyISY v2.0.2, add extra UOMs, omit EMPTY_TIME attributes * Fix typo in function doc string. Co-authored-by: J. Nick Koston <nick@koston.org> Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
7ac547a6e0
commit
4ec88b41dc
@ -1,7 +1,7 @@
|
|||||||
"""Support the ISY-994 controllers."""
|
"""Support the ISY-994 controllers."""
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import PyISY
|
from pyisy import ISY
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
@ -16,7 +16,6 @@ from homeassistant.helpers.typing import ConfigType
|
|||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
_LOGGER,
|
_LOGGER,
|
||||||
CONF_ENABLE_CLIMATE,
|
|
||||||
CONF_IGNORE_STRING,
|
CONF_IGNORE_STRING,
|
||||||
CONF_SENSOR_STRING,
|
CONF_SENSOR_STRING,
|
||||||
CONF_TLS_VER,
|
CONF_TLS_VER,
|
||||||
@ -25,11 +24,10 @@ from .const import (
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
ISY994_NODES,
|
ISY994_NODES,
|
||||||
ISY994_PROGRAMS,
|
ISY994_PROGRAMS,
|
||||||
ISY994_WEATHER,
|
|
||||||
SUPPORTED_PLATFORMS,
|
SUPPORTED_PLATFORMS,
|
||||||
SUPPORTED_PROGRAM_PLATFORMS,
|
SUPPORTED_PROGRAM_PLATFORMS,
|
||||||
)
|
)
|
||||||
from .helpers import _categorize_nodes, _categorize_programs, _categorize_weather
|
from .helpers import _categorize_nodes, _categorize_programs
|
||||||
|
|
||||||
CONFIG_SCHEMA = vol.Schema(
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
@ -45,7 +43,6 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
vol.Optional(
|
vol.Optional(
|
||||||
CONF_SENSOR_STRING, default=DEFAULT_SENSOR_STRING
|
CONF_SENSOR_STRING, default=DEFAULT_SENSOR_STRING
|
||||||
): cv.string,
|
): cv.string,
|
||||||
vol.Optional(CONF_ENABLE_CLIMATE, default=True): cv.boolean,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -59,8 +56,6 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
for platform in SUPPORTED_PLATFORMS:
|
for platform in SUPPORTED_PLATFORMS:
|
||||||
hass.data[ISY994_NODES][platform] = []
|
hass.data[ISY994_NODES][platform] = []
|
||||||
|
|
||||||
hass.data[ISY994_WEATHER] = []
|
|
||||||
|
|
||||||
hass.data[ISY994_PROGRAMS] = {}
|
hass.data[ISY994_PROGRAMS] = {}
|
||||||
for platform in SUPPORTED_PROGRAM_PLATFORMS:
|
for platform in SUPPORTED_PROGRAM_PLATFORMS:
|
||||||
hass.data[ISY994_PROGRAMS][platform] = []
|
hass.data[ISY994_PROGRAMS][platform] = []
|
||||||
@ -73,7 +68,6 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
host = urlparse(isy_config.get(CONF_HOST))
|
host = urlparse(isy_config.get(CONF_HOST))
|
||||||
ignore_identifier = isy_config.get(CONF_IGNORE_STRING)
|
ignore_identifier = isy_config.get(CONF_IGNORE_STRING)
|
||||||
sensor_identifier = isy_config.get(CONF_SENSOR_STRING)
|
sensor_identifier = isy_config.get(CONF_SENSOR_STRING)
|
||||||
enable_climate = isy_config.get(CONF_ENABLE_CLIMATE)
|
|
||||||
|
|
||||||
if host.scheme == "http":
|
if host.scheme == "http":
|
||||||
https = False
|
https = False
|
||||||
@ -86,7 +80,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Connect to ISY controller.
|
# Connect to ISY controller.
|
||||||
isy = PyISY.ISY(
|
isy = ISY(
|
||||||
host.hostname,
|
host.hostname,
|
||||||
port,
|
port,
|
||||||
username=user,
|
username=user,
|
||||||
@ -101,9 +95,6 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
_categorize_nodes(hass, isy.nodes, ignore_identifier, sensor_identifier)
|
_categorize_nodes(hass, isy.nodes, ignore_identifier, sensor_identifier)
|
||||||
_categorize_programs(hass, isy.programs)
|
_categorize_programs(hass, isy.programs)
|
||||||
|
|
||||||
if enable_climate and isy.configuration.get("Weather Information"):
|
|
||||||
_categorize_weather(hass, isy.climate)
|
|
||||||
|
|
||||||
def stop(event: object) -> None:
|
def stop(event: object) -> None:
|
||||||
"""Stop ISY auto updates."""
|
"""Stop ISY auto updates."""
|
||||||
isy.auto_update = False
|
isy.auto_update = False
|
||||||
|
@ -2,6 +2,8 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
from pyisy.constants import ISY_VALUE_UNKNOWN
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import (
|
from homeassistant.components.binary_sensor import (
|
||||||
DOMAIN as BINARY_SENSOR,
|
DOMAIN as BINARY_SENSOR,
|
||||||
BinarySensorEntity,
|
BinarySensorEntity,
|
||||||
@ -22,14 +24,14 @@ def setup_platform(
|
|||||||
):
|
):
|
||||||
"""Set up the ISY994 binary sensor platform."""
|
"""Set up the ISY994 binary sensor platform."""
|
||||||
devices = []
|
devices = []
|
||||||
devices_by_nid = {}
|
devices_by_address = {}
|
||||||
child_nodes = []
|
child_nodes = []
|
||||||
|
|
||||||
for node in hass.data[ISY994_NODES][BINARY_SENSOR]:
|
for node in hass.data[ISY994_NODES][BINARY_SENSOR]:
|
||||||
if node.parent_node is None:
|
if node.parent_node is None:
|
||||||
device = ISYBinarySensorEntity(node)
|
device = ISYBinarySensorEntity(node)
|
||||||
devices.append(device)
|
devices.append(device)
|
||||||
devices_by_nid[node.nid] = device
|
devices_by_address[node.address] = device
|
||||||
else:
|
else:
|
||||||
# We'll process the child nodes last, to ensure all parent nodes
|
# We'll process the child nodes last, to ensure all parent nodes
|
||||||
# have been processed
|
# have been processed
|
||||||
@ -37,17 +39,17 @@ def setup_platform(
|
|||||||
|
|
||||||
for node in child_nodes:
|
for node in child_nodes:
|
||||||
try:
|
try:
|
||||||
parent_device = devices_by_nid[node.parent_node.nid]
|
parent_device = devices_by_address[node.parent_node.address]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Node %s has a parent node %s, but no device "
|
"Node %s has a parent node %s, but no device "
|
||||||
"was created for the parent. Skipping.",
|
"was created for the parent. Skipping.",
|
||||||
node.nid,
|
node.address,
|
||||||
node.parent_nid,
|
node.primary_node,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
device_type = _detect_device_type(node)
|
device_type = _detect_device_type(node)
|
||||||
subnode_id = int(node.nid[-1], 16)
|
subnode_id = int(node.address[-1], 16)
|
||||||
if device_type in ("opening", "moisture"):
|
if device_type in ("opening", "moisture"):
|
||||||
# These sensors use an optional "negative" subnode 2 to snag
|
# These sensors use an optional "negative" subnode 2 to snag
|
||||||
# all state changes
|
# all state changes
|
||||||
@ -86,11 +88,6 @@ def _detect_device_type(node) -> str:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _is_val_unknown(val):
|
|
||||||
"""Determine if a number value represents UNKNOWN from PyISY."""
|
|
||||||
return val == -1 * float("inf")
|
|
||||||
|
|
||||||
|
|
||||||
class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity):
|
class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity):
|
||||||
"""Representation of an ISY994 binary sensor device.
|
"""Representation of an ISY994 binary sensor device.
|
||||||
|
|
||||||
@ -106,21 +103,21 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity):
|
|||||||
self._negative_node = None
|
self._negative_node = None
|
||||||
self._heartbeat_device = None
|
self._heartbeat_device = None
|
||||||
self._device_class_from_type = _detect_device_type(self._node)
|
self._device_class_from_type = _detect_device_type(self._node)
|
||||||
if _is_val_unknown(self._node.status._val):
|
if self._node.status == ISY_VALUE_UNKNOWN:
|
||||||
self._computed_state = None
|
self._computed_state = None
|
||||||
self._status_was_unknown = True
|
self._status_was_unknown = True
|
||||||
else:
|
else:
|
||||||
self._computed_state = bool(self._node.status._val)
|
self._computed_state = bool(self._node.status)
|
||||||
self._status_was_unknown = False
|
self._status_was_unknown = False
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Subscribe to the node and subnode event emitters."""
|
"""Subscribe to the node and subnode event emitters."""
|
||||||
await super().async_added_to_hass()
|
await super().async_added_to_hass()
|
||||||
|
|
||||||
self._node.controlEvents.subscribe(self._positive_node_control_handler)
|
self._node.control_events.subscribe(self._positive_node_control_handler)
|
||||||
|
|
||||||
if self._negative_node is not None:
|
if self._negative_node is not None:
|
||||||
self._negative_node.controlEvents.subscribe(
|
self._negative_node.control_events.subscribe(
|
||||||
self._negative_node_control_handler
|
self._negative_node_control_handler
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -146,20 +143,19 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity):
|
|||||||
"""
|
"""
|
||||||
self._negative_node = child
|
self._negative_node = child
|
||||||
|
|
||||||
# pylint: disable=protected-access
|
if self._negative_node.status != ISY_VALUE_UNKNOWN:
|
||||||
if not _is_val_unknown(self._negative_node.status._val):
|
|
||||||
# If the negative node has a value, it means the negative node is
|
# If the negative node has a value, it means the negative node is
|
||||||
# in use for this device. Next we need to check to see if the
|
# in use for this device. Next we need to check to see if the
|
||||||
# negative and positive nodes disagree on the state (both ON or
|
# negative and positive nodes disagree on the state (both ON or
|
||||||
# both OFF).
|
# both OFF).
|
||||||
if self._negative_node.status._val == self._node.status._val:
|
if self._negative_node.status == self._node.status:
|
||||||
# The states disagree, therefore we cannot determine the state
|
# The states disagree, therefore we cannot determine the state
|
||||||
# of the sensor until we receive our first ON event.
|
# of the sensor until we receive our first ON event.
|
||||||
self._computed_state = None
|
self._computed_state = None
|
||||||
|
|
||||||
def _negative_node_control_handler(self, event: object) -> None:
|
def _negative_node_control_handler(self, event: object) -> None:
|
||||||
"""Handle an "On" control event from the "negative" node."""
|
"""Handle an "On" control event from the "negative" node."""
|
||||||
if event == "DON":
|
if event.control == "DON":
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Sensor %s turning Off via the Negative node sending a DON command",
|
"Sensor %s turning Off via the Negative node sending a DON command",
|
||||||
self.name,
|
self.name,
|
||||||
@ -175,7 +171,7 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity):
|
|||||||
will come to this node, with the negative node representing Off
|
will come to this node, with the negative node representing Off
|
||||||
events
|
events
|
||||||
"""
|
"""
|
||||||
if event == "DON":
|
if event.control == "DON":
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Sensor %s turning On via the Primary node sending a DON command",
|
"Sensor %s turning On via the Primary node sending a DON command",
|
||||||
self.name,
|
self.name,
|
||||||
@ -183,7 +179,7 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity):
|
|||||||
self._computed_state = True
|
self._computed_state = True
|
||||||
self.schedule_update_ha_state()
|
self.schedule_update_ha_state()
|
||||||
self._heartbeat()
|
self._heartbeat()
|
||||||
if event == "DOF":
|
if event.control == "DOF":
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Sensor %s turning Off via the Primary node sending a DOF command",
|
"Sensor %s turning Off via the Primary node sending a DOF command",
|
||||||
self.name,
|
self.name,
|
||||||
@ -263,14 +259,14 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity):
|
|||||||
"""Subscribe to the node and subnode event emitters."""
|
"""Subscribe to the node and subnode event emitters."""
|
||||||
await super().async_added_to_hass()
|
await super().async_added_to_hass()
|
||||||
|
|
||||||
self._node.controlEvents.subscribe(self._heartbeat_node_control_handler)
|
self._node.control_events.subscribe(self._heartbeat_node_control_handler)
|
||||||
|
|
||||||
# Start the timer on bootup, so we can change from UNKNOWN to ON
|
# Start the timer on bootup, so we can change from UNKNOWN to ON
|
||||||
self._restart_timer()
|
self._restart_timer()
|
||||||
|
|
||||||
def _heartbeat_node_control_handler(self, event: object) -> None:
|
def _heartbeat_node_control_handler(self, event: object) -> None:
|
||||||
"""Update the heartbeat timestamp when an On event is sent."""
|
"""Update the heartbeat timestamp when an On event is sent."""
|
||||||
if event == "DON":
|
if event.control == "DON":
|
||||||
self.heartbeat()
|
self.heartbeat()
|
||||||
|
|
||||||
def heartbeat(self):
|
def heartbeat(self):
|
||||||
|
@ -37,6 +37,7 @@ from homeassistant.const import (
|
|||||||
LENGTH_INCHES,
|
LENGTH_INCHES,
|
||||||
LENGTH_KILOMETERS,
|
LENGTH_KILOMETERS,
|
||||||
LENGTH_METERS,
|
LENGTH_METERS,
|
||||||
|
LENGTH_MILES,
|
||||||
MASS_KILOGRAMS,
|
MASS_KILOGRAMS,
|
||||||
MASS_POUNDS,
|
MASS_POUNDS,
|
||||||
POWER_WATT,
|
POWER_WATT,
|
||||||
@ -81,7 +82,6 @@ MANUFACTURER = "Universal Devices, Inc"
|
|||||||
|
|
||||||
CONF_IGNORE_STRING = "ignore_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_IGNORE_STRING = "{IGNORE ME}"
|
DEFAULT_IGNORE_STRING = "{IGNORE ME}"
|
||||||
@ -89,8 +89,6 @@ DEFAULT_SENSOR_STRING = "sensor"
|
|||||||
DEFAULT_TLS_VERSION = 1.1
|
DEFAULT_TLS_VERSION = 1.1
|
||||||
|
|
||||||
KEY_ACTIONS = "actions"
|
KEY_ACTIONS = "actions"
|
||||||
KEY_FOLDER = "folder"
|
|
||||||
KEY_MY_PROGRAMS = "My Programs"
|
|
||||||
KEY_STATUS = "status"
|
KEY_STATUS = "status"
|
||||||
|
|
||||||
SUPPORTED_PLATFORMS = [BINARY_SENSOR, SENSOR, LOCK, FAN, COVER, LIGHT, SWITCH]
|
SUPPORTED_PLATFORMS = [BINARY_SENSOR, SENSOR, LOCK, FAN, COVER, LIGHT, SWITCH]
|
||||||
@ -104,7 +102,6 @@ ISY_GROUP_PLATFORM = SWITCH
|
|||||||
|
|
||||||
ISY994_ISY = "isy"
|
ISY994_ISY = "isy"
|
||||||
ISY994_NODES = "isy994_nodes"
|
ISY994_NODES = "isy994_nodes"
|
||||||
ISY994_WEATHER = "isy994_weather"
|
|
||||||
ISY994_PROGRAMS = "isy994_programs"
|
ISY994_PROGRAMS = "isy994_programs"
|
||||||
|
|
||||||
# Do not use the Home Assistant consts for the states here - we're matching exact API
|
# Do not use the Home Assistant consts for the states here - we're matching exact API
|
||||||
@ -288,12 +285,26 @@ UOM_FRIENDLY_NAME = {
|
|||||||
"90": FREQUENCY_HERTZ,
|
"90": FREQUENCY_HERTZ,
|
||||||
"91": DEGREE,
|
"91": DEGREE,
|
||||||
"92": f"{DEGREE} South",
|
"92": f"{DEGREE} South",
|
||||||
|
"100": "", # Range 0-255, no unit.
|
||||||
"101": f"{DEGREE} (x2)",
|
"101": f"{DEGREE} (x2)",
|
||||||
"102": "kWs",
|
"102": "kWs",
|
||||||
"103": "$",
|
"103": "$",
|
||||||
"104": "¢",
|
"104": "¢",
|
||||||
"105": LENGTH_INCHES,
|
"105": LENGTH_INCHES,
|
||||||
"106": "mm/day",
|
"106": f"mm/{TIME_DAYS}",
|
||||||
|
"107": "", # raw 1-byte unsigned value
|
||||||
|
"108": "", # raw 2-byte unsigned value
|
||||||
|
"109": "", # raw 3-byte unsigned value
|
||||||
|
"110": "", # raw 4-byte unsigned value
|
||||||
|
"111": "", # raw 1-byte signed value
|
||||||
|
"112": "", # raw 2-byte signed value
|
||||||
|
"113": "", # raw 3-byte signed value
|
||||||
|
"114": "", # raw 4-byte signed value
|
||||||
|
"116": LENGTH_MILES,
|
||||||
|
"117": "mb",
|
||||||
|
"118": "hPa",
|
||||||
|
"119": f"{POWER_WATT}{TIME_HOURS}",
|
||||||
|
"120": f"{LENGTH_INCHES}/{TIME_DAYS}",
|
||||||
}
|
}
|
||||||
|
|
||||||
UOM_TO_STATES = {
|
UOM_TO_STATES = {
|
||||||
@ -466,6 +477,21 @@ UOM_TO_STATES = {
|
|||||||
7: HVAC_MODE_AUTO, # Program Cool-Set @ Local Device Only
|
7: HVAC_MODE_AUTO, # Program Cool-Set @ Local Device Only
|
||||||
},
|
},
|
||||||
"99": {7: FAN_ON, 8: FAN_AUTO}, # Insteon Thermostat Fan Mode
|
"99": {7: FAN_ON, 8: FAN_AUTO}, # Insteon Thermostat Fan Mode
|
||||||
|
"115": { # Most recent On style action taken for lamp control
|
||||||
|
0: "on",
|
||||||
|
1: "off",
|
||||||
|
2: "fade up",
|
||||||
|
3: "fade down",
|
||||||
|
4: "fade stop",
|
||||||
|
5: "fast on",
|
||||||
|
6: "fast off",
|
||||||
|
7: "triple press on",
|
||||||
|
8: "triple press off",
|
||||||
|
9: "4x press on",
|
||||||
|
10: "4x press off",
|
||||||
|
11: "5x press on",
|
||||||
|
12: "5x press off",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ISY_BIN_SENS_DEVICE_TYPES = {
|
ISY_BIN_SENS_DEVICE_TYPES = {
|
||||||
@ -474,6 +500,3 @@ ISY_BIN_SENS_DEVICE_TYPES = {
|
|||||||
"motion": ["16.1.", "16.4.", "16.5.", "16.3.", "16.22."],
|
"motion": ["16.1.", "16.4.", "16.5.", "16.3.", "16.22."],
|
||||||
"climate": ["5.11.", "5.10."],
|
"climate": ["5.11.", "5.10."],
|
||||||
}
|
}
|
||||||
|
|
||||||
# TEMPORARY CONSTANTS -- REMOVE AFTER PyISYv2 IS AVAILABLE
|
|
||||||
ISY_VALUE_UNKNOWN = -1 * float("inf")
|
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
"""Support for ISY994 covers."""
|
"""Support for ISY994 covers."""
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
from pyisy.constants import ISY_VALUE_UNKNOWN
|
||||||
|
|
||||||
from homeassistant.components.cover import DOMAIN as COVER, CoverEntity
|
from homeassistant.components.cover import DOMAIN as COVER, CoverEntity
|
||||||
from homeassistant.const import STATE_CLOSED, STATE_OPEN
|
from homeassistant.const import STATE_CLOSED, STATE_OPEN, STATE_UNKNOWN
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from . import ISY994_NODES, ISY994_PROGRAMS
|
from . import ISY994_NODES, ISY994_PROGRAMS
|
||||||
@ -30,8 +32,8 @@ class ISYCoverEntity(ISYNodeEntity, CoverEntity):
|
|||||||
@property
|
@property
|
||||||
def current_cover_position(self) -> int:
|
def current_cover_position(self) -> int:
|
||||||
"""Return the current cover position."""
|
"""Return the current cover position."""
|
||||||
if self.is_unknown() or self.value is None:
|
if self.value in [None, ISY_VALUE_UNKNOWN]:
|
||||||
return None
|
return STATE_UNKNOWN
|
||||||
return sorted((0, self.value, 100))[1]
|
return sorted((0, self.value, 100))[1]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -42,19 +44,18 @@ class ISYCoverEntity(ISYNodeEntity, CoverEntity):
|
|||||||
@property
|
@property
|
||||||
def state(self) -> str:
|
def state(self) -> str:
|
||||||
"""Get the state of the ISY994 cover device."""
|
"""Get the state of the ISY994 cover device."""
|
||||||
if self.is_unknown():
|
if self.value == ISY_VALUE_UNKNOWN:
|
||||||
return None
|
return STATE_UNKNOWN
|
||||||
# TEMPORARY: Cast value to int until PyISYv2.
|
return UOM_TO_STATES["97"].get(self.value, STATE_OPEN)
|
||||||
return UOM_TO_STATES["97"].get(int(self.value), STATE_OPEN)
|
|
||||||
|
|
||||||
def open_cover(self, **kwargs) -> None:
|
def open_cover(self, **kwargs) -> None:
|
||||||
"""Send the open cover command to the ISY994 cover device."""
|
"""Send the open cover command to the ISY994 cover device."""
|
||||||
if not self._node.on(val=100):
|
if not self._node.turn_on(val=100):
|
||||||
_LOGGER.error("Unable to open the cover")
|
_LOGGER.error("Unable to open the cover")
|
||||||
|
|
||||||
def close_cover(self, **kwargs) -> None:
|
def close_cover(self, **kwargs) -> None:
|
||||||
"""Send the close cover command to the ISY994 cover device."""
|
"""Send the close cover command to the ISY994 cover device."""
|
||||||
if not self._node.off():
|
if not self._node.turn_off():
|
||||||
_LOGGER.error("Unable to close the cover")
|
_LOGGER.error("Unable to close the cover")
|
||||||
|
|
||||||
|
|
||||||
@ -68,10 +69,10 @@ class ISYCoverProgramEntity(ISYProgramEntity, CoverEntity):
|
|||||||
|
|
||||||
def open_cover(self, **kwargs) -> None:
|
def open_cover(self, **kwargs) -> None:
|
||||||
"""Send the open cover command to the ISY994 cover program."""
|
"""Send the open cover command to the ISY994 cover program."""
|
||||||
if not self._actions.runThen():
|
if not self._actions.run_then():
|
||||||
_LOGGER.error("Unable to open the cover")
|
_LOGGER.error("Unable to open the cover")
|
||||||
|
|
||||||
def close_cover(self, **kwargs) -> None:
|
def close_cover(self, **kwargs) -> None:
|
||||||
"""Send the close cover command to the ISY994 cover program."""
|
"""Send the close cover command to the ISY994 cover program."""
|
||||||
if not self._actions.runElse():
|
if not self._actions.run_else():
|
||||||
_LOGGER.error("Unable to close the cover")
|
_LOGGER.error("Unable to close the cover")
|
||||||
|
@ -1,5 +1,14 @@
|
|||||||
"""Representation of ISYEntity Types."""
|
"""Representation of ISYEntity Types."""
|
||||||
|
|
||||||
|
from pyisy.constants import (
|
||||||
|
COMMAND_FRIENDLY_NAME,
|
||||||
|
EMPTY_TIME,
|
||||||
|
EVENT_PROPS_IGNORED,
|
||||||
|
ISY_VALUE_UNKNOWN,
|
||||||
|
)
|
||||||
|
from pyisy.helpers import NodeProperty
|
||||||
|
|
||||||
|
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN
|
||||||
from homeassistant.helpers.entity import Entity
|
from homeassistant.helpers.entity import Entity
|
||||||
from homeassistant.helpers.typing import Dict
|
from homeassistant.helpers.typing import Dict
|
||||||
|
|
||||||
@ -7,39 +16,48 @@ from homeassistant.helpers.typing import Dict
|
|||||||
class ISYEntity(Entity):
|
class ISYEntity(Entity):
|
||||||
"""Representation of an ISY994 device."""
|
"""Representation of an ISY994 device."""
|
||||||
|
|
||||||
_attrs = {}
|
|
||||||
_name: str = None
|
_name: str = None
|
||||||
|
|
||||||
def __init__(self, node) -> None:
|
def __init__(self, node) -> None:
|
||||||
"""Initialize the insteon device."""
|
"""Initialize the insteon device."""
|
||||||
self._node = node
|
self._node = node
|
||||||
|
self._attrs = {}
|
||||||
self._change_handler = None
|
self._change_handler = None
|
||||||
self._control_handler = None
|
self._control_handler = None
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Subscribe to the node change events."""
|
"""Subscribe to the node change events."""
|
||||||
self._change_handler = self._node.status.subscribe("changed", self.on_update)
|
self._change_handler = self._node.status_events.subscribe(self.on_update)
|
||||||
|
|
||||||
if hasattr(self._node, "controlEvents"):
|
if hasattr(self._node, "control_events"):
|
||||||
self._control_handler = self._node.controlEvents.subscribe(self.on_control)
|
self._control_handler = self._node.control_events.subscribe(self.on_control)
|
||||||
|
|
||||||
def on_update(self, event: object) -> None:
|
def on_update(self, event: object) -> None:
|
||||||
"""Handle the update event from the ISY994 Node."""
|
"""Handle the update event from the ISY994 Node."""
|
||||||
self.schedule_update_ha_state()
|
self.schedule_update_ha_state()
|
||||||
|
|
||||||
def on_control(self, event: object) -> None:
|
def on_control(self, event: NodeProperty) -> None:
|
||||||
"""Handle a control event from the ISY994 Node."""
|
"""Handle a control event from the ISY994 Node."""
|
||||||
self.hass.bus.fire(
|
event_data = {
|
||||||
"isy994_control", {"entity_id": self.entity_id, "control": event}
|
"entity_id": self.entity_id,
|
||||||
)
|
"control": event.control,
|
||||||
|
"value": event.value,
|
||||||
|
"formatted": event.formatted,
|
||||||
|
"uom": event.uom,
|
||||||
|
"precision": event.prec,
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.value is None or event.control not in EVENT_PROPS_IGNORED:
|
||||||
|
# New state attributes may be available, update the state.
|
||||||
|
self.schedule_update_ha_state()
|
||||||
|
|
||||||
|
self.hass.bus.fire("isy994_control", event_data)
|
||||||
|
|
||||||
@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
|
if hasattr(self._node, "address"):
|
||||||
if hasattr(self._node, "_id"):
|
return self._node.address
|
||||||
return self._node._id
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -55,21 +73,13 @@ class ISYEntity(Entity):
|
|||||||
@property
|
@property
|
||||||
def value(self) -> int:
|
def value(self) -> int:
|
||||||
"""Get the current value of the device."""
|
"""Get the current value of the device."""
|
||||||
# pylint: disable=protected-access
|
return self._node.status
|
||||||
return self._node.status._val
|
|
||||||
|
|
||||||
def is_unknown(self) -> bool:
|
|
||||||
"""Get whether or not the value of this Entity's node is unknown.
|
|
||||||
|
|
||||||
PyISY reports unknown values as -inf
|
|
||||||
"""
|
|
||||||
return self.value == -1 * float("inf")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state(self):
|
def state(self):
|
||||||
"""Return the state of the ISY device."""
|
"""Return the state of the ISY device."""
|
||||||
if self.is_unknown():
|
if self.value == ISY_VALUE_UNKNOWN:
|
||||||
return None
|
return STATE_UNKNOWN
|
||||||
return super().state
|
return super().state
|
||||||
|
|
||||||
|
|
||||||
@ -78,12 +88,25 @@ class ISYNodeEntity(ISYEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def device_state_attributes(self) -> Dict:
|
def device_state_attributes(self) -> Dict:
|
||||||
"""Get the state attributes for the device."""
|
"""Get the state attributes for the device.
|
||||||
|
|
||||||
|
The 'aux_properties' in the pyisy Node class are combined with the
|
||||||
|
other attributes which have been picked up from the event stream and
|
||||||
|
the combined result are returned as the device state attributes.
|
||||||
|
"""
|
||||||
attr = {}
|
attr = {}
|
||||||
if hasattr(self._node, "aux_properties"):
|
if hasattr(self._node, "aux_properties"):
|
||||||
for name, val in self._node.aux_properties.items():
|
# Cast as list due to RuntimeError if a new property is added while running.
|
||||||
attr[name] = f"{val.get('value')} {val.get('uom')}"
|
for name, value in list(self._node.aux_properties.items()):
|
||||||
return attr
|
attr_name = COMMAND_FRIENDLY_NAME.get(name, name)
|
||||||
|
attr[attr_name] = str(value.formatted).lower()
|
||||||
|
|
||||||
|
# If a Group/Scene, set a property if the entire scene is on/off
|
||||||
|
if hasattr(self._node, "group_all_on"):
|
||||||
|
attr["group_all_on"] = STATE_ON if self._node.group_all_on else STATE_OFF
|
||||||
|
|
||||||
|
self._attrs.update(attr)
|
||||||
|
return self._attrs
|
||||||
|
|
||||||
|
|
||||||
class ISYProgramEntity(ISYEntity):
|
class ISYProgramEntity(ISYEntity):
|
||||||
@ -94,3 +117,28 @@ class ISYProgramEntity(ISYEntity):
|
|||||||
super().__init__(status)
|
super().__init__(status)
|
||||||
self._name = name
|
self._name = name
|
||||||
self._actions = actions
|
self._actions = actions
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_state_attributes(self) -> Dict:
|
||||||
|
"""Get the state attributes for the device."""
|
||||||
|
attr = {}
|
||||||
|
if self._actions:
|
||||||
|
attr["actions_enabled"] = self._actions.enabled
|
||||||
|
if self._actions.last_finished != EMPTY_TIME:
|
||||||
|
attr["actions_last_finished"] = self._actions.last_finished
|
||||||
|
if self._actions.last_run != EMPTY_TIME:
|
||||||
|
attr["actions_last_run"] = self._actions.last_run
|
||||||
|
if self._actions.last_update != EMPTY_TIME:
|
||||||
|
attr["actions_last_update"] = self._actions.last_update
|
||||||
|
attr["ran_else"] = self._actions.ran_else
|
||||||
|
attr["ran_then"] = self._actions.ran_then
|
||||||
|
attr["run_at_startup"] = self._actions.run_at_startup
|
||||||
|
attr["running"] = self._actions.running
|
||||||
|
attr["status_enabled"] = self._node.enabled
|
||||||
|
if self._node.last_finished != EMPTY_TIME:
|
||||||
|
attr["status_last_finished"] = self._node.last_finished
|
||||||
|
if self._node.last_run != EMPTY_TIME:
|
||||||
|
attr["status_last_run"] = self._node.last_run
|
||||||
|
if self._node.last_update != EMPTY_TIME:
|
||||||
|
attr["status_last_update"] = self._node.last_update
|
||||||
|
return attr
|
||||||
|
@ -60,7 +60,7 @@ class ISYFanEntity(ISYNodeEntity, FanEntity):
|
|||||||
|
|
||||||
def set_speed(self, speed: str) -> None:
|
def set_speed(self, speed: str) -> None:
|
||||||
"""Send the set speed command to the ISY994 fan device."""
|
"""Send the set speed command to the ISY994 fan device."""
|
||||||
self._node.on(val=STATE_TO_VALUE.get(speed, 255))
|
self._node.turn_on(val=STATE_TO_VALUE.get(speed, 255))
|
||||||
|
|
||||||
def turn_on(self, speed: str = None, **kwargs) -> None:
|
def turn_on(self, speed: str = None, **kwargs) -> None:
|
||||||
"""Send the turn on command to the ISY994 fan device."""
|
"""Send the turn on command to the ISY994 fan device."""
|
||||||
@ -68,7 +68,7 @@ class ISYFanEntity(ISYNodeEntity, FanEntity):
|
|||||||
|
|
||||||
def turn_off(self, **kwargs) -> None:
|
def turn_off(self, **kwargs) -> None:
|
||||||
"""Send the turn off command to the ISY994 fan device."""
|
"""Send the turn off command to the ISY994 fan device."""
|
||||||
self._node.off()
|
self._node.turn_off()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def speed_list(self) -> list:
|
def speed_list(self) -> list:
|
||||||
@ -87,21 +87,19 @@ class ISYFanProgramEntity(ISYProgramEntity, FanEntity):
|
|||||||
@property
|
@property
|
||||||
def speed(self) -> str:
|
def speed(self) -> str:
|
||||||
"""Return the current speed."""
|
"""Return the current speed."""
|
||||||
# TEMPORARY: Cast value to int until PyISYv2.
|
return VALUE_TO_STATE.get(self.value)
|
||||||
return VALUE_TO_STATE.get(int(self.value))
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
"""Get if the fan is on."""
|
"""Get if the fan is on."""
|
||||||
# TEMPORARY: Cast value to int until PyISYv2.
|
return self.value != 0
|
||||||
return int(self.value) != 0
|
|
||||||
|
|
||||||
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.run_then():
|
||||||
_LOGGER.error("Unable to turn off the fan")
|
_LOGGER.error("Unable to turn off the fan")
|
||||||
|
|
||||||
def turn_on(self, speed: str = None, **kwargs) -> None:
|
def turn_on(self, speed: str = None, **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.run_else():
|
||||||
_LOGGER.error("Unable to turn on the fan")
|
_LOGGER.error("Unable to turn on the fan")
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
"""Sorting helpers for ISY994 device classifications."""
|
"""Sorting helpers for ISY994 device classifications."""
|
||||||
from collections import namedtuple
|
from typing import Union
|
||||||
|
|
||||||
from PyISY.Nodes import Group
|
from pyisy.constants import PROTO_GROUP, PROTO_INSTEON, PROTO_PROGRAM, TAG_FOLDER
|
||||||
|
from pyisy.nodes import Group, Node, Nodes
|
||||||
|
from pyisy.programs import Programs
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR
|
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR
|
||||||
from homeassistant.components.fan import DOMAIN as FAN
|
from homeassistant.components.fan import DOMAIN as FAN
|
||||||
@ -13,22 +15,17 @@ from .const import (
|
|||||||
_LOGGER,
|
_LOGGER,
|
||||||
ISY994_NODES,
|
ISY994_NODES,
|
||||||
ISY994_PROGRAMS,
|
ISY994_PROGRAMS,
|
||||||
ISY994_WEATHER,
|
|
||||||
ISY_GROUP_PLATFORM,
|
ISY_GROUP_PLATFORM,
|
||||||
KEY_ACTIONS,
|
KEY_ACTIONS,
|
||||||
KEY_FOLDER,
|
|
||||||
KEY_MY_PROGRAMS,
|
|
||||||
KEY_STATUS,
|
KEY_STATUS,
|
||||||
NODE_FILTERS,
|
NODE_FILTERS,
|
||||||
SUPPORTED_PLATFORMS,
|
SUPPORTED_PLATFORMS,
|
||||||
SUPPORTED_PROGRAM_PLATFORMS,
|
SUPPORTED_PROGRAM_PLATFORMS,
|
||||||
)
|
)
|
||||||
|
|
||||||
WeatherNode = namedtuple("WeatherNode", ("status", "name", "uom"))
|
|
||||||
|
|
||||||
|
|
||||||
def _check_for_node_def(
|
def _check_for_node_def(
|
||||||
hass: HomeAssistantType, node, single_platform: str = None
|
hass: HomeAssistantType, node: Union[Group, Node], single_platform: str = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check if the node matches the node_def_id for any platforms.
|
"""Check if the node matches the node_def_id for any platforms.
|
||||||
|
|
||||||
@ -52,7 +49,7 @@ def _check_for_node_def(
|
|||||||
|
|
||||||
|
|
||||||
def _check_for_insteon_type(
|
def _check_for_insteon_type(
|
||||||
hass: HomeAssistantType, node, single_platform: str = None
|
hass: HomeAssistantType, node: Union[Group, Node], single_platform: str = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check if the node matches the Insteon type for any platforms.
|
"""Check if the node matches the Insteon type for any platforms.
|
||||||
|
|
||||||
@ -60,6 +57,8 @@ def _check_for_insteon_type(
|
|||||||
works for Insteon device. "Node Server" (v5+) and Z-Wave and others will
|
works for Insteon device. "Node Server" (v5+) and Z-Wave and others will
|
||||||
not have a type.
|
not have a type.
|
||||||
"""
|
"""
|
||||||
|
if not hasattr(node, "protocol") or node.protocol != PROTO_INSTEON:
|
||||||
|
return False
|
||||||
if not hasattr(node, "type") or node.type is None:
|
if not hasattr(node, "type") or node.type is None:
|
||||||
# Node doesn't have a type (non-Insteon device most likely)
|
# Node doesn't have a type (non-Insteon device most likely)
|
||||||
return False
|
return False
|
||||||
@ -77,7 +76,7 @@ def _check_for_insteon_type(
|
|||||||
# Hacky special-case just for FanLinc, which has a light module
|
# Hacky special-case just for FanLinc, which has a light module
|
||||||
# as one of its nodes. Note that this special-case is not necessary
|
# as one of its nodes. Note that this special-case is not necessary
|
||||||
# on ISY 5.x firmware as it uses the superior NodeDefs method
|
# on ISY 5.x firmware as it uses the superior NodeDefs method
|
||||||
if platform == FAN and int(node.nid[-1]) == 1:
|
if platform == FAN and int(node.address[-1]) == 1:
|
||||||
hass.data[ISY994_NODES][LIGHT].append(node)
|
hass.data[ISY994_NODES][LIGHT].append(node)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -88,7 +87,10 @@ def _check_for_insteon_type(
|
|||||||
|
|
||||||
|
|
||||||
def _check_for_uom_id(
|
def _check_for_uom_id(
|
||||||
hass: HomeAssistantType, node, single_platform: str = None, uom_list: list = None
|
hass: HomeAssistantType,
|
||||||
|
node: Union[Group, Node],
|
||||||
|
single_platform: str = None,
|
||||||
|
uom_list: list = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check if a node's uom matches any of the platforms uom filter.
|
"""Check if a node's uom matches any of the platforms uom filter.
|
||||||
|
|
||||||
@ -116,7 +118,10 @@ def _check_for_uom_id(
|
|||||||
|
|
||||||
|
|
||||||
def _check_for_states_in_uom(
|
def _check_for_states_in_uom(
|
||||||
hass: HomeAssistantType, node, single_platform: str = None, states_list: list = None
|
hass: HomeAssistantType,
|
||||||
|
node: Union[Group, Node],
|
||||||
|
single_platform: str = None,
|
||||||
|
states_list: list = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check if a list of uoms matches two possible filters.
|
"""Check if a list of uoms matches two possible filters.
|
||||||
|
|
||||||
@ -168,7 +173,10 @@ def _is_sensor_a_binary_sensor(hass: HomeAssistantType, node) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _categorize_nodes(
|
def _categorize_nodes(
|
||||||
hass: HomeAssistantType, nodes, ignore_identifier: str, sensor_identifier: str
|
hass: HomeAssistantType,
|
||||||
|
nodes: Nodes,
|
||||||
|
ignore_identifier: str,
|
||||||
|
sensor_identifier: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Sort the nodes to their proper platforms."""
|
"""Sort the nodes to their proper platforms."""
|
||||||
for (path, node) in nodes:
|
for (path, node) in nodes:
|
||||||
@ -177,7 +185,7 @@ def _categorize_nodes(
|
|||||||
# Don't import this node as a device at all
|
# Don't import this node as a device at all
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if isinstance(node, Group):
|
if hasattr(node, "protocol") and node.protocol == PROTO_GROUP:
|
||||||
hass.data[ISY994_NODES][ISY_GROUP_PLATFORM].append(node)
|
hass.data[ISY994_NODES][ISY_GROUP_PLATFORM].append(node)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -203,47 +211,37 @@ def _categorize_nodes(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
def _categorize_programs(hass: HomeAssistantType, programs: dict) -> None:
|
def _categorize_programs(hass: HomeAssistantType, programs: Programs) -> None:
|
||||||
"""Categorize the ISY994 programs."""
|
"""Categorize the ISY994 programs."""
|
||||||
for platform in SUPPORTED_PROGRAM_PLATFORMS:
|
for platform in SUPPORTED_PROGRAM_PLATFORMS:
|
||||||
try:
|
folder = programs.get_by_name(f"HA.{platform}")
|
||||||
folder = programs[KEY_MY_PROGRAMS][f"HA.{platform}"]
|
if not folder:
|
||||||
except KeyError:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for dtype, _, node_id in folder.children:
|
for dtype, _, node_id in folder.children:
|
||||||
if dtype != KEY_FOLDER:
|
if dtype != TAG_FOLDER:
|
||||||
continue
|
continue
|
||||||
entity_folder = folder[node_id]
|
entity_folder = folder[node_id]
|
||||||
try:
|
|
||||||
status = entity_folder[KEY_STATUS]
|
actions = None
|
||||||
assert status.dtype == "program", "Not a program"
|
status = entity_folder.get_by_name(KEY_STATUS)
|
||||||
if platform != BINARY_SENSOR:
|
if not status or not status.protocol == PROTO_PROGRAM:
|
||||||
actions = entity_folder[KEY_ACTIONS]
|
|
||||||
assert actions.dtype == "program", "Not a program"
|
|
||||||
else:
|
|
||||||
actions = None
|
|
||||||
except (AttributeError, KeyError, AssertionError):
|
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Program entity '%s' not loaded due "
|
"Program %s entity '%s' not loaded, invalid/missing status program.",
|
||||||
"to invalid folder structure.",
|
platform,
|
||||||
entity_folder.name,
|
entity_folder.name,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if platform != BINARY_SENSOR:
|
||||||
|
actions = entity_folder.get_by_name(KEY_ACTIONS)
|
||||||
|
if not actions or not actions.protocol == PROTO_PROGRAM:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Program %s entity '%s' not loaded, invalid/missing actions program.",
|
||||||
|
platform,
|
||||||
|
entity_folder.name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
entity = (entity_folder.name, status, actions)
|
entity = (entity_folder.name, status, actions)
|
||||||
hass.data[ISY994_PROGRAMS][platform].append(entity)
|
hass.data[ISY994_PROGRAMS][platform].append(entity)
|
||||||
|
|
||||||
|
|
||||||
def _categorize_weather(hass: HomeAssistantType, climate) -> None:
|
|
||||||
"""Categorize the ISY994 weather data."""
|
|
||||||
climate_attrs = dir(climate)
|
|
||||||
weather_nodes = [
|
|
||||||
WeatherNode(
|
|
||||||
getattr(climate, attr),
|
|
||||||
attr.replace("_", " "),
|
|
||||||
getattr(climate, f"{attr}_units"),
|
|
||||||
)
|
|
||||||
for attr in climate_attrs
|
|
||||||
if f"{attr}_units" in climate_attrs
|
|
||||||
]
|
|
||||||
hass.data[ISY994_WEATHER].extend(weather_nodes)
|
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
"""Support for ISY994 lights."""
|
"""Support for ISY994 lights."""
|
||||||
from typing import Callable
|
from typing import Callable, Dict
|
||||||
|
|
||||||
|
from pyisy.constants import ISY_VALUE_UNKNOWN
|
||||||
|
|
||||||
from homeassistant.components.light import (
|
from homeassistant.components.light import (
|
||||||
DOMAIN as LIGHT,
|
DOMAIN as LIGHT,
|
||||||
SUPPORT_BRIGHTNESS,
|
SUPPORT_BRIGHTNESS,
|
||||||
LightEntity,
|
LightEntity,
|
||||||
)
|
)
|
||||||
|
from homeassistant.const import STATE_UNKNOWN
|
||||||
from homeassistant.helpers.restore_state import RestoreEntity
|
from homeassistant.helpers.restore_state import RestoreEntity
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
@ -38,24 +41,24 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity):
|
|||||||
@property
|
@property
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
"""Get whether the ISY994 light is on."""
|
"""Get whether the ISY994 light is on."""
|
||||||
if self.is_unknown():
|
if self.value == ISY_VALUE_UNKNOWN:
|
||||||
return False
|
return False
|
||||||
return self.value != 0
|
return int(self.value) != 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def brightness(self) -> float:
|
def brightness(self) -> float:
|
||||||
"""Get the brightness of the ISY994 light."""
|
"""Get the brightness of the ISY994 light."""
|
||||||
return None if self.is_unknown() else self.value
|
return STATE_UNKNOWN if self.value == ISY_VALUE_UNKNOWN else int(self.value)
|
||||||
|
|
||||||
def turn_off(self, **kwargs) -> None:
|
def turn_off(self, **kwargs) -> None:
|
||||||
"""Send the turn off command to the ISY994 light device."""
|
"""Send the turn off command to the ISY994 light device."""
|
||||||
self._last_brightness = self.brightness
|
self._last_brightness = self.brightness
|
||||||
if not self._node.off():
|
if not self._node.turn_off():
|
||||||
_LOGGER.debug("Unable to turn off light")
|
_LOGGER.debug("Unable to turn off light")
|
||||||
|
|
||||||
def on_update(self, event: object) -> None:
|
def on_update(self, event: object) -> None:
|
||||||
"""Save brightness in the update event from the ISY994 Node."""
|
"""Save brightness in the update event from the ISY994 Node."""
|
||||||
if not self.is_unknown() and self.value != 0:
|
if self.value not in (0, ISY_VALUE_UNKNOWN):
|
||||||
self._last_brightness = self.value
|
self._last_brightness = self.value
|
||||||
super().on_update(event)
|
super().on_update(event)
|
||||||
|
|
||||||
@ -64,7 +67,7 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity):
|
|||||||
"""Send the turn on command to the ISY994 light device."""
|
"""Send the turn on command to the ISY994 light device."""
|
||||||
if brightness is None and self._last_brightness:
|
if brightness is None and self._last_brightness:
|
||||||
brightness = self._last_brightness
|
brightness = self._last_brightness
|
||||||
if not self._node.on(val=brightness):
|
if not self._node.turn_on(val=brightness):
|
||||||
_LOGGER.debug("Unable to turn on light")
|
_LOGGER.debug("Unable to turn on light")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -73,9 +76,11 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity):
|
|||||||
return SUPPORT_BRIGHTNESS
|
return SUPPORT_BRIGHTNESS
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_state_attributes(self):
|
def device_state_attributes(self) -> Dict:
|
||||||
"""Return the light attributes."""
|
"""Return the light attributes."""
|
||||||
return {ATTR_LAST_BRIGHTNESS: self._last_brightness}
|
attribs = super().device_state_attributes
|
||||||
|
attribs[ATTR_LAST_BRIGHTNESS] = self._last_brightness
|
||||||
|
return attribs
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Restore last_brightness on restart."""
|
"""Restore last_brightness on restart."""
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
"""Support for ISY994 locks."""
|
"""Support for ISY994 locks."""
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
from pyisy.constants import ISY_VALUE_UNKNOWN
|
||||||
|
|
||||||
from homeassistant.components.lock import DOMAIN as LOCK, LockEntity
|
from homeassistant.components.lock import DOMAIN as LOCK, LockEntity
|
||||||
from homeassistant.const import STATE_LOCKED, STATE_UNKNOWN, STATE_UNLOCKED
|
from homeassistant.const import STATE_LOCKED, STATE_UNKNOWN, STATE_UNLOCKED
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
@ -29,11 +31,6 @@ def setup_platform(
|
|||||||
class ISYLockEntity(ISYNodeEntity, LockEntity):
|
class ISYLockEntity(ISYNodeEntity, LockEntity):
|
||||||
"""Representation of an ISY994 lock device."""
|
"""Representation of an ISY994 lock device."""
|
||||||
|
|
||||||
def __init__(self, node) -> None:
|
|
||||||
"""Initialize the ISY994 lock device."""
|
|
||||||
super().__init__(node)
|
|
||||||
self._conn = node.parent.parent.conn
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_locked(self) -> bool:
|
def is_locked(self) -> bool:
|
||||||
"""Get whether the lock is in locked state."""
|
"""Get whether the lock is in locked state."""
|
||||||
@ -42,28 +39,20 @@ class ISYLockEntity(ISYNodeEntity, LockEntity):
|
|||||||
@property
|
@property
|
||||||
def state(self) -> str:
|
def state(self) -> str:
|
||||||
"""Get the state of the lock."""
|
"""Get the state of the lock."""
|
||||||
if self.is_unknown():
|
if self.value == ISY_VALUE_UNKNOWN:
|
||||||
return None
|
return STATE_UNKNOWN
|
||||||
return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN)
|
return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN)
|
||||||
|
|
||||||
def lock(self, **kwargs) -> None:
|
def lock(self, **kwargs) -> None:
|
||||||
"""Send the lock command to the ISY994 device."""
|
"""Send the lock command to the ISY994 device."""
|
||||||
# Hack until PyISY is updated
|
if not self._node.secure_lock():
|
||||||
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")
|
_LOGGER.error("Unable to lock device")
|
||||||
|
|
||||||
self._node.update(0.5)
|
self._node.update(0.5)
|
||||||
|
|
||||||
def unlock(self, **kwargs) -> None:
|
def unlock(self, **kwargs) -> None:
|
||||||
"""Send the unlock command to the ISY994 device."""
|
"""Send the unlock command to the ISY994 device."""
|
||||||
# Hack until PyISY is updated
|
if not self._node.secure_unlock():
|
||||||
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")
|
_LOGGER.error("Unable to lock device")
|
||||||
|
|
||||||
self._node.update(0.5)
|
self._node.update(0.5)
|
||||||
@ -84,10 +73,10 @@ class ISYLockProgramEntity(ISYProgramEntity, LockEntity):
|
|||||||
|
|
||||||
def lock(self, **kwargs) -> None:
|
def lock(self, **kwargs) -> None:
|
||||||
"""Lock the device."""
|
"""Lock the device."""
|
||||||
if not self._actions.runThen():
|
if not self._actions.run_then():
|
||||||
_LOGGER.error("Unable to lock device")
|
_LOGGER.error("Unable to lock device")
|
||||||
|
|
||||||
def unlock(self, **kwargs) -> None:
|
def unlock(self, **kwargs) -> None:
|
||||||
"""Unlock the device."""
|
"""Unlock the device."""
|
||||||
if not self._actions.runElse():
|
if not self._actions.run_else():
|
||||||
_LOGGER.error("Unable to unlock device")
|
_LOGGER.error("Unable to unlock device")
|
||||||
|
@ -2,6 +2,6 @@
|
|||||||
"domain": "isy994",
|
"domain": "isy994",
|
||||||
"name": "Universal Devices ISY994",
|
"name": "Universal Devices ISY994",
|
||||||
"documentation": "https://www.home-assistant.io/integrations/isy994",
|
"documentation": "https://www.home-assistant.io/integrations/isy994",
|
||||||
"requirements": ["PyISY==1.1.2"],
|
"requirements": ["pyisy==2.0.2"],
|
||||||
"codeowners": ["@bdraco", "@shbatm"]
|
"codeowners": ["@bdraco", "@shbatm"]
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
"""Support for ISY994 sensors."""
|
"""Support for ISY994 sensors."""
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
from pyisy.constants import ISY_VALUE_UNKNOWN
|
||||||
|
|
||||||
from homeassistant.components.sensor import DOMAIN as SENSOR
|
from homeassistant.components.sensor import DOMAIN as SENSOR
|
||||||
from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
|
from homeassistant.const import STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from . import ISY994_NODES, ISY994_WEATHER
|
from . import ISY994_NODES
|
||||||
from .const import _LOGGER, UOM_FRIENDLY_NAME, UOM_TO_STATES
|
from .const import _LOGGER, UOM_FRIENDLY_NAME, UOM_TO_STATES
|
||||||
from .entity import ISYEntity, ISYNodeEntity
|
from .entity import ISYNodeEntity
|
||||||
|
|
||||||
|
|
||||||
def setup_platform(
|
def setup_platform(
|
||||||
@ -20,9 +22,6 @@ def setup_platform(
|
|||||||
_LOGGER.debug("Loading %s", node.name)
|
_LOGGER.debug("Loading %s", node.name)
|
||||||
devices.append(ISYSensorEntity(node))
|
devices.append(ISYSensorEntity(node))
|
||||||
|
|
||||||
for node in hass.data[ISY994_WEATHER]:
|
|
||||||
devices.append(ISYWeatherDevice(node))
|
|
||||||
|
|
||||||
add_entities(devices)
|
add_entities(devices)
|
||||||
|
|
||||||
|
|
||||||
@ -32,42 +31,40 @@ class ISYSensorEntity(ISYNodeEntity):
|
|||||||
@property
|
@property
|
||||||
def raw_unit_of_measurement(self) -> str:
|
def raw_unit_of_measurement(self) -> str:
|
||||||
"""Get the raw unit of measurement for the ISY994 sensor device."""
|
"""Get the raw unit of measurement for the ISY994 sensor device."""
|
||||||
if len(self._node.uom) == 1:
|
uom = self._node.uom
|
||||||
if self._node.uom[0] in UOM_FRIENDLY_NAME:
|
|
||||||
friendly_name = UOM_FRIENDLY_NAME.get(self._node.uom[0])
|
# Backwards compatibility for ISYv4 Firmware:
|
||||||
if friendly_name in (TEMP_CELSIUS, TEMP_FAHRENHEIT):
|
if isinstance(uom, list):
|
||||||
friendly_name = self.hass.config.units.temperature_unit
|
return UOM_FRIENDLY_NAME.get(uom[0], uom[0])
|
||||||
return friendly_name
|
return UOM_FRIENDLY_NAME.get(uom)
|
||||||
return self._node.uom[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state(self) -> str:
|
def state(self) -> str:
|
||||||
"""Get the state of the ISY994 sensor device."""
|
"""Get the state of the ISY994 sensor device."""
|
||||||
if self.is_unknown():
|
if self.value == ISY_VALUE_UNKNOWN:
|
||||||
return None
|
return STATE_UNKNOWN
|
||||||
|
|
||||||
if len(self._node.uom) == 1:
|
uom = self._node.uom
|
||||||
if self._node.uom[0] in UOM_TO_STATES:
|
# Backwards compatibility for ISYv4 Firmware:
|
||||||
states = UOM_TO_STATES.get(self._node.uom[0])
|
if isinstance(uom, list):
|
||||||
# TEMPORARY: Cast value to int until PyISYv2.
|
uom = uom[0]
|
||||||
if int(self.value) in states:
|
if not uom:
|
||||||
return states.get(int(self.value))
|
return STATE_UNKNOWN
|
||||||
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(f"{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)
|
states = UOM_TO_STATES.get(uom)
|
||||||
else:
|
if states and states.get(self.value):
|
||||||
return self.value
|
return states.get(self.value)
|
||||||
|
if self._node.prec and int(self._node.prec) != 0:
|
||||||
return None
|
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(f"{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 val
|
||||||
|
return self.value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def unit_of_measurement(self) -> str:
|
def unit_of_measurement(self) -> str:
|
||||||
@ -76,37 +73,3 @@ class ISYSensorEntity(ISYNodeEntity):
|
|||||||
if raw_units in (TEMP_FAHRENHEIT, TEMP_CELSIUS):
|
if raw_units in (TEMP_FAHRENHEIT, TEMP_CELSIUS):
|
||||||
return self.hass.config.units.temperature_unit
|
return self.hass.config.units.temperature_unit
|
||||||
return raw_units
|
return raw_units
|
||||||
|
|
||||||
|
|
||||||
# Depreciated, not renaming. Will be removed in next PR.
|
|
||||||
class ISYWeatherDevice(ISYEntity):
|
|
||||||
"""Representation of an ISY994 weather device."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def raw_units(self) -> str:
|
|
||||||
"""Return the raw unit of measurement."""
|
|
||||||
if self._node.uom == "F":
|
|
||||||
return TEMP_FAHRENHEIT
|
|
||||||
if self._node.uom == "C":
|
|
||||||
return TEMP_CELSIUS
|
|
||||||
return self._node.uom
|
|
||||||
|
|
||||||
@property
|
|
||||||
def state(self) -> object:
|
|
||||||
"""Return the value of the node."""
|
|
||||||
# pylint: disable=protected-access
|
|
||||||
val = self._node.status._val
|
|
||||||
raw_units = self._node.uom
|
|
||||||
|
|
||||||
if raw_units in [TEMP_CELSIUS, TEMP_FAHRENHEIT]:
|
|
||||||
return self.hass.config.units.temperature(val, raw_units)
|
|
||||||
return val
|
|
||||||
|
|
||||||
@property
|
|
||||||
def unit_of_measurement(self) -> str:
|
|
||||||
"""Return the unit of measurement for the node."""
|
|
||||||
raw_units = self.raw_units
|
|
||||||
|
|
||||||
if raw_units in [TEMP_CELSIUS, TEMP_FAHRENHEIT]:
|
|
||||||
return self.hass.config.units.temperature_unit
|
|
||||||
return raw_units
|
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
"""Support for ISY994 switches."""
|
"""Support for ISY994 switches."""
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
from pyisy.constants import ISY_VALUE_UNKNOWN, PROTO_GROUP
|
||||||
|
|
||||||
from homeassistant.components.switch import DOMAIN as SWITCH, SwitchEntity
|
from homeassistant.components.switch import DOMAIN as SWITCH, SwitchEntity
|
||||||
|
from homeassistant.const import STATE_UNKNOWN
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from . import ISY994_NODES, ISY994_PROGRAMS
|
from . import ISY994_NODES, ISY994_PROGRAMS
|
||||||
@ -15,8 +18,7 @@ def setup_platform(
|
|||||||
"""Set up the ISY994 switch platform."""
|
"""Set up the ISY994 switch platform."""
|
||||||
devices = []
|
devices = []
|
||||||
for node in hass.data[ISY994_NODES][SWITCH]:
|
for node in hass.data[ISY994_NODES][SWITCH]:
|
||||||
if not node.dimmable:
|
devices.append(ISYSwitchEntity(node))
|
||||||
devices.append(ISYSwitchEntity(node))
|
|
||||||
|
|
||||||
for name, status, actions in hass.data[ISY994_PROGRAMS][SWITCH]:
|
for name, status, actions in hass.data[ISY994_PROGRAMS][SWITCH]:
|
||||||
devices.append(ISYSwitchProgramEntity(name, status, actions))
|
devices.append(ISYSwitchProgramEntity(name, status, actions))
|
||||||
@ -30,18 +32,27 @@ class ISYSwitchEntity(ISYNodeEntity, SwitchEntity):
|
|||||||
@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."""
|
||||||
|
if self.value == ISY_VALUE_UNKNOWN:
|
||||||
|
return STATE_UNKNOWN
|
||||||
return bool(self.value)
|
return bool(self.value)
|
||||||
|
|
||||||
def turn_off(self, **kwargs) -> None:
|
def turn_off(self, **kwargs) -> None:
|
||||||
"""Send the turn on command to the ISY994 switch."""
|
"""Send the turn off command to the ISY994 switch."""
|
||||||
if not self._node.off():
|
if not self._node.turn_off():
|
||||||
_LOGGER.debug("Unable to turn on switch.")
|
_LOGGER.debug("Unable to turn off switch.")
|
||||||
|
|
||||||
def turn_on(self, **kwargs) -> None:
|
def turn_on(self, **kwargs) -> None:
|
||||||
"""Send the turn off command to the ISY994 switch."""
|
"""Send the turn on command to the ISY994 switch."""
|
||||||
if not self._node.on():
|
if not self._node.turn_on():
|
||||||
_LOGGER.debug("Unable to turn on switch.")
|
_LOGGER.debug("Unable to turn on switch.")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def icon(self) -> str:
|
||||||
|
"""Get the icon for groups."""
|
||||||
|
if hasattr(self._node, "protocol") and self._node.protocol == PROTO_GROUP:
|
||||||
|
return "mdi:google-circles-communities" # Matches isy scene icon
|
||||||
|
return super().icon
|
||||||
|
|
||||||
|
|
||||||
class ISYSwitchProgramEntity(ISYProgramEntity, SwitchEntity):
|
class ISYSwitchProgramEntity(ISYProgramEntity, SwitchEntity):
|
||||||
"""A representation of an ISY994 program switch."""
|
"""A representation of an ISY994 program switch."""
|
||||||
@ -53,12 +64,12 @@ class ISYSwitchProgramEntity(ISYProgramEntity, SwitchEntity):
|
|||||||
|
|
||||||
def turn_on(self, **kwargs) -> None:
|
def turn_on(self, **kwargs) -> None:
|
||||||
"""Send the turn on command to the ISY994 switch program."""
|
"""Send the turn on command to the ISY994 switch program."""
|
||||||
if not self._actions.runThen():
|
if not self._actions.run_then():
|
||||||
_LOGGER.error("Unable to turn on switch")
|
_LOGGER.error("Unable to turn on switch")
|
||||||
|
|
||||||
def turn_off(self, **kwargs) -> None:
|
def turn_off(self, **kwargs) -> None:
|
||||||
"""Send the turn off command to the ISY994 switch program."""
|
"""Send the turn off command to the ISY994 switch program."""
|
||||||
if not self._actions.runElse():
|
if not self._actions.run_else():
|
||||||
_LOGGER.error("Unable to turn off switch")
|
_LOGGER.error("Unable to turn off switch")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -49,9 +49,6 @@ PyEssent==0.13
|
|||||||
# homeassistant.components.github
|
# homeassistant.components.github
|
||||||
PyGithub==1.43.8
|
PyGithub==1.43.8
|
||||||
|
|
||||||
# homeassistant.components.isy994
|
|
||||||
PyISY==1.1.2
|
|
||||||
|
|
||||||
# homeassistant.components.mvglive
|
# homeassistant.components.mvglive
|
||||||
PyMVGLive==1.1.4
|
PyMVGLive==1.1.4
|
||||||
|
|
||||||
@ -1380,6 +1377,9 @@ pyirishrail==0.0.2
|
|||||||
# homeassistant.components.iss
|
# homeassistant.components.iss
|
||||||
pyiss==1.0.1
|
pyiss==1.0.1
|
||||||
|
|
||||||
|
# homeassistant.components.isy994
|
||||||
|
pyisy==2.0.2
|
||||||
|
|
||||||
# homeassistant.components.itach
|
# homeassistant.components.itach
|
||||||
pyitachip2ir==0.0.7
|
pyitachip2ir==0.0.7
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user