Skip to content

Commit

Permalink
introduced the "fetchers" concept and removed broken protocols (#10)
Browse files Browse the repository at this point in the history
  • Loading branch information
urbas authored Nov 17, 2024
1 parent d441a53 commit 3558d6b
Show file tree
Hide file tree
Showing 15 changed files with 281 additions and 248 deletions.
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# 0.4.0 (upcoming)

- By default `py-air-control-exporter` now listens on port `127.0.0.1`.
- Support to provide multiple air quality sensors in a config file.
- All metrics now have labels `host` and `name`.
- Added support for multiple air quality sensors (targets) in a config file.
- Added labels `host` and `name` to all metrics.
- Removed support for broken `coap` and `plain_coap` protocols.

# 0.3.1

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ pip install py-air-control-exporter
## Running

```bash
py-air-control-exporter --host 192.168.1.105 --protocol <http|coap|plain_coap>
py-air-control-exporter --host 192.168.1.105 --protocol http
```

This will serve metrics at `http://0.0.0.0:9896/metrics`.
Expand Down
6 changes: 6 additions & 0 deletions py_air_control_exporter/fetchers/fetcher_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from py_air_control_exporter import fetchers_api
from py_air_control_exporter.fetchers import http_philips

KNOWN_FETCHERS: dict[str, fetchers_api.FetcherCreator] = {
"http": http_philips.create_fetcher,
}
58 changes: 58 additions & 0 deletions py_air_control_exporter/fetchers/http_philips.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from pyairctrl import http_client

from py_air_control_exporter import fetchers_api
from py_air_control_exporter.logging import LOG

_FAN_SPEED_TO_INT = {"s": 0, "1": 1, "2": 2, "3": 3, "t": 4}


def create_fetcher(config: fetchers_api.FetcherCreatorArgs) -> fetchers_api.Fetcher:
return lambda target_host=config.target_host: get_reading(target_host)


def get_reading(
host: str,
) -> fetchers_api.TargetReading | None:
client = http_client.HTTPAirClient(host)

try:
status_data = client.get_status() or {}
filters_data = client.get_filters() or {}

return fetchers_api.TargetReading(
air_quality=create_air_quality(status_data),
control_info=create_control_info(status_data),
filters=create_filter_info(filters_data),
)
except Exception as ex:
LOG.error(
"Could not read values from air control device %s. Error: %s",
host,
ex,
)
return None


def create_air_quality(status_data: dict) -> fetchers_api.AirQuality:
return fetchers_api.AirQuality(iaql=status_data["iaql"], pm25=status_data["pm25"])


def create_control_info(status_data: dict) -> fetchers_api.ControlInfo:
return fetchers_api.ControlInfo(
fan_speed=_FAN_SPEED_TO_INT[status_data["om"]],
is_manual=status_data["mode"] == "M",
is_on=status_data["pwr"] == "1",
)


def create_filter_info(filters_data: dict) -> dict[str, fetchers_api.Filter]:
filters: dict[str, fetchers_api.Filter] = {}
for key, value in filters_data.items():
if key.startswith("fltsts"):
filter_id = key[6:]
filters[filter_id] = fetchers_api.Filter(
hours=value,
filter_type=filters_data.get(f"fltt{filter_id}", ""),
)

return filters
40 changes: 40 additions & 0 deletions py_air_control_exporter/fetchers_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from collections.abc import Callable
from dataclasses import dataclass


@dataclass(frozen=True)
class AirQuality:
iaql: float # IAI allergen index
pm25: float


@dataclass(frozen=True)
class ControlInfo:
fan_speed: float
is_manual: bool
is_on: bool


@dataclass(frozen=True)
class Filter:
hours: float # Hours remaining before replacement
filter_type: str


@dataclass(frozen=True)
class TargetReading:
air_quality: AirQuality | None
control_info: ControlInfo | None
filters: dict[str, Filter] | None


Fetcher = Callable[[], TargetReading | None]


@dataclass(frozen=True)
class FetcherCreatorArgs:
target_host: str
target_name: str


FetcherCreator = Callable[[FetcherCreatorArgs], Fetcher]
45 changes: 32 additions & 13 deletions py_air_control_exporter/main.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import logging
import sys
from pathlib import Path
from typing import Any

import click
import yaml

from py_air_control_exporter import app, metrics, py_air_fetcher
from py_air_control_exporter import app, fetchers_api, metrics
from py_air_control_exporter.fetchers import fetcher_registry
from py_air_control_exporter.logging import LOG


Expand All @@ -29,13 +31,9 @@
)
@click.option(
"--protocol",
default=py_air_fetcher.HTTP_PROTOCOL,
default="http",
type=click.Choice(
[
py_air_fetcher.HTTP_PROTOCOL,
py_air_fetcher.COAP_PROTOCOL,
py_air_fetcher.PLAIN_COAP_PROTOCOL,
],
tuple(fetcher_registry.KNOWN_FETCHERS.keys()),
case_sensitive=False,
),
show_default=True,
Expand Down Expand Up @@ -69,6 +67,14 @@ def main(host, name, protocol, listen_address, listen_port, config, verbose, qui
targets_config[name or host] = {"host": host, "protocol": protocol}

targets = create_targets(targets_config)

if targets is None:
sys.exit(1)

if not targets:
LOG.error("No targets specified. Please specify at least one target.")
sys.exit(1)

app.create_app(targets).run(host=listen_address, port=listen_port)


Expand All @@ -93,17 +99,30 @@ def load_config(config_path: Path | None) -> dict[str, Any]:

def create_targets(
targets_config: dict[str, dict] | None = None,
) -> dict[str, metrics.Target]:
) -> dict[str, metrics.Target] | None:
targets = {}

if targets_config:
for name, target_config in targets_config.items():
host_addr = target_config["host"]
fetcher_config = fetchers_api.FetcherCreatorArgs(
target_host=target_config["host"],
target_name=name,
)
protocol = target_config["protocol"]

if protocol not in fetcher_registry.KNOWN_FETCHERS:
LOG.error(
"Unknown protocol '%s' for target '%s'. Known protocols: %s",
protocol,
name,
", ".join(fetcher_registry.KNOWN_FETCHERS.keys()),
)
return None

targets[name] = metrics.Target(
host=host_addr,
name=name,
fetcher=lambda h=host_addr,
p=target_config["protocol"]: py_air_fetcher.get_reading(h, p),
host=fetcher_config.target_host,
name=fetcher_config.target_name,
fetcher=fetcher_registry.KNOWN_FETCHERS[protocol](fetcher_config),
)

return targets
89 changes: 44 additions & 45 deletions py_air_control_exporter/metrics.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,19 @@
import itertools
from collections.abc import Callable, Iterable
from collections.abc import Iterable
from dataclasses import dataclass

import prometheus_client.core
from prometheus_client import Metric, registry

from py_air_control_exporter import fetchers_api
from py_air_control_exporter.logging import LOG


@dataclass(frozen=True)
class Status:
fan_speed: float
iaql: float # IAI allergen index
is_manual: bool
is_on: bool
pm25: float


@dataclass(frozen=True)
class Filter:
hours: float # Hours remaining before replacement
filter_type: str


@dataclass(frozen=True)
class TargetReading:
status: Status | None
filters: dict[str, Filter] | None


@dataclass(frozen=True)
class Target:
host: str
name: str
fetcher: Callable[[], TargetReading | None]
fetcher: fetchers_api.Fetcher


class PyAirControlCollector(registry.Collector):
Expand All @@ -43,7 +23,7 @@ def __init__(self, targets: dict[str, Target]):

def collect(self):
targets_with_errors: set[str] = set()
target_readings: dict[str, TargetReading] = {}
target_readings: dict[str, fetchers_api.TargetReading] = {}
for name, target in self._targets.items():
target_reading = None
try:
Expand All @@ -65,7 +45,8 @@ def collect(self):

return itertools.chain(
self._sampling_error(targets_with_errors),
self._get_status_metrics(target_readings),
self._air_quality_metrics(target_readings),
self._control_info_metrics(target_readings),
self._get_filters_metrics(target_readings),
)

Expand All @@ -82,13 +63,37 @@ def _sampling_error(self, targets_with_errors: set[str]) -> Iterable[Metric]:
sampling_error.add_metric([target.host, target.name], error_counter)
return [sampling_error]

def _get_status_metrics(self, statuses: dict[str, TargetReading]):
air_quality = prometheus_client.core.GaugeMetricFamily(
def _air_quality_metrics(self, statuses: dict[str, fetchers_api.TargetReading]):
iaql = prometheus_client.core.GaugeMetricFamily(
"py_air_control_air_quality",
"IAI allergen index from 1 to 12, where 1 indicates best air quality.",
labels=["host", "name"],
)

pm25 = prometheus_client.core.GaugeMetricFamily(
"py_air_control_pm25",
"Micrograms of PM2.5 particles per cubic metre.",
labels=["host", "name"],
)

for name, full_status in statuses.items():
air_quality = full_status.air_quality
if air_quality is None:
LOG.info("No air quality information from air quality host '%s'.", name)
continue

target = self._targets[name]
LOG.debug(
"Got the following air quality information from host '%s': %s",
target.host,
air_quality,
)
iaql.add_metric([target.host, target.name], air_quality.iaql)
pm25.add_metric([target.host, target.name], air_quality.pm25)

return [iaql, pm25]

def _control_info_metrics(self, statuses: dict[str, fetchers_api.TargetReading]):
is_manual = prometheus_client.core.GaugeMetricFamily(
"py_air_control_is_manual",
"Value '1' indicates manual mode while value '0' indicates automatic mode.",
Expand All @@ -102,12 +107,6 @@ def _get_status_metrics(self, statuses: dict[str, TargetReading]):
labels=["host", "name"],
)

pm25 = prometheus_client.core.GaugeMetricFamily(
"py_air_control_pm25",
"Micrograms of PM2.5 particles per cubic metre.",
labels=["host", "name"],
)

speed = prometheus_client.core.GaugeMetricFamily(
"py_air_control_speed",
"The fan speed setting (0 is sleep, 1-3 correspond to level settings, "
Expand All @@ -116,28 +115,28 @@ def _get_status_metrics(self, statuses: dict[str, TargetReading]):
)

for name, full_status in statuses.items():
status = full_status.status
if status is None:
LOG.info("No status from air quality host '%s'.", name)
control_info = full_status.control_info
if control_info is None:
LOG.info("No control info for air quality host '%s'.", name)
continue

target = self._targets[name]
LOG.debug(
"Got the following status from host '%s': %s",
"Got the following control info from host '%s': %s",
target.host,
status,
control_info,
)
air_quality.add_metric([target.host, target.name], status.iaql)
is_manual.add_metric(
[target.host, target.name], 1 if status.is_manual else 0
[target.host, target.name], 1 if control_info.is_manual else 0
)
is_on.add_metric([target.host, target.name], 1 if status.is_on else 0)
pm25.add_metric([target.host, target.name], status.pm25)
speed.add_metric([target.host, target.name], status.fan_speed)
is_on.add_metric([target.host, target.name], 1 if control_info.is_on else 0)
speed.add_metric([target.host, target.name], control_info.fan_speed)

return [air_quality, is_manual, is_on, pm25, speed]
return [is_manual, is_on, speed]

def _get_filters_metrics(self, target_readings: dict[str, TargetReading]):
def _get_filters_metrics(
self, target_readings: dict[str, fetchers_api.TargetReading]
):
filter_metric_family = prometheus_client.core.GaugeMetricFamily(
"py_air_control_filter_hours",
"The number of values left before the filter has to be replaced or cleaned",
Expand Down
Loading

0 comments on commit 3558d6b

Please sign in to comment.