Skip to content

Commit

Permalink
Full implementation of SDS011 spec
Browse files Browse the repository at this point in the history
  • Loading branch information
TimOrme committed Apr 18, 2023
1 parent 8304083 commit 47e1c93
Show file tree
Hide file tree
Showing 12 changed files with 507 additions and 117 deletions.
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ A simple Air Quality Index monitor, designed to work on the Raspberry Pi with th
### Pre-Requisites

- Python 3.9+
- [uhubctl](https://github.com/mvp/uhubctl) must be installed and on your PATH.

### Install

Expand Down
3 changes: 3 additions & 0 deletions aqimon/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class Config:
usb_path: str
usb_sleep_time_sec: int
sample_count_per_read: int
warm_up_sec: int

# EPA Properties
epa_lookback_minutes: int
Expand All @@ -36,6 +37,7 @@ class Config:
reader_type="NOVAPM",
usb_path="/dev/ttyUSB0",
usb_sleep_time_sec=5,
warm_up_sec=15,
sample_count_per_read=5,
server_port=8000,
server_host="0.0.0.0",
Expand All @@ -51,6 +53,7 @@ def get_config_from_env() -> Config:
reader_type=os.environ.get("AQIMON_READER_TYPE", DEFAULT_CONFIG.reader_type),
usb_path=os.environ.get("AQIMON_USB_PATH", DEFAULT_CONFIG.usb_path),
usb_sleep_time_sec=int(os.environ.get("AQIMON_USB_SLEEP_TIME_SEC", DEFAULT_CONFIG.usb_sleep_time_sec)),
warm_up_sec=int(os.environ.get("AQIMON_WARM_UP_SEC", DEFAULT_CONFIG.warm_up_sec)),
sample_count_per_read=int(os.environ.get("AQIMON_SAMPLE_COUNT_PER_READ", DEFAULT_CONFIG.sample_count_per_read)),
server_port=int(os.environ.get("AQIMON_SERVER_PORT", DEFAULT_CONFIG.server_port)),
server_host=os.environ.get("AQIMON_SERVER_HOST", DEFAULT_CONFIG.server_host),
Expand Down
5 changes: 3 additions & 2 deletions aqimon/read/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ class ReaderStatus(Enum):
"""Enum of possible reader states."""

IDLE = 1
READING = 2
ERRORING = 3
WARM_UP = 2
READING = 3
ERRORING = 4


@dataclass(frozen=True)
Expand Down
96 changes: 33 additions & 63 deletions aqimon/read/novapm.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,90 +2,60 @@
https://www.amazon.com/SDS011-Quality-Detection-Conditioning-Monitor/dp/B07FSDMRR5
"""
from aqimon import usb
from . import AqiRead, ReaderState, ReaderStatus
import asyncio

from . import AqiRead, ReaderState, ReaderStatus
import serial
from typing import Union
from .sds011 import NovaPmReader
from .sds011.constants import ReportingState
from statistics import mean


class NovaPmReader:
class OpinionatedReader:
"""NOVA PM SDS011 Reader."""

def __init__(self, usb_path: str, iterations: int = 5, sleep_time: int = 60):
def __init__(
self, ser_dev: Union[str, serial.Serial], warm_up_secs: int = 15, iterations: int = 5, sleep_time: int = 3
):
"""Create the device."""
self.usb_path = usb_path
if isinstance(ser_dev, str):
ser_dev = serial.Serial(ser_dev, timeout=2)

self.reader = NovaPmReader(ser_dev=ser_dev)

# Initial the reader to be in the mode we want.
self.reader.wake()
self.reader.set_reporting_mode(ReportingState.QUERYING)
self.reader.set_working_period(0)

self.warm_up_secs = warm_up_secs
self.iterations = iterations
self.sleep_time = sleep_time

self.state = ReaderState(ReaderStatus.IDLE, None)

async def read(self) -> AqiRead:
"""Read from the device."""
try:
self.reader.wake()
self.state = ReaderState(ReaderStatus.WARM_UP, None)
await asyncio.sleep(self.warm_up_secs)
self.state = ReaderState(ReaderStatus.READING, None)
result = await self._power_saving_read()
pm25_reads = []
pm10_reads = []
for x in range(0, self.iterations):
await asyncio.sleep(self.sleep_time)
result = self.reader.query()
pm25_reads.append(result.pm25)
pm10_reads.append(result.pm10)
self.reader.sleep()
self.state = ReaderState(ReaderStatus.IDLE, None)
return result
return AqiRead(pmtwofive=mean(pm25_reads), pmten=mean(pm10_reads))
except Exception as e:
self.state = ReaderState(ReaderStatus.ERRORING, e)
raise e

def get_state(self) -> ReaderState:
"""Get the current state of the reader."""
return self.state

async def _power_saving_read(self) -> AqiRead:
try:
await usb.turn_on_usb()
await asyncio.sleep(5)
except usb.UhubCtlNotInstalled:
pass
result = await self._averaged_read()
try:
await usb.turn_off_usb()
await asyncio.sleep(5)
except usb.UhubCtlNotInstalled:
pass

return AqiRead(result.pmtwofive, result.pmten)

async def _averaged_read(self) -> AqiRead:
pm25_reads = []
pm10_reads = []

for x in range(self.iterations):
data = self._read()
pm25_reads.append(data.pmtwofive)
pm10_reads.append(data.pmten)
await asyncio.sleep(self.sleep_time)

avg_pm25 = mean(pm25_reads)
avg_pm10 = mean(pm10_reads)

return AqiRead(pmtwofive=avg_pm25, pmten=avg_pm10)

def _read(self) -> AqiRead:
ser = serial.Serial(self.usb_path)
data = ser.read(10)
pmtwofive = int.from_bytes(data[2:4], byteorder="little") / 10
pmten = int.from_bytes(data[4:6], byteorder="little") / 10

checksum = data[8]
checksum_vals = sum([data[x] for x in range(2, 8)]) & 255

if data[0:1] != b"\xaa":
raise Exception("Incorrect header read.")

if data[9:10] != b"\xab":
raise Exception("Incorrect footer read.")

if checksum_vals != checksum:
raise Exception(f"Expected read checksum of {checksum}, but got {checksum_vals}")

if pmten > 999:
raise Exception("PM10 value out of range!")

if pmtwofive > 999:
raise Exception("PM2.5 value out of range!")

return AqiRead(pmtwofive, pmten)
170 changes: 170 additions & 0 deletions aqimon/read/sds011/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""Nova PM SDS011 Reader module.
Device: https://www.amazon.com/SDS011-Quality-Detection-Conditioning-Monitor/dp/B07FSDMRR5
Spec: https://cdn.sparkfun.com/assets/parts/1/2/2/7/5/Laser_Dust_Sensor_Control_Protocol_V1.3.pdf
Spec: https://cdn-reichelt.de/documents/datenblatt/X200/SDS011-DATASHEET.pdf
"""

import serial
from .responses import (
QueryReadResponse,
ReportingModeReadResponse,
SleepWakeReadResponse,
DeviceIdResponse,
CheckFirmwareReadResponse,
WorkingPeriodReadResponse,
)
from . import constants as con
from .exceptions import IncompleteReadException, InvalidDeviceIdException


class NovaPmReader:
"""NOVA PM SDS011 Reader."""

def __init__(self, ser_dev: serial.Serial):
"""Create the device."""
self.ser = ser_dev

def query(self, query_mode: bool = True) -> QueryReadResponse:
"""Query the device for pollutant data."""
if query_mode:
cmd = con.Commands.QUERY.value + (b"\x00" * 12) + con.ALL_SENSOR_ID
self._send_command(cmd)
return QueryReadResponse(self._read_response())

def get_reporting_mode(self) -> ReportingModeReadResponse:
"""Get the current reporting mode of the device."""
cmd = (
con.Commands.SET_REPORTING_MODE.value
+ con.ReportingMode.QUERY.value
+ con.ReportingState.ACTIVE.value
+ (b"\x00" * 10)
+ con.ALL_SENSOR_ID
)
self._send_command(cmd)
return ReportingModeReadResponse(self._read_response())

def set_reporting_mode(self, reporting_mode: con.ReportingState) -> ReportingModeReadResponse:
"""Set the reporting mode, either ACTIVE or QUERYING.
ACTIVE mode means the device will always return a Query command response when data is asked for, regardless of
what command was sent.
QUERYING mode means the device will only return responses to submitted commands, even for Query commands.
ACTIVE mode is the factory default, but generally, QUERYING mode is preferrable for the longevity of the device.
"""
cmd = (
con.Commands.SET_REPORTING_MODE.value
+ con.ReportingMode.SET_MODE.value
+ reporting_mode.value
+ (b"\x00" * 10)
+ con.ALL_SENSOR_ID
)
self._send_command(cmd)
# Switching between reporting modes is finicky; resetting the serial connection seems to address issues.
self.ser.close()
self.ser.open()
return ReportingModeReadResponse(self._read_response())

def get_sleep_state(self) -> SleepWakeReadResponse:
"""Get the current sleep state."""
cmd = (
con.Commands.SET_SLEEP.value
+ con.SleepMode.QUERY.value
+ con.SleepState.WORK.value
+ (b"\x00" * 10)
+ con.ALL_SENSOR_ID
)
self._send_command(cmd)
return SleepWakeReadResponse(self._read_response())

def set_sleep_state(self, sleep_state: con.SleepState) -> SleepWakeReadResponse:
"""Set the sleep state, either wake or sleep."""
cmd = (
con.Commands.SET_SLEEP.value
+ con.SleepMode.SET_MODE.value
+ sleep_state.value
+ (b"\x00" * 10)
+ con.ALL_SENSOR_ID
)
self._send_command(cmd)
return SleepWakeReadResponse(self._read_response())

def sleep(self) -> SleepWakeReadResponse:
"""Put the device to sleep, turning off fan and diode."""
return self.set_sleep_state(con.SleepState.SLEEP)

def wake(self) -> SleepWakeReadResponse:
"""Wake the device up to start reading."""
return self.set_sleep_state(con.SleepState.WORK)

def set_device_id(self, device_id: bytes, target_device_id: bytes = con.ALL_SENSOR_ID) -> DeviceIdResponse:
"""Set the device ID."""
if len(device_id) != 2 or len(target_device_id) != 2:
raise AttributeError(f"Device ID must be 4 bytes, found {len(device_id)}, and {len(target_device_id)}")
cmd = con.Commands.SET_DEVICE_ID.value + (b"\x00" * 10) + device_id + target_device_id
self._send_command(cmd)
try:
return DeviceIdResponse(self._read_response())
except IncompleteReadException:
raise InvalidDeviceIdException(f"Unable to find device ID of {target_device_id!s}")

def get_working_period(self) -> WorkingPeriodReadResponse:
"""Retrieve the current working period for the device."""
cmd = (
con.Commands.SET_WORKING_PERIOD.value
+ con.WorkingPeriodMode.QUERY.value
+ (b"\x00" * 11)
+ con.ALL_SENSOR_ID
)
self._send_command(cmd)
return WorkingPeriodReadResponse(self._read_response())

def set_working_period(self, working_period: int) -> WorkingPeriodReadResponse:
"""Set the working period for the device.
Working period must be between 0 and 30.
0 means the device will read continuously.
Any value 1-30 means the device will wake and read for 30 seconds every n*60-30 seconds.
"""
if 0 >= working_period >= 30:
raise AttributeError("Working period must be between 0 and 30")
cmd = (
con.Commands.SET_WORKING_PERIOD.value
+ con.WorkingPeriodMode.SET_MODE.value
+ bytes([working_period])
+ (b"\x00" * 10)
+ con.ALL_SENSOR_ID
)
self._send_command(cmd)
return WorkingPeriodReadResponse(self._read_response())

def get_firmware_version(self) -> CheckFirmwareReadResponse:
"""Retrieve the firmware version from the device."""
cmd = con.Commands.CHECK_FIRMWARE_VERSION.value + (b"\x00" * 12) + con.ALL_SENSOR_ID
self._send_command(cmd)
return CheckFirmwareReadResponse(self._read_response())

def _send_command(self, cmd: bytes):
"""Send a command to the device as bytes."""
head = con.HEAD + con.SUBMIT_TYPE
full_command = head + cmd + bytes([self._cmd_checksum(cmd)]) + con.TAIL
if len(full_command) != 19:
raise Exception(f"Command length must be 19, but was {len(full_command)}")
self.ser.write(full_command)

def _read_response(self) -> bytes:
"""Read a response from the device."""
result = self.ser.read(10)
if len(result) != 10:
raise IncompleteReadException(len(result))
return result

def _cmd_checksum(self, data: bytes) -> int:
"""Generate a checksum for the data bytes of a command."""
if len(data) != 15:
raise AttributeError("Invalid checksum length.")
return sum(d for d in data) % 256
Loading

0 comments on commit 47e1c93

Please sign in to comment.