diff --git a/homeassistant/components/airthings_ble/config_flow.py b/homeassistant/components/airthings_ble/config_flow.py index 57b55cb2bfb..a697c8e6e2c 100644 --- a/homeassistant/components/airthings_ble/config_flow.py +++ b/homeassistant/components/airthings_ble/config_flow.py @@ -23,7 +23,7 @@ from homeassistant.components.bluetooth import ( from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ADDRESS -from .const import DOMAIN, MFCT_ID +from .const import DEVICE_MODEL, DOMAIN, MFCT_ID _LOGGER = logging.getLogger(__name__) @@ -128,15 +128,15 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm discovery.""" + assert self._discovered_device is not None + if user_input is not None: - if ( - self._discovered_device is not None - and self._discovered_device.device.firmware.need_firmware_upgrade - ): + if self._discovered_device.device.firmware.need_firmware_upgrade: return self.async_abort(reason="firmware_upgrade_required") 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 +164,10 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): 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 fdfebea8bff..43b6268bd09 100644 --- a/homeassistant/components/airthings_ble/const.py +++ b/homeassistant/components/airthings_ble/const.py @@ -1,11 +1,16 @@ """Constants for Airthings BLE.""" +from airthings_ble import AirthingsDeviceType + DOMAIN = "airthings_ble" MFCT_ID = 820 VOLUME_BECQUEREL = "Bq/m³" VOLUME_PICOCURIE = "pCi/L" +DEVICE_MODEL = "device_model" + DEFAULT_SCAN_INTERVAL = 300 +DEVICE_SPECIFIC_SCAN_INTERVAL = {AirthingsDeviceType.CORENTIUM_HOME_2.value: 1800} MAX_RETRIES_AFTER_STARTUP = 5 diff --git a/homeassistant/components/airthings_ble/coordinator.py b/homeassistant/components/airthings_ble/coordinator.py index 81009dcea81..74bab314876 100644 --- a/homeassistant/components/airthings_ble/coordinator.py +++ b/homeassistant/components/airthings_ble/coordinator.py @@ -16,7 +16,12 @@ from homeassistant.exceptions import ConfigEntryNotReady 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, + DEVICE_SPECIFIC_SCAN_INTERVAL, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) @@ -34,12 +39,18 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]): self.airthings = AirthingsBluetoothDeviceData( _LOGGER, hass.config.units is METRIC_SYSTEM ) + + device_model = entry.data.get(DEVICE_MODEL) + interval = DEVICE_SPECIFIC_SCAN_INTERVAL.get( + device_model, 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,29 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]): ) 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}, + ) + self.update_interval = timedelta( + seconds=DEVICE_SPECIFIC_SCAN_INTERVAL.get( + data.model.value, DEFAULT_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 cf91634f71f..23c66a9c3ec 100644 --- a/tests/components/airthings_ble/__init__.py +++ b/tests/components/airthings_ble/__init__.py @@ -135,6 +135,27 @@ WAVE_ENHANCE_SERVICE_INFO = BluetoothServiceInfoBleak( 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 @@ WAVE_ENHANCE_DEVICE_INFO = AirthingsDevice( 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 71f2148b56b..a33224fe8a5 100644 --- a/tests/components/airthings_ble/test_config_flow.py +++ b/tests/components/airthings_ble/test_config_flow.py @@ -7,7 +7,7 @@ from bleak import BleakError from home_assistant_bluetooth import BluetoothServiceInfoBleak import pytest -from homeassistant.components.airthings_ble.const import DOMAIN +from homeassistant.components.airthings_ble.const import DEVICE_MODEL, DOMAIN from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_IGNORE, SOURCE_USER from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant @@ -29,12 +29,13 @@ from tests.common import MockConfigEntry async def test_bluetooth_discovery(hass: HomeAssistant) -> None: """Test discovery via bluetooth with a valid device.""" + wave_plus_device = AirthingsDeviceType.WAVE_PLUS with ( patch_async_ble_device_from_address(WAVE_SERVICE_INFO), patch_airthings_ble( AirthingsDevice( manufacturer="Airthings AS", - model=AirthingsDeviceType.WAVE_PLUS, + model=wave_plus_device, name="Airthings Wave Plus", identifier="123456", ) @@ -60,6 +61,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: wave_plus_device.value} + assert result["result"].data == {DEVICE_MODEL: wave_plus_device.value} async def test_bluetooth_discovery_no_BLEDevice(hass: HomeAssistant) -> None: @@ -118,6 +121,7 @@ async def test_bluetooth_discovery_already_setup(hass: HomeAssistant) -> None: async def test_user_setup(hass: HomeAssistant) -> None: """Test the user initiated form.""" + wave_plus_device = AirthingsDeviceType.WAVE_PLUS with ( patch( "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", @@ -127,7 +131,7 @@ async def test_user_setup(hass: HomeAssistant) -> None: patch_airthings_ble( AirthingsDevice( manufacturer="Airthings AS", - model=AirthingsDeviceType.WAVE_PLUS, + model=wave_plus_device, name="Airthings Wave Plus", identifier="123456", ) @@ -158,6 +162,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: wave_plus_device.value} + assert result["result"].data == {DEVICE_MODEL: wave_plus_device.value} async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None: @@ -168,6 +174,7 @@ async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None: source=SOURCE_IGNORE, ) entry.add_to_hass(hass) + wave_plus_device = AirthingsDeviceType.WAVE_PLUS with ( patch( "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", @@ -177,7 +184,7 @@ async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None: patch_airthings_ble( AirthingsDevice( manufacturer="Airthings AS", - model=AirthingsDeviceType.WAVE_PLUS, + model=wave_plus_device, name="Airthings Wave Plus", identifier="123456", ) @@ -208,6 +215,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: wave_plus_device.value} + assert result["result"].data == {DEVICE_MODEL: wave_plus_device.value} 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 00000000000..50351f2290d --- /dev/null +++ b/tests/components/airthings_ble/test_init.py @@ -0,0 +1,192 @@ +"""Test the Airthings BLE integration init.""" + +from copy import deepcopy + +from airthings_ble import AirthingsDeviceType +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.airthings_ble.const import ( + DEFAULT_SCAN_INTERVAL, + DEVICE_MODEL, + DEVICE_SPECIFIC_SCAN_INTERVAL, + DOMAIN, +) +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, async_fire_time_changed +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, freezer: FrozenDateTimeFactory +) -> 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() + + assert ( + hass.states.get("sensor.airthings_corentium_home_2_123456_battery").state + == "90" + ) + + changed_info = deepcopy(CORENTIUM_HOME_2_DEVICE_INFO) + changed_info.sensors["battery"] = 89 + + with patch_airthings_ble(changed_info): + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.airthings_corentium_home_2_123456_battery").state + == "90" + ) + + freezer.tick( + DEVICE_SPECIFIC_SCAN_INTERVAL.get( + AirthingsDeviceType.CORENTIUM_HOME_2.value + ) + - DEFAULT_SCAN_INTERVAL + ) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.airthings_corentium_home_2_123456_battery").state + == "89" + ) + + +@pytest.mark.parametrize( + ("service_info", "device_info", "battery_entity_id"), + [ + (WAVE_SERVICE_INFO, WAVE_DEVICE_INFO, "sensor.airthings_wave_123456_battery"), + ( + WAVE_ENHANCE_SERVICE_INFO, + WAVE_ENHANCE_DEVICE_INFO, + "sensor.airthings_wave_enhance_123456_battery", + ), + ], +) +async def test_coordinator_default_scan_interval( + hass: HomeAssistant, + service_info, + device_info, + freezer: FrozenDateTimeFactory, + battery_entity_id: str, +) -> 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() + + assert hass.states.get(battery_entity_id).state == "85" + + changed_info = deepcopy(device_info) + changed_info.sensors["battery"] = 84 + + with patch_airthings_ble(changed_info): + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(battery_entity_id).state == "84" diff --git a/tests/components/airthings_ble/test_sensor.py b/tests/components/airthings_ble/test_sensor.py index 988dc313dab..e6f029ca23e 100644 --- a/tests/components/airthings_ble/test_sensor.py +++ b/tests/components/airthings_ble/test_sensor.py @@ -1,10 +1,17 @@ """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, + DEVICE_MODEL, + DEVICE_SPECIFIC_SCAN_INTERVAL, + DOMAIN, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -12,6 +19,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from . import ( CO2_V1, CO2_V2, + CORENTIUM_HOME_2_DEVICE_INFO, HUMIDITY_V2, TEMPERATURE_V1, VOC_V1, @@ -21,6 +29,8 @@ from . import ( WAVE_ENHANCE_DEVICE_INFO, WAVE_ENHANCE_SERVICE_INFO, WAVE_SERVICE_INFO, + AirthingsDevice, + BluetoothServiceInfoBleak, create_device, create_entry, patch_airthings_ble, @@ -29,6 +39,7 @@ from . import ( 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 +278,102 @@ 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_corentium_home_2( + 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=DEVICE_SPECIFIC_SCAN_INTERVAL.get( + CORENTIUM_HOME_2_DEVICE_INFO.model.value + ) + ) + + # 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( + DEVICE_SPECIFIC_SCAN_INTERVAL.get(CORENTIUM_HOME_2_DEVICE_INFO.model.value) + ) + 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