diff --git a/homeassistant/components/inkbird/__init__.py b/homeassistant/components/inkbird/__init__.py index bc81b852f02..8daa94f2f6d 100644 --- a/homeassistant/components/inkbird/__init__.py +++ b/homeassistant/components/inkbird/__init__.py @@ -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 diff --git a/homeassistant/components/inkbird/config_flow.py b/homeassistant/components/inkbird/config_flow.py index 09dd31a9cf6..9ce20baaeda 100644 --- a/homeassistant/components/inkbird/config_flow.py +++ b/homeassistant/components/inkbird/config_flow.py @@ -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: diff --git a/homeassistant/components/inkbird/const.py b/homeassistant/components/inkbird/const.py index 93fdcc7519c..b20e1af8de1 100644 --- a/homeassistant/components/inkbird/const.py +++ b/homeassistant/components/inkbird/const.py @@ -3,3 +3,4 @@ DOMAIN = "inkbird" CONF_DEVICE_TYPE = "device_type" +CONF_DEVICE_DATA = "device_data" diff --git a/homeassistant/components/inkbird/coordinator.py b/homeassistant/components/inkbird/coordinator.py index b119682a7d6..ed55bc79115 100644 --- a/homeassistant/components/inkbird/coordinator.py +++ b/homeassistant/components/inkbird/coordinator.py @@ -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.""" diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index 2e23663f5ff..76296870846 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -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"], diff --git a/homeassistant/components/inkbird/sensor.py b/homeassistant/components/inkbird/sensor.py index 447d7ac961b..c7d80e9bc9f 100644 --- a/homeassistant/components/inkbird/sensor.py +++ b/homeassistant/components/inkbird/sensor.py @@ -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, + ), } diff --git a/homeassistant/components/inkbird/strings.json b/homeassistant/components/inkbird/strings.json index 4e12a84b653..b8490dfb92a 100644 --- a/homeassistant/components/inkbird/strings.json +++ b/homeassistant/components/inkbird/strings.json @@ -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." + } } } diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 1ff444ca25f..da4b21cbba2 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -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", diff --git a/tests/components/inkbird/__init__.py b/tests/components/inkbird/__init__.py index 63acff7a150..f798fee292c 100644 --- a/tests/components/inkbird/__init__.py +++ b/tests/components/inkbird/__init__.py @@ -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", +) diff --git a/tests/components/inkbird/test_config_flow.py b/tests/components/inkbird/test_config_flow.py index 796f57da55b..419bc742479 100644 --- a/tests/components/inkbird/test_config_flow.py +++ b/tests/components/inkbird/test_config_flow.py @@ -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 diff --git a/tests/components/inkbird/test_sensor.py b/tests/components/inkbird/test_sensor.py index 67e08396c79..1feb5f5b02c 100644 --- a/tests/components/inkbird/test_sensor.py +++ b/tests/components/inkbird/test_sensor.py @@ -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"}