Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions app/sensor.py
Original file line number Diff line number Diff line change
@@ -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():

Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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


Expand Down
10 changes: 8 additions & 2 deletions app/uploader.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from Adafruit_IO import Client, AdafruitIOError, RequestError
import logging
import os
import yaml


Expand All @@ -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:
Expand Down
21 changes: 12 additions & 9 deletions setup.py
Original file line number Diff line number Diff line change
@@ -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():
Expand All @@ -18,29 +18,32 @@ 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)


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:
Expand Down
68 changes: 51 additions & 17 deletions start.py
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
5 changes: 5 additions & 0 deletions tests/integration/test_config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
aio:
username: 'test user'
feeds:
pm-two-five: 'pm-two-five'
pm-ten: 'pm-ten'
12 changes: 12 additions & 0 deletions tests/integration/test_uploader_integration.py
Original file line number Diff line number Diff line change
@@ -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'
1 change: 0 additions & 1 deletion tests/unit/test_config.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
aio:
username: 'test user'
key: 'abc123'
feeds:
pm-two-five: 'pm-two-five'
pm-ten: 'pm-ten'
33 changes: 31 additions & 2 deletions tests/unit/test_sensor.py
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
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)
10 changes: 10 additions & 0 deletions tests/unit/test_start.py
Original file line number Diff line number Diff line change
@@ -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

3 changes: 2 additions & 1 deletion tests/unit/test_uploader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading