diff --git a/homeassistant/components/airthings_ble/config_flow.py b/homeassistant/components/airthings_ble/config_flow.py index 57b55cb2bfb1da..67191f6864dc52 100644 --- a/homeassistant/components/airthings_ble/config_flow.py +++ b/homeassistant/components/airthings_ble/config_flow.py @@ -135,8 +135,12 @@ async def async_step_bluetooth_confirm( ): return self.async_abort(reason="firmware_upgrade_required") + if self._discovered_device is None: + return self.async_abort(reason="no_devices_found") + return self.async_create_entry( - title=self.context["title_placeholders"]["name"], data={} + title=self.context["title_placeholders"]["name"], + data={"device_model": self._discovered_device.device.model.value}, ) self._set_confirm_only() @@ -164,7 +168,10 @@ async def async_step_user( self._discovered_device = discovery - return self.async_create_entry(title=discovery.name, data={}) + return self.async_create_entry( + title=discovery.name, + data={"device_model": discovery.device.model.value}, + ) current_addresses = self._async_current_ids(include_ignore=False) devices: list[BluetoothServiceInfoBleak] = [] diff --git a/homeassistant/components/airthings_ble/const.py b/homeassistant/components/airthings_ble/const.py index fdfebea8bff179..aa5de9ac357162 100644 --- a/homeassistant/components/airthings_ble/const.py +++ b/homeassistant/components/airthings_ble/const.py @@ -6,6 +6,9 @@ VOLUME_BECQUEREL = "Bq/m³" VOLUME_PICOCURIE = "pCi/L" +DEVICE_MODEL = "device_model" + DEFAULT_SCAN_INTERVAL = 300 +RADON_SCAN_INTERVAL = 1800 MAX_RETRIES_AFTER_STARTUP = 5 diff --git a/homeassistant/components/airthings_ble/coordinator.py b/homeassistant/components/airthings_ble/coordinator.py index 81009dcea812c2..41eaf3aa135922 100644 --- a/homeassistant/components/airthings_ble/coordinator.py +++ b/homeassistant/components/airthings_ble/coordinator.py @@ -5,7 +5,11 @@ from datetime import timedelta import logging -from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice +from airthings_ble import ( + AirthingsBluetoothDeviceData, + AirthingsDevice, + AirthingsDeviceType, +) from bleak.backends.device import BLEDevice from bleak_retry_connector import close_stale_connections_by_address @@ -16,7 +20,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.unit_system import METRIC_SYSTEM -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DEFAULT_SCAN_INTERVAL, DEVICE_MODEL, DOMAIN, RADON_SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -34,12 +38,19 @@ def __init__(self, hass: HomeAssistant, entry: AirthingsBLEConfigEntry) -> None: self.airthings = AirthingsBluetoothDeviceData( _LOGGER, hass.config.units is METRIC_SYSTEM ) + + device_model = entry.data.get(DEVICE_MODEL) + if device_model == AirthingsDeviceType.CORENTIUM_HOME_2.value: + interval = RADON_SCAN_INTERVAL + else: + interval = DEFAULT_SCAN_INTERVAL + super().__init__( hass, _LOGGER, config_entry=entry, name=DOMAIN, - update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + update_interval=timedelta(seconds=interval), ) async def _async_setup(self) -> None: @@ -58,11 +69,26 @@ async def _async_setup(self) -> None: ) self.ble_device = ble_device + if DEVICE_MODEL not in self.config_entry.data: + _LOGGER.debug("Fetching device info for migration") + try: + data = await self.airthings.update_device(self.ble_device) + except Exception as err: + raise UpdateFailed( + f"Unable to fetch data for migration: {err}" + ) from err + + self.hass.config_entries.async_update_entry( + self.config_entry, + data={**self.config_entry.data, DEVICE_MODEL: data.model.value}, + ) + if data.model == AirthingsDeviceType.CORENTIUM_HOME_2: + self.update_interval = timedelta(seconds=RADON_SCAN_INTERVAL) + async def _async_update_data(self) -> AirthingsDevice: """Get data from Airthings BLE.""" try: data = await self.airthings.update_device(self.ble_device) except Exception as err: raise UpdateFailed(f"Unable to fetch data: {err}") from err - return data diff --git a/tests/components/airthings_ble/__init__.py b/tests/components/airthings_ble/__init__.py index cf91634f71f764..23c66a9c3ec56c 100644 --- a/tests/components/airthings_ble/__init__.py +++ b/tests/components/airthings_ble/__init__.py @@ -135,6 +135,27 @@ def patch_airthings_device_update(): tx_power=0, ) +CORENTIUM_HOME_2_SERVICE_INFO = BluetoothServiceInfoBleak( + name="cc-cc-cc-cc-cc-cc", + address="cc:cc:cc:cc:cc:cc", + device=generate_ble_device( + address="cc:cc:cc:cc:cc:cc", + name="Airthings Corentium Home 2", + ), + rssi=-61, + manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"}, + service_data={}, + service_uuids=[], + source="local", + advertisement=generate_advertisement_data( + manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"}, + service_uuids=[], + ), + connectable=True, + time=0, + tx_power=0, +) + VIEW_PLUS_SERVICE_INFO = BluetoothServiceInfoBleak( name="cc-cc-cc-cc-cc-cc", address="cc:cc:cc:cc:cc:cc", @@ -265,6 +286,24 @@ def patch_airthings_device_update(): address="cc:cc:cc:cc:cc:cc", ) +CORENTIUM_HOME_2_DEVICE_INFO = AirthingsDevice( + manufacturer="Airthings AS", + hw_version="REV X", + sw_version="R-SUB-1.3.4-master+0", + model=AirthingsDeviceType.CORENTIUM_HOME_2, + name="Airthings Corentium Home 2", + identifier="123456", + sensors={ + "connectivity_mode": "Bluetooth", + "battery": 90, + "temperature": 20.0, + "humidity": 55.0, + "radon_1day_avg": 45, + "radon_1day_level": "low", + }, + address="cc:cc:cc:cc:cc:cc", +) + TEMPERATURE_V1 = MockEntity( unique_id="Airthings Wave Plus 123456_temperature", name="Airthings Wave Plus 123456 Temperature", diff --git a/tests/components/airthings_ble/test_config_flow.py b/tests/components/airthings_ble/test_config_flow.py index 71f2148b56be24..065ac371d005e1 100644 --- a/tests/components/airthings_ble/test_config_flow.py +++ b/tests/components/airthings_ble/test_config_flow.py @@ -60,6 +60,8 @@ async def test_bluetooth_discovery(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Airthings Wave Plus (2930123456)" assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc" + assert result["data"] == {"device_model": "2930"} + assert result["result"].data == {"device_model": "2930"} async def test_bluetooth_discovery_no_BLEDevice(hass: HomeAssistant) -> None: @@ -158,6 +160,8 @@ async def test_user_setup(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Airthings Wave Plus (2930123456)" assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc" + assert result["data"] == {"device_model": "2930"} + assert result["result"].data == {"device_model": "2930"} async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None: @@ -208,6 +212,8 @@ async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Airthings Wave Plus (2930123456)" assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc" + assert result["data"] == {"device_model": "2930"} + assert result["result"].data == {"device_model": "2930"} async def test_user_setup_no_device(hass: HomeAssistant) -> None: diff --git a/tests/components/airthings_ble/test_init.py b/tests/components/airthings_ble/test_init.py new file mode 100644 index 00000000000000..b187898baf2b20 --- /dev/null +++ b/tests/components/airthings_ble/test_init.py @@ -0,0 +1,147 @@ +"""Test the Airthings BLE integration init.""" + +from datetime import timedelta + +import pytest + +from homeassistant.components.airthings_ble.const import ( + DEFAULT_SCAN_INTERVAL, + DOMAIN, + RADON_SCAN_INTERVAL, +) +from homeassistant.core import HomeAssistant + +from . import ( + CORENTIUM_HOME_2_DEVICE_INFO, + CORENTIUM_HOME_2_SERVICE_INFO, + WAVE_DEVICE_INFO, + WAVE_ENHANCE_DEVICE_INFO, + WAVE_ENHANCE_SERVICE_INFO, + WAVE_SERVICE_INFO, + patch_airthings_ble, + patch_async_ble_device_from_address, +) + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + ("service_info", "device_info"), + [ + (WAVE_SERVICE_INFO, WAVE_DEVICE_INFO), + (WAVE_ENHANCE_SERVICE_INFO, WAVE_ENHANCE_DEVICE_INFO), + (CORENTIUM_HOME_2_SERVICE_INFO, CORENTIUM_HOME_2_DEVICE_INFO), + ], +) +async def test_migration_existing_entries( + hass: HomeAssistant, + service_info, + device_info, +) -> None: + """Test migration of existing config entry without device model.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=service_info.address, + data={}, + ) + entry.add_to_hass(hass) + + inject_bluetooth_service_info(hass, service_info) + + assert "device_model" not in entry.data + + with ( + patch_async_ble_device_from_address(service_info.device), + patch_airthings_ble(device_info), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Migration should have added device_model to entry data + assert "device_model" in entry.data + assert entry.data["device_model"] == device_info.model.value + + +async def test_no_migration_when_device_model_exists( + hass: HomeAssistant, +) -> None: + """Test that migration does not run when device_model already exists.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=WAVE_SERVICE_INFO.address, + data={"device_model": WAVE_DEVICE_INFO.model.value}, + ) + entry.add_to_hass(hass) + + inject_bluetooth_service_info(hass, WAVE_SERVICE_INFO) + + with ( + patch_async_ble_device_from_address(WAVE_SERVICE_INFO.device), + patch_airthings_ble(WAVE_DEVICE_INFO) as mock_update, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Should have only 1 call for initial refresh (no migration call) + assert mock_update.call_count == 1 + assert entry.data["device_model"] == WAVE_DEVICE_INFO.model.value + + +async def test_scan_interval_corentium_home_2( + hass: HomeAssistant, +) -> None: + """Test that coordinator uses radon scan interval for Corentium Home 2.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=WAVE_SERVICE_INFO.address, + data={"device_model": CORENTIUM_HOME_2_DEVICE_INFO.model.value}, + ) + entry.add_to_hass(hass) + + inject_bluetooth_service_info(hass, WAVE_SERVICE_INFO) + + with ( + patch_async_ble_device_from_address(WAVE_SERVICE_INFO.device), + patch_airthings_ble(CORENTIUM_HOME_2_DEVICE_INFO), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Coordinator should have radon scan interval + coordinator = entry.runtime_data + assert coordinator.update_interval == timedelta(seconds=RADON_SCAN_INTERVAL) + + +@pytest.mark.parametrize( + ("service_info", "device_info"), + [ + (WAVE_SERVICE_INFO, WAVE_DEVICE_INFO), + (WAVE_ENHANCE_SERVICE_INFO, WAVE_ENHANCE_DEVICE_INFO), + ], +) +async def test_coordinator_default_scan_interval( + hass: HomeAssistant, + service_info, + device_info, +) -> None: + """Test that coordinator uses default scan interval.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=service_info.address, + data={"device_model": device_info.model.value}, + ) + entry.add_to_hass(hass) + + inject_bluetooth_service_info(hass, service_info) + + with ( + patch_async_ble_device_from_address(service_info.device), + patch_airthings_ble(device_info), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Coordinator should have default scan interval + coordinator = entry.runtime_data + assert coordinator.update_interval == timedelta(seconds=DEFAULT_SCAN_INTERVAL) diff --git a/tests/components/airthings_ble/test_sensor.py b/tests/components/airthings_ble/test_sensor.py index 988dc313dab171..19641d8c6fc7f7 100644 --- a/tests/components/airthings_ble/test_sensor.py +++ b/tests/components/airthings_ble/test_sensor.py @@ -1,10 +1,16 @@ """Test the Airthings Wave sensor.""" +from datetime import timedelta import logging +from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.airthings_ble.const import DOMAIN +from homeassistant.components.airthings_ble.const import ( + DEFAULT_SCAN_INTERVAL, + DOMAIN, + RADON_SCAN_INTERVAL, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -12,6 +18,7 @@ from . import ( CO2_V1, CO2_V2, + CORENTIUM_HOME_2_DEVICE_INFO, HUMIDITY_V2, TEMPERATURE_V1, VOC_V1, @@ -21,6 +28,8 @@ WAVE_ENHANCE_DEVICE_INFO, WAVE_ENHANCE_SERVICE_INFO, WAVE_SERVICE_INFO, + AirthingsDevice, + BluetoothServiceInfoBleak, create_device, create_entry, patch_airthings_ble, @@ -29,6 +38,7 @@ patch_async_discovered_service_info, ) +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import inject_bluetooth_service_info _LOGGER = logging.getLogger(__name__) @@ -267,3 +277,96 @@ async def test_translation_keys( expected_name = f"Airthings Wave Enhance (123456) {expected_sensor_name}" assert state.attributes.get("friendly_name") == expected_name + + +async def test_scan_interval_migration_radon_device( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that radon device migration uses 30-minute scan interval.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=WAVE_SERVICE_INFO.address, + data={}, + ) + entry.add_to_hass(hass) + + inject_bluetooth_service_info(hass, WAVE_SERVICE_INFO) + + with ( + patch_async_ble_device_from_address(WAVE_SERVICE_INFO.device), + patch_airthings_ble(CORENTIUM_HOME_2_DEVICE_INFO) as mock_update, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Migration should have added device_model to entry data + assert "device_model" in entry.data + assert entry.data["device_model"] == CORENTIUM_HOME_2_DEVICE_INFO.model.value + + # Coordinator should have been configured with radon scan interval + coordinator = entry.runtime_data + assert coordinator.update_interval == timedelta(seconds=RADON_SCAN_INTERVAL) + + # Should have 2 calls: 1 for migration + 1 for initial refresh + assert mock_update.call_count == 2 + + # Fast forward by default interval (300s) - should NOT trigger update + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_update.call_count == 2 + + # Fast forward to radon interval (1800s) - should trigger update + freezer.tick(RADON_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_update.call_count == 3 + + +@pytest.mark.parametrize( + ("service_info", "device_info"), + [ + (WAVE_SERVICE_INFO, WAVE_DEVICE_INFO), + (WAVE_ENHANCE_SERVICE_INFO, WAVE_ENHANCE_DEVICE_INFO), + ], +) +async def test_default_scan_interval_migration( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + service_info: BluetoothServiceInfoBleak, + device_info: AirthingsDevice, +) -> None: + """Test that non-radon device migration uses default 5-minute scan interval.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=service_info.address, + data={}, + ) + entry.add_to_hass(hass) + + inject_bluetooth_service_info(hass, service_info) + + with ( + patch_async_ble_device_from_address(service_info.device), + patch_airthings_ble(device_info) as mock_update, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Migration should have added device_model to entry data + assert "device_model" in entry.data + assert entry.data["device_model"] == device_info.model.value + + # Coordinator should have been configured with default scan interval + coordinator = entry.runtime_data + assert coordinator.update_interval == timedelta(seconds=DEFAULT_SCAN_INTERVAL) + + # Should have 2 calls: 1 for migration + 1 for initial refresh + assert mock_update.call_count == 2 + + # Fast forward by default interval (300s) - SHOULD trigger update + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_update.call_count == 3