ha-core/homeassistant/components/homekit_controller/sensor.py

663 lines
24 KiB
Python

"""Support for Homekit sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from aiohomekit.model import Accessory, Transport
from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes
from aiohomekit.model.characteristics.const import ThreadNodeCapabilities, ThreadStatus
from aiohomekit.model.services import Service, ServicesTypes
from homeassistant.components.bluetooth import (
async_ble_device_from_address,
async_last_service_info,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
LIGHT_LUX,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
Platform,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfPower,
UnitOfPressure,
UnitOfSoundPressure,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from . import KNOWN_DEVICES
from .connection import HKDevice
from .entity import CharacteristicEntity, HomeKitEntity
from .utils import folded_name
@dataclass
class HomeKitSensorEntityDescription(SensorEntityDescription):
"""Describes Homekit sensor."""
probe: Callable[[Characteristic], bool] | None = None
format: Callable[[Characteristic], str] | None = None
def thread_node_capability_to_str(char: Characteristic) -> str:
"""Return the thread device type as a string.
The underlying value is a bitmask, but we want to turn that to
a human readable string. Some devices will have multiple capabilities.
For example, an NL55 is SLEEPY | MINIMAL. In that case we return the
"best" capability.
https://openthread.io/guides/thread-primer/node-roles-and-types
"""
val = ThreadNodeCapabilities(char.value)
if val & ThreadNodeCapabilities.BORDER_ROUTER_CAPABLE:
# can act as a bridge between thread network and e.g. WiFi
return "border_router_capable"
if val & ThreadNodeCapabilities.ROUTER_ELIGIBLE:
# radio always on, can be a router
return "router_eligible"
if val & ThreadNodeCapabilities.FULL:
# radio always on, but can't be a router
return "full"
if val & ThreadNodeCapabilities.MINIMAL:
# transceiver always on, does not need to poll for messages from its parent
return "minimal"
if val & ThreadNodeCapabilities.SLEEPY:
# normally disabled, wakes on occasion to poll for messages from its parent
return "sleepy"
# Device has no known thread capabilities
return "none"
def thread_status_to_str(char: Characteristic) -> str:
"""Return the thread status as a string.
The underlying value is a bitmask, but we want to turn that to
a human readable string. So we check the flags in order. E.g. BORDER_ROUTER implies
ROUTER, so its more important to show that value.
"""
val = ThreadStatus(char.value)
if val & ThreadStatus.BORDER_ROUTER:
# Device has joined the Thread network and is participating
# in routing between mesh nodes.
# It's also the border router - bridging the thread network
# to WiFI/Ethernet/etc
return "border_router"
if val & ThreadStatus.LEADER:
# Device has joined the Thread network and is participating
# in routing between mesh nodes.
# It's also the leader. There's only one leader and it manages
# which nodes are routers.
return "leader"
if val & ThreadStatus.ROUTER:
# Device has joined the Thread network and is participating
# in routing between mesh nodes.
return "router"
if val & ThreadStatus.CHILD:
# Device has joined the Thread network as a child
# It's not participating in routing between mesh nodes
return "child"
if val & ThreadStatus.JOINING:
# Device is currently joining its Thread network
return "joining"
if val & ThreadStatus.DETACHED:
# Device is currently unable to reach its Thread network
return "detached"
# Must be ThreadStatus.DISABLED
# Device is not currently connected to Thread and will not try to.
return "disabled"
SIMPLE_SENSOR: dict[str, HomeKitSensorEntityDescription] = {
CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_WATT: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_WATT,
name="Power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
),
CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_AMPS: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_AMPS,
name="Current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
),
CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_AMPS_20: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_AMPS_20,
name="Current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
),
CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_KW_HOUR: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_KW_HOUR,
name="Energy kWh",
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
),
CharacteristicsTypes.VENDOR_EVE_ENERGY_WATT: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.VENDOR_EVE_ENERGY_WATT,
name="Power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
),
CharacteristicsTypes.VENDOR_EVE_ENERGY_KW_HOUR: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.VENDOR_EVE_ENERGY_KW_HOUR,
name="Energy kWh",
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
),
CharacteristicsTypes.VENDOR_EVE_ENERGY_VOLTAGE: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.VENDOR_EVE_ENERGY_VOLTAGE,
name="Volts",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
),
CharacteristicsTypes.VENDOR_EVE_ENERGY_AMPERE: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.VENDOR_EVE_ENERGY_AMPERE,
name="Amps",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
),
CharacteristicsTypes.VENDOR_KOOGEEK_REALTIME_ENERGY: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.VENDOR_KOOGEEK_REALTIME_ENERGY,
name="Power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
),
CharacteristicsTypes.VENDOR_KOOGEEK_REALTIME_ENERGY_2: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.VENDOR_KOOGEEK_REALTIME_ENERGY_2,
name="Power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
),
CharacteristicsTypes.VENDOR_EVE_DEGREE_AIR_PRESSURE: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.VENDOR_EVE_DEGREE_AIR_PRESSURE,
name="Air Pressure",
device_class=SensorDeviceClass.PRESSURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.HPA,
),
CharacteristicsTypes.VENDOR_VOCOLINC_OUTLET_ENERGY: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.VENDOR_VOCOLINC_OUTLET_ENERGY,
name="Power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
),
CharacteristicsTypes.TEMPERATURE_CURRENT: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.TEMPERATURE_CURRENT,
name="Current Temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
# This sensor is only for temperature characteristics that are not part
# of a temperature sensor service.
probe=(lambda char: char.service.type != ServicesTypes.TEMPERATURE_SENSOR),
),
CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT,
name="Current Humidity",
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
# This sensor is only for humidity characteristics that are not part
# of a humidity sensor service.
probe=(lambda char: char.service.type != ServicesTypes.HUMIDITY_SENSOR),
),
CharacteristicsTypes.AIR_QUALITY: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.AIR_QUALITY,
name="Air Quality",
device_class=SensorDeviceClass.AQI,
state_class=SensorStateClass.MEASUREMENT,
),
CharacteristicsTypes.DENSITY_PM25: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.DENSITY_PM25,
name="PM2.5 Density",
device_class=SensorDeviceClass.PM25,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
CharacteristicsTypes.DENSITY_PM10: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.DENSITY_PM10,
name="PM10 Density",
device_class=SensorDeviceClass.PM10,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
CharacteristicsTypes.DENSITY_OZONE: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.DENSITY_OZONE,
name="Ozone Density",
device_class=SensorDeviceClass.OZONE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
CharacteristicsTypes.DENSITY_NO2: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.DENSITY_NO2,
name="Nitrogen Dioxide Density",
device_class=SensorDeviceClass.NITROGEN_DIOXIDE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
CharacteristicsTypes.DENSITY_SO2: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.DENSITY_SO2,
name="Sulphur Dioxide Density",
device_class=SensorDeviceClass.SULPHUR_DIOXIDE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
CharacteristicsTypes.DENSITY_VOC: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.DENSITY_VOC,
name="Volatile Organic Compound Density",
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
CharacteristicsTypes.THREAD_NODE_CAPABILITIES: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.THREAD_NODE_CAPABILITIES,
name="Thread Capabilities",
entity_category=EntityCategory.DIAGNOSTIC,
format=thread_node_capability_to_str,
device_class=SensorDeviceClass.ENUM,
options=[
"border_router_capable",
"full",
"minimal",
"none",
"router_eligible",
"sleepy",
],
translation_key="thread_node_capabilities",
),
CharacteristicsTypes.THREAD_STATUS: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.THREAD_STATUS,
name="Thread Status",
entity_category=EntityCategory.DIAGNOSTIC,
format=thread_status_to_str,
device_class=SensorDeviceClass.ENUM,
options=[
"border_router",
"child",
"detached",
"disabled",
"joining",
"leader",
"router",
],
translation_key="thread_status",
),
CharacteristicsTypes.VENDOR_NETATMO_NOISE: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.VENDOR_NETATMO_NOISE,
name="Noise",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfSoundPressure.DECIBEL,
device_class=SensorDeviceClass.SOUND_PRESSURE,
),
}
class HomeKitSensor(HomeKitEntity, SensorEntity):
"""Representation of a HomeKit sensor."""
_attr_state_class = SensorStateClass.MEASUREMENT
@property
def name(self) -> str | None:
"""Return the name of the device."""
full_name = super().name
default_name = self.default_name
if (
default_name
and full_name
and folded_name(default_name) not in folded_name(full_name)
):
return f"{full_name} {default_name}"
return full_name
class HomeKitHumiditySensor(HomeKitSensor):
"""Representation of a Homekit humidity sensor."""
_attr_device_class = SensorDeviceClass.HUMIDITY
_attr_native_unit_of_measurement = PERCENTAGE
def get_characteristic_types(self) -> list[str]:
"""Define the homekit characteristics the entity is tracking."""
return [CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT]
@property
def default_name(self) -> str:
"""Return the default name of the device."""
return "Humidity"
@property
def native_value(self) -> float:
"""Return the current humidity."""
return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT)
class HomeKitTemperatureSensor(HomeKitSensor):
"""Representation of a Homekit temperature sensor."""
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
def get_characteristic_types(self) -> list[str]:
"""Define the homekit characteristics the entity is tracking."""
return [CharacteristicsTypes.TEMPERATURE_CURRENT]
@property
def default_name(self) -> str:
"""Return the default name of the device."""
return "Temperature"
@property
def native_value(self) -> float:
"""Return the current temperature in Celsius."""
return self.service.value(CharacteristicsTypes.TEMPERATURE_CURRENT)
class HomeKitLightSensor(HomeKitSensor):
"""Representation of a Homekit light level sensor."""
_attr_device_class = SensorDeviceClass.ILLUMINANCE
_attr_native_unit_of_measurement = LIGHT_LUX
def get_characteristic_types(self) -> list[str]:
"""Define the homekit characteristics the entity is tracking."""
return [CharacteristicsTypes.LIGHT_LEVEL_CURRENT]
@property
def default_name(self) -> str:
"""Return the default name of the device."""
return "Light Level"
@property
def native_value(self) -> int:
"""Return the current light level in lux."""
return self.service.value(CharacteristicsTypes.LIGHT_LEVEL_CURRENT)
class HomeKitCarbonDioxideSensor(HomeKitSensor):
"""Representation of a Homekit Carbon Dioxide sensor."""
_attr_device_class = SensorDeviceClass.CO2
_attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION
def get_characteristic_types(self) -> list[str]:
"""Define the homekit characteristics the entity is tracking."""
return [CharacteristicsTypes.CARBON_DIOXIDE_LEVEL]
@property
def default_name(self) -> str:
"""Return the default name of the device."""
return "Carbon Dioxide"
@property
def native_value(self) -> int:
"""Return the current CO2 level in ppm."""
return self.service.value(CharacteristicsTypes.CARBON_DIOXIDE_LEVEL)
class HomeKitBatterySensor(HomeKitSensor):
"""Representation of a Homekit battery sensor."""
_attr_device_class = SensorDeviceClass.BATTERY
_attr_native_unit_of_measurement = PERCENTAGE
_attr_entity_category = EntityCategory.DIAGNOSTIC
def get_characteristic_types(self) -> list[str]:
"""Define the homekit characteristics the entity is tracking."""
return [
CharacteristicsTypes.BATTERY_LEVEL,
CharacteristicsTypes.STATUS_LO_BATT,
CharacteristicsTypes.CHARGING_STATE,
]
@property
def default_name(self) -> str:
"""Return the default name of the device."""
return "Battery"
@property
def icon(self) -> str:
"""Return the sensor icon."""
if not self.available or self.state is None:
return "mdi:battery-unknown"
# This is similar to the logic in helpers.icon, but we have delegated the
# decision about what mdi:battery-alert is to the device.
icon = "mdi:battery"
if self.is_charging and self.state > 10:
percentage = int(round(self.state / 20 - 0.01)) * 20
icon += f"-charging-{percentage}"
elif self.is_charging:
icon += "-outline"
elif self.is_low_battery:
icon += "-alert"
elif self.state < 95:
percentage = max(int(round(self.state / 10 - 0.01)) * 10, 10)
icon += f"-{percentage}"
return icon
@property
def is_low_battery(self) -> bool:
"""Return true if battery level is low."""
return self.service.value(CharacteristicsTypes.STATUS_LO_BATT) == 1
@property
def is_charging(self) -> bool:
"""Return true if currently charing."""
# 0 = not charging
# 1 = charging
# 2 = not chargeable
return self.service.value(CharacteristicsTypes.CHARGING_STATE) == 1
@property
def native_value(self) -> int:
"""Return the current battery level percentage."""
return self.service.value(CharacteristicsTypes.BATTERY_LEVEL)
class SimpleSensor(CharacteristicEntity, SensorEntity):
"""A simple sensor for a single characteristic.
This may be an additional secondary entity that is part of another service. An
example is a switch that has an energy sensor.
These *have* to have a different unique_id to the normal sensors as there could
be multiple entities per HomeKit service (this was not previously the case).
"""
entity_description: HomeKitSensorEntityDescription
def __init__(
self,
conn: HKDevice,
info: ConfigType,
char: Characteristic,
description: HomeKitSensorEntityDescription,
) -> None:
"""Initialise a secondary HomeKit characteristic sensor."""
self.entity_description = description
super().__init__(conn, info, char)
def get_characteristic_types(self) -> list[str]:
"""Define the homekit characteristics the entity is tracking."""
return [self._char.type]
@property
def name(self) -> str:
"""Return the name of the device if any."""
if name := self.accessory.name:
return f"{name} {self.entity_description.name}"
return f"{self.entity_description.name}"
@property
def native_value(self) -> str | int | float:
"""Return the current sensor value."""
val = self._char.value
if self.entity_description.format:
return self.entity_description.format(val)
return val
ENTITY_TYPES = {
ServicesTypes.HUMIDITY_SENSOR: HomeKitHumiditySensor,
ServicesTypes.TEMPERATURE_SENSOR: HomeKitTemperatureSensor,
ServicesTypes.LIGHT_SENSOR: HomeKitLightSensor,
ServicesTypes.CARBON_DIOXIDE_SENSOR: HomeKitCarbonDioxideSensor,
ServicesTypes.BATTERY_SERVICE: HomeKitBatterySensor,
}
# Only create the entity if it has the required characteristic
REQUIRED_CHAR_BY_TYPE = {
ServicesTypes.BATTERY_SERVICE: CharacteristicsTypes.BATTERY_LEVEL,
}
class RSSISensor(HomeKitEntity, SensorEntity):
"""HomeKit Controller RSSI sensor."""
_attr_device_class = SensorDeviceClass.SIGNAL_STRENGTH
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_entity_registry_enabled_default = False
_attr_has_entity_name = True
_attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT
_attr_should_poll = False
def get_characteristic_types(self) -> list[str]:
"""Define the homekit characteristics the entity cares about."""
return []
@property
def available(self) -> bool:
"""Return if the bluetooth device is available."""
address = self._accessory.pairing_data["AccessoryAddress"]
return async_ble_device_from_address(self.hass, address) is not None
@property
def name(self) -> str:
"""Return the name of the sensor."""
return "Signal strength"
@property
def old_unique_id(self) -> str:
"""Return the old ID of this device."""
serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER)
return f"homekit-{serial}-rssi"
@property
def unique_id(self) -> str:
"""Return the ID of this device."""
return f"{self._accessory.unique_id}_rssi"
@property
def native_value(self) -> int | None:
"""Return the current rssi value."""
address = self._accessory.pairing_data["AccessoryAddress"]
last_service_info = async_last_service_info(self.hass, address)
return last_service_info.rssi if last_service_info else None
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Homekit sensors."""
hkid = config_entry.data["AccessoryPairingID"]
conn: HKDevice = hass.data[KNOWN_DEVICES][hkid]
@callback
def async_add_service(service: Service) -> bool:
if not (entity_class := ENTITY_TYPES.get(service.type)):
return False
if (
required_char := REQUIRED_CHAR_BY_TYPE.get(service.type)
) and not service.has(required_char):
return False
info = {"aid": service.accessory.aid, "iid": service.iid}
entity: HomeKitSensor = entity_class(conn, info)
conn.async_migrate_unique_id(
entity.old_unique_id, entity.unique_id, Platform.SENSOR
)
async_add_entities([entity])
return True
conn.add_listener(async_add_service)
@callback
def async_add_characteristic(char: Characteristic) -> bool:
if not (description := SIMPLE_SENSOR.get(char.type)):
return False
if description.probe and not description.probe(char):
return False
info = {"aid": char.service.accessory.aid, "iid": char.service.iid}
entity = SimpleSensor(conn, info, char, description)
conn.async_migrate_unique_id(
entity.old_unique_id, entity.unique_id, Platform.SENSOR
)
async_add_entities([entity])
return True
conn.add_char_factory(async_add_characteristic)
@callback
def async_add_accessory(accessory: Accessory) -> bool:
if conn.pairing.transport != Transport.BLE:
return False
accessory_info = accessory.services.first(
service_type=ServicesTypes.ACCESSORY_INFORMATION
)
info = {"aid": accessory.aid, "iid": accessory_info.iid}
entity = RSSISensor(conn, info)
conn.async_migrate_unique_id(
entity.old_unique_id, entity.unique_id, Platform.SENSOR
)
async_add_entities([entity])
return True
conn.add_accessory_factory(async_add_accessory)