mirror of
https://github.com/home-assistant/core.git
synced 2025-07-13 00:07:10 +00:00
Add support for InkBird IAM-T1 (#142824)
This commit is contained in:
parent
9239ace1c8
commit
a6643d8fb3
@ -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
|
||||
|
@ -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:
|
||||
|
@ -3,3 +3,4 @@
|
||||
DOMAIN = "inkbird"
|
||||
|
||||
CONF_DEVICE_TYPE = "device_type"
|
||||
CONF_DEVICE_DATA = "device_data"
|
||||
|
@ -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."""
|
||||
|
@ -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"],
|
||||
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
15
homeassistant/generated/bluetooth.py
generated
15
homeassistant/generated/bluetooth.py
generated
@ -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",
|
||||
|
@ -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",
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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"}
|
||||
|
Loading…
x
Reference in New Issue
Block a user