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",