diff --git a/.coveragerc b/.coveragerc index cec2649b132..712c5292e57 100644 --- a/.coveragerc +++ b/.coveragerc @@ -221,6 +221,7 @@ omit = homeassistant/components/ecobee/weather.py homeassistant/components/econet/__init__.py homeassistant/components/econet/binary_sensor.py + homeassistant/components/econet/climate.py homeassistant/components/econet/const.py homeassistant/components/econet/sensor.py homeassistant/components/econet/water_heater.py diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index 03ca9c76120..e605b16a237 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -13,7 +13,7 @@ from pyeconet.errors import ( PyeconetError, ) -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, TEMP_FAHRENHEIT from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import dispatcher_send @@ -24,7 +24,7 @@ from .const import API_CLIENT, DOMAIN, EQUIPMENT _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["binary_sensor", "sensor", "water_heater"] +PLATFORMS = ["climate", "binary_sensor", "sensor", "water_heater"] PUSH_UPDATE = "econet.push_update" INTERVAL = timedelta(minutes=60) @@ -54,7 +54,9 @@ async def async_setup_entry(hass, config_entry): raise ConfigEntryNotReady from err try: - equipment = await api.get_equipment_by_type([EquipmentType.WATER_HEATER]) + equipment = await api.get_equipment_by_type( + [EquipmentType.WATER_HEATER, EquipmentType.THERMOSTAT] + ) except (ClientError, GenericHTTPError, InvalidResponseFormat) as err: raise ConfigEntryNotReady from err hass.data[DOMAIN][API_CLIENT][config_entry.entry_id] = api @@ -74,6 +76,9 @@ async def async_setup_entry(hass, config_entry): for _eqip in equipment[EquipmentType.WATER_HEATER]: _eqip.set_update_callback(update_published) + for _eqip in equipment[EquipmentType.THERMOSTAT]: + _eqip.set_update_callback(update_published) + async def resubscribe(now): """Resubscribe to the MQTT updates.""" await hass.async_add_executor_job(api.unsubscribe) @@ -149,6 +154,11 @@ class EcoNetEntity(Entity): """Return the unique ID of the entity.""" return f"{self._econet.device_id}_{self._econet.device_name}" + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_FAHRENHEIT + @property def should_poll(self) -> bool: """Return True if entity has to be polled for state. diff --git a/homeassistant/components/econet/binary_sensor.py b/homeassistant/components/econet/binary_sensor.py index b87e6bb0cd0..116b1243ee0 100644 --- a/homeassistant/components/econet/binary_sensor.py +++ b/homeassistant/components/econet/binary_sensor.py @@ -2,8 +2,10 @@ from pyeconet.equipment import EquipmentType from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_LOCK, DEVICE_CLASS_OPENING, DEVICE_CLASS_POWER, + DEVICE_CLASS_SOUND, BinarySensorEntity, ) @@ -12,27 +14,40 @@ from .const import DOMAIN, EQUIPMENT SENSOR_NAME_RUNNING = "running" SENSOR_NAME_SHUTOFF_VALVE = "shutoff_valve" -SENSOR_NAME_VACATION = "vacation" +SENSOR_NAME_RUNNING = "running" +SENSOR_NAME_SCREEN_LOCKED = "screen_locked" +SENSOR_NAME_BEEP_ENABLED = "beep_enabled" + +ATTR = "attr" +DEVICE_CLASS = "device_class" +SENSORS = { + SENSOR_NAME_SHUTOFF_VALVE: { + ATTR: "shutoff_valve_open", + DEVICE_CLASS: DEVICE_CLASS_OPENING, + }, + SENSOR_NAME_RUNNING: {ATTR: "running", DEVICE_CLASS: DEVICE_CLASS_POWER}, + SENSOR_NAME_SCREEN_LOCKED: { + ATTR: "screen_locked", + DEVICE_CLASS: DEVICE_CLASS_LOCK, + }, + SENSOR_NAME_BEEP_ENABLED: { + ATTR: "beep_enabled", + DEVICE_CLASS: DEVICE_CLASS_SOUND, + }, +} async def async_setup_entry(hass, entry, async_add_entities): """Set up EcoNet binary sensor based on a config entry.""" equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] binary_sensors = [] - for water_heater in equipment[EquipmentType.WATER_HEATER]: - if water_heater.has_shutoff_valve: - binary_sensors.append( - EcoNetBinarySensor( - water_heater, - SENSOR_NAME_SHUTOFF_VALVE, - ) - ) - if water_heater.running is not None: - binary_sensors.append(EcoNetBinarySensor(water_heater, SENSOR_NAME_RUNNING)) - if water_heater.vacation is not None: - binary_sensors.append( - EcoNetBinarySensor(water_heater, SENSOR_NAME_VACATION) - ) + all_equipment = equipment[EquipmentType.WATER_HEATER].copy() + all_equipment.extend(equipment[EquipmentType.THERMOSTAT].copy()) + for _equip in all_equipment: + for sensor_name, sensor in SENSORS.items(): + if getattr(_equip, sensor[ATTR], None) is not None: + binary_sensors.append(EcoNetBinarySensor(_equip, sensor_name)) + async_add_entities(binary_sensors) @@ -48,22 +63,12 @@ class EcoNetBinarySensor(EcoNetEntity, BinarySensorEntity): @property def is_on(self): """Return true if the binary sensor is on.""" - if self._device_name == SENSOR_NAME_SHUTOFF_VALVE: - return self._econet.shutoff_valve_open - if self._device_name == SENSOR_NAME_RUNNING: - return self._econet.running - if self._device_name == SENSOR_NAME_VACATION: - return self._econet.vacation - return False + return getattr(self._econet, SENSORS[self._device_name][ATTR]) @property def device_class(self): """Return the class of this sensor, from DEVICE_CLASSES.""" - if self._device_name == SENSOR_NAME_SHUTOFF_VALVE: - return DEVICE_CLASS_OPENING - if self._device_name == SENSOR_NAME_RUNNING: - return DEVICE_CLASS_POWER - return None + return SENSORS[self._device_name][DEVICE_CLASS] @property def name(self): diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py new file mode 100644 index 00000000000..fe50855d559 --- /dev/null +++ b/homeassistant/components/econet/climate.py @@ -0,0 +1,241 @@ +"""Support for Rheem EcoNet thermostats.""" +import logging + +from pyeconet.equipment import EquipmentType +from pyeconet.equipment.thermostat import ThermostatFanMode, ThermostatOperationMode + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + HVAC_MODE_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + SUPPORT_AUX_HEAT, + SUPPORT_FAN_MODE, + SUPPORT_TARGET_HUMIDITY, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, +) +from homeassistant.const import ATTR_TEMPERATURE + +from . import EcoNetEntity +from .const import DOMAIN, EQUIPMENT + +_LOGGER = logging.getLogger(__name__) + +ECONET_STATE_TO_HA = { + ThermostatOperationMode.HEATING: HVAC_MODE_HEAT, + ThermostatOperationMode.COOLING: HVAC_MODE_COOL, + ThermostatOperationMode.OFF: HVAC_MODE_OFF, + ThermostatOperationMode.AUTO: HVAC_MODE_HEAT_COOL, + ThermostatOperationMode.FAN_ONLY: HVAC_MODE_FAN_ONLY, +} +HA_STATE_TO_ECONET = {value: key for key, value in ECONET_STATE_TO_HA.items()} + +ECONET_FAN_STATE_TO_HA = { + ThermostatFanMode.AUTO: FAN_AUTO, + ThermostatFanMode.LOW: FAN_LOW, + ThermostatFanMode.MEDIUM: FAN_MEDIUM, + ThermostatFanMode.HIGH: FAN_HIGH, +} +HA_FAN_STATE_TO_ECONET = {value: key for key, value in ECONET_FAN_STATE_TO_HA.items()} + +SUPPORT_FLAGS_THERMOSTAT = ( + SUPPORT_TARGET_TEMPERATURE + | SUPPORT_TARGET_TEMPERATURE_RANGE + | SUPPORT_FAN_MODE + | SUPPORT_AUX_HEAT +) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up EcoNet thermostat based on a config entry.""" + equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] + async_add_entities( + [ + EcoNetThermostat(thermostat) + for thermostat in equipment[EquipmentType.THERMOSTAT] + ], + ) + + +class EcoNetThermostat(EcoNetEntity, ClimateEntity): + """Define a Econet thermostat.""" + + def __init__(self, thermostat): + """Initialize.""" + super().__init__(thermostat) + self._running = thermostat.running + self._poll = True + self.econet_state_to_ha = {} + self.ha_state_to_econet = {} + self.op_list = [] + for mode in self._econet.modes: + if mode not in [ + ThermostatOperationMode.UNKNOWN, + ThermostatOperationMode.EMERGENCY_HEAT, + ]: + ha_mode = ECONET_STATE_TO_HA[mode] + self.op_list.append(ha_mode) + + @property + def supported_features(self): + """Return the list of supported features.""" + if self._econet.supports_humidifier: + return SUPPORT_FLAGS_THERMOSTAT | SUPPORT_TARGET_HUMIDITY + return SUPPORT_FLAGS_THERMOSTAT + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._econet.set_point + + @property + def current_humidity(self): + """Return the current humidity.""" + return self._econet.humidity + + @property + def target_humidity(self): + """Return the humidity we try to reach.""" + if self._econet.supports_humidifier: + return self._econet.dehumidifier_set_point + return None + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + if self.hvac_mode == HVAC_MODE_COOL: + return self._econet.cool_set_point + if self.hvac_mode == HVAC_MODE_HEAT: + return self._econet.heat_set_point + return None + + @property + def target_temperature_low(self): + """Return the lower bound temperature we try to reach.""" + if self.hvac_mode == HVAC_MODE_HEAT_COOL: + return self._econet.heat_set_point + return None + + @property + def target_temperature_high(self): + """Return the higher bound temperature we try to reach.""" + if self.hvac_mode == HVAC_MODE_HEAT_COOL: + return self._econet.cool_set_point + return None + + def set_temperature(self, **kwargs): + """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: + self._econet.set_set_point(target_temp, None, None) + if target_temp_low or target_temp_high: + self._econet.set_set_point(None, target_temp_high, target_temp_low) + + @property + def is_aux_heat(self): + """Return true if aux heater.""" + return self._econet.mode == ThermostatOperationMode.EMERGENCY_HEAT + + @property + def hvac_modes(self): + """Return hvac operation ie. heat, cool mode. + + Needs to be one of HVAC_MODE_*. + """ + return self.op_list + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool, mode. + + Needs to be one of HVAC_MODE_*. + """ + econet_mode = self._econet.mode + _current_op = HVAC_MODE_OFF + if econet_mode is not None: + _current_op = ECONET_STATE_TO_HA[econet_mode] + + return _current_op + + def set_hvac_mode(self, hvac_mode): + """Set new target hvac mode.""" + hvac_mode_to_set = HA_STATE_TO_ECONET.get(hvac_mode) + if hvac_mode_to_set is None: + raise ValueError(f"{hvac_mode} is not a valid mode.") + self._econet.set_mode(hvac_mode_to_set) + + def set_humidity(self, humidity: int): + """Set new target humidity.""" + self._econet.set_dehumidifier_set_point(humidity) + + @property + def fan_mode(self): + """Return the current fan mode.""" + econet_fan_mode = self._econet.fan_mode + + # Remove this after we figure out how to handle med lo and med hi + if econet_fan_mode in [ThermostatFanMode.MEDHI, ThermostatFanMode.MEDLO]: + econet_fan_mode = ThermostatFanMode.MEDIUM + + _current_fan_mode = FAN_AUTO + if econet_fan_mode is not None: + _current_fan_mode = ECONET_FAN_STATE_TO_HA[econet_fan_mode] + return _current_fan_mode + + @property + def fan_modes(self): + """Return the fan modes.""" + econet_fan_modes = self._econet.fan_modes + fan_list = [] + for mode in econet_fan_modes: + # Remove the MEDLO MEDHI once we figure out how to handle it + if mode not in [ + ThermostatFanMode.UNKNOWN, + ThermostatFanMode.MEDLO, + ThermostatFanMode.MEDHI, + ]: + fan_list.append(ECONET_FAN_STATE_TO_HA[mode]) + return fan_list + + def set_fan_mode(self, fan_mode): + """Set the fan mode.""" + self._econet.set_fan_mode(HA_FAN_STATE_TO_ECONET[fan_mode]) + + def turn_aux_heat_on(self): + """Turn auxiliary heater on.""" + self._econet.set_mode(ThermostatOperationMode.EMERGENCY_HEAT) + + def turn_aux_heat_off(self): + """Turn auxiliary heater off.""" + self._econet.set_mode(ThermostatOperationMode.HEATING) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self._econet.set_point_limits[0] + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self._econet.set_point_limits[1] + + @property + def min_humidity(self) -> int: + """Return the minimum humidity.""" + return self._econet.dehumidifier_set_point_limits[0] + + @property + def max_humidity(self) -> int: + """Return the maximum humidity.""" + return self._econet.dehumidifier_set_point_limits[1] diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index 7e4cf0106ba..c658542295e 100644 --- a/homeassistant/components/econet/manifest.json +++ b/homeassistant/components/econet/manifest.json @@ -4,6 +4,6 @@ "name": "Rheem EcoNet Products", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/econet", - "requirements": ["pyeconet==0.1.12"], + "requirements": ["pyeconet==0.1.13"], "codeowners": ["@vangorra", "@w1ll1am23"] } \ No newline at end of file diff --git a/homeassistant/components/econet/sensor.py b/homeassistant/components/econet/sensor.py index 05fa1b734ea..0dfe8df7fb3 100644 --- a/homeassistant/components/econet/sensor.py +++ b/homeassistant/components/econet/sensor.py @@ -3,9 +3,9 @@ from pyeconet.equipment import EquipmentType from homeassistant.components.sensor import SensorEntity from homeassistant.const import ( + DEVICE_CLASS_SIGNAL_STRENGTH, ENERGY_KILO_WATT_HOUR, PERCENTAGE, - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, VOLUME_GALLONS, ) @@ -13,6 +13,7 @@ from . import EcoNetEntity from .const import DOMAIN, EQUIPMENT ENERGY_KILO_BRITISH_THERMAL_UNIT = "kBtu" + TANK_HEALTH = "tank_health" AVAILIBLE_HOT_WATER = "availible_hot_water" COMPRESSOR_HEALTH = "compressor_health" @@ -23,28 +24,51 @@ ALERT_COUNT = "alert_count" WIFI_SIGNAL = "wifi_signal" RUNNING_STATE = "running_state" +SENSOR_NAMES_TO_ATTRIBUTES = { + TANK_HEALTH: "tank_health", + AVAILIBLE_HOT_WATER: "tank_hot_water_availability", + COMPRESSOR_HEALTH: "compressor_health", + OVERRIDE_STATUS: "override_status", + WATER_USAGE_TODAY: "todays_water_usage", + POWER_USAGE_TODAY: "todays_energy_usage", + ALERT_COUNT: "alert_count", + WIFI_SIGNAL: "wifi_signal", + RUNNING_STATE: "running_state", +} + +SENSOR_NAMES_TO_UNIT_OF_MEASUREMENT = { + TANK_HEALTH: PERCENTAGE, + AVAILIBLE_HOT_WATER: PERCENTAGE, + COMPRESSOR_HEALTH: PERCENTAGE, + OVERRIDE_STATUS: None, + WATER_USAGE_TODAY: VOLUME_GALLONS, + POWER_USAGE_TODAY: None, # Depends on unit type + ALERT_COUNT: None, + WIFI_SIGNAL: DEVICE_CLASS_SIGNAL_STRENGTH, + RUNNING_STATE: None, # This is just a string +} + async def async_setup_entry(hass, entry, async_add_entities): """Set up EcoNet sensor based on a config entry.""" + equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] sensors = [] + all_equipment = equipment[EquipmentType.WATER_HEATER].copy() + all_equipment.extend(equipment[EquipmentType.THERMOSTAT].copy()) + + for _equip in all_equipment: + for name, attribute in SENSOR_NAMES_TO_ATTRIBUTES.items(): + if getattr(_equip, attribute, None) is not None: + sensors.append(EcoNetSensor(_equip, name)) + # This is None to start with and all device have it + sensors.append(EcoNetSensor(_equip, WIFI_SIGNAL)) + for water_heater in equipment[EquipmentType.WATER_HEATER]: - if water_heater.tank_hot_water_availability is not None: - sensors.append(EcoNetSensor(water_heater, AVAILIBLE_HOT_WATER)) - if water_heater.tank_health is not None: - sensors.append(EcoNetSensor(water_heater, TANK_HEALTH)) - if water_heater.compressor_health is not None: - sensors.append(EcoNetSensor(water_heater, COMPRESSOR_HEALTH)) - if water_heater.override_status: - sensors.append(EcoNetSensor(water_heater, OVERRIDE_STATUS)) - if water_heater.running_state is not None: - sensors.append(EcoNetSensor(water_heater, RUNNING_STATE)) - # All units have this - sensors.append(EcoNetSensor(water_heater, ALERT_COUNT)) # These aren't part of the device and start off as None in pyeconet so always add them sensors.append(EcoNetSensor(water_heater, WATER_USAGE_TODAY)) sensors.append(EcoNetSensor(water_heater, POWER_USAGE_TODAY)) - sensors.append(EcoNetSensor(water_heater, WIFI_SIGNAL)) + async_add_entities(sensors) @@ -60,50 +84,21 @@ class EcoNetSensor(EcoNetEntity, SensorEntity): @property def state(self): """Return sensors state.""" - if self._device_name == AVAILIBLE_HOT_WATER: - return self._econet.tank_hot_water_availability - if self._device_name == TANK_HEALTH: - return self._econet.tank_health - if self._device_name == COMPRESSOR_HEALTH: - return self._econet.compressor_health - if self._device_name == OVERRIDE_STATUS: - return self._econet.oveerride_status - if self._device_name == WATER_USAGE_TODAY: - if self._econet.todays_water_usage: - return round(self._econet.todays_water_usage, 2) - return None - if self._device_name == POWER_USAGE_TODAY: - if self._econet.todays_energy_usage: - return round(self._econet.todays_energy_usage, 2) - return None - if self._device_name == WIFI_SIGNAL: - if self._econet.wifi_signal: - return self._econet.wifi_signal - return None - if self._device_name == ALERT_COUNT: - return self._econet.alert_count - if self._device_name == RUNNING_STATE: - return self._econet.running_state - return None + value = getattr(self._econet, SENSOR_NAMES_TO_ATTRIBUTES[self._device_name]) + if isinstance(value, float): + value = round(value, 2) + return value @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - if self._device_name == AVAILIBLE_HOT_WATER: - return PERCENTAGE - if self._device_name == TANK_HEALTH: - return PERCENTAGE - if self._device_name == COMPRESSOR_HEALTH: - return PERCENTAGE - if self._device_name == WATER_USAGE_TODAY: - return VOLUME_GALLONS + unit_of_measurement = SENSOR_NAMES_TO_UNIT_OF_MEASUREMENT[self._device_name] if self._device_name == POWER_USAGE_TODAY: if self._econet.energy_type == ENERGY_KILO_BRITISH_THERMAL_UNIT.upper(): - return ENERGY_KILO_BRITISH_THERMAL_UNIT - return ENERGY_KILO_WATT_HOUR - if self._device_name == WIFI_SIGNAL: - return SIGNAL_STRENGTH_DECIBELS_MILLIWATT - return None + unit_of_measurement = ENERGY_KILO_BRITISH_THERMAL_UNIT + else: + unit_of_measurement = ENERGY_KILO_WATT_HOUR + return unit_of_measurement @property def name(self): diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index af3399b53af..ed31e78af7c 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -18,7 +18,6 @@ from homeassistant.components.water_heater import ( SUPPORT_TARGET_TEMPERATURE, WaterHeaterEntity, ) -from homeassistant.const import TEMP_FAHRENHEIT from homeassistant.core import callback from . import EcoNetEntity @@ -77,11 +76,6 @@ class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity): """Return true if away mode is on.""" return self._econet.away - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_FAHRENHEIT - @property def current_operation(self): """Return current operation.""" @@ -160,6 +154,7 @@ class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity): """Get the latest energy usage.""" await self.water_heater.get_energy_usage() await self.water_heater.get_water_usage() + self.async_write_ha_state() self._poll = False def turn_away_mode_on(self): diff --git a/requirements_all.txt b/requirements_all.txt index 11ef33c6436..6730162928e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1355,7 +1355,7 @@ pydroid-ipcam==0.8 pyebox==1.1.4 # homeassistant.components.econet -pyeconet==0.1.12 +pyeconet==0.1.13 # homeassistant.components.edimax pyedimax==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a0e78661180..c44a19e997d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -708,7 +708,7 @@ pydexcom==0.2.0 pydispatcher==2.0.5 # homeassistant.components.econet -pyeconet==0.1.12 +pyeconet==0.1.13 # homeassistant.components.everlights pyeverlights==0.1.0