diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index 62fae6cca37..ccd8a732415 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -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, diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py new file mode 100644 index 00000000000..647c8b212a9 --- /dev/null +++ b/homeassistant/components/isy994/climate.py @@ -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() diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index 5474f76d413..468d1be5650 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -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: [ diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index 2fde917485f..d1265eff508 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -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