From ac72dea09a98c37172626787466d34d59feec5bc Mon Sep 17 00:00:00 2001 From: Russell Cloran Date: Mon, 10 Jul 2017 21:20:17 -0700 Subject: [PATCH] Add support for Prometheus (#8211) Prometheus (https://prometheus.io/) is an open source metric and alerting system. This adds support for exporting some metrics to Prometheus, using its Python client library. --- .coveragerc | 1 + homeassistant/components/prometheus.py | 234 +++++++++++++++++++++++++ homeassistant/helpers/state.py | 13 +- requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/test_prometheus.py | 33 ++++ tests/helpers/test_state.py | 8 +- 8 files changed, 287 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/prometheus.py create mode 100644 tests/components/test_prometheus.py diff --git a/.coveragerc b/.coveragerc index 8d2c85627ed..0defebb7e7a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -383,6 +383,7 @@ omit = homeassistant/components/notify/twitter.py homeassistant/components/notify/xmpp.py homeassistant/components/nuimo_controller.py + homeassistant/components/prometheus.py homeassistant/components/remote/harmony.py homeassistant/components/remote/itach.py homeassistant/components/scene/hunterdouglas_powerview.py diff --git a/homeassistant/components/prometheus.py b/homeassistant/components/prometheus.py new file mode 100644 index 00000000000..4ed6028ac56 --- /dev/null +++ b/homeassistant/components/prometheus.py @@ -0,0 +1,234 @@ +""" +Support for Prometheus metrics export. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/prometheus/ +""" +import asyncio +import logging + +import voluptuous as vol +from aiohttp import web + +from homeassistant.components.http import HomeAssistantView +from homeassistant.components import recorder +from homeassistant.const import (CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, + CONF_INCLUDE, EVENT_STATE_CHANGED, + TEMP_CELSIUS, TEMP_FAHRENHEIT) +from homeassistant import core as hacore +from homeassistant.helpers import state as state_helper + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['prometheus_client==0.0.19'] + +DOMAIN = 'prometheus' +DEPENDENCIES = ['http'] + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: recorder.FILTER_SCHEMA, +}, extra=vol.ALLOW_EXTRA) + +API_ENDPOINT = '/api/prometheus' + + +def setup(hass, config): + """Activate Prometheus component.""" + import prometheus_client + + hass.http.register_view(PrometheusView(prometheus_client)) + + conf = config.get(DOMAIN, {}) + exclude = conf.get(CONF_EXCLUDE, {}) + include = conf.get(CONF_INCLUDE, {}) + metrics = Metrics(prometheus_client, exclude, include) + + hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_event) + + return True + + +class Metrics: + """Model all of the metrics which should be exposed to Prometheus.""" + + def __init__(self, prometheus_client, exclude, include): + """Initialize Prometheus Metrics.""" + self.prometheus_client = prometheus_client + self.exclude = exclude.get(CONF_ENTITIES, []) + \ + exclude.get(CONF_DOMAINS, []) + self.include_domains = include.get(CONF_DOMAINS, []) + self.include_entities = include.get(CONF_ENTITIES, []) + self._metrics = {} + + def handle_event(self, event): + """Listen for new messages on the bus, and add them to Prometheus.""" + state = event.data.get('new_state') + if state is None: + return + + entity_id = state.entity_id + _LOGGER.debug("Handling state update for %s", entity_id) + domain, _ = hacore.split_entity_id(entity_id) + + if entity_id in self.exclude: + return + if domain in self.exclude and entity_id not in self.include_entities: + return + if self.include_domains and domain not in self.include_domains: + return + if not self.exclude and (self.include_entities and + entity_id not in self.include_entities): + return + + handler = '_handle_' + domain + + if hasattr(self, handler): + getattr(self, handler)(state) + + def _metric(self, metric, factory, documentation, labels=None): + if labels is None: + labels = ['entity', 'friendly_name'] + + try: + return self._metrics[metric] + except KeyError: + self._metrics[metric] = factory(metric, documentation, labels) + return self._metrics[metric] + + @staticmethod + def _labels(state): + return { + 'entity': state.entity_id, + 'friendly_name': state.attributes.get('friendly_name'), + } + + def _battery(self, state): + if 'battery_level' in state.attributes: + metric = self._metric( + 'battery_level_percent', + self.prometheus_client.Gauge, + 'Battery level as a percentage of its capacity', + ) + try: + value = float(state.attributes['battery_level']) + metric.labels(**self._labels(state)).set(value) + except ValueError: + pass + + def _handle_binary_sensor(self, state): + metric = self._metric( + 'binary_sensor_state', + self.prometheus_client.Gauge, + 'State of the binary sensor (0/1)', + ) + value = state_helper.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + + def _handle_device_tracker(self, state): + metric = self._metric( + 'device_tracker_state', + self.prometheus_client.Gauge, + 'State of the device tracker (0/1)', + ) + value = state_helper.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + + def _handle_light(self, state): + metric = self._metric( + 'light_state', + self.prometheus_client.Gauge, + 'Load level of a light (0..1)', + ) + + try: + if 'brightness' in state.attributes: + value = state.attributes['brightness'] / 255.0 + else: + value = state_helper.state_as_number(state) + value = value * 100 + metric.labels(**self._labels(state)).set(value) + except ValueError: + pass + + def _handle_lock(self, state): + metric = self._metric( + 'lock_state', + self.prometheus_client.Gauge, + 'State of the lock (0/1)', + ) + value = state_helper.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + + def _handle_sensor(self, state): + _sensor_types = { + TEMP_CELSIUS: ( + 'temperature_c', self.prometheus_client.Gauge, + 'Temperature in degrees Celsius', + ), + TEMP_FAHRENHEIT: ( + 'temperature_c', self.prometheus_client.Gauge, + 'Temperature in degrees Celsius', + ), + '%': ( + 'relative_humidity', self.prometheus_client.Gauge, + 'Relative humidity (0..100)', + ), + 'lux': ( + 'light_lux', self.prometheus_client.Gauge, + 'Light level in lux', + ), + 'kWh': ( + 'electricity_used_kwh', self.prometheus_client.Gauge, + 'Electricity used by this device in KWh', + ), + 'V': ( + 'voltage', self.prometheus_client.Gauge, + 'Currently reported voltage in Volts', + ), + 'W': ( + 'electricity_usage_w', self.prometheus_client.Gauge, + 'Currently reported electricity draw in Watts', + ), + } + + unit = state.attributes.get('unit_of_measurement') + metric = _sensor_types.get(unit) + + if metric is not None: + metric = self._metric(*metric) + try: + value = state_helper.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + except ValueError: + pass + + self._battery(state) + + def _handle_switch(self, state): + metric = self._metric( + 'switch_state', + self.prometheus_client.Gauge, + 'State of the switch (0/1)', + ) + value = state_helper.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + + +class PrometheusView(HomeAssistantView): + """Handle Prometheus requests.""" + + url = API_ENDPOINT + name = 'api:prometheus' + + def __init__(self, prometheus_client): + """Initialize Prometheus view.""" + self.prometheus_client = prometheus_client + + @asyncio.coroutine + def get(self, request): + """Handle request for Prometheus metrics.""" + _LOGGER.debug('Received Prometheus metrics request') + + return web.Response( + body=self.prometheus_client.generate_latest(), + content_type="text/plain") diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index 7715e49880d..19113f243d2 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -25,15 +25,16 @@ from homeassistant.components.climate.ecobee import ( ATTR_FAN_MIN_ON_TIME, SERVICE_SET_FAN_MIN_ON_TIME, ATTR_RESUME_ALL, SERVICE_RESUME_PROGRAM) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_TEMPERATURE, SERVICE_ALARM_ARM_AWAY, + ATTR_ENTITY_ID, ATTR_OPTION, ATTR_TEMPERATURE, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_DISARM, SERVICE_ALARM_TRIGGER, SERVICE_LOCK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_SEEK, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_UNLOCK, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, STATE_CLOSED, STATE_LOCKED, - STATE_OFF, STATE_ON, STATE_OPEN, STATE_PAUSED, STATE_PLAYING, - STATE_UNKNOWN, STATE_UNLOCKED, SERVICE_SELECT_OPTION, ATTR_OPTION) + STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, STATE_CLOSED, STATE_HOME, + STATE_LOCKED, STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, + STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, STATE_UNLOCKED, + SERVICE_SELECT_OPTION) from homeassistant.core import State from homeassistant.util.async import run_coroutine_threadsafe @@ -203,10 +204,10 @@ def state_as_number(state): Raises ValueError if this is not possible. """ if state.state in (STATE_ON, STATE_LOCKED, STATE_ABOVE_HORIZON, - STATE_OPEN): + STATE_OPEN, STATE_HOME): return 1 elif state.state in (STATE_OFF, STATE_UNLOCKED, STATE_UNKNOWN, - STATE_BELOW_HORIZON, STATE_CLOSED): + STATE_BELOW_HORIZON, STATE_CLOSED, STATE_NOT_HOME): return 0 return float(state.state) diff --git a/requirements_all.txt b/requirements_all.txt index 37e3f8502f9..80eb6987ce8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -476,6 +476,9 @@ pocketcasts==0.1 # homeassistant.components.climate.proliphix proliphix==0.4.1 +# homeassistant.components.prometheus +prometheus_client==0.0.19 + # homeassistant.components.sensor.systemmonitor psutil==5.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8ea6c98042b..58fdcecf63c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -88,6 +88,9 @@ pilight==0.1.1 # homeassistant.components.sensor.serial_pm pmsensor==0.4 +# homeassistant.components.prometheus +prometheus_client==0.0.19 + # homeassistant.components.zwave pydispatcher==2.0.5 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index f1f0678c60f..7e2f1d99f2a 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -69,6 +69,7 @@ TEST_REQUIREMENTS = ( 'PyJWT', 'restrictedpython', 'pyunifi', + 'prometheus_client', ) IGNORE_PACKAGES = ( diff --git a/tests/components/test_prometheus.py b/tests/components/test_prometheus.py new file mode 100644 index 00000000000..dd8cbfe55e0 --- /dev/null +++ b/tests/components/test_prometheus.py @@ -0,0 +1,33 @@ +"""The tests for the Prometheus exporter.""" +import asyncio +import pytest + +from homeassistant.setup import async_setup_component +import homeassistant.components.prometheus as prometheus + + +@pytest.fixture +def prometheus_client(loop, hass, test_client): + """Initialize a test_client with Prometheus component.""" + assert loop.run_until_complete(async_setup_component( + hass, + prometheus.DOMAIN, + {}, + )) + return loop.run_until_complete(test_client(hass.http.app)) + + +@asyncio.coroutine +def test_view(prometheus_client): # pylint: disable=redefined-outer-name + """Test prometheus metrics view.""" + resp = yield from prometheus_client.get(prometheus.API_ENDPOINT) + + assert resp.status == 200 + assert resp.headers['content-type'] == 'text/plain' + body = yield from resp.text() + body = body.split("\n") + + assert len(body) > 3 # At least two comment lines and a metric + for line in body: + if line: + assert line.startswith('# ') or line.startswith('process_') diff --git a/tests/helpers/test_state.py b/tests/helpers/test_state.py index e9d163ad471..cc42bc8d7f8 100644 --- a/tests/helpers/test_state.py +++ b/tests/helpers/test_state.py @@ -13,7 +13,8 @@ from homeassistant.helpers import state from homeassistant.const import ( STATE_OPEN, STATE_CLOSED, STATE_LOCKED, STATE_UNLOCKED, - STATE_ON, STATE_OFF) + STATE_ON, STATE_OFF, + STATE_HOME, STATE_NOT_HOME) from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PAUSE) from homeassistant.components.sun import (STATE_ABOVE_HORIZON, @@ -258,8 +259,9 @@ class TestStateHelpers(unittest.TestCase): def test_as_number_states(self): """Test state_as_number with states.""" zero_states = (STATE_OFF, STATE_CLOSED, STATE_UNLOCKED, - STATE_BELOW_HORIZON) - one_states = (STATE_ON, STATE_OPEN, STATE_LOCKED, STATE_ABOVE_HORIZON) + STATE_BELOW_HORIZON, STATE_NOT_HOME) + one_states = (STATE_ON, STATE_OPEN, STATE_LOCKED, STATE_ABOVE_HORIZON, + STATE_HOME) for _state in zero_states: self.assertEqual(0, state.state_as_number( ha.State('domain.test', _state, {})))