diff --git a/README.md b/README.md index 312eb23..2e4e212 100644 --- a/README.md +++ b/README.md @@ -253,5 +253,8 @@ The integration also provides the original Philips icons for your use in the fro | ![Preview](./custom_components/philips_airpurifier_coap/icons/pap/heating.svg) | heating | | ![Preview](./custom_components/philips_airpurifier_coap/icons/pap/gas.svg) | gas | | ![Preview](./custom_components/philips_airpurifier_coap/icons/pap/circle.svg) | circle | +| ![Preview](./custom_components/philips_airpurifier_coap/icons/pap/temp_high.svg) | temp_high | +| ![Preview](./custom_components/philips_airpurifier_coap/icons/pap/temp_medium.svg) | temp_medium | +| ![Preview](./custom_components/philips_airpurifier_coap/icons/pap/temp_low.svg) | temp_low | Note: you might have to clear your browser cache after installation to see the icons. diff --git a/custom_components/philips_airpurifier_coap/climate.py b/custom_components/philips_airpurifier_coap/climate.py new file mode 100644 index 0000000..6f6a619 --- /dev/null +++ b/custom_components/philips_airpurifier_coap/climate.py @@ -0,0 +1,266 @@ +"""Philips Air Purifier & Humidifier Heater.""" + +from __future__ import annotations + +from collections.abc import Callable +import logging +from typing import Any + +from homeassistant.components.climate import ( + SWING_OFF, + SWING_ON, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity + +from .config_entry_data import ConfigEntryData +from .const import ( + DOMAIN, + HEATER_TYPES, + SWITCH_OFF, + SWITCH_ON, + FanAttributes, + PresetMode, +) +from .philips import PhilipsGenericControlBase, model_to_class + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: Callable[[list[Entity], bool], None], +) -> None: + """Set up the climate platform.""" + + config_entry_data: ConfigEntryData = hass.data[DOMAIN][entry.entry_id] + + model = config_entry_data.device_information.model + + model_class = model_to_class.get(model) + if model_class: + available_heaters = [] + available_preset_modes = {} + available_oscillation = {} + + for cls in reversed(model_class.__mro__): + # Get the available heaters from the base classes + cls_available_heaters = getattr(cls, "AVAILABLE_HEATERS", []) + available_heaters.extend(cls_available_heaters) + + # Get the available preset modes from the base classes + cls_available_preset_modes = getattr(cls, "AVAILABLE_PRESET_MODES", []) + available_preset_modes.update(cls_available_preset_modes) + + # Get the available oscillation from the base classes + cls_available_oscillation = getattr(cls, "KEY_OSCILLATION", {}) + _LOGGER.debug("Available oscillation: %s", cls_available_oscillation) + if cls_available_oscillation: + available_oscillation.update(cls_available_oscillation) + + heaters = [ + PhilipsHeater( + hass, + entry, + config_entry_data, + heater, + available_preset_modes, + available_oscillation, + ) + for heater in HEATER_TYPES + if heater in available_heaters + ] + async_add_entities(heaters, update_before_add=False) + + else: + _LOGGER.error("Unsupported model: %s", model) + return + + +class PhilipsHeater(PhilipsGenericControlBase, ClimateEntity): + """Define a Philips AirPurifier heater.""" + + _attr_is_on: bool | None = False + _attr_temperature_unit: str = UnitOfTemperature.CELSIUS + _attr_hvac_modes: list[HVACMode] = [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.AUTO, + HVACMode.FAN_ONLY, + ] + _attr_target_temperature_step: float = 1.0 + + def __init__( + self, + hass: HomeAssistant, + config: ConfigEntry, + config_entry_data: ConfigEntryData, + heater: str, + available_preset_modes: list[str], + available_oscillation: dict[str, dict[str, Any]], + ) -> None: + """Initialize the select.""" + + super().__init__(hass, config, config_entry_data) + + self._model = config_entry_data.device_information.model + latest_status = config_entry_data.latest_status + + self._description = HEATER_TYPES[heater] + + device_id = config_entry_data.device_information.device_id + self._attr_unique_id = f"{self._model}-{device_id}-{heater.lower()}" + + # preset modes in the climate entity are used for HVAC, so we use fan modes + self._preset_modes = available_preset_modes + self._attr_preset_modes = list(self._preset_modes.keys()) + + self._power_key = self._description[FanAttributes.POWER] + self._temperature_target_key = heater.partition("#")[0] + + self._attr_min_temp = self._description[FanAttributes.MIN_TEMPERATURE] + self._attr_max_temp = self._description[FanAttributes.MAX_TEMPERATURE] + self._attr_target_temperature = latest_status.get(self._temperature_target_key) + self._attr_current_temperature = latest_status.get( + self._description[FanAttributes.TEMPERATURE] + ) + + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + + # some devices can oscillate + if available_oscillation: + self._oscillation_key = list(available_oscillation.keys())[0] + self._oscillation_modes = available_oscillation[self._oscillation_key] + self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + self._attr_swing_modes = [SWING_ON, SWING_OFF] + + @property + def target_temperature(self) -> int | None: + """Return the target temperature.""" + return self._device_status.get(self._temperature_target_key) + + @property + def hvac_mode(self) -> HVACMode | None: + """Return the current HVAC mode.""" + if not self.is_on: + return HVACMode.OFF + if self.preset_mode == PresetMode.AUTO: + return HVACMode.AUTO + if self.preset_mode == PresetMode.VENTILATION: + return HVACMode.FAN_ONLY + return HVACMode.HEAT + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the HVAC mode of the heater.""" + if hvac_mode == HVACMode.OFF: + await self.async_turn_off() + elif hvac_mode == HVACMode.AUTO: + await self.async_set_preset_mode(PresetMode.AUTO) + elif hvac_mode == HVACMode.FAN_ONLY: + await self.async_set_preset_mode(PresetMode.VENTILATION) + elif hvac_mode == HVACMode.HEAT: + await self.async_set_preset_mode(PresetMode.LOW) + + @property + def preset_mode(self) -> str | None: + """Return the current fan mode.""" + + for fan_mode, status_pattern in self._preset_modes.items(): + for k, v in status_pattern.items(): + status = self._device_status.get(k) + if status != v: + break + else: + return fan_mode + + # no mode found + return None + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the fan mode of the heater.""" + if preset_mode not in self._attr_preset_modes: + return + + status_pattern = self._preset_modes.get(preset_mode) + await self.coordinator.client.set_control_values(data=status_pattern) + self._device_status.update(status_pattern) + self._handle_coordinator_update() + + @property + def swing_mode(self) -> str | None: + """Return the current swing mode.""" + if not self._oscillation_key: + return None + + value = self._device_status.get(self._oscillation_key) + if value == self._oscillation_modes[SWITCH_OFF]: + return SWING_OFF + + return SWING_ON + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set the swing mode of the heater.""" + if swing_mode not in self._attr_swing_modes: + return + + if swing_mode == SWING_ON: + value = self._oscillation_modes[SWITCH_ON] + else: + value = self._oscillation_modes[SWITCH_OFF] + + await self.coordinator.client.set_control_value(self._oscillation_key, value) + self._device_status[self._oscillation_key] = value + self._handle_coordinator_update() + + @property + def is_on(self) -> bool | None: + """Return the device state.""" + if ( + self._device_status.get(self._power_key) + == self._description[FanAttributes.OFF] + ): + return False + + return True + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the device.""" + await self.coordinator.client.set_control_values( + data={ + self._power_key: self._description[FanAttributes.ON], + } + ) + self._device_status[self._power_key] = self._description[FanAttributes.ON] + self._handle_coordinator_update() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the device.""" + await self.coordinator.client.set_control_values( + data={ + self._power_key: self._description[FanAttributes.OFF], + } + ) + self._device_status[self._power_key] = self._description[FanAttributes.OFF] + self._handle_coordinator_update() + + async def async_set_temperature(self, **kwargs) -> None: + """Select target temperature.""" + temperature = int(kwargs.get(ATTR_TEMPERATURE)) + + target = max(self._attr_min_temp, min(temperature, self._attr_max_temp)) + await self.coordinator.client.set_control_value( + self._temperature_target_key, target + ) + self._device_status[self._temperature_target_key] = temperature + self._handle_coordinator_update() diff --git a/custom_components/philips_airpurifier_coap/const.py b/custom_components/philips_airpurifier_coap/const.py index 4fdeaeb..da77039 100644 --- a/custom_components/philips_airpurifier_coap/const.py +++ b/custom_components/philips_airpurifier_coap/const.py @@ -24,6 +24,7 @@ from .model import ( FilterDescription, + HeaterDescription, HumidifierDescription, LightDescription, NumberDescription, @@ -304,7 +305,10 @@ class FanAttributes(StrEnum): MAX = "max" STEP = "step" TIMER = "timer" + TEMPERATURE = "temperature" TARGET_TEMP = "target_temperature" + MIN_TEMPERATURE = "min_temperature" + MAX_TEMPERATURE = "max_temperature" STANDBY_SENSORS = "standby_sensors" AUTO_PLUS = "auto_plus" WATER_TANK = "water_tank" @@ -384,11 +388,11 @@ class PhilipsApi: } OSCILLATION_MAP = { - SWITCH_ON: "17920", - SWITCH_OFF: "0", + SWITCH_ON: 17920, + SWITCH_OFF: 0, } OSCILLATION_MAP2 = { - SWITCH_ON: [17242, 23040], + SWITCH_ON: 17242, SWITCH_OFF: 0, } OSCILLATION_MAP3 = { @@ -1016,3 +1020,15 @@ class PhilipsApi: FanAttributes.STEP: 5, }, } + +HEATER_TYPES: dict[str, HeaterDescription] = { + PhilipsApi.NEW2_TARGET_TEMP: { + FanAttributes.TEMPERATURE: PhilipsApi.TEMPERATURE, + FanAttributes.POWER: PhilipsApi.NEW2_POWER, + FanAttributes.ON: 1, + FanAttributes.OFF: 0, + FanAttributes.MIN_TEMPERATURE: 1, + FanAttributes.MAX_TEMPERATURE: 37, + FanAttributes.STEP: 1, + }, +} diff --git a/custom_components/philips_airpurifier_coap/icons.json b/custom_components/philips_airpurifier_coap/icons.json index 5844146..e721b3f 100644 --- a/custom_components/philips_airpurifier_coap/icons.json +++ b/custom_components/philips_airpurifier_coap/icons.json @@ -44,6 +44,9 @@ }, "humidifier": { "pap": { + "state": { + "off": "pap:power_button" + }, "state_attributes": { "mode": { "state": { @@ -74,6 +77,33 @@ } } }, + "climate": { + "pap": { + "state": { + "off": "pap:power_button", + "auto": "pap:auto_mode", + "fan_only": "pap:circulate", + "heat": "pap:heating" + }, + "state_attributes": { + "preset_mode": { + "state": { + "auto": "pap:auto_mode", + "high": "pap:temp_high", + "medium": "pap:temp_medium", + "low": "pap:temp_low", + "ventilation": "pap:circulate" + } + }, + "swing_mode": { + "state": { + "off": "pap:rotate", + "on": "pap:rotate" + } + } + } + } + }, "binary_sensor": { "water_tank": { "state": { diff --git a/custom_components/philips_airpurifier_coap/icons/pap/temp_high.svg b/custom_components/philips_airpurifier_coap/icons/pap/temp_high.svg new file mode 100644 index 0000000..6f0b68b --- /dev/null +++ b/custom_components/philips_airpurifier_coap/icons/pap/temp_high.svg @@ -0,0 +1,4 @@ + + + + diff --git a/custom_components/philips_airpurifier_coap/icons/pap/temp_low.svg b/custom_components/philips_airpurifier_coap/icons/pap/temp_low.svg new file mode 100644 index 0000000..bfd1934 --- /dev/null +++ b/custom_components/philips_airpurifier_coap/icons/pap/temp_low.svg @@ -0,0 +1,4 @@ + + + + diff --git a/custom_components/philips_airpurifier_coap/icons/pap/temp_medium.svg b/custom_components/philips_airpurifier_coap/icons/pap/temp_medium.svg new file mode 100644 index 0000000..7aaa680 --- /dev/null +++ b/custom_components/philips_airpurifier_coap/icons/pap/temp_medium.svg @@ -0,0 +1,4 @@ + + + + diff --git a/custom_components/philips_airpurifier_coap/model.py b/custom_components/philips_airpurifier_coap/model.py index 9a66dc8..a604da1 100644 --- a/custom_components/philips_airpurifier_coap/model.py +++ b/custom_components/philips_airpurifier_coap/model.py @@ -96,7 +96,6 @@ class NumberDescription(TypedDict): class HumidifierDescription(TypedDict): """Humidifier description class.""" - icon: str label: str humidity: str power: str @@ -108,3 +107,15 @@ class HumidifierDescription(TypedDict): switch: bool max_humidity: str min_humidity: str + + +class HeaterDescription(TypedDict): + """Heater description class.""" + + temperature: str + power: str + on: Any + off: Any + min_temperature: int + max_temperature: int + step: int diff --git a/custom_components/philips_airpurifier_coap/philips.py b/custom_components/philips_airpurifier_coap/philips.py index 754b394..c19893f 100644 --- a/custom_components/philips_airpurifier_coap/philips.py +++ b/custom_components/philips_airpurifier_coap/philips.py @@ -210,18 +210,6 @@ def __init__( super().__init__(hass, entry, config_entry_data) - # self._attr_name = next( - # name - # for name in ( - # self._device_status.get(key) - # for key in [ - # PhilipsApi.NAME, - # PhilipsApi.NEW_NAME, - # PhilipsApi.NEW2_NAME, - # ] - # ) - # if name - # ) model = config_entry_data.device_information.model device_id = config_entry_data.device_information.device_id self._attr_unique_id = f"{model}-{device_id}" @@ -358,14 +346,12 @@ async def async_oscillate(self, oscillating: bool) -> None: on = values.get(SWITCH_ON) off = values.get(SWITCH_OFF) - on_value = on if isinstance(on, int) else on[0] - if oscillating: - await self.coordinator.client.set_control_value(key, on_value) + await self.coordinator.client.set_control_value(key, on) else: await self.coordinator.client.set_control_value(key, off) - self._device_status[key] = on_value if oscillating else off + self._device_status[key] = on if oscillating else off self._handle_coordinator_update() @property @@ -863,6 +849,12 @@ class PhilipsAC2729(PhilipsGenericFan): AVAILABLE_HUMIDIFIERS = [PhilipsApi.HUMIDITY_TARGET] AVAILABLE_BINARY_SENSORS = [PhilipsApi.ERROR_CODE] + # only for experimental purposes + # AVAILABLE_HEATERS = [PhilipsApi.NEW2_TARGET_TEMP] + # KEY_OSCILLATION = { + # PhilipsApi.NEW2_OSCILLATION: PhilipsApi.OSCILLATION_MAP3, + # } + class PhilipsAC2889(PhilipsGenericFan): """AC2889.""" @@ -1872,6 +1864,9 @@ class PhilipsCX3120(PhilipsNew2GenericFan): AVAILABLE_NUMBERS = [PhilipsApi.NEW2_TARGET_TEMP] AVAILABLE_SWITCHES = [PhilipsApi.NEW2_CHILD_LOCK] + CREATE_FAN = True # later set to false once everything is working + AVAILABLE_HEATERS = [PhilipsApi.NEW2_TARGET_TEMP] + class PhilipsCX5120(PhilipsNew2GenericFan): """CX5120.""" @@ -1911,7 +1906,7 @@ class PhilipsCX5120(PhilipsNew2GenericFan): }, } KEY_OSCILLATION = { - PhilipsApi.NEW2_OSCILLATION: PhilipsApi.OSCILLATION_MAP, + PhilipsApi.NEW2_OSCILLATION: PhilipsApi.OSCILLATION_MAP2, } AVAILABLE_LIGHTS = [PhilipsApi.NEW2_DISPLAY_BACKLIGHT2] @@ -1920,6 +1915,9 @@ class PhilipsCX5120(PhilipsNew2GenericFan): AVAILABLE_SELECTS = [PhilipsApi.NEW2_TIMER2] AVAILABLE_NUMBERS = [PhilipsApi.NEW2_TARGET_TEMP] + CREATE_FAN = True # later set to false once everything is working + AVAILABLE_HEATERS = [PhilipsApi.NEW2_TARGET_TEMP] + class PhilipsCX3550(PhilipsNew2GenericFan): """CX3550.""" diff --git a/custom_components/philips_airpurifier_coap/strings.json b/custom_components/philips_airpurifier_coap/strings.json index a6b15ab..071d4f9 100644 --- a/custom_components/philips_airpurifier_coap/strings.json +++ b/custom_components/philips_airpurifier_coap/strings.json @@ -98,6 +98,39 @@ } } }, + "climate": { + "pap": { + "name": "Fan heater", + "state": { + "off": "Off", + "auto": "Auto", + "fan_only": "Ventilation", + "heat": "Heating" + }, + "state_attributes": { + "preset_mode": { + "name": "Preset mode", + "state": { + "auto": "Auto", + "high": "High", + "medium": "Medium", + "low": "Low", + "ventilation": "Ventilation" + } + }, + "swing_mode": { + "name": "Swing mode", + "state": { + "on": "On", + "off": "Off" + } + }, + "current_temperature": { + "name": "Current temperature" + } + } + } + }, "binary_sensor": { "water_tank": { "name": "Water tank", diff --git a/custom_components/philips_airpurifier_coap/switch.py b/custom_components/philips_airpurifier_coap/switch.py index a8e691f..a362452 100644 --- a/custom_components/philips_airpurifier_coap/switch.py +++ b/custom_components/philips_airpurifier_coap/switch.py @@ -86,7 +86,7 @@ def __init__( @property def is_on(self) -> bool: """Return if switch is on.""" - return self._device_status.get(self.kind) == self._on + return self._device_status.get(self.kind) != self._off async def async_turn_on(self, **kwargs) -> None: """Switch the switch on.""" diff --git a/custom_components/philips_airpurifier_coap/translations/de.json b/custom_components/philips_airpurifier_coap/translations/de.json index c8d5e8d..7c43160 100644 --- a/custom_components/philips_airpurifier_coap/translations/de.json +++ b/custom_components/philips_airpurifier_coap/translations/de.json @@ -98,6 +98,39 @@ } } }, + "climate": { + "pap": { + "name": "Heizlüfter", + "state": { + "off": "Aus", + "auto": "Auto", + "fan_only": "Belüftung", + "heat": "Heizung" + }, + "state_attributes": { + "preset_mode": { + "name": "Voreinstellung", + "state": { + "auto": "Auto", + "high": "Hoch", + "medium": "Mittel", + "low": "Niedrig", + "ventilation": "Belüftung" + } + }, + "swing_mode": { + "name": "Rotationsmodus", + "state": { + "on": "An", + "off": "Aus" + } + }, + "current_temperature": { + "name": "Aktuelle Temperatur" + } + } + } + }, "binary_sensor": { "water_tank": { "name": "Wassertank", diff --git a/custom_components/philips_airpurifier_coap/translations/en.json b/custom_components/philips_airpurifier_coap/translations/en.json index a6b15ab..071d4f9 100644 --- a/custom_components/philips_airpurifier_coap/translations/en.json +++ b/custom_components/philips_airpurifier_coap/translations/en.json @@ -98,6 +98,39 @@ } } }, + "climate": { + "pap": { + "name": "Fan heater", + "state": { + "off": "Off", + "auto": "Auto", + "fan_only": "Ventilation", + "heat": "Heating" + }, + "state_attributes": { + "preset_mode": { + "name": "Preset mode", + "state": { + "auto": "Auto", + "high": "High", + "medium": "Medium", + "low": "Low", + "ventilation": "Ventilation" + } + }, + "swing_mode": { + "name": "Swing mode", + "state": { + "on": "On", + "off": "Off" + } + }, + "current_temperature": { + "name": "Current temperature" + } + } + } + }, "binary_sensor": { "water_tank": { "name": "Water tank",