mirror of
https://github.com/home-assistant/core.git
synced 2025-07-10 06:47:09 +00:00
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:
parent
1b66278a68
commit
9fe306f056
@ -31,6 +31,7 @@ from homeassistant.components.device_automation.trigger import (
|
|||||||
async_validate_trigger_config,
|
async_validate_trigger_config,
|
||||||
)
|
)
|
||||||
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventDeviceClass
|
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.http import KEY_HASS, HomeAssistantView
|
||||||
from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN
|
from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN
|
||||||
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
|
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
|
||||||
@ -49,6 +50,7 @@ from homeassistant.const import (
|
|||||||
CONF_IP_ADDRESS,
|
CONF_IP_ADDRESS,
|
||||||
CONF_NAME,
|
CONF_NAME,
|
||||||
CONF_PORT,
|
CONF_PORT,
|
||||||
|
CONF_TYPE,
|
||||||
EVENT_HOMEASSISTANT_STOP,
|
EVENT_HOMEASSISTANT_STOP,
|
||||||
SERVICE_RELOAD,
|
SERVICE_RELOAD,
|
||||||
)
|
)
|
||||||
@ -83,6 +85,7 @@ from homeassistant.loader import IntegrationNotFound, async_get_integration
|
|||||||
from homeassistant.util.async_ import create_eager_task
|
from homeassistant.util.async_ import create_eager_task
|
||||||
|
|
||||||
from . import ( # noqa: F401
|
from . import ( # noqa: F401
|
||||||
|
type_air_purifiers,
|
||||||
type_cameras,
|
type_cameras,
|
||||||
type_covers,
|
type_covers,
|
||||||
type_fans,
|
type_fans,
|
||||||
@ -113,6 +116,8 @@ from .const import (
|
|||||||
CONF_LINKED_DOORBELL_SENSOR,
|
CONF_LINKED_DOORBELL_SENSOR,
|
||||||
CONF_LINKED_HUMIDITY_SENSOR,
|
CONF_LINKED_HUMIDITY_SENSOR,
|
||||||
CONF_LINKED_MOTION_SENSOR,
|
CONF_LINKED_MOTION_SENSOR,
|
||||||
|
CONF_LINKED_PM25_SENSOR,
|
||||||
|
CONF_LINKED_TEMPERATURE_SENSOR,
|
||||||
CONFIG_OPTIONS,
|
CONFIG_OPTIONS,
|
||||||
DEFAULT_EXCLUDE_ACCESSORY_MODE,
|
DEFAULT_EXCLUDE_ACCESSORY_MODE,
|
||||||
DEFAULT_HOMEKIT_MODE,
|
DEFAULT_HOMEKIT_MODE,
|
||||||
@ -126,6 +131,7 @@ from .const import (
|
|||||||
SERVICE_HOMEKIT_UNPAIR,
|
SERVICE_HOMEKIT_UNPAIR,
|
||||||
SHUTDOWN_TIMEOUT,
|
SHUTDOWN_TIMEOUT,
|
||||||
SIGNAL_RELOAD_ENTITIES,
|
SIGNAL_RELOAD_ENTITIES,
|
||||||
|
TYPE_AIR_PURIFIER,
|
||||||
)
|
)
|
||||||
from .iidmanager import AccessoryIIDStorage
|
from .iidmanager import AccessoryIIDStorage
|
||||||
from .models import HomeKitConfigEntry, HomeKitEntryData
|
from .models import HomeKitConfigEntry, HomeKitEntryData
|
||||||
@ -169,6 +175,8 @@ MOTION_EVENT_SENSOR = (EVENT_DOMAIN, EventDeviceClass.MOTION)
|
|||||||
MOTION_SENSOR = (BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass.MOTION)
|
MOTION_SENSOR = (BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass.MOTION)
|
||||||
DOORBELL_EVENT_SENSOR = (EVENT_DOMAIN, EventDeviceClass.DOORBELL)
|
DOORBELL_EVENT_SENSOR = (EVENT_DOMAIN, EventDeviceClass.DOORBELL)
|
||||||
HUMIDITY_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.HUMIDITY)
|
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(
|
def _has_all_unique_names_and_ports(
|
||||||
@ -1136,6 +1144,21 @@ class HomeKit:
|
|||||||
CONF_LINKED_DOORBELL_SENSOR, doorbell_event_entity_id
|
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 (
|
if domain == HUMIDIFIER_DOMAIN and (
|
||||||
current_humidity_sensor_entity_id := lookup.get(HUMIDITY_SENSOR)
|
current_humidity_sensor_entity_id := lookup.get(HUMIDITY_SENSOR)
|
||||||
):
|
):
|
||||||
|
@ -85,6 +85,8 @@ from .const import (
|
|||||||
SERV_ACCESSORY_INFO,
|
SERV_ACCESSORY_INFO,
|
||||||
SERV_BATTERY_SERVICE,
|
SERV_BATTERY_SERVICE,
|
||||||
SIGNAL_RELOAD_ENTITIES,
|
SIGNAL_RELOAD_ENTITIES,
|
||||||
|
TYPE_AIR_PURIFIER,
|
||||||
|
TYPE_FAN,
|
||||||
TYPE_FAUCET,
|
TYPE_FAUCET,
|
||||||
TYPE_OUTLET,
|
TYPE_OUTLET,
|
||||||
TYPE_SHOWER,
|
TYPE_SHOWER,
|
||||||
@ -112,6 +114,10 @@ SWITCH_TYPES = {
|
|||||||
TYPE_SWITCH: "Switch",
|
TYPE_SWITCH: "Switch",
|
||||||
TYPE_VALVE: "ValveSwitch",
|
TYPE_VALVE: "ValveSwitch",
|
||||||
}
|
}
|
||||||
|
FAN_TYPES = {
|
||||||
|
TYPE_AIR_PURIFIER: "AirPurifier",
|
||||||
|
TYPE_FAN: "Fan",
|
||||||
|
}
|
||||||
TYPES: Registry[str, type[HomeAccessory]] = Registry()
|
TYPES: Registry[str, type[HomeAccessory]] = Registry()
|
||||||
|
|
||||||
RELOAD_ON_CHANGE_ATTRS = (
|
RELOAD_ON_CHANGE_ATTRS = (
|
||||||
@ -178,7 +184,10 @@ def get_accessory( # noqa: C901
|
|||||||
a_type = "WindowCovering"
|
a_type = "WindowCovering"
|
||||||
|
|
||||||
elif state.domain == "fan":
|
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":
|
elif state.domain == "humidifier":
|
||||||
a_type = "HumidifierDehumidifier"
|
a_type = "HumidifierDehumidifier"
|
||||||
|
@ -49,9 +49,13 @@ CONF_EXCLUDE_ACCESSORY_MODE = "exclude_accessory_mode"
|
|||||||
CONF_LINKED_BATTERY_SENSOR = "linked_battery_sensor"
|
CONF_LINKED_BATTERY_SENSOR = "linked_battery_sensor"
|
||||||
CONF_LINKED_BATTERY_CHARGING_SENSOR = "linked_battery_charging_sensor"
|
CONF_LINKED_BATTERY_CHARGING_SENSOR = "linked_battery_charging_sensor"
|
||||||
CONF_LINKED_DOORBELL_SENSOR = "linked_doorbell_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_MOTION_SENSOR = "linked_motion_sensor"
|
||||||
CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor"
|
CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor"
|
||||||
CONF_LINKED_OBSTRUCTION_SENSOR = "linked_obstruction_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_LOW_BATTERY_THRESHOLD = "low_battery_threshold"
|
||||||
CONF_MAX_FPS = "max_fps"
|
CONF_MAX_FPS = "max_fps"
|
||||||
CONF_MAX_HEIGHT = "max_height"
|
CONF_MAX_HEIGHT = "max_height"
|
||||||
@ -120,12 +124,15 @@ TYPE_SHOWER = "shower"
|
|||||||
TYPE_SPRINKLER = "sprinkler"
|
TYPE_SPRINKLER = "sprinkler"
|
||||||
TYPE_SWITCH = "switch"
|
TYPE_SWITCH = "switch"
|
||||||
TYPE_VALVE = "valve"
|
TYPE_VALVE = "valve"
|
||||||
|
TYPE_FAN = "fan"
|
||||||
|
TYPE_AIR_PURIFIER = "air_purifier"
|
||||||
|
|
||||||
# #### Categories ####
|
# #### Categories ####
|
||||||
CATEGORY_RECEIVER = 34
|
CATEGORY_RECEIVER = 34
|
||||||
|
|
||||||
# #### Services ####
|
# #### Services ####
|
||||||
SERV_ACCESSORY_INFO = "AccessoryInformation"
|
SERV_ACCESSORY_INFO = "AccessoryInformation"
|
||||||
|
SERV_AIR_PURIFIER = "AirPurifier"
|
||||||
SERV_AIR_QUALITY_SENSOR = "AirQualitySensor"
|
SERV_AIR_QUALITY_SENSOR = "AirQualitySensor"
|
||||||
SERV_BATTERY_SERVICE = "BatteryService"
|
SERV_BATTERY_SERVICE = "BatteryService"
|
||||||
SERV_CAMERA_RTP_STREAM_MANAGEMENT = "CameraRTPStreamManagement"
|
SERV_CAMERA_RTP_STREAM_MANAGEMENT = "CameraRTPStreamManagement"
|
||||||
@ -135,6 +142,7 @@ SERV_CONTACT_SENSOR = "ContactSensor"
|
|||||||
SERV_DOOR = "Door"
|
SERV_DOOR = "Door"
|
||||||
SERV_DOORBELL = "Doorbell"
|
SERV_DOORBELL = "Doorbell"
|
||||||
SERV_FANV2 = "Fanv2"
|
SERV_FANV2 = "Fanv2"
|
||||||
|
SERV_FILTER_MAINTENANCE = "FilterMaintenance"
|
||||||
SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener"
|
SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener"
|
||||||
SERV_HUMIDIFIER_DEHUMIDIFIER = "HumidifierDehumidifier"
|
SERV_HUMIDIFIER_DEHUMIDIFIER = "HumidifierDehumidifier"
|
||||||
SERV_HUMIDITY_SENSOR = "HumiditySensor"
|
SERV_HUMIDITY_SENSOR = "HumiditySensor"
|
||||||
@ -181,6 +189,7 @@ CHAR_CONFIGURED_NAME = "ConfiguredName"
|
|||||||
CHAR_CONTACT_SENSOR_STATE = "ContactSensorState"
|
CHAR_CONTACT_SENSOR_STATE = "ContactSensorState"
|
||||||
CHAR_COOLING_THRESHOLD_TEMPERATURE = "CoolingThresholdTemperature"
|
CHAR_COOLING_THRESHOLD_TEMPERATURE = "CoolingThresholdTemperature"
|
||||||
CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = "CurrentAmbientLightLevel"
|
CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = "CurrentAmbientLightLevel"
|
||||||
|
CHAR_CURRENT_AIR_PURIFIER_STATE = "CurrentAirPurifierState"
|
||||||
CHAR_CURRENT_DOOR_STATE = "CurrentDoorState"
|
CHAR_CURRENT_DOOR_STATE = "CurrentDoorState"
|
||||||
CHAR_CURRENT_FAN_STATE = "CurrentFanState"
|
CHAR_CURRENT_FAN_STATE = "CurrentFanState"
|
||||||
CHAR_CURRENT_HEATING_COOLING = "CurrentHeatingCoolingState"
|
CHAR_CURRENT_HEATING_COOLING = "CurrentHeatingCoolingState"
|
||||||
@ -192,6 +201,8 @@ CHAR_CURRENT_TEMPERATURE = "CurrentTemperature"
|
|||||||
CHAR_CURRENT_TILT_ANGLE = "CurrentHorizontalTiltAngle"
|
CHAR_CURRENT_TILT_ANGLE = "CurrentHorizontalTiltAngle"
|
||||||
CHAR_CURRENT_VISIBILITY_STATE = "CurrentVisibilityState"
|
CHAR_CURRENT_VISIBILITY_STATE = "CurrentVisibilityState"
|
||||||
CHAR_DEHUMIDIFIER_THRESHOLD_HUMIDITY = "RelativeHumidityDehumidifierThreshold"
|
CHAR_DEHUMIDIFIER_THRESHOLD_HUMIDITY = "RelativeHumidityDehumidifierThreshold"
|
||||||
|
CHAR_FILTER_CHANGE_INDICATION = "FilterChangeIndication"
|
||||||
|
CHAR_FILTER_LIFE_LEVEL = "FilterLifeLevel"
|
||||||
CHAR_FIRMWARE_REVISION = "FirmwareRevision"
|
CHAR_FIRMWARE_REVISION = "FirmwareRevision"
|
||||||
CHAR_HARDWARE_REVISION = "HardwareRevision"
|
CHAR_HARDWARE_REVISION = "HardwareRevision"
|
||||||
CHAR_HEATING_THRESHOLD_TEMPERATURE = "HeatingThresholdTemperature"
|
CHAR_HEATING_THRESHOLD_TEMPERATURE = "HeatingThresholdTemperature"
|
||||||
@ -229,6 +240,7 @@ CHAR_SMOKE_DETECTED = "SmokeDetected"
|
|||||||
CHAR_STATUS_LOW_BATTERY = "StatusLowBattery"
|
CHAR_STATUS_LOW_BATTERY = "StatusLowBattery"
|
||||||
CHAR_STREAMING_STRATUS = "StreamingStatus"
|
CHAR_STREAMING_STRATUS = "StreamingStatus"
|
||||||
CHAR_SWING_MODE = "SwingMode"
|
CHAR_SWING_MODE = "SwingMode"
|
||||||
|
CHAR_TARGET_AIR_PURIFIER_STATE = "TargetAirPurifierState"
|
||||||
CHAR_TARGET_DOOR_STATE = "TargetDoorState"
|
CHAR_TARGET_DOOR_STATE = "TargetDoorState"
|
||||||
CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState"
|
CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState"
|
||||||
CHAR_TARGET_POSITION = "TargetPosition"
|
CHAR_TARGET_POSITION = "TargetPosition"
|
||||||
@ -256,6 +268,7 @@ PROP_VALID_VALUES = "ValidValues"
|
|||||||
# #### Thresholds ####
|
# #### Thresholds ####
|
||||||
THRESHOLD_CO = 25
|
THRESHOLD_CO = 25
|
||||||
THRESHOLD_CO2 = 1000
|
THRESHOLD_CO2 = 1000
|
||||||
|
THRESHOLD_FILTER_CHANGE_NEEDED = 10
|
||||||
|
|
||||||
# #### Default values ####
|
# #### Default values ####
|
||||||
DEFAULT_MIN_TEMP_WATER_HEATER = 40 # °C
|
DEFAULT_MIN_TEMP_WATER_HEATER = 40 # °C
|
||||||
|
469
homeassistant/components/homekit/type_air_purifiers.py
Normal file
469
homeassistant/components/homekit/type_air_purifiers.py
Normal 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()})
|
@ -4,6 +4,7 @@ import logging
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pyhap.const import CATEGORY_FAN
|
from pyhap.const import CATEGORY_FAN
|
||||||
|
from pyhap.service import Service
|
||||||
|
|
||||||
from homeassistant.components.fan import (
|
from homeassistant.components.fan import (
|
||||||
ATTR_DIRECTION,
|
ATTR_DIRECTION,
|
||||||
@ -56,9 +57,9 @@ class Fan(HomeAccessory):
|
|||||||
Currently supports: state, speed, oscillate, direction.
|
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."""
|
"""Initialize a new Fan accessory object."""
|
||||||
super().__init__(*args, category=CATEGORY_FAN)
|
super().__init__(*args, category=category)
|
||||||
self.chars: list[str] = []
|
self.chars: list[str] = []
|
||||||
state = self.hass.states.get(self.entity_id)
|
state = self.hass.states.get(self.entity_id)
|
||||||
assert state
|
assert state
|
||||||
@ -79,12 +80,8 @@ class Fan(HomeAccessory):
|
|||||||
self.chars.append(CHAR_SWING_MODE)
|
self.chars.append(CHAR_SWING_MODE)
|
||||||
if features & FanEntityFeature.SET_SPEED:
|
if features & FanEntityFeature.SET_SPEED:
|
||||||
self.chars.append(CHAR_ROTATION_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)
|
serv_fan = self.create_services()
|
||||||
self.set_primary_service(serv_fan)
|
|
||||||
self.char_active = serv_fan.configure_char(CHAR_ACTIVE, value=0)
|
|
||||||
|
|
||||||
self.char_direction = None
|
self.char_direction = None
|
||||||
self.char_speed = None
|
self.char_speed = None
|
||||||
@ -107,13 +104,21 @@ class Fan(HomeAccessory):
|
|||||||
properties={PROP_MIN_STEP: percentage_step},
|
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(
|
self.char_target_fan_state = serv_fan.configure_char(
|
||||||
CHAR_TARGET_FAN_STATE,
|
CHAR_TARGET_FAN_STATE,
|
||||||
value=0,
|
value=0,
|
||||||
)
|
)
|
||||||
elif self.preset_modes:
|
elif self.preset_modes:
|
||||||
for preset_mode in 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(
|
preset_serv = self.add_preload_service(
|
||||||
SERV_SWITCH, CHAR_NAME, unique_id=preset_mode
|
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:
|
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(
|
self.preset_mode_chars[preset_mode] = preset_serv.configure_char(
|
||||||
CHAR_ON,
|
CHAR_ON,
|
||||||
@ -137,10 +142,27 @@ class Fan(HomeAccessory):
|
|||||||
if CHAR_SWING_MODE in self.chars:
|
if CHAR_SWING_MODE in self.chars:
|
||||||
self.char_swing = serv_fan.configure_char(CHAR_SWING_MODE, value=0)
|
self.char_swing = serv_fan.configure_char(CHAR_SWING_MODE, value=0)
|
||||||
self.async_update_state(state)
|
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:
|
def create_services(self) -> Service:
|
||||||
_LOGGER.debug("Fan _set_chars: %s", char_values)
|
"""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_ACTIVE in char_values:
|
||||||
if char_values[CHAR_ACTIVE]:
|
if char_values[CHAR_ACTIVE]:
|
||||||
# If the device supports set speed we
|
# If the device supports set speed we
|
||||||
|
@ -62,9 +62,13 @@ from .const import (
|
|||||||
CONF_LINKED_BATTERY_CHARGING_SENSOR,
|
CONF_LINKED_BATTERY_CHARGING_SENSOR,
|
||||||
CONF_LINKED_BATTERY_SENSOR,
|
CONF_LINKED_BATTERY_SENSOR,
|
||||||
CONF_LINKED_DOORBELL_SENSOR,
|
CONF_LINKED_DOORBELL_SENSOR,
|
||||||
|
CONF_LINKED_FILTER_CHANGE_INDICATION,
|
||||||
|
CONF_LINKED_FILTER_LIFE_LEVEL,
|
||||||
CONF_LINKED_HUMIDITY_SENSOR,
|
CONF_LINKED_HUMIDITY_SENSOR,
|
||||||
CONF_LINKED_MOTION_SENSOR,
|
CONF_LINKED_MOTION_SENSOR,
|
||||||
CONF_LINKED_OBSTRUCTION_SENSOR,
|
CONF_LINKED_OBSTRUCTION_SENSOR,
|
||||||
|
CONF_LINKED_PM25_SENSOR,
|
||||||
|
CONF_LINKED_TEMPERATURE_SENSOR,
|
||||||
CONF_LOW_BATTERY_THRESHOLD,
|
CONF_LOW_BATTERY_THRESHOLD,
|
||||||
CONF_MAX_FPS,
|
CONF_MAX_FPS,
|
||||||
CONF_MAX_HEIGHT,
|
CONF_MAX_HEIGHT,
|
||||||
@ -98,6 +102,8 @@ from .const import (
|
|||||||
FEATURE_PLAY_STOP,
|
FEATURE_PLAY_STOP,
|
||||||
FEATURE_TOGGLE_MUTE,
|
FEATURE_TOGGLE_MUTE,
|
||||||
MAX_NAME_LENGTH,
|
MAX_NAME_LENGTH,
|
||||||
|
TYPE_AIR_PURIFIER,
|
||||||
|
TYPE_FAN,
|
||||||
TYPE_FAUCET,
|
TYPE_FAUCET,
|
||||||
TYPE_OUTLET,
|
TYPE_OUTLET,
|
||||||
TYPE_SHOWER,
|
TYPE_SHOWER,
|
||||||
@ -187,6 +193,27 @@ HUMIDIFIER_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
|||||||
{vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN)}
|
{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(
|
COVER_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
||||||
{
|
{
|
||||||
vol.Optional(CONF_LINKED_OBSTRUCTION_SENSOR): cv.entity_domain(
|
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":
|
elif domain == "cover":
|
||||||
config = COVER_SCHEMA(config)
|
config = COVER_SCHEMA(config)
|
||||||
|
|
||||||
|
elif domain == "fan":
|
||||||
|
config = FAN_SCHEMA(config)
|
||||||
|
|
||||||
elif domain == "sensor":
|
elif domain == "sensor":
|
||||||
config = SENSOR_SCHEMA(config)
|
config = SENSOR_SCHEMA(config)
|
||||||
|
|
||||||
|
@ -6,11 +6,13 @@ import pytest
|
|||||||
|
|
||||||
from homeassistant.components.climate import ClimateEntityFeature
|
from homeassistant.components.climate import ClimateEntityFeature
|
||||||
from homeassistant.components.cover import CoverEntityFeature
|
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.accessories import TYPES, get_accessory
|
||||||
from homeassistant.components.homekit.const import (
|
from homeassistant.components.homekit.const import (
|
||||||
ATTR_INTEGRATION,
|
ATTR_INTEGRATION,
|
||||||
CONF_FEATURE_LIST,
|
CONF_FEATURE_LIST,
|
||||||
FEATURE_ON_OFF,
|
FEATURE_ON_OFF,
|
||||||
|
TYPE_FAN,
|
||||||
TYPE_FAUCET,
|
TYPE_FAUCET,
|
||||||
TYPE_OUTLET,
|
TYPE_OUTLET,
|
||||||
TYPE_SHOWER,
|
TYPE_SHOWER,
|
||||||
@ -350,6 +352,23 @@ def test_type_switches(type_name, entity_id, state, attrs, config) -> None:
|
|||||||
assert mock_type.called
|
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(
|
@pytest.mark.parametrize(
|
||||||
("type_name", "entity_id", "state", "attrs"),
|
("type_name", "entity_id", "state", "attrs"),
|
||||||
[
|
[
|
||||||
|
@ -21,6 +21,7 @@ from homeassistant.components.homekit import (
|
|||||||
STATUS_RUNNING,
|
STATUS_RUNNING,
|
||||||
STATUS_STOPPED,
|
STATUS_STOPPED,
|
||||||
STATUS_WAIT,
|
STATUS_WAIT,
|
||||||
|
TYPE_AIR_PURIFIER,
|
||||||
HomeKit,
|
HomeKit,
|
||||||
)
|
)
|
||||||
from homeassistant.components.homekit.accessories import HomeBridge
|
from homeassistant.components.homekit.accessories import HomeBridge
|
||||||
@ -51,6 +52,7 @@ from homeassistant.const import (
|
|||||||
ATTR_DEVICE_ID,
|
ATTR_DEVICE_ID,
|
||||||
ATTR_ENTITY_ID,
|
ATTR_ENTITY_ID,
|
||||||
ATTR_UNIT_OF_MEASUREMENT,
|
ATTR_UNIT_OF_MEASUREMENT,
|
||||||
|
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||||
CONF_NAME,
|
CONF_NAME,
|
||||||
CONF_PORT,
|
CONF_PORT,
|
||||||
EVENT_HOMEASSISTANT_STARTED,
|
EVENT_HOMEASSISTANT_STARTED,
|
||||||
@ -58,6 +60,7 @@ from homeassistant.const import (
|
|||||||
SERVICE_RELOAD,
|
SERVICE_RELOAD,
|
||||||
STATE_ON,
|
STATE_ON,
|
||||||
EntityCategory,
|
EntityCategory,
|
||||||
|
UnitOfTemperature,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, State
|
from homeassistant.core import HomeAssistant, State
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
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")
|
@pytest.mark.usefixtures("mock_async_zeroconf")
|
||||||
async def test_reload(hass: HomeAssistant) -> None:
|
async def test_reload(hass: HomeAssistant) -> None:
|
||||||
"""Test we can reload from yaml."""
|
"""Test we can reload from yaml."""
|
||||||
|
702
tests/components/homekit/test_type_air_purifiers.py
Normal file
702
tests/components/homekit/test_type_air_purifiers.py
Normal 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
|
@ -128,6 +128,7 @@ def test_validate_entity_config() -> None:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{"switch.test": {CONF_TYPE: "invalid_type"}},
|
{"switch.test": {CONF_TYPE: "invalid_type"}},
|
||||||
|
{"fan.test": {CONF_TYPE: "invalid_type"}},
|
||||||
]
|
]
|
||||||
|
|
||||||
for conf in configs:
|
for conf in configs:
|
||||||
|
Loading…
x
Reference in New Issue
Block a user