diff --git a/README.md b/README.md index 0b7c11b..3e5d4a1 100644 --- a/README.md +++ b/README.md @@ -13,16 +13,11 @@ This will capture those whenever theyre transmitted and emit them to a backend o The Tilt supports writing to a google doc which you could use with something like IFTTT or Zapier, but this is much lighter and lets you decide how you want to push that out with a pluggable backend system. -## TODO ## +## Supported Emitters ## -Right now all it does is log to STDOUT. -As it progresses it will have pluggablel emitters such as: - * InfluxDb - * Webhooks - * SNS - - etc +* Webhooks +* InfluxDB ## Usage ## @@ -38,6 +33,13 @@ sleep_interval = 10 url = http://www.foo.com payload_template = {"color": "{{ color }}", "gravity": {{ gravity }}, "temp": {{ temp }}, "timestamp": "{{ timestamp }}"} method = GET + +[influxdb] +url = influxdb.corp.com +port = 80 +database = tilty +gravity_payload_template = {"measurement": "gravity", "tags": {"color": "{{ color }}"}, "fields": {"value": {{ gravity }}}} +temperature_payload_template = {"measurement": "temperature", "tags": {"color": "{{ color }}"}, "fields": {"value": {{ temp }}}} EOF $ tilty ``` diff --git a/poetry.lock b/poetry.lock index d6e5507..51f9044 100644 --- a/poetry.lock +++ b/poetry.lock @@ -136,6 +136,23 @@ zipp = ">=0.5" docs = ["sphinx", "rst.linker"] testing = ["packaging", "importlib-resources"] +[[package]] +category = "main" +description = "InfluxDB client" +name = "influxdb" +optional = false +python-versions = "*" +version = "5.2.3" + +[package.dependencies] +python-dateutil = ">=2.6.0" +pytz = "*" +requests = ">=2.17.0" +six = ">=1.10.0" + +[package.extras] +test = ["nose", "nose-cov", "mock", "requests-mock"] + [[package]] category = "dev" description = "A Python utility / library to sort Python imports." @@ -321,6 +338,25 @@ pytest = ">=3.6" [package.extras] testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "virtualenv"] +[[package]] +category = "main" +description = "Extensions to the standard Python datetime module" +name = "python-dateutil" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +version = "2.8.1" + +[package.dependencies] +six = ">=1.5" + +[[package]] +category = "main" +description = "World timezone definitions, modern and historical" +name = "pytz" +optional = false +python-versions = "*" +version = "2019.3" + [[package]] category = "main" description = "Python HTTP for Humans." @@ -340,7 +376,7 @@ security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)"] socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] [[package]] -category = "dev" +category = "main" description = "Python 2 and 3 compatibility utilities" name = "six" optional = false @@ -448,7 +484,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["pathlib2", "contextlib2", "unittest2"] [metadata] -content-hash = "b8de88fcd77dd71a746d3725e695889c62a2f93eec73bc63a142f7e0430ef44b" +content-hash = "aaaaaee62ecd567cb981bd6029700fd8189fc7da649f363cbd32793f686a74ad" python-versions = ">=3.6,<3.8" [metadata.files] @@ -533,6 +569,10 @@ importlib-metadata = [ {file = "importlib_metadata-1.3.0-py2.py3-none-any.whl", hash = "sha256:d95141fbfa7ef2ec65cfd945e2af7e5a6ddbd7c8d9a25e66ff3be8e3daf9f60f"}, {file = "importlib_metadata-1.3.0.tar.gz", hash = "sha256:073a852570f92da5f744a3472af1b61e28e9f78ccf0c9117658dc32b15de7b45"}, ] +influxdb = [ + {file = "influxdb-5.2.3-py2.py3-none-any.whl", hash = "sha256:270ec1ec9cf1927a38cf5ec808e76f364482977577eb8c335f6aed5fcdc4cb25"}, + {file = "influxdb-5.2.3.tar.gz", hash = "sha256:30276c7e04bf7659424c733b239ba2f0804d7a1f3c59ec5dd3f88c56176c8d36"}, +] isort = [ {file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"}, {file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"}, @@ -651,6 +691,14 @@ pytest-cov = [ {file = "pytest-cov-2.8.1.tar.gz", hash = "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b"}, {file = "pytest_cov-2.8.1-py2.py3-none-any.whl", hash = "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626"}, ] +python-dateutil = [ + {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, + {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, +] +pytz = [ + {file = "pytz-2019.3-py2.py3-none-any.whl", hash = "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d"}, + {file = "pytz-2019.3.tar.gz", hash = "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"}, +] requests = [ {file = "requests-2.22.0-py2.py3-none-any.whl", hash = "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"}, {file = "requests-2.22.0.tar.gz", hash = "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4"}, diff --git a/pyproject.toml b/pyproject.toml index 5afd2d1..c35a39a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ click = "^7.0" pybluez = "^0.22.0" requests = "^2.22" jinja2 = "^2.11.1" +influxdb = "^5.2.3" [tool.poetry.dev-dependencies] flake8 = "^3.7" diff --git a/requirements.txt b/requirements.txt index 2cf8fc3..959afef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ filelock==3.0.12 flake8==3.7.9 idna==2.8 importlib-metadata==1.3.0 +influxdb==5.2.3 isort==4.3.21 Jinja2==2.11.1 lazy-object-proxy==1.4.3 @@ -25,6 +26,8 @@ pylint==2.4.4 pyparsing==2.4.5 pytest==5.3.2 pytest-cov==2.8.1 +python-dateutil==2.8.1 +pytz==2019.3 requests==2.22.0 six==1.13.0 toml==0.10.0 diff --git a/setup.py b/setup.py index a99645d..9475117 100644 --- a/setup.py +++ b/setup.py @@ -12,6 +12,7 @@ packages=find_packages(exclude=['tests*']), install_requires=[ 'Click', + 'influxdb', 'Jinja2', 'pybluez', 'requests', diff --git a/tests/mock_config_parser.py b/tests/mock_config_parser.py index 207b784..9d6ba78 100644 --- a/tests/mock_config_parser.py +++ b/tests/mock_config_parser.py @@ -1,15 +1,24 @@ # -*- coding: utf-8 -*- class MockConfigParser: - def __init__(self): - pass + def __init__(self, section): + self.section = section def __getitem__(self, key): - return { - 'url': 'http://www.google.com', - 'headers': {'Content-Type': 'application/json'}, - 'payload_template': '{"color": "{{ color }}", "gravity": {{ gravity }}, "temp": {{ temp }}, "timestamp": "{{ timestamp }}"}', - 'method': 'GET' - } + if self.section == 'webhook': + return { + 'url': 'http://www.google.com', + 'headers': {'Content-Type': 'application/json'}, + 'payload_template': '{"color": "{{ color }}", "gravity": {{ gravity }}, "temp": {{ temp }}, "timestamp": "{{ timestamp }}"}', # noqa + 'method': 'GET' + } + if self.section == 'influxdb': + return { + 'url': 'http://www.google.com', + 'database': 'foo', + 'gravity_payload_template': 'gravity,color={{ color }} value={{ gravity }} {{timestamp}}', # noqa + 'temperature_payload_template': 'temperature,scale=fahrenheit,color={{ color }} value={{ temp }} {{timestamp}}', # noqa + } + return None - def has_section(*args, **kwargs): - return True + def has_section(self, *args, **kwargs): + return self.section in args diff --git a/tests/test_common.py b/tests/test_common.py new file mode 100644 index 0000000..2221ef9 --- /dev/null +++ b/tests/test_common.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +from tilty import common + + +def test_safe_get_key_no_fallback(): + assert common.safe_get_key({}, 'foo') is None + + +def test_safe_get_key_fallback(): + assert common.safe_get_key({}, 'foo', 'wut') == 'wut' + + +def test_safe_get_key_valid(): + assert common.safe_get_key({'foo': 'asdf'}, 'foo', 'wut') == 'asdf' diff --git a/tests/test_influxdb.py b/tests/test_influxdb.py new file mode 100644 index 0000000..1c867bf --- /dev/null +++ b/tests/test_influxdb.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +from unittest import mock + +from tilty.emitters import influxdb + + +@mock.patch('tilty.emitters.influxdb.InfluxDBClient') +def test_influxdb( + mock_influx_client, +): + config = { + 'url': 'http://www.google.com', + 'database': 'foo', + 'gravity_payload': '{"measurement": "gravity", "tags": {"color": "Black"}, "fields": {"value": 1.054}}', # noqa + 'temperature_payload': '{"measurement": "temperature", "tags": {"color": "Black", "scale": "fahrenheight"}, "fields": {"value": 32}}', # noqa + } + influxdb.InfluxDB(config=config).emit() + assert mock_influx_client.mock_calls == [ + mock.call( + 'http://www.google.com', + 80, + None, + None, + 'foo' + ), + mock.call().write_points([ + { + 'measurement': 'temperature', + 'tags': {'color': 'Black', 'scale': 'fahrenheight'}, + 'fields': {'value': 32} + } + ]), + mock.call().write_points([ + { + 'measurement': 'gravity', + 'tags': {'color': 'Black'}, + 'fields': {'value': 1.054} + } + ]) + ] diff --git a/tests/test_tilty.py b/tests/test_tilty.py index 87a0e4d..15e0d7a 100644 --- a/tests/test_tilty.py +++ b/tests/test_tilty.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- from unittest import mock -from tilty import tilt_device, tilty from mock_config_parser import MockConfigParser +from tilty import tilt_device, tilty @mock.patch('tilty.blescan.parse_events', return_value=[{'uuid': 'foo', 'major': 2, 'minor': 1}]) # noqa @@ -13,12 +13,11 @@ def test_scan_for_tilt_data( t.scan_for_tilt_data() - @mock.patch('tilty.emitters.webhook.Webhook') -def test_scan_for_tilt_data( +def test_scan_for_tilt_data_parse_webhook( mock_webhook, ): - config = MockConfigParser() + config = MockConfigParser('webhook') tilty.emit( config, {'color': 'black', 'gravity': 1, 'temp': 32, 'timestamp': 155558888} @@ -29,7 +28,29 @@ def test_scan_for_tilt_data( 'url': 'http://www.google.com', 'headers': {'Content-Type': 'application/json'}, 'method': 'GET', - 'payload': {'color': 'black', 'gravity': 1, 'temp': 32, 'timestamp': '155558888'} + 'payload': {'color': 'black', 'gravity': 1, 'temp': 32, 'timestamp': '155558888'} # noqa + } + ), + mock.call().emit() + ] + + +@mock.patch('tilty.emitters.influxdb.InfluxDB') +def test_scan_for_tilt_data_parse_influxdb( + mock_influxdb, +): + config = MockConfigParser('influxdb') + tilty.emit( + config, + {'color': 'black', 'gravity': 1, 'temp': 32, 'timestamp': 155558888} + ) + assert mock_influxdb.mock_calls == [ + mock.call( + config={ + 'url': 'http://www.google.com', + 'database': 'foo', + 'temperature_payload': 'temperature,scale=fahrenheit,color=black value=32 155558888', # noqa + 'gravity_payload': 'gravity,color=black value=1 155558888' } ), mock.call().emit() diff --git a/tests/test_webhook.py b/tests/test_webhook.py index af1a079..2a36f5e 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -13,7 +13,8 @@ def test_webhook_get( config = { 'url': 'http://www.google.com', 'headers': {'Content-Type': 'application/json'}, - 'payload': {'b': 'b1'}, 'method': 'GET' + 'payload': {'b': 'b1'}, + 'method': 'GET', } webhook.Webhook(config=config).emit() assert mock_requests.mock_calls == [ @@ -26,6 +27,48 @@ def test_webhook_get( ] +@mock.patch('tilty.emitters.webhook.METHODS') +def test_webhook_post_json( + mock_requests, +): + config = { + 'url': 'http://www.google.com', + 'headers': {'Content-Type': 'application/json'}, + 'payload': {'b': 'b1'}, + 'method': 'POST', + } + webhook.Webhook(config=config).emit() + assert mock_requests.mock_calls == [ + mock.call.get('POST'), + mock.call.get()( + json={'b': 'b1'}, + headers={'Content-Type': 'application/json'}, + url='http://www.google.com' + ) + ] + + +@mock.patch('tilty.emitters.webhook.METHODS') +def test_webhook_post_data( + mock_requests, +): + config = { + 'url': 'http://www.google.com', + 'headers': {'Content-Type': 'text/plain'}, + 'payload': 'foo', + 'method': 'POST', + } + webhook.Webhook(config=config).emit() + assert mock_requests.mock_calls == [ + mock.call.get('POST'), + mock.call.get()( + data='foo', + headers={'Content-Type': 'text/plain'}, + url='http://www.google.com' + ) + ] + + def test_webhook_invalid_method(): config = { 'url': 'http://www.google.com', diff --git a/tilty/common.py b/tilty/common.py new file mode 100644 index 0000000..94810c1 --- /dev/null +++ b/tilty/common.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +""" Common methods """ + + +def safe_get_key(config, key, fallback=None): + """ Class to safely pull key from config or a fallback value """ + try: + return config[key] + except KeyError: + pass + return fallback diff --git a/tilty/emitters/influxdb.py b/tilty/emitters/influxdb.py new file mode 100644 index 0000000..cdde1f8 --- /dev/null +++ b/tilty/emitters/influxdb.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +""" InfluxDB emitter """ +import json +import logging + +from influxdb import InfluxDBClient + +from tilty.common import safe_get_key + +LOGGER = logging.getLogger() + + +class InfluxDB: # pylint: disable=too-few-public-methods + """ Class to represent the actual device """ + def __init__(self, config): + """ Initializer + + Args: + config: (dict) represents the configuration for the emitter + """ + self.temperature_payload = config['temperature_payload'] + self.gravity_payload = config['gravity_payload'] + self.client = InfluxDBClient( + config['url'], + safe_get_key(config, 'port', 80), + safe_get_key(config, 'user'), + safe_get_key(config, 'password'), + config['database'] + ) + + def emit(self, **kwargs): # pylint: disable=no-self-use,unused-argument + """ Initializer + + Args: + """ + LOGGER.info('[influxdb] posting temperature data') + self.client.write_points([json.loads(self.temperature_payload)]) + LOGGER.info('[influxdb] posting gravity data') + self.client.write_points([json.loads(self.gravity_payload)]) diff --git a/tilty/tilty.py b/tilty/tilty.py index 0fe962e..c7dd67a 100644 --- a/tilty/tilty.py +++ b/tilty/tilty.py @@ -4,7 +4,7 @@ from jinja2 import Template -from tilty.emitters import webhook +from tilty.emitters import influxdb, webhook def emit(config, tilt_data): @@ -15,7 +15,12 @@ def emit(config, tilt_data): """ if tilt_data is None: return - + # + # [webhook] + # url = http://www.foo.com + # self.headers = {"Content-Type": "application/json"} + # payload_template = {"color": "{{ color }}", "gravity"... + # method = GET if config.has_section('webhook'): _template = Template(config['webhook']['payload_template']) _config = { @@ -31,3 +36,32 @@ def emit(config, tilt_data): } _webhook = webhook.Webhook(config=_config) _webhook.emit() + + # + # [influxdb] + # url = www.foo.com + # port = 80 + # database = tilty + # gravity_payload_template = {"measurement": "gravity", "tags": {"color": "{{ color }}"}, "fields": {"value": {{ gravity }}}} # noqa # pylint: disable=line-too-long + # temperature_payload_template = {"measurement": "temperature", "tags": {"color": "{{ color }}"}, "fields": {"value": {{ temp }}}} # noqa # pylint: disable=line-too-long + if config.has_section('influxdb'): + _gravity_template = Template(config['influxdb']['gravity_payload_template']) # noqa + _temperature_template = Template(config['influxdb']['temperature_payload_template']) # noqa + _config = { + 'url': config['influxdb']['url'], + 'database': config['influxdb']['database'], + 'temperature_payload': _temperature_template.render( + color=tilt_data['color'], + gravity=tilt_data['gravity'], + temp=tilt_data['temp'], + timestamp=tilt_data['timestamp'], + ), + 'gravity_payload': _gravity_template.render( + color=tilt_data['color'], + gravity=tilt_data['gravity'], + temp=tilt_data['temp'], + timestamp=tilt_data['timestamp'], + ), + } + _influxdb = influxdb.InfluxDB(config=_config) + _influxdb.emit()