Add support for air purifiers in HomeKit (#142467)

* Add support for air purifier type in HomeKit.

Any fan and PM2.5 in the same device will be treated as an air purifier.

type_air_purifiers.py heavily based on type_fans.py -
I tried extending type_fans.py but this looked better to me.

* Refactor to make AirPurifier class extend Fan.

* Ensure all chars are added before creating service

* Add support for switching automatic mode.

* Add test for auto/manual switch

* Add support for air purifier type in HomeKit.

Any fan and PM2.5 in the same device will be treated as an air purifier.

type_air_purifiers.py heavily based on type_fans.py -
I tried extending type_fans.py but this looked better to me.

* Add support for air purifier type in HomeKit.

Any fan and PM2.5 in the same device will be treated as an air purifier.

type_air_purifiers.py heavily based on type_fans.py -
I tried extending type_fans.py but this looked better to me.

* Refactor to make AirPurifier class extend Fan.

* Ensure all chars are added before creating service

* Add support for switching automatic mode.

* Add test for auto/manual switch

* Add support for air purifier type in HomeKit.

Any fan and PM2.5 in the same device will be treated as an air purifier.

type_air_purifiers.py heavily based on type_fans.py -
I tried extending type_fans.py but this looked better to me.

* Improve fan config: allow setting fan type (fan or air purifier)

Be more explicit than assuming a fan is an air purifier if it has a PM2.5 sensor. Set defaults based on the presence of sensors.

* Fix return type annotation for fan/air purifier create_services

* Allow linking air purifier filter level/change indicator

* Remove no longer needed if statement in fan init

* Fix up types and clean up code

* Update homekit tests to account for air purifiers

* Fix pylint errors

* Fix mypy errors

* Improve type annotations

* Improve readability of auto preset mode discovery

* Test air purifier with 'Auto' preset mode

* Handle case with a single preset mode

* Test air purifier edge cases: state updates to same value, and removed linked entities

* Don't create 'auto mode' switch for air purifiers

This is already exposed as a target mode on the air purifier service itself

* Handle unavailable states in air purifier

Also don't remove device class when updating state in test

* Reduce branching in air purifier test

* Split up air purifier tests for with and without auto presets, to reduce branching

* Handle unavailable states in air purifier more explicitly

* Use constant for ignored state values

* Use a set for ignored_states

* Update tests/components/homekit/test_type_air_purifiers.py

---------

Co-authored-by: Andrew Kurowski <62596884+ak6i@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick+github@koston.org>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Maarten Staa 2025-04-09 22:20:21 +02:00 committed by GitHub
parent 1b66278a68
commit 9fe306f056
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1407 additions and 13 deletions

View File

@ -31,6 +31,7 @@ from homeassistant.components.device_automation.trigger import (
async_validate_trigger_config,
)
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventDeviceClass
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
@ -49,6 +50,7 @@ from homeassistant.const import (
CONF_IP_ADDRESS,
CONF_NAME,
CONF_PORT,
CONF_TYPE,
EVENT_HOMEASSISTANT_STOP,
SERVICE_RELOAD,
)
@ -83,6 +85,7 @@ from homeassistant.loader import IntegrationNotFound, async_get_integration
from homeassistant.util.async_ import create_eager_task
from . import ( # noqa: F401
type_air_purifiers,
type_cameras,
type_covers,
type_fans,
@ -113,6 +116,8 @@ from .const import (
CONF_LINKED_DOORBELL_SENSOR,
CONF_LINKED_HUMIDITY_SENSOR,
CONF_LINKED_MOTION_SENSOR,
CONF_LINKED_PM25_SENSOR,
CONF_LINKED_TEMPERATURE_SENSOR,
CONFIG_OPTIONS,
DEFAULT_EXCLUDE_ACCESSORY_MODE,
DEFAULT_HOMEKIT_MODE,
@ -126,6 +131,7 @@ from .const import (
SERVICE_HOMEKIT_UNPAIR,
SHUTDOWN_TIMEOUT,
SIGNAL_RELOAD_ENTITIES,
TYPE_AIR_PURIFIER,
)
from .iidmanager import AccessoryIIDStorage
from .models import HomeKitConfigEntry, HomeKitEntryData
@ -169,6 +175,8 @@ MOTION_EVENT_SENSOR = (EVENT_DOMAIN, EventDeviceClass.MOTION)
MOTION_SENSOR = (BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass.MOTION)
DOORBELL_EVENT_SENSOR = (EVENT_DOMAIN, EventDeviceClass.DOORBELL)
HUMIDITY_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.HUMIDITY)
TEMPERATURE_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.TEMPERATURE)
PM25_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.PM25)
def _has_all_unique_names_and_ports(
@ -1136,6 +1144,21 @@ class HomeKit:
CONF_LINKED_DOORBELL_SENSOR, doorbell_event_entity_id
)
if domain == FAN_DOMAIN:
if current_humidity_sensor_entity_id := lookup.get(HUMIDITY_SENSOR):
config[entity_id].setdefault(
CONF_LINKED_HUMIDITY_SENSOR, current_humidity_sensor_entity_id
)
if current_pm25_sensor_entity_id := lookup.get(PM25_SENSOR):
config[entity_id].setdefault(CONF_TYPE, TYPE_AIR_PURIFIER)
config[entity_id].setdefault(
CONF_LINKED_PM25_SENSOR, current_pm25_sensor_entity_id
)
if current_temperature_sensor_entity_id := lookup.get(TEMPERATURE_SENSOR):
config[entity_id].setdefault(
CONF_LINKED_TEMPERATURE_SENSOR, current_temperature_sensor_entity_id
)
if domain == HUMIDIFIER_DOMAIN and (
current_humidity_sensor_entity_id := lookup.get(HUMIDITY_SENSOR)
):

View File

@ -85,6 +85,8 @@ from .const import (
SERV_ACCESSORY_INFO,
SERV_BATTERY_SERVICE,
SIGNAL_RELOAD_ENTITIES,
TYPE_AIR_PURIFIER,
TYPE_FAN,
TYPE_FAUCET,
TYPE_OUTLET,
TYPE_SHOWER,
@ -112,6 +114,10 @@ SWITCH_TYPES = {
TYPE_SWITCH: "Switch",
TYPE_VALVE: "ValveSwitch",
}
FAN_TYPES = {
TYPE_AIR_PURIFIER: "AirPurifier",
TYPE_FAN: "Fan",
}
TYPES: Registry[str, type[HomeAccessory]] = Registry()
RELOAD_ON_CHANGE_ATTRS = (
@ -178,7 +184,10 @@ def get_accessory( # noqa: C901
a_type = "WindowCovering"
elif state.domain == "fan":
a_type = "Fan"
if fan_type := config.get(CONF_TYPE):
a_type = FAN_TYPES[fan_type]
else:
a_type = "Fan"
elif state.domain == "humidifier":
a_type = "HumidifierDehumidifier"

View File

@ -49,9 +49,13 @@ CONF_EXCLUDE_ACCESSORY_MODE = "exclude_accessory_mode"
CONF_LINKED_BATTERY_SENSOR = "linked_battery_sensor"
CONF_LINKED_BATTERY_CHARGING_SENSOR = "linked_battery_charging_sensor"
CONF_LINKED_DOORBELL_SENSOR = "linked_doorbell_sensor"
CONF_LINKED_FILTER_CHANGE_INDICATION = "linked_filter_change_indication_binary_sensor"
CONF_LINKED_FILTER_LIFE_LEVEL = "linked_filter_life_level_sensor"
CONF_LINKED_MOTION_SENSOR = "linked_motion_sensor"
CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor"
CONF_LINKED_OBSTRUCTION_SENSOR = "linked_obstruction_sensor"
CONF_LINKED_PM25_SENSOR = "linked_pm25_sensor"
CONF_LINKED_TEMPERATURE_SENSOR = "linked_temperature_sensor"
CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold"
CONF_MAX_FPS = "max_fps"
CONF_MAX_HEIGHT = "max_height"
@ -120,12 +124,15 @@ TYPE_SHOWER = "shower"
TYPE_SPRINKLER = "sprinkler"
TYPE_SWITCH = "switch"
TYPE_VALVE = "valve"
TYPE_FAN = "fan"
TYPE_AIR_PURIFIER = "air_purifier"
# #### Categories ####
CATEGORY_RECEIVER = 34
# #### Services ####
SERV_ACCESSORY_INFO = "AccessoryInformation"
SERV_AIR_PURIFIER = "AirPurifier"
SERV_AIR_QUALITY_SENSOR = "AirQualitySensor"
SERV_BATTERY_SERVICE = "BatteryService"
SERV_CAMERA_RTP_STREAM_MANAGEMENT = "CameraRTPStreamManagement"
@ -135,6 +142,7 @@ SERV_CONTACT_SENSOR = "ContactSensor"
SERV_DOOR = "Door"
SERV_DOORBELL = "Doorbell"
SERV_FANV2 = "Fanv2"
SERV_FILTER_MAINTENANCE = "FilterMaintenance"
SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener"
SERV_HUMIDIFIER_DEHUMIDIFIER = "HumidifierDehumidifier"
SERV_HUMIDITY_SENSOR = "HumiditySensor"
@ -181,6 +189,7 @@ CHAR_CONFIGURED_NAME = "ConfiguredName"
CHAR_CONTACT_SENSOR_STATE = "ContactSensorState"
CHAR_COOLING_THRESHOLD_TEMPERATURE = "CoolingThresholdTemperature"
CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = "CurrentAmbientLightLevel"
CHAR_CURRENT_AIR_PURIFIER_STATE = "CurrentAirPurifierState"
CHAR_CURRENT_DOOR_STATE = "CurrentDoorState"
CHAR_CURRENT_FAN_STATE = "CurrentFanState"
CHAR_CURRENT_HEATING_COOLING = "CurrentHeatingCoolingState"
@ -192,6 +201,8 @@ CHAR_CURRENT_TEMPERATURE = "CurrentTemperature"
CHAR_CURRENT_TILT_ANGLE = "CurrentHorizontalTiltAngle"
CHAR_CURRENT_VISIBILITY_STATE = "CurrentVisibilityState"
CHAR_DEHUMIDIFIER_THRESHOLD_HUMIDITY = "RelativeHumidityDehumidifierThreshold"
CHAR_FILTER_CHANGE_INDICATION = "FilterChangeIndication"
CHAR_FILTER_LIFE_LEVEL = "FilterLifeLevel"
CHAR_FIRMWARE_REVISION = "FirmwareRevision"
CHAR_HARDWARE_REVISION = "HardwareRevision"
CHAR_HEATING_THRESHOLD_TEMPERATURE = "HeatingThresholdTemperature"
@ -229,6 +240,7 @@ CHAR_SMOKE_DETECTED = "SmokeDetected"
CHAR_STATUS_LOW_BATTERY = "StatusLowBattery"
CHAR_STREAMING_STRATUS = "StreamingStatus"
CHAR_SWING_MODE = "SwingMode"
CHAR_TARGET_AIR_PURIFIER_STATE = "TargetAirPurifierState"
CHAR_TARGET_DOOR_STATE = "TargetDoorState"
CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState"
CHAR_TARGET_POSITION = "TargetPosition"
@ -256,6 +268,7 @@ PROP_VALID_VALUES = "ValidValues"
# #### Thresholds ####
THRESHOLD_CO = 25
THRESHOLD_CO2 = 1000
THRESHOLD_FILTER_CHANGE_NEEDED = 10
# #### Default values ####
DEFAULT_MIN_TEMP_WATER_HEATER = 40 # °C

View File

@ -0,0 +1,469 @@
"""Class to hold all air purifier accessories."""
import logging
from typing import Any
from pyhap.characteristic import Characteristic
from pyhap.const import CATEGORY_AIR_PURIFIER
from pyhap.service import Service
from pyhap.util import callback as pyhap_callback
from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import (
Event,
EventStateChangedData,
HassJobType,
State,
callback,
)
from homeassistant.helpers.event import async_track_state_change_event
from .accessories import TYPES
from .const import (
CHAR_ACTIVE,
CHAR_AIR_QUALITY,
CHAR_CURRENT_AIR_PURIFIER_STATE,
CHAR_CURRENT_HUMIDITY,
CHAR_CURRENT_TEMPERATURE,
CHAR_FILTER_CHANGE_INDICATION,
CHAR_FILTER_LIFE_LEVEL,
CHAR_NAME,
CHAR_PM25_DENSITY,
CHAR_TARGET_AIR_PURIFIER_STATE,
CONF_LINKED_FILTER_CHANGE_INDICATION,
CONF_LINKED_FILTER_LIFE_LEVEL,
CONF_LINKED_HUMIDITY_SENSOR,
CONF_LINKED_PM25_SENSOR,
CONF_LINKED_TEMPERATURE_SENSOR,
SERV_AIR_PURIFIER,
SERV_AIR_QUALITY_SENSOR,
SERV_FILTER_MAINTENANCE,
SERV_HUMIDITY_SENSOR,
SERV_TEMPERATURE_SENSOR,
THRESHOLD_FILTER_CHANGE_NEEDED,
)
from .type_fans import ATTR_PRESET_MODE, CHAR_ROTATION_SPEED, Fan
from .util import cleanup_name_for_homekit, convert_to_float, density_to_air_quality
_LOGGER = logging.getLogger(__name__)
CURRENT_STATE_INACTIVE = 0
CURRENT_STATE_IDLE = 1
CURRENT_STATE_PURIFYING_AIR = 2
TARGET_STATE_MANUAL = 0
TARGET_STATE_AUTO = 1
FILTER_CHANGE_FILTER = 1
FILTER_OK = 0
IGNORED_STATES = {STATE_UNAVAILABLE, STATE_UNKNOWN}
@TYPES.register("AirPurifier")
class AirPurifier(Fan):
"""Generate an AirPurifier accessory for an air purifier entity.
Currently supports, in addition to Fan properties:
temperature; humidity; PM2.5; auto mode.
"""
def __init__(self, *args: Any) -> None:
"""Initialize a new AirPurifier accessory object."""
super().__init__(*args, category=CATEGORY_AIR_PURIFIER)
self.auto_preset: str | None = None
if self.preset_modes is not None:
for preset in self.preset_modes:
if str(preset).lower() == "auto":
self.auto_preset = preset
break
def create_services(self) -> Service:
"""Create and configure the primary service for this accessory."""
self.chars.append(CHAR_ACTIVE)
self.chars.append(CHAR_CURRENT_AIR_PURIFIER_STATE)
self.chars.append(CHAR_TARGET_AIR_PURIFIER_STATE)
serv_air_purifier = self.add_preload_service(SERV_AIR_PURIFIER, self.chars)
self.set_primary_service(serv_air_purifier)
self.char_active: Characteristic = serv_air_purifier.configure_char(
CHAR_ACTIVE, value=0
)
self.preset_mode_chars: dict[str, Characteristic]
self.char_current_humidity: Characteristic | None = None
self.char_pm25_density: Characteristic | None = None
self.char_current_temperature: Characteristic | None = None
self.char_filter_change_indication: Characteristic | None = None
self.char_filter_life_level: Characteristic | None = None
self.char_target_air_purifier_state: Characteristic = (
serv_air_purifier.configure_char(
CHAR_TARGET_AIR_PURIFIER_STATE,
value=0,
)
)
self.char_current_air_purifier_state: Characteristic = (
serv_air_purifier.configure_char(
CHAR_CURRENT_AIR_PURIFIER_STATE,
value=0,
)
)
self.linked_humidity_sensor = self.config.get(CONF_LINKED_HUMIDITY_SENSOR)
if self.linked_humidity_sensor:
humidity_serv = self.add_preload_service(SERV_HUMIDITY_SENSOR, CHAR_NAME)
serv_air_purifier.add_linked_service(humidity_serv)
self.char_current_humidity = humidity_serv.configure_char(
CHAR_CURRENT_HUMIDITY, value=0
)
humidity_state = self.hass.states.get(self.linked_humidity_sensor)
if humidity_state:
self._async_update_current_humidity(humidity_state)
self.linked_pm25_sensor = self.config.get(CONF_LINKED_PM25_SENSOR)
if self.linked_pm25_sensor:
pm25_serv = self.add_preload_service(
SERV_AIR_QUALITY_SENSOR,
[CHAR_AIR_QUALITY, CHAR_NAME, CHAR_PM25_DENSITY],
)
serv_air_purifier.add_linked_service(pm25_serv)
self.char_pm25_density = pm25_serv.configure_char(
CHAR_PM25_DENSITY, value=0
)
self.char_air_quality = pm25_serv.configure_char(CHAR_AIR_QUALITY)
pm25_state = self.hass.states.get(self.linked_pm25_sensor)
if pm25_state:
self._async_update_current_pm25(pm25_state)
self.linked_temperature_sensor = self.config.get(CONF_LINKED_TEMPERATURE_SENSOR)
if self.linked_temperature_sensor:
temperature_serv = self.add_preload_service(
SERV_TEMPERATURE_SENSOR, [CHAR_NAME, CHAR_CURRENT_TEMPERATURE]
)
serv_air_purifier.add_linked_service(temperature_serv)
self.char_current_temperature = temperature_serv.configure_char(
CHAR_CURRENT_TEMPERATURE, value=0
)
temperature_state = self.hass.states.get(self.linked_temperature_sensor)
if temperature_state:
self._async_update_current_temperature(temperature_state)
self.linked_filter_change_indicator_binary_sensor = self.config.get(
CONF_LINKED_FILTER_CHANGE_INDICATION
)
self.linked_filter_life_level_sensor = self.config.get(
CONF_LINKED_FILTER_LIFE_LEVEL
)
if (
self.linked_filter_change_indicator_binary_sensor
or self.linked_filter_life_level_sensor
):
chars = [CHAR_NAME, CHAR_FILTER_CHANGE_INDICATION]
if self.linked_filter_life_level_sensor:
chars.append(CHAR_FILTER_LIFE_LEVEL)
serv_filter_maintenance = self.add_preload_service(
SERV_FILTER_MAINTENANCE, chars
)
serv_air_purifier.add_linked_service(serv_filter_maintenance)
serv_filter_maintenance.configure_char(
CHAR_NAME,
value=cleanup_name_for_homekit(f"{self.display_name} Filter"),
)
self.char_filter_change_indication = serv_filter_maintenance.configure_char(
CHAR_FILTER_CHANGE_INDICATION,
value=0,
)
if self.linked_filter_change_indicator_binary_sensor:
filter_change_indicator_state = self.hass.states.get(
self.linked_filter_change_indicator_binary_sensor
)
if filter_change_indicator_state:
self._async_update_filter_change_indicator(
filter_change_indicator_state
)
if self.linked_filter_life_level_sensor:
self.char_filter_life_level = serv_filter_maintenance.configure_char(
CHAR_FILTER_LIFE_LEVEL,
value=0,
)
filter_life_level_state = self.hass.states.get(
self.linked_filter_life_level_sensor
)
if filter_life_level_state:
self._async_update_filter_life_level(filter_life_level_state)
return serv_air_purifier
def should_add_preset_mode_switch(self, preset_mode: str) -> bool:
"""Check if a preset mode switch should be added."""
return preset_mode.lower() != "auto"
@callback
@pyhap_callback # type: ignore[misc]
def run(self) -> None:
"""Handle accessory driver started event.
Run inside the Home Assistant event loop.
"""
if self.linked_humidity_sensor:
self._subscriptions.append(
async_track_state_change_event(
self.hass,
[self.linked_humidity_sensor],
self._async_update_current_humidity_event,
job_type=HassJobType.Callback,
)
)
if self.linked_pm25_sensor:
self._subscriptions.append(
async_track_state_change_event(
self.hass,
[self.linked_pm25_sensor],
self._async_update_current_pm25_event,
job_type=HassJobType.Callback,
)
)
if self.linked_temperature_sensor:
self._subscriptions.append(
async_track_state_change_event(
self.hass,
[self.linked_temperature_sensor],
self._async_update_current_temperature_event,
job_type=HassJobType.Callback,
)
)
if self.linked_filter_change_indicator_binary_sensor:
self._subscriptions.append(
async_track_state_change_event(
self.hass,
[self.linked_filter_change_indicator_binary_sensor],
self._async_update_filter_change_indicator_event,
job_type=HassJobType.Callback,
)
)
if self.linked_filter_life_level_sensor:
self._subscriptions.append(
async_track_state_change_event(
self.hass,
[self.linked_filter_life_level_sensor],
self._async_update_filter_life_level_event,
job_type=HassJobType.Callback,
)
)
super().run()
@callback
def _async_update_current_humidity_event(
self, event: Event[EventStateChangedData]
) -> None:
"""Handle state change event listener callback."""
self._async_update_current_humidity(event.data["new_state"])
@callback
def _async_update_current_humidity(self, new_state: State | None) -> None:
"""Handle linked humidity sensor state change to update HomeKit value."""
if new_state is None or new_state.state in IGNORED_STATES:
return
if (
(current_humidity := convert_to_float(new_state.state)) is None
or not self.char_current_humidity
or self.char_current_humidity.value == current_humidity
):
return
_LOGGER.debug(
"%s: Linked humidity sensor %s changed to %d",
self.entity_id,
self.linked_humidity_sensor,
current_humidity,
)
self.char_current_humidity.set_value(current_humidity)
@callback
def _async_update_current_pm25_event(
self, event: Event[EventStateChangedData]
) -> None:
"""Handle state change event listener callback."""
self._async_update_current_pm25(event.data["new_state"])
@callback
def _async_update_current_pm25(self, new_state: State | None) -> None:
"""Handle linked pm25 sensor state change to update HomeKit value."""
if new_state is None or new_state.state in IGNORED_STATES:
return
if (
(current_pm25 := convert_to_float(new_state.state)) is None
or not self.char_pm25_density
or self.char_pm25_density.value == current_pm25
):
return
_LOGGER.debug(
"%s: Linked pm25 sensor %s changed to %d",
self.entity_id,
self.linked_pm25_sensor,
current_pm25,
)
self.char_pm25_density.set_value(current_pm25)
air_quality = density_to_air_quality(current_pm25)
self.char_air_quality.set_value(air_quality)
_LOGGER.debug("%s: Set air_quality to %d", self.entity_id, air_quality)
@callback
def _async_update_current_temperature_event(
self, event: Event[EventStateChangedData]
) -> None:
"""Handle state change event listener callback."""
self._async_update_current_temperature(event.data["new_state"])
@callback
def _async_update_current_temperature(self, new_state: State | None) -> None:
"""Handle linked temperature sensor state change to update HomeKit value."""
if new_state is None or new_state.state in IGNORED_STATES:
return
if (
(current_temperature := convert_to_float(new_state.state)) is None
or not self.char_current_temperature
or self.char_current_temperature.value == current_temperature
):
return
_LOGGER.debug(
"%s: Linked temperature sensor %s changed to %d",
self.entity_id,
self.linked_temperature_sensor,
current_temperature,
)
self.char_current_temperature.set_value(current_temperature)
@callback
def _async_update_filter_change_indicator_event(
self, event: Event[EventStateChangedData]
) -> None:
"""Handle state change event listener callback."""
self._async_update_filter_change_indicator(event.data.get("new_state"))
@callback
def _async_update_filter_change_indicator(self, new_state: State | None) -> None:
"""Handle linked filter change indicator binary sensor state change to update HomeKit value."""
if new_state is None or new_state.state in IGNORED_STATES:
return
current_change_indicator = (
FILTER_CHANGE_FILTER if new_state.state == "on" else FILTER_OK
)
if (
not self.char_filter_change_indication
or self.char_filter_change_indication.value == current_change_indicator
):
return
_LOGGER.debug(
"%s: Linked filter change indicator binary sensor %s changed to %d",
self.entity_id,
self.linked_filter_change_indicator_binary_sensor,
current_change_indicator,
)
self.char_filter_change_indication.set_value(current_change_indicator)
@callback
def _async_update_filter_life_level_event(
self, event: Event[EventStateChangedData]
) -> None:
"""Handle state change event listener callback."""
self._async_update_filter_life_level(event.data.get("new_state"))
@callback
def _async_update_filter_life_level(self, new_state: State | None) -> None:
"""Handle linked filter life level sensor state change to update HomeKit value."""
if new_state is None or new_state.state in IGNORED_STATES:
return
if (
(current_life_level := convert_to_float(new_state.state)) is not None
and self.char_filter_life_level
and self.char_filter_life_level.value != current_life_level
):
_LOGGER.debug(
"%s: Linked filter life level sensor %s changed to %d",
self.entity_id,
self.linked_filter_life_level_sensor,
current_life_level,
)
self.char_filter_life_level.set_value(current_life_level)
if self.linked_filter_change_indicator_binary_sensor or not current_life_level:
# Handled by its own event listener
return
current_change_indicator = (
FILTER_CHANGE_FILTER
if (current_life_level < THRESHOLD_FILTER_CHANGE_NEEDED)
else FILTER_OK
)
if (
not self.char_filter_change_indication
or self.char_filter_change_indication.value == current_change_indicator
):
return
_LOGGER.debug(
"%s: Linked filter life level sensor %s changed to %d",
self.entity_id,
self.linked_filter_life_level_sensor,
current_change_indicator,
)
self.char_filter_change_indication.set_value(current_change_indicator)
@callback
def async_update_state(self, new_state: State) -> None:
"""Update fan after state change."""
super().async_update_state(new_state)
# Handle State
state = new_state.state
if self.char_current_air_purifier_state is not None:
self.char_current_air_purifier_state.set_value(
CURRENT_STATE_PURIFYING_AIR
if state == STATE_ON
else CURRENT_STATE_INACTIVE
)
# Automatic mode is represented in HASS by a preset called Auto or auto
attributes = new_state.attributes
if ATTR_PRESET_MODE in attributes:
current_preset_mode = attributes.get(ATTR_PRESET_MODE)
self.char_target_air_purifier_state.set_value(
TARGET_STATE_AUTO
if current_preset_mode and current_preset_mode.lower() == "auto"
else TARGET_STATE_MANUAL
)
def set_chars(self, char_values: dict[str, Any]) -> None:
"""Handle automatic mode after state change."""
super().set_chars(char_values)
if (
CHAR_TARGET_AIR_PURIFIER_STATE in char_values
and self.auto_preset is not None
):
if char_values[CHAR_TARGET_AIR_PURIFIER_STATE] == TARGET_STATE_AUTO:
super().set_preset_mode(True, self.auto_preset)
elif self.char_speed is not None:
super().set_chars({CHAR_ROTATION_SPEED: self.char_speed.get_value()})

View File

@ -4,6 +4,7 @@ import logging
from typing import Any
from pyhap.const import CATEGORY_FAN
from pyhap.service import Service
from homeassistant.components.fan import (
ATTR_DIRECTION,
@ -56,9 +57,9 @@ class Fan(HomeAccessory):
Currently supports: state, speed, oscillate, direction.
"""
def __init__(self, *args: Any) -> None:
def __init__(self, *args: Any, category: int = CATEGORY_FAN) -> None:
"""Initialize a new Fan accessory object."""
super().__init__(*args, category=CATEGORY_FAN)
super().__init__(*args, category=category)
self.chars: list[str] = []
state = self.hass.states.get(self.entity_id)
assert state
@ -79,12 +80,8 @@ class Fan(HomeAccessory):
self.chars.append(CHAR_SWING_MODE)
if features & FanEntityFeature.SET_SPEED:
self.chars.append(CHAR_ROTATION_SPEED)
if self.preset_modes and len(self.preset_modes) == 1:
self.chars.append(CHAR_TARGET_FAN_STATE)
serv_fan = self.add_preload_service(SERV_FANV2, self.chars)
self.set_primary_service(serv_fan)
self.char_active = serv_fan.configure_char(CHAR_ACTIVE, value=0)
serv_fan = self.create_services()
self.char_direction = None
self.char_speed = None
@ -107,13 +104,21 @@ class Fan(HomeAccessory):
properties={PROP_MIN_STEP: percentage_step},
)
if self.preset_modes and len(self.preset_modes) == 1:
if (
self.preset_modes
and len(self.preset_modes) == 1
# NOTE: This would be missing for air purifiers
and CHAR_TARGET_FAN_STATE in self.chars
):
self.char_target_fan_state = serv_fan.configure_char(
CHAR_TARGET_FAN_STATE,
value=0,
)
elif self.preset_modes:
for preset_mode in self.preset_modes:
if not self.should_add_preset_mode_switch(preset_mode):
continue
preset_serv = self.add_preload_service(
SERV_SWITCH, CHAR_NAME, unique_id=preset_mode
)
@ -126,7 +131,7 @@ class Fan(HomeAccessory):
)
def setter_callback(value: int, preset_mode: str = preset_mode) -> None:
return self.set_preset_mode(value, preset_mode)
self.set_preset_mode(value, preset_mode)
self.preset_mode_chars[preset_mode] = preset_serv.configure_char(
CHAR_ON,
@ -137,10 +142,27 @@ class Fan(HomeAccessory):
if CHAR_SWING_MODE in self.chars:
self.char_swing = serv_fan.configure_char(CHAR_SWING_MODE, value=0)
self.async_update_state(state)
serv_fan.setter_callback = self._set_chars
serv_fan.setter_callback = self.set_chars
def _set_chars(self, char_values: dict[str, Any]) -> None:
_LOGGER.debug("Fan _set_chars: %s", char_values)
def create_services(self) -> Service:
"""Create and configure the primary service for this accessory."""
if self.preset_modes and len(self.preset_modes) == 1:
self.chars.append(CHAR_TARGET_FAN_STATE)
serv_fan = self.add_preload_service(SERV_FANV2, self.chars)
self.set_primary_service(serv_fan)
self.char_active = serv_fan.configure_char(CHAR_ACTIVE, value=0)
return serv_fan
def should_add_preset_mode_switch(self, preset_mode: str) -> bool:
"""Check if a preset mode switch should be added.
Always true for fans, but can be overridden by subclasses.
"""
return True
def set_chars(self, char_values: dict[str, Any]) -> None:
"""Set characteristic values."""
_LOGGER.debug("Fan set_chars: %s", char_values)
if CHAR_ACTIVE in char_values:
if char_values[CHAR_ACTIVE]:
# If the device supports set speed we

View File

@ -62,9 +62,13 @@ from .const import (
CONF_LINKED_BATTERY_CHARGING_SENSOR,
CONF_LINKED_BATTERY_SENSOR,
CONF_LINKED_DOORBELL_SENSOR,
CONF_LINKED_FILTER_CHANGE_INDICATION,
CONF_LINKED_FILTER_LIFE_LEVEL,
CONF_LINKED_HUMIDITY_SENSOR,
CONF_LINKED_MOTION_SENSOR,
CONF_LINKED_OBSTRUCTION_SENSOR,
CONF_LINKED_PM25_SENSOR,
CONF_LINKED_TEMPERATURE_SENSOR,
CONF_LOW_BATTERY_THRESHOLD,
CONF_MAX_FPS,
CONF_MAX_HEIGHT,
@ -98,6 +102,8 @@ from .const import (
FEATURE_PLAY_STOP,
FEATURE_TOGGLE_MUTE,
MAX_NAME_LENGTH,
TYPE_AIR_PURIFIER,
TYPE_FAN,
TYPE_FAUCET,
TYPE_OUTLET,
TYPE_SHOWER,
@ -187,6 +193,27 @@ HUMIDIFIER_SCHEMA = BASIC_INFO_SCHEMA.extend(
{vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN)}
)
FAN_SCHEMA = BASIC_INFO_SCHEMA.extend(
{
vol.Optional(CONF_TYPE, default=TYPE_FAN): vol.All(
cv.string,
vol.In(
(
TYPE_FAN,
TYPE_AIR_PURIFIER,
)
),
),
vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN),
vol.Optional(CONF_LINKED_PM25_SENSOR): cv.entity_domain(sensor.DOMAIN),
vol.Optional(CONF_LINKED_TEMPERATURE_SENSOR): cv.entity_domain(sensor.DOMAIN),
vol.Optional(CONF_LINKED_FILTER_CHANGE_INDICATION): cv.entity_domain(
binary_sensor.DOMAIN
),
vol.Optional(CONF_LINKED_FILTER_LIFE_LEVEL): cv.entity_domain(sensor.DOMAIN),
}
)
COVER_SCHEMA = BASIC_INFO_SCHEMA.extend(
{
vol.Optional(CONF_LINKED_OBSTRUCTION_SENSOR): cv.entity_domain(
@ -325,6 +352,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]:
elif domain == "cover":
config = COVER_SCHEMA(config)
elif domain == "fan":
config = FAN_SCHEMA(config)
elif domain == "sensor":
config = SENSOR_SCHEMA(config)

View File

@ -6,11 +6,13 @@ import pytest
from homeassistant.components.climate import ClimateEntityFeature
from homeassistant.components.cover import CoverEntityFeature
from homeassistant.components.homekit import TYPE_AIR_PURIFIER
from homeassistant.components.homekit.accessories import TYPES, get_accessory
from homeassistant.components.homekit.const import (
ATTR_INTEGRATION,
CONF_FEATURE_LIST,
FEATURE_ON_OFF,
TYPE_FAN,
TYPE_FAUCET,
TYPE_OUTLET,
TYPE_SHOWER,
@ -350,6 +352,23 @@ def test_type_switches(type_name, entity_id, state, attrs, config) -> None:
assert mock_type.called
@pytest.mark.parametrize(
("type_name", "entity_id", "state", "attrs", "config"),
[
("Fan", "fan.test", "on", {}, {}),
("Fan", "fan.test", "on", {}, {CONF_TYPE: TYPE_FAN}),
("AirPurifier", "fan.test", "on", {}, {CONF_TYPE: TYPE_AIR_PURIFIER}),
],
)
def test_type_fans(type_name, entity_id, state, attrs, config) -> None:
"""Test if switch types are associated correctly."""
mock_type = Mock()
with patch.dict(TYPES, {type_name: mock_type}):
entity_state = State(entity_id, state, attrs)
get_accessory(None, None, entity_state, 2, config)
assert mock_type.called
@pytest.mark.parametrize(
("type_name", "entity_id", "state", "attrs"),
[

View File

@ -21,6 +21,7 @@ from homeassistant.components.homekit import (
STATUS_RUNNING,
STATUS_STOPPED,
STATUS_WAIT,
TYPE_AIR_PURIFIER,
HomeKit,
)
from homeassistant.components.homekit.accessories import HomeBridge
@ -51,6 +52,7 @@ from homeassistant.const import (
ATTR_DEVICE_ID,
ATTR_ENTITY_ID,
ATTR_UNIT_OF_MEASUREMENT,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONF_NAME,
CONF_PORT,
EVENT_HOMEASSISTANT_STARTED,
@ -58,6 +60,7 @@ from homeassistant.const import (
SERVICE_RELOAD,
STATE_ON,
EntityCategory,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError
@ -2162,6 +2165,109 @@ async def test_homekit_finds_linked_humidity_sensors(
)
@pytest.mark.usefixtures("mock_async_zeroconf")
async def test_homekit_finds_linked_air_purifier_sensors(
hass: HomeAssistant,
hk_driver,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test HomeKit start method."""
entry = await async_init_integration(hass)
homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE)
homekit.driver = hk_driver
homekit.bridge = HomeBridge(hass, hk_driver, "mock_bridge")
config_entry = MockConfigEntry(domain="air_purifier", data={})
config_entry.add_to_hass(hass)
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
sw_version="0.16.1",
model="Smart Air Purifier",
manufacturer="Home Assistant",
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
humidity_sensor = entity_registry.async_get_or_create(
"sensor",
"air_purifier",
"humidity_sensor",
device_id=device_entry.id,
original_device_class=SensorDeviceClass.HUMIDITY,
)
pm25_sensor = entity_registry.async_get_or_create(
"sensor",
"air_purifier",
"pm25_sensor",
device_id=device_entry.id,
original_device_class=SensorDeviceClass.PM25,
)
temperature_sensor = entity_registry.async_get_or_create(
"sensor",
"air_purifier",
"temperature_sensor",
device_id=device_entry.id,
original_device_class=SensorDeviceClass.TEMPERATURE,
)
air_purifier = entity_registry.async_get_or_create(
"fan", "air_purifier", "demo", device_id=device_entry.id
)
hass.states.async_set(
humidity_sensor.entity_id,
"42",
{
ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY,
ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE,
},
)
hass.states.async_set(
pm25_sensor.entity_id,
8,
{
ATTR_DEVICE_CLASS: SensorDeviceClass.PM25,
ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
)
hass.states.async_set(
temperature_sensor.entity_id,
22,
{
ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
},
)
hass.states.async_set(air_purifier.entity_id, STATE_ON)
with (
patch.object(homekit.bridge, "add_accessory"),
patch(f"{PATH_HOMEKIT}.async_show_setup_message"),
patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc,
patch("pyhap.accessory_driver.AccessoryDriver.async_start"),
):
await homekit.async_start()
await hass.async_block_till_done()
mock_get_acc.assert_called_with(
hass,
ANY,
ANY,
ANY,
{
"manufacturer": "Home Assistant",
"model": "Smart Air Purifier",
"platform": "air_purifier",
"sw_version": "0.16.1",
"type": TYPE_AIR_PURIFIER,
"linked_humidity_sensor": "sensor.air_purifier_humidity_sensor",
"linked_pm25_sensor": "sensor.air_purifier_pm25_sensor",
"linked_temperature_sensor": "sensor.air_purifier_temperature_sensor",
},
)
@pytest.mark.usefixtures("mock_async_zeroconf")
async def test_reload(hass: HomeAssistant) -> None:
"""Test we can reload from yaml."""

View File

@ -0,0 +1,702 @@
"""Test different accessory types: Air Purifiers."""
from unittest.mock import MagicMock
from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE
import pytest
from homeassistant.components.fan import (
ATTR_PERCENTAGE,
ATTR_PRESET_MODE,
ATTR_PRESET_MODES,
DOMAIN as FAN_DOMAIN,
FanEntityFeature,
)
from homeassistant.components.homekit import (
CONF_LINKED_HUMIDITY_SENSOR,
CONF_LINKED_PM25_SENSOR,
CONF_LINKED_TEMPERATURE_SENSOR,
)
from homeassistant.components.homekit.const import (
CONF_LINKED_FILTER_CHANGE_INDICATION,
CONF_LINKED_FILTER_LIFE_LEVEL,
THRESHOLD_FILTER_CHANGE_NEEDED,
)
from homeassistant.components.homekit.type_air_purifiers import (
FILTER_CHANGE_FILTER,
FILTER_OK,
TARGET_STATE_AUTO,
TARGET_STATE_MANUAL,
AirPurifier,
)
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
)
from homeassistant.core import Event, HomeAssistant
from tests.common import async_mock_service
@pytest.mark.parametrize(
("auto_preset", "preset_modes"),
[
("auto", ["sleep", "smart", "auto"]),
("Auto", ["sleep", "smart", "Auto"]),
],
)
async def test_fan_auto_manual(
hass: HomeAssistant,
hk_driver,
events: list[Event],
auto_preset: str,
preset_modes: list[str],
) -> None:
"""Test switching between Auto and Manual."""
entity_id = "fan.demo"
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE
| FanEntityFeature.SET_SPEED,
ATTR_PRESET_MODE: auto_preset,
ATTR_PRESET_MODES: preset_modes,
},
)
await hass.async_block_till_done()
acc = AirPurifier(hass, hk_driver, "Air Purifier", entity_id, 1, None)
hk_driver.add_accessory(acc)
assert acc.preset_mode_chars["smart"].value == 0
assert acc.preset_mode_chars["sleep"].value == 0
assert acc.auto_preset is not None
# Auto presets are handled as the target air purifier state, so
# not supposed to be exposed as a separate switch
switches = set()
for service in acc.services:
if service.display_name == "Switch":
switches.add(service.unique_id)
assert len(switches) == len(preset_modes) - 1
for preset in preset_modes:
if preset != auto_preset:
assert preset in switches
else:
# Auto preset should not be in switches
assert preset not in switches
acc.run()
await hass.async_block_till_done()
assert acc.char_target_air_purifier_state.value == TARGET_STATE_AUTO
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE
| FanEntityFeature.SET_SPEED,
ATTR_PRESET_MODE: "smart",
ATTR_PRESET_MODES: preset_modes,
},
)
await hass.async_block_till_done()
assert acc.preset_mode_chars["smart"].value == 1
assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL
# Set from HomeKit
call_set_preset_mode = async_mock_service(hass, FAN_DOMAIN, "set_preset_mode")
call_set_percentage = async_mock_service(hass, FAN_DOMAIN, "set_percentage")
char_auto_iid = acc.char_target_air_purifier_state.to_HAP()[HAP_REPR_IID]
hk_driver.set_characteristics(
{
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: char_auto_iid,
HAP_REPR_VALUE: 1,
},
]
},
"mock_addr",
)
await hass.async_block_till_done()
assert acc.char_target_air_purifier_state.value == TARGET_STATE_AUTO
assert len(call_set_preset_mode) == 1
assert call_set_preset_mode[0].data[ATTR_ENTITY_ID] == entity_id
assert call_set_preset_mode[0].data[ATTR_PRESET_MODE] == auto_preset
assert len(events) == 1
assert events[-1].data["service"] == "set_preset_mode"
hk_driver.set_characteristics(
{
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: char_auto_iid,
HAP_REPR_VALUE: 0,
},
]
},
"mock_addr",
)
await hass.async_block_till_done()
assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL
assert len(call_set_percentage) == 1
assert call_set_percentage[0].data[ATTR_ENTITY_ID] == entity_id
assert events[-1].data["service"] == "set_percentage"
assert len(events) == 2
async def test_presets_no_auto(
hass: HomeAssistant,
hk_driver,
events: list[Event],
) -> None:
"""Test preset without an auto mode."""
entity_id = "fan.demo"
preset_modes = ["sleep", "smart"]
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE
| FanEntityFeature.SET_SPEED,
ATTR_PRESET_MODE: "smart",
ATTR_PRESET_MODES: preset_modes,
},
)
await hass.async_block_till_done()
acc = AirPurifier(hass, hk_driver, "Air Purifier", entity_id, 1, None)
hk_driver.add_accessory(acc)
assert acc.preset_mode_chars["smart"].value == 1
assert acc.preset_mode_chars["sleep"].value == 0
assert acc.auto_preset is None
# Auto presets are handled as the target air purifier state, so
# not supposed to be exposed as a separate switch
switches = set()
for service in acc.services:
if service.display_name == "Switch":
switches.add(service.unique_id)
assert len(switches) == len(preset_modes)
for preset in preset_modes:
assert preset in switches
acc.run()
await hass.async_block_till_done()
assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE
| FanEntityFeature.SET_SPEED,
ATTR_PRESET_MODE: "sleep",
ATTR_PRESET_MODES: preset_modes,
},
)
await hass.async_block_till_done()
assert acc.preset_mode_chars["smart"].value == 0
assert acc.preset_mode_chars["sleep"].value == 1
assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL
async def test_air_purifier_single_preset_mode(
hass: HomeAssistant, hk_driver, events: list[Event]
) -> None:
"""Test air purifier with a single preset mode."""
entity_id = "fan.demo"
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE
| FanEntityFeature.SET_SPEED,
ATTR_PERCENTAGE: 42,
ATTR_PRESET_MODE: "auto",
ATTR_PRESET_MODES: ["auto"],
},
)
await hass.async_block_till_done()
acc = AirPurifier(hass, hk_driver, "Air Purifier", entity_id, 1, None)
hk_driver.add_accessory(acc)
assert acc.char_target_air_purifier_state.value == TARGET_STATE_AUTO
acc.run()
await hass.async_block_till_done()
# Set from HomeKit
call_set_preset_mode = async_mock_service(hass, FAN_DOMAIN, "set_preset_mode")
call_set_percentage = async_mock_service(hass, FAN_DOMAIN, "set_percentage")
char_target_air_purifier_state_iid = acc.char_target_air_purifier_state.to_HAP()[
HAP_REPR_IID
]
hk_driver.set_characteristics(
{
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: char_target_air_purifier_state_iid,
HAP_REPR_VALUE: TARGET_STATE_MANUAL,
},
]
},
"mock_addr",
)
await hass.async_block_till_done()
assert call_set_percentage[0]
assert call_set_percentage[0].data[ATTR_ENTITY_ID] == entity_id
assert call_set_percentage[0].data[ATTR_PERCENTAGE] == 42
assert len(events) == 1
assert events[-1].data["service"] == "set_percentage"
hk_driver.set_characteristics(
{
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: char_target_air_purifier_state_iid,
HAP_REPR_VALUE: 1,
},
]
},
"mock_addr",
)
await hass.async_block_till_done()
assert call_set_preset_mode[0]
assert call_set_preset_mode[0].data[ATTR_ENTITY_ID] == entity_id
assert call_set_preset_mode[0].data[ATTR_PRESET_MODE] == "auto"
assert events[-1].data["service"] == "set_preset_mode"
assert len(events) == 2
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE
| FanEntityFeature.SET_SPEED,
ATTR_PERCENTAGE: 42,
ATTR_PRESET_MODE: None,
ATTR_PRESET_MODES: ["auto"],
},
)
await hass.async_block_till_done()
assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL
async def test_expose_linked_sensors(
hass: HomeAssistant, hk_driver, events: list[Event]
) -> None:
"""Test that linked sensors are exposed."""
entity_id = "fan.demo"
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED,
},
)
humidity_entity_id = "sensor.demo_humidity"
hass.states.async_set(
humidity_entity_id,
50,
{
ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY,
},
)
pm25_entity_id = "sensor.demo_pm25"
hass.states.async_set(
pm25_entity_id,
10,
{
ATTR_DEVICE_CLASS: SensorDeviceClass.PM25,
},
)
temperature_entity_id = "sensor.demo_temperature"
hass.states.async_set(
temperature_entity_id,
25,
{
ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE,
},
)
await hass.async_block_till_done()
acc = AirPurifier(
hass,
hk_driver,
"Air Purifier",
entity_id,
1,
{
CONF_LINKED_TEMPERATURE_SENSOR: temperature_entity_id,
CONF_LINKED_PM25_SENSOR: pm25_entity_id,
CONF_LINKED_HUMIDITY_SENSOR: humidity_entity_id,
},
)
hk_driver.add_accessory(acc)
assert acc.linked_humidity_sensor is not None
assert acc.char_current_humidity is not None
assert acc.linked_pm25_sensor is not None
assert acc.char_pm25_density is not None
assert acc.char_air_quality is not None
assert acc.linked_temperature_sensor is not None
assert acc.char_current_temperature is not None
acc.run()
await hass.async_block_till_done()
assert acc.char_current_humidity.value == 50
assert acc.char_pm25_density.value == 10
assert acc.char_air_quality.value == 2
assert acc.char_current_temperature.value == 25
# Updated humidity should reflect in HomeKit
broker = MagicMock()
acc.char_current_humidity.broker = broker
hass.states.async_set(
humidity_entity_id,
60,
{
ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY,
},
)
await hass.async_block_till_done()
assert acc.char_current_humidity.value == 60
assert len(broker.mock_calls) == 2
broker.reset_mock()
# Change to same state should not trigger update in HomeKit
hass.states.async_set(
humidity_entity_id,
60,
{
ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY,
},
force_update=True,
)
await hass.async_block_till_done()
assert acc.char_current_humidity.value == 60
assert len(broker.mock_calls) == 0
# Updated PM2.5 should reflect in HomeKit
broker = MagicMock()
acc.char_pm25_density.broker = broker
acc.char_air_quality.broker = broker
hass.states.async_set(
pm25_entity_id,
5,
{
ATTR_DEVICE_CLASS: SensorDeviceClass.PM25,
},
)
await hass.async_block_till_done()
assert acc.char_pm25_density.value == 5
assert acc.char_air_quality.value == 1
assert len(broker.mock_calls) == 4
broker.reset_mock()
# Change to same state should not trigger update in HomeKit
hass.states.async_set(
pm25_entity_id,
5,
{
ATTR_DEVICE_CLASS: SensorDeviceClass.PM25,
},
force_update=True,
)
await hass.async_block_till_done()
assert acc.char_pm25_density.value == 5
assert acc.char_air_quality.value == 1
assert len(broker.mock_calls) == 0
# Updated temperature should reflect in HomeKit
broker = MagicMock()
acc.char_current_temperature.broker = broker
hass.states.async_set(
temperature_entity_id,
30,
{
ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE,
},
)
await hass.async_block_till_done()
assert acc.char_current_temperature.value == 30
assert len(broker.mock_calls) == 2
broker.reset_mock()
# Change to same state should not trigger update in HomeKit
hass.states.async_set(
temperature_entity_id,
30,
{
ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE,
},
force_update=True,
)
await hass.async_block_till_done()
assert acc.char_current_temperature.value == 30
assert len(broker.mock_calls) == 0
# Should handle unavailable state, show last known value
hass.states.async_set(
humidity_entity_id,
STATE_UNAVAILABLE,
{
ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY,
},
)
hass.states.async_set(
pm25_entity_id,
STATE_UNAVAILABLE,
{
ATTR_DEVICE_CLASS: SensorDeviceClass.PM25,
},
)
hass.states.async_set(
temperature_entity_id,
STATE_UNAVAILABLE,
{
ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE,
},
)
await hass.async_block_till_done()
assert acc.char_current_humidity.value == 60
assert acc.char_pm25_density.value == 5
assert acc.char_air_quality.value == 1
assert acc.char_current_temperature.value == 30
# Check that all goes well if we remove the linked sensors
hass.states.async_remove(humidity_entity_id)
hass.states.async_remove(pm25_entity_id)
hass.states.async_remove(temperature_entity_id)
await hass.async_block_till_done()
acc.run()
await hass.async_block_till_done()
assert len(acc.char_current_humidity.broker.mock_calls) == 0
assert len(acc.char_pm25_density.broker.mock_calls) == 0
assert len(acc.char_air_quality.broker.mock_calls) == 0
assert len(acc.char_current_temperature.broker.mock_calls) == 0
# HomeKit will show the last known values
assert acc.char_current_humidity.value == 60
assert acc.char_pm25_density.value == 5
assert acc.char_air_quality.value == 1
assert acc.char_current_temperature.value == 30
async def test_filter_maintenance_linked_sensors(
hass: HomeAssistant, hk_driver, events: list[Event]
) -> None:
"""Test that a linked filter level and filter change indicator are exposed."""
entity_id = "fan.demo"
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED,
},
)
filter_change_indicator_entity_id = "binary_sensor.demo_filter_change_indicator"
hass.states.async_set(filter_change_indicator_entity_id, STATE_OFF)
filter_life_level_entity_id = "sensor.demo_filter_life_level"
hass.states.async_set(filter_life_level_entity_id, 50)
await hass.async_block_till_done()
acc = AirPurifier(
hass,
hk_driver,
"Air Purifier",
entity_id,
1,
{
CONF_LINKED_FILTER_CHANGE_INDICATION: filter_change_indicator_entity_id,
CONF_LINKED_FILTER_LIFE_LEVEL: filter_life_level_entity_id,
},
)
hk_driver.add_accessory(acc)
assert acc.linked_filter_change_indicator_binary_sensor is not None
assert acc.char_filter_change_indication is not None
assert acc.linked_filter_life_level_sensor is not None
assert acc.char_filter_life_level is not None
acc.run()
await hass.async_block_till_done()
assert acc.char_filter_change_indication.value == FILTER_OK
assert acc.char_filter_life_level.value == 50
# Updated filter change indicator should reflect in HomeKit
broker = MagicMock()
acc.char_filter_change_indication.broker = broker
hass.states.async_set(filter_change_indicator_entity_id, STATE_ON)
await hass.async_block_till_done()
assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER
assert len(broker.mock_calls) == 2
broker.reset_mock()
# Change to same state should not trigger update in HomeKit
hass.states.async_set(
filter_change_indicator_entity_id, STATE_ON, force_update=True
)
await hass.async_block_till_done()
assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER
assert len(broker.mock_calls) == 0
# Updated filter life level should reflect in HomeKit
broker = MagicMock()
acc.char_filter_life_level.broker = broker
hass.states.async_set(filter_life_level_entity_id, 25)
await hass.async_block_till_done()
assert acc.char_filter_life_level.value == 25
assert len(broker.mock_calls) == 2
broker.reset_mock()
# Change to same state should not trigger update in HomeKit
hass.states.async_set(filter_life_level_entity_id, 25, force_update=True)
await hass.async_block_till_done()
assert acc.char_filter_life_level.value == 25
assert len(broker.mock_calls) == 0
# Should handle unavailable state, show last known value
hass.states.async_set(filter_change_indicator_entity_id, STATE_UNAVAILABLE)
hass.states.async_set(filter_life_level_entity_id, STATE_UNAVAILABLE)
await hass.async_block_till_done()
assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER
assert acc.char_filter_life_level.value == 25
# Check that all goes well if we remove the linked sensors
hass.states.async_remove(filter_change_indicator_entity_id)
hass.states.async_remove(filter_life_level_entity_id)
await hass.async_block_till_done()
acc.run()
await hass.async_block_till_done()
assert len(acc.char_filter_change_indication.broker.mock_calls) == 0
assert len(acc.char_filter_life_level.broker.mock_calls) == 0
# HomeKit will show the last known values
assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER
assert acc.char_filter_life_level.value == 25
async def test_filter_maintenance_only_change_indicator_sensor(
hass: HomeAssistant, hk_driver, events: list[Event]
) -> None:
"""Test that a linked filter change indicator is exposed."""
entity_id = "fan.demo"
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED,
},
)
filter_change_indicator_entity_id = "binary_sensor.demo_filter_change_indicator"
hass.states.async_set(filter_change_indicator_entity_id, STATE_OFF)
await hass.async_block_till_done()
acc = AirPurifier(
hass,
hk_driver,
"Air Purifier",
entity_id,
1,
{
CONF_LINKED_FILTER_CHANGE_INDICATION: filter_change_indicator_entity_id,
},
)
hk_driver.add_accessory(acc)
assert acc.linked_filter_change_indicator_binary_sensor is not None
assert acc.char_filter_change_indication is not None
assert acc.linked_filter_life_level_sensor is None
acc.run()
await hass.async_block_till_done()
assert acc.char_filter_change_indication.value == FILTER_OK
hass.states.async_set(filter_change_indicator_entity_id, STATE_ON)
await hass.async_block_till_done()
assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER
async def test_filter_life_level_linked_sensors(
hass: HomeAssistant, hk_driver, events: list[Event]
) -> None:
"""Test that a linked filter life level sensor exposed."""
entity_id = "fan.demo"
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED,
},
)
filter_life_level_entity_id = "sensor.demo_filter_life_level"
hass.states.async_set(filter_life_level_entity_id, 50)
await hass.async_block_till_done()
acc = AirPurifier(
hass,
hk_driver,
"Air Purifier",
entity_id,
1,
{
CONF_LINKED_FILTER_LIFE_LEVEL: filter_life_level_entity_id,
},
)
hk_driver.add_accessory(acc)
assert acc.linked_filter_change_indicator_binary_sensor is None
assert (
acc.char_filter_change_indication is not None
) # calculated based on filter life level
assert acc.linked_filter_life_level_sensor is not None
assert acc.char_filter_life_level is not None
acc.run()
await hass.async_block_till_done()
assert acc.char_filter_change_indication.value == FILTER_OK
assert acc.char_filter_life_level.value == 50
hass.states.async_set(
filter_life_level_entity_id, THRESHOLD_FILTER_CHANGE_NEEDED - 1
)
await hass.async_block_till_done()
assert acc.char_filter_life_level.value == THRESHOLD_FILTER_CHANGE_NEEDED - 1
assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER

View File

@ -128,6 +128,7 @@ def test_validate_entity_config() -> None:
}
},
{"switch.test": {CONF_TYPE: "invalid_type"}},
{"fan.test": {CONF_TYPE: "invalid_type"}},
]
for conf in configs: