diff --git a/bin/glowprom b/bin/glowprom index 67cb1cd..750ac54 100644 --- a/bin/glowprom +++ b/bin/glowprom @@ -17,15 +17,14 @@ import sys -from glowprom import get_arguments, connect - -def on_message(client, userdata, msg): - print(str(msg.payload)) +from glowprom import get_arguments, connect, serve, update_stats def main(): args = get_arguments(sys.argv[1:]) - connect(args, on_message) + connect(args, update_stats) + + serve(args) if __name__ == "__main__": main() diff --git a/glowprom/__init__.py b/glowprom/__init__.py index 07071b4..b4803dc 100644 --- a/glowprom/__init__.py +++ b/glowprom/__init__.py @@ -17,5 +17,7 @@ from .arguments import get_arguments from .exceptions import InvalidArguments from .mqtt import connect +from .prometheus import prometheus +from .server import serve, update_stats __version__ = "0.1.1" diff --git a/glowprom/prometheus.py b/glowprom/prometheus.py new file mode 100644 index 0000000..5f9d1c3 --- /dev/null +++ b/glowprom/prometheus.py @@ -0,0 +1,110 @@ +# glowprom +# Copyright (C) 2020 Andrew Wilkinson +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import json + +# Taken from https://gist.github.com/ndfred/b373eeafc4f5b0870c1b8857041289a9 +# Fields gathered from the ZigBee Smart Energy Standard document +# 0702: Metering +# - 00: Reading Information Set +# - 00: CurrentSummationDelivered: meter reading +# - 01: CurrentSummationReceived +# - 02: CurrentMaxDemandDelivered +# - 07: ReadingSnapshotTime (UTC time) +# - 14: Supply Status (enum): 0x2 is on +# - 02: Meter Status +# - 00: Status (bit map): 10 means power quality event +# - 03: Formatting +# - 00: UnitofMeasure (enum): 00 means kWh, 01 means m3 +# - 01: Multiplier +# - 02: Divisor +# - 03: SummationFormatting (bit map): +# 2B means 3 digits after the decimal point, 2 digits before. +# FB means 3 digits after the decimal point, 16 digits before. +# no leading zeros +# - 04: DemandFormatting +# - 06: MeteringDeviceType: 00 means Electric Metering, +# 80 means Mirrored Gas Metering +# - 07: SiteID: MPAN encoded in UTF-8 +# - 08: MeterSerialNumber (string) +# - 12: AlternativeUnitofMeasure (enum) +# - 04: Historical Consumption +# - 00: InstantaneousDemand (signed): current consumption +# - 01: CurrentDayConsumptionDelivered +# - 30: CurrentWeekConsumptionDelivered +# - 40: CurrentMonthConsumptionDelivered +# - 0C: Alternative Historical Consumption +# - 01: CurrentDayConsumptionDelivered +# - 30: CurrentWeekConsumptionDelivered +# - 40: CurrentMonthConsumptionDelivered +# 0705: Prepayment +# - 00: Prepayment Information Set +# - 00: PaymentControlConfiguration (bit map) +# - 01: CreditRemaining (signed) +# 0708: Device Management +# - 01: Supplier Control Attribute Set +# - 01: ProviderName (string) + + +def prometheus(msg): + # Code adapted from + # https://gist.github.com/ndfred/b373eeafc4f5b0870c1b8857041289a9 + payload = json.loads(msg.payload) + + elecMtr = payload["elecMtr"]["0702"] + + elec_consumption = int(elecMtr["04"]["00"], 16) + elec_daily_consumption = int(elecMtr["04"]["01"], 16) + elec_weekly_consumption = int(elecMtr["04"]["30"], 16) + elec_monthly_consumption = int(elecMtr["04"]["40"], 16) + elec_multiplier = int(elecMtr["03"]["01"], 16) + elec_divisor = float(int(elecMtr["03"]["02"], 16)) + elec_meter = int(elecMtr["00"]["00"], 16) + + gasMtr = payload["gasMtr"]["0702"] + + gas_daily_consumption = int(gasMtr["0C"]["01"], 16) + gas_weekly_consumption = int(gasMtr["0C"]["30"], 16) + gas_monthly_consumption = int(gasMtr["0C"]["40"], 16) + gas_multiplier = int(gasMtr["03"]["01"], 16) + gas_divisor = float(int(gasMtr["03"]["02"], 16)) + gas_meter = int(gasMtr["00"]["00"], 16) + + elec_daily_consumption = elec_daily_consumption * \ + elec_multiplier / elec_divisor + elec_weekly_consumption = elec_weekly_consumption * \ + elec_multiplier / elec_divisor + electricity_monthly_consumption = elec_monthly_consumption * \ + elec_multiplier / elec_divisor + electricity_meter = elec_meter * elec_multiplier / elec_divisor + gas_daily_consumption = gas_daily_consumption * \ + gas_multiplier / gas_divisor + gas_weekly_consumption = gas_weekly_consumption * \ + gas_multiplier / gas_divisor + gas_monthly_consumption = gas_monthly_consumption * \ + gas_multiplier / gas_divisor + gas_meter = gas_meter * gas_multiplier / gas_divisor + + return f""" +consumption{{type='electricity',period='daily'}} {elec_daily_consumption} +consumption{{type='electricity',period='weekly'}} {elec_weekly_consumption} +consumption{{type='electricity',period='monthly'}} {elec_monthly_consumption} +meter{{type='electricity'}} {electricity_meter} +consumption{{type='gas',period='daily'}} {gas_daily_consumption} +consumption{{type='gas',period='weekly'}} {gas_weekly_consumption} +consumption{{type='gas',period='monthly'}} {gas_monthly_consumption} +meter{{type='gas'}} {gas_meter} +""" diff --git a/glowprom/server.py b/glowprom/server.py new file mode 100644 index 0000000..fd12982 --- /dev/null +++ b/glowprom/server.py @@ -0,0 +1,67 @@ +# glowprom +# Copyright (C) 2020 Andrew Wilkinson +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from datetime import datetime +import http.server + +from .prometheus import prometheus + + +STATS = None + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/": + self.send_index() + elif self.path == "/metrics": + self.send_metrics() + else: + self.send_error(404) + + def send_index(self): + self.send_response(200) + self.end_headers() + self.wfile.write(""" + +Glow Prometheus + +

Glow Prometheus

+

Metrics

+ +""".encode("utf8")) + + def send_metrics(self): + if STATS is None: + self.send_response(404) + self.end_headers() + else: + self.send_response(200) + self.end_headers() + self.wfile.write(STATS.encode("utf8")) + + +def serve(args): # pragma: no cover + server = http.server.HTTPServer(args.bind, Handler) + server.serve_forever() + + +def update_stats(client, userdata, msg): + global STATS + if msg is None: + STATS = None + else: + STATS = prometheus(msg) diff --git a/tests/__init__.py b/tests/__init__.py index 69cf3da..00b41ee 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -16,3 +16,5 @@ from .test_arguments import TestArguments from .test_mqtt import TestMQTT +from .test_prometheus import TestPrometheus +from .test_server import TestServer diff --git a/tests/test_message.txt b/tests/test_message.txt new file mode 100644 index 0000000..10e3f04 --- /dev/null +++ b/tests/test_message.txt @@ -0,0 +1 @@ +{"elecMtr":{"0702":{"03":{"01":"00000001","04":"00","02":"000003E8","07":"1023497978063","03":"2B","08":"","00":"00","06":"00"},"00":{"07":"00000000","01":"000000000000","00":"0000004D64CC","14":"02","02":"000000000000"},"04":{"01":"001F23","40":"011C3C","30":"004A12","00":"000009F3"},"02":{"00":"00"}},"0705":{"00":{"01":"00000000","00":"0C94"}},"0708":{"01":{"01":"NPOWER NORTHERN"}}},"gasMtr":{"0702":{"03":{"01":"00000001","12":"00","02":"000003E8","07":"7443608110","03":"2B","08":"","00":"01","06":"80"},"00":{"00":"00000022D37B","14":"02"},"0C":{"01":"009A1C","40":"0440B4","30":"015DF5"},"02":{"00":"00"}},"0705":{"00":{"01":"00000000","00":"0C94"}},"0708":{"01":{"01":""}}},"ts":"2020-10-06 20:53:16","hversion":"GLOW-IHD-01-1v4-SMETS2","time":"5F7CD93C","zbSoftVer":"1.2.5","gmtime":1602017596,"pan":{"rssi":"BF","status":"joined","nPAN":"00","join":"0","lqi":"8C"},"smetsVer":"SMETS2","ets":"2000-01-01 00:00:00","gid":"0123456789ABCDEF"} diff --git a/tests/test_prometheus.py b/tests/test_prometheus.py new file mode 100644 index 0000000..f72724f --- /dev/null +++ b/tests/test_prometheus.py @@ -0,0 +1,35 @@ +# glowprom +# Copyright (C) 2020 Andrew Wilkinson +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +import unittest + +from glowprom import prometheus + + +MESSAGE_TEXT = open("tests/test_message.txt", "rb").read() + + +class MockMessage: + payload = MESSAGE_TEXT + + +class TestPrometheus(unittest.TestCase): + def test_prometheus(self): + prom = prometheus(MockMessage()) + + self.assertIn("consumption{type='electricity',period='daily'} 7.971", + prom) diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..542673d --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,77 @@ +# glowprom +# Copyright (C) 2020 Andrew Wilkinson +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import io +import json +from datetime import datetime, timedelta +import unittest + +from glowprom.server import Handler, update_stats + +MESSAGE_TEXT = open("tests/test_message.txt", "rb").read() + + +class MockMessage: + payload = MESSAGE_TEXT + + +class MockHandler(Handler): + def __init__(self): + self.wfile = io.BytesIO() + self.requestline = "GET" + self.client_address = ("127.0.0.1", 8000) + self.request_version = "1.0" + self.command = "GET" + + +class TestServer(unittest.TestCase): + def setUp(self): + update_stats(None, None, None) + + def test_index(self): + handler = MockHandler() + handler.path = "/" + handler.do_GET() + + handler.wfile.seek(0) + self.assertTrue("/metrics" in handler.wfile.read().decode("utf8")) + + def test_error(self): + handler = MockHandler() + handler.path = "/error" + handler.do_GET() + + handler.wfile.seek(0) + self.assertTrue("404" in handler.wfile.read().decode("utf8")) + + def test_metrics(self): + update_stats(None, None, MockMessage()) + + handler = MockHandler() + handler.path = "/metrics" + handler.do_GET() + + handler.wfile.seek(0) + self.assertTrue( + "consumption" in handler.wfile.read().decode("utf8")) + + def test_metrics_before_update(self): + handler = MockHandler() + handler.path = "/metrics" + handler.do_GET() + + handler.wfile.seek(0) + self.assertTrue("404" in handler.wfile.read().decode("utf8"))