diff --git a/tempermon.ini b/tempermon.ini index eb3f00cf52359f64479d8162a4b324f6b7d9998f..2851b7320a4c4d81344a6cbe74aeacde22685995 100644 --- a/tempermon.ini +++ b/tempermon.ini @@ -1,6 +1,7 @@ [serial] port=/dev/ttyUSB0 baudrate=115200 +timeout=100 [collectd] socketpath=/var/run/collectd-unixsock @@ -10,6 +11,7 @@ hostname=hugin from=Temperman <root@temperator.stusta.de> to=jw@stusta.de,markus.hefele@stusta.de to_urgent=jw@stusta.de,markus.hefele@stusta.de +min_delay_between_messages=3600 [testsensor] name=Test diff --git a/tempermonitor.py b/tempermonitor.py index ef329342a6e2424cd052356a0e2da16d75b50c4e..dbc78c3fb262c0a1643e0b20d0c8a894f111bda2 100755 --- a/tempermonitor.py +++ b/tempermonitor.py @@ -1,39 +1,82 @@ #!/usr/bin/env python3 +""" +This is the tempermonitoring system v2. + +it is based on the work of the old temperature monitoring system and released +under the terms as stated in the LICENSE.md file. + +Changelog: + +2018-08 jotweh: reimplemented using a micropython-esp32 + +Open issues: + +- Temperature Limits +- Integrate USB Sensors +""" + + import asyncio import configparser +import sys import time from datetime import datetime - from email.mime.text import MIMEText from email.utils import formatdate - import smtplib - import serial_asyncio -UNKNOWN_SENSOR_HEADER = "WARNING: Unknown Sensor ID" +UNKNOWN_SENSOR_SUBJECT = "WARNING: Unconfigured Sensor ID" UNKNOWN_SENSOR_BODY = """Hello Guys, -An unknown Sensor has been connected to the Temperature monitoring service. -Please add the sensor to the list of known sensors: {config}. +An unknown sensor has been connected to the temperature monitoring service. +Please add the following section to the list of known sensors in {config}. -The SensorID is {owid} -Its current Temperature is {temp} +[{owid}] +name=changeme +calibration=0 -Regards, Temperature""" +The current temperature of the sensor is {temp} -SENSOR_MEASUREMENT_MISSED = "WARNING: Sensor Measurement was missed" -SENSOR_MEASUREMENT_MISSED = """Hello Guys, +Regards, Temperature +""" -A Sensor measurement was missed from the temperature monitoring. +SENSOR_MEASUREMENT_MISSED_SUBJECT = "WARNING: Sensor Measurement was missed" +SENSOR_MEASUREMENT_MISSED_BODY = """Hello Guys, -The sensor in question is {owid}, named {name}. +A sensor measurement was missed from the temperature monitoring. +This indicates either a problem with the hardware (check the wireing!) or the config. + +ID: {owid} +NAME: {name}. Please go check it! -Regards, Temperature""" +Regards, Temperature +""" + +SENSOR_PROBLEM_SUBJECT = "WARNING: Sensor error" +SENSOR_PROBLEM_BODY = """Hello Guys, + +A sensor measurement was invalid. This might mean, that the sensor was disconnected. +Please go and check the sensor with the id +ID: {owid} +NAME: {name}. +LAST: {tem} + +Regards, Temperature +""" + +NO_DATA_SUBJECT = "WARNING: Did not receive any data" +NO_DATA_BODY = """Helly guys, + +It has been {time} seconds since i have last received a temperature value. +This is unlikely - please come and check + +Regards, Temperature +""" class Sensor: """ @@ -48,8 +91,10 @@ class Sensor: if owid in config: self.name = config[owid]['name'] self.calibration = config[owid]['calibration'] + else: + print("Invalid Config: missing section {}".format(owid)) except KeyError as exc: - print("Invalid Config: ", exc) + print("Invalid Config: for {}: {}".format(owid, exc)) raise def update(self, temperature): @@ -60,15 +105,28 @@ class Sensor: self.last_update = time.time() class Collectd: + """ + Implements a super simple collectd interface for only sending temperature data + """ def __init__(self, loop, config): self.loop = loop or asyncio.get_event_loop() self.config = config + self.path = self.config['collectd']['socketpath'] + self._reader, self._writer = (None, None) + self.loop.run_until_complete(self.reconnect()) - self._reader, self._writer = self.loop.run_until_complete( - asyncio.open_unix_connection( - path=self.config['collectd']['socketpath'], - loop=self.loop - )) + async def reconnect(self): + """ + optionally close and then reconnect to the unix socket + """ + if self._reader: + self._reader.close() + if self._writer: + self._writer.close() + + self._reader, self._writer = await asyncio.open_unix_connection( + path=self.path, + loop=self.loop) async def send(self, sensor): """ @@ -76,12 +134,21 @@ class Collectd: """ data = "PUTVAL \"{}/{}\" interval={} {}:{}\n".format( self.config['collectd']['hostname'], - sensor.name, + "tail-temperature/temperature-{}".format(sensor.name), int(self.config['collectd']['interval']), int(sensor.last_update), sensor.temperature) - self._writer.write(data) + print("Sending data:", data.strip()) + self._writer.write(data.encode('utf-8')) await self._writer.drain() + line = (await self._reader.readline()).decode('utf-8').strip() + if not line: + print("Connection reset. reconnecting") + await self.reconnect() + else: + print("recv:", line) + + class TempMonitor: """ @@ -95,36 +162,42 @@ class TempMonitor: """ def __init__(self, loop, configfile): - self.loop = loop or asyncio.get_event_loop() + loop = loop or asyncio.get_event_loop() self._configname = configfile self.config = configparser.ConfigParser() self.config.read(configfile) - self._collectd = Collectd(self.loop, self.config) - - self._reader, self._writer = self.loop.run_until_complete( + self._collectd = Collectd(loop, self.config) + print("connecting to", self.config['serial']['port']) + self._reader, self._writer = loop.run_until_complete( serial_asyncio.open_serial_connection( url=self.config['serial']['port'], baudrate=self.config['serial']['baudrate'], - loop=self.loop + loop=loop )) self._known_sensors = {} self._last_store = 0 - # Test if all necessary config fields, that are not part of the normal + self._mail_rate_limit = {} + + # Test if all necessary config fields are set, that are not part of the normal # startup configtest = [ + self.config['collectd']['hostname'], + self.config['collectd']['interval'], self.config['mail']['from'], self.config['mail']['to'], self.config['mail']['to_urgent'], + self.config['mail']['min_delay_between_messages'], + self.config['serial']['timeout'], ] del configtest - predefined_sections = ['serial', 'collectd', 'mail'] for owid in self.config: - if owid in predefined_sections: + # Skip all known and predefined sections + if owid in ['DEFAULT', 'serial', 'collectd', 'mail']: continue self._known_sensors[owid] = Sensor(self.config, owid) @@ -134,45 +207,66 @@ class TempMonitor: """ Read the protocol, update the sensors or trigger a collectd update """ - # This is just a hack to drop the micropython startup - # The parameter has to be tuned - await asyncio.sleep(0.1) - self._reader.drain() - firstrun = True + # upon startup we only see garbage. (micropython starting up), + # also it will produce warnings if the recording is started in the middle + # of a message, so wait until the end of a message block to start the game + # If the baudrate is wrong during micropython startup - this will also be + # skiped. + while True: + try: + if await self._reader.readline().decode('ascii').strip() == "": + break + except UnicodeError: + continue while True: - line = self._reader.readline() + # Wait for the next line + try: + line = await asyncio.wait_for( + self._reader.readline(), + timeout=self.config['serial']['timeout']) + except asyncio.TimeoutError: + await self.send_mail(NO_DATA_SUBJECT, NO_DATA_BODY) + continue + try: line = line.decode('ascii') except UnicodeError: continue + print("recv:", line) if line == '': # Block has ended await self.store_sensors() - firstrun = False - elif firstrun: - # We start recording after we have seen the first empty line - # else our first package might be incomplete - pass + continue + # Try to parse the line + try: + owid, temp = line.split(' ') + except ValueError as exc: + print("Invaid line received: {}\n{}".format(line, exc)) + continue + + sensor = self._known_sensors.get(owid, None) + if not sensor: + # If the sensor is new - notify the operators + await self.send_mail( + UNKNOWN_SENSOR_SUBJECT, + UNKNOWN_SENSOR_BODY.format( + configname=self._configname, + owid=owid, + temp=temp)) + + elif temp > 1000 or temp < -1000: + # if the sensor is giving bullshit data - notify the operators + await self.send_mail( + SENSOR_PROBLEM_SUBJECT, + SENSOR_PROBLEM_BODY.format( + owid=owid, + name=sensor.name, + temp=temp)) else: - try: - owid, temp = line.split(' ') - except ValueError as exc: - # TODO upon startup we only see garbage. (micropython starting up) - # maybe there is an efficient way of dropping those? - # like waiting for ~10 seconds in the beginning? - print("Invaid line received: {}\n{}".format(line, exc)) - continue - if owid not in self._known_sensors: - await self.send_mail( - UNKNOWN_SENSOR_HEADER, - UNKNOWN_SENSOR_BODY.format( - configparser=self._configname, - owid=owid, - temp=temp)) - else: - self._known_sensors[owid].update(temp) + # in the unlikely event that everyting is fine: log the data + sensor.update(temp) async def teardown(self): """ Terminate all started tasks """ @@ -187,12 +281,13 @@ class TempMonitor: Prepare the sensors to be stored and maybe send an email """ for owid, sensor in self._known_sensors.items(): - if sensor.last_update < self._last_store: + if sensor.last_update <= self._last_store: isotime = datetime.utcfromtimestamp(sensor.last_update).isoformat() - self.send_mail( - SENSOR_MEASUREMENT_MISSED, - SENSOR_MEASUREMENT_MISSED.format( + await self.send_mail( + SENSOR_MEASUREMENT_MISSED_SUBJECT, + SENSOR_MEASUREMENT_MISSED_BODY.format( owid=owid, + name=sensor.name, last_update=isotime)) else: await self._collectd.send(sensor) @@ -203,6 +298,7 @@ class TempMonitor: """ Send a mail to the configured recipients """ + msg = MIMEText(body, _charset="UTF-8") msg['Subject'] = subject msg['From'] = self.config['mail']['from'] @@ -213,7 +309,14 @@ class TempMonitor: msg['Date'] = formatdate(localtime=True) - # Commented out for debugging reasons to not concern the admins + print("Problem {}\n\n{}\n".format(subject, body)) + + # Ratelimit the emails + time_since_last_mail = time.time() - self._mail_rate_limit.get(subject, 0) + if time_since_last_mail < self.config['mail']['min_delay_between_messages']: + return + + self._mail_rate_limit[subject] = time.time() smtp = smtplib.SMTP("mail.stusta.mhn.de") smtp.sendmail(msg['From'], msg['To'], msg.as_string()) smtp.quit() @@ -223,9 +326,19 @@ def main(): Start the tempmonitor """ loop = asyncio.get_event_loop() - monitor = TempMonitor(loop, "./tempermon.ini") - loop.run_forever() - loop.run_until_complete(monitor.teardown()) -if __name__ == "__init__": + configfile = "/etc/temperature/tempermon.ini" + if len(sys.argv) == 2: + configfile = sys.argv[1] + + print("Configuring temperature monitoring system from {}.".format(configfile)) + monitor = TempMonitor(loop, configfile) + try: + loop.run_forever() + except KeyboardInterrupt: + pass + finally: + loop.run_until_complete(monitor.teardown()) + +if __name__ == "__main__": main()