From 208cc3df9a987e8985879b27f38cc19475646d94 Mon Sep 17 00:00:00 2001 From: Maurizio Zucchelli Date: Sun, 18 Jul 2021 16:22:21 +0100 Subject: [PATCH 1/4] style: configure isort Configuration copied from homeassistant. --- .../philips_airpurifier_coap/fan.py | 46 ++++++++----------- pyproject.toml | 14 ++++++ 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/custom_components/philips_airpurifier_coap/fan.py b/custom_components/philips_airpurifier_coap/fan.py index 4419755c..d4f3721c 100644 --- a/custom_components/philips_airpurifier_coap/fan.py +++ b/custom_components/philips_airpurifier_coap/fan.py @@ -1,21 +1,13 @@ """Philips Air Purifier & Humidifier""" import asyncio -import logging from datetime import timedelta -from typing import ( - Any, - Callable, - Dict, - List, - Optional, - Union, -) +import logging +from typing import Any, Callable, Dict, List, Optional, Union -from homeassistant.components.fan import ( - FanEntity, - PLATFORM_SCHEMA, - SUPPORT_PRESET_MODE, -) +from aioairctrl import CoAPClient +import voluptuous as vol + +from homeassistant.components.fan import PLATFORM_SCHEMA, SUPPORT_PRESET_MODE, FanEntity from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.const import ( ATTR_ENTITY_ID, @@ -32,8 +24,6 @@ DiscoveryInfoType, HomeAssistantType, ) -import voluptuous as vol -from aioairctrl import CoAPClient from .const import ( ATTR_AIR_QUALITY_INDEX, @@ -124,23 +114,23 @@ PHILIPS_TYPE, PHILIPS_WATER_LEVEL, PHILIPS_WIFI_VERSION, - SERVICE_SET_CHILD_LOCK_OFF, - SERVICE_SET_CHILD_LOCK_ON, - SERVICE_SET_DISPLAY_BACKLIGHT_OFF, - SERVICE_SET_DISPLAY_BACKLIGHT_ON, - SERVICE_SET_FUNCTION, - SERVICE_SET_HUMIDITY_TARGET, - SERVICE_SET_LIGHT_BRIGHTNESS, - PRESET_MODE_SPEED_1, - PRESET_MODE_SPEED_2, - PRESET_MODE_SPEED_3, PRESET_MODE_ALLERGEN, PRESET_MODE_AUTO, PRESET_MODE_BACTERIA, PRESET_MODE_GENTLE, PRESET_MODE_NIGHT, PRESET_MODE_SLEEP, + PRESET_MODE_SPEED_1, + PRESET_MODE_SPEED_2, + PRESET_MODE_SPEED_3, PRESET_MODE_TURBO, + SERVICE_SET_CHILD_LOCK_OFF, + SERVICE_SET_CHILD_LOCK_ON, + SERVICE_SET_DISPLAY_BACKLIGHT_OFF, + SERVICE_SET_DISPLAY_BACKLIGHT_ON, + SERVICE_SET_FUNCTION, + SERVICE_SET_HUMIDITY_TARGET, + SERVICE_SET_LIGHT_BRIGHTNESS, ) _LOGGER = logging.getLogger(__name__) @@ -625,6 +615,7 @@ class PhilipsAC2889(PhilipsGenericCoAPFan): PRESET_MODE_TURBO: {PHILIPS_POWER: "1", PHILIPS_MODE: "M", PHILIPS_SPEED: "t"}, } + class PhilipsAC2939(PhilipsTVOCMixin, PhilipsGenericCoAPFan): AVAILABLE_PRESET_MODES = { PRESET_MODE_AUTO: {PHILIPS_POWER: "1", PHILIPS_MODE: "AG"}, @@ -633,6 +624,7 @@ class PhilipsAC2939(PhilipsTVOCMixin, PhilipsGenericCoAPFan): PRESET_MODE_TURBO: {PHILIPS_POWER: "1", PHILIPS_MODE: "T"}, } + class PhilipsAC2958(PhilipsGenericCoAPFan): AVAILABLE_PRESET_MODES = { PRESET_MODE_AUTO: {PHILIPS_POWER: "1", PHILIPS_MODE: "AG"}, @@ -641,6 +633,7 @@ class PhilipsAC2958(PhilipsGenericCoAPFan): PRESET_MODE_TURBO: {PHILIPS_POWER: "1", PHILIPS_MODE: "T"}, } + class PhilipsAC3033(PhilipsTVOCMixin, PhilipsGenericCoAPFan): AVAILABLE_PRESET_MODES = { PRESET_MODE_SPEED_1: {PHILIPS_POWER: "1", PHILIPS_MODE: "M", PHILIPS_SPEED: "1"}, @@ -650,6 +643,7 @@ class PhilipsAC3033(PhilipsTVOCMixin, PhilipsGenericCoAPFan): PRESET_MODE_TURBO: {PHILIPS_POWER: "1", PHILIPS_MODE: "T", PHILIPS_SPEED: "t"}, } + class PhilipsAC3059(PhilipsTVOCMixin, PhilipsGenericCoAPFan): AVAILABLE_PRESET_MODES = { PRESET_MODE_SPEED_1: {PHILIPS_POWER: "1", PHILIPS_MODE: "M", PHILIPS_SPEED: "1"}, diff --git a/pyproject.toml b/pyproject.toml index c265102f..530c688e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,17 @@ [tool.black] line-length = 100 target-version = ['py36', 'py37', 'py38'] + +[tool.isort] +# https://github.com/PyCQA/isort/wiki/isort-Settings +profile = "black" +# will group `import x` and `from x import` of the same module. +force_sort_within_sections = true +known_first_party = [ + "homeassistant", + "tests", +] +forced_separate = [ + "tests", +] +combine_as_imports = true \ No newline at end of file From 7f0408e03d55df7d57356f84d4af5c1d802b6655 Mon Sep 17 00:00:00 2001 From: Maurizio Zucchelli Date: Sun, 18 Jul 2021 17:01:03 +0100 Subject: [PATCH 2/4] refactor!: extract the connection logic Should allow to support multiple platforms by sharing the same connection. BREAKING CHANGE: the configuration is now specified as its own group instead of inside the `fan` platform. --- README.md | 15 +- .../philips_airpurifier_coap/__init__.py | 191 ++++++++++++++++++ .../philips_airpurifier_coap/const.py | 5 +- .../philips_airpurifier_coap/fan.py | 142 +++++-------- 4 files changed, 251 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index 09e86976..cf98562d 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,7 @@ Add `https://github.com/betaboon/philips-airpurifier.git` as custom-repository i Add the following to your `configuration.yaml`: ```yaml -fan: - platform: philips_airpurifier_coap +philips_airpurifier_coap: host: 192.168.0.17 model: ac4236 ``` @@ -32,17 +31,14 @@ fan: Add the following to your `configuration.yaml`: ```yaml -fan: - - platform: philips_airpurifier_coap - host: 192.168.0.100 +philips_airpurifier_coap: + - host: 192.168.0.100 model: ac1214 - - platform: philips_airpurifier_coap - host: 192.168.0.101 + - host: 192.168.0.101 model: ac1214 - - platform: philips_airpurifier_coap - host: 192.168.0.102 + - host: 192.168.0.102 model: ac1214 ``` @@ -52,7 +48,6 @@ fan: ## Configuration variables: Field | Value | Necessity | Description --- | --- | --- | --- -platform | `philips_airpurifier_coap` | *Required* | The platform name. host | 192.168.0.17 | *Required* | IP address of the Purifier. model | ac4236 | *Required* | Model of the Purifier. name | Philips Air Purifier | Optional | Name of the Fan. diff --git a/custom_components/philips_airpurifier_coap/__init__.py b/custom_components/philips_airpurifier_coap/__init__.py index 8b137891..c309a812 100644 --- a/custom_components/philips_airpurifier_coap/__init__.py +++ b/custom_components/philips_airpurifier_coap/__init__.py @@ -1 +1,192 @@ +"""Support for Philips AirPurifier with CoAP.""" +from __future__ import annotations +import asyncio +from asyncio.tasks import Task +import logging +from typing import Any, Callable + +from aioairctrl import CoAPClient +import voluptuous as vol + +from homeassistant.const import CONF_HOST, CONF_ICON, CONF_NAME +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType + +from .const import ( + CONF_MODEL, + DATA_KEY_CLIENT, + DATA_KEY_COORDINATOR, + DEFAULT_ICON, + DEFAULT_NAME, + DOMAIN, + MODEL_AC1214, + MODEL_AC2729, + MODEL_AC2889, + MODEL_AC2939, + MODEL_AC2958, + MODEL_AC3033, + MODEL_AC3059, + MODEL_AC3829, + MODEL_AC3858, + MODEL_AC4236, +) + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.All( + cv.ensure_list, + [ + vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_MODEL): vol.In( + [ + MODEL_AC1214, + MODEL_AC2729, + MODEL_AC2889, + MODEL_AC2939, + MODEL_AC2958, + MODEL_AC3033, + MODEL_AC3059, + MODEL_AC3829, + MODEL_AC3858, + MODEL_AC4236, + ] + ), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_ICON, default=DEFAULT_ICON): cv.icon, + }, + ) + ], + ) + }, + extra=vol.ALLOW_EXTRA, +) + +PLATFORMS = ["fan"] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Philips AirPurifier integration.""" + hass.data[DOMAIN] = {} + + async def async_setup_air_purifier(conf: ConfigType): + host = conf[CONF_HOST] + + _LOGGER.debug("Setting up %s integration with %s", DOMAIN, host) + + try: + client = await CoAPClient.create(host) + except Exception as ex: + _LOGGER.warning(r"Failed to connect: %s", ex) + raise ConfigEntryNotReady from ex + + coordinator = Coordinator(client) + + hass.data[DOMAIN][host] = { + DATA_KEY_CLIENT: client, + DATA_KEY_COORDINATOR: coordinator, + } + + await coordinator.async_first_refresh() + + for platform in PLATFORMS: + hass.async_create_task( + discovery.async_load_platform(hass, platform, DOMAIN, conf, config) + ) + + tasks = [async_setup_air_purifier(conf) for conf in config[DOMAIN]] + if tasks: + await asyncio.wait(tasks) + + return True + + +class Coordinator: + def __init__(self, client: CoAPClient) -> None: + self.client = client + + self.status: dict[str, Any] = None + + self._listeners: list[CALLBACK_TYPE] = [] + self._task: Task | None = None + + async def async_first_refresh(self) -> None: + try: + self.status = await self.client.get_status() + except Exception as ex: + raise ConfigEntryNotReady from ex + + @callback + def async_add_listener(self, update_callback: CALLBACK_TYPE) -> Callable[[], None]: + """Listen for data updates.""" + start_observing = not self._listeners + + self._listeners.append(update_callback) + + if start_observing: + self._start_observing() + + @callback + def remove_listener() -> None: + """Remove update listener.""" + self.async_remove_listener(update_callback) + + return remove_listener + + @callback + def async_remove_listener(self, update_callback) -> None: + """Remove data update.""" + self._listeners.remove(update_callback) + + if not self._listeners and self._task: + self._task.cancel() + self._task = None + + async def _async_observe_status(self) -> None: + async for status in self.client.observe_status(): + self.status = status + for update_callback in self._listeners: + update_callback() + + def _start_observing(self) -> None: + """Schedule state observation.""" + if self._task: + self._task.cancel() + self._task = None + self._task = asyncio.create_task(self._async_observe_status()) + + +class PhilipsEntity(Entity): + def __init__(self, coordinator: Coordinator) -> None: + super().__init__() + self.coordinator = coordinator + + @property + def should_poll(self) -> bool: + """No need to poll. Coordinator notifies entity of updates.""" + return False + + @property + def available(self): + return self.coordinator.status is not None + + @property + def _device_status(self) -> dict[str, Any]: + return self.coordinator.status + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + self.async_on_remove(self.coordinator.async_add_listener(self._handle_coordinator_update)) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_write_ha_state() diff --git a/custom_components/philips_airpurifier_coap/const.py b/custom_components/philips_airpurifier_coap/const.py index 416106ba..47dce9a9 100644 --- a/custom_components/philips_airpurifier_coap/const.py +++ b/custom_components/philips_airpurifier_coap/const.py @@ -1,5 +1,8 @@ DOMAIN = "philips_airpurifier_coap" -DATA_KEY = "fan.philips_airpurifier" + +DATA_KEY_CLIENT = "client" +DATA_KEY_COORDINATOR = "coordinator" +DATA_KEY_FAN = "fan" DEFAULT_NAME = "Philips AirPurifier" DEFAULT_ICON = "mdi:air-purifier" diff --git a/custom_components/philips_airpurifier_coap/fan.py b/custom_components/philips_airpurifier_coap/fan.py index d4f3721c..7b2da1c6 100644 --- a/custom_components/philips_airpurifier_coap/fan.py +++ b/custom_components/philips_airpurifier_coap/fan.py @@ -1,5 +1,6 @@ """Philips Air Purifier & Humidifier""" -import asyncio +from __future__ import annotations + from datetime import timedelta import logging from typing import Any, Callable, Dict, List, Optional, Union @@ -7,7 +8,7 @@ from aioairctrl import CoAPClient import voluptuous as vol -from homeassistant.components.fan import PLATFORM_SCHEMA, SUPPORT_PRESET_MODE, FanEntity +from homeassistant.components.fan import SUPPORT_PRESET_MODE, FanEntity from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.const import ( ATTR_ENTITY_ID, @@ -16,15 +17,13 @@ CONF_ICON, CONF_NAME, ) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.helpers.typing import ( - ConfigType, - DiscoveryInfoType, - HomeAssistantType, -) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import Coordinator, PhilipsEntity from .const import ( ATTR_AIR_QUALITY_INDEX, ATTR_CHILD_LOCK, @@ -61,9 +60,9 @@ ATTR_WATER_LEVEL, ATTR_WIFI_VERSION, CONF_MODEL, - DATA_KEY, - DEFAULT_ICON, - DEFAULT_NAME, + DATA_KEY_CLIENT, + DATA_KEY_COORDINATOR, + DATA_KEY_FAN, DOMAIN, FUNCTION_PURIFICATION, FUNCTION_PURIFICATION_HUMIDIFICATION, @@ -135,39 +134,21 @@ _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_MODEL): vol.In( - [ - MODEL_AC1214, - MODEL_AC2729, - MODEL_AC2889, - MODEL_AC2939, - MODEL_AC2958, - MODEL_AC3033, - MODEL_AC3059, - MODEL_AC3829, - MODEL_AC3858, - MODEL_AC4236, - ] - ), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_ICON, default=DEFAULT_ICON): cv.icon, - } -) - async def async_setup_platform( - hass: HomeAssistantType, + hass: HomeAssistant, config: ConfigType, async_add_entities: Callable[[List[Entity], bool], None], discovery_info: Optional[DiscoveryInfoType] = None, ) -> None: - host = config[CONF_HOST] - model = config[CONF_MODEL] - name = config[CONF_NAME] - icon = config[CONF_ICON] + if discovery_info is None: + return + + host = discovery_info[CONF_HOST] + model = discovery_info[CONF_MODEL] + name = discovery_info[CONF_NAME] + icon = discovery_info[CONF_ICON] + data = hass.data[DOMAIN][host] model_to_class = { MODEL_AC1214: PhilipsAC1214, @@ -184,15 +165,18 @@ async def async_setup_platform( model_class = model_to_class.get(model) if model_class: - device = model_class(host=host, model=model, name=name, icon=icon) - await device.init() + device = model_class( + data[DATA_KEY_CLIENT], + data[DATA_KEY_COORDINATOR], + model=model, + name=name, + icon=icon, + ) else: _LOGGER.error("Unsupported model: %s", model) return False - if DATA_KEY not in hass.data: - hass.data[DATA_KEY] = [] - hass.data[DATA_KEY].append(device) + data[DATA_KEY_FAN] = device async_add_entities([device], update_before_add=True) def wrapped_async_register( @@ -204,7 +188,11 @@ def wrapped_async_register( async def service_func_wrapper(service_call): service_data = service_call.data.copy() entity_id = service_data.pop("entity_id", None) - devices = [d for d in hass.data[DATA_KEY] if d.entity_id == entity_id] + devices = [ + d + for entry in hass.data[DOMAIN].values() + if (d := entry[DATA_KEY_FAN]).entity_id == entity_id + ] for d in devices: device_service_func = getattr(d, service_func.__name__) return await device_service_func(**service_data) @@ -219,25 +207,20 @@ async def service_func_wrapper(service_call): device._register_services(wrapped_async_register) -class PhilipsGenericFan(FanEntity): - def __init__(self, host: str, model: str, name: str, icon: str) -> None: - self._host = host +class PhilipsGenericFan(PhilipsEntity, FanEntity): + def __init__( + self, + coordinator: Coordinator, + model: str, + name: str, + icon: str, + ) -> None: + super().__init__(coordinator) self._model = model self._name = name self._icon = icon - self._available = False - self._state = None self._unique_id = None - async def init(self) -> None: - pass - - async def async_added_to_hass(self) -> None: - pass - - async def async_will_remove_from_hass(self) -> None: - pass - def _register_services(self, async_register) -> None: for cls in reversed(self.__class__.__mro__): register_method = getattr(cls, "register_services", None) @@ -259,18 +242,21 @@ def name(self) -> str: def icon(self) -> str: return self._icon - @property - def available(self) -> bool: - return self._available - class PhilipsGenericCoAPFanBase(PhilipsGenericFan): AVAILABLE_PRESET_MODES = {} AVAILABLE_ATTRIBUTES = [] - def __init__(self, host: str, model: str, name: str, icon: str) -> None: - super().__init__(host, model, name, icon) - self._device_status = None + def __init__( + self, + client: CoAPClient, + coordinator: Coordinator, + model: str, + name: str, + icon: str, + ) -> None: + super().__init__(coordinator, model, name, icon) + self._client = client self._preset_modes = [] self._available_preset_modes = {} @@ -279,14 +265,9 @@ def __init__(self, host: str, model: str, name: str, icon: str) -> None: self._available_attributes = [] self._collect_available_attributes() - async def init(self) -> None: - self._client = await CoAPClient.create(self._host) - self._observer_task = None try: - status = await self._client.get_status() - device_id = status[PHILIPS_DEVICE_ID] + device_id = self._device_status[PHILIPS_DEVICE_ID] self._unique_id = f"{self._model}-{device_id}" - self._device_status = status except Exception as e: _LOGGER.error("Failed retrieving unique_id: %s", e) raise PlatformNotReady @@ -306,27 +287,6 @@ def _collect_available_attributes(self): attributes.extend(cls_attributes) self._available_attributes = attributes - async def async_added_to_hass(self) -> None: - self._observer_task = asyncio.create_task(self._observe_status()) - - async def async_will_remove_from_hass(self) -> None: - self._observer_task.cancel() - await self._observer_task - await self._client.shutdown() - - async def _observe_status(self) -> None: - async for status in self._client.observe_status(): - self._device_status = status - self.schedule_update_ha_state() - - @property - def should_poll(self) -> bool: - return False - - @property - def available(self): - return self._device_status is not None - @property def is_on(self) -> bool: return self._device_status.get(PHILIPS_POWER) == "1" From 531f8745479eec4405f37ca3c12f1fbc93366318 Mon Sep 17 00:00:00 2001 From: Maurizio Zucchelli Date: Thu, 22 Jul 2021 11:36:08 +0100 Subject: [PATCH 3/4] feat: move some fan attributes to sensors --- .../philips_airpurifier_coap/__init__.py | 3 +- .../philips_airpurifier_coap/const.py | 74 +++++++++++ .../philips_airpurifier_coap/fan.py | 90 +------------- .../philips_airpurifier_coap/model.py | 25 ++++ .../philips_airpurifier_coap/sensor.py | 116 ++++++++++++++++++ 5 files changed, 223 insertions(+), 85 deletions(-) create mode 100644 custom_components/philips_airpurifier_coap/model.py create mode 100644 custom_components/philips_airpurifier_coap/sensor.py diff --git a/custom_components/philips_airpurifier_coap/__init__.py b/custom_components/philips_airpurifier_coap/__init__.py index c309a812..19d6f110 100644 --- a/custom_components/philips_airpurifier_coap/__init__.py +++ b/custom_components/philips_airpurifier_coap/__init__.py @@ -70,7 +70,7 @@ extra=vol.ALLOW_EXTRA, ) -PLATFORMS = ["fan"] +PLATFORMS = ["fan", "sensor"] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -152,6 +152,7 @@ def async_remove_listener(self, update_callback) -> None: async def _async_observe_status(self) -> None: async for status in self.client.observe_status(): + _LOGGER.debug("Status update: %s", status) self.status = status for update_callback in self._listeners: update_callback() diff --git a/custom_components/philips_airpurifier_coap/const.py b/custom_components/philips_airpurifier_coap/const.py index 47dce9a9..2356e0de 100644 --- a/custom_components/philips_airpurifier_coap/const.py +++ b/custom_components/philips_airpurifier_coap/const.py @@ -1,3 +1,19 @@ +"""Constants for Philips AirPurifier integration.""" +from __future__ import annotations + +from datetime import timedelta +from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ICON, + ATTR_TEMPERATURE, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, +) + +from .model import SensorDescription + DOMAIN = "philips_airpurifier_coap" DATA_KEY_CLIENT = "client" @@ -63,6 +79,9 @@ ATTR_HUMIDITY = "humidity" ATTR_HUMIDITY_TARGET = "humidity_target" ATTR_INDOOR_ALLERGEN_INDEX = "indoor_allergen_index" +ATTR_LABEL = "label" +ATTR_UNIT = "unit" +ATTR_VALUE = "value" ATTR_LANGUAGE = "language" ATTR_LIGHT_BRIGHTNESS = "light_brightness" ATTR_MODE = "mode" @@ -132,3 +151,58 @@ 49155: "pre-filter must be cleaned", 49408: "no water", } + +SENSOR_TYPES: dict[str, SensorDescription] = { + # filter information + PHILIPS_FILTER_PRE_REMAINING: { + ATTR_LABEL: ATTR_FILTER_PRE_REMAINING, + ATTR_VALUE: lambda value, _: str(timedelta(hours=value)), + }, + PHILIPS_FILTER_HEPA_REMAINING: { + ATTR_LABEL: ATTR_FILTER_HEPA_REMAINING, + ATTR_VALUE: lambda value, _: str(timedelta(hours=value)), + }, + PHILIPS_FILTER_ACTIVE_CARBON_REMAINING: { + ATTR_LABEL: ATTR_FILTER_ACTIVE_CARBON_REMAINING, + ATTR_VALUE: lambda value, _: str(timedelta(hours=value)), + }, + PHILIPS_FILTER_WICK_REMAINING: { + ATTR_LABEL: ATTR_FILTER_WICK_REMAINING, + ATTR_VALUE: lambda value, _: str(timedelta(hours=value)), + }, + PHILIPS_WATER_LEVEL: { + ATTR_ICON: "mdi:water", + ATTR_LABEL: ATTR_WATER_LEVEL, + ATTR_VALUE: lambda value, status: 0 if status.get("err") in [32768, 49408] else value, + }, + # device sensors + PHILIPS_AIR_QUALITY_INDEX: { + ATTR_LABEL: ATTR_AIR_QUALITY_INDEX, + }, + PHILIPS_INDOOR_ALLERGEN_INDEX: { + ATTR_ICON: "mdi:blur", + ATTR_LABEL: ATTR_INDOOR_ALLERGEN_INDEX, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + PHILIPS_PM25: { + ATTR_ICON: "mdi:blur", + ATTR_LABEL: "PM2.5", + ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + PHILIPS_TOTAL_VOLATILE_ORGANIC_COMPOUNDS: { + ATTR_ICON: "mdi:blur", + ATTR_LABEL: ATTR_TOTAL_VOLATILE_ORGANIC_COMPOUNDS, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + PHILIPS_HUMIDITY: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_LABEL: ATTR_HUMIDITY, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + PHILIPS_TEMPERATURE: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_LABEL: ATTR_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, +} diff --git a/custom_components/philips_airpurifier_coap/fan.py b/custom_components/philips_airpurifier_coap/fan.py index 7b2da1c6..6298acd7 100644 --- a/custom_components/philips_airpurifier_coap/fan.py +++ b/custom_components/philips_airpurifier_coap/fan.py @@ -12,7 +12,6 @@ from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_TEMPERATURE, CONF_HOST, CONF_ICON, CONF_NAME, @@ -25,39 +24,25 @@ from . import Coordinator, PhilipsEntity from .const import ( - ATTR_AIR_QUALITY_INDEX, ATTR_CHILD_LOCK, ATTR_DEVICE_ID, ATTR_DEVICE_VERSION, ATTR_DISPLAY_BACKLIGHT, ATTR_ERROR, ATTR_ERROR_CODE, - ATTR_FILTER_ACTIVE_CARBON_REMAINING, - ATTR_FILTER_ACTIVE_CARBON_REMAINING_RAW, ATTR_FILTER_ACTIVE_CARBON_TYPE, - ATTR_FILTER_HEPA_REMAINING, - ATTR_FILTER_HEPA_REMAINING_RAW, ATTR_FILTER_HEPA_TYPE, - ATTR_FILTER_PRE_REMAINING, - ATTR_FILTER_PRE_REMAINING_RAW, - ATTR_FILTER_WICK_REMAINING, - ATTR_FILTER_WICK_REMAINING_RAW, ATTR_FUNCTION, - ATTR_HUMIDITY, ATTR_HUMIDITY_TARGET, - ATTR_INDOOR_ALLERGEN_INDEX, ATTR_LANGUAGE, ATTR_LIGHT_BRIGHTNESS, ATTR_MODEL_ID, ATTR_NAME, - ATTR_PM25, ATTR_PREFERRED_INDEX, ATTR_PRODUCT_ID, ATTR_RUNTIME, ATTR_SOFTWARE_VERSION, - ATTR_TOTAL_VOLATILE_ORGANIC_COMPOUNDS, ATTR_TYPE, - ATTR_WATER_LEVEL, ATTR_WIFI_VERSION, CONF_MODEL, DATA_KEY_CLIENT, @@ -76,7 +61,6 @@ MODEL_AC3829, MODEL_AC3858, MODEL_AC4236, - PHILIPS_AIR_QUALITY_INDEX, PHILIPS_CHILD_LOCK, PHILIPS_DEVICE_ID, PHILIPS_DEVICE_VERSION, @@ -84,23 +68,16 @@ PHILIPS_DISPLAY_BACKLIGHT_MAP, PHILIPS_ERROR_CODE, PHILIPS_ERROR_CODE_MAP, - PHILIPS_FILTER_ACTIVE_CARBON_REMAINING, PHILIPS_FILTER_ACTIVE_CARBON_TYPE, - PHILIPS_FILTER_HEPA_REMAINING, PHILIPS_FILTER_HEPA_TYPE, - PHILIPS_FILTER_PRE_REMAINING, - PHILIPS_FILTER_WICK_REMAINING, PHILIPS_FUNCTION, PHILIPS_FUNCTION_MAP, - PHILIPS_HUMIDITY, PHILIPS_HUMIDITY_TARGET, - PHILIPS_INDOOR_ALLERGEN_INDEX, PHILIPS_LANGUAGE, PHILIPS_LIGHT_BRIGHTNESS, PHILIPS_MODE, PHILIPS_MODEL_ID, PHILIPS_NAME, - PHILIPS_PM25, PHILIPS_POWER, PHILIPS_PREFERRED_INDEX, PHILIPS_PREFERRED_INDEX_MAP, @@ -108,10 +85,7 @@ PHILIPS_RUNTIME, PHILIPS_SOFTWARE_VERSION, PHILIPS_SPEED, - PHILIPS_TEMPERATURE, - PHILIPS_TOTAL_VOLATILE_ORGANIC_COMPOUNDS, PHILIPS_TYPE, - PHILIPS_WATER_LEVEL, PHILIPS_WIFI_VERSION, PRESET_MODE_ALLERGEN, PRESET_MODE_AUTO, @@ -374,31 +348,10 @@ class PhilipsGenericCoAPFan(PhilipsGenericCoAPFanBase): (ATTR_DISPLAY_BACKLIGHT, PHILIPS_DISPLAY_BACKLIGHT, PHILIPS_DISPLAY_BACKLIGHT_MAP), (ATTR_PREFERRED_INDEX, PHILIPS_PREFERRED_INDEX, PHILIPS_PREFERRED_INDEX_MAP), # filter information - ( - ATTR_FILTER_PRE_REMAINING, - PHILIPS_FILTER_PRE_REMAINING, - lambda x, _: str(timedelta(hours=x)), - ), - (ATTR_FILTER_PRE_REMAINING_RAW, PHILIPS_FILTER_PRE_REMAINING), (ATTR_FILTER_HEPA_TYPE, PHILIPS_FILTER_HEPA_TYPE), - ( - ATTR_FILTER_HEPA_REMAINING, - PHILIPS_FILTER_HEPA_REMAINING, - lambda x, _: str(timedelta(hours=x)), - ), - (ATTR_FILTER_HEPA_REMAINING_RAW, PHILIPS_FILTER_HEPA_REMAINING), (ATTR_FILTER_ACTIVE_CARBON_TYPE, PHILIPS_FILTER_ACTIVE_CARBON_TYPE), - ( - ATTR_FILTER_ACTIVE_CARBON_REMAINING, - PHILIPS_FILTER_ACTIVE_CARBON_REMAINING, - lambda x, _: str(timedelta(hours=x)), - ), - (ATTR_FILTER_ACTIVE_CARBON_REMAINING_RAW, PHILIPS_FILTER_ACTIVE_CARBON_REMAINING), # device sensors (ATTR_RUNTIME, PHILIPS_RUNTIME, lambda x, _: str(timedelta(seconds=round(x / 1000)))), - (ATTR_AIR_QUALITY_INDEX, PHILIPS_AIR_QUALITY_INDEX), - (ATTR_INDOOR_ALLERGEN_INDEX, PHILIPS_INDOOR_ALLERGEN_INDEX), - (ATTR_PM25, PHILIPS_PM25), ] SERVICE_SCHEMA_SET_LIGHT_BRIGHTNESS = vol.Schema( @@ -452,29 +405,10 @@ async def async_set_light_brightness(self, brightness: int): await self._client.set_control_value(PHILIPS_LIGHT_BRIGHTNESS, brightness) -class PhilipsTVOCMixin(PhilipsGenericCoAPFanBase): - AVAILABLE_ATTRIBUTES = [ - (ATTR_TOTAL_VOLATILE_ORGANIC_COMPOUNDS, PHILIPS_TOTAL_VOLATILE_ORGANIC_COMPOUNDS), - ] - - -class PhilipsFilterWickMixin(PhilipsGenericCoAPFanBase): - AVAILABLE_ATTRIBUTES = [ - ( - ATTR_FILTER_WICK_REMAINING, - PHILIPS_FILTER_WICK_REMAINING, - lambda x, _: str(timedelta(hours=x)), - ), - (ATTR_FILTER_WICK_REMAINING_RAW, PHILIPS_FILTER_WICK_REMAINING), - ] - - class PhilipsHumidifierMixin(PhilipsGenericCoAPFanBase): AVAILABLE_ATTRIBUTES = [ (ATTR_FUNCTION, PHILIPS_FUNCTION, PHILIPS_FUNCTION_MAP), - (ATTR_HUMIDITY, PHILIPS_HUMIDITY), (ATTR_HUMIDITY_TARGET, PHILIPS_HUMIDITY_TARGET), - (ATTR_TEMPERATURE, PHILIPS_TEMPERATURE), ] SERVICE_SCHEMA_SET_FUNCTION = vol.Schema( @@ -523,16 +457,6 @@ async def async_set_humidity_target(self, humidity_target: int) -> None: await self._client.set_control_value(PHILIPS_HUMIDITY_TARGET, humidity_target) -class PhilipsWaterLevelMixin(PhilipsGenericCoAPFanBase): - AVAILABLE_ATTRIBUTES = [ - ( - ATTR_WATER_LEVEL, - PHILIPS_WATER_LEVEL, - lambda x, y: 0 if y.get("err") in [32768, 49408] else x, - ), - ] - - # TODO consolidate these classes as soon as we see a proper pattern class PhilipsAC1214(PhilipsGenericCoAPFan): AVAILABLE_PRESET_MODES = { @@ -547,9 +471,7 @@ class PhilipsAC1214(PhilipsGenericCoAPFan): class PhilipsAC2729( - PhilipsWaterLevelMixin, PhilipsHumidifierMixin, - PhilipsFilterWickMixin, PhilipsGenericCoAPFan, ): AVAILABLE_PRESET_MODES = { @@ -576,7 +498,7 @@ class PhilipsAC2889(PhilipsGenericCoAPFan): } -class PhilipsAC2939(PhilipsTVOCMixin, PhilipsGenericCoAPFan): +class PhilipsAC2939(PhilipsGenericCoAPFan): AVAILABLE_PRESET_MODES = { PRESET_MODE_AUTO: {PHILIPS_POWER: "1", PHILIPS_MODE: "AG"}, PRESET_MODE_GENTLE: {PHILIPS_POWER: "1", PHILIPS_MODE: "GT"}, @@ -594,7 +516,7 @@ class PhilipsAC2958(PhilipsGenericCoAPFan): } -class PhilipsAC3033(PhilipsTVOCMixin, PhilipsGenericCoAPFan): +class PhilipsAC3033(PhilipsGenericCoAPFan): AVAILABLE_PRESET_MODES = { PRESET_MODE_SPEED_1: {PHILIPS_POWER: "1", PHILIPS_MODE: "M", PHILIPS_SPEED: "1"}, PRESET_MODE_SPEED_2: {PHILIPS_POWER: "1", PHILIPS_MODE: "M", PHILIPS_SPEED: "2"}, @@ -604,7 +526,7 @@ class PhilipsAC3033(PhilipsTVOCMixin, PhilipsGenericCoAPFan): } -class PhilipsAC3059(PhilipsTVOCMixin, PhilipsGenericCoAPFan): +class PhilipsAC3059(PhilipsGenericCoAPFan): AVAILABLE_PRESET_MODES = { PRESET_MODE_SPEED_1: {PHILIPS_POWER: "1", PHILIPS_MODE: "M", PHILIPS_SPEED: "1"}, PRESET_MODE_SPEED_2: {PHILIPS_POWER: "1", PHILIPS_MODE: "M", PHILIPS_SPEED: "2"}, @@ -614,7 +536,7 @@ class PhilipsAC3059(PhilipsTVOCMixin, PhilipsGenericCoAPFan): } -class PhilipsAC3829(PhilipsHumidifierMixin, PhilipsFilterWickMixin, PhilipsGenericCoAPFan): +class PhilipsAC3829(PhilipsHumidifierMixin, PhilipsGenericCoAPFan): AVAILABLE_PRESET_MODES = { PRESET_MODE_SPEED_1: {PHILIPS_POWER: "1", PHILIPS_MODE: "M", PHILIPS_SPEED: "1"}, PRESET_MODE_SPEED_2: {PHILIPS_POWER: "1", PHILIPS_MODE: "M", PHILIPS_SPEED: "2"}, @@ -626,7 +548,7 @@ class PhilipsAC3829(PhilipsHumidifierMixin, PhilipsFilterWickMixin, PhilipsGener } -class PhilipsAC3858(PhilipsTVOCMixin, PhilipsGenericCoAPFan): +class PhilipsAC3858(PhilipsGenericCoAPFan): AVAILABLE_PRESET_MODES = { PRESET_MODE_SPEED_1: {PHILIPS_POWER: "1", PHILIPS_MODE: "M", PHILIPS_SPEED: "1"}, PRESET_MODE_SPEED_2: {PHILIPS_POWER: "1", PHILIPS_MODE: "M", PHILIPS_SPEED: "2"}, @@ -636,7 +558,7 @@ class PhilipsAC3858(PhilipsTVOCMixin, PhilipsGenericCoAPFan): } -class PhilipsAC4236(PhilipsTVOCMixin, PhilipsGenericCoAPFan): +class PhilipsAC4236(PhilipsGenericCoAPFan): AVAILABLE_PRESET_MODES = { PRESET_MODE_SPEED_1: {PHILIPS_POWER: "1", PHILIPS_MODE: "M", PHILIPS_SPEED: "1"}, PRESET_MODE_SPEED_2: {PHILIPS_POWER: "1", PHILIPS_MODE: "M", PHILIPS_SPEED: "2"}, diff --git a/custom_components/philips_airpurifier_coap/model.py b/custom_components/philips_airpurifier_coap/model.py new file mode 100644 index 00000000..1bf0061e --- /dev/null +++ b/custom_components/philips_airpurifier_coap/model.py @@ -0,0 +1,25 @@ +"""Type definitions for Philips AirPurifier integration.""" +from __future__ import annotations + +from typing import Any, Callable, TypedDict + +from homeassistant.helpers.typing import StateType + + +DeviceStatus = dict[str, Any] + + +class _SensorDescription(TypedDict): + """Mandatory attributes for a sensor description.""" + + label: str + + +class SensorDescription(_SensorDescription, total=False): + """Sensor description class.""" + + device_class: str + icon: str + unit: str + state_class: str + value: Callable[[Any, DeviceStatus], StateType] diff --git a/custom_components/philips_airpurifier_coap/sensor.py b/custom_components/philips_airpurifier_coap/sensor.py new file mode 100644 index 00000000..c07781a2 --- /dev/null +++ b/custom_components/philips_airpurifier_coap/sensor.py @@ -0,0 +1,116 @@ +"""Philips Air Purifier & Humidifier Sensors""" +from __future__ import annotations + +import logging +from typing import Any, Callable, List, cast + +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorEntity +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ICON, CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType + +from . import Coordinator, PhilipsEntity +from .const import ( + ATTR_FILTER_ACTIVE_CARBON_REMAINING_RAW, + ATTR_FILTER_ACTIVE_CARBON_TYPE, + ATTR_FILTER_HEPA_REMAINING_RAW, + ATTR_FILTER_HEPA_TYPE, + ATTR_FILTER_PRE_REMAINING_RAW, + ATTR_FILTER_WICK_REMAINING_RAW, + ATTR_LABEL, + ATTR_UNIT, + ATTR_VALUE, + CONF_MODEL, + DATA_KEY_COORDINATOR, + DOMAIN, + PHILIPS_DEVICE_ID, + PHILIPS_FILTER_ACTIVE_CARBON_REMAINING, + PHILIPS_FILTER_ACTIVE_CARBON_TYPE, + PHILIPS_FILTER_HEPA_REMAINING, + PHILIPS_FILTER_HEPA_TYPE, + PHILIPS_FILTER_PRE_REMAINING, + PHILIPS_FILTER_WICK_REMAINING, + SENSOR_TYPES, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: Callable[[List[Entity], bool], None], + discovery_info: DiscoveryInfoType | None = None, +) -> None: + if discovery_info is None: + return + + host = discovery_info[CONF_HOST] + model = discovery_info[CONF_MODEL] + name = discovery_info[CONF_NAME] + data = hass.data[DOMAIN][host] + + coordinator = data[DATA_KEY_COORDINATOR] + + sensors = [] + for sensor in SENSOR_TYPES: + if coordinator.status.get(sensor): + sensors.append(PhilipsSensor(coordinator, name, model, sensor)) + + async_add_entities(sensors, update_before_add=False) + + +class PhilipsSensor(PhilipsEntity, SensorEntity): + """Define a Philips AirPurifier sensor.""" + + def __init__(self, coordinator: Coordinator, name: str, model: str, kind: str) -> None: + super().__init__(coordinator) + self._model = model + self._description = SENSOR_TYPES[kind] + self._attr_device_class = self._description.get(ATTR_DEVICE_CLASS) + self._attr_icon = self._description.get(ATTR_ICON) + self._attr_name = f"{name} {self._description[ATTR_LABEL].replace('_', ' ').title()}" + self._attr_state_class = self._description.get(ATTR_STATE_CLASS) + self._attr_unit_of_measurement = self._description.get(ATTR_UNIT) + try: + device_id = self._device_status[PHILIPS_DEVICE_ID] + self._attr_unique_id = f"{self._model}-{device_id}-{kind.lower()}" + except Exception as e: + _LOGGER.error("Failed retrieving unique_id: %s", e) + raise PlatformNotReady + self._attrs: dict[str, Any] = {} + self.kind = kind + + @property + def state(self) -> StateType: + value = self._device_status[self.kind] + convert = self._description.get(ATTR_VALUE) + if convert: + value = convert(value, self._device_status) + return cast(StateType, value) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + if self.kind == PHILIPS_FILTER_PRE_REMAINING: + self._attrs[ATTR_FILTER_PRE_REMAINING_RAW] = self._device_status[ + PHILIPS_FILTER_PRE_REMAINING + ] + if self.kind == PHILIPS_FILTER_HEPA_REMAINING: + self._attrs[ATTR_FILTER_HEPA_TYPE] = self._device_status[PHILIPS_FILTER_HEPA_TYPE] + self._attrs[ATTR_FILTER_HEPA_REMAINING_RAW] = self._device_status[ + PHILIPS_FILTER_HEPA_REMAINING + ] + if self.kind == PHILIPS_FILTER_ACTIVE_CARBON_REMAINING: + self._attrs[ATTR_FILTER_ACTIVE_CARBON_TYPE] = self._device_status[ + PHILIPS_FILTER_ACTIVE_CARBON_TYPE + ] + self._attrs[ATTR_FILTER_ACTIVE_CARBON_REMAINING_RAW] = self._device_status[ + PHILIPS_FILTER_ACTIVE_CARBON_REMAINING + ] + if self.kind == PHILIPS_FILTER_WICK_REMAINING: + self._attrs[ATTR_FILTER_WICK_REMAINING_RAW] = self._device_status[ + PHILIPS_FILTER_WICK_REMAINING + ] + return self._attrs From c9e83905e038082963820a44ebccdfbf435ebd09 Mon Sep 17 00:00:00 2001 From: Maurizio Zucchelli Date: Thu, 22 Jul 2021 11:36:35 +0100 Subject: [PATCH 4/4] fix: correct some type annotations --- custom_components/philips_airpurifier_coap/__init__.py | 8 +++++++- custom_components/philips_airpurifier_coap/fan.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/custom_components/philips_airpurifier_coap/__init__.py b/custom_components/philips_airpurifier_coap/__init__.py index 19d6f110..c7d10940 100644 --- a/custom_components/philips_airpurifier_coap/__init__.py +++ b/custom_components/philips_airpurifier_coap/__init__.py @@ -35,6 +35,7 @@ MODEL_AC3858, MODEL_AC4236, ) +from .model import DeviceStatus _LOGGER = logging.getLogger(__name__) @@ -113,7 +114,12 @@ class Coordinator: def __init__(self, client: CoAPClient) -> None: self.client = client - self.status: dict[str, Any] = None + # It's None before the first successful update. + # Components should call async_first_refresh to make sure the first + # update was successful. Set type to just DeviceStatus to remove + # annoying checks that status is not None when it was already checked + # during setup. + self.status: DeviceStatus = None # type: ignore[assignment] self._listeners: list[CALLBACK_TYPE] = [] self._task: Task | None = None diff --git a/custom_components/philips_airpurifier_coap/fan.py b/custom_components/philips_airpurifier_coap/fan.py index 6298acd7..991f259e 100644 --- a/custom_components/philips_airpurifier_coap/fan.py +++ b/custom_components/philips_airpurifier_coap/fan.py @@ -148,7 +148,7 @@ async def async_setup_platform( ) else: _LOGGER.error("Unsupported model: %s", model) - return False + return data[DATA_KEY_FAN] = device async_add_entities([device], update_before_add=True)