Skip to content

Commit

Permalink
Add active/query mode specific readers
Browse files Browse the repository at this point in the history
  • Loading branch information
TimOrme committed Apr 22, 2023
1 parent 02adc75 commit 3036666
Show file tree
Hide file tree
Showing 5 changed files with 502 additions and 216 deletions.
16 changes: 3 additions & 13 deletions aqimon/read/novapm.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from . import AqiRead, ReaderState, ReaderStatus
import serial
from typing import Union
from .sds011 import NovaPmReader
from .sds011 import QueryModeReader
from statistics import mean


Expand All @@ -21,17 +21,11 @@ def __init__(
if isinstance(ser_dev, str):
ser_dev = serial.Serial(ser_dev, timeout=2)

self.reader = NovaPmReader(ser_dev=ser_dev)
self.reader = QueryModeReader(ser_dev=ser_dev)

# Initial the reader to be in the mode we want.
self.reader.wake()
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 @@ -43,26 +37,22 @@ 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)
self.reader.request_data()
result = self.reader.query_data()
result = self.reader.query()
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
129 changes: 126 additions & 3 deletions aqimon/read/sds011/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
WorkingPeriodReadResponse,
)
from . import constants as con
from .exceptions import IncompleteReadException, IncorrectCommandException
from .exceptions import IncompleteReadException, IncorrectCommandException, IncorrectCommandCodeException


class NovaPmReader:
Expand Down Expand Up @@ -71,6 +71,8 @@ def set_query_mode(self) -> None:
pass
except IncompleteReadException:
pass
except IncorrectCommandCodeException:
pass

def _set_reporting_mode(self, reporting_mode: con.ReportingMode) -> None:
"""Set the reporting mode, either ACTIVE or QUERYING.
Expand Down Expand Up @@ -116,11 +118,22 @@ def set_sleep_state(self, sleep_state: con.SleepState) -> None:

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

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

def safe_wake(self) -> None:
"""Wake the device up, if you don't know what mode its in.
This operates as a fire-and-forget, even in query mode. You shouldn't have to (and can't) query for a response
after this command.
"""
self.wake()
# If we were in query mode, this would flush out the response. If in active mode, this would be return read
# data, but we don't care.
self.ser.read(10)

def set_device_id(self, device_id: bytes, target_device_id: bytes = con.ALL_SENSOR_ID) -> None:
"""Set the device ID."""
Expand Down Expand Up @@ -191,3 +204,113 @@ def _cmd_checksum(self, data: bytes) -> int:
if len(data) != 15:
raise AttributeError("Invalid checksum length.")
return sum(d for d in data) % 256


class QueryModeReader:
"""Reader working in query mode."""

def __init__(self, ser_dev: serial.Serial, send_command_sleep: int = 1):
"""Create the device."""
self.base_reader = NovaPmReader(ser_dev=ser_dev, send_command_sleep=send_command_sleep)
self.base_reader.safe_wake()
self.base_reader.set_query_mode()

def query(self) -> QueryReadResponse:
"""Query the device for pollutant data."""
self.base_reader.request_data()
return self.base_reader.query_data()

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

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

def sleep(self) -> SleepWakeReadResponse:
"""Put the device to sleep, turning off fan and diode."""
self.base_reader.sleep()
return self.base_reader.query_sleep_state()

def wake(self) -> SleepWakeReadResponse:
"""Wake the device up to start reading."""
self.base_reader.wake()
return self.base_reader.query_sleep_state()

def set_device_id(self, device_id: bytes, target_device_id: bytes = con.ALL_SENSOR_ID) -> DeviceIdResponse:
"""Set the device ID."""
self.base_reader.set_device_id(device_id, target_device_id)
return self.base_reader.query_device_id()

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

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.
"""
self.base_reader.set_working_period(working_period)
return self.base_reader.query_working_period()

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


class ActiveModeReader:
"""Active Mode Reader.
Use with caution! Active mode is unpredictable. Query mode is much preferred.
"""

def __init__(self, ser_dev: serial.Serial, send_command_sleep: int = 2):
"""Create the device."""
self.base_reader = NovaPmReader(ser_dev=ser_dev, send_command_sleep=send_command_sleep)
self.ser_dev = ser_dev
self.base_reader.safe_wake()
self.base_reader.set_active_mode()

def query(self) -> QueryReadResponse:
"""Query the device for pollutant data."""
return self.base_reader.query_data()

def sleep(self) -> None:
"""Put the device to sleep, turning off fan and diode."""
self.base_reader.sleep()

# Sleep seems to behave very strangely in active mode. It continually outputs data for old commands for quite
# a while before eventually having nothing to report. This forces it to "drain" whatever it was doing before
# returning, but also feels quite dangerous.
while len(self.ser_dev.read(10)) == 10:
pass

def wake(self) -> None:
"""Wake the device up to start reading."""
self.base_reader.wake()
self.ser_dev.read(10)

def set_device_id(self, device_id: bytes, target_device_id: bytes = con.ALL_SENSOR_ID) -> None:
"""Set the device ID."""
self.base_reader.set_device_id(device_id, target_device_id)
self.ser_dev.read(10)

def set_working_period(self, working_period: int) -> None:
"""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.
"""
self.base_reader.set_working_period(working_period)
self.ser_dev.read(10)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ ignore = ["D203", "D213"]
line-length = 120

[tool.ruff.per-file-ignores]
"tests/*" = ["D103", "D104", "D100"] # We dont need docstrings in tests
"tests/*" = ["D100", "D101", "D102", "D103", "D104" ] # We dont need docstrings in tests

[tool.black]
line-length = 120
Expand Down
36 changes: 23 additions & 13 deletions tests/read/sds011/mock_device_serial.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def __init__(self) -> None:
"""
super().__init__()
self.response_buffer = b""
self.response_type = b""
self.operation_type = b""
self.query_mode = ReportingMode.ACTIVE
self.device_id = b"\x01\x01"
self.sleep_state = SleepState.WORK
Expand All @@ -52,8 +52,8 @@ def close(self):

def read(self, size: int = 1) -> bytes:
"""Read from the emulator."""
if self.query_mode == ReportingMode.ACTIVE:
# If in active mode, always return query response.
if self.query_mode == ReportingMode.ACTIVE and self.sleep_state == SleepState.WORK:
# If in active mode and awake, always return query response.
return self._get_query_response()
else:
response = self.response_buffer
Expand All @@ -68,35 +68,45 @@ def _generate_read(self, response_type: ResponseType, cmd: bytes) -> bytes:
def write(self, data: bytes) -> int:
"""Write to the emulator."""
last_write = parse_write_data(data)
self.response_type = last_write.raw_body_data[1:2]
self.operation_type = last_write.raw_body_data[1:2]

if self.sleep_state == SleepState.SLEEP and last_write.command != Command.SET_SLEEP:
# Device ignores commands in sleep mode, unless its a sleep command
return len(data)

if last_write.command == Command.SET_REPORTING_MODE:
if OperationType(last_write.raw_body_data[1:2]) == OperationType.SET_MODE:
self.query_mode = ReportingMode(last_write.raw_body_data[2:3])
self.response_buffer = self._set_reporting_mode_response()
self._set_response_buffer(self._set_reporting_mode_response())
elif last_write.command == Command.QUERY:
self.response_buffer = self._get_query_response()
self._set_response_buffer(self._get_query_response())
elif last_write.command == Command.SET_DEVICE_ID:
self.device_id = last_write.raw_body_data[11:13]
self.response_buffer = self._set_device_id_response()
self._set_response_buffer(self._set_device_id_response())
elif last_write.command == Command.SET_SLEEP:
if OperationType(last_write.raw_body_data[1:2]) == OperationType.SET_MODE:
self.sleep_state = SleepState(last_write.raw_body_data[2:3])
self.response_buffer = self._set_sleep_response()
self._set_response_buffer(self._set_sleep_response())
elif last_write.command == Command.SET_WORKING_PERIOD:
if OperationType(last_write.raw_body_data[1:2]) == OperationType.SET_MODE:
self.working_period = last_write.raw_body_data[2:3]
self.response_buffer = self._set_working_period_response()
self._set_response_buffer(self._set_working_period_response())
elif last_write.command == Command.CHECK_FIRMWARE_VERSION:
self.response_buffer = self._check_firmware_response()
self._set_response_buffer(self._check_firmware_response())
return len(data)

def _get_query_response(self) -> bytes:
return self._generate_read(ResponseType.QUERY_RESPONSE, b"\x19\x00\x64\x00")

def _set_response_buffer(self, data: bytes) -> None:
# Response buffer should only be written if there wasn't something already there.
if self.response_buffer == b"":
self.response_buffer = data

def _set_reporting_mode_response(self) -> bytes:
return self._generate_read(
ResponseType.GENERAL_RESPONSE,
Command.SET_REPORTING_MODE.value + self.response_type + self.query_mode.value + b"\x00",
Command.SET_REPORTING_MODE.value + self.operation_type + self.query_mode.value + b"\x00",
)

def _set_device_id_response(self) -> bytes:
Expand All @@ -105,13 +115,13 @@ def _set_device_id_response(self) -> bytes:
def _set_sleep_response(self) -> bytes:
return self._generate_read(
ResponseType.GENERAL_RESPONSE,
Command.SET_SLEEP.value + self.response_type + self.sleep_state.value + b"\x00",
Command.SET_SLEEP.value + self.operation_type + self.sleep_state.value + b"\x00",
)

def _set_working_period_response(self) -> bytes:
return self._generate_read(
ResponseType.GENERAL_RESPONSE,
Command.SET_WORKING_PERIOD.value + self.response_type + self.working_period + b"\x00",
Command.SET_WORKING_PERIOD.value + self.operation_type + self.working_period + b"\x00",
)

def _check_firmware_response(self) -> bytes:
Expand Down
Loading

0 comments on commit 3036666

Please sign in to comment.