diff --git a/app/sensor.py b/app/sensor.py index 4df033b..f585e95 100644 --- a/app/sensor.py +++ b/app/sensor.py @@ -1,8 +1,12 @@ import sys +import time import serial, logging from Adafruit_IO import Client from serial import SerialException +WARMUP_SECONDS = 30 +PM_MAX = 999.9 + class Sensor(): @@ -14,6 +18,10 @@ def __init__(self, name, port, startbyte, endbyte, receivebyte): self.endbyte = endbyte self.receivebyte = receivebyte + def warm_up(self): + logging.info('Waiting %s seconds for sensor warm-up', WARMUP_SECONDS) + time.sleep(WARMUP_SECONDS) + def connect_to_sensor(self, port): try: ser = serial.Serial(port) @@ -40,16 +48,21 @@ def read_from_sensor(self): def get_pm_two_five(self, data): pm_two_five = int.from_bytes(b''.join(data[2:4]), byteorder='little') / 10 + if pm_two_five > PM_MAX: + message = str.format('PM2.5 reading {0} exceeds maximum valid value {1}', pm_two_five, PM_MAX) + logging.error(message) + raise Exception(message) return pm_two_five - def get_pm_ten(self, data): pm_ten = int.from_bytes(b''.join(data[4:6]), byteorder='little') / 10 + if pm_ten > PM_MAX: + message = str.format('PM10 reading {0} exceeds maximum valid value {1}', pm_ten, PM_MAX) + logging.error(message) + raise Exception(message) return pm_ten - ##TODO Checksum on message - ## Sanity check values for PM - def check_message(self,data): + def check_message(self, data): if data[0] != self.startbyte: message = str.format('Unexpected startbyte {0} received from sensor. Expected {1}', data[0], self.startbyte) logging.error(message) @@ -58,6 +71,11 @@ def check_message(self,data): message = str.format('Unexpected recievebyte {0} received from sensor. Expected {1}', data[1], self.receivebyte) logging.error(message) raise Exception(message) + checksum = sum(data[i][0] for i in range(2, 8)) % 256 + if checksum != data[8][0]: + message = str.format('Checksum mismatch: calculated {0}, received {1}', checksum, data[8][0]) + logging.error(message) + raise Exception(message) return data diff --git a/app/uploader.py b/app/uploader.py index bf64fa3..aa092a7 100644 --- a/app/uploader.py +++ b/app/uploader.py @@ -1,5 +1,6 @@ from Adafruit_IO import Client, AdafruitIOError, RequestError import logging +import os import yaml @@ -14,12 +15,17 @@ def read_config(self, file): try: with open(file, 'r') as ymlfile: config = yaml.load(ymlfile, Loader=yaml.SafeLoader) - return config['aio']['username'], config['aio']['key'], config['aio']['feeds']['pm-two-five'], \ - config['aio']['feeds']['pm-ten'] except FileNotFoundError: message = 'Config file not found' logging.error(message) raise Exception(message) + key = os.environ.get('AIO_KEY') + if not key: + message = 'AIO_KEY environment variable not set' + logging.error(message) + raise Exception(message) + return config['aio']['username'], key, config['aio']['feeds']['pm-two-five'], \ + config['aio']['feeds']['pm-ten'] def connect_to_aio(self, username, key): try: diff --git a/setup.py b/setup.py index 1123af8..8cbcb25 100644 --- a/setup.py +++ b/setup.py @@ -1,12 +1,12 @@ -import yaml, os -from Adafruit_IO import Client, AdafruitIOError, RequestError -import logging, argparse, sys +import yaml +from Adafruit_IO import Client, AdafruitIOError +import logging, argparse, sys, serial logging.basicConfig(filename='airquality.log', level=logging.DEBUG, filemode='a', format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') file = 'config.yml' -default_config = {"aio": {"username": '', "key": '', "feeds": {"pm-two-five": '', "pm-ten": ''}}} +default_config = {"aio": {"username": '', "feeds": {"pm-two-five": '', "pm-ten": ''}}} def main(): @@ -18,10 +18,11 @@ def main(): write_config(file, default_config) else: username = input("Enter AIO username: ") - key = (input("Enter AIO API Key: ")) + print("Note: your AIO API key must be set as the AIO_KEY environment variable, not stored in config.") pm_two_five = (input("Enter name of PM 2.5 feed: ")) pm_two_ten = (input("Enter name of PM 10 feed: ")) - config = set_config(username, key, pm_two_five, pm_two_ten) + list_serial_devices() + config = set_config(username, pm_two_five, pm_two_ten) write_config(file, config) @@ -29,18 +30,20 @@ def read_config(file): logging.debug('Reading config file') with open(file, 'r') as ymlfile: config = yaml.load(ymlfile, Loader=yaml.SafeLoader) - return config['aio']['username'], config['aio']['key'] + return config['aio']['username'] -def set_config(username, key, pm_two_five, pm_ten): +def set_config(username, pm_two_five, pm_ten): config = default_config config['aio']['username'] = ("{0}".format(username)) - config['aio']['key'] = key config['aio']['feeds']['pm-two-five'] = pm_two_five config['aio']['feeds']['pm-ten'] = pm_ten return config +def list_serial_devices(): + serial.tools.list_ports.comports(include_links=False) + def write_config(file, values): logging.debug('Writing to config file') try: diff --git a/start.py b/start.py index e1c41ef..42e4399 100644 --- a/start.py +++ b/start.py @@ -1,44 +1,78 @@ from app.sensor import Sensor from app.uploader import Uploader from app.offline import Offline -import time, logging, argparse, sys, random, datetime +import time, logging, argparse, sys logging.basicConfig(filename='airquality.log', level=logging.DEBUG, filemode='a', format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') -sen = Sensor('PM Sensor 1', '/dev/ttyUSB0', b'\xaa', b'0xAB', b'\xc0') def main(): parser = argparse.ArgumentParser() - parser.add_argument('-o','--offline', action="store_true") + parser.add_argument('-o', '--offline', action="store_true") args = parser.parse_args(sys.argv[2:]) + try: + sen = Sensor('PM Sensor 1', '/dev/ttyUSB0', b'\xaa', b'0xAB', b'\xc0') + except Exception as e: + message = 'Failed to initialise sensor: {0}'.format(e) + logging.error(message) + print(message) + sys.exit(1) + + sen.warm_up() + if args.offline: - start_offline() + start_offline(sen) else: try: - start_online() - except: - start_offline() + start_online(sen) + except Exception as e: + message = 'Online mode failed, falling back to offline mode: {0}'.format(e) + logging.error(message) + print(message) + start_offline(sen) + -def start_offline(): +def start_offline(sen): print("Starting Air Monitor in offline mode") off = Offline('pm25', 'pm10') while True: - data = sen.read_from_sensor() - sen.check_message(data) - pm_two_five = sen.get_pm_two_five(data) - pm_ten = sen.get_pm_ten(data) - off.write_pm_two_five(pm_two_five) - off.write_pm_ten(pm_ten) + try: + data = sen.read_from_sensor() + sen.check_message(data) + pm_two_five = sen.get_pm_two_five(data) + pm_ten = sen.get_pm_ten(data) + off.write_pm_two_five(pm_two_five) + off.write_pm_ten(pm_ten) + except Exception as e: + message = 'Error reading from sensor in offline mode: {0}'.format(e) + logging.error(message) + print(message) + -def start_online(): +def start_online(sen): print("Starting Air Monitor") file = 'config.yml' up = Uploader('AIO') - username, key, feed_two_five, feed_ten = up.read_config(file) - aio = up.connect_to_aio(username, key) + + try: + username, key, feed_two_five, feed_ten = up.read_config(file) + except Exception as e: + message = 'Failed to read config: {0}'.format(e) + logging.error(message) + print(message) + sys.exit(1) + + try: + aio = up.connect_to_aio(username, key) + except Exception as e: + message = 'Failed to connect to Adafruit IO: {0}'.format(e) + logging.error(message) + print(message) + sys.exit(1) + while True: up.get_feeds(aio) data = sen.read_from_sensor() diff --git a/tests/integration/test_config.yml b/tests/integration/test_config.yml new file mode 100644 index 0000000..f98131e --- /dev/null +++ b/tests/integration/test_config.yml @@ -0,0 +1,5 @@ +aio: + username: 'test user' + feeds: + pm-two-five: 'pm-two-five' + pm-ten: 'pm-ten' diff --git a/tests/integration/test_uploader_integration.py b/tests/integration/test_uploader_integration.py new file mode 100644 index 0000000..768ccea --- /dev/null +++ b/tests/integration/test_uploader_integration.py @@ -0,0 +1,12 @@ +import sys, pytest +from app.uploader import Uploader + +def test_upload_service_read_config(monkeypatch): + monkeypatch.setenv('AIO_KEY', 'abc123') + up = Uploader('target name') + file='tests/integration/test_config.yml' + username, key, pm_two_five, pm_ten = up.read_config(file) + assert username == 'test user' + assert key == 'abc123' + assert pm_two_five == 'pm-two-five' + assert pm_ten == 'pm-ten' \ No newline at end of file diff --git a/tests/unit/test_config.yml b/tests/unit/test_config.yml index d0573b7..f98131e 100644 --- a/tests/unit/test_config.yml +++ b/tests/unit/test_config.yml @@ -1,6 +1,5 @@ aio: username: 'test user' - key: 'abc123' feeds: pm-two-five: 'pm-two-five' pm-ten: 'pm-ten' diff --git a/tests/unit/test_sensor.py b/tests/unit/test_sensor.py index dd11fa5..76bb812 100644 --- a/tests/unit/test_sensor.py +++ b/tests/unit/test_sensor.py @@ -1,12 +1,15 @@ import serial, pytest from serial import SerialException -from unittest.mock import Mock +from unittest.mock import Mock, patch from app.sensor import Sensor valid_message = [b'\xaa', b'\xc0', b'\x13', b'\x00', b'5', b'\x00', b'\xd6', b'(', b'F', b'\xab'] invalid_startbyte = [b'\xab', b'\xc0', b'\x13', b'\x00', b'5', b'\x00', b'\xd6', b'(', b'F', b'\xab'] invalid_receivebyte = [b'\xaa', b'\xc1', b'\x13', b'\x00', b'5', b'\x00', b'\xd6', b'(', b'F', b'\xab'] +invalid_checksum = [b'\xaa', b'\xc0', b'\x13', b'\x00', b'5', b'\x00', b'\xd6', b'(', b'G', b'\xab'] +pm_two_five_out_of_range = [b'\xaa', b'\xc0', b'\x10', b'\x27', b'5', b'\x00', b'\xd6', b'(', b'j', b'\xab'] +pm_ten_out_of_range = [b'\xaa', b'\xc0', b'\x13', b'\x00', b'\x10', b'\x27', b'\xd6', b'(', b'H', b'\xab'] startbyte = b'\xaa' endbyte = b'0xAB' receivebyte = b'\xc0' @@ -65,4 +68,30 @@ def test_invalid_receivebyte_failure_raises_exception(): with pytest.raises(Exception) as e_info: sen = Sensor('Test sensor name string', '/testdir/testserialport', startbyte, endbyte, receivebyte) sen.check_message(invalid_receivebyte) - assert "Unexpected recievebyte" in str(e_info.value) \ No newline at end of file + assert "Unexpected recievebyte" in str(e_info.value) + +def test_invalid_checksum_raises_exception(): + with pytest.raises(Exception) as e_info: + sen = Sensor('Test sensor name string', '/testdir/testserialport', startbyte, endbyte, receivebyte) + sen.check_message(invalid_checksum) + assert "Checksum mismatch" in str(e_info.value) + +def test_pm_two_five_out_of_range_raises_exception(): + with pytest.raises(Exception) as e_info: + sen = Sensor('Test sensor name string', '/testdir/testserialport', startbyte, endbyte, receivebyte) + sen.get_pm_two_five(pm_two_five_out_of_range) + assert "PM2.5 reading" in str(e_info.value) + assert "exceeds maximum valid value" in str(e_info.value) + +def test_pm_ten_out_of_range_raises_exception(): + with pytest.raises(Exception) as e_info: + sen = Sensor('Test sensor name string', '/testdir/testserialport', startbyte, endbyte, receivebyte) + sen.get_pm_ten(pm_ten_out_of_range) + assert "PM10 reading" in str(e_info.value) + assert "exceeds maximum valid value" in str(e_info.value) + +def test_warm_up_sleeps_for_warmup_seconds(): + sen = Sensor('Test sensor name string', '/testdir/testserialport', startbyte, endbyte, receivebyte) + with patch('app.sensor.time.sleep') as mock_sleep: + sen.warm_up() + mock_sleep.assert_called_once_with(30) \ No newline at end of file diff --git a/tests/unit/test_start.py b/tests/unit/test_start.py new file mode 100644 index 0000000..23aab09 --- /dev/null +++ b/tests/unit/test_start.py @@ -0,0 +1,10 @@ +import pytest +from serial import SerialException +from unittest.mock import Mock +from start import start_online + +def xtest_start_online(capsys): + start() + captured = capsys.readouterr() + assert "Starting Air Monitor in offline mode" in captured.out + diff --git a/tests/unit/test_uploader.py b/tests/unit/test_uploader.py index 883e24b..def87a5 100644 --- a/tests/unit/test_uploader.py +++ b/tests/unit/test_uploader.py @@ -16,7 +16,8 @@ def test_upload_service_init_values(): assert up.target == 'target name' assert len(up.data) == 0 -def test_upload_service_read_config(): +def test_upload_service_read_config(monkeypatch): + monkeypatch.setenv('AIO_KEY', 'abc123') up = Uploader('target name') file='tests/unit/test_config.yml' username, key, pm_two_five, pm_ten = up.read_config(file)