Home Assistant Unofficial Reference 2024.12.1
sensor.py
Go to the documentation of this file.
1 """Support for Awair sensors."""
2 
3 from __future__ import annotations
4 
5 from dataclasses import dataclass
6 from typing import Any, cast
7 
8 from python_awair.air_data import AirData
9 from python_awair.devices import AwairBaseDevice, AwairLocalDevice
10 
12  SensorDeviceClass,
13  SensorEntity,
14  SensorEntityDescription,
15  SensorStateClass,
16 )
17 from homeassistant.config_entries import ConfigEntry
18 from homeassistant.const import (
19  ATTR_CONNECTIONS,
20  ATTR_SW_VERSION,
21  CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
22  CONCENTRATION_PARTS_PER_BILLION,
23  CONCENTRATION_PARTS_PER_MILLION,
24  LIGHT_LUX,
25  PERCENTAGE,
26  UnitOfSoundPressure,
27  UnitOfTemperature,
28 )
29 from homeassistant.core import HomeAssistant
30 from homeassistant.helpers import device_registry as dr
31 from homeassistant.helpers.device_registry import DeviceInfo
32 from homeassistant.helpers.entity_platform import AddEntitiesCallback
33 from homeassistant.helpers.update_coordinator import CoordinatorEntity
34 
35 from .const import (
36  API_CO2,
37  API_DUST,
38  API_HUMID,
39  API_LUX,
40  API_PM10,
41  API_PM25,
42  API_SCORE,
43  API_SPL_A,
44  API_TEMP,
45  API_VOC,
46  ATTRIBUTION,
47  DOMAIN,
48 )
49 from .coordinator import AwairConfigEntry, AwairDataUpdateCoordinator
50 
51 DUST_ALIASES = [API_PM25, API_PM10]
52 
53 
54 @dataclass(frozen=True, kw_only=True)
56  """Describes Awair sensor entity."""
57 
58  unique_id_tag: str
59 
60 
61 SENSOR_TYPE_SCORE = AwairSensorEntityDescription(
62  key=API_SCORE,
63  native_unit_of_measurement=PERCENTAGE,
64  translation_key="score",
65  unique_id_tag="score", # matches legacy format
66  state_class=SensorStateClass.MEASUREMENT,
67 )
68 
69 SENSOR_TYPES: tuple[AwairSensorEntityDescription, ...] = (
71  key=API_HUMID,
72  device_class=SensorDeviceClass.HUMIDITY,
73  native_unit_of_measurement=PERCENTAGE,
74  unique_id_tag="HUMID", # matches legacy format
75  state_class=SensorStateClass.MEASUREMENT,
76  ),
78  key=API_LUX,
79  device_class=SensorDeviceClass.ILLUMINANCE,
80  native_unit_of_measurement=LIGHT_LUX,
81  unique_id_tag="illuminance",
82  state_class=SensorStateClass.MEASUREMENT,
83  ),
85  key=API_SPL_A,
86  device_class=SensorDeviceClass.SOUND_PRESSURE,
87  native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A,
88  translation_key="sound_level",
89  unique_id_tag="sound_level",
90  state_class=SensorStateClass.MEASUREMENT,
91  ),
93  key=API_VOC,
94  device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
95  native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
96  unique_id_tag="VOC", # matches legacy format
97  state_class=SensorStateClass.MEASUREMENT,
98  ),
100  key=API_TEMP,
101  device_class=SensorDeviceClass.TEMPERATURE,
102  native_unit_of_measurement=UnitOfTemperature.CELSIUS,
103  unique_id_tag="TEMP", # matches legacy format
104  state_class=SensorStateClass.MEASUREMENT,
105  ),
107  key=API_CO2,
108  device_class=SensorDeviceClass.CO2,
109  native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
110  unique_id_tag="CO2", # matches legacy format
111  state_class=SensorStateClass.MEASUREMENT,
112  ),
113 )
114 
115 SENSOR_TYPES_DUST: tuple[AwairSensorEntityDescription, ...] = (
117  key=API_PM25,
118  device_class=SensorDeviceClass.PM25,
119  native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
120  unique_id_tag="PM25", # matches legacy format
121  state_class=SensorStateClass.MEASUREMENT,
122  ),
124  key=API_PM10,
125  device_class=SensorDeviceClass.PM10,
126  native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
127  unique_id_tag="PM10", # matches legacy format
128  state_class=SensorStateClass.MEASUREMENT,
129  ),
130 )
131 
132 
134  hass: HomeAssistant,
135  config_entry: AwairConfigEntry,
136  async_add_entities: AddEntitiesCallback,
137 ) -> None:
138  """Set up Awair sensor entity based on a config entry."""
139  coordinator = config_entry.runtime_data
140  entities = []
141 
142  for result in coordinator.data.values():
143  if result.air_data:
144  entities.append(AwairSensor(result.device, coordinator, SENSOR_TYPE_SCORE))
145  device_sensors = result.air_data.sensors.keys()
146  entities.extend(
147  [
148  AwairSensor(result.device, coordinator, description)
149  for description in (*SENSOR_TYPES, *SENSOR_TYPES_DUST)
150  if description.key in device_sensors
151  ]
152  )
153 
154  # The "DUST" sensor for Awair is a combo pm2.5/pm10 sensor only
155  # present on first-gen devices in lieu of separate pm2.5/pm10 sensors.
156  # We handle that by creating fake pm2.5/pm10 sensors that will always
157  # report identical values, and we let users decide how they want to use
158  # that data - because we can't really tell what kind of particles the
159  # "DUST" sensor actually detected. However, it's still useful data.
160  if API_DUST in device_sensors:
161  entities.extend(
162  [
163  AwairSensor(result.device, coordinator, description)
164  for description in SENSOR_TYPES_DUST
165  ]
166  )
167 
168  async_add_entities(entities)
169 
170 
171 class AwairSensor(CoordinatorEntity[AwairDataUpdateCoordinator], SensorEntity):
172  """Defines an Awair sensor entity."""
173 
174  entity_description: AwairSensorEntityDescription
175  _attr_has_entity_name = True
176  _attr_attribution = ATTRIBUTION
177 
178  def __init__(
179  self,
180  device: AwairBaseDevice,
181  coordinator: AwairDataUpdateCoordinator,
182  description: AwairSensorEntityDescription,
183  ) -> None:
184  """Set up an individual AwairSensor."""
185  super().__init__(coordinator)
186  self.entity_descriptionentity_description = description
187  self._device_device = device
188 
189  @property
190  def unique_id(self) -> str:
191  """Return the uuid as the unique_id."""
192  unique_id_tag = self.entity_descriptionentity_description.unique_id_tag
193 
194  # This integration used to create a sensor that was labelled as a "PM2.5"
195  # sensor for first-gen Awair devices, but its unique_id reflected the truth:
196  # under the hood, it was a "DUST" sensor. So we preserve that specific unique_id
197  # for users with first-gen devices that are upgrading.
198  if (
199  self.entity_descriptionentity_description.key == API_PM25
200  and self._air_data_air_data
201  and API_DUST in self._air_data_air_data.sensors
202  ):
203  unique_id_tag = "DUST"
204 
205  return f"{self._device.uuid}_{unique_id_tag}"
206 
207  @property
208  def available(self) -> bool:
209  """Determine if the sensor is available based on API results."""
210  # If the last update was successful...
211  if self.coordinator.last_update_success and self._air_data_air_data:
212  # and the results included our sensor type...
213  sensor_type = self.entity_descriptionentity_description.key
214  if sensor_type in self._air_data_air_data.sensors:
215  # then we are available.
216  return True
217 
218  # or, we're a dust alias
219  if sensor_type in DUST_ALIASES and API_DUST in self._air_data_air_data.sensors:
220  return True
221 
222  # or we are API_SCORE
223  if sensor_type == API_SCORE:
224  # then we are available.
225  return True
226 
227  # Otherwise, we are not.
228  return False
229 
230  @property
231  def native_value(self) -> float | None:
232  """Return the state, rounding off to reasonable values."""
233  if not self._air_data_air_data:
234  return None
235 
236  state: float
237  sensor_type = self.entity_descriptionentity_description.key
238 
239  # Special-case for "SCORE", which we treat as the AQI
240  if sensor_type == API_SCORE:
241  state = self._air_data_air_data.score
242  elif sensor_type in DUST_ALIASES and API_DUST in self._air_data_air_data.sensors:
243  state = self._air_data_air_data.sensors.dust
244  else:
245  state = self._air_data_air_data.sensors[sensor_type]
246 
247  if sensor_type in {API_VOC, API_SCORE}:
248  return round(state)
249 
250  if sensor_type == API_TEMP:
251  return round(state, 1)
252 
253  return round(state, 2)
254 
255  @property
256  def extra_state_attributes(self) -> dict[str, Any]:
257  """Return the Awair Index alongside state attributes.
258 
259  The Awair Index is a subjective score ranging from 0-4 (inclusive) that
260  is is used by the Awair app when displaying the relative "safety" of a
261  given measurement. Each value is mapped to a color indicating the safety:
262 
263  0: green
264  1: yellow
265  2: light-orange
266  3: orange
267  4: red
268 
269  The API indicates that both positive and negative values may be returned,
270  but the negative values are mapped to identical colors as the positive values.
271  Knowing that, we just return the absolute value of a given index so that
272  users don't have to handle positive/negative values that ultimately "mean"
273  the same thing.
274 
275  https://docs.developer.getawair.com/?version=latest#awair-score-and-index
276  """
277  sensor_type = self.entity_descriptionentity_description.key
278  attrs: dict[str, Any] = {}
279  if not self._air_data_air_data:
280  return attrs
281  if sensor_type in self._air_data_air_data.indices:
282  attrs["awair_index"] = abs(self._air_data_air_data.indices[sensor_type])
283  elif sensor_type in DUST_ALIASES and API_DUST in self._air_data_air_data.indices:
284  attrs["awair_index"] = abs(self._air_data_air_data.indices.dust)
285 
286  return attrs
287 
288  @property
289  def device_info(self) -> DeviceInfo:
290  """Device information."""
291  info = DeviceInfo(
292  identifiers={(DOMAIN, self._device_device.uuid)},
293  manufacturer="Awair",
294  model=self._device_device.model,
295  model_id=self._device_device.device_type,
296  name=(
297  self._device_device.name
298  or cast(ConfigEntry, self.coordinator.config_entry).title
299  or f"{self._device.model} ({self._device.device_id})"
300  ),
301  )
302 
303  if self._device_device.mac_address:
304  info[ATTR_CONNECTIONS] = {
305  (dr.CONNECTION_NETWORK_MAC, self._device_device.mac_address)
306  }
307 
308  if isinstance(self._device_device, AwairLocalDevice):
309  info[ATTR_SW_VERSION] = self._device_device.fw_version
310 
311  return info
312 
313  @property
314  def _air_data(self) -> AirData | None:
315  """Return the latest data for our device, or None."""
316  if result := self.coordinator.data.get(self._device_device.uuid):
317  return result.air_data
318 
319  return None
None __init__(self, AwairBaseDevice device, AwairDataUpdateCoordinator coordinator, AwairSensorEntityDescription description)
Definition: sensor.py:183
None async_setup_entry(HomeAssistant hass, AwairConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: sensor.py:137