Add support for InkBird IAM-T1 (#142824)

This commit is contained in:
J. Nick Koston 2025-04-13 22:31:38 -10:00 committed by GitHub
parent 9239ace1c8
commit a6643d8fb3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 219 additions and 28 deletions

View File

@ -2,25 +2,29 @@
from __future__ import annotations
from inkbird_ble import INKBIRDBluetoothDeviceData
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import CONF_DEVICE_TYPE
from .const import CONF_DEVICE_DATA, CONF_DEVICE_TYPE
from .coordinator import INKBIRDActiveBluetoothProcessorCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
INKBIRDConfigEntry = ConfigEntry[INKBIRDActiveBluetoothProcessorCoordinator]
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: INKBIRDConfigEntry) -> bool:
"""Set up INKBIRD BLE device from a config entry."""
assert entry.unique_id is not None
device_type: str | None = entry.data.get(CONF_DEVICE_TYPE)
data = INKBIRDBluetoothDeviceData(device_type)
coordinator = INKBIRDActiveBluetoothProcessorCoordinator(hass, entry, data)
device_data: dict[str, Any] | None = entry.data.get(CONF_DEVICE_DATA)
coordinator = INKBIRDActiveBluetoothProcessorCoordinator(
hass, entry, device_type, device_data
)
await coordinator.async_init()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# only start after all platforms have had a chance to subscribe

View File

@ -14,7 +14,7 @@ from homeassistant.components.bluetooth import (
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS
from .const import DOMAIN
from .const import CONF_DEVICE_TYPE, DOMAIN
class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
@ -26,7 +26,7 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
"""Initialize the config flow."""
self._discovery_info: BluetoothServiceInfoBleak | None = None
self._discovered_device: DeviceData | None = None
self._discovered_devices: dict[str, str] = {}
self._discovered_devices: dict[str, tuple[str, str]] = {}
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
@ -51,7 +51,10 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
discovery_info = self._discovery_info
title = device.title or device.get_device_name() or discovery_info.name
if user_input is not None:
return self.async_create_entry(title=title, data={})
return self.async_create_entry(
title=title,
data={CONF_DEVICE_TYPE: str(self._discovered_device.device_type)},
)
self._set_confirm_only()
placeholders = {"name": title}
@ -68,8 +71,9 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
address = user_input[CONF_ADDRESS]
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()
title, device_type = self._discovered_devices[address]
return self.async_create_entry(
title=self._discovered_devices[address], data={}
title=title, data={CONF_DEVICE_TYPE: device_type}
)
current_addresses = self._async_current_ids(include_ignore=False)
@ -80,7 +84,8 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
device = DeviceData()
if device.supported(discovery_info):
self._discovered_devices[address] = (
device.title or device.get_device_name() or discovery_info.name
device.title or device.get_device_name() or discovery_info.name,
str(device.device_type),
)
if not self._discovered_devices:

View File

@ -3,3 +3,4 @@
DOMAIN = "inkbird"
CONF_DEVICE_TYPE = "device_type"
CONF_DEVICE_DATA = "device_data"

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from datetime import datetime, timedelta
import logging
from typing import Any
from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate
@ -12,15 +13,17 @@ from homeassistant.components.bluetooth import (
BluetoothServiceInfo,
BluetoothServiceInfoBleak,
async_ble_device_from_address,
async_last_service_info,
)
from homeassistant.components.bluetooth.active_update_processor import (
ActiveBluetoothProcessorCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_interval
from .const import CONF_DEVICE_TYPE
from .const import CONF_DEVICE_DATA, CONF_DEVICE_TYPE, DOMAIN
_LOGGER = logging.getLogger(__name__)
@ -32,15 +35,19 @@ class INKBIRDActiveBluetoothProcessorCoordinator(
):
"""Coordinator for INKBIRD Bluetooth devices."""
_data: INKBIRDBluetoothDeviceData
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
data: INKBIRDBluetoothDeviceData,
device_type: str | None,
device_data: dict[str, Any] | None,
) -> None:
"""Initialize the INKBIRD Bluetooth processor coordinator."""
self._data = data
self._entry = entry
self._device_type = device_type
self._device_data = device_data
address = entry.unique_id
assert address is not None
entry.async_on_unload(
@ -58,6 +65,25 @@ class INKBIRDActiveBluetoothProcessorCoordinator(
poll_method=self._async_poll_data,
)
async def async_init(self) -> None:
"""Initialize the coordinator."""
self._data = INKBIRDBluetoothDeviceData(
self._device_type,
self._device_data,
self.async_set_updated_data,
self._async_device_data_changed,
)
if not self._data.uses_notify:
return
if not (service_info := async_last_service_info(self.hass, self.address)):
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="no_advertisement",
translation_placeholders={"address": self.address},
)
await self._data.async_start(service_info, service_info.device)
self._entry.async_on_unload(self._data.async_stop)
async def _async_poll_data(
self, last_service_info: BluetoothServiceInfoBleak
) -> SensorUpdate:
@ -78,6 +104,13 @@ class INKBIRDActiveBluetoothProcessorCoordinator(
)
)
@callback
def _async_device_data_changed(self, new_device_data: dict[str, Any]) -> None:
"""Handle device data changed."""
self.hass.config_entries.async_update_entry(
self._entry, data={**self._entry.data, CONF_DEVICE_DATA: new_device_data}
)
@callback
def _async_on_update(self, service_info: BluetoothServiceInfo) -> SensorUpdate:
"""Handle update callback from the passive BLE processor."""

View File

@ -33,6 +33,15 @@
{
"local_name": "ITH-21-B",
"connectable": false
},
{
"local_name": "Ink@IAM-T1",
"connectable": true
},
{
"manufacturer_id": 12628,
"manufacturer_data_start": [65, 67, 45],
"connectable": true
}
],
"codeowners": ["@bdraco"],

View File

@ -17,8 +17,10 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfPressure,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
@ -56,6 +58,18 @@ SENSOR_DESCRIPTIONS = {
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
(DeviceClass.CO2, Units.CONCENTRATION_PARTS_PER_MILLION): SensorEntityDescription(
key=f"{DeviceClass.CO2}_{Units.CONCENTRATION_PARTS_PER_MILLION}",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
),
(DeviceClass.PRESSURE, Units.PRESSURE_HPA): SensorEntityDescription(
key=f"{DeviceClass.PRESSURE}_{Units.PRESSURE_HPA}",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.HPA,
state_class=SensorStateClass.MEASUREMENT,
),
}

View File

@ -17,5 +17,10 @@
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"exceptions": {
"no_advertisement": {
"message": "The device with address {address} is not advertising; Make sure it is in range and powered on."
}
}
}

View File

@ -371,6 +371,21 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
"domain": "inkbird",
"local_name": "ITH-21-B",
},
{
"connectable": True,
"domain": "inkbird",
"local_name": "Ink@IAM-T1",
},
{
"connectable": True,
"domain": "inkbird",
"manufacturer_data_start": [
65,
67,
45,
],
"manufacturer_id": 12628,
},
{
"connectable": True,
"domain": "iron_os",

View File

@ -92,3 +92,14 @@ IBBQ_SERVICE_INFO = _make_bluetooth_service_info(
service_data={},
source="local",
)
IAM_T1_SERVICE_INFO = _make_bluetooth_service_info(
name="Ink@IAM-T1",
manufacturer_data={12628: b"AC-6200a13cae\x00\x00"},
service_uuids=["0000fff0-0000-1000-8000-00805f9b34fb"],
address="62:00:A1:3C:AE:7B",
rssi=-44,
service_data={},
source="local",
)

View File

@ -3,7 +3,7 @@
from unittest.mock import patch
from homeassistant import config_entries
from homeassistant.components.inkbird.const import DOMAIN
from homeassistant.components.inkbird.const import CONF_DEVICE_TYPE, DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@ -27,7 +27,7 @@ async def test_async_step_bluetooth_valid_device(hass: HomeAssistant) -> None:
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "iBBQ AC3D"
assert result2["data"] == {}
assert result2["data"] == {CONF_DEVICE_TYPE: "iBBQ-4"}
assert result2["result"].unique_id == "4125DDBA-2774-4851-9889-6AADDD4CAC3D"
@ -71,7 +71,7 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None:
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "IBS-TH 8105"
assert result2["data"] == {}
assert result2["data"] == {CONF_DEVICE_TYPE: "IBS-TH"}
assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105"
@ -101,7 +101,7 @@ async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None:
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "IBS-TH 8105"
assert result2["data"] == {}
assert result2["data"] == {CONF_DEVICE_TYPE: "IBS-TH"}
assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105"
@ -220,7 +220,7 @@ async def test_async_step_user_takes_precedence_over_discovery(
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "IBS-TH 8105"
assert result2["data"] == {}
assert result2["data"] == {CONF_DEVICE_TYPE: "IBS-TH"}
assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105"
# Verify the original one was aborted

View File

@ -1,25 +1,35 @@
"""Test the INKBIRD config flow."""
from unittest.mock import patch
from collections.abc import Callable
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from inkbird_ble import (
DeviceKey,
INKBIRDBluetoothDeviceData,
SensorDescription,
SensorDeviceInfo,
SensorUpdate,
SensorValue,
Units,
)
from inkbird_ble.parser import Model
from sensor_state_data import SensorDeviceClass
from homeassistant.components.inkbird.const import CONF_DEVICE_TYPE, DOMAIN
from homeassistant.components.inkbird.const import (
CONF_DEVICE_DATA,
CONF_DEVICE_TYPE,
DOMAIN,
)
from homeassistant.components.inkbird.coordinator import FALLBACK_POLL_INTERVAL
from homeassistant.components.sensor import ATTR_STATE_CLASS
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from . import (
IAM_T1_SERVICE_INFO,
SPS_PASSIVE_SERVICE_INFO,
SPS_SERVICE_INFO,
SPS_WITH_CORRUPT_NAME_SERVICE_INFO,
@ -29,13 +39,13 @@ from tests.common import MockConfigEntry, async_fire_time_changed
from tests.components.bluetooth import inject_bluetooth_service_info
def _make_sensor_update(humidity: float) -> SensorUpdate:
def _make_sensor_update(name: str, humidity: float) -> SensorUpdate:
return SensorUpdate(
title=None,
devices={
None: SensorDeviceInfo(
name="IBS-TH EEFF",
model="IBS-TH",
name=f"{name} EEFF",
model=name,
manufacturer="INKBIRD",
sw_version=None,
hw_version=None,
@ -132,8 +142,8 @@ async def test_polling_sensor(hass: HomeAssistant) -> None:
assert len(hass.states.async_all()) == 0
with patch(
"homeassistant.components.inkbird.INKBIRDBluetoothDeviceData.async_poll",
return_value=_make_sensor_update(10.24),
"homeassistant.components.inkbird.coordinator.INKBIRDBluetoothDeviceData.async_poll",
return_value=_make_sensor_update("IBS-TH", 10.24),
):
inject_bluetooth_service_info(hass, SPS_PASSIVE_SERVICE_INFO)
await hass.async_block_till_done()
@ -149,8 +159,8 @@ async def test_polling_sensor(hass: HomeAssistant) -> None:
assert entry.data[CONF_DEVICE_TYPE] == "IBS-TH"
with patch(
"homeassistant.components.inkbird.INKBIRDBluetoothDeviceData.async_poll",
return_value=_make_sensor_update(20.24),
"homeassistant.components.inkbird.coordinator.INKBIRDBluetoothDeviceData.async_poll",
return_value=_make_sensor_update("IBS-TH", 20.24),
):
async_fire_time_changed(hass, dt_util.utcnow() + FALLBACK_POLL_INTERVAL)
inject_bluetooth_service_info(hass, SPS_PASSIVE_SERVICE_INFO)
@ -162,3 +172,87 @@ async def test_polling_sensor(hass: HomeAssistant) -> None:
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
async def test_notify_sensor_no_advertisement(hass: HomeAssistant) -> None:
"""Test setting up a notify sensor that has no advertisement."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="62:00:A1:3C:AE:7B",
data={CONF_DEVICE_TYPE: "IAM-T1"},
)
entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
async def test_notify_sensor(hass: HomeAssistant) -> None:
"""Test setting up a notify sensor."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="62:00:A1:3C:AE:7B",
data={CONF_DEVICE_TYPE: "IAM-T1"},
)
entry.add_to_hass(hass)
inject_bluetooth_service_info(hass, IAM_T1_SERVICE_INFO)
saved_update_callback = None
saved_device_data_changed_callback = None
class MockINKBIRDBluetoothDeviceData(INKBIRDBluetoothDeviceData):
def __init__(
self,
device_type: Model | str | None = None,
device_data: dict[str, Any] | None = None,
update_callback: Callable[[SensorUpdate], None] | None = None,
device_data_changed_callback: Callable[[dict[str, Any]], None]
| None = None,
) -> None:
nonlocal saved_update_callback
nonlocal saved_device_data_changed_callback
saved_update_callback = update_callback
saved_device_data_changed_callback = device_data_changed_callback
super().__init__(
device_type=device_type,
device_data=device_data,
update_callback=update_callback,
device_data_changed_callback=device_data_changed_callback,
)
mock_client = MagicMock(start_notify=AsyncMock(), disconnect=AsyncMock())
with (
patch(
"homeassistant.components.inkbird.coordinator.INKBIRDBluetoothDeviceData",
MockINKBIRDBluetoothDeviceData,
),
patch("inkbird_ble.parser.establish_connection", return_value=mock_client),
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
assert len(hass.states.async_all()) == 0
saved_update_callback(_make_sensor_update("IAM-T1", 10.24))
assert len(hass.states.async_all()) == 1
temp_sensor = hass.states.get("sensor.iam_t1_eeff_humidity")
temp_sensor_attribtes = temp_sensor.attributes
assert temp_sensor.state == "10.24"
assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "IAM-T1 EEFF Humidity"
assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "%"
assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement"
assert entry.data[CONF_DEVICE_TYPE] == "IAM-T1"
saved_device_data_changed_callback({"temp_unit": "F"})
assert entry.data[CONF_DEVICE_DATA] == {"temp_unit": "F"}
saved_device_data_changed_callback({"temp_unit": "C"})
assert entry.data[CONF_DEVICE_DATA] == {"temp_unit": "C"}
saved_device_data_changed_callback({"temp_unit": "C"})
assert entry.data[CONF_DEVICE_DATA] == {"temp_unit": "C"}