Home Assistant Unofficial Reference 2024.12.1
sensor.py
Go to the documentation of this file.
1 """Support for ISY sensors."""
2 
3 from __future__ import annotations
4 
5 from typing import Any, cast
6 
7 from pyisy.constants import (
8  ATTR_ACTION,
9  ATTR_CONTROL,
10  COMMAND_FRIENDLY_NAME,
11  ISY_VALUE_UNKNOWN,
12  NC_NODE_ENABLED,
13  PROP_BATTERY_LEVEL,
14  PROP_COMMS_ERROR,
15  PROP_ENERGY_MODE,
16  PROP_HEAT_COOL_STATE,
17  PROP_HUMIDITY,
18  PROP_ON_LEVEL,
19  PROP_RAMP_RATE,
20  PROP_STATUS,
21  PROP_TEMPERATURE,
22  TAG_ADDRESS,
23 )
24 from pyisy.helpers import EventListener, NodeProperty
25 from pyisy.nodes import Node, NodeChangedEvent
26 
28  SensorDeviceClass,
29  SensorEntity,
30  SensorStateClass,
31 )
32 from homeassistant.config_entries import ConfigEntry
33 from homeassistant.const import EntityCategory, Platform, UnitOfTemperature
34 from homeassistant.core import HomeAssistant, callback
35 from homeassistant.helpers.device_registry import DeviceInfo
36 from homeassistant.helpers.entity_platform import AddEntitiesCallback
37 
38 from .const import (
39  _LOGGER,
40  DOMAIN,
41  UOM_DOUBLE_TEMP,
42  UOM_FRIENDLY_NAME,
43  UOM_INDEX,
44  UOM_ON_OFF,
45  UOM_TO_STATES,
46 )
47 from .entity import ISYNodeEntity
48 from .helpers import convert_isy_value_to_hass
49 from .models import IsyData
50 
51 # Disable general purpose and redundant sensors by default
52 AUX_DISABLED_BY_DEFAULT_MATCH = ["GV", "DO"]
53 AUX_DISABLED_BY_DEFAULT_EXACT = {
54  PROP_COMMS_ERROR,
55  PROP_ENERGY_MODE,
56  PROP_HEAT_COOL_STATE,
57  PROP_ON_LEVEL,
58  PROP_RAMP_RATE,
59  PROP_STATUS,
60 }
61 
62 # Reference pyisy.constants.COMMAND_FRIENDLY_NAME for API details.
63 # Note: "LUMIN"/Illuminance removed, some devices use non-conformant "%" unit
64 # "VOCLVL"/VOC removed, uses qualitative UOM not ug/m^3
65 ISY_CONTROL_TO_DEVICE_CLASS = {
66  PROP_BATTERY_LEVEL: SensorDeviceClass.BATTERY,
67  PROP_HUMIDITY: SensorDeviceClass.HUMIDITY,
68  PROP_TEMPERATURE: SensorDeviceClass.TEMPERATURE,
69  "BARPRES": SensorDeviceClass.ATMOSPHERIC_PRESSURE,
70  "CC": SensorDeviceClass.CURRENT,
71  "CO2LVL": SensorDeviceClass.CO2,
72  "CPW": SensorDeviceClass.POWER,
73  "CV": SensorDeviceClass.VOLTAGE,
74  "DEWPT": SensorDeviceClass.TEMPERATURE,
75  "DISTANC": SensorDeviceClass.DISTANCE,
76  "ETO": SensorDeviceClass.PRECIPITATION_INTENSITY,
77  "FATM": SensorDeviceClass.WEIGHT,
78  "FREQ": SensorDeviceClass.FREQUENCY,
79  "MUSCLEM": SensorDeviceClass.WEIGHT,
80  "PF": SensorDeviceClass.POWER_FACTOR,
81  "PM10": SensorDeviceClass.PM10,
82  "PM25": SensorDeviceClass.PM25,
83  "PRECIP": SensorDeviceClass.PRECIPITATION,
84  "RAINRT": SensorDeviceClass.PRECIPITATION_INTENSITY,
85  "RFSS": SensorDeviceClass.SIGNAL_STRENGTH,
86  "SOILH": SensorDeviceClass.MOISTURE,
87  "SOILT": SensorDeviceClass.TEMPERATURE,
88  "SOLRAD": SensorDeviceClass.IRRADIANCE,
89  "SPEED": SensorDeviceClass.SPEED,
90  "TEMPEXH": SensorDeviceClass.TEMPERATURE,
91  "TEMPOUT": SensorDeviceClass.TEMPERATURE,
92  "TPW": SensorDeviceClass.ENERGY,
93  "WATERP": SensorDeviceClass.PRESSURE,
94  "WATERT": SensorDeviceClass.TEMPERATURE,
95  "WATERTB": SensorDeviceClass.TEMPERATURE,
96  "WATERTD": SensorDeviceClass.TEMPERATURE,
97  "WEIGHT": SensorDeviceClass.WEIGHT,
98  "WINDCH": SensorDeviceClass.TEMPERATURE,
99 }
100 ISY_CONTROL_TO_STATE_CLASS = {
101  control: SensorStateClass.MEASUREMENT for control in ISY_CONTROL_TO_DEVICE_CLASS
102 }
103 ISY_CONTROL_TO_ENTITY_CATEGORY = {
104  PROP_RAMP_RATE: EntityCategory.DIAGNOSTIC,
105  PROP_ON_LEVEL: EntityCategory.DIAGNOSTIC,
106  PROP_COMMS_ERROR: EntityCategory.DIAGNOSTIC,
107 }
108 
109 
111  hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
112 ) -> None:
113  """Set up the ISY sensor platform."""
114  isy_data: IsyData = hass.data[DOMAIN][entry.entry_id]
115  entities: list[ISYSensorEntity] = []
116  devices: dict[str, DeviceInfo] = isy_data.devices
117 
118  for node in isy_data.nodes[Platform.SENSOR]:
119  _LOGGER.debug("Loading %s", node.name)
120  entities.append(ISYSensorEntity(node, devices.get(node.primary_node)))
121 
122  aux_sensors_list = isy_data.aux_properties[Platform.SENSOR]
123  for node, control in aux_sensors_list:
124  _LOGGER.debug("Loading %s %s", node.name, COMMAND_FRIENDLY_NAME.get(control))
125  enabled_default = control not in AUX_DISABLED_BY_DEFAULT_EXACT and not any(
126  control.startswith(match) for match in AUX_DISABLED_BY_DEFAULT_MATCH
127  )
128  entities.append(
130  node=node,
131  control=control,
132  enabled_default=enabled_default,
133  unique_id=f"{isy_data.uid_base(node)}_{control}",
134  device_info=devices.get(node.primary_node),
135  )
136  )
137 
138  async_add_entities(entities)
139 
140 
142  """Representation of an ISY sensor device."""
143 
144  @property
145  def target(self) -> Node | NodeProperty | None:
146  """Return target for the sensor."""
147  return self._node_node
148 
149  @property
150  def target_value(self) -> Any:
151  """Return the target value."""
152  return self._node_node.status
153 
154  @property
155  def raw_unit_of_measurement(self) -> dict | str | None:
156  """Get the raw unit of measurement for the ISY sensor device."""
157  if self.targettarget is None:
158  return None
159 
160  uom = self.targettarget.uom
161 
162  # Backwards compatibility for ISYv4 Firmware:
163  if isinstance(uom, list):
164  return UOM_FRIENDLY_NAME.get(uom[0], uom[0])
165 
166  # Special cases for ISY UOM index units:
167  if isy_states := UOM_TO_STATES.get(uom):
168  return isy_states
169 
170  if uom in (UOM_ON_OFF, UOM_INDEX):
171  assert isinstance(uom, str)
172  return uom
173 
174  return UOM_FRIENDLY_NAME.get(uom)
175 
176  @property
177  def native_value(self) -> float | int | str | None:
178  """Get the state of the ISY sensor device."""
179  if self.targettarget is None:
180  return None
181 
182  if (value := self.target_valuetarget_value) == ISY_VALUE_UNKNOWN:
183  return None
184 
185  # Get the translated ISY Unit of Measurement
186  uom = self.raw_unit_of_measurementraw_unit_of_measurement
187 
188  # Check if this is a known index pair UOM
189  if isinstance(uom, dict):
190  return uom.get(value, value) # type: ignore[no-any-return]
191 
192  if uom in (UOM_INDEX, UOM_ON_OFF):
193  return cast(str, self.targettarget.formatted)
194 
195  # Check if this is an index type and get formatted value
196  if uom == UOM_INDEX and hasattr(self.targettarget, "formatted"):
197  return cast(str, self.targettarget.formatted)
198 
199  # Handle ISY precision and rounding
200  value = convert_isy_value_to_hass(value, uom, self.targettarget.prec)
201 
202  # Convert temperatures to Home Assistant's unit
203  if uom in (UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT):
204  value = self.hasshass.config.units.temperature(value, uom)
205 
206  if value is None:
207  return None
208 
209  assert isinstance(value, (int, float))
210  return value
211 
212  @property
213  def native_unit_of_measurement(self) -> str | None:
214  """Get the Home Assistant unit of measurement for the device."""
215  raw_units = self.raw_unit_of_measurementraw_unit_of_measurement
216  # Check if this is a known index pair UOM
217  if isinstance(raw_units, dict) or raw_units in (UOM_ON_OFF, UOM_INDEX):
218  return None
219  if raw_units in (
220  UnitOfTemperature.FAHRENHEIT,
221  UnitOfTemperature.CELSIUS,
222  UOM_DOUBLE_TEMP,
223  ):
224  return self.hasshass.config.units.temperature_unit
225  return raw_units
226 
227 
229  """Representation of an ISY aux sensor device."""
230 
231  def __init__(
232  self,
233  node: Node,
234  control: str,
235  enabled_default: bool,
236  unique_id: str,
237  device_info: DeviceInfo | None = None,
238  ) -> None:
239  """Initialize the ISY aux sensor."""
240  super().__init__(node, device_info=device_info)
241  self._control_control = control
242  self._attr_entity_registry_enabled_default_attr_entity_registry_enabled_default = enabled_default
243  self._attr_entity_category_attr_entity_category = ISY_CONTROL_TO_ENTITY_CATEGORY.get(control)
244  self._attr_device_class_attr_device_class = ISY_CONTROL_TO_DEVICE_CLASS.get(control)
245  self._attr_state_class_attr_state_class = ISY_CONTROL_TO_STATE_CLASS.get(control)
246  self._attr_unique_id_attr_unique_id_attr_unique_id = unique_id
247  self._change_handler_change_handler_change_handler: EventListener = None
248  self._availability_handler_availability_handler: EventListener = None
249 
250  name = COMMAND_FRIENDLY_NAME.get(self._control_control, self._control_control)
251  self._attr_name_attr_name_attr_name_attr_name = f"{node.name} {name.replace('_', ' ').title()}"
252 
253  @property
254  def target(self) -> Node | NodeProperty | None:
255  """Return target for the sensor."""
256  if self._control_control not in self._node_node.aux_properties:
257  # Property not yet set (i.e. no errors)
258  return None
259  return cast(NodeProperty, self._node_node.aux_properties[self._control_control])
260 
261  @property
262  def target_value(self) -> Any:
263  """Return the target value."""
264  return None if self.targettargettarget is None else self.targettargettarget.value
265 
266  # pylint: disable-next=hass-missing-super-call
267  async def async_added_to_hass(self) -> None:
268  """Subscribe to the node control change events.
269 
270  Overloads the default ISYNodeEntity updater to only update when
271  this control is changed on the device and prevent duplicate firing
272  of `isy994_control` events.
273  """
274  self._change_handler_change_handler_change_handler = self._node_node.control_events.subscribe(
275  self.async_on_updateasync_on_updateasync_on_update, event_filter={ATTR_CONTROL: self._control_control}
276  )
277  self._availability_handler_availability_handler = self._node_node.isy.nodes.status_events.subscribe(
278  self.async_on_updateasync_on_updateasync_on_update,
279  event_filter={
280  TAG_ADDRESS: self._node_node.address,
281  ATTR_ACTION: NC_NODE_ENABLED,
282  },
283  )
284 
285  @callback
286  def async_on_update(self, event: NodeProperty | NodeChangedEvent) -> None:
287  """Handle a control event from the ISY Node."""
288  self.async_write_ha_stateasync_write_ha_state()
289 
290  @property
291  def available(self) -> bool:
292  """Return entity availability."""
293  return cast(bool, self._node_node.enabled)
None async_on_update(self, NodeProperty event)
Definition: entity.py:66
None __init__(self, Node node, str control, bool enabled_default, str unique_id, DeviceInfo|None device_info=None)
Definition: sensor.py:238
None async_on_update(self, NodeProperty|NodeChangedEvent event)
Definition: sensor.py:286
float|int|None convert_isy_value_to_hass(float|None value, str|None uom, int|str precision, int|None fallback_precision=None)
Definition: helpers.py:438
None async_setup_entry(HomeAssistant hass, ConfigEntry entry, AddEntitiesCallback async_add_entities)
Definition: sensor.py:112