core/homeassistant/components/aranet/sensor.py

214 lines
7.1 KiB
Python

"""Support for Aranet sensors."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from aranet4.client import Aranet4Advertisement
from bleak.backends.device import BLEDevice
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate,
PassiveBluetoothEntityKey,
PassiveBluetoothProcessorEntity,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
ATTR_MANUFACTURER,
ATTR_NAME,
ATTR_SW_VERSION,
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
EntityCategory,
UnitOfPressure,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AranetConfigEntry
from .const import ARANET_MANUFACTURER_NAME
@dataclass(frozen=True)
class AranetSensorEntityDescription(SensorEntityDescription):
"""Class to describe an Aranet sensor entity."""
# PassiveBluetoothDataUpdate does not support UNDEFINED
# Restrict the type to satisfy the type checker and catch attempts
# to use UNDEFINED in the entity descriptions.
name: str | None = None
scale: float | int = 1
SENSOR_DESCRIPTIONS = {
"temperature": AranetSensorEntityDescription(
key="temperature",
name="Temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
"humidity": AranetSensorEntityDescription(
key="humidity",
name="Humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
"pressure": AranetSensorEntityDescription(
key="pressure",
name="Pressure",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.HPA,
state_class=SensorStateClass.MEASUREMENT,
),
"co2": AranetSensorEntityDescription(
key="co2",
name="Carbon Dioxide",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
),
"radiation_rate": AranetSensorEntityDescription(
key="radiation_rate",
translation_key="radiation_rate",
name="Radiation Dose Rate",
native_unit_of_measurement="μSv/h",
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
scale=0.001,
),
"radiation_total": AranetSensorEntityDescription(
key="radiation_total",
translation_key="radiation_total",
name="Radiation Total Dose",
native_unit_of_measurement="mSv",
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=4,
scale=0.000001,
),
"radon_concentration": AranetSensorEntityDescription(
key="radon_concentration",
translation_key="radon_concentration",
name="Radon Concentration",
native_unit_of_measurement="Bq/m³",
state_class=SensorStateClass.MEASUREMENT,
),
"battery": AranetSensorEntityDescription(
key="battery",
name="Battery",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
"interval": AranetSensorEntityDescription(
key="update_interval",
name="Update Interval",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
state_class=SensorStateClass.MEASUREMENT,
# The interval setting is not a generally useful entity for most users.
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
}
def _device_key_to_bluetooth_entity_key(
device: BLEDevice,
key: str,
) -> PassiveBluetoothEntityKey:
"""Convert a device key to an entity key."""
return PassiveBluetoothEntityKey(key, device.address)
def _sensor_device_info_to_hass(
adv: Aranet4Advertisement,
) -> DeviceInfo:
"""Convert a sensor device info to hass device info."""
hass_device_info = DeviceInfo({})
if adv.readings and adv.readings.name:
hass_device_info[ATTR_NAME] = adv.readings.name
hass_device_info[ATTR_MANUFACTURER] = ARANET_MANUFACTURER_NAME
if adv.manufacturer_data:
hass_device_info[ATTR_SW_VERSION] = str(adv.manufacturer_data.version)
return hass_device_info
def sensor_update_to_bluetooth_data_update(
adv: Aranet4Advertisement,
) -> PassiveBluetoothDataUpdate[Any]:
"""Convert a sensor update to a Bluetooth data update."""
data: dict[PassiveBluetoothEntityKey, Any] = {}
names: dict[PassiveBluetoothEntityKey, str | None] = {}
descs: dict[PassiveBluetoothEntityKey, EntityDescription] = {}
for key, desc in SENSOR_DESCRIPTIONS.items():
tag = _device_key_to_bluetooth_entity_key(adv.device, key)
val = getattr(adv.readings, key)
if val == -1:
continue
val *= desc.scale
data[tag] = val
names[tag] = desc.name
descs[tag] = desc
return PassiveBluetoothDataUpdate(
devices={adv.device.address: _sensor_device_info_to_hass(adv)},
entity_descriptions=descs,
entity_data=data,
entity_names=names,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AranetConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Aranet sensors."""
processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update)
entry.async_on_unload(
processor.async_add_entities_listener(
Aranet4BluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(entry.runtime_data.async_register_processor(processor))
class Aranet4BluetoothSensorEntity(
PassiveBluetoothProcessorEntity[
PassiveBluetoothDataProcessor[float | int | None, Aranet4Advertisement],
],
SensorEntity,
):
"""Representation of an Aranet sensor."""
@property
def available(self) -> bool:
"""Return whether the entity was available in the last update."""
# Our superclass covers "did the device disappear entirely", but if the
# device has smart home integrations disabled, it will send BLE beacons
# without data, which we turn into Nones here. Because None is never a
# valid value for any of the Aranet sensors, that means the entity is
# actually unavailable.
return (
super().available
and self.processor.entity_data.get(self.entity_key) is not None
)
@property
def native_value(self) -> int | float | None:
"""Return the native value."""
return self.processor.entity_data.get(self.entity_key)