Skip to content
This repository has been archived by the owner on Dec 14, 2022. It is now read-only.

Move some fan attributes to sensors. #56

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 5 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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
```

Expand All @@ -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.
Expand Down
198 changes: 198 additions & 0 deletions custom_components/philips_airpurifier_coap/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,199 @@
"""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,
)
from .model import DeviceStatus

_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", "sensor"]


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

# 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

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():
_LOGGER.debug("Status update: %s", 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()
79 changes: 78 additions & 1 deletion custom_components/philips_airpurifier_coap/const.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
"""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 = "fan.philips_airpurifier"

DATA_KEY_CLIENT = "client"
DATA_KEY_COORDINATOR = "coordinator"
DATA_KEY_FAN = "fan"

DEFAULT_NAME = "Philips AirPurifier"
DEFAULT_ICON = "mdi:air-purifier"
Expand Down Expand Up @@ -60,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"
Expand Down Expand Up @@ -129,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,
},
}
Loading