Add GATT polling support to INKBird (#142307)

* Add GATT polling support to INKBird

* reduce

* fixes

* coverage

* dry

* reduce

* reduce
This commit is contained in:
J. Nick Koston 2025-04-06 22:18:46 -10:00 committed by GitHub
parent 8d82ef8e36
commit 04dfa45db0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 190 additions and 27 deletions

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta
import logging import logging
from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate
@ -9,13 +10,16 @@ from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate
from homeassistant.components.bluetooth import ( from homeassistant.components.bluetooth import (
BluetoothScanningMode, BluetoothScanningMode,
BluetoothServiceInfo, BluetoothServiceInfo,
BluetoothServiceInfoBleak,
async_ble_device_from_address,
) )
from homeassistant.components.bluetooth.passive_update_processor import ( from homeassistant.components.bluetooth.active_update_processor import (
PassiveBluetoothProcessorCoordinator, ActiveBluetoothProcessorCoordinator,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.event import async_track_time_interval
from .const import CONF_DEVICE_TYPE, DOMAIN from .const import CONF_DEVICE_TYPE, DOMAIN
@ -23,34 +27,87 @@ PLATFORMS: list[Platform] = [Platform.SENSOR]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
FALLBACK_POLL_INTERVAL = timedelta(seconds=180)
class INKBIRDActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordinator):
"""Coordinator for INKBIRD Bluetooth devices."""
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
data: INKBIRDBluetoothDeviceData,
) -> None:
"""Initialize the INKBIRD Bluetooth processor coordinator."""
self._data = data
self._entry = entry
address = entry.unique_id
assert address is not None
entry.async_on_unload(
async_track_time_interval(
hass, self._async_schedule_poll, FALLBACK_POLL_INTERVAL
)
)
super().__init__(
hass=hass,
logger=_LOGGER,
address=address,
mode=BluetoothScanningMode.ACTIVE,
update_method=self._async_on_update,
needs_poll_method=self._async_needs_poll,
poll_method=self._async_poll_data,
)
async def _async_poll_data(
self, last_service_info: BluetoothServiceInfoBleak
) -> SensorUpdate:
"""Poll the device."""
return await self._data.async_poll(last_service_info.device)
@callback
def _async_needs_poll(
self, service_info: BluetoothServiceInfoBleak, last_poll: float | None
) -> bool:
return (
not self.hass.is_stopping
and self._data.poll_needed(service_info, last_poll)
and bool(
async_ble_device_from_address(
self.hass, service_info.device.address, connectable=True
)
)
)
@callback
def _async_on_update(self, service_info: BluetoothServiceInfo) -> SensorUpdate:
"""Handle update callback from the passive BLE processor."""
update = self._data.update(service_info)
if (
self._entry.data.get(CONF_DEVICE_TYPE) is None
and self._data.device_type is not None
):
device_type_str = str(self._data.device_type)
self.hass.config_entries.async_update_entry(
self._entry,
data={**self._entry.data, CONF_DEVICE_TYPE: device_type_str},
)
return update
@callback
def _async_schedule_poll(self, _: datetime) -> None:
"""Schedule a poll of the device."""
if self._last_service_info and self._async_needs_poll(
self._last_service_info, self._last_poll
):
self._debounced_poll.async_schedule_call()
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up INKBIRD BLE device from a config entry.""" """Set up INKBIRD BLE device from a config entry."""
address = entry.unique_id
assert address is not None
device_type: str | None = entry.data.get(CONF_DEVICE_TYPE) device_type: str | None = entry.data.get(CONF_DEVICE_TYPE)
data = INKBIRDBluetoothDeviceData(device_type) data = INKBIRDBluetoothDeviceData(device_type)
coordinator = INKBIRDActiveBluetoothProcessorCoordinator(hass, entry, data)
@callback
def _async_on_update(service_info: BluetoothServiceInfo) -> SensorUpdate:
"""Handle update callback from the passive BLE processor."""
nonlocal device_type
update = data.update(service_info)
if device_type is None and data.device_type is not None:
device_type_str = str(data.device_type)
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_DEVICE_TYPE: device_type_str}
)
device_type = device_type_str
return update
coordinator = PassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
address=address,
mode=BluetoothScanningMode.ACTIVE,
update_method=_async_on_update,
)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# only start after all platforms have had a chance to subscribe # only start after all platforms have had a chance to subscribe

View File

@ -22,6 +22,18 @@ SPS_SERVICE_INFO = BluetoothServiceInfo(
source="local", source="local",
) )
SPS_PASSIVE_SERVICE_INFO = BluetoothServiceInfo(
name="sps",
address="AA:BB:CC:DD:EE:FF",
rssi=-63,
service_data={},
manufacturer_data={},
service_uuids=["0000fff0-0000-1000-8000-00805f9b34fb"],
source="local",
)
SPS_WITH_CORRUPT_NAME_SERVICE_INFO = BluetoothServiceInfo( SPS_WITH_CORRUPT_NAME_SERVICE_INFO = BluetoothServiceInfo(
name="XXXXcorruptXXXX", name="XXXXcorruptXXXX",
address="AA:BB:CC:DD:EE:FF", address="AA:BB:CC:DD:EE:FF",

View File

@ -1,16 +1,63 @@
"""Test the INKBIRD config flow.""" """Test the INKBIRD config flow."""
from unittest.mock import patch
from inkbird_ble import (
DeviceKey,
SensorDescription,
SensorDeviceInfo,
SensorUpdate,
SensorValue,
Units,
)
from sensor_state_data import SensorDeviceClass
from homeassistant.components.inkbird import FALLBACK_POLL_INTERVAL
from homeassistant.components.inkbird.const import CONF_DEVICE_TYPE, DOMAIN from homeassistant.components.inkbird.const import CONF_DEVICE_TYPE, DOMAIN
from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.components.sensor import ATTR_STATE_CLASS
from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from . import SPS_SERVICE_INFO, SPS_WITH_CORRUPT_NAME_SERVICE_INFO from . import (
SPS_PASSIVE_SERVICE_INFO,
SPS_SERVICE_INFO,
SPS_WITH_CORRUPT_NAME_SERVICE_INFO,
)
from tests.common import MockConfigEntry from tests.common import MockConfigEntry, async_fire_time_changed
from tests.components.bluetooth import inject_bluetooth_service_info from tests.components.bluetooth import inject_bluetooth_service_info
def _make_sensor_update(humidity: float) -> SensorUpdate:
return SensorUpdate(
title=None,
devices={
None: SensorDeviceInfo(
name="IBS-TH EEFF",
model="IBS-TH",
manufacturer="INKBIRD",
sw_version=None,
hw_version=None,
)
},
entity_descriptions={
DeviceKey(key="humidity", device_id=None): SensorDescription(
device_key=DeviceKey(key="humidity", device_id=None),
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=Units.PERCENTAGE,
),
},
entity_values={
DeviceKey(key="humidity", device_id=None): SensorValue(
device_key=DeviceKey(key="humidity", device_id=None),
name="Humidity",
native_value=humidity,
),
},
)
async def test_sensors(hass: HomeAssistant) -> None: async def test_sensors(hass: HomeAssistant) -> None:
"""Test setting up creates the sensors.""" """Test setting up creates the sensors."""
entry = MockConfigEntry( entry = MockConfigEntry(
@ -68,3 +115,50 @@ async def test_device_with_corrupt_name(hass: HomeAssistant) -> None:
assert entry.data[CONF_DEVICE_TYPE] == "IBS-TH" assert entry.data[CONF_DEVICE_TYPE] == "IBS-TH"
assert await hass.config_entries.async_unload(entry.entry_id) assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
async def test_polling_sensor(hass: HomeAssistant) -> None:
"""Test setting up a device that needs polling."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="AA:BB:CC:DD:EE:FF",
data={CONF_DEVICE_TYPE: "IBS-TH"},
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 0
with patch(
"homeassistant.components.inkbird.INKBIRDBluetoothDeviceData.async_poll",
return_value=_make_sensor_update(10.24),
):
inject_bluetooth_service_info(hass, SPS_PASSIVE_SERVICE_INFO)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 1
temp_sensor = hass.states.get("sensor.ibs_th_eeff_humidity")
temp_sensor_attribtes = temp_sensor.attributes
assert temp_sensor.state == "10.24"
assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "IBS-TH EEFF Humidity"
assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "%"
assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement"
assert entry.data[CONF_DEVICE_TYPE] == "IBS-TH"
with patch(
"homeassistant.components.inkbird.INKBIRDBluetoothDeviceData.async_poll",
return_value=_make_sensor_update(20.24),
):
async_fire_time_changed(hass, dt_util.utcnow() + FALLBACK_POLL_INTERVAL)
inject_bluetooth_service_info(hass, SPS_PASSIVE_SERVICE_INFO)
await hass.async_block_till_done()
temp_sensor = hass.states.get("sensor.ibs_th_eeff_humidity")
temp_sensor_attribtes = temp_sensor.attributes
assert temp_sensor.state == "20.24"
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()