|
| 1 | +### pmsensors-adafruitio v1.1 |
| 2 | +### Send values from Plantower PMS5003, Sensirion SPS-30 and Omron B5W LD0101 to Adafruit IO |
| 3 | + |
| 4 | +### Tested with Maker Pi PICO using CircuitPython 7.0.0 |
| 5 | +### and ESP-01S using Cytron's firmware 2.2.0.0 |
| 6 | + |
| 7 | +### copy this file to Maker Pi Pico as code.py |
| 8 | + |
| 9 | +### MIT License |
| 10 | + |
| 11 | +### Copyright (c) 2021 Kevin J. Walters |
| 12 | + |
| 13 | +### Permission is hereby granted, free of charge, to any person obtaining a copy |
| 14 | +### of this software and associated documentation files (the "Software"), to deal |
| 15 | +### in the Software without restriction, including without limitation the rights |
| 16 | +### to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| 17 | +### copies of the Software, and to permit persons to whom the Software is |
| 18 | +### furnished to do so, subject to the following conditions: |
| 19 | + |
| 20 | +### The above copyright notice and this permission notice shall be included in all |
| 21 | +### copies or substantial portions of the Software. |
| 22 | + |
| 23 | +### THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| 24 | +### IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| 25 | +### FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| 26 | +### AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| 27 | +### LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| 28 | +### OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| 29 | +### SOFTWARE. |
| 30 | + |
| 31 | + |
| 32 | +import random |
| 33 | +import time |
| 34 | +from collections import OrderedDict |
| 35 | + |
| 36 | +from secrets import secrets |
| 37 | + |
| 38 | +import board |
| 39 | +import busio |
| 40 | +import analogio |
| 41 | +import digitalio |
| 42 | +import pwmio |
| 43 | +##import ulab |
| 44 | +from neopixel import NeoPixel |
| 45 | + |
| 46 | + |
| 47 | +### ESP-01S |
| 48 | +##import adafruit_requests as requests |
| 49 | +import adafruit_espatcontrol.adafruit_espatcontrol_socket as socket |
| 50 | +from adafruit_espatcontrol import adafruit_espatcontrol |
| 51 | +from adafruit_espatcontrol import adafruit_espatcontrol_wifimanager |
| 52 | +import adafruit_minimqtt.adafruit_minimqtt as MQTT |
| 53 | +from adafruit_io.adafruit_io import IO_MQTT |
| 54 | +from adafruit_io.adafruit_io_errors import AdafruitIO_MQTTError |
| 55 | +from microcontroller import cpu |
| 56 | + |
| 57 | +### Particulate Matter sensors |
| 58 | +from adafruit_pm25.uart import PM25_UART |
| 59 | +from adafruit_b5wld0101 import B5WLD0101 |
| 60 | +from adafruit_sps30.i2c import SPS30_I2C |
| 61 | + |
| 62 | +debug = 5 |
| 63 | +mu_output = 2 |
| 64 | + |
| 65 | +### Instructables video was shot with this set to 25 seconds |
| 66 | +UPLOAD_PERIOD = 25 ### TODO - DEFAULT VALUE??? 60? NOTE |
| 67 | +ADAFRUIT_IO_GROUP_NAME = "mpp-pm" |
| 68 | +VCC_DIVIDER = 2.0 |
| 69 | +SENSORS = ("pms5003", "sps30", "b5wld0101") |
| 70 | + |
| 71 | +### Pins |
| 72 | +SPS30_SDA = board.GP0 |
| 73 | +SPS30_SCL = board.GP1 |
| 74 | + |
| 75 | +PMS5003_EN = board.GP2 |
| 76 | +PMS5003_RST = board.GP3 |
| 77 | +PMS5003_TX = board.GP4 |
| 78 | +PMS5003_RX = board.GP5 |
| 79 | + |
| 80 | +B5WLD0101_OUT1 = board.GP10 |
| 81 | +B5WLD0101_OUT2 = board.GP11 |
| 82 | +B5WLD0101_VTH = board.GP12 |
| 83 | + |
| 84 | +ESP01_TX = board.GP16 |
| 85 | +ESP01_RX = board.GP17 |
| 86 | + |
| 87 | +### Pi Pico only has three analogue capable inputs GP26 - GP28 |
| 88 | +B5WLD0101_VTH_MON = board.GP26 |
| 89 | +B5WLD0101_VCC_DIV2 = board.GP27 |
| 90 | + |
| 91 | +### Maker Pi Pico has a WS2812 RGB pixel on GP28 |
| 92 | +### In general, GP28 can be used for analogue input but |
| 93 | +### should be last choice given its dual role on this board) |
| 94 | +MPP_NEOPIXEL = board.GP28 |
| 95 | + |
| 96 | +### RGB pixel indications |
| 97 | +GOOD = (0, 8, 0) |
| 98 | +UPLOADING = (0, 0, 12) |
| 99 | +ERROR = (12, 0, 0) |
| 100 | +BLACK = (0, 0, 0) |
| 101 | + |
| 102 | +### Voltage of GPIO PWM |
| 103 | +PWM_V = 3.3 |
| 104 | +MS_TO_NS = 1000 * 1000 * 1000 |
| 105 | +PMS5003_READ_ATTEMPTS = 10 |
| 106 | +ADC_SAMPLES = 100 |
| 107 | +RECONNECT_SLEEP = 1.25 |
| 108 | + |
| 109 | +### Data fields to publish to Adafruit IO |
| 110 | +UPLOAD_PMS5003 = ("pm10 standard", "pm25 standard") |
| 111 | +UPLOAD_SPS30 = ("pm10 standard", "pm25 standard") |
| 112 | +UPLOAD_B5WLD0101 = ("raw out1", "raw out2") |
| 113 | + |
| 114 | +UPLOAD_PM_FIELDS = ("pms5003-pm10-standard", "pms5003-pm25-standard", |
| 115 | + "sps30-pm10-standard", "sps30-pm25-standard", |
| 116 | + "b5wld0101-raw-out1", "b5wld0101-raw-out2") |
| 117 | +UPLOAD_V_FIELDS = ("b5wld0101-vth", "b5wld0101-vcc") |
| 118 | +UPLOAD_CPU_FIELDS = ("cpu-temperature",) |
| 119 | +UPLOAD_FIELDS = UPLOAD_PM_FIELDS + UPLOAD_V_FIELDS + UPLOAD_CPU_FIELDS |
| 120 | + |
| 121 | + |
| 122 | +def d_print(level, *args, **kwargs): |
| 123 | + """A simple conditional print for debugging based on global debug level.""" |
| 124 | + if not isinstance(level, int): |
| 125 | + print(level, *args, **kwargs) |
| 126 | + elif debug >= level: |
| 127 | + print(*args, **kwargs) |
| 128 | + |
| 129 | + |
| 130 | +pixel = NeoPixel(MPP_NEOPIXEL, 1) |
| 131 | +pixel.fill(BLACK) |
| 132 | + |
| 133 | +### Initialise the trio of sensors |
| 134 | +### Plantower PMS5003 - serial connected |
| 135 | +pms5003_en = digitalio.DigitalInOut(PMS5003_EN) |
| 136 | +pms5003_en.direction = digitalio.Direction.OUTPUT |
| 137 | +pms5003_en.value = True |
| 138 | +pms5003_rst = digitalio.DigitalInOut(PMS5003_RST) |
| 139 | +pms5003_rst.direction = digitalio.Direction.OUTPUT |
| 140 | +pms5003_rst.value = True |
| 141 | +serial = busio.UART(PMS5003_TX, |
| 142 | + PMS5003_RX, |
| 143 | + baudrate=9600, |
| 144 | + timeout=15.0) |
| 145 | + |
| 146 | +### Sensirion SPS30 - i2c connected |
| 147 | +i2c = busio.I2C(SPS30_SCL, SPS30_SDA) |
| 148 | +pms5003 = PM25_UART(serial) |
| 149 | +sps30 = SPS30_I2C(i2c, fp_mode=True) |
| 150 | +b5wld0101 = B5WLD0101(B5WLD0101_OUT1, B5WLD0101_OUT2) |
| 151 | + |
| 152 | +### Omron B5W LD0101 - pulsed outputs |
| 153 | +### create Vth with smoothed PWM |
| 154 | +b5wld0101_vth_pwm = pwmio.PWMOut(B5WLD0101_VTH, frequency=125 * 1000) |
| 155 | +### R=10k (to pin) C=0.1uF - looks flat on 0.1 AC on scope |
| 156 | +### 0.5 shows as 515mV (496mV on Astro AI at pin GP26, 491mV on resistor on breadboard) |
| 157 | +b5wld0101_vth_pwm.duty_cycle = round(0.5 / PWM_V * 65535) |
| 158 | +b5wld0101_vth_mon = analogio.AnalogIn(B5WLD0101_VTH_MON) |
| 159 | +b5wld0101_vcc_div2 = analogio.AnalogIn(B5WLD0101_VCC_DIV2) |
| 160 | + |
| 161 | + |
| 162 | +def read_voltages(samples=ADC_SAMPLES): |
| 163 | + v_data = OrderedDict() |
| 164 | + conv = b5wld0101_vth_mon.reference_voltage / (samples * 65535) |
| 165 | + v_data["b5wld0101-vcc"] = (sum([b5wld0101_vcc_div2.value |
| 166 | + for _ in range(samples)]) * conv * VCC_DIVIDER) |
| 167 | + v_data["b5wld0101-vth"] = (sum([b5wld0101_vth_mon.value |
| 168 | + for _ in range(samples)]) * conv) |
| 169 | + return v_data |
| 170 | + |
| 171 | + |
| 172 | +def get_pm(sensors): |
| 173 | + all_data = OrderedDict() |
| 174 | + |
| 175 | + for sensor in sensors: |
| 176 | + s_data = {} |
| 177 | + if sensor == "pms5003": |
| 178 | + for _ in range(PMS5003_READ_ATTEMPTS): |
| 179 | + try: |
| 180 | + s_data = pms5003.read() |
| 181 | + except RuntimeError: |
| 182 | + pass |
| 183 | + if s_data: |
| 184 | + break |
| 185 | + elif sensor == "sps30": |
| 186 | + s_data = sps30.read() |
| 187 | + elif sensor == "b5wld0101": |
| 188 | + s_data = b5wld0101.read() |
| 189 | + else: |
| 190 | + print("Whatcha talkin' bout Willis?") |
| 191 | + |
| 192 | + for key in s_data.keys(): |
| 193 | + new_key = sensor + "-" + key.replace(" ","-") |
| 194 | + all_data[new_key] = s_data[key] |
| 195 | + |
| 196 | + return all_data |
| 197 | + |
| 198 | + |
| 199 | +class DataWarehouse(): |
| 200 | + SECRETS_REQUIRED = ("ssid", "password", "aio_username", "aio_key") |
| 201 | + |
| 202 | + def __init__(self, secrets_, *, |
| 203 | + esp01_pins=[], |
| 204 | + esp01_uart=None, |
| 205 | + esp01_baud=115200, |
| 206 | + pub_prefix="", |
| 207 | + debug=False ### pylint: disable=redefined-outer-name |
| 208 | + ): |
| 209 | + |
| 210 | + if esp01_uart: |
| 211 | + self.esp01_uart = esp01_uart |
| 212 | + else: |
| 213 | + self.esp01_uart = busio.UART(*esp01_pins, receiver_buffer_size=2048) |
| 214 | + |
| 215 | + self.debug = debug |
| 216 | + self.esp = adafruit_espatcontrol.ESP_ATcontrol(self.esp01_uart, |
| 217 | + esp01_baud, |
| 218 | + debug=debug) |
| 219 | + self.esp_version = "" |
| 220 | + self.wifi = None |
| 221 | + try: |
| 222 | + _ = [secrets_[key] for key in self.SECRETS_REQUIRED] |
| 223 | + except KeyError: |
| 224 | + raise RuntimeError("secrets.py must contain: " |
| 225 | + + " ".join(self.SECRETS_REQUIRED)) |
| 226 | + self.secrets = secrets_ |
| 227 | + self.io = None |
| 228 | + self.pub_prefix = pub_prefix |
| 229 | + self.pub_name = {} |
| 230 | + self.init_connect() |
| 231 | + |
| 232 | + |
| 233 | + def init_connect(self): |
| 234 | + self.esp.soft_reset() |
| 235 | + |
| 236 | + self.wifi = adafruit_espatcontrol_wifimanager.ESPAT_WiFiManager(self.esp, |
| 237 | + self.secrets) |
| 238 | + ### A few retries here seems to greatly improve reliability |
| 239 | + for _ in range(4): |
| 240 | + if self.debug: |
| 241 | + print("Connecting to WiFi...") |
| 242 | + try: |
| 243 | + self.wifi.connect() |
| 244 | + self.esp_version = self.esp.get_version() |
| 245 | + if self.debug: |
| 246 | + print("Connected!") |
| 247 | + break |
| 248 | + except (RuntimeError, |
| 249 | + TypeError, |
| 250 | + adafruit_espatcontrol.OKError) as ex: |
| 251 | + if self.debug: |
| 252 | + print("EXCEPTION: Failed to publish()", repr(ex)) |
| 253 | + time.sleep(RECONNECT_SLEEP) |
| 254 | + |
| 255 | + ### This uses global variables |
| 256 | + socket.set_interface(self.esp) |
| 257 | + |
| 258 | + ### MQTT Client |
| 259 | + ### pylint: disable=protected-access |
| 260 | + self.mqtt_client = MQTT.MQTT( |
| 261 | + broker="io.adafruit.com", |
| 262 | + username=self.secrets["aio_username"], |
| 263 | + password=self.secrets["aio_key"], |
| 264 | + socket_pool=socket, |
| 265 | + ssl_context=MQTT._FakeSSLContext(self.esp) |
| 266 | + ) |
| 267 | + self.io = IO_MQTT(self.mqtt_client) |
| 268 | + ### Callbacks of interest on io are |
| 269 | + ### on_connect on_disconnect on_subscribe |
| 270 | + self.io.connect() |
| 271 | + |
| 272 | + |
| 273 | + def reset_and_reconnect(self): |
| 274 | + self.wifi.reset() |
| 275 | + self.io.reconnect() |
| 276 | + |
| 277 | + |
| 278 | + def update_pub_name(self, field_name): |
| 279 | + pub_name = self.pub_prefix + field_name |
| 280 | + return pub_name |
| 281 | + |
| 282 | + |
| 283 | + def poll(self): |
| 284 | + dw_poll_ok = False |
| 285 | + try_reconnect = False |
| 286 | + for _ in range(2): |
| 287 | + try: |
| 288 | + ### Process any incoming messages |
| 289 | + if try_reconnect: |
| 290 | + self.reset_and_reconnect() |
| 291 | + try_reconnect = False |
| 292 | + self.io.loop() |
| 293 | + dw_poll_ok = True |
| 294 | + except (ValueError, RuntimeError, AttributeError, |
| 295 | + MQTT.MMQTTException, |
| 296 | + adafruit_espatcontrol.OKError, |
| 297 | + AdafruitIO_MQTTError) as ex: |
| 298 | + if self.debug: |
| 299 | + print("EXCEPTION: Failed to get data in loop()", repr(ex)) |
| 300 | + try_reconnect = True |
| 301 | + |
| 302 | + return dw_poll_ok |
| 303 | + |
| 304 | + |
| 305 | + def publish(self, p_data, p_fields): |
| 306 | + ok = [False] * len(p_fields) |
| 307 | + try_reconnect = False |
| 308 | + if self.debug: |
| 309 | + print("publish()") |
| 310 | + |
| 311 | + for idx, field_name in enumerate(p_fields): |
| 312 | + try: |
| 313 | + pub_name = self.pub_name[field_name] |
| 314 | + except KeyError: |
| 315 | + pub_name = self.update_pub_name(field_name) |
| 316 | + for _ in range(2): |
| 317 | + try: |
| 318 | + if try_reconnect: |
| 319 | + self.reset_and_reconnect() |
| 320 | + try_reconnect = False |
| 321 | + self.io.publish(pub_name, p_data[field_name]) |
| 322 | + ok[idx] = True |
| 323 | + break |
| 324 | + except (ValueError, RuntimeError, AttributeError, |
| 325 | + MQTT.MMQTTException, |
| 326 | + adafruit_espatcontrol.OKError, |
| 327 | + AdafruitIO_MQTTError) as ex: |
| 328 | + if self.debug: |
| 329 | + print("EXCEPTION: Failed to publish()", repr(ex)) |
| 330 | + try_reconnect = True |
| 331 | + time.sleep(RECONNECT_SLEEP) |
| 332 | + |
| 333 | + return all(ok) |
| 334 | + |
| 335 | + |
| 336 | +dw = DataWarehouse(secrets, |
| 337 | + esp01_pins=(ESP01_TX, ESP01_RX), |
| 338 | + pub_prefix=ADAFRUIT_IO_GROUP_NAME + ".", |
| 339 | + debug=(debug >= 5)) |
| 340 | + |
| 341 | +last_upload_ns = 0 |
| 342 | + |
| 343 | +while True: |
| 344 | + poll_ok = dw.poll() |
| 345 | + pixel.fill(GOOD if poll_ok else ERROR) |
| 346 | + |
| 347 | + cpu_temp = cpu.temperature |
| 348 | + voltages = read_voltages() |
| 349 | + time_ns = time.monotonic_ns() |
| 350 | + |
| 351 | + data = get_pm(SENSORS) |
| 352 | + data.update(voltages) |
| 353 | + data.update({"cpu-temperature": cpu_temp}) |
| 354 | + |
| 355 | + if debug >= 2: |
| 356 | + print(data) |
| 357 | + elif mu_output: |
| 358 | + output = ("(" |
| 359 | + + ",".join(str(item) |
| 360 | + for item in [data[key] for key in UPLOAD_PM_FIELDS]) |
| 361 | + + ")") |
| 362 | + print(output) |
| 363 | + |
| 364 | + if time_ns - last_upload_ns >= UPLOAD_PERIOD * MS_TO_NS: |
| 365 | + pixel.fill(UPLOADING) |
| 366 | + pub_ok = dw.publish(data, UPLOAD_FIELDS) |
| 367 | + pixel.fill(GOOD if pub_ok else ERROR) |
| 368 | + if pub_ok: |
| 369 | + last_upload_ns = time_ns |
| 370 | + |
| 371 | + time.sleep(1.5 + random.random()) |
0 commit comments