From 90e2d074ba54da0a7c95c354e8ba38bd06ccb1a8 Mon Sep 17 00:00:00 2001 From: yohan <783b8c87@scimetis.net> Date: Sun, 4 Feb 2024 21:19:01 +0100 Subject: [PATCH] Initial commit. --- .gitignore | 1 + conf-example.yml | 26 +++++++ get-teleinfo.py | 37 +++++++++ install.sh | 25 +++++++ requirements | 1 + sensors-polling.py | 182 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 272 insertions(+) create mode 100644 .gitignore create mode 100644 conf-example.yml create mode 100755 get-teleinfo.py create mode 100755 install.sh create mode 100644 requirements create mode 100755 sensors-polling.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db1bed7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +conf.yml diff --git a/conf-example.yml b/conf-example.yml new file mode 100644 index 0000000..a206656 --- /dev/null +++ b/conf-example.yml @@ -0,0 +1,26 @@ +http_port: 3000 + +polling_conf: + - name: teleinfo + metrics: + - name: Modane_elec_main_power + type: int + - name: Modane_elec_energy_index + type: int + script: ./get-teleinfo.py + arguments: + - "-f" + - "custom_json" + # interval between sensors polling in seconds + polling_interval: 10.0 + +# Default interval between sending to record API in seconds +default_recording_interval: 600.0 + +# Default interval between sensors polling in seconds +default_polling_interval: 600.0 + +recording_api_key: "FIXME" +post_url: + int: "http://ovh1.scimetis.net:3001/integer_metric/add" + float: "http://ovh1.scimetis.net:3001/float_metric/add" diff --git a/get-teleinfo.py b/get-teleinfo.py new file mode 100755 index 0000000..1a8e676 --- /dev/null +++ b/get-teleinfo.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# # pip install teleinfo +# https://pypi.org/project/teleinfo/ +# https://www.magdiblog.fr/gpio/teleinfo-edf-suivi-conso-de-votre-compteur-electrique/ + +import argparse +import json +from teleinfo import Parser +from teleinfo.hw_vendors import UTInfo2 + +parser = argparse.ArgumentParser(description='Téléinfo retriever.') +parser.add_argument("-f", "--format", help="Output format.", + type=str, choices=['human-readable', 'raw_json', 'custom_json'], default='human-readable') +args = parser.parse_args() + +ti = Parser(UTInfo2(port="/dev/ttyUSB0")) +res = ti.get_frame() + +if args.format == 'human-readable': + print "Puissance apparente compteur : "+str(int(res['PAPP']))+"VA" + # moins précis car Intensité arrondie à l'entier + print "Puissance apparente calculée : "+str(int(res['IINST'])*230)+"VA" + print "Puissance souscrite : 6kVA" + print "Puissance max avant coupure (marge 30%) : 7,8kVA" + print "Intensité : "+str(int(res['IINST']))+"A" + print "Intensité abonnement : "+str(int(res['ISOUSC']))+"A" + print "Consommation : "+str(int(res['BASE']))+"Wh" +elif args.format == 'raw_json': + print json.dumps(res) +elif args.format == 'custom_json': + data = {} + data['Modane_elec_main_power'] = int(res['PAPP']) + data['Modane_elec_energy_index'] = int(res['BASE']) + print json.dumps(data) +#for frame in ti: +# print frame diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..46e4ff6 --- /dev/null +++ b/install.sh @@ -0,0 +1,25 @@ +#!/bin/bash +#Absolute path to this script +SCRIPT=$(readlink -f $0) +#Absolute path this script is in +SCRIPTPATH=$(dirname $SCRIPT) +SERVICE="sensors-polling" + +cat << EOF > /etc/systemd/system/${SERVICE}.service +[Unit] +Description=Starting ${SERVICE} +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +ExecStart=$SCRIPTPATH/${SERVICE}.py +WorkingDirectory=$SCRIPTPATH + +[Install] +WantedBy=multi-user.target +EOF +systemctl daemon-reload +systemctl enable ${SERVICE}.service + diff --git a/requirements b/requirements new file mode 100644 index 0000000..14784d9 --- /dev/null +++ b/requirements @@ -0,0 +1 @@ +apt install python3 python3-requests python3-yaml diff --git a/sensors-polling.py b/sensors-polling.py new file mode 100755 index 0000000..ef694c9 --- /dev/null +++ b/sensors-polling.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import json +import logging +import time +import signal +import yaml +import requests +import subprocess +import argparse +import threading +import socketserver +from http.server import BaseHTTPRequestHandler +from threading import Event +from datetime import datetime +from concurrent.futures import ThreadPoolExecutor + +parser = argparse.ArgumentParser(description='Sensors polling and metrics recording.') +parser.add_argument("-v", "--verbosity", help="Increase output verbosity", + type=str, choices=['DEBUG', 'INFO', 'WARNING'], default='INFO') +args = parser.parse_args() + +if args.verbosity == 'DEBUG': + logging.basicConfig(level=logging.DEBUG) +elif args.verbosity == 'INFO': + logging.basicConfig(level=logging.INFO) +elif args.verbosity == 'WARNING': + logging.basicConfig(level=logging.WARNING) + +logging.info("====== Starting ======") + +stop = Event() +last_data = {} + +def handler(signum, frame): + global stop + logging.info("Got interrupt: "+str(signum)) + stop.set() + logging.info("Shutdown") + +signal.signal(signal.SIGTERM,handler) +signal.signal(signal.SIGINT,handler) + +with open('./conf.yml') as conf: + yaml_conf = yaml.load(conf) + polling_conf = yaml_conf.get("polling_conf") + http_port = yaml_conf.get("http_port") + default_polling_interval = yaml_conf.get("default_polling_interval") + default_recording_interval = yaml_conf.get("default_recording_interval") + max_threads = len(polling_conf) + recording_api_key = yaml_conf.get("recording_api_key") + post_url = yaml_conf.get("post_url") + +def sensors_polling(poller_conf): + global stop + global last_data + s = requests.Session() + start_time=time.time() + last_polling_time=None + last_recording_time=None + if 'polling_interval' in poller_conf.keys(): + polling_interval = poller_conf['polling_interval'] + else: + polling_interval = default_polling_interval + + if 'recording_interval' in poller_conf.keys(): + recording_interval = poller_conf['recording_interval'] + else: + recording_interval = default_recording_interval + + while True: + if stop.is_set(): + logging.info('Stopping thread '+poller_conf['name']) + break + logging.debug('New while loop for '+poller_conf['name']) + utc_now = datetime.utcnow() + now = datetime.now() + current_time=time.time() + + # Polling + try: + logging.debug('Getting data for '+poller_conf['name']) + command = [poller_conf['script']] + poller_conf['arguments'] + returned_output = subprocess.check_output(command) + data = json.loads(returned_output.decode("utf-8")) + logging.debug('Got: '+returned_output.decode("utf-8")) + for metric in poller_conf['metrics']: + last_data[metric['name']] = {'value': data[metric['name']], 'timestamp': utc_now.isoformat()} + last_polling_time=time.time() + except Exception as e: + logging.error(e) + if last_polling_time is None: + polling_missed = int((current_time - start_time) // polling_interval) + else: + polling_missed = int((current_time - last_polling_time) // polling_interval) + if polling_missed > 0: + logging.warning("Missed "+str(polling_missed)+" polling iteration(s)") + + # Recording + if last_polling_time is not None and (last_recording_time is None or (current_time - last_recording_time > recording_interval and last_polling_time > last_recording_time + recording_interval/2)): + try: + for metric in poller_conf['metrics']: + logging.debug('Posting data for '+metric['name']) + r = s.post(post_url[metric['type']], + headers={'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:75.0) Gecko/20100101 Firefox/75.0', + 'X-API-KEY': recording_api_key}, + json={'metric': metric['name'], + 'value': last_data[metric['name']]['value'], + 'time': utc_now.isoformat()}) + if r.status_code != 201: + logging.error(str(r.status_code)+" "+r.reason) + last_recording_time=time.time() + except Exception as e: + logging.error(e) + if last_recording_time is None: + recording_missed = int((current_time - start_time) // recording_interval) + else: + recording_missed = int((current_time - last_recording_time) // recording_interval) + if recording_missed > 0: + logging.warning("Missed "+str(recording_missed)+" recording iteration(s)") + + # Sleeping + time_to_sleep = polling_interval - ((current_time - start_time) % polling_interval) + logging.debug('Sleeping '+str(time_to_sleep)+' seconds for '+poller_conf['name']) + stop.wait(timeout=time_to_sleep) + +def metric_list(): + return([metric['name'] for metric in poller_conf['metrics']]) + +class MyHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == '/': + self.send_response(200) + self.send_header('Content-type', 'text/plain') + self.end_headers() + self.wfile.write(bytes(str(metric_list())+'\n', 'utf-8')) + if self.path[1:] in metric_list(): + self.send_response(200) + self.send_header('Content-type', 'text/plain') + self.end_headers() + self.wfile.write(bytes(json.dumps(last_data[self.path[1:]])+'\n', 'utf-8')) + else: + self.send_response(404) + +class WebThread(threading.Thread): + def run(self): + httpd.serve_forever() + +httpd = socketserver.TCPServer(("", http_port), MyHandler, bind_and_activate=False) +httpd.allow_reuse_address = True +httpd.server_bind() +httpd.server_activate() +webserver_thread = WebThread() +webserver_thread.start() + +executor = ThreadPoolExecutor(max_workers=max_threads) +threads = [] +for poller_conf in polling_conf: + threads.append(executor.submit(sensors_polling, poller_conf)) + +logging.info("Polling "+str(metric_list())) + +while True: + if stop.is_set(): + executor.shutdown(wait=True) + httpd.shutdown() + httpd.server_close() + break + for thread in threads: + if not thread.running(): + try: + res = thread.exception(timeout=1) + if res is not None: + logging.error(res) + except Exception as e: + logging.error(e) + stop.wait(timeout=0.5) + +logging.info("====== Ended successfully ======") + +# vim: set ts=4 sw=4 sts=4 et :