Pico W Air Quality Monitoring w/ PM 2.5 Sensor
# MIT License

# Copyright (c) 2023 dphacks.com
# Copyright (c) 2023 André Costa

# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:

# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.

# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.


## Created by André Costa for dphacks.com
## Documentation is available at https://github.com/DPHacks/picow-air
## Make sure to edit the settings.toml file with WiFi credentials

import os
import time
#import ipaddress
import wifi
import socketpool
import json
import board
import busio
from digitalio import DigitalInOut, Direction, Pull
import adafruit_minimqtt.adafruit_minimqtt as MQTT

import adafruit_ahtx0
from adafruit_httpserver import (
    Server,
    Request,
    Response,
    FileResponse,
    JSONResponse,
    GET,
    POST
)

# getenv variables are setup in the ***setting.toml*** file
# These variables are replicated here for code readability only.
MQTT_ENABLED = os.getenv('MQTT_ENABLED')
MQTT_BROKER = os.getenv('MQTT_BROKER')
MQTT_TOPIC = os.getenv('MQTT_TOPIC')
MQTT_PORT = os.getenv('MQTT_PORT')
MQTT_ISTLS = os.getenv('MQTT_ISTLS')
MQTT_USERNAME = os.getenv('MQTT_USERNAME')
MQTT_PASSWORD = os.getenv('MQTT_PASSWORD')

INTERVAL = os.getenv('INTERVAL')

LED_R_MEASURE = os.getenv('LED_R_MEASURE')
LED_R_LOW_THRESHOLD = os.getenv('LED_R_LOW_THRESHOLD')
LED_R_HIGH_THRESHOLD = os.getenv('LED_R_HIGH_THRESHOLD')
LED_G_MEASURE = os.getenv('LED_G_MEASURE')
LED_G_LOW_THRESHOLD = os.getenv('LED_G_LOW_THRESHOLD')
LED_G_HIGH_THRESHOLD = os.getenv('LED_G_HIGH_THRESHOLD')

C_TO_F = os.getenv('C_TO_F')
SMOOTH = os.getenv('SMOOTH')

VERSION = 0.9

### PIN DEFINITIONS ###
## PICO LED
led = DigitalInOut(board.LED)
led.direction = Direction.OUTPUT

## BOARD LED
## Red LED on GP22
board_led_r = DigitalInOut(board.GP22)
board_led_r.direction = Direction.OUTPUT

## Green LED on GP18
board_led_g = DigitalInOut(board.GP18)
board_led_g.direction = Direction.OUTPUT

# Header Pin 1 is connected to GPIO ADC1 (A1)
# Uncomment the code below for reading analog signal through A1
#import analogio
#adc = analogio.AnalogIn(board.A1)

### SENSOR DEFINITIONS ###

## PMS 5003
print('Setting up PM Sensor...')
from pms5003 import PMS5003

pm25 = PMS5003()
time.sleep(0.1)
pm25.cmd_mode_passive()

print('Sensor Setup!')

## Add Qwiic/QT/I2C Sensors
## SCL on GP21; SDA on GP20

# Comment out the lines below if you don't have any I2C
# sensors plugged into the Qwiic connector
i2c = busio.I2C(board.GP21, board.GP20)
th = adafruit_ahtx0.AHTx0(i2c)

avgDict = {}

### SENSOR METHODS ###
def read_all():
    """
    Read all sensor data and return a single dictionary
    """
    data = {}
    data = merge_dicts(read_pms25(), data)
    data = merge_dicts(read_temp_hum(), data)

    return data

def read_pms25():
    """
    Read air quality information from PMS5003
    """
    pmvalues = {}
    pmdata = {}

    try:
        pmdata = pm25.read()
    except RuntimeError:
        print("Unable to read PM2.5 Data")
        # pm25.reset()

    if(pmdata):
        pmvalues['pm10 standard'] = pmdata.data[0]
        pmvalues['pm25 standard'] = pmdata.data[1]
        pmvalues['pm100 standard'] = pmdata.data[2]
        pmvalues['pm10 env'] = pmdata.data[3]
        pmvalues['pm25 env'] = pmdata.data[4]
        pmvalues['pm100 env'] = pmdata.data[5]
        pmvalues['particles 03um'] = pmdata.data[6]
        pmvalues['particles 05um'] = pmdata.data[7]
        pmvalues['particles 10um'] = pmdata.data[8]
        pmvalues['particles 25um'] = pmdata.data[9]
        pmvalues['particles 50um'] = pmdata.data[10]
        pmvalues['particles 100um'] = pmdata.data[11]


    return pmvalues

def read_temp_hum():
    """
    Read temp and humidity from environment sensor
    """
    values = {}

    if(C_TO_F):
        values['temperature'] = round(c_to_f(th.temperature), 2)
    else:
        values['temperature'] = round(th.temperature, 2)
    
    values['humidity'] = round(th.relative_humidity, 2)

    return values

def read_analog():
    """
    Read analog signal from Pin ADC1 (A1)
    """
    return adc.value

### LED METHODS ###
def led_on(led):
    """
    Turn on the LED passed to the function
    """
    led.value = True

def led_off(led):
    """
    Turn off the LED passed to the function
    """
    led.value = False

def blink(led, period, times):
    """
    Blink the LED passed to the function
    """
    for i in range(times):
        led_on(led)
        time.sleep(period)
        led_off(led)
        if(i != times):
            time.sleep(period)

def led_status(values):
    """
    Turn onboard LEDs on/off based on sensor data. This can be used to create a status
    indicator based on sensor reading values.
    """
    if(LED_R_LOW_THRESHOLD < values[LED_R_MEASURE] <= LED_R_HIGH_THRESHOLD):
        board_led_r.value = True
    else:
        board_led_r.value = False

    if(LED_G_LOW_THRESHOLD < values[LED_G_MEASURE] <= LED_G_HIGH_THRESHOLD):
        board_led_g.value = True
    else:
        board_led_g.value = False

### HELPER METHODS ###
def merge_dicts(values, dicts):
    """
    Merge two dictionaries into a single dict
    """
    dicts.update(values)

    return dicts

def average_dict(dicts):
    """
    Record sensor measurements in a dictionary of lists. Used to smooth sensor readings
    so they don't jump around too much
    """
    for d in dicts:
        # Add sensor measurement to avgDict if it doesn't already exists
        # Helpful for not having to declare every possible sensor measure
        if(d not in avgDict):
            avgDict[d]=[]
        avgDict[d].append(dicts[d])
        ln = len(avgDict[d])
        if(ln > SMOOTH):
            newList = avgDict[d]
            # Only keep SMOOTH number of items in the list
            avgDict[d] = newList[ln-SMOOTH:]

    return avgDict
    
def average_values(avgDict):
    """
    Calculates the average measurement based on values stored in the average dict
    """
    values = {}
    for v in avgDict:
        values[v] = round(sum(avgDict[v])/len(avgDict[v]))

    return values

def c_to_f(temp):
    """
    Convert Celsius to Fahrenheit
    """
    return (temp * 9 / 5) + 32


print("Connecting to WiFi")

wifi.radio.connect(os.getenv('WIFI_SSID'), os.getenv('WIFI_PASSWORD'))

# Blink the green LED if Wifi is connected
if(wifi.radio.connected):
    print("Connected to WiFi")
    blink(board_led_g, 0.2, 3)
    

# Create socket pool
pool = socketpool.SocketPool(wifi.radio)

# Create html server
server = Server(pool, '/html', debug=True)

#  prints MAC address to REPL
print("My MAC addr:", [hex(i) for i in wifi.radio.mac_address])

#  prints IP address to REPL
print("My IP address is", wifi.radio.ipv4_address)

### HTML SERVER ROUTES ###
@server.route("/")
def base(request: Request):
    """
    Serve the default index.html file.
    """
    return FileResponse(request, "index.html")

@server.route("/getdata")
def get_sensor_data(request: Request):
    """
    Read sendor data and return JSON
    """
    data = read_all()

    # averrage to smooth out values
    avgDict = average_dict(data)
    data = average_values(avgDict)

    return JSONResponse(request, data)

@server.route("/pmdata")
def pmdata_client(request: Request):
    """
    Serve PMS 5003 data as JSON
    """
    data = read_pms25()
    return JSONResponse(request, data)

@server.route("/th")
def pmdata_client(request: Request):
    """
    Serve Temp and Humidity data as JSON
    """
    data = read_temp_hum()
    return JSONResponse(request, data)
    
@server.route("/ledon")
def pico_led_on(request: Request):
    """
    Turn on the Pico W LED
    """
    led.value = True

    return FileResponse(request, "index.html")

@server.route("/ledoff")
def pico_led_on(request: Request):
    """
    Turn off the Pico W LED
    """
    led.value = False

    return FileResponse(request, "index.html")

@server.route("/redledon")
def board_led_on(request: Request):
    """
    Turn on the red LED
    """
    led_on(board_led_r)

    return FileResponse(request, "index.html")

@server.route("/redledoff")
def board_led_on(request: Request):
    """
    Turn off the red LED
    """
    led_off(board_led_r)

    return FileResponse(request, "index.html")

@server.route("/greenledon")
def board_led_on(request: Request):
    """
    Turn on the green LED
    """
    led_on(board_led_g)

    return FileResponse(request, "index.html")

@server.route("/greenledoff")
def board_led_on(request: Request):
    """
    Turn off the green LED
    """
    led_off(board_led_g)

    return FileResponse(request, "index.html")

### MQTT METHODS ###
## Leaving all methods here even though not all are being used
## Some people might find it helpful
def connect(mqtt_client, userdata, flags, rc):
    """
    This method is be called when the mqtt_client is connected successfully to the broker.
    """
    print("Connected to MQTT Broker!")
    print("Flags: {0}\n RC: {1}".format(flags, rc))


def disconnect(mqtt_client, userdata, rc):
    """
    This method is called when the mqtt_client disconnects from the broker.
    """
    print("Disconnected from MQTT Broker!")


def subscribe(mqtt_client, userdata, topic, granted_qos):
    """
    This method is called when the mqtt_client subscribes to a new feed.
    """
    print("Subscribed to {0} with QOS level {1}".format(topic, granted_qos))


def unsubscribe(mqtt_client, userdata, topic, pid):
    """
    This method is called when the mqtt_client unsubscribes from a feed.
    """
    print("Unsubscribed from {0} with PID {1}".format(topic, pid))


def publish(mqtt_client, userdata, topic, pid):
    """
    This method is called when the mqtt_client publishes data to a feed.
    """
    print("Published to {0} with PID {1}".format(topic, pid))


def message(client, topic, message):
    """
    This method is called when a client's subscribed feed has a new value.
    """
    print("New message on topic {0}: {1}".format(topic, message))
    

# Setup MQTT Client
mqtt_client = MQTT.MQTT(
    broker=MQTT_BROKER,
    port=MQTT_PORT,
    socket_pool=pool,
    is_ssl=MQTT_ISTLS
)

# Connect callback handlers for mqtt_client
mqtt_client.on_connect = connect
mqtt_client.on_disconnect = disconnect
mqtt_client.on_subscribe = subscribe
mqtt_client.on_unsubscribe = unsubscribe
mqtt_client.on_publish = publish
mqtt_client.on_message = message

if MQTT_USERNAME and MQTT_PASSWORD:
    mqtt_client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)

mqtt_client.connect()

# Start the html server.
server.start(str(wifi.radio.ipv4_address))

# Get clock reference
clock = time.monotonic()
values = {}

while True:
    
    # Take the measurements after every interval
    if (clock + INTERVAL) < time.monotonic():

        mqtt_msg = {}

        mqtt_msg = read_all()
        
        # averrage to smooth out values
        avgDict = average_dict(mqtt_msg)

        mqtt_msg = average_values(avgDict)

        led_status(mqtt_msg)

        if (mqtt_msg and MQTT_ENABLED and wifi.radio.connected):
            mqtt_client.publish(MQTT_TOPIC, json.dumps(mqtt_msg))
        else:
            print("No successful readings or wifi is disconnected")

        clock = time.monotonic()
    
    # Process html requests between reading sensor data
    pool_result = server.poll()
code.py
DPHacks/picow-air
# MIT License

# Copyright (c) 2018 Pimoroni Ltd.
# Copyright (c) 2020 Kevin J. Walters
# Copyright (c) 2020 Erik Hess

# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:

# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.

# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

# Info added on Sept 2023 by André Costa @ dphacks.com:
# This is a modified version of the module provided by Pimoroni made to work
# specifically with the Pico W Air board by DPHacks

import struct
import time

import board
import busio
from digitalio import DigitalInOut, Direction

__version__ = '0.0.6'


PMS5003_SOF = bytearray(b'\x42\x4d')
PMS5003_CMD_MODE_PASSIVE = b'\xe1\x00\x00'
PMS5003_CMD_MODE_ACTIVE = b'\xe1\x00\x01'
PMS5003_CMD_READ = b'\xe2\x00\x00'
PMS5003_CMD_SLEEP = b'\xe4\x00\x00'
PMS5003_CMD_WAKEUP = b'\xe4\x00\x01'


class ChecksumMismatchError(RuntimeError):
    pass


class FrameLengthError(RuntimeError):
    pass


class ReadTimeoutError(RuntimeError):
    pass


class SerialTimeoutError(RuntimeError):
    pass


class PMS5003Response:
    FRAME_LEN = None
    DATA_LEN = None
    DATA_FMT = None
    CHECKSUM_IDX = None

    @classmethod
    def check_data_len(cls, raw_data_len, desc="Data"):
        if raw_data_len != cls.DATA_LEN:
            raise FrameLengthError(desc + " too "
                                   + ("short" if raw_data_len < cls.DATA_LEN else "long")
                                   + " {:d} bytes".format(raw_data_len))

    def __init__(self, raw_data, *, frame_length_bytes):
        raw_data_len = len(raw_data)
        self.check_data_len(raw_data_len)
        self.raw_data = raw_data
        self.data = struct.unpack(self.DATA_FMT, raw_data)
        self.checksum = self.data[self.CHECKSUM_IDX]

        # Don't include the checksum bytes in the checksum calculation
        checksum = sum(PMS5003_SOF) + sum(raw_data[:-2])
        if frame_length_bytes is None:
            checksum += (raw_data_len >> 256) + (raw_data_len & 0xff)
        else:
            checksum += sum(frame_length_bytes)
        if checksum != self.checksum:
            raise ChecksumMismatchError("PMS5003 Checksum Mismatch {} != {}".format(checksum,
                                                                                    self.checksum))


class PMS5003CmdResponse(PMS5003Response):
    FRAME_LEN = 8
    DATA_LEN = FRAME_LEN - 4  # includes checksum
    DATA_FMT = ">BBH"
    CHECKSUM_IDX = 2

    def __init__(self, raw_data, *, frame_length_bytes=None):
        super().__init__(raw_data, frame_length_bytes=frame_length_bytes)


class PMS5003Data(PMS5003Response):
    FRAME_LEN = 32
    DATA_LEN = FRAME_LEN - 4  # includes checksum
    DATA_FMT = ">HHHHHHHHHHHHHH"
    CHECKSUM_IDX = 13

    def __init__(self, raw_data, *, frame_length_bytes=None):
        super().__init__(raw_data, frame_length_bytes=frame_length_bytes)

    def pm_ug_per_m3(self, size, atmospheric_environment=False):
        if atmospheric_environment:
            if size == 1.0:
                return self.data[3]
            if size == 2.5:
                return self.data[4]
            if size is None:
                return self.data[5]

        else:
            if size == 1.0:
                return self.data[0]
            if size == 2.5:
                return self.data[1]
            if size == 10:
                return self.data[2]

        raise ValueError("Particle size {} measurement not available.".format(size))

    def pm_per_1l_air(self, size):
        if size == 0.3:
            return self.data[6]
        if size == 0.5:
            return self.data[7]
        if size == 1.0:
            return self.data[8]
        if size == 2.5:
            return self.data[9]
        if size == 5:
            return self.data[10]
        if size == 10:
            return self.data[11]

        raise ValueError("Particle size {} measurement not available.".format(size))

    def __repr__(self):
        return """
PM1.0 ug/m3 (ultrafine particles):                             {}
PM2.5 ug/m3 (combustion particles, organic compounds, metals): {}
PM10 ug/m3  (dust, pollen, mould spores):                      {}
PM1.0 ug/m3 (atmos env):                                       {}
PM2.5 ug/m3 (atmos env):                                       {}
PM10 ug/m3 (atmos env):                                        {}
>0.3um in 0.1L air:                                            {}
>0.5um in 0.1L air:                                            {}
>1.0um in 0.1L air:                                            {}
>2.5um in 0.1L air:                                            {}
>5.0um in 0.1L air:                                            {}
>10um in 0.1L air:                                             {}
""".format(*self.data[:-2], checksum=self.checksum)

    def __str__(self):
        return self.__repr__()


class PMS5003():
    #def __init__(self, baudrate=9600, pin_enable=board.D10, pin_reset=board.D11):

    MAX_RESET_TIME = 20.0  # 9.2 seconds seen in testing
    MIN_CMD_INTERVAL = 0.1  # mode changes with interval < 50ms break a PMS5003

    @staticmethod
    def _build_cmd_frame(cmd_bytes):
        """
        Builds a valid command frame byte array with checksum for given command bytes
        """
        if len(cmd_bytes) != 3:
            raise RuntimeError("Malformed command frame")
        cmd_frame = bytearray()
        cmd_frame.extend(PMS5003_SOF)
        cmd_frame.extend(cmd_bytes)
        cmd_frame.extend(sum(cmd_frame).to_bytes(2, "big"))
        return cmd_frame


    def __init__(self,
                 serial=None,
                 pin_reset=board.GP8,
                 pin_enable=board.GP9,
                 mode='active',
                 retries=5,
                 baudrate=9600
                 ):
        self._serial = None
        self._mode = 'active'  # device starts up in active mode

        self._baudrate = baudrate
        self._pin_enable = pin_enable
        self._enable = None
        self._pin_reset = pin_reset
        self._reset = None
        self._attempts = retries + 1 if retries else 1

        if mode not in ('active', 'passive'):
            raise ValueError("Invalid mode")

        # Exceptions are caught here as constructor has not
        # raised them in the prior versions
        try:
            self.setup(serial)
            if mode == 'passive':
                self.cmd_mode_passive()
        except RuntimeError:
            pass

    def cmd_mode_passive(self):
        """
        Sends command to device to enable 'passive' mode.
        In passive mode data frames are only sent in response to
        a read command.
        """
        self._mode = 'passive'

        time.sleep(self.MIN_CMD_INTERVAL)
        self._serial.reset_input_buffer()
        self._serial.write(self._build_cmd_frame(PMS5003_CMD_MODE_PASSIVE))
        # In rare cases a single data frame sneaks in giving FrameLengthError
        try:
            resp = self._read_data(PMS5003CmdResponse)
        except FrameLengthError:
            resp = self._read_data(PMS5003CmdResponse)
        time.sleep(self.MIN_CMD_INTERVAL)
        return resp

    def cmd_mode_active(self):
        """
        Sends command to device to enable 'active' mode.
        In active mode data frames are streamed continuously at intervals
        ranging from 200ms to 2.3 seconds.
        """
        self._mode = 'active'
        # mode changes with interval < 50ms break on a PMS5003
        time.sleep(self.MIN_CMD_INTERVAL)
        self._serial.reset_input_buffer()
        self._serial.write(self._build_cmd_frame(PMS5003_CMD_MODE_ACTIVE))
        # In rare cases a single data frame sneaks in giving FrameLengthError
        try:
            resp = self._read_data(PMS5003CmdResponse)
        except FrameLengthError:
            resp = self._read_data(PMS5003CmdResponse)
        time.sleep(self.MIN_CMD_INTERVAL)
        return resp

    def setup(self, serial=None):
        if self._pin_enable:
            self._enable = DigitalInOut(self._pin_enable)
            self._enable.direction = Direction.OUTPUT
            self._enable.value = True

        if self._pin_reset:
            self._reset = DigitalInOut(self._pin_reset)
            self._reset.direction = Direction.OUTPUT
            self._reset.value = True

        if self._serial is not None:
            self._serial.deinit()

        self._serial = busio.UART(board.GP16, board.GP17,
                                  baudrate=self._baudrate,
                                  timeout=4) if serial is None else serial

        self.reset()

    def reset(self):
        """This resets the device via a pin if one is defined.
           It restores passive mode as necessary."""
        if self._reset is None:
            return False

        time.sleep(0.1)
        self._reset.value = False
        self._serial.reset_input_buffer()
        time.sleep(0.1)
        self._reset.value = True

        # Wait for first data frame from the device
        # CircuitPython 6.0.0 on nRF52840 sometimes picks up 2 bogus bytes here
        start = time.monotonic()
        while True:
            if self.data_available():
                break
            elapsed = time.monotonic() - start
            if elapsed > self.MAX_RESET_TIME:
                raise ReadTimeoutError("PMS5003 Read Timeout: No response after reset")

        # After a reset device will be in active mode, restore passive mode
        if self._mode == "passive":
            _ = self._read_data()  # discard buffered active data frame
            self.cmd_mode_passive()

        return True

    def deinit(self):
        if self._enable is not None:
            self._enable.deinit()

        if self._reset is not None:
            self._reset.deinit()

        if self._serial is not None:
            self._serial.deinit()

    def data_available(self):
        """Returns boolean indicating if one or more data frames are waiting.
           Only for use in active mode."""
        return self._serial.in_waiting >= PMS5003Data.FRAME_LEN

    def read(self):
        """Read a data frame. In passive mode this will transmit a request for one.
           This will make additional attempts based on retries value in constructor
           if there are exceptions and only raise the first exception if all fail."""
        read_ex = None
        for _ in range(self._attempts):
            if self._mode == 'passive':
                self._cmd_passive_read()
            try:
                return self._read_data()
            except RuntimeError as ex:
                if read_ex is None:
                    read_ex = ex
        raise read_ex if read_ex else RuntimeError("read failed - internal error")


    def _read_data(self, response_class=PMS5003Data):
        start = time.monotonic()

        sof_index = 0

        while True:
            elapsed = time.monotonic() - start
            if elapsed > 5:
                raise ReadTimeoutError("PMS5003 Read Timeout: Could not find start of frame")

            one_byte = self._serial.read(1)
            if one_byte is None or len(one_byte) == 0:
                raise SerialTimeoutError("PMS5003 Read Timeout: Failed to read start of frame byte")

            if ord(one_byte) == PMS5003_SOF[sof_index]:
                if sof_index == 0:
                    sof_index = 1
                elif sof_index == 1:
                    break
            else:
                sof_index = 0

        len_data = self._serial.read(2)  # Get frame length packet
        if len_data is None or len(len_data) != 2:
            raise SerialTimeoutError("PMS5003 Read Timeout: Could not find length packet")
        frame_length = struct.unpack(">H", len_data)[0]
        response_class.check_data_len(frame_length, desc="Length field")

        raw_data = self._serial.read(frame_length)
        if raw_data is None or len(raw_data) != frame_length:
            read_len = "TIMEOUT" if raw_data is None else len(raw_data)
            raise SerialTimeoutError("PMS5003 Read Timeout: Invalid frame length. "
                                     "Got {} bytes, expected {}.".format(read_len,
                                                                         frame_length))

        return response_class(raw_data, frame_length_bytes=len_data)

    def _cmd_passive_read(self):
        """
        Sends command to request a data frame while in 'passive'
        mode and immediately reads in frame.
        """
        self._serial.reset_input_buffer()
        self._serial.write(self._build_cmd_frame(PMS5003_CMD_READ))

lib/pms5003/__init__.py
DPHacks/picow-air