Skip to content

Commit

Permalink
Finalize API, add tests
Browse files Browse the repository at this point in the history
  • Loading branch information
TimOrme committed Apr 21, 2023
1 parent 06fed19 commit b80f121
Show file tree
Hide file tree
Showing 7 changed files with 241 additions and 182 deletions.
15 changes: 12 additions & 3 deletions aqimon/read/novapm.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import serial
from typing import Union
from .sds011 import NovaPmReader
from .sds011.constants import ReportingState
from statistics import mean


Expand All @@ -26,8 +25,13 @@ def __init__(

# Initial the reader to be in the mode we want.
self.reader.wake()
self.reader.set_reporting_mode(ReportingState.QUERYING)
try:
self.reader.query_sleep_state()
except Exception:
pass
self.reader.set_query_mode()
self.reader.set_working_period(0)
self.reader.query_working_period()

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

def get_state(self) -> ReaderState:
Expand Down
90 changes: 59 additions & 31 deletions aqimon/read/sds011/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"""

import serial
import time
from .responses import (
QueryReadResponse,
ReportingModeReadResponse,
Expand All @@ -16,25 +17,28 @@
WorkingPeriodReadResponse,
)
from . import constants as con
from .exceptions import IncompleteReadException, InvalidDeviceIdException
from .exceptions import IncompleteReadException, IncorrectCommandException


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

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

def query(self, query_mode: bool = True) -> QueryReadResponse:
def request_data(self) -> None:
"""Request device to return pollutant data."""
cmd = con.Commands.QUERY.value + (b"\x00" * 12) + con.ALL_SENSOR_ID
self._send_command(cmd)

def query_data(self) -> 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."""
def request_reporting_mode(self) -> None:
"""Request device to return the current reporting mode."""
cmd = (
con.Commands.SET_REPORTING_MODE.value
+ con.ReportingMode.QUERY.value
Expand All @@ -43,9 +47,32 @@ def get_reporting_mode(self) -> ReportingModeReadResponse:
+ con.ALL_SENSOR_ID
)
self._send_command(cmd)

def query_reporting_mode(self) -> ReportingModeReadResponse:
"""Get the current reporting mode of the device."""
return ReportingModeReadResponse(self._read_response())

def set_reporting_mode(self, reporting_mode: con.ReportingState) -> ReportingModeReadResponse:
def set_active_mode(self) -> None:
"""Set the reporting mode to active."""
self._set_reporting_mode(con.ReportingState.ACTIVE)
try:
self.query_reporting_mode()
except IncorrectCommandException:
pass
except IncompleteReadException:
pass

def set_query_mode(self) -> None:
"""Set the reporting mode to querying."""
self._set_reporting_mode(con.ReportingState.QUERYING)
try:
self.query_reporting_mode()
except IncorrectCommandException:
pass
except IncompleteReadException:
pass

def _set_reporting_mode(self, reporting_mode: con.ReportingState) -> None:
"""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
Expand All @@ -66,21 +93,17 @@ def set_reporting_mode(self, reporting_mode: con.ReportingState) -> ReportingMod
# 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:
def request_sleep_state(self) -> None:
"""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
)
cmd = con.Commands.SET_SLEEP.value + con.SleepMode.QUERY.value + b"\x00" + (b"\x00" * 10) + con.ALL_SENSOR_ID
self._send_command(cmd)

def query_sleep_state(self) -> SleepWakeReadResponse:
"""Get the current sleep state."""
return SleepWakeReadResponse(self._read_response())

def set_sleep_state(self, sleep_state: con.SleepState) -> SleepWakeReadResponse:
def set_sleep_state(self, sleep_state: con.SleepState) -> None:
"""Set the sleep state, either wake or sleep."""
cmd = (
con.Commands.SET_SLEEP.value
Expand All @@ -90,28 +113,27 @@ def set_sleep_state(self, sleep_state: con.SleepState) -> SleepWakeReadResponse:
+ con.ALL_SENSOR_ID
)
self._send_command(cmd)
return SleepWakeReadResponse(self._read_response())

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

def wake(self) -> SleepWakeReadResponse:
def wake(self) -> None:
"""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:
def set_device_id(self, device_id: bytes, target_device_id: bytes = con.ALL_SENSOR_ID) -> None:
"""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:
def query_device_id(self) -> DeviceIdResponse:
"""Set the device ID."""
return DeviceIdResponse(self._read_response())

def request_working_period(self) -> None:
"""Retrieve the current working period for the device."""
cmd = (
con.Commands.SET_WORKING_PERIOD.value
Expand All @@ -120,9 +142,12 @@ def get_working_period(self) -> WorkingPeriodReadResponse:
+ con.ALL_SENSOR_ID
)
self._send_command(cmd)

def query_working_period(self) -> WorkingPeriodReadResponse:
"""Retrieve the current working period for the device."""
return WorkingPeriodReadResponse(self._read_response())

def set_working_period(self, working_period: int) -> WorkingPeriodReadResponse:
def set_working_period(self, working_period: int) -> None:
"""Set the working period for the device.
Working period must be between 0 and 30.
Expand All @@ -140,12 +165,14 @@ def set_working_period(self, working_period: int) -> WorkingPeriodReadResponse:
+ con.ALL_SENSOR_ID
)
self._send_command(cmd)
return WorkingPeriodReadResponse(self._read_response())

def get_firmware_version(self) -> CheckFirmwareReadResponse:
def request_firmware_version(self) -> None:
"""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)

def query_firmware_version(self) -> CheckFirmwareReadResponse:
"""Retrieve the firmware version from the device."""
return CheckFirmwareReadResponse(self._read_response())

def _send_command(self, cmd: bytes):
Expand All @@ -155,6 +182,7 @@ def _send_command(self, cmd: bytes):
if len(full_command) != 19:
raise Exception(f"Command length must be 19, but was {len(full_command)}")
self.ser.write(full_command)
time.sleep(self.send_command_sleep)

def _read_response(self) -> bytes:
"""Read a response from the device."""
Expand Down
6 changes: 0 additions & 6 deletions aqimon/read/sds011/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,6 @@ class IncompleteReadException(Sds011Exception):
pass


class QueryInActiveModeException(Sds011Exception):
"""Thrown if any query is issued while the device is in ACTIVE mode."""

pass


class InvalidDeviceIdException(Sds011Exception):
"""Thrown if the trying to set the device ID on an invalid device."""

Expand Down
5 changes: 0 additions & 5 deletions aqimon/read/sds011/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
IncorrectCommandCodeException,
IncorrectWrapperException,
IncompleteReadException,
QueryInActiveModeException,
)


Expand Down Expand Up @@ -53,10 +52,6 @@ def verify(self):
if self.checksum != self.calc_checksum():
raise ChecksumFailedException(expected=self.checksum, actual=self.calc_checksum())
if self.cmd_id != self.expected_response_type.value:
if self.cmd_id == ResponseTypes.QUERY_RESPONSE.value:
raise QueryInActiveModeException(
"Tried to retrieve response, but it looks like device is in ACTIVE mode."
)
raise IncorrectCommandException(expected=self.expected_response_type.value, actual=self.cmd_id)

# Query responses don't validate the command code
Expand Down
1 change: 1 addition & 0 deletions aqimon/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ async def read_function() -> None:
scheduled_reader.next_schedule = datetime.now() + timedelta(seconds=config.poll_frequency_sec)
except Exception as e:
log.exception("Failed to retrieve data from reader", e)
scheduled_reader.next_schedule = datetime.now() + timedelta(seconds=config.poll_frequency_sec)

# Note that we leverage the @repeat_every decorator here, but as a regular function call. This allows us to
# use a non-global config object to specify the poll frequency
Expand Down
Loading

0 comments on commit b80f121

Please sign in to comment.