diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index 7dc6701217d..cf1df6b8ac8 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -9,8 +9,8 @@ from aiohttp.client_exceptions import ClientConnectorError import async_timeout from nettigo_air_monitor import ( ApiError, - DictToObj, InvalidSensorData, + NAMSensors, NettigoAirMonitor, ) @@ -75,7 +75,7 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator): hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_UPDATE_INTERVAL ) - async def _async_update_data(self) -> DictToObj: + async def _async_update_data(self) -> NAMSensors: """Update data via library.""" try: # Device firmware uses synchronous code and doesn't respond to http queries @@ -86,8 +86,6 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator): except (ApiError, ClientConnectorError, InvalidSensorData) as error: raise UpdateFailed(error) from error - _LOGGER.debug(data) - return data @property diff --git a/homeassistant/components/nam/air_quality.py b/homeassistant/components/nam/air_quality.py index 4f560740937..4c51003f3e6 100644 --- a/homeassistant/components/nam/air_quality.py +++ b/homeassistant/components/nam/air_quality.py @@ -1,17 +1,20 @@ """Support for the Nettigo Air Monitor air_quality service.""" from __future__ import annotations -from homeassistant.components.air_quality import AirQualityEntity +import logging +from typing import Union, cast + +from homeassistant.components.air_quality import DOMAIN as PLATFORM, AirQualityEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NAMDataUpdateCoordinator from .const import ( AIR_QUALITY_SENSORS, - ATTR_MHZ14A_CARBON_DIOXIDE, + ATTR_SDS011, DEFAULT_NAME, DOMAIN, SUFFIX_P1, @@ -20,6 +23,8 @@ from .const import ( PARALLEL_UPDATES = 1 +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -27,9 +32,23 @@ async def async_setup_entry( """Add a Nettigo Air Monitor entities from a config_entry.""" coordinator: NAMDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + # Due to the change of the attribute name of one sensor, it is necessary to migrate + # the unique_id to the new name. + ent_reg = entity_registry.async_get(hass) + old_unique_id = f"{coordinator.unique_id}-sds" + new_unique_id = f"{coordinator.unique_id}-{ATTR_SDS011}" + if entity_id := ent_reg.async_get_entity_id(PLATFORM, DOMAIN, old_unique_id): + _LOGGER.debug( + "Migrating entity %s from old unique ID '%s' to new unique ID '%s'", + entity_id, + old_unique_id, + new_unique_id, + ) + ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) + entities: list[NAMAirQuality] = [] for sensor in AIR_QUALITY_SENSORS: - if f"{sensor}{SUFFIX_P1}" in coordinator.data: + if getattr(coordinator.data, f"{sensor}{SUFFIX_P1}") is not None: entities.append(NAMAirQuality(coordinator, sensor)) async_add_entities(entities, False) @@ -49,25 +68,25 @@ class NAMAirQuality(CoordinatorEntity, AirQualityEntity): self.sensor_type = sensor_type @property - def particulate_matter_2_5(self) -> StateType: + def particulate_matter_2_5(self) -> int | None: """Return the particulate matter 2.5 level.""" - return round_state( - getattr(self.coordinator.data, f"{self.sensor_type}{SUFFIX_P2}") + return cast( + Union[int, None], + getattr(self.coordinator.data, f"{self.sensor_type}{SUFFIX_P2}"), ) @property - def particulate_matter_10(self) -> StateType: + def particulate_matter_10(self) -> int | None: """Return the particulate matter 10 level.""" - return round_state( - getattr(self.coordinator.data, f"{self.sensor_type}{SUFFIX_P1}") + return cast( + Union[int, None], + getattr(self.coordinator.data, f"{self.sensor_type}{SUFFIX_P1}"), ) @property - def carbon_dioxide(self) -> StateType: + def carbon_dioxide(self) -> int | None: """Return the particulate matter 10 level.""" - return round_state( - getattr(self.coordinator.data, ATTR_MHZ14A_CARBON_DIOXIDE, None) - ) + return cast(Union[int, None], self.coordinator.data.mhz14a_carbon_dioxide) @property def available(self) -> bool: @@ -77,14 +96,8 @@ class NAMAirQuality(CoordinatorEntity, AirQualityEntity): # For a short time after booting, the device does not return values for all # sensors. For this reason, we mark entities for which data is missing as # unavailable. - return available and bool( - getattr(self.coordinator.data, f"{self.sensor_type}{SUFFIX_P2}", None) + return ( + available + and getattr(self.coordinator.data, f"{self.sensor_type}{SUFFIX_P2}") + is not None ) - - -def round_state(state: StateType) -> StateType: - """Round state.""" - if isinstance(state, float): - return round(state) - - return state diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index ccb5e6e6e84..a44f3f2ba6a 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -118,4 +118,4 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # when reading data from sensors. The nettigo-air-monitor library tries to get # the data 4 times, so we use a longer than usual timeout here. with async_timeout.timeout(30): - return cast(str, await nam.async_get_mac_address()) + return await nam.async_get_mac_address() diff --git a/homeassistant/components/nam/const.py b/homeassistant/components/nam/const.py index 1c191019c04..318d1e15802 100644 --- a/homeassistant/components/nam/const.py +++ b/homeassistant/components/nam/const.py @@ -22,21 +22,27 @@ from homeassistant.const import ( from .model import SensorDescription +SUFFIX_P0: Final = "_p0" +SUFFIX_P1: Final = "_p1" +SUFFIX_P2: Final = "_p2" +SUFFIX_P4: Final = "_p4" + ATTR_BME280_HUMIDITY: Final = "bme280_humidity" ATTR_BME280_PRESSURE: Final = "bme280_pressure" ATTR_BME280_TEMPERATURE: Final = "bme280_temperature" ATTR_BMP280_PRESSURE: Final = "bmp280_pressure" ATTR_BMP280_TEMPERATURE: Final = "bmp280_temperature" -ATTR_DHT22_HUMIDITY: Final = "humidity" -ATTR_DHT22_TEMPERATURE: Final = "temperature" +ATTR_DHT22_HUMIDITY: Final = "dht22_humidity" +ATTR_DHT22_TEMPERATURE: Final = "dht22_temperature" ATTR_HECA_HUMIDITY: Final = "heca_humidity" ATTR_HECA_TEMPERATURE: Final = "heca_temperature" -ATTR_MHZ14A_CARBON_DIOXIDE: Final = "conc_co2_ppm" +ATTR_SDS011: Final = "sds011" ATTR_SHT3X_HUMIDITY: Final = "sht3x_humidity" ATTR_SHT3X_TEMPERATURE: Final = "sht3x_temperature" ATTR_SIGNAL_STRENGTH: Final = "signal" -ATTR_SPS30_P0: Final = "sps30_p0" -ATTR_SPS30_P4: Final = "sps30_p4" +ATTR_SPS30: Final = "sps30" +ATTR_SPS30_P0: Final = f"{ATTR_SPS30}{SUFFIX_P0}" +ATTR_SPS30_P4: Final = f"{ATTR_SPS30}{SUFFIX_P4}" ATTR_UPTIME: Final = "uptime" ATTR_ENABLED: Final = "enabled" @@ -48,10 +54,15 @@ DEFAULT_UPDATE_INTERVAL: Final = timedelta(minutes=6) DOMAIN: Final = "nam" MANUFACTURER: Final = "Nettigo" -SUFFIX_P1: Final = "_p1" -SUFFIX_P2: Final = "_p2" +AIR_QUALITY_SENSORS: Final[dict[str, str]] = { + ATTR_SDS011: "SDS011", + ATTR_SPS30: "SPS30", +} -AIR_QUALITY_SENSORS: Final[dict[str, str]] = {"sds": "SDS011", "sps30": "SPS30"} +MIGRATION_SENSORS: Final = [ + ("temperature", ATTR_DHT22_TEMPERATURE), + ("humidity", ATTR_DHT22_HUMIDITY), +] SENSORS: Final[dict[str, SensorDescription]] = { ATTR_BME280_HUMIDITY: { diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index a003f3948e2..a1401c485de 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -3,7 +3,7 @@ "name": "Nettigo Air Monitor", "documentation": "https://www.home-assistant.io/integrations/nam", "codeowners": ["@bieniu"], - "requirements": ["nettigo-air-monitor==0.2.6"], + "requirements": ["nettigo-air-monitor==1.0.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 6adb29d3efb..e088a8f00c1 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -2,21 +2,38 @@ from __future__ import annotations from datetime import timedelta -from typing import Any +import logging +from typing import cast -from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorEntity +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN as PLATFORM, + SensorEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ICON from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow from . import NAMDataUpdateCoordinator -from .const import ATTR_ENABLED, ATTR_LABEL, ATTR_UNIT, ATTR_UPTIME, DOMAIN, SENSORS +from .const import ( + ATTR_ENABLED, + ATTR_LABEL, + ATTR_UNIT, + ATTR_UPTIME, + DOMAIN, + MIGRATION_SENSORS, + SENSORS, +) PARALLEL_UPDATES = 1 +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -24,9 +41,24 @@ async def async_setup_entry( """Add a Nettigo Air Monitor entities from a config_entry.""" coordinator: NAMDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + # Due to the change of the attribute name of two sensora, it is necessary to migrate + # the unique_ids to the new names. + ent_reg = entity_registry.async_get(hass) + for old_sensor, new_sensor in MIGRATION_SENSORS: + old_unique_id = f"{coordinator.unique_id}-{old_sensor}" + new_unique_id = f"{coordinator.unique_id}-{new_sensor}" + if entity_id := ent_reg.async_get_entity_id(PLATFORM, DOMAIN, old_unique_id): + _LOGGER.debug( + "Migrating entity %s from old unique ID '%s' to new unique ID '%s'", + entity_id, + old_unique_id, + new_unique_id, + ) + ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) + sensors: list[NAMSensor | NAMSensorUptime] = [] for sensor in SENSORS: - if sensor in coordinator.data: + if getattr(coordinator.data, sensor) is not None: if sensor == ATTR_UPTIME: sensors.append(NAMSensorUptime(coordinator, sensor)) else: @@ -55,9 +87,9 @@ class NAMSensor(CoordinatorEntity, SensorEntity): self.sensor_type = sensor_type @property - def state(self) -> Any: + def state(self) -> StateType: """Return the state.""" - return getattr(self.coordinator.data, self.sensor_type) + return cast(StateType, getattr(self.coordinator.data, self.sensor_type)) @property def available(self) -> bool: @@ -67,8 +99,8 @@ class NAMSensor(CoordinatorEntity, SensorEntity): # For a short time after booting, the device does not return values for all # sensors. For this reason, we mark entities for which data is missing as # unavailable. - return available and bool( - getattr(self.coordinator.data, self.sensor_type, None) + return ( + available and getattr(self.coordinator.data, self.sensor_type) is not None ) diff --git a/requirements_all.txt b/requirements_all.txt index 5d19bd04e9d..70c8a2d886d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1008,7 +1008,7 @@ netdata==0.2.0 netdisco==2.8.3 # homeassistant.components.nam -nettigo-air-monitor==0.2.6 +nettigo-air-monitor==1.0.0 # homeassistant.components.neurio_energy neurio==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b8ff4863197..3dcdde675a4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -562,7 +562,7 @@ nessclient==0.9.15 netdisco==2.8.3 # homeassistant.components.nam -nettigo-air-monitor==0.2.6 +nettigo-air-monitor==1.0.0 # homeassistant.components.nexia nexia==0.9.7 diff --git a/tests/components/nam/test_air_quality.py b/tests/components/nam/test_air_quality.py index f9a213cec3e..5f687b8a29a 100644 --- a/tests/components/nam/test_air_quality.py +++ b/tests/components/nam/test_air_quality.py @@ -4,7 +4,13 @@ from unittest.mock import patch from nettigo_air_monitor import ApiError -from homeassistant.components.air_quality import ATTR_CO2, ATTR_PM_2_5, ATTR_PM_10 +from homeassistant.components.air_quality import ( + ATTR_CO2, + ATTR_PM_2_5, + ATTR_PM_10, + DOMAIN as AIR_QUALITY_DOMAIN, +) +from homeassistant.components.nam.const import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, @@ -39,7 +45,7 @@ async def test_air_quality(hass): entry = registry.async_get("air_quality.nettigo_air_monitor_sds011") assert entry - assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sds" + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sds011" state = hass.states.get("air_quality.nettigo_air_monitor_sps30") assert state @@ -146,3 +152,22 @@ async def test_manual_update_entity(hass): ) assert mock_get_data.call_count == 1 + + +async def test_unique_id_migration(hass): + """Test states of the unique_id migration.""" + registry = er.async_get(hass) + + registry.async_get_or_create( + AIR_QUALITY_DOMAIN, + DOMAIN, + "aa:bb:cc:dd:ee:ff-sds", + suggested_object_id="nettigo_air_monitor_sds011", + disabled_by=None, + ) + + await init_integration(hass) + + entry = registry.async_get("air_quality.nettigo_air_monitor_sds011") + assert entry + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sds011" diff --git a/tests/components/nam/test_sensor.py b/tests/components/nam/test_sensor.py index b4c92c92e67..12fa0f01e44 100644 --- a/tests/components/nam/test_sensor.py +++ b/tests/components/nam/test_sensor.py @@ -143,7 +143,7 @@ async def test_sensor(hass): entry = registry.async_get("sensor.nettigo_air_monitor_dht22_humidity") assert entry - assert entry.unique_id == "aa:bb:cc:dd:ee:ff-humidity" + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-dht22_humidity" state = hass.states.get("sensor.nettigo_air_monitor_dht22_temperature") assert state @@ -154,7 +154,7 @@ async def test_sensor(hass): entry = registry.async_get("sensor.nettigo_air_monitor_dht22_temperature") assert entry - assert entry.unique_id == "aa:bb:cc:dd:ee:ff-temperature" + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-dht22_temperature" state = hass.states.get("sensor.nettigo_air_monitor_heca_humidity") assert state @@ -302,3 +302,36 @@ async def test_manual_update_entity(hass): ) assert mock_get_data.call_count == 1 + + +async def test_unique_id_migration(hass): + """Test states of the unique_id migration.""" + registry = er.async_get(hass) + + registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + "aa:bb:cc:dd:ee:ff-temperature", + suggested_object_id="nettigo_air_monitor_dht22_temperature", + disabled_by=None, + ) + + registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + "aa:bb:cc:dd:ee:ff-humidity", + suggested_object_id="nettigo_air_monitor_dht22_humidity", + disabled_by=None, + ) + + await init_integration(hass) + + entry = registry.async_get("sensor.nettigo_air_monitor_dht22_temperature") + assert entry + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-dht22_temperature" + + await init_integration(hass) + + entry = registry.async_get("sensor.nettigo_air_monitor_dht22_humidity") + assert entry + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-dht22_humidity"