Add Climate Platform Support to ISY994 (#35440)

* ISY994 Add support for climate platform

Remove services from Climate (not added yet)

* Incorporate suggestions based on review.

* Collect string literals to Const. Rename device to entity

* Fix merge error
This commit is contained in:
shbatm 2020-05-09 22:03:05 -05:00 committed by GitHub
parent a97460d1ab
commit 8947ce5053
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 345 additions and 22 deletions

View File

@ -37,7 +37,16 @@ from .const import (
DOMAIN as ISY994_DOMAIN,
ISY994_NODES,
ISY994_PROGRAMS,
SUBNODE_CLIMATE_COOL,
SUBNODE_CLIMATE_HEAT,
SUBNODE_DUSK_DAWN,
SUBNODE_HEARTBEAT,
SUBNODE_LOW_BATTERY,
SUBNODE_MOTION_DISABLED,
SUBNODE_NEGATIVE,
SUBNODE_TAMPER,
TYPE_CATEGORY_CLIMATE,
TYPE_INSTEON_MOTION,
)
from .entity import ISYNodeEntity, ISYProgramEntity
from .helpers import migrate_old_unique_ids
@ -48,17 +57,6 @@ DEVICE_PARENT_REQUIRED = [
DEVICE_CLASS_MOTION,
]
SUBNODE_CLIMATE_COOL = 2
SUBNODE_CLIMATE_HEAT = 3
SUBNODE_NEGATIVE = 2
SUBNODE_HEARTBEAT = 4
SUBNODE_DUSK_DAWN = 2
SUBNODE_LOW_BATTERY = 3
SUBNODE_TAMPER = (10, 16) # Int->10 or Hex->0xA depending on firmware
SUBNODE_MOTION_DISABLED = (13, 19) # Int->13 or Hex->0xD depending on firmware
TYPE_INSTEON_MOTION = ("16.1.", "16.22.")
async def async_setup_entry(
hass: HomeAssistantType,

View File

@ -0,0 +1,260 @@
"""Support for Insteon Thermostats via ISY994 Platform."""
from typing import Callable, List, Optional, Union
from pyisy.constants import (
CMD_CLIMATE_FAN_SETTING,
CMD_CLIMATE_MODE,
ISY_VALUE_UNKNOWN,
PROP_HEAT_COOL_STATE,
PROP_HUMIDITY,
PROP_SETPOINT_COOL,
PROP_SETPOINT_HEAT,
PROP_UOM,
PROTO_INSTEON,
)
from homeassistant.components.climate import ClimateEntity
from homeassistant.components.climate.const import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
DOMAIN as CLIMATE,
FAN_AUTO,
FAN_ON,
HVAC_MODE_COOL,
HVAC_MODE_HEAT,
SUPPORT_FAN_MODE,
SUPPORT_TARGET_TEMPERATURE,
SUPPORT_TARGET_TEMPERATURE_RANGE,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
PRECISION_TENTHS,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
)
from homeassistant.helpers.typing import HomeAssistantType
from .const import (
_LOGGER,
DOMAIN as ISY994_DOMAIN,
HA_FAN_TO_ISY,
HA_HVAC_TO_ISY,
ISY994_NODES,
ISY_HVAC_MODES,
UOM_DOUBLE_TEMP,
UOM_FAN_MODES,
UOM_HVAC_ACTIONS,
UOM_HVAC_MODE_GENERIC,
UOM_HVAC_MODE_INSTEON,
UOM_ISY_CELSIUS,
UOM_ISY_FAHRENHEIT,
UOM_ISYV4_DEGREES,
UOM_ISYV4_NONE,
UOM_TO_STATES,
)
from .entity import ISYNodeEntity
from .helpers import migrate_old_unique_ids
ISY_SUPPORTED_FEATURES = (
SUPPORT_FAN_MODE | SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_RANGE
)
async def async_setup_entry(
hass: HomeAssistantType,
entry: ConfigEntry,
async_add_entities: Callable[[list], None],
) -> bool:
"""Set up the ISY994 thermostat platform."""
entities = []
hass_isy_data = hass.data[ISY994_DOMAIN][entry.entry_id]
for node in hass_isy_data[ISY994_NODES][CLIMATE]:
entities.append(ISYThermostatEntity(node))
await migrate_old_unique_ids(hass, CLIMATE, entities)
async_add_entities(entities)
def convert_isy_temp_to_hass(
temp: Union[int, float, None], uom: str, precision: str
) -> float:
"""Fix Insteon Thermostats' Reported Temperature.
Insteon Thermostats report temperature in 0.5-deg precision as an int
by sending a value of 2 times the Temp. Correct by dividing by 2 here.
Z-Wave Thermostats report temps in tenths as an integer and precision.
Correct by shifting the decimal place left by the value of precision.
"""
if temp is None or temp == ISY_VALUE_UNKNOWN:
return None
if uom in [UOM_DOUBLE_TEMP, UOM_ISYV4_DEGREES]:
return round(float(temp) / 2.0, 1)
if precision != "0":
return round(float(temp) / 10 ** int(precision), int(precision))
return round(float(temp), 1)
class ISYThermostatEntity(ISYNodeEntity, ClimateEntity):
"""Representation of an ISY994 thermostat entity."""
def __init__(self, node) -> None:
"""Initialize the ISY Thermostat entity."""
super().__init__(node)
self._node = node
self._uom = self._node.uom
if isinstance(self._uom, list):
self._uom = self._node.uom[0]
self._hvac_action = None
self._hvac_mode = None
self._fan_mode = None
self._temp_unit = None
self._current_humidity = 0
self._target_temp_low = 0
self._target_temp_high = 0
@property
def supported_features(self) -> int:
"""Return the list of supported features."""
return ISY_SUPPORTED_FEATURES
@property
def precision(self) -> str:
"""Return the precision of the system."""
return PRECISION_TENTHS
@property
def temperature_unit(self) -> str:
"""Return the unit of measurement."""
uom = self._node.aux_properties.get(PROP_UOM)
if not uom:
return self.hass.config.units.temperature_unit
if uom.value == UOM_ISY_CELSIUS:
return TEMP_CELSIUS
if uom.value == UOM_ISY_FAHRENHEIT:
return TEMP_FAHRENHEIT
@property
def current_humidity(self) -> Optional[int]:
"""Return the current humidity."""
humidity = self._node.aux_properties.get(PROP_HUMIDITY)
if not humidity:
return None
return int(humidity.value)
@property
def hvac_mode(self) -> Optional[str]:
"""Return hvac operation ie. heat, cool mode."""
hvac_mode = self._node.aux_properties.get(CMD_CLIMATE_MODE)
if not hvac_mode:
return None
# Which state values used depends on the mode property's UOM:
uom = hvac_mode.uom
# Handle special case for ISYv4 Firmware:
if uom == UOM_ISYV4_NONE:
uom = (
UOM_HVAC_MODE_INSTEON
if self._node.protocol == PROTO_INSTEON
else UOM_HVAC_MODE_GENERIC
)
return UOM_TO_STATES[uom].get(hvac_mode.value)
@property
def hvac_modes(self) -> List[str]:
"""Return the list of available hvac operation modes."""
return ISY_HVAC_MODES
@property
def hvac_action(self) -> Optional[str]:
"""Return the current running hvac operation if supported."""
hvac_action = self._node.aux_properties.get(PROP_HEAT_COOL_STATE)
if not hvac_action:
return None
return UOM_TO_STATES[UOM_HVAC_ACTIONS].get(hvac_action.value)
@property
def current_temperature(self) -> Optional[float]:
"""Return the current temperature."""
return convert_isy_temp_to_hass(self._node.status, self._uom, self._node.prec)
@property
def target_temperature_step(self) -> Optional[float]:
"""Return the supported step of target temperature."""
return 1.0
@property
def target_temperature(self) -> Optional[float]:
"""Return the temperature we try to reach."""
if self.hvac_mode == HVAC_MODE_COOL:
return self.target_temperature_high
if self.hvac_mode == HVAC_MODE_HEAT:
return self.target_temperature_low
return None
@property
def target_temperature_high(self) -> Optional[float]:
"""Return the highbound target temperature we try to reach."""
target = self._node.aux_properties.get(PROP_SETPOINT_COOL)
if not target:
return None
return convert_isy_temp_to_hass(target.value, target.uom, target.prec)
@property
def target_temperature_low(self) -> Optional[float]:
"""Return the lowbound target temperature we try to reach."""
target = self._node.aux_properties.get(PROP_SETPOINT_HEAT)
if not target:
return None
return convert_isy_temp_to_hass(target.value, target.uom, target.prec)
@property
def fan_modes(self):
"""Return the list of available fan modes."""
return [FAN_AUTO, FAN_ON]
@property
def fan_mode(self) -> str:
"""Return the current fan mode ie. auto, on."""
fan_mode = self._node.aux_properties.get(CMD_CLIMATE_FAN_SETTING)
if not fan_mode:
return None
return UOM_TO_STATES[UOM_FAN_MODES].get(fan_mode.value)
def set_temperature(self, **kwargs) -> None:
"""Set new target temperature."""
target_temp = kwargs.get(ATTR_TEMPERATURE)
target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW)
target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
if target_temp is not None:
if self.hvac_mode == HVAC_MODE_COOL:
target_temp_high = target_temp
if self.hvac_mode == HVAC_MODE_HEAT:
target_temp_low = target_temp
if target_temp_low is not None:
self._node.set_climate_setpoint_heat(int(target_temp_low))
# Presumptive setting--event stream will correct if cmd fails:
self._target_temp_low = target_temp_low
if target_temp_high is not None:
self._node.set_climate_setpoint_cool(int(target_temp_high))
# Presumptive setting--event stream will correct if cmd fails:
self._target_temp_high = target_temp_high
self.schedule_update_ha_state()
def set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
_LOGGER.debug("Requested fan mode %s", fan_mode)
self._node.set_fan_mode(HA_FAN_TO_ISY.get(fan_mode))
# Presumptive setting--event stream will correct if cmd fails:
self._fan_mode = fan_mode
self.schedule_update_ha_state()
def set_hvac_mode(self, hvac_mode: str) -> None:
"""Set new target hvac mode."""
_LOGGER.debug("Requested operation mode %s", hvac_mode)
self._node.set_climate_mode(HA_HVAC_TO_ISY.get(hvac_mode))
# Presumptive setting--event stream will correct if cmd fails:
self._hvac_mode = hvac_mode
self.schedule_update_ha_state()

View File

@ -22,6 +22,7 @@ from homeassistant.components.climate.const import (
CURRENT_HVAC_FAN,
CURRENT_HVAC_HEAT,
CURRENT_HVAC_IDLE,
DOMAIN as CLIMATE,
FAN_AUTO,
FAN_HIGH,
FAN_MEDIUM,
@ -109,7 +110,7 @@ DEFAULT_PROGRAM_STRING = "HA."
KEY_ACTIONS = "actions"
KEY_STATUS = "status"
SUPPORTED_PLATFORMS = [BINARY_SENSOR, SENSOR, LOCK, FAN, COVER, LIGHT, SWITCH]
SUPPORTED_PLATFORMS = [BINARY_SENSOR, SENSOR, LOCK, FAN, COVER, LIGHT, SWITCH, CLIMATE]
SUPPORTED_PROGRAM_PLATFORMS = [BINARY_SENSOR, LOCK, FAN, COVER, SWITCH]
SUPPORTED_BIN_SENS_CLASSES = ["moisture", "opening", "motion", "climate"]
@ -128,6 +129,19 @@ FILTER_NODE_DEF_ID = "node_def_id"
FILTER_INSTEON_TYPE = "insteon_type"
FILTER_ZWAVE_CAT = "zwave_cat"
# Special Subnodes for some Insteon Devices
SUBNODE_CLIMATE_COOL = 2
SUBNODE_CLIMATE_HEAT = 3
SUBNODE_DUSK_DAWN = 2
SUBNODE_EZIO2X4_SENSORS = [9, 10, 11, 12]
SUBNODE_FANLINC_LIGHT = 1
SUBNODE_HEARTBEAT = 4
SUBNODE_IOLINC_RELAY = 2
SUBNODE_LOW_BATTERY = 3
SUBNODE_MOTION_DISABLED = (13, 19) # Int->13 or Hex->0xD depending on firmware
SUBNODE_NEGATIVE = 2
SUBNODE_TAMPER = (10, 16) # Int->10 or Hex->0xA depending on firmware
# Generic Insteon Type Categories for Filters
TYPE_CATEGORY_CONTROLLERS = "0."
TYPE_CATEGORY_DIMMABLE = "1."
@ -142,6 +156,9 @@ TYPE_CATEGORY_LOCK = "15."
TYPE_CATEGORY_SAFETY = "16."
TYPE_CATEGORY_X10 = "113."
TYPE_EZIO2X4 = "7.3.255."
TYPE_INSTEON_MOTION = ("16.1.", "16.22.")
UNDO_UPDATE_LISTENER = "undo_update_listener"
# Do not use the Home Assistant consts for the states here - we're matching exact API
@ -261,8 +278,27 @@ NODE_FILTERS = {
],
FILTER_ZWAVE_CAT: ["121", "122", "123", "137", "141", "147"],
},
CLIMATE: {
FILTER_UOM: ["2"],
FILTER_STATES: ["heating", "cooling", "idle", "fan_only", "off"],
FILTER_NODE_DEF_ID: ["TempLinc", "Thermostat"],
FILTER_INSTEON_TYPE: ["4.8", TYPE_CATEGORY_CLIMATE],
FILTER_ZWAVE_CAT: ["140"],
},
}
UOM_ISYV4_DEGREES = "degrees"
UOM_ISYV4_NONE = "n/a"
UOM_ISY_CELSIUS = 1
UOM_ISY_FAHRENHEIT = 2
UOM_DOUBLE_TEMP = "101"
UOM_HVAC_ACTIONS = "66"
UOM_HVAC_MODE_GENERIC = "67"
UOM_HVAC_MODE_INSTEON = "98"
UOM_FAN_MODES = "99"
UOM_FRIENDLY_NAME = {
"1": "A",
"3": f"btu/{TIME_HOURS}",
@ -400,7 +436,7 @@ UOM_TO_STATES = {
26: "hardware failure",
27: "factory reset",
},
"66": { # Thermostat Heat/Cool State
UOM_HVAC_ACTIONS: { # Thermostat Heat/Cool State
0: CURRENT_HVAC_IDLE,
1: CURRENT_HVAC_HEAT,
2: CURRENT_HVAC_COOL,
@ -415,7 +451,7 @@ UOM_TO_STATES = {
10: CURRENT_HVAC_HEAT,
11: CURRENT_HVAC_HEAT,
},
"67": { # Thermostat Mode
UOM_HVAC_MODE_GENERIC: { # Thermostat Mode
0: HVAC_MODE_OFF,
1: HVAC_MODE_HEAT,
2: HVAC_MODE_COOL,
@ -524,7 +560,7 @@ UOM_TO_STATES = {
b: f"{b} %" for a, b in enumerate(list(range(1, 100)))
}, # 1-99 are percentage open
},
"98": { # Insteon Thermostat Mode
UOM_HVAC_MODE_INSTEON: { # Insteon Thermostat Mode
0: HVAC_MODE_OFF,
1: HVAC_MODE_HEAT,
2: HVAC_MODE_COOL,
@ -534,7 +570,7 @@ UOM_TO_STATES = {
6: HVAC_MODE_AUTO, # Program Heat-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
UOM_FAN_MODES: {7: FAN_ON, 8: FAN_AUTO}, # Insteon Thermostat Fan Mode
"115": { # Most recent On style action taken for lamp control
0: "on",
1: "off",
@ -552,6 +588,26 @@ UOM_TO_STATES = {
},
}
ISY_HVAC_MODES = [
HVAC_MODE_OFF,
HVAC_MODE_HEAT,
HVAC_MODE_COOL,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_AUTO,
HVAC_MODE_FAN_ONLY,
]
HA_HVAC_TO_ISY = {
HVAC_MODE_OFF: "off",
HVAC_MODE_HEAT: "heat",
HVAC_MODE_COOL: "cool",
HVAC_MODE_HEAT_COOL: "auto",
HVAC_MODE_FAN_ONLY: "fan_only",
HVAC_MODE_AUTO: "program_auto",
}
HA_FAN_TO_ISY = {FAN_ON: "on", FAN_AUTO: "auto"}
BINARY_SENSOR_DEVICE_TYPES_ISY = {
DEVICE_CLASS_MOISTURE: ["16.8.", "16.13.", "16.14."],
DEVICE_CLASS_OPENING: [

View File

@ -12,6 +12,7 @@ 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.climate.const import DOMAIN as CLIMATE
from homeassistant.components.fan import DOMAIN as FAN
from homeassistant.components.light import DOMAIN as LIGHT
from homeassistant.components.sensor import DOMAIN as SENSOR
@ -34,20 +35,20 @@ from .const import (
KEY_ACTIONS,
KEY_STATUS,
NODE_FILTERS,
SUBNODE_CLIMATE_COOL,
SUBNODE_CLIMATE_HEAT,
SUBNODE_EZIO2X4_SENSORS,
SUBNODE_FANLINC_LIGHT,
SUBNODE_IOLINC_RELAY,
SUPPORTED_PLATFORMS,
SUPPORTED_PROGRAM_PLATFORMS,
TYPE_CATEGORY_SENSOR_ACTUATORS,
TYPE_EZIO2X4,
)
BINARY_SENSOR_UOMS = ["2", "78"]
BINARY_SENSOR_ISY_STATES = ["on", "off"]
TYPE_EZIO2X4 = "7.3.255."
SUBNODE_EZIO2X4_SENSORS = [9, 10, 11, 12]
SUBNODE_FANLINC_LIGHT = 1
SUBNODE_IOLINC_RELAY = 2
def _check_for_node_def(
hass_isy_data: dict, node: Union[Group, Node], single_platform: str = None
@ -107,6 +108,14 @@ def _check_for_insteon_type(
hass_isy_data[ISY994_NODES][LIGHT].append(node)
return True
# Thermostats, which has a "Heat" and "Cool" sub-node on address 2 and 3
if platform == CLIMATE and subnode_id in [
SUBNODE_CLIMATE_COOL,
SUBNODE_CLIMATE_HEAT,
]:
hass_isy_data[ISY994_NODES][BINARY_SENSOR].append(node)
return True
# IOLincs which have a sensor and relay on 2 different nodes
if (
platform == BINARY_SENSOR