Add RSSI sensor to HomeKit Controller (#78906)
This commit is contained in:
parent
c1bc26b413
commit
92612c9fe3
4 changed files with 133 additions and 17 deletions
|
@ -231,6 +231,9 @@ class HKDevice:
|
||||||
self.async_update_available_state,
|
self.async_update_available_state,
|
||||||
timedelta(seconds=BLE_AVAILABILITY_CHECK_INTERVAL),
|
timedelta(seconds=BLE_AVAILABILITY_CHECK_INTERVAL),
|
||||||
)
|
)
|
||||||
|
# BLE devices always get an RSSI sensor as well
|
||||||
|
if "sensor" not in self.platforms:
|
||||||
|
await self.async_load_platform("sensor")
|
||||||
|
|
||||||
async def async_add_new_entities(self) -> None:
|
async def async_add_new_entities(self) -> None:
|
||||||
"""Add new entities to Home Assistant."""
|
"""Add new entities to Home Assistant."""
|
||||||
|
@ -455,7 +458,7 @@ class HKDevice:
|
||||||
self.entities.append((accessory.aid, None, None))
|
self.entities.append((accessory.aid, None, None))
|
||||||
break
|
break
|
||||||
|
|
||||||
def add_char_factory(self, add_entities_cb) -> None:
|
def add_char_factory(self, add_entities_cb: AddCharacteristicCb) -> None:
|
||||||
"""Add a callback to run when discovering new entities for accessories."""
|
"""Add a callback to run when discovering new entities for accessories."""
|
||||||
self.char_factories.append(add_entities_cb)
|
self.char_factories.append(add_entities_cb)
|
||||||
self._add_new_entities_for_char([add_entities_cb])
|
self._add_new_entities_for_char([add_entities_cb])
|
||||||
|
@ -471,7 +474,7 @@ class HKDevice:
|
||||||
self.entities.append((accessory.aid, service.iid, char.iid))
|
self.entities.append((accessory.aid, service.iid, char.iid))
|
||||||
break
|
break
|
||||||
|
|
||||||
def add_listener(self, add_entities_cb) -> None:
|
def add_listener(self, add_entities_cb: AddServiceCb) -> None:
|
||||||
"""Add a callback to run when discovering new entities for services."""
|
"""Add a callback to run when discovering new entities for services."""
|
||||||
self.listeners.append(add_entities_cb)
|
self.listeners.append(add_entities_cb)
|
||||||
self._add_new_entities([add_entities_cb])
|
self._add_new_entities([add_entities_cb])
|
||||||
|
@ -513,22 +516,24 @@ class HKDevice:
|
||||||
|
|
||||||
async def async_load_platforms(self) -> None:
|
async def async_load_platforms(self) -> None:
|
||||||
"""Load any platforms needed by this HomeKit device."""
|
"""Load any platforms needed by this HomeKit device."""
|
||||||
tasks = []
|
to_load: set[str] = set()
|
||||||
for accessory in self.entity_map.accessories:
|
for accessory in self.entity_map.accessories:
|
||||||
for service in accessory.services:
|
for service in accessory.services:
|
||||||
if service.type in HOMEKIT_ACCESSORY_DISPATCH:
|
if service.type in HOMEKIT_ACCESSORY_DISPATCH:
|
||||||
platform = HOMEKIT_ACCESSORY_DISPATCH[service.type]
|
platform = HOMEKIT_ACCESSORY_DISPATCH[service.type]
|
||||||
if platform not in self.platforms:
|
if platform not in self.platforms:
|
||||||
tasks.append(self.async_load_platform(platform))
|
to_load.add(platform)
|
||||||
|
|
||||||
for char in service.characteristics:
|
for char in service.characteristics:
|
||||||
if char.type in CHARACTERISTIC_PLATFORMS:
|
if char.type in CHARACTERISTIC_PLATFORMS:
|
||||||
platform = CHARACTERISTIC_PLATFORMS[char.type]
|
platform = CHARACTERISTIC_PLATFORMS[char.type]
|
||||||
if platform not in self.platforms:
|
if platform not in self.platforms:
|
||||||
tasks.append(self.async_load_platform(platform))
|
to_load.add(platform)
|
||||||
|
|
||||||
if tasks:
|
if to_load:
|
||||||
await asyncio.gather(*tasks)
|
await asyncio.gather(
|
||||||
|
*[self.async_load_platform(platform) for platform in to_load]
|
||||||
|
)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_update_available_state(self, *_: Any) -> None:
|
def async_update_available_state(self, *_: Any) -> None:
|
||||||
|
|
|
@ -3,11 +3,14 @@ from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aiohomekit.model import Accessory, Transport
|
||||||
from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes
|
from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes
|
||||||
from aiohomekit.model.characteristics.const import ThreadNodeCapabilities, ThreadStatus
|
from aiohomekit.model.characteristics.const import ThreadNodeCapabilities, ThreadStatus
|
||||||
from aiohomekit.model.services import Service, ServicesTypes
|
from aiohomekit.model.services import Service, ServicesTypes
|
||||||
|
|
||||||
|
from homeassistant.components.bluetooth import async_ble_device_from_address
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
SensorEntity,
|
SensorEntity,
|
||||||
|
@ -25,6 +28,7 @@ from homeassistant.const import (
|
||||||
PERCENTAGE,
|
PERCENTAGE,
|
||||||
POWER_WATT,
|
POWER_WATT,
|
||||||
PRESSURE_HPA,
|
PRESSURE_HPA,
|
||||||
|
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||||
TEMP_CELSIUS,
|
TEMP_CELSIUS,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
@ -37,6 +41,8 @@ from .connection import HKDevice
|
||||||
from .entity import CharacteristicEntity, HomeKitEntity
|
from .entity import CharacteristicEntity, HomeKitEntity
|
||||||
from .utils import folded_name
|
from .utils import folded_name
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class HomeKitSensorEntityDescription(SensorEntityDescription):
|
class HomeKitSensorEntityDescription(SensorEntityDescription):
|
||||||
|
@ -524,6 +530,45 @@ REQUIRED_CHAR_BY_TYPE = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 unique_id(self) -> str:
|
||||||
|
"""Return the ID of this device."""
|
||||||
|
serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER)
|
||||||
|
return f"homekit-{serial}-rssi"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> int | None:
|
||||||
|
"""Return the current rssi value."""
|
||||||
|
address = self._accessory.pairing_data["AccessoryAddress"]
|
||||||
|
ble_device = async_ble_device_from_address(self.hass, address)
|
||||||
|
return ble_device.rssi if ble_device else None
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
config_entry: ConfigEntry,
|
config_entry: ConfigEntry,
|
||||||
|
@ -531,7 +576,7 @@ async def async_setup_entry(
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up Homekit sensors."""
|
"""Set up Homekit sensors."""
|
||||||
hkid = config_entry.data["AccessoryPairingID"]
|
hkid = config_entry.data["AccessoryPairingID"]
|
||||||
conn = hass.data[KNOWN_DEVICES][hkid]
|
conn: HKDevice = hass.data[KNOWN_DEVICES][hkid]
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_add_service(service: Service) -> bool:
|
def async_add_service(service: Service) -> bool:
|
||||||
|
@ -542,7 +587,7 @@ async def async_setup_entry(
|
||||||
) and not service.has(required_char):
|
) and not service.has(required_char):
|
||||||
return False
|
return False
|
||||||
info = {"aid": service.accessory.aid, "iid": service.iid}
|
info = {"aid": service.accessory.aid, "iid": service.iid}
|
||||||
async_add_entities([entity_class(conn, info)], True)
|
async_add_entities([entity_class(conn, info)])
|
||||||
return True
|
return True
|
||||||
|
|
||||||
conn.add_listener(async_add_service)
|
conn.add_listener(async_add_service)
|
||||||
|
@ -554,8 +599,22 @@ async def async_setup_entry(
|
||||||
if description.probe and not description.probe(char):
|
if description.probe and not description.probe(char):
|
||||||
return False
|
return False
|
||||||
info = {"aid": char.service.accessory.aid, "iid": char.service.iid}
|
info = {"aid": char.service.accessory.aid, "iid": char.service.iid}
|
||||||
async_add_entities([SimpleSensor(conn, info, char, description)], True)
|
async_add_entities([SimpleSensor(conn, info, char, description)])
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
conn.add_char_factory(async_add_characteristic)
|
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}
|
||||||
|
async_add_entities([RSSISensor(conn, info)])
|
||||||
|
return True
|
||||||
|
|
||||||
|
conn.add_accessory_factory(async_add_accessory)
|
||||||
|
|
|
@ -26,6 +26,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant, State, callback
|
from homeassistant.core import HomeAssistant, State, callback
|
||||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||||
from homeassistant.helpers.entity import EntityCategory
|
from homeassistant.helpers.entity import EntityCategory
|
||||||
|
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
|
@ -42,6 +43,19 @@ logger = logging.getLogger(__name__)
|
||||||
# Root device in test harness always has an accessory id of this
|
# Root device in test harness always has an accessory id of this
|
||||||
HUB_TEST_ACCESSORY_ID: Final[str] = "00:00:00:00:00:00:aid:1"
|
HUB_TEST_ACCESSORY_ID: Final[str] = "00:00:00:00:00:00:aid:1"
|
||||||
|
|
||||||
|
TEST_ACCESSORY_ADDRESS = "AA:BB:CC:DD:EE:FF"
|
||||||
|
|
||||||
|
|
||||||
|
TEST_DEVICE_SERVICE_INFO = BluetoothServiceInfo(
|
||||||
|
name="test_accessory",
|
||||||
|
address=TEST_ACCESSORY_ADDRESS,
|
||||||
|
rssi=-56,
|
||||||
|
manufacturer_data={},
|
||||||
|
service_uuids=["0000ec88-0000-1000-8000-00805f9b34fb"],
|
||||||
|
service_data={},
|
||||||
|
source="local",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EntityTestInfo:
|
class EntityTestInfo:
|
||||||
|
@ -182,15 +196,17 @@ async def setup_platform(hass):
|
||||||
return await async_get_controller(hass)
|
return await async_get_controller(hass)
|
||||||
|
|
||||||
|
|
||||||
async def setup_test_accessories(hass, accessories):
|
async def setup_test_accessories(hass, accessories, connection=None):
|
||||||
"""Load a fake homekit device based on captured JSON profile."""
|
"""Load a fake homekit device based on captured JSON profile."""
|
||||||
fake_controller = await setup_platform(hass)
|
fake_controller = await setup_platform(hass)
|
||||||
return await setup_test_accessories_with_controller(
|
return await setup_test_accessories_with_controller(
|
||||||
hass, accessories, fake_controller
|
hass, accessories, fake_controller, connection
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def setup_test_accessories_with_controller(hass, accessories, fake_controller):
|
async def setup_test_accessories_with_controller(
|
||||||
|
hass, accessories, fake_controller, connection=None
|
||||||
|
):
|
||||||
"""Load a fake homekit device based on captured JSON profile."""
|
"""Load a fake homekit device based on captured JSON profile."""
|
||||||
|
|
||||||
pairing_id = "00:00:00:00:00:00"
|
pairing_id = "00:00:00:00:00:00"
|
||||||
|
@ -200,11 +216,16 @@ async def setup_test_accessories_with_controller(hass, accessories, fake_control
|
||||||
accessories_obj.add_accessory(accessory)
|
accessories_obj.add_accessory(accessory)
|
||||||
pairing = await fake_controller.add_paired_device(accessories_obj, pairing_id)
|
pairing = await fake_controller.add_paired_device(accessories_obj, pairing_id)
|
||||||
|
|
||||||
|
data = {"AccessoryPairingID": pairing_id}
|
||||||
|
if connection == "BLE":
|
||||||
|
data["Connection"] = "BLE"
|
||||||
|
data["AccessoryAddress"] = TEST_ACCESSORY_ADDRESS
|
||||||
|
|
||||||
config_entry = MockConfigEntry(
|
config_entry = MockConfigEntry(
|
||||||
version=1,
|
version=1,
|
||||||
domain="homekit_controller",
|
domain="homekit_controller",
|
||||||
entry_id="TestData",
|
entry_id="TestData",
|
||||||
data={"AccessoryPairingID": pairing_id},
|
data=data,
|
||||||
title="test",
|
title="test",
|
||||||
)
|
)
|
||||||
config_entry.add_to_hass(hass)
|
config_entry.add_to_hass(hass)
|
||||||
|
@ -250,7 +271,9 @@ async def device_config_changed(hass, accessories):
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
|
||||||
async def setup_test_component(hass, setup_accessory, capitalize=False, suffix=None):
|
async def setup_test_component(
|
||||||
|
hass, setup_accessory, capitalize=False, suffix=None, connection=None
|
||||||
|
):
|
||||||
"""Load a fake homekit accessory based on a homekit accessory model.
|
"""Load a fake homekit accessory based on a homekit accessory model.
|
||||||
|
|
||||||
If capitalize is True, property names will be in upper case.
|
If capitalize is True, property names will be in upper case.
|
||||||
|
@ -271,7 +294,7 @@ async def setup_test_component(hass, setup_accessory, capitalize=False, suffix=N
|
||||||
|
|
||||||
assert domain, "Cannot map test homekit services to Home Assistant domain"
|
assert domain, "Cannot map test homekit services to Home Assistant domain"
|
||||||
|
|
||||||
config_entry, pairing = await setup_test_accessories(hass, [accessory])
|
config_entry, pairing = await setup_test_accessories(hass, [accessory], connection)
|
||||||
entity = "testdevice" if suffix is None else f"testdevice_{suffix}"
|
entity = "testdevice" if suffix is None else f"testdevice_{suffix}"
|
||||||
return Helper(hass, ".".join((domain, entity)), pairing, accessory, config_entry)
|
return Helper(hass, ".".join((domain, entity)), pairing, accessory, config_entry)
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
"""Basic checks for HomeKit sensor."""
|
"""Basic checks for HomeKit sensor."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from aiohomekit.model import Transport
|
||||||
from aiohomekit.model.characteristics import CharacteristicsTypes
|
from aiohomekit.model.characteristics import CharacteristicsTypes
|
||||||
from aiohomekit.model.characteristics.const import ThreadNodeCapabilities, ThreadStatus
|
from aiohomekit.model.characteristics.const import ThreadNodeCapabilities, ThreadStatus
|
||||||
from aiohomekit.model.services import ServicesTypes
|
from aiohomekit.model.services import ServicesTypes
|
||||||
from aiohomekit.protocol.statuscodes import HapStatusCode
|
from aiohomekit.protocol.statuscodes import HapStatusCode
|
||||||
|
from aiohomekit.testing import FakePairing
|
||||||
|
|
||||||
from homeassistant.components.homekit_controller.sensor import (
|
from homeassistant.components.homekit_controller.sensor import (
|
||||||
thread_node_capability_to_str,
|
thread_node_capability_to_str,
|
||||||
|
@ -10,7 +14,9 @@ from homeassistant.components.homekit_controller.sensor import (
|
||||||
)
|
)
|
||||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||||
|
|
||||||
from .common import Helper, setup_test_component
|
from .common import TEST_DEVICE_SERVICE_INFO, Helper, setup_test_component
|
||||||
|
|
||||||
|
from tests.components.bluetooth import inject_bluetooth_service_info
|
||||||
|
|
||||||
|
|
||||||
def create_temperature_sensor_service(accessory):
|
def create_temperature_sensor_service(accessory):
|
||||||
|
@ -349,3 +355,26 @@ def test_thread_status_to_str():
|
||||||
assert thread_status_to_str(ThreadStatus.JOINING) == "joining"
|
assert thread_status_to_str(ThreadStatus.JOINING) == "joining"
|
||||||
assert thread_status_to_str(ThreadStatus.DETACHED) == "detached"
|
assert thread_status_to_str(ThreadStatus.DETACHED) == "detached"
|
||||||
assert thread_status_to_str(ThreadStatus.DISABLED) == "disabled"
|
assert thread_status_to_str(ThreadStatus.DISABLED) == "disabled"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rssi_sensor(
|
||||||
|
hass, utcnow, entity_registry_enabled_by_default, enable_bluetooth
|
||||||
|
):
|
||||||
|
"""Test an rssi sensor."""
|
||||||
|
|
||||||
|
inject_bluetooth_service_info(hass, TEST_DEVICE_SERVICE_INFO)
|
||||||
|
|
||||||
|
class FakeBLEPairing(FakePairing):
|
||||||
|
"""Fake BLE pairing."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def transport(self):
|
||||||
|
return Transport.BLE
|
||||||
|
|
||||||
|
with patch("aiohomekit.testing.FakePairing", FakeBLEPairing):
|
||||||
|
# Any accessory will do for this test, but we need at least
|
||||||
|
# one or the rssi sensor will not be created
|
||||||
|
await setup_test_component(
|
||||||
|
hass, create_battery_level_sensor, suffix="battery", connection="BLE"
|
||||||
|
)
|
||||||
|
assert hass.states.get("sensor.testdevice_signal_strength").state == "-56"
|
||||||
|
|
Loading…
Add table
Reference in a new issue