"""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, EntityCategory, Platform, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfPower, UnitOfPressure, UnitOfSoundPressure, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback 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, ), CharacteristicsTypes.FILTER_LIFE_LEVEL: HomeKitSensorEntityDescription( key=CharacteristicsTypes.FILTER_LIFE_LEVEL, name="Filter Life", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), } 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)