Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion homeassistant/components/rabbitair/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from .coordinator import RabbitAirConfigEntry, RabbitAirDataUpdateCoordinator

PLATFORMS: list[Platform] = [Platform.FAN]
PLATFORMS: list[Platform] = [Platform.FAN, Platform.SENSOR]


async def async_setup_entry(hass: HomeAssistant, entry: RabbitAirConfigEntry) -> bool:
Expand Down
3 changes: 2 additions & 1 deletion homeassistant/components/rabbitair/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,15 @@
class RabbitAirBaseEntity(CoordinatorEntity[RabbitAirDataUpdateCoordinator]):
"""Base class for Rabbit Air entity."""

_attr_has_entity_name = True

def __init__(
self,
coordinator: RabbitAirDataUpdateCoordinator,
entry: RabbitAirConfigEntry,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_name = entry.title
self._attr_unique_id = entry.unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.data[CONF_MAC])},
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/rabbitair/fan.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ async def async_setup_entry(
class RabbitAirFanEntity(RabbitAirBaseEntity, FanEntity):
"""Fan control functions of the Rabbit Air air purifier."""

_attr_name = None
_attr_translation_key = "rabbitair"
_attr_supported_features = (
FanEntityFeature.PRESET_MODE
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/components/rabbitair/icons.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
}
}
}
},
"sensor": {
"air_quality": {
"default": "mdi:air-filter"
}
}
}
}
60 changes: 60 additions & 0 deletions homeassistant/components/rabbitair/sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Support for Rabbit Air sensors."""

from rabbitair import Quality

from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType

from .coordinator import RabbitAirConfigEntry, RabbitAirDataUpdateCoordinator
from .entity import RabbitAirBaseEntity


def _quality_value(quality: Quality | None) -> StateType:
"""Return the air quality state."""
return None if quality is None else quality.name.lower()


AIR_QUALITY_OPTIONS = [quality.name.lower() for quality in Quality]

AIR_QUALITY_DESCRIPTION = SensorEntityDescription(
key="air_quality",
translation_key="air_quality",
device_class=SensorDeviceClass.ENUM,
options=AIR_QUALITY_OPTIONS,
)


async def async_setup_entry(
hass: HomeAssistant,
entry: RabbitAirConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Rabbit Air sensors."""
if entry.runtime_data.data.quality is not None:
async_add_entities([RabbitAirAirQualitySensor(entry.runtime_data, entry)])
Comment on lines +39 to +40
Copy link
Copy Markdown
Contributor Author

@MagikalUnicorn MagikalUnicorn Jun 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@joostlek - I’m not sure we can definitively know which current and future Rabbit Air models support quality, though my assumption is that they all will do. Given that, would you prefer that we always create the air quality sensor and let it report unknown when the device returns quality=None, or keep the current setup-time check and only create the entity when the first fetched state includes a quality value?



class RabbitAirAirQualitySensor(RabbitAirBaseEntity, SensorEntity):
"""Rabbit Air air quality sensor."""

entity_description = AIR_QUALITY_DESCRIPTION

def __init__(
self,
coordinator: RabbitAirDataUpdateCoordinator,
entry: RabbitAirConfigEntry,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.unique_id}_{self.entity_description.key}"

@property
def native_value(self) -> StateType:
"""Return the air quality state."""
return _quality_value(self.coordinator.data.quality)
12 changes: 12 additions & 0 deletions homeassistant/components/rabbitair/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@
}
}
}
},
"sensor": {
"air_quality": {
"name": "Air quality",
"state": {
"high": "[%key:common::state::high%]",
"highest": "Highest",
"low": "[%key:common::state::low%]",
"lowest": "Lowest",
"medium": "[%key:common::state::medium%]"
}
}
}
}
}
4 changes: 3 additions & 1 deletion tests/components/rabbitair/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from unittest.mock import MagicMock, Mock, patch

import pytest
from rabbitair import Mode, Model, Speed
from rabbitair import Mode, Model, Quality, Speed

from homeassistant import config_entries
from homeassistant.components.rabbitair.const import DOMAIN
Expand Down Expand Up @@ -62,6 +62,7 @@ def get_mock_state(
main_firmware: str | None = TEST_HARDWARE,
power: bool | None = True,
mode: Mode | None = Mode.Auto,
quality: Quality | None = Quality.High,
speed: Speed | None = Speed.Low,
wifi_firmware: str | None = TEST_FIRMWARE,
) -> Mock:
Expand All @@ -71,6 +72,7 @@ def get_mock_state(
mock_state.main_firmware = main_firmware
mock_state.power = power
mock_state.mode = mode
mock_state.quality = quality
mock_state.speed = speed
mock_state.wifi_firmware = wifi_firmware
return mock_state
Expand Down
77 changes: 77 additions & 0 deletions tests/components/rabbitair/test_sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Test the Rabbit Air sensor platform."""

from unittest.mock import patch

import pytest
from rabbitair import Quality

from homeassistant.components.rabbitair.const import DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MAC
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er

from .test_config_flow import (
TEST_HOST,
TEST_MAC,
TEST_TITLE,
TEST_TOKEN,
TEST_UNIQUE_ID,
get_mock_state,
)

from tests.common import MockConfigEntry

pytestmark = pytest.mark.usefixtures("mock_async_zeroconf")


@pytest.fixture
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Return a mock config entry."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: TEST_HOST,
CONF_ACCESS_TOKEN: TEST_TOKEN,
CONF_MAC: TEST_MAC,
},
title=TEST_TITLE,
unique_id=TEST_UNIQUE_ID,
)
Comment on lines +30 to +39
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we make this a test fixture?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated. The repeated MockConfigEntry setup is now in a mock_config_entry fixture.

entry.add_to_hass(hass)
return entry


async def test_air_quality_sensor(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the air quality sensor."""
with patch(
"homeassistant.components.rabbitair.coordinator.Client.get_state",
return_value=get_mock_state(quality=Quality.High),
):
Comment on lines +50 to +53
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we patch it where we use it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated. The tests now patch homeassistant.components.rabbitair.coordinator.Client.get_state, where the integration uses the client.

assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()

state = hass.states.get("sensor.rabbit_air_air_quality")
assert state
assert state.state == "high"

registry_entry = entity_registry.async_get("sensor.rabbit_air_air_quality")
assert registry_entry
assert registry_entry.unique_id == f"{TEST_UNIQUE_ID}_air_quality"


async def test_no_air_quality_sensor_when_quality_is_none(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test the air quality sensor is not created when quality is unavailable."""
with patch(
"homeassistant.components.rabbitair.coordinator.Client.get_state",
return_value=get_mock_state(quality=None),
):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()

assert hass.states.get("sensor.rabbit_air_air_quality") is None
Loading