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.
This commit is contained in:
parent
2f474a0ed8
commit
ac72dea09a
8 changed files with 287 additions and 9 deletions
|
@ -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
|
||||
|
|
234
homeassistant/components/prometheus.py
Normal file
234
homeassistant/components/prometheus.py
Normal file
|
@ -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")
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -69,6 +69,7 @@ TEST_REQUIREMENTS = (
|
|||
'PyJWT',
|
||||
'restrictedpython',
|
||||
'pyunifi',
|
||||
'prometheus_client',
|
||||
)
|
||||
|
||||
IGNORE_PACKAGES = (
|
||||
|
|
33
tests/components/test_prometheus.py
Normal file
33
tests/components/test_prometheus.py
Normal file
|
@ -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_')
|
|
@ -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, {})))
|
||||
|
|
Loading…
Add table
Reference in a new issue