1
mirror of https://github.com/home-assistant/core synced 2024-08-02 23:40:32 +02:00
ha-core/homeassistant/components/zha/sensor.py
Alexei Chetroi 723596076d
Add ZHA HVAC Action sensor (#57021)
* WIP

* Refactor multi-entity matching

Eliminate the notion on primary channel.

* Cleanup climate tests

* Refactor multi-entity match

Remove the "primary channel" in multiple entity matches

* Cleanup

* Add HVAC Action sensor

* Add a "stop_on_match" option for multi entities matches

Nominally working HVAC state sensors

* Add id_suffix for HVAC action sensor

* Fix Zen HVAC action sensor

* Pylint
2021-10-04 12:57:58 -04:00

610 lines
18 KiB
Python

"""Sensors on Zigbee Home Automation networks."""
from __future__ import annotations
import functools
import numbers
from typing import Any
from homeassistant.components.climate.const import (
CURRENT_HVAC_COOL,
CURRENT_HVAC_FAN,
CURRENT_HVAC_HEAT,
CURRENT_HVAC_IDLE,
CURRENT_HVAC_OFF,
)
from homeassistant.components.sensor import (
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_CO,
DEVICE_CLASS_CO2,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_POWER,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_TEMPERATURE,
DOMAIN,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
SensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
DEVICE_CLASS_ENERGY,
ELECTRIC_CURRENT_AMPERE,
ELECTRIC_POTENTIAL_VOLT,
ENERGY_KILO_WATT_HOUR,
LIGHT_LUX,
PERCENTAGE,
POWER_WATT,
PRESSURE_HPA,
TEMP_CELSIUS,
TIME_HOURS,
TIME_SECONDS,
VOLUME_CUBIC_FEET,
VOLUME_CUBIC_METERS,
VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE,
VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR,
VOLUME_GALLONS,
VOLUME_LITERS,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from .core import discovery
from .core.const import (
CHANNEL_ANALOG_INPUT,
CHANNEL_ELECTRICAL_MEASUREMENT,
CHANNEL_HUMIDITY,
CHANNEL_ILLUMINANCE,
CHANNEL_POWER_CONFIGURATION,
CHANNEL_PRESSURE,
CHANNEL_SMARTENERGY_METERING,
CHANNEL_TEMPERATURE,
CHANNEL_THERMOSTAT,
DATA_ZHA,
DATA_ZHA_DISPATCHERS,
SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED,
)
from .core.registries import SMARTTHINGS_HUMIDITY_CLUSTER, ZHA_ENTITIES
from .core.typing import ChannelType, ZhaDeviceType
from .entity import ZhaEntity
PARALLEL_UPDATES = 5
BATTERY_SIZES = {
0: "No battery",
1: "Built in",
2: "Other",
3: "AA",
4: "AAA",
5: "C",
6: "D",
7: "CR2",
8: "CR123A",
9: "CR2450",
10: "CR2032",
11: "CR1632",
255: "Unknown",
}
CHANNEL_ST_HUMIDITY_CLUSTER = f"channel_0x{SMARTTHINGS_HUMIDITY_CLUSTER:04x}"
STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN)
MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, DOMAIN)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Zigbee Home Automation sensor from config entry."""
entities_to_create = hass.data[DATA_ZHA][DOMAIN]
unsub = async_dispatcher_connect(
hass,
SIGNAL_ADD_ENTITIES,
functools.partial(
discovery.async_add_entities,
async_add_entities,
entities_to_create,
update_before_add=False,
),
)
hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)
class Sensor(ZhaEntity, SensorEntity):
"""Base ZHA sensor."""
SENSOR_ATTR: int | str | None = None
_decimals: int = 1
_device_class: str | None = None
_divisor: int = 1
_multiplier: int = 1
_state_class: str | None = None
_unit: str | None = None
def __init__(
self,
unique_id: str,
zha_device: ZhaDeviceType,
channels: list[ChannelType],
**kwargs,
) -> None:
"""Init this sensor."""
super().__init__(unique_id, zha_device, channels, **kwargs)
self._channel: ChannelType = channels[0]
@classmethod
def create_entity(
cls,
unique_id: str,
zha_device: ZhaDeviceType,
channels: list[ChannelType],
**kwargs,
) -> ZhaEntity | None:
"""Entity Factory.
Return entity if it is a supported configuration, otherwise return None
"""
channel = channels[0]
if cls.SENSOR_ATTR in channel.cluster.unsupported_attributes:
return None
return cls(unique_id, zha_device, channels, **kwargs)
async def async_added_to_hass(self) -> None:
"""Run when about to be added to hass."""
await super().async_added_to_hass()
self.async_accept_signal(
self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state
)
@property
def device_class(self) -> str:
"""Return device class from component DEVICE_CLASSES."""
return self._device_class
@property
def state_class(self) -> str | None:
"""Return the state class of this entity, from STATE_CLASSES, if any."""
return self._state_class
@property
def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement of this entity."""
return self._unit
@property
def native_value(self) -> StateType:
"""Return the state of the entity."""
assert self.SENSOR_ATTR is not None
raw_state = self._channel.cluster.get(self.SENSOR_ATTR)
if raw_state is None:
return None
return self.formatter(raw_state)
@callback
def async_set_state(self, attr_id: int, attr_name: str, value: Any) -> None:
"""Handle state update from channel."""
self.async_write_ha_state()
def formatter(self, value: int) -> int | float:
"""Numeric pass-through formatter."""
if self._decimals > 0:
return round(
float(value * self._multiplier) / self._divisor, self._decimals
)
return round(float(value * self._multiplier) / self._divisor)
@STRICT_MATCH(
channel_names=CHANNEL_ANALOG_INPUT,
manufacturers="LUMI",
models={"lumi.plug", "lumi.plug.maus01", "lumi.plug.mmeu01"},
)
@STRICT_MATCH(channel_names=CHANNEL_ANALOG_INPUT, manufacturers="Digi")
class AnalogInput(Sensor):
"""Sensor that displays analog input values."""
SENSOR_ATTR = "present_value"
@STRICT_MATCH(channel_names=CHANNEL_POWER_CONFIGURATION)
class Battery(Sensor):
"""Battery sensor of power configuration cluster."""
SENSOR_ATTR = "battery_percentage_remaining"
_device_class = DEVICE_CLASS_BATTERY
_state_class = STATE_CLASS_MEASUREMENT
_unit = PERCENTAGE
@staticmethod
def formatter(value: int) -> int:
"""Return the state of the entity."""
# per zcl specs battery percent is reported at 200% ¯\_(ツ)_/¯
if not isinstance(value, numbers.Number) or value == -1:
return value
value = round(value / 2)
return value
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return device state attrs for battery sensors."""
state_attrs = {}
battery_size = self._channel.cluster.get("battery_size")
if battery_size is not None:
state_attrs["battery_size"] = BATTERY_SIZES.get(battery_size, "Unknown")
battery_quantity = self._channel.cluster.get("battery_quantity")
if battery_quantity is not None:
state_attrs["battery_quantity"] = battery_quantity
battery_voltage = self._channel.cluster.get("battery_voltage")
if battery_voltage is not None:
state_attrs["battery_voltage"] = round(battery_voltage / 10, 2)
return state_attrs
@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT)
class ElectricalMeasurement(Sensor):
"""Active power measurement."""
SENSOR_ATTR = "active_power"
_device_class = DEVICE_CLASS_POWER
_state_class = STATE_CLASS_MEASUREMENT
_unit = POWER_WATT
_div_mul_prefix = "ac_power"
@property
def should_poll(self) -> bool:
"""Return True if HA needs to poll for state changes."""
return True
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return device state attrs for sensor."""
attrs = {}
if self._channel.measurement_type is not None:
attrs["measurement_type"] = self._channel.measurement_type
max_attr_name = f"{self.SENSOR_ATTR}_max"
if (max_v := self._channel.cluster.get(max_attr_name)) is not None:
attrs[max_attr_name] = str(self.formatter(max_v))
return attrs
def formatter(self, value: int) -> int | float:
"""Return 'normalized' value."""
multiplier = getattr(self._channel, f"{self._div_mul_prefix}_multiplier")
divisor = getattr(self._channel, f"{self._div_mul_prefix}_divisor")
value = float(value * multiplier) / divisor
if value < 100 and divisor > 1:
return round(value, self._decimals)
return round(value)
async def async_update(self) -> None:
"""Retrieve latest state."""
if not self.available:
return
await super().async_update()
@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT)
class ElectricalMeasurementRMSCurrent(ElectricalMeasurement, id_suffix="rms_current"):
"""RMS current measurement."""
SENSOR_ATTR = "rms_current"
_device_class = DEVICE_CLASS_CURRENT
_unit = ELECTRIC_CURRENT_AMPERE
_div_mul_prefix = "ac_current"
@property
def should_poll(self) -> bool:
"""Poll indirectly by ElectricalMeasurementSensor."""
return False
@MULTI_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT)
class ElectricalMeasurementRMSVoltage(ElectricalMeasurement, id_suffix="rms_voltage"):
"""RMS Voltage measurement."""
SENSOR_ATTR = "rms_voltage"
_device_class = DEVICE_CLASS_CURRENT
_unit = ELECTRIC_POTENTIAL_VOLT
_div_mul_prefix = "ac_voltage"
@property
def should_poll(self) -> bool:
"""Poll indirectly by ElectricalMeasurementSensor."""
return False
@STRICT_MATCH(generic_ids=CHANNEL_ST_HUMIDITY_CLUSTER)
@STRICT_MATCH(channel_names=CHANNEL_HUMIDITY)
class Humidity(Sensor):
"""Humidity sensor."""
SENSOR_ATTR = "measured_value"
_device_class = DEVICE_CLASS_HUMIDITY
_divisor = 100
_state_class = STATE_CLASS_MEASUREMENT
_unit = PERCENTAGE
@STRICT_MATCH(channel_names=CHANNEL_ILLUMINANCE)
class Illuminance(Sensor):
"""Illuminance Sensor."""
SENSOR_ATTR = "measured_value"
_device_class = DEVICE_CLASS_ILLUMINANCE
_unit = LIGHT_LUX
@staticmethod
def formatter(value: int) -> float:
"""Convert illumination data."""
return round(pow(10, ((value - 1) / 10000)), 1)
@MULTI_MATCH(channel_names=CHANNEL_SMARTENERGY_METERING)
class SmartEnergyMetering(Sensor):
"""Metering sensor."""
SENSOR_ATTR: int | str = "instantaneous_demand"
_device_class: str | None = DEVICE_CLASS_POWER
_state_class: str | None = STATE_CLASS_MEASUREMENT
unit_of_measure_map = {
0x00: POWER_WATT,
0x01: VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR,
0x02: VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE,
0x03: f"100 {VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR}",
0x04: f"US {VOLUME_GALLONS}/{TIME_HOURS}",
0x05: f"IMP {VOLUME_GALLONS}/{TIME_HOURS}",
0x06: f"BTU/{TIME_HOURS}",
0x07: f"l/{TIME_HOURS}",
0x08: "kPa", # gauge
0x09: "kPa", # absolute
0x0A: f"1000 {VOLUME_GALLONS}/{TIME_HOURS}",
0x0B: "unitless",
0x0C: f"MJ/{TIME_SECONDS}",
}
def formatter(self, value: int) -> int | float:
"""Pass through channel formatter."""
return self._channel.demand_formatter(value)
@property
def native_unit_of_measurement(self) -> str:
"""Return Unit of measurement."""
return self.unit_of_measure_map.get(self._channel.unit_of_measurement)
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return device state attrs for battery sensors."""
attrs = {}
if self._channel.device_type is not None:
attrs["device_type"] = self._channel.device_type
status = self._channel.status
if status is not None:
attrs["status"] = str(status)[len(status.__class__.__name__) + 1 :]
return attrs
@MULTI_MATCH(channel_names=CHANNEL_SMARTENERGY_METERING)
class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered"):
"""Smart Energy Metering summation sensor."""
SENSOR_ATTR: int | str = "current_summ_delivered"
_device_class: str | None = DEVICE_CLASS_ENERGY
_state_class: str = STATE_CLASS_TOTAL_INCREASING
unit_of_measure_map = {
0x00: ENERGY_KILO_WATT_HOUR,
0x01: VOLUME_CUBIC_METERS,
0x02: VOLUME_CUBIC_FEET,
0x03: f"100 {VOLUME_CUBIC_FEET}",
0x04: f"US {VOLUME_GALLONS}",
0x05: f"IMP {VOLUME_GALLONS}",
0x06: "BTU",
0x07: VOLUME_LITERS,
0x08: "kPa", # gauge
0x09: "kPa", # absolute
0x0A: f"1000 {VOLUME_CUBIC_FEET}",
0x0B: "unitless",
0x0C: "MJ",
}
def formatter(self, value: int) -> int | float:
"""Numeric pass-through formatter."""
if self._channel.unit_of_measurement != 0:
return self._channel.summa_formatter(value)
cooked = float(self._channel.multiplier * value) / self._channel.divisor
return round(cooked, 3)
@STRICT_MATCH(channel_names=CHANNEL_PRESSURE)
class Pressure(Sensor):
"""Pressure sensor."""
SENSOR_ATTR = "measured_value"
_device_class = DEVICE_CLASS_PRESSURE
_decimals = 0
_state_class = STATE_CLASS_MEASUREMENT
_unit = PRESSURE_HPA
@STRICT_MATCH(channel_names=CHANNEL_TEMPERATURE)
class Temperature(Sensor):
"""Temperature Sensor."""
SENSOR_ATTR = "measured_value"
_device_class = DEVICE_CLASS_TEMPERATURE
_divisor = 100
_state_class = STATE_CLASS_MEASUREMENT
_unit = TEMP_CELSIUS
@STRICT_MATCH(channel_names="carbon_dioxide_concentration")
class CarbonDioxideConcentration(Sensor):
"""Carbon Dioxide Concentration sensor."""
SENSOR_ATTR = "measured_value"
_device_class = DEVICE_CLASS_CO2
_decimals = 0
_multiplier = 1e6
_unit = CONCENTRATION_PARTS_PER_MILLION
@STRICT_MATCH(channel_names="carbon_monoxide_concentration")
class CarbonMonoxideConcentration(Sensor):
"""Carbon Monoxide Concentration sensor."""
SENSOR_ATTR = "measured_value"
_device_class = DEVICE_CLASS_CO
_decimals = 0
_multiplier = 1e6
_unit = CONCENTRATION_PARTS_PER_MILLION
@STRICT_MATCH(generic_ids="channel_0x042e")
@STRICT_MATCH(channel_names="voc_level")
class VOCLevel(Sensor):
"""VOC Level sensor."""
SENSOR_ATTR = "measured_value"
_decimals = 0
_multiplier = 1e6
_unit = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
@STRICT_MATCH(channel_names="formaldehyde_concentration")
class FormaldehydeConcentration(Sensor):
"""Formaldehyde Concentration sensor."""
SENSOR_ATTR = "measured_value"
_decimals = 0
_multiplier = 1e6
_unit = CONCENTRATION_PARTS_PER_MILLION
@MULTI_MATCH(channel_names=CHANNEL_THERMOSTAT)
class ThermostatHVACAction(Sensor, id_suffix="hvac_action"):
"""Thermostat HVAC action sensor."""
@classmethod
def create_entity(
cls,
unique_id: str,
zha_device: ZhaDeviceType,
channels: list[ChannelType],
**kwargs,
) -> ZhaEntity | None:
"""Entity Factory.
Return entity if it is a supported configuration, otherwise return None
"""
return cls(unique_id, zha_device, channels, **kwargs)
@property
def native_value(self) -> str | None:
"""Return the current HVAC action."""
if (
self._channel.pi_heating_demand is None
and self._channel.pi_cooling_demand is None
):
return self._rm_rs_action
return self._pi_demand_action
@property
def _rm_rs_action(self) -> str | None:
"""Return the current HVAC action based on running mode and running state."""
running_mode = self._channel.running_mode
if running_mode == self._channel.RunningMode.Heat:
return CURRENT_HVAC_HEAT
if running_mode == self._channel.RunningMode.Cool:
return CURRENT_HVAC_COOL
running_state = self._channel.running_state
if running_state and running_state & (
self._channel.RunningState.Fan_State_On
| self._channel.RunningState.Fan_2nd_Stage_On
| self._channel.RunningState.Fan_3rd_Stage_On
):
return CURRENT_HVAC_FAN
if (
self._channel.system_mode != self._channel.SystemMode.Off
and running_mode == self._channel.SystemMode.Off
):
return CURRENT_HVAC_IDLE
return CURRENT_HVAC_OFF
@property
def _pi_demand_action(self) -> str | None:
"""Return the current HVAC action based on pi_demands."""
heating_demand = self._channel.pi_heating_demand
if heating_demand is not None and heating_demand > 0:
return CURRENT_HVAC_HEAT
cooling_demand = self._channel.pi_cooling_demand
if cooling_demand is not None and cooling_demand > 0:
return CURRENT_HVAC_COOL
if self._channel.system_mode != self._channel.SystemMode.Off:
return CURRENT_HVAC_IDLE
return CURRENT_HVAC_OFF
@callback
def async_set_state(self, *args, **kwargs) -> None:
"""Handle state update from channel."""
self.async_write_ha_state()
@MULTI_MATCH(
channel_names=CHANNEL_THERMOSTAT,
manufacturers="Zen Within",
stop_on_match=True,
)
class ZenHVACAction(ThermostatHVACAction):
"""Zen Within Thermostat HVAC Action."""
@property
def _rm_rs_action(self) -> str | None:
"""Return the current HVAC action based on running mode and running state."""
running_state = self._channel.running_state
if running_state is None:
return None
rs_heat = (
self._channel.RunningState.Heat_State_On
| self._channel.RunningState.Heat_2nd_Stage_On
)
if running_state & rs_heat:
return CURRENT_HVAC_HEAT
rs_cool = (
self._channel.RunningState.Cool_State_On
| self._channel.RunningState.Cool_2nd_Stage_On
)
if running_state & rs_cool:
return CURRENT_HVAC_COOL
running_state = self._channel.running_state
if running_state and running_state & (
self._channel.RunningState.Fan_State_On
| self._channel.RunningState.Fan_2nd_Stage_On
| self._channel.RunningState.Fan_3rd_Stage_On
):
return CURRENT_HVAC_FAN
if self._channel.system_mode != self._channel.SystemMode.Off:
return CURRENT_HVAC_IDLE
return CURRENT_HVAC_OFF