diff --git a/README.md b/README.md index 4a8e7c7f..0fcae3de 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,13 @@ Note: `configuration.yaml` is no longer supported and your configuration is not ## Supported models -- AC0850 +- AC0850/11 AWS_Philips_AIR +- AC0850/11 AWS_Philips_AIR_Combo +- AC0850/20 AWS_Philips_AIR +- AC0850/20 AWS_Philips_AIR_Combo +- AC0850/31 +- AC0950 +- AC0951 - AC1214 - AC1715 - AC2729 @@ -73,6 +79,7 @@ Note: `configuration.yaml` is no longer supported and your configuration is not - AC3039 - AC3055 - AC3059 +- AC3421 - AC3259 - AC3737 - AC3829 diff --git a/custom_components/philips_airpurifier_coap/__init__.py b/custom_components/philips_airpurifier_coap/__init__.py index 2cf7c351..b1673e12 100644 --- a/custom_components/philips_airpurifier_coap/__init__.py +++ b/custom_components/philips_airpurifier_coap/__init__.py @@ -1,4 +1,5 @@ """Support for Philips AirPurifier with CoAP.""" + from __future__ import annotations import asyncio @@ -6,7 +7,8 @@ from ipaddress import IPv6Address, ip_address import json import logging -from os import path, walk +from os import walk +from pathlib import Path from aioairctrl import CoAPClient from getmac import get_mac_address @@ -25,7 +27,6 @@ DATA_KEY_COORDINATOR, DOMAIN, ICONLIST_URL, - ICONS, ICONS_PATH, ICONS_URL, LOADER_PATH, @@ -37,7 +38,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["fan", "binary_sensor", "sensor", "switch", "light", "select", "number"] +PLATFORMS = ["binary_sensor", "fan", "light", "number", "select", "sensor", "switch"] # icons code thanks to Thomas Loven: @@ -50,17 +51,20 @@ class ListingView(HomeAssistantView): requires_auth = False def __init__(self, url, iconpath) -> None: + """Initialize the ListingView with a URL and icon path.""" self.url = url self.iconpath = iconpath self.name = "Icon Listing" async def get(self, request): + """Handle GET request to provide a JSON list of the used icons.""" icons = [] - for (dirpath, dirnames, filenames) in walk(self.iconpath): + for dirpath, _dirnames, filenames in walk(self.iconpath): icons.extend( [ - {"name": path.join(dirpath[len(self.iconpath):], fn[:-4])} - for fn in filenames if fn.endswith(".svg") + {"name": (Path(dirpath[len(self.iconpath) :]) / fn[:-4]).as_posix()} + for fn in filenames + if fn.endswith(".svg") ] ) return json.dumps(icons) @@ -70,12 +74,16 @@ async def async_setup(hass: HomeAssistant, config) -> bool: """Set up the icons for the Philips AirPurifier integration.""" _LOGGER.debug("async_setup called") - await hass.http.async_register_static_paths([StaticPathConfig(LOADER_URL, hass.config.path(LOADER_PATH), True)]) + await hass.http.async_register_static_paths( + [StaticPathConfig(LOADER_URL, hass.config.path(LOADER_PATH), True)] + ) add_extra_js_url(hass, LOADER_URL) iset = PAP iconpath = hass.config.path(ICONS_PATH + "/" + iset) - await hass.http.async_register_static_paths([StaticPathConfig(ICONS_URL + "/" + iset, iconpath, True)]) + await hass.http.async_register_static_paths( + [StaticPathConfig(ICONS_URL + "/" + iset, iconpath, True)] + ) hass.http.register_view(ListingView(ICONLIST_URL + "/" + iset, iconpath)) return True diff --git a/custom_components/philips_airpurifier_coap/binary_sensor.py b/custom_components/philips_airpurifier_coap/binary_sensor.py index a8690aa1..ea1e374a 100644 --- a/custom_components/philips_airpurifier_coap/binary_sensor.py +++ b/custom_components/philips_airpurifier_coap/binary_sensor.py @@ -1,4 +1,5 @@ """Philips Air Purifier & Humidifier Binary Sensors.""" + from __future__ import annotations from collections.abc import Callable @@ -54,13 +55,11 @@ async def async_setup_entry( # noqa: D103 cls_available_binary_sensors = getattr(cls, "AVAILABLE_BINARY_SENSORS", []) available_binary_sensors.extend(cls_available_binary_sensors) - binary_sensors = [] - - for binary_sensor in BINARY_SENSOR_TYPES: - if binary_sensor in status and binary_sensor in available_binary_sensors: - binary_sensors.append( - PhilipsBinarySensor(coordinator, name, model, binary_sensor) - ) + binary_sensors = [ + PhilipsBinarySensor(coordinator, name, model, binary_sensor) + for binary_sensor in BINARY_SENSOR_TYPES + if binary_sensor in status and binary_sensor in available_binary_sensors + ] async_add_entities(binary_sensors, update_before_add=False) @@ -89,9 +88,10 @@ def __init__( # noqa: D107 try: device_id = self._device_status[PhilipsApi.DEVICE_ID] self._attr_unique_id = f"{self._model}-{device_id}-{kind.lower()}" - except Exception as e: + except KeyError as e: _LOGGER.error("Failed retrieving unique_id: %s", e) - raise PlatformNotReady + raise PlatformNotReady from e + self._attrs: dict[str, Any] = {} self.kind = kind diff --git a/custom_components/philips_airpurifier_coap/config_flow.py b/custom_components/philips_airpurifier_coap/config_flow.py index d46b7618..f94cb8f9 100644 --- a/custom_components/philips_airpurifier_coap/config_flow.py +++ b/custom_components/philips_airpurifier_coap/config_flow.py @@ -1,5 +1,5 @@ """The Philips AirPurifier component.""" -import asyncio + import ipaddress import logging import re @@ -43,13 +43,13 @@ def __init__(self) -> None: self._model: Any = None self._name: Any = None self._device_id: str = None + self._wifi_version: Any = None def _get_schema(self, user_input): """Provide schema for user input.""" - schema = vol.Schema( + return vol.Schema( {vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): cv.string} ) - return schema async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle initial step of auto discovery flow.""" @@ -80,7 +80,7 @@ async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowRes # get the status out of the queue _LOGGER.debug("status for host %s is: %s", self._host, status) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning( r"Timeout, host %s looks like a Philips AirPurifier but doesn't answer, aborting", self._host, @@ -110,6 +110,9 @@ async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowRes self._model = first_model[:9] _LOGGER.debug("model type extracted: %s", self._model) + # autodetect Wifi version + self._wifi_version = status.get(PhilipsApi.WIFI_VERSION) + # autodetect name self._name = list( filter( @@ -129,18 +132,21 @@ async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowRes ) # check if model is supported - if self._model not in model_to_class: - _LOGGER.info( - "Model %s found, but not supported directly. Trying model family", - self._model, - ) - self._model = self._model[:6] - if self._model not in model_to_class: - _LOGGER.warning( - "Model %s found, but not supported. Aborting discovery", - self._model, - ) - return self.async_abort(reason="model_unsupported") + model_long = self._model + " " + self._wifi_version.split("@")[0] + model = self._model + model_family = self._model[:6] + + if model in model_to_class: + _LOGGER.info("Model %s supported", model) + self._model = model + elif model_long in model_to_class: + _LOGGER.info("Model %s supported", model_long) + self._model = model_long + elif model_family in model_to_class: + _LOGGER.info("Model family %s supported", model_family) + self._model = model_family + else: + return self.async_abort(reason="model_unsupported") # use the device ID as unique_id unique_id = self._device_id @@ -163,7 +169,9 @@ async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowRes _LOGGER.debug("waiting for async_step_confirm") return await self.async_step_confirm() - async def async_step_confirm(self, user_input: dict[str, Any] = None) -> FlowResult: + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Confirm the dhcp discovered data.""" _LOGGER.debug("async_step_confirm called with user_input: %s", user_input) @@ -192,7 +200,9 @@ async def async_step_confirm(self, user_input: dict[str, Any] = None) -> FlowRes description_placeholders={"model": self._model, "name": self._name}, ) - async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle initial step of user config flow.""" errors = {} @@ -202,7 +212,7 @@ async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult try: # first some sanitycheck on the host input if not host_valid(user_input[CONF_HOST]): - raise InvalidHost() + raise InvalidHost # noqa: TRY301 self._host = user_input[CONF_HOST] _LOGGER.debug("trying to configure host: %s", self._host) @@ -225,7 +235,7 @@ async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult if client is not None: await client.shutdown() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning( r"Timeout, host %s doesn't answer, aborting", self._host ) @@ -254,6 +264,9 @@ async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult self._model = first_model[:9] _LOGGER.debug("model type extracted: %s", self._model) + # autodetect Wifi version + self._wifi_version = status.get(PhilipsApi.WIFI_VERSION) + # autodetect name self._name = list( filter( @@ -281,14 +294,21 @@ async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult ) # check if model is supported - if self._model not in model_to_class: - _LOGGER.info( - "Model %s not supported. Trying model family", self._model - ) - self._model = self._model[:6] - if self._model not in model_to_class: - return self.async_abort(reason="model_unsupported") - user_input[CONF_MODEL] = self._model + model_long = self._model + " " + self._wifi_version.split("@")[0] + model = self._model + model_family = self._model[:6] + + if model in model_to_class: + _LOGGER.info("Model %s supported", model) + user_input[CONF_MODEL] = model + elif model_long in model_to_class: + _LOGGER.info("Model %s supported", model_long) + user_input[CONF_MODEL] = model_long + elif model_family in model_to_class: + _LOGGER.info("Model family %s supported", model_family) + user_input[CONF_MODEL] = model_family + else: + return self.async_abort(reason="model_unsupported") # use the device ID as unique_id unique_id = self._device_id diff --git a/custom_components/philips_airpurifier_coap/const.py b/custom_components/philips_airpurifier_coap/const.py index 1289f593..079f525e 100644 --- a/custom_components/philips_airpurifier_coap/const.py +++ b/custom_components/philips_airpurifier_coap/const.py @@ -1,4 +1,5 @@ """Constants for Philips AirPurifier integration.""" + from __future__ import annotations from enum import StrEnum @@ -90,6 +91,7 @@ class ICON(StrEnum): SWITCH_ON = "on" SWITCH_OFF = "off" +SWITCH_MEDIUM = "medium" OPTIONS = "options" DIMMABLE = "dimmable" @@ -97,7 +99,13 @@ class ICON(StrEnum): class FanModel(StrEnum): """Supported fan models.""" - AC0850 = "AC0850" + AC0850_11 = "AC0850/11 AWS_Philips_AIR" + AC0850_11C = "AC0850/11 AWS_Philips_AIR_Combo" + AC0850_20 = "AC0850/20 AWS_Philips_AIR" + AC0850_20C = "AC0850/20 AWS_Philips_AIR_Combo" + AC0850_31 = "AC0850/31" + AC0950 = "AC0950" + AC0951 = "AC0951" AC1214 = "AC1214" AC1715 = "AC1715" AC2729 = "AC2729" @@ -112,6 +120,7 @@ class FanModel(StrEnum): AC3055 = "AC3055" AC3059 = "AC3059" AC3259 = "AC3259" + AC3421 = "AC3421" AC3737 = "AC3737" AC3829 = "AC3829" AC3836 = "AC3836" @@ -153,6 +162,7 @@ class PresetMode: SLEEP = "sleep" SLEEP_ALLERGY = "allergy sleep" TURBO = "turbo" + MEDIUM = "medium" GAS = "gas" POLLUTION = "pollution" LOW = "low" @@ -361,6 +371,7 @@ class PhilipsApi: NEW2_POWER = "D03102" NEW2_DISPLAY_BACKLIGHT = "D0312D" NEW2_DISPLAY_BACKLIGHT2 = "D03105" + NEW2_DISPLAY_BACKLIGHT3 = "D03105#1" # dimmable in 3 steps NEW2_TEMPERATURE = "D03224" NEW2_SOFTWARE_VERSION = "D01S12" NEW2_CHILD_LOCK = "D03103" @@ -390,7 +401,6 @@ class PhilipsApi: NEW2_AUTO_PLUS_AI = "D03180" NEW2_PREFERRED_INDEX = "D0312A#1" NEW2_GAS_PREFERRED_INDEX = "D0312A#2" - NEW2_ERROR_CODE = "D03240 " PREFERRED_INDEX_MAP = { 0: ("Indoor Allergen Index", ICON.IAI), @@ -601,7 +611,6 @@ class PhilipsApi: } - EXTRA_SENSOR_TYPES: dict[str, SensorDescription] = {} BINARY_SENSOR_TYPES: dict[str, SensorDescription] = { @@ -795,6 +804,15 @@ class PhilipsApi: SWITCH_OFF: 0, DIMMABLE: True, }, + PhilipsApi.NEW2_DISPLAY_BACKLIGHT3: { + ATTR_ICON: ICON.LIGHT_DIMMING_BUTTON, + FanAttributes.LABEL: FanAttributes.DISPLAY_BACKLIGHT, + CONF_ENTITY_CATEGORY: EntityCategory.CONFIG, + SWITCH_ON: 123, + SWITCH_OFF: 0, + SWITCH_MEDIUM: 115, + DIMMABLE: True, + }, } SELECT_TYPES: dict[str, SelectDescription] = { diff --git a/custom_components/philips_airpurifier_coap/light.py b/custom_components/philips_airpurifier_coap/light.py index 6cc7b339..09a005be 100644 --- a/custom_components/philips_airpurifier_coap/light.py +++ b/custom_components/philips_airpurifier_coap/light.py @@ -1,4 +1,5 @@ """Philips Air Purifier & Humidifier Switches.""" + from __future__ import annotations from collections.abc import Callable @@ -24,6 +25,7 @@ DIMMABLE, DOMAIN, LIGHT_TYPES, + SWITCH_MEDIUM, SWITCH_OFF, SWITCH_ON, FanAttributes, @@ -58,11 +60,11 @@ async def async_setup_entry( cls_available_lights = getattr(cls, "AVAILABLE_LIGHTS", []) available_lights.extend(cls_available_lights) - lights = [] - - for light in LIGHT_TYPES: - if light in available_lights: - lights.append(PhilipsLight(coordinator, name, model, light)) + lights = [ + PhilipsLight(coordinator, name, model, light) + for light in LIGHT_TYPES + if light in available_lights + ] async_add_entities(lights, update_before_add=False) @@ -84,6 +86,7 @@ def __init__( # noqa: D107 self._description = LIGHT_TYPES[light] self._on = self._description.get(SWITCH_ON) self._off = self._description.get(SWITCH_OFF) + self._medium = self._description.get(SWITCH_MEDIUM) self._dimmable = self._description.get(DIMMABLE) self._attr_device_class = self._description.get(ATTR_DEVICE_CLASS) self._attr_icon = self._description.get(ATTR_ICON) @@ -94,6 +97,7 @@ def __init__( # noqa: D107 if self._dimmable is None: self._dimmable = False + self._medium = None if self._dimmable: self._attr_color_mode = ColorMode.BRIGHTNESS @@ -105,38 +109,43 @@ def __init__( # noqa: D107 try: device_id = self._device_status[PhilipsApi.DEVICE_ID] self._attr_unique_id = f"{self._model}-{device_id}-{light.lower()}" - except Exception as e: - _LOGGER.error("Failed retrieving unique_id: %s", e) - raise PlatformNotReady + except KeyError as e: + _LOGGER.error("Failed retrieving unique_id due to missing key: %s", e) + raise PlatformNotReady from e + except TypeError as e: + _LOGGER.error("Failed retrieving unique_id due to type error: %s", e) + raise PlatformNotReady from e + self._attrs: dict[str, Any] = {} - self.kind = light + self.kind = light.partition("#")[0] @property def is_on(self) -> bool: """Return if the light is on.""" status = int(self._device_status.get(self.kind)) # _LOGGER.debug("is_on, kind: %s - status: %s - on: %s", self.kind, status, self._on) - if self._dimmable: - return status > 0 - else: - return status == int(self._on) + return int(status) != int(self._off) @property def brightness(self) -> int | None: """Return the brightness of the light.""" if self._dimmable: brightness = int(self._device_status.get(self.kind)) - return round(255 * brightness / 100) - else: - return None + if self._medium and brightness == int(self._medium): + return 128 + return round(255 * brightness / int(self._on)) + return None async def async_turn_on(self, **kwargs) -> None: """Turn the light on.""" if self._dimmable: if ATTR_BRIGHTNESS in kwargs: - value = round(100 * int(kwargs[ATTR_BRIGHTNESS]) / 255) + if self._medium and kwargs[ATTR_BRIGHTNESS] < 255: + value = self._medium + else: + value = round(int(self._on) * int(kwargs[ATTR_BRIGHTNESS]) / 255) else: - value = 100 + value = int(self._on) else: value = self._on diff --git a/custom_components/philips_airpurifier_coap/manifest.json b/custom_components/philips_airpurifier_coap/manifest.json index e67eadb1..e44783c1 100644 --- a/custom_components/philips_airpurifier_coap/manifest.json +++ b/custom_components/philips_airpurifier_coap/manifest.json @@ -14,6 +14,9 @@ { "macaddress": "849DC2*" }, + { + "macaddress": "80A036*" + }, { "hostname": "mxchip*" }, @@ -26,5 +29,5 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/kongo09/philips-airpurifier-coap/issues", "requirements": ["aioairctrl==0.2.5", "getmac==0.9.4"], - "version": "0.18.9" + "version": "0.18.10" } diff --git a/custom_components/philips_airpurifier_coap/model.py b/custom_components/philips_airpurifier_coap/model.py index b43a0f48..93c25b3d 100644 --- a/custom_components/philips_airpurifier_coap/model.py +++ b/custom_components/philips_airpurifier_coap/model.py @@ -1,4 +1,5 @@ """Type definitions for Philips AirPurifier integration.""" + from __future__ import annotations from collections.abc import Callable @@ -24,7 +25,7 @@ class SensorDescription(_SensorDescription, total=False): unit: str state_class: str value: Callable[[Any, DeviceStatus], StateType] - icon_map: [int, str] + icon_map: list[tuple[int, str]] # warn_value: int # warn_icon: str @@ -35,7 +36,7 @@ class FilterDescription(TypedDict): prefix: str postfix: str icon: str - icon_map: [int, str] + icon_map: list[tuple[int, str]] # warn_icon: str # warn_value: int diff --git a/custom_components/philips_airpurifier_coap/number.py b/custom_components/philips_airpurifier_coap/number.py index 2ee11a88..6af49dfc 100644 --- a/custom_components/philips_airpurifier_coap/number.py +++ b/custom_components/philips_airpurifier_coap/number.py @@ -1,4 +1,5 @@ """Philips Air Purifier & Humidifier Numbers.""" + from __future__ import annotations from collections.abc import Callable @@ -55,11 +56,11 @@ async def async_setup_entry( cls_available_numbers = getattr(cls, "AVAILABLE_NUMBERS", []) available_numbers.extend(cls_available_numbers) - numbers = [] - - for number in NUMBER_TYPES: - if number in available_numbers: - numbers.append(PhilipsNumber(coordinator, name, model, number)) + numbers = [ + PhilipsNumber(coordinator, name, model, number) + for number in NUMBER_TYPES + if number in available_numbers + ] async_add_entities(numbers, update_before_add=False) @@ -96,17 +97,20 @@ def __init__( # noqa: D107 try: device_id = self._device_status[PhilipsApi.DEVICE_ID] self._attr_unique_id = f"{self._model}-{device_id}-{number.lower()}" - except Exception as e: - _LOGGER.error("Failed retrieving unique_id: %s", e) - raise PlatformNotReady + except KeyError as e: + _LOGGER.error("Failed retrieving unique_id due to missing key: %s", e) + raise PlatformNotReady from e + except TypeError as e: + _LOGGER.error("Failed retrieving unique_id due to type error: %s", e) + raise PlatformNotReady from e + self._attrs: dict[str, Any] = {} self.kind = number @property def native_value(self) -> float | None: """Return the current number.""" - value = self._device_status.get(self.kind) - return value + return self._device_status.get(self.kind) async def async_set_native_value(self, value: float) -> None: """Select a number.""" @@ -118,10 +122,8 @@ async def async_set_native_value(self, value: float) -> None: value = self._attr_native_min_value if value % self._attr_native_step > 0: value = value // self._attr_native_step * self._attr_native_step - if value > 0 and value < self._min: - value = self._min - if value > self._attr_native_max_value: - value = self._attr_native_max_value + value = max(value, self._min) if value > 0 else value + value = min(value, self._attr_native_max_value) _LOGGER.debug("setting number with: %s", value) diff --git a/custom_components/philips_airpurifier_coap/philips.py b/custom_components/philips_airpurifier_coap/philips.py index 46fcd4d6..2b41cf1d 100644 --- a/custom_components/philips_airpurifier_coap/philips.py +++ b/custom_components/philips_airpurifier_coap/philips.py @@ -1,4 +1,5 @@ """Collection of classes to manage Philips AirPurifier devices.""" + from __future__ import annotations import asyncio @@ -7,7 +8,7 @@ import contextlib from datetime import timedelta import logging -from typing import Any, Optional, Union +from typing import Any from aioairctrl import CoAPClient @@ -45,7 +46,7 @@ class Coordinator: def __init__(self, client: CoAPClient, host: str, mac: str) -> None: # noqa: D107 self.client = client self._host = host - self._mac = mac + self.mac = mac # It's None before the first successful update. # Components should call async_first_refresh to make sure the first @@ -67,7 +68,7 @@ def __init__(self, client: CoAPClient, host: str, mac: str) -> None: # noqa: D1 callback=self.reconnect, autostart=True, ) - self._timer_disconnected._auto_restart = True + self._timer_disconnected.setAutoRestart(True) _LOGGER.debug("init: finished for host %s", self._host) async def shutdown(self): @@ -208,7 +209,7 @@ def __init__(self, coordinator: Coordinator) -> None: # noqa: D107 )[0] self._firmware = coordinator.status["WifiVersion"] self._manufacturer = "Philips" - self._mac = coordinator._mac + self._mac = coordinator.mac @property def should_poll(self) -> bool: @@ -267,7 +268,7 @@ def __init__( # noqa: D107 self._unique_id = None @property - def unique_id(self) -> Optional[str]: + def unique_id(self) -> str | None: """Return the unique ID of the fan.""" return self._unique_id @@ -325,7 +326,7 @@ def __init__( # noqa: D107 self._unique_id = f"{self._model}-{device_id}" except Exception as e: _LOGGER.error("Failed retrieving unique_id: %s", e) - raise PlatformNotReady + raise PlatformNotReady from e def _collect_available_preset_modes(self): preset_modes = {} @@ -359,8 +360,8 @@ def is_on(self) -> bool: async def async_turn_on( self, - percentage: Optional[int] = None, - preset_mode: Optional[str] = None, + percentage: int | None = None, + preset_mode: str | None = None, **kwargs, ): """Turn the fan on.""" @@ -383,7 +384,11 @@ async def async_turn_off(self, **kwargs) -> None: @property def supported_features(self) -> int: """Return the supported features.""" - features = FanEntityFeature.PRESET_MODE | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON + features = ( + FanEntityFeature.PRESET_MODE + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) if self._speeds: features |= FanEntityFeature.SET_SPEED if self.KEY_OSCILLATION is not None: @@ -391,12 +396,12 @@ def supported_features(self) -> int: return features @property - def preset_modes(self) -> Optional[list[str]]: + def preset_modes(self) -> list[str] | None: """Return the supported preset modes.""" return self._preset_modes @property - def preset_mode(self) -> Optional[str]: + def preset_mode(self) -> str | None: """Return the selected preset mode.""" for preset_mode, status_pattern in self._available_preset_modes.items(): for k, v in status_pattern.items(): @@ -408,6 +413,7 @@ def preset_mode(self) -> Optional[str]: break else: return preset_mode + return None async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" @@ -434,7 +440,7 @@ def oscillating(self) -> bool | None: async def async_oscillate(self, oscillating: bool) -> None: """Osciallate the fan.""" if self.KEY_OSCILLATION is None: - return None + return key = next(iter(self.KEY_OSCILLATION)) values = self.KEY_OSCILLATION.get(key) @@ -446,7 +452,7 @@ async def async_oscillate(self, oscillating: bool) -> None: await self.coordinator.client.set_control_value(key, off) @property - def percentage(self) -> Optional[int]: + def percentage(self) -> int | None: """Return the speed percentages.""" for speed, status_pattern in self._available_speeds.items(): for k, v in status_pattern.items(): @@ -471,14 +477,14 @@ async def async_set_percentage(self, percentage: int) -> None: await self.coordinator.client.set_control_values(data=status_pattern) @property - def extra_state_attributes(self) -> Optional[dict[str, Any]]: + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the extra state attributes.""" def append( attributes: dict, key: str, philips_key: str, - value_map: Union[dict, Callable[[Any, Any], Any]] = None, + value_map: dict | Callable[[Any, Any], Any] | None = None, ): # some philips keys are not unique, so # serves as a marker and needs to be filtered out philips_clean_key = philips_key.partition("#")[0] @@ -641,8 +647,13 @@ class PhilipsHumidifierMixin(PhilipsGenericCoAPFanBase): # similar to the AC1715, the AC0850 seems to be a new class of devices that # follows some patterns of its own -class PhilipsAC0850(PhilipsNewGenericCoAPFan): - """AC0850.""" + + +# the AC0850/11 comes in two versions. +# the first version has a Wifi string starting with "AWS_Philips_AIR" +# the second version has a Wifi string starting with "AWS_Philips_AIR_Combo" +class PhilipsAC085011(PhilipsNewGenericCoAPFan): + """AC0850/11 with firmware AWS_Philips_AIR.""" AVAILABLE_PRESET_MODES = { PresetMode.AUTO: { @@ -660,6 +671,66 @@ class PhilipsAC0850(PhilipsNewGenericCoAPFan): UNAVAILABLE_FILTERS = [PhilipsApi.FILTER_NANOPROTECT_PREFILTER] +class PhilipsAC085011C(PhilipsNew2GenericCoAPFan): + """AC0850/11 with firmware AWS_Philips_AIR_Combo.""" + + AVAILABLE_PRESET_MODES = { + PresetMode.AUTO: { + PhilipsApi.NEW2_POWER: 1, + PhilipsApi.NEW2_MODE_B: 0, + }, + PresetMode.TURBO: {PhilipsApi.NEW2_POWER: 1, PhilipsApi.NEW2_MODE_B: 18}, + PresetMode.SLEEP: {PhilipsApi.NEW2_POWER: 1, PhilipsApi.NEW2_MODE_B: 17}, + } + AVAILABLE_SPEEDS = { + PresetMode.SLEEP: {PhilipsApi.NEW2_POWER: 1, PhilipsApi.NEW2_MODE_B: 17}, + PresetMode.TURBO: {PhilipsApi.NEW2_POWER: 1, PhilipsApi.NEW2_MODE_B: 18}, + } + # the prefilter data is present but doesn't change for this device, so let's take it out + UNAVAILABLE_FILTERS = [PhilipsApi.FILTER_NANOPROTECT_PREFILTER] + + +class PhilipsAC085020(PhilipsAC085011): + """AC0850/20 with firmware AWS_Philips_AIR.""" + + +class PhilipsAC085020C(PhilipsAC085011C): + """AC0850/20 with firmware AWS_Philips_AIR_Combo.""" + + +class PhilipsAC085031(PhilipsAC085011C): + """AC0850/31.""" + + +class PhilipsAC0950(PhilipsNew2GenericCoAPFan): + """AC0950.""" + + AVAILABLE_PRESET_MODES = { + PresetMode.AUTO: { + PhilipsApi.NEW2_POWER: 1, + PhilipsApi.NEW2_MODE_B: 0, + }, + PresetMode.TURBO: {PhilipsApi.NEW2_POWER: 1, PhilipsApi.NEW2_MODE_B: 18}, + PresetMode.MEDIUM: {PhilipsApi.NEW2_POWER: 1, PhilipsApi.NEW2_MODE_B: 19}, + PresetMode.SLEEP: {PhilipsApi.NEW2_POWER: 1, PhilipsApi.NEW2_MODE_B: 17}, + } + AVAILABLE_SPEEDS = { + PresetMode.SLEEP: {PhilipsApi.NEW2_POWER: 1, PhilipsApi.NEW2_MODE_B: 17}, + PresetMode.MEDIUM: {PhilipsApi.NEW2_POWER: 1, PhilipsApi.NEW2_MODE_B: 19}, + PresetMode.TURBO: {PhilipsApi.NEW2_POWER: 1, PhilipsApi.NEW2_MODE_B: 18}, + } + # the prefilter data is present but doesn't change for this device, so let's take it out + UNAVAILABLE_FILTERS = [PhilipsApi.FILTER_NANOPROTECT_PREFILTER] + + AVAILABLE_SWITCHES = [PhilipsApi.NEW2_CHILD_LOCK, PhilipsApi.NEW2_BEEP] + AVAILABLE_LIGHTS = [PhilipsApi.NEW2_DISPLAY_BACKLIGHT3] + AVAILABLE_SELECTS = [PhilipsApi.NEW2_GAS_PREFERRED_INDEX, PhilipsApi.NEW2_TIMER2] + + +class PhilipsAC0951(PhilipsAC0950): + """AC0951.""" + + # the AC1715 seems to be a new class of devices that follows some patterns of its own class PhilipsAC1715(PhilipsNewGenericCoAPFan): """AC1715.""" @@ -793,8 +864,8 @@ async def async_set_percentage(self, percentage: int) -> None: async def async_turn_on( self, - percentage: Optional[int] = None, - preset_mode: Optional[str] = None, + percentage: int | None = None, + preset_mode: str | None = None, **kwargs, ): """Turn on the device.""" @@ -1180,6 +1251,10 @@ class PhilipsAC3259(PhilipsGenericCoAPFan): AVAILABLE_SELECTS = [PhilipsApi.GAS_PREFERRED_INDEX] +class PhilipsAC3421(PhilipsAC0950): + """AC3421.""" + + class PhilipsAC3737(PhilipsNew2GenericCoAPFan): """AC3737.""" @@ -1786,7 +1861,13 @@ class PhilipsCX5120(PhilipsNew2GenericCoAPFan): model_to_class = { - FanModel.AC0850: PhilipsAC0850, + FanModel.AC0850_11: PhilipsAC085011, + FanModel.AC0850_11C: PhilipsAC085011C, + FanModel.AC0850_20: PhilipsAC085020, + FanModel.AC0850_20C: PhilipsAC085020C, + FanModel.AC0850_31: PhilipsAC085031, + FanModel.AC0950: PhilipsAC0950, + FanModel.AC0951: PhilipsAC0951, FanModel.AC1214: PhilipsAC1214, FanModel.AC1715: PhilipsAC1715, FanModel.AC2729: PhilipsAC2729, @@ -1801,6 +1882,7 @@ class PhilipsCX5120(PhilipsNew2GenericCoAPFan): FanModel.AC3055: PhilipsAC3055, FanModel.AC3059: PhilipsAC3059, FanModel.AC3259: PhilipsAC3259, + FanModel.AC3421: PhilipsAC3421, FanModel.AC3737: PhilipsAC3737, FanModel.AC3829: PhilipsAC3829, FanModel.AC3836: PhilipsAC3836, diff --git a/custom_components/philips_airpurifier_coap/select.py b/custom_components/philips_airpurifier_coap/select.py index 1447cd81..0b733444 100644 --- a/custom_components/philips_airpurifier_coap/select.py +++ b/custom_components/philips_airpurifier_coap/select.py @@ -1,4 +1,5 @@ """Philips Air Purifier & Humidifier Selects.""" + from __future__ import annotations from collections.abc import Callable @@ -55,11 +56,11 @@ async def async_setup_entry( cls_available_selects = getattr(cls, "AVAILABLE_SELECTS", []) available_selects.extend(cls_available_selects) - selects = [] - - for select in SELECT_TYPES: - if select in available_selects: - selects.append(PhilipsSelect(coordinator, name, model, select)) + selects = [ + PhilipsSelect(coordinator, name, model, select) + for select in SELECT_TYPES + if select in available_selects + ] async_add_entities(selects, update_before_add=False) @@ -98,9 +99,13 @@ def __init__( # noqa: D107 try: device_id = self._device_status[PhilipsApi.DEVICE_ID] self._attr_unique_id = f"{self._model}-{device_id}-{select.lower()}" - except Exception as e: - _LOGGER.error("Failed retrieving unique_id: %s", e) - raise PlatformNotReady + except KeyError as e: + _LOGGER.error("Failed retrieving unique_id due to missing key: %s", e) + raise PlatformNotReady from e + except TypeError as e: + _LOGGER.error("Failed retrieving unique_id due to type error: %s", e) + raise PlatformNotReady from e + self._attrs: dict[str, Any] = {} self.kind = select.partition("#")[0] @@ -108,6 +113,7 @@ def __init__( # noqa: D107 def current_option(self) -> str: """Return the currently selected option.""" option = self._device_status.get(self.kind) + _LOGGER.debug("current_option: %s", option) if option in self._options: return self._options[option] return None @@ -128,9 +134,10 @@ async def async_select_option(self, option: str) -> None: option_key, ) await self.coordinator.client.set_control_value(self.kind, option_key) - except Exception as e: - # TODO: catching Exception is actually too broad and needs to be tightened - _LOGGER.error("Failed setting option: '%s' with error: %s", option, e) + except KeyError as e: + _LOGGER.error("Invalid option key: '%s' with error: %s", option, e) + except ValueError as e: + _LOGGER.error("Invalid value for option: '%s' with error: %s", option, e) @property def icon(self) -> str: diff --git a/custom_components/philips_airpurifier_coap/sensor.py b/custom_components/philips_airpurifier_coap/sensor.py index 0b464def..c4f0f874 100644 --- a/custom_components/philips_airpurifier_coap/sensor.py +++ b/custom_components/philips_airpurifier_coap/sensor.py @@ -1,4 +1,5 @@ """Philips Air Purifier & Humidifier Sensors.""" + from __future__ import annotations from collections.abc import Callable @@ -66,19 +67,23 @@ async def async_setup_entry( # noqa: D103 cls_extra_sensors = getattr(cls, "EXTRA_SENSORS", []) extra_sensors.extend(cls_extra_sensors) - sensors = [] - - for sensor in SENSOR_TYPES: - if sensor in status and sensor not in unavailable_sensors: - sensors.append(PhilipsSensor(coordinator, name, model, sensor)) - - for sensor in EXTRA_SENSOR_TYPES: - if sensor in status and sensor in extra_sensors: - sensors.append(PhilipsSensor(coordinator, name, model, sensor)) - - for _filter in FILTER_TYPES: - if _filter in status and _filter not in unavailable_filters: - sensors.append(PhilipsFilterSensor(coordinator, name, model, _filter)) + sensors = ( + [ + PhilipsSensor(coordinator, name, model, sensor) + for sensor in SENSOR_TYPES + if sensor in status and sensor not in unavailable_sensors + ] + + [ + PhilipsSensor(coordinator, name, model, sensor) + for sensor in EXTRA_SENSOR_TYPES + if sensor in status and sensor in extra_sensors + ] + + [ + PhilipsFilterSensor(coordinator, name, model, _filter) + for _filter in FILTER_TYPES + if _filter in status and _filter not in unavailable_filters + ] + ) async_add_entities(sensors, update_before_add=False) @@ -117,9 +122,10 @@ def __init__( # noqa: D107 try: device_id = self._device_status[PhilipsApi.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 + except KeyError as e: + _LOGGER.error("Failed retrieving unique_id due to missing key: %s", e) + raise PlatformNotReady from e + self._attrs: dict[str, Any] = {} self.kind = kind @@ -180,9 +186,13 @@ def __init__( # noqa: D107 self._attr_unique_id = ( f"{self._model}-{device_id}-{self._description[FanAttributes.LABEL]}" ) - except Exception as e: - _LOGGER.error("Failed retrieving unique_id: %s", e) - raise PlatformNotReady + except KeyError as e: + _LOGGER.error("Failed retrieving unique_id due to missing key: %s", e) + raise PlatformNotReady from e + except TypeError as e: + _LOGGER.error("Failed retrieving unique_id due to type error: %s", e) + raise PlatformNotReady from e + self._attrs: dict[str, Any] = {} @property @@ -190,8 +200,7 @@ def native_value(self) -> StateType: """Return the native value of the filter sensor.""" if self._has_total: return self._percentage - else: - return self._time_remaining + return self._time_remaining @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/custom_components/philips_airpurifier_coap/switch.py b/custom_components/philips_airpurifier_coap/switch.py index 9de71c74..9aa617b5 100644 --- a/custom_components/philips_airpurifier_coap/switch.py +++ b/custom_components/philips_airpurifier_coap/switch.py @@ -1,4 +1,5 @@ """Philips Air Purifier & Humidifier Switches.""" + from __future__ import annotations from collections.abc import Callable @@ -57,11 +58,11 @@ async def async_setup_entry( cls_available_switches = getattr(cls, "AVAILABLE_SWITCHES", []) available_switches.extend(cls_available_switches) - switches = [] - - for switch in SWITCH_TYPES: - if switch in available_switches: - switches.append(PhilipsSwitch(coordinator, name, model, switch)) + switches = [ + PhilipsSwitch(coordinator, name, model, switch) + for switch in SWITCH_TYPES + if switch in available_switches + ] async_add_entities(switches, update_before_add=False) @@ -93,9 +94,10 @@ def __init__( # noqa: D107 try: device_id = self._device_status[PhilipsApi.DEVICE_ID] self._attr_unique_id = f"{self._model}-{device_id}-{switch.lower()}" - except Exception as e: + except KeyError as e: _LOGGER.error("Failed retrieving unique_id: %s", e) - raise PlatformNotReady + raise PlatformNotReady from e + self._attrs: dict[str, Any] = {} self.kind = switch diff --git a/custom_components/philips_airpurifier_coap/timer.py b/custom_components/philips_airpurifier_coap/timer.py index 978324b8..4c41bd7b 100644 --- a/custom_components/philips_airpurifier_coap/timer.py +++ b/custom_components/philips_airpurifier_coap/timer.py @@ -1,4 +1,5 @@ """Timer class to handle instable Philips CoaP API.""" + import asyncio import contextlib import logging @@ -78,3 +79,7 @@ def start(self): """Start the task.""" if self._task is None: self._task = asyncio.ensure_future(self._job()) + + def setAutoRestart(self, auto_restart): + """Set the autorestart.""" + self._auto_restart = auto_restart