diff --git a/.gitignore b/.gitignore index 35e9d46..bc2ecc5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ __pycache__/ pymakr.conf -**secret.py \ No newline at end of file +**secret.py +software/update-mjlo-* diff --git a/README.md b/README.md index b642cb2..2d408d6 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,14 @@ Let op: versie 3.1 van dit breakout board verschilt op meer vlakken van v3.0 dan [Voltage divider: mess](https://community.hiveeyes.org/t/batterieuberwachung-voltage-divider-und-attenuation-fur-micropython-firmware/2128/46?page=2) ## Stroomgebruik en spanning -***Verouderd: v2.0 i.t.t. huidige v2.5*** +***Verouderd: v2.0 i.t.t. huidige v2.7*** Zie de figuur hieronder voor het stroomgebruik van de vorige versie software. De gemiddelde stroomsterkte tijdens activiteit is 105 mA; in deepsleep 3.4 mA. De vermoedde accuduur is drie weken, waarbij het zonnepaneel buiten beschouwing wordt gelaten. ![Stroomgebruik MJLO-12 op v19.01.22](Stroomgebruik_v19_01_22.png) ## Schema Zie de figuur voor de opbouw van het circuit in de sensorkastjes. -![Schematic v2.5 15-08-2022](Schematic_Meet_je_leefomgeving_2022-09-01.svg) +![Schematic v2.7 15-04-2023](Schematic_Meet_je_leefomgeving_2023-04-15.svg) ## Custom firmware De eenvoudige variant voor het ontwikkelen van software is het uploaden van alle losse bestanden naar `/flash`. Bij het wijzigen van een bestand kan dat losse bestand snel gewijzigd en opnieuw geupload worden. Er zijn echter meerdere nadelen aan verbonden: @@ -82,15 +82,21 @@ De volgende twee regels moeten elke keer uitgevoerd worden bij het openen van ee (De volgende opmerkingen gaan er allemaal vanuit dat je je in de map `pycom-micropython-sigfox/esp32` bevindt.) -Om bestanden in te vriezen, moeten ze in de subfolder `/frozen/Base` geplaatst worden. Standaard staan daar een `_boot.py` en `_main.py`: die kunnen overschreven worden met de desbetreffende bestanden uit deze repository zonder verlies van functionaliteit. +Om bestanden te bevriezen, moeten ze in de subfolder `/frozen/Base` geplaatst worden. Standaard staan daar een `_boot.py` en `_main.py`: die kunnen overschreven worden met de desbetreffende bestanden uit deze repository zonder verlies van functionaliteit. Om relatieve imports te behouden (bijvoorbeeld `import lib.SSD1306`) kan de map `/lib` ook gewoon binnen de map `/frozen/Base` geplaatst worden. Let op: het is niet mogelijk de bestanden `boot.py` en `main.py` zelf te bevriezen: die worden standaard geleegd bij het compilen. Om de versienaam aan te passen: * `nano pycom_version.h` -> regel 13: aanpassen +[bron](https://forum.pycom.io/topic/3902/frozen-modules-for-my-sipy-solved/3) Om aan te passen welk bestand er uitgevoerd wordt na `_boot.py` en `_main.py` (in dit geval `"error.py"` in `/frozen/Base`): * `nano mptask.c` -> regel 339: vervang `pyexec_file(main.py)` door `pyexec_frozen_module("error.py")` +[bron](https://forum.pycom.io/topic/2038/flashing-with-frozen-main-py-and-boot-py/4) + +Om een `OrderedDict` toe te voegen: +* `nano mpconfigport.h` -> regel 79 nieuwe regel: `#define MICROPY_PY_COLLECTIONS_ORDEREDDICT (1)` +[bron](https://forum.pycom.io/topic/972/enable-ordereddict-support-by-default/5) De volgende regels zijn (elke keer) nodig om de firmware te compilen: * `make clean` @@ -99,9 +105,9 @@ De volgende regels zijn (elke keer) nodig om de firmware te compilen: Het resulterende `.tar.gz` bestand staat in de subfolder `/build`. Dit bestand kan gebruikt worden om de LoPy4 te flashen via de Pycom Firmware Updater. Om het `.bin` bestand te verkrijgen dat nodig is voor de OTA updates, moet de `.tar.gz` uitgepakt worden via bijvoorbeeld `tar -xzf filename`: het resulterende `lopy4.bin` is het gezochte bestand. -[Installing pycom-esp-idf](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/get-started/linux-macos-setup.html) -[Installing pycom-micropython-sigfox](https://github.com/pycom/pycom-micropython-sigfox) -[Frozen modules documentation](https://docs.pycom.io/advance/frozen/) -[Modyfing startup sequence](https://forum.pycom.io/topic/2038/flashing-with-frozen-main-py-and-boot-py/6) -[Relative frozen imports](https://forum.pycom.io/topic/7255/lib-folder-in-frozen-base) -[Pycom Firmware Updater](https://docs.pycom.io/updatefirmware/device/) \ No newline at end of file +[Installing pycom-esp-idf](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/get-started/linux-macos-setup.html) +[Installing pycom-micropython-sigfox](https://github.com/pycom/pycom-micropython-sigfox) +[Frozen modules documentation](https://docs.pycom.io/advance/frozen/) +[Modyfing startup sequence](https://forum.pycom.io/topic/2038/flashing-with-frozen-main-py-and-boot-py/6) +[Relative frozen imports](https://forum.pycom.io/topic/7255/lib-folder-in-frozen-base) +[Pycom Firmware Updater](https://docs.pycom.io/updatefirmware/device/) \ No newline at end of file diff --git a/Schematic_Meet_je_leefomgeving_2022-09-01.svg b/Schematic_Meet_je_leefomgeving_2022-09-01.svg deleted file mode 100644 index 0dcaa4b..0000000 --- a/Schematic_Meet_je_leefomgeving_2022-09-01.svg +++ /dev/null @@ -1,5 +0,0 @@ -Sheet_1 -NO.1AABBCCDD1122334455TITLE:Meet je leefomgevingREV:2.6Date:01-09-2022Sheet:1/1EasyEDA V5.3.14Drawn By:BoonstoppelExpansion BoardV3.1RST1P02P13P24P35P46P57P68P79P810P911P1012P2224P2123P2022P1921P1820P1719P1618P1517P1416P1315P1214P1113P23253V326GND27VIn28Pycom LoPy4VEML6070I2C_0X38VIN1GND2SCL3SDA4ACK5UV-indexSDS011UART_1NC11μm25V32.5μm4GND5RXD6TXD7FijnstofMAX4466ANALOG_18OUT1GND2VCC3VolumeSSD1306-128x64I2C_0X3CSDA4SCL3GND1VCC2MQ135_SensorANALOG_17VCC1GND2DO3AO4MQ135GNDGNDTSL2591I2C_0X29Vin1GND23.3V3Int4SDA5SCL6LichtGNDGNDGNDGNDGNDNEO-6m-GPSUART_2GPSPPS5GND1TX2RX3VCC4GNDGND3V33V33V33V33V3CO2-gehalteLuchtdrukLuchtvochtigheidDisplay3V326650 5200mAhBAT3,7V+-1122JST-PHJ1+1-2SwitchON/OFF1122microUSB-BD+D+D-D-GNDGNDV+VBUSZonnepaneel5V+1-2Externe opladerUSB331122200 mAKeeppower Li-iondrukknop.P$ACTIVEP$ACTIVEP$GNDP$GND3V3U1V11F5POLOLUVOUT1GND2VIN3SHDN4SpanningsregelaarGNDBME680I2C_0X77VCC1GND2SCL3SDA4SDO5CS6TemperatuurVOC2N2907PNPC3B1E21MR222111MR12211GND \ No newline at end of file diff --git a/Schematic_Meet_je_leefomgeving_2022-09-01.pdf b/Schematic_Meet_je_leefomgeving_2023-04-15.pdf similarity index 55% rename from Schematic_Meet_je_leefomgeving_2022-09-01.pdf rename to Schematic_Meet_je_leefomgeving_2023-04-15.pdf index 1a98d24..a56edf3 100644 Binary files a/Schematic_Meet_je_leefomgeving_2022-09-01.pdf and b/Schematic_Meet_je_leefomgeving_2023-04-15.pdf differ diff --git a/Schematic_Meet_je_leefomgeving_2023-04-15.svg b/Schematic_Meet_je_leefomgeving_2023-04-15.svg new file mode 100644 index 0000000..5d2146d --- /dev/null +++ b/Schematic_Meet_je_leefomgeving_2023-04-15.svg @@ -0,0 +1,5 @@ +Sheet_1 +NO.1AABBCCDD1122334455TITLE:Meet je leefomgevingREV:2.7Date:15-04-2023Sheet:1/1EasyEDA V5.3.14Drawn By:BoonstoppelExpansion BoardV3.1RST1P02P13P24P35P46P57P68P79P810P911P1012P2224P2123P2022P1921P1820P1719P1618P1517P1416P1315P1214P1113P23253V326GND27VIn28Pycom LoPy4VEML6070I2C_0X38VIN1GND2SCL3SDA4ACK5UV-indexSDS011UART_1NC11μm25V32.5μm4GND5RXD6TXD7FijnstofMAX4466ANALOG_15OUT1GND2VCC3VolumeSSD1306-128x64I2C_0X3CSDA4SCL3GND1VCC2GNDGNDTSL2591I2C_0X29Vin1GND23.3V3Int4SDA5SCL6LichtGNDGNDGNDGNDNEO-6m-GPSUART_2GPSPPS5GND1TX2RX3VCC4GNDGND3V33V33V33V33V3CO2-gehalteLuchtdrukLuchtvochtigheidDisplay3V326650 5200mAhBAT3,7V+-1122JST-PHJ1+1-2SwitchON/OFF1122microUSB-BD+D+D-D-GNDGNDV+VBUSZonnepaneel5V+1-2Externe opladerUSB331122200 mAKeeppower Li-iondrukknop.P$ACTIVEP$ACTIVEP$GNDP$GND3V3U1V11F5POLOLUVOUT1GND2VIN3SHDN4SpanningsregelaarGNDBME680I2C_0X77VCC1GND2SCL3SDA4SDO5CS6TemperatuurVOC2N2907PNPC3B1E21MR222111MR12211GNDSCD41 MODULEI2C_0X62GND1VCC2SCL3SDA4GND3V3 \ No newline at end of file diff --git a/software/LoRa.py b/software/LoRa.py new file mode 100644 index 0000000..cc93404 --- /dev/null +++ b/software/LoRa.py @@ -0,0 +1,125 @@ +import network +import socket +import pycom +import time + +_configs = { # bytes, offset, precision + 'temp' : (2, 100, 0.01 ), + 'pres' : (2, 0, 0.1 ), + 'humi' : (1, 0, 0.5 ), + 'voc' : (2, 0, 1 ), + 'uv' : (2, 0, 1 ), + 'lx' : (2, 0, 1 ), + 'volu' : (1, 0, 0.5 ), + 'batt' : (2, 0, 0.001 ), + 'co2' : (2, 0, 0.1 ), + 'pm25' : (2, 0, 0.1 ), + 'pm10' : (2, 0, 0.1 ), + 'lat' : (3, 90, 0.0001), + 'long' : (3, 180, 0.0001), + 'alt' : (2, 100, 0.1 ), + 'hdop' : (1, 0, 0.1 ), + 'fw' : (1, 0, 1 ), + 'error': (1, 128, 1 ) +} + +class LoRaWAN: + def __init__(self, sf = None, fport = None): + # create lora object + self.lora = network.LoRa(mode = network.LoRa.LORAWAN, region = network.LoRa.EU868) + + # maybe we got here from error.py after already creating this object in main.py + # in that case, we should not restore from nvram as that would erase the information + if not self.has_joined: + self.lora.nvram_restore() + + # if, after restoring data from nvram, it turns out that lora is not joined, join now + # this join is performed non-blocking as it should have completed before sending a message + if not self.has_joined: + import secret + self.lora.join(activation = pycom.nvs_get('lora'), # 0 = OTAA, 1 = ABP + auth = secret.auth(), # get keys for this specific node + dr = 12 - pycom.nvs_get('sf_h')) # always join using maximum power + self._fcnt = 0 # default LoRa frame count + else: + self._fcnt = pycom.nvs_get('fcnt') # restore LoRa frame count from nvRAM + + if sf: + self._dr = 12 - sf + else: + self._dr = 12 - pycom.nvs_get('sf_l') # default SF (low) + if self._fcnt % pycom.nvs_get('adr') == 0: + self._dr = 12 - pycom.nvs_get('sf_h') # every adr'th message, send on high SF + + if fport: + self._fport = 4 + else: + self._fport = 1 # default LoRa packet decoding type 1 (no GPS) + + self._frame = bytes([]) + + @property + def fcnt(self): + return self._fcnt + + @property + def sf(self): + return 12 - self._dr + + @sf.setter + def sf(self, sfactor): + self._dr = 12 - sfactor + + @property + def dr(self): + return self._dr + + @property + def fport(self): + return self._fport + + @fport.setter + def fport(self, port): + self._fport = port + + @property + def frame(self): + return self._frame + + @property + def has_joined(self): + return self.lora.has_joined() + + @staticmethod + def pack(name, values): + numbytes, offset, precision = _configs[name] + value = round((values + offset) / precision) # add offset, then round to precision + value = max(0, min(value, 2**(8*numbytes) - 1)) # stay in range 0 .. int.max_size - 1 + out = value.to_bytes(numbytes, 'big') # pack to bytes + return out + + def make_frame(self, odict): + for key, values in odict.items(): + self._frame += LoRaWAN.pack(key, values) + + return len(self._frame) + + def send_frame(self, join_flag = False): + if not self._frame: + raise AttributeError("empty frame") + + if join_flag: + while not self.has_joined: + time.sleep(1) + + # send LoRa message and store LoRa context + frame count in NVRAM + sckt = socket.socket(socket.AF_LORA, socket.SOCK_RAW) # create a LoRa socket (blocking by default) + sckt.setsockopt(socket.SOL_LORA, socket.SO_DR, self._dr) # set the LoRaWAN data rate + sckt.bind(self._fport) # set the type of message used for decoding the packet + sckt.send(self._frame) + sckt.close() + + self.lora.nvram_save() + pycom.nvs_set('fcnt', self._fcnt + 1) + + self._frame = bytes([]) \ No newline at end of file diff --git a/software/_main.py b/software/_main.py index 5f8803a..835a8ef 100644 --- a/software/_main.py +++ b/software/_main.py @@ -1,163 +1,215 @@ #_main.py -- frozen into the firmware along all other modules -import time -start_time = time.ticks_ms() # save current boot time +version_str = "v2.7.1" +version_int = int(version_str.replace('v', '').replace('.', '')) +import time import pycom import machine -from lib.SSD1306 import SSD1306 -wake_reason = machine.wake_reason()[0] # tuple of (wake_reason, GPIO_list) +t_boot = time.ticks_ms() # save current boot time + +USE_SD = machine.reset_cause() == machine.PWRON_RESET # check SD card if there was a reset / poweron +USE_GPS = machine.reset_cause() == machine.PWRON_RESET # use GPS if there was a reset / poweron +USE_GPS |= machine.reset_cause() == machine.WDT_RESET # use GPS if there was an update or error last time +USE_GPS |= machine.wake_reason()[0] == machine.PIN_WAKE # use GPS if the green button was pressed + +import pins +from lib.SSD1306 import SSD1306 +from lib.VEML6070 import VEML6070 +from lib.TSL2591 import TSL2591 +from lib.BME680 import BME680 +from lib.MAX4466 import MAX4466 +from lib.KP26650 import KP26650 +from lib.SCD41 import SCD41 +from lib.SDS011 import SDS011 +from LoRa import LoRaWAN + +from ucollections import OrderedDict + +i2c = machine.I2C(0, pins = (pins.SDA, pins.SCL)) # create I2C object +display = SSD1306(128, 64, i2c) # initialize display (4.4 / 0.0 mA) + +# update firmware register if necessary, and check for SD card updates +if USE_SD: + if pycom.nvs_get('fwversion') != version_int: + pycom.nvs_set('fwversion', version_int) + if pycom.nvs_get('error'): + pycom.nvs_set('error', 0) + + from updateFW import check_SD + reboot = check_SD(display) # check if an SD card is present and apply any changes + if reboot: + machine.reset() # in case of an update, reboot the device -i2c = machine.I2C(0) # create I2C object -display = SSD1306(128, 64, i2c) # initialize display (4.4 / 0.0 mA) +# enable power to the voltage regulator (and in turn SDS011) which requires most time +vr_en = machine.Pin(pins.VR, mode = machine.Pin.OUT) # voltage regulator SHDN pin +vr_en.hold(False) # disable hold from deepsleep +vr_en.value(1) # enable power -# on first boot, disable integrated LED and WiFi and check for SD card updates -if wake_reason == machine.PWRON_WAKE: - pycom.heartbeat_on_boot(False) - pycom.wifi_on_boot(False) +uart1 = machine.UART(1, pins = (pins.TX1, pins.RX1), baudrate = 9600) # UART communication to SDS011 +sds011 = SDS011(uart1) # fine particle sensor (110 / 0.0 mA) +sds011.wake() - from lib.updateFW import check_SD - reboot = check_SD(display) - if reboot: - machine.reset() # in case of an update, reboot the device +t_start = time.ticks_ms() # keep track of SDS011 wake time +lora = LoRaWAN() # sort out all LoRa related settings (frame count, port, sf) + +# if necessary, powerup GPS in advance (powered through voltage regulator) +if USE_GPS: + gps_en = machine.Pin(pins.GPS, mode = machine.Pin.OUT) # 2N2907 (PNP) gate pin + gps_en.hold(False) # disable hold from deepsleep + gps_en.value(0) # enable GPS power + lora.fport = 2 # set LoRa decoding type 2 (includes GPS) + lora.sf = pycom.nvs_get('sf_h') # send GPS on high SF + +# show some stats on screen while sensors are busy display.fill(0) -display.text("MJLO-{:>02}".format(pycom.nvs_get('node')), 1, 1) -display.text("FW: v2.6.3", 1, 11) +display.text("MJLO-{:>02}" .format(pycom.nvs_get('node')), 1, 1) +display.text("FW {}" .format(version_str), 1, 11) +display.text("sf {:> 4}".format(lora.sf), 1, 34) +display.text("fport {:> 4}".format(lora.fport), 1, 44) +display.text("fcnt {:> 5}" .format(lora.fcnt), 1, 54) display.show() -""" This part is only executed if DEBUG == True """ -if pycom.nvs_get('debug') == 1: - - if wake_reason == machine.PIN_WAKE: # if button is pressed in DEBUG mode, enable GPS - from collect_gps import run_gps - loc = run_gps(timeout = 120) - print("NB", loc['lat'], "OL", loc['long'], "H", loc['alt']) - - from collect_sensors import run_collection - values = run_collection(i2c = i2c, all_sensors = True, t_wake = pycom.nvs_get('t_wake')) - - print("Temp: " + str(values['temp']) + " C") - print("Druk: " + str(values['pres']) + " hPa") - print("Vocht: " + str(values['humi']) + " %") - print("Licht: " + str(values['lx']) + " lx") - print("UV: " + str(values['uv'])) - print("Accu: " + str(values['perc']) + " %") - print("Volume: " + str(values['volu']) + " dB") - print("VOC: " + str(values['voc']) + " Ohm") - print("CO2: " + str(values['co2']) + " ppm") - print("PM2.5: " + str(values['pm25']) + " ppm") - print("PM10: " + str(values['pm10']) + " ppm") - - push_button = machine.Pin('P2', mode = machine.Pin.IN, pull = machine.Pin.PULL_DOWN) # initialize wake-up pin - machine.pin_sleep_wakeup(['P2'], mode = machine.WAKEUP_ANY_HIGH, enable_pull = True) # set wake-up pin as trigger - machine.deepsleep((pycom.nvs_get('t_debug') - 30) * 1000) # deepsleep for remainder of the interval time - -""" This part is only executed if DEBUG == False """ -import network -import socket - -lora = network.LoRa(mode = network.LoRa.LORAWAN, region = network.LoRa.EU868) # create LoRa object -LORA_FCNT = 0 # default LoRa frame count -if wake_reason != machine.PWRON_WAKE: # if woken up from deepsleep (timer or button).. - lora.nvram_restore() # ..restore LoRa information from nvRAM - LORA_FCNT = pycom.nvs_get('fcnt') # ..restore LoRa frame count from nvRAM - -frame = bytes([0]) # LoRa packet decoding type 0 (minimal) - -all_sensors = False -# every ADR'th message or if the button was pushed, use all sensors (but not if GPS is used) -if LORA_FCNT % pycom.nvs_get('adr') == 0 or wake_reason == machine.PIN_WAKE: - all_sensors = True - frame = bytes([1]) # LoRa packet decoding type 1 (all sensors) - -use_gps = False -# once a day, enable GPS (but not if the button was pressed) (every second message of the day) -if LORA_FCNT % int(86400 / pycom.nvs_get('t_int')) == 1 and wake_reason != machine.PIN_WAKE: - use_gps = True - all_sensors = False - frame = bytes([2]) # LoRa packet decoding type 2 (use GPS) - -# if GPS or all sensors are used, send on high SF (don't let precious power go to waste) -if use_gps == True or all_sensors == True: - LORA_SF = pycom.nvs_get('sf_h') -else: - LORA_SF = pycom.nvs_get('sf_l') - -lora.sf(LORA_SF) # set SF for this uplink -LORA_DR = 12 - LORA_SF # calculate DR for this SF - -s = socket.socket(socket.AF_LORA, socket.SOCK_RAW) # create a LoRa socket (blocking) -s.setsockopt(socket.SOL_LORA, socket.SO_DR, LORA_DR) # set the LoRaWAN data rate - -# join the network upon first-time wake -if LORA_FCNT == 0: - import secret - - if pycom.nvs_get('lora') == 0: # OTAA - mode = network.LoRa.OTAA - else: # ABP - mode = network.LoRa.ABP - - lora.join(activation = mode, auth = secret.auth(), dr = LORA_DR) - # don't need to wait for has_joined(): GPS takes much longer to start - -# run sensor routine -from collect_sensors import run_collection -values = run_collection(i2c = i2c, all_sensors = all_sensors, t_wake = pycom.nvs_get('t_wake')) - -def pack(value, precision, size = 2): - value = int(value / precision) # round to precision - value = max(0, min(value, 2**(8*size) - 1)) # stay in range 0 .. int.max_size - 1 - return value.to_bytes(size, 'big') # pack to bytes - -# add the sensor values that are always measured (frame is now 1 + 15 = 16 bytes) -frame += pack(values['volt'], 0.001) + pack(values['temp'] + 25, 0.01) + pack(values['pres'], 0.1) \ - + pack(values['humi'], 0.5, size = 1) + pack(values['volu'], 0.5, size = 1) \ - + pack(values['lx'], 1) + pack(values['uv'], 1) + pack(values['voc'], 1, size = 3) - -if all_sensors == True: - # add extra sensor values (frame is now 1 + 15 + 6 = 22 bytes) - frame += pack(values['co2'], 1) + pack(values['pm25'], 0.1) + pack(values['pm10'], 0.1) - -if use_gps == True: - # run gps routine - from collect_gps import run_gps - loc = run_gps() - - # add gps values (frame is now 1 + 15 + 9 = 25 bytes) - frame += pack(loc['lat'] + 90, 0.0000001, size = 4) + pack(loc['long'] + 180, 0.0000001, size = 4) \ - + pack(loc['alt'], 0.1, size = 1) - -# send LoRa message and store LoRa context + frame count in NVRAM (should be using wear leveling) -s.send(frame) -lora.nvram_save() -pycom.nvs_set('fcnt', LORA_FCNT + 1) - -# write all values to display in two series +# start collection of all sensor data +values = OrderedDict() # ordered collection of all values + +bme680 = BME680(i2c = i2c, address = 119) # temp, hum, pres & voc sensor (12 / 0.0 mA) (0x77) +bme680.set_gas_heater_temperature(400, nb_profile = 1) # set VOC plate heating temperature +bme680.set_gas_heater_duration(50, nb_profile = 1) # set VOC plate heating duration +bme680.select_gas_heater_profile(1) # select those settings +while not bme680.get_sensor_data(): + machine.sleep(200) +values['temp'] = bme680.temperature +values['humi'] = bme680.humidity +values['pres'] = bme680.pressure +values['voc'] = bme680.gas / 10 # TODO solve VOC (dirty hack /10) +bme680.set_power_mode(0) + +tsl2591 = TSL2591(i2c = i2c, address = 41) # lux sensor (0.4 / 0.0 mA) (0x29) +tsl2591.wake() +values['lx'] = tsl2591.lux # don't ask why but this may do nothing, so poll twice +machine.sleep(200) # sensor stabilization time +values['lx'] = tsl2591.lux +tsl2591.sleep() + +veml6070 = VEML6070(i2c = i2c, address = 56) # UV sensor (0.4 / 0.0 mA) (0x38) +veml6070.wake() +values['uv'] = veml6070.uv_raw # don't ask why but this may do nothing, so poll twice +machine.sleep(200) # sensor stabilization time +values['uv'] = veml6070.uv_raw +veml6070.sleep() + +max4466 = MAX4466(pins.Vol, duration = 500) # analog loudness sensor (500ms measurement) +values['volu'] = max4466.get_volume() # active: 0.3 mA, sleep: 0.3 mA (always on) + +battery = KP26650(pins.Batt, duration = 200, ratio = 2)# battery voltage (200ms measurement, 1:1 voltage divider) +values['batt'] = battery.get_voltage() +perc = battery.get_percentage(lb = 3.1, ub = 4.3) # map voltage from 3.1..4.3 V to 0..100% + +# write first set of values to display display.fill(0) -display.text("Temp: " + str(round(values['temp'], 1)) + " C", 1, 1), -display.text("Druk: " + str(round(values['pres'], 1)) + " hPa", 1, 11), -display.text("Vocht: " + str(round(values['humi'], 1)) + " %", 1, 21), -display.text("Licht: " + str(int(values['lx'])) + " lx", 1, 31), -display.text("UV: " + str(int(values['uv'])), 1, 41), -display.text("Accu: " + str(int(values['perc'])) + " %", 1, 54), +display.text("Temp: {:> 6} C" .format(round(values['temp'], 1)), 1, 1) +display.text("Druk:{:> 6} hPa" .format(round(values['pres'], 1)), 1, 11) +display.text("Vocht: {:> 5} %" .format(round(values['humi'], 1)), 1, 21) +display.text("Licht: {:> 5} lx" .format(round(values[ 'lx'] )), 1, 31) +display.text("UV: {:> 8}" .format(round(values[ 'uv'] )), 1, 41) +display.text("Accu: {:> 6} %" .format(round( perc )), 1, 54) display.show() -machine.sleep(pycom.nvs_get('t_disp') * 1000) + +scd41 = SCD41(i2c = i2c, address = 98) # CO2 sensor (50 / 0.2 mA) (0x62) +scd41.wake() +machine.sleep(200) # apparently needs some extra time to wake +scd41.measure_single_shot() # start measurement, takes 5 seconds to complete +machine.sleep(5000) +values['co2'] = scd41.CO2 +scd41.sleep() + +# sleep for the remainder of 25 seconds +machine.sleep(25000 - time.ticks_diff(time.ticks_ms(), t_start)) + +# try to get a response from SDS011 within 5 seconds +while (not sds011.read() and time.ticks_diff(time.ticks_ms(), t_start) < 30000): + machine.sleep(200) + +values['pm25'] = sds011.pm25 +values['pm10'] = sds011.pm10 +sds011.sleep() + +t_stop = time.ticks_ms() + +# write second set of values to display display.fill(0) -display.text("Volume: " + str(int(values['volu'])) + " dB", 1, 1), -display.text("VOC: " + str(int(values['voc'])) + " Ohm", 1, 11), -if all_sensors == True: - display.text("CO2: " + str(int(values['co2'])) + " ppm", 1, 21), - display.text("PM2.5: " + str(values['pm25']) + " ppm", 1, 31), - display.text("PM10: " + str(values['pm10']) + " ppm", 1, 41), -display.text("Accu: " + str(int(values['perc'])) + " %", 1, 54), +display.text("Volume: {:> 4} dB".format(round(values['volu'] )), 1, 1) +display.text("VOC: {:> 7}" .format(round(values[ 'voc'] )), 1, 11) +display.text("CO2: {:> 7} ppm" .format(round(values[ 'co2'] )), 1, 21) +display.text("PM2.5: {:> 5} ppm".format(round(values['pm25'], 1)), 1, 31) +display.text("PM10: {:> 6} ppm" .format(round(values['pm10'], 1)), 1, 41) +display.text("Accu: {:> 6} %" .format(round( perc )), 1, 54) display.show() -machine.sleep(pycom.nvs_get('t_disp') * 1000) + +# if necessary, start reading GPS to get a location fix +if USE_GPS: + + # the GPS module has a pulling rate of 1Hz + # therefore, if there is no data present within 2 seconds, raise an error + uart2 = machine.UART(2, pins = (pins.TX2, pins.RX2), baudrate = 9600) + time.sleep_ms(2000) + if not uart2.any(): + raise ModuleNotFoundError + + from lib.micropyGPS import MicropyGPS + gps = MicropyGPS() # create GPS object + + t = time.ticks_ms() + # THIS IS A BLOCKING CALL!! there MUST be a reasonable fix before sending data + while not gps.valid or gps.hdop > 5: + while uart2.any(): # wait for incoming communication + my_sentence = uart2.readline() # read NMEA sentence + for x in my_sentence: + gps.update(chr(x)) # decode it through micropyGPS + + # every two seconds, update some stats on the display + if (time.ticks_ms() - t) > 2000: + display.fill(0) + display.text("GPS stats:", 1, 1) + display.text("fix: {:> 4}" .format("yes" if gps.valid else "no"), 1, 11) + display.text("hdop: {:> 4}" .format(round(gps.hdop, 1)), 1, 21) + display.text("sats: {:> 4}" .format(gps.satellites), 1, 31) + display.text("time: {:> 4} s".format(round(t/1000)), 1, 41) + display.show() + t = time.ticks_ms() + + gps_en.value(1) # disable power to GPS module + gps_en.hold(True) # hold through deepsleep + + values['lat'] = gps.latitude + values['long'] = gps.longitude + values['alt'] = gps.altitude + values['hdop'] = gps.hdop + + values['fw'] = pycom.nvs_get('fwversion') % 100 # add current firmware version to values (two trailing numbers) + +vr_en.value(0) # disable voltage regulator +vr_en.hold(True) # hold pin low during deepsleep + +# if LoRa failed to join, perform a reset which causes the device to restart from the top +if not lora.has_joined: + machine.reset() + +lora.make_frame(values) +lora.send_frame() + +# show values on display for the remainder of 10 seconds +machine.sleep(10000 - time.ticks_diff(time.ticks_ms(), t_stop)) display.poweroff() +# if there was an error last time, but we got here now, set register to 0 +if pycom.nvs_get("error"): + pycom.nvs_set("error", 0) + # set up for deepsleep -awake_time = time.ticks_diff(time.ticks_ms(), start_time) - 3000 # time in milliseconds the program has been running -push_button = machine.Pin('P2', mode = machine.Pin.IN, pull = machine.Pin.PULL_DOWN) # initialize wake-up pin -machine.pin_sleep_wakeup(['P2'], mode = machine.WAKEUP_ANY_HIGH, enable_pull = True) # set wake-up pin as trigger -machine.deepsleep(pycom.nvs_get('t_int') * 1000 - awake_time) # deepsleep for remainder of the interval time +awake_time = time.ticks_diff(time.ticks_ms(), t_boot) - 3000 # time in milliseconds the program has been running +machine.Pin(pins.Wake, mode = machine.Pin.IN, pull = machine.Pin.PULL_DOWN) # initialize wake-up pin +machine.pin_sleep_wakeup([pins.Wake], mode = machine.WAKEUP_ANY_HIGH, enable_pull = True) # set wake-up pin as trigger +machine.deepsleep(pycom.nvs_get('t_int') * 1000 - awake_time) # deepsleep for remainder of the interval time \ No newline at end of file diff --git a/software/collect_gps.py b/software/collect_gps.py deleted file mode 100644 index 3593373..0000000 --- a/software/collect_gps.py +++ /dev/null @@ -1,28 +0,0 @@ -import machine -import time -from lib.micropyGPS import MicropyGPS - -def run_gps(timeout = 120): - values = {} - - gps_en = machine.Pin('P22', mode = machine.Pin.OUT) # 2N2907 (PNP) gate pin - gps_en.hold(False) # disable hold from deepsleep - gps_en.value(0) # enable GPS power - gps = MicropyGPS() # create GPS object - - com = machine.UART(2, pins = ('P3', 'P11'), baudrate = 9600)# GPS communication - - t1 = time.time() - while gps.latitude == gps.longitude == 0 and time.time() - t1 < timeout: # timeout if no fix after .. - while com.any(): # wait for incoming communication - my_sentence = com.readline() # read NMEA sentence - for x in my_sentence: - gps.update(chr(x)) # decode it through micropyGPS - - gps_en.value(1) - gps_en.hold(True) - - values["lat"] = gps.latitude - values["long"] = gps.longitude - values["alt"] = gps.alt - return values diff --git a/software/collect_sensors.py b/software/collect_sensors.py deleted file mode 100644 index 28b80b4..0000000 --- a/software/collect_sensors.py +++ /dev/null @@ -1,73 +0,0 @@ -import machine -import time - -from lib.VEML6070 import VEML6070 -from lib.TSL2591 import TSL2591 -from lib.BME680 import BME680 -from lib.MAX4466 import MAX4466 -from lib.KP26650 import KP26650 - -def run_collection(i2c, all_sensors, t_wake = 30): - - values = {} # collection of all values, to be returned - - bme680 = BME680(i2c = i2c, address = 119) - bme680.set_gas_heater_temperature(400, nb_profile = 1) # set VOC plate heating temperature - bme680.set_gas_heater_duration(50, nb_profile = 1) # set VOC plate heating duration - bme680.select_gas_heater_profile(1) # select those settings - while not bme680.get_sensor_data(): - time.sleep_ms(10) - values['temp'] = bme680.temperature - values['pres'] = bme680.pressure - values['humi'] = bme680.humidity - values['voc'] = bme680.gas - bme680.set_power_mode(0) - - veml6070 = VEML6070(i2c = i2c, address = 56) # UV sensor (0.4 / 0.0 mA) (0x38) - veml6070.wake() - values['uv'] = veml6070.uv_raw - time.sleep(0.2) # sensor stabilization time (required!!) - values['uv'] = veml6070.uv_raw # first poll may fail so do it twice - veml6070.sleep() - - tsl2591 = TSL2591(i2c = i2c, address = 41) # lux sensor (0.4 / 0.0 mA) (0x29) - tsl2591.wake() - values['lx'] = tsl2591.lux - time.sleep(0.2) # sensor stabilization time (required!!) - values['lx'] = tsl2591.lux - tsl2591.sleep() - - max4466 = MAX4466('P15', duration = 200) # analog loudness sensor (200ms measurement) - values['volu'] = max4466.get_volume() # active: 0.3 mA, sleep: 0.3 mA (always on) - - battery = KP26650('P16', duration = 50, ratio = 2) # battery voltage (50ms measurement, 1:1 voltage divider) - values['volt'] = battery.get_voltage() - values['perc'] = battery.get_percentage(values['volt'], lb = 2.9, ub = 4.1) # map voltage from 2.9..4.1 V to 0..100% - - if all_sensors == True: - from lib.SDS011 import SDS011 - from lib.MQ135 import MQ135 - - regulator = machine.Pin('P21', mode = machine.Pin.OUT) # voltage regulator SHDN pin - regulator.hold(False) # disable hold from deepsleep - regulator.value(1) # start SDS011 and MQ135 - - com = machine.UART(1, pins=('P20', 'P19'), baudrate = 9600) # UART communication to SDS011 - sds011 = SDS011(com) # fine particle sensor (70 / 0.0 mA) - - mq135 = MQ135('P17', duration = 50) # CO2 sensor (200 / 0.0 mA) - - machine.sleep(t_wake * 1000) # wait for ~25 seconds - - values['co2'] = mq135.get_corrected_ppm(values['temp'], values['humi']) - - t1 = time.ticks_ms() - while (not sds011.read() and time.ticks_ms() - t1 < 5000): # try to get a response from SDS011 within 5 seconds - time.sleep_ms(10) - - values['pm25'] = sds011.pm25 - values['pm10'] = sds011.pm10 - regulator.value(0) # disable voltage regulator - regulator.hold(True) # hold pin low during deepsleep - - return values \ No newline at end of file diff --git a/software/error.py b/software/error.py index c540fc2..3ac7b0e 100644 --- a/software/error.py +++ b/software/error.py @@ -1,38 +1,83 @@ import machine import pycom -from lib.SSD1306 import SSD1306 - -def blink(R, G, B): - color = (R << 16) + (G << 8) + B - while True: - pycom.rgbled(color) - machine.sleep(500) - pycom.rgbled(0) - machine.sleep(1500) - -i2c = machine.I2C(0) - -try: - display = SSD1306(128, 64, i2c) - display.text("Error!", 1, 1) - display.show() -except: - blink(0, 0, 255) - -addresses = i2c.scan() - -sensors = { 41 : "TSL2591", 56 : "VEML6070", 119 : "BME680" } - -i = 11 -for key in sensors: - if key not in addresses: - display.text(sensors[key], 1, i) - display.show() - i += 10 - -if i == 11: - display.text("I2C ok", 1, 11) - display.show() - blink(255, 0, 0) -else: - blink(180, 180, 0) \ No newline at end of file +import time + +import pins + +sensors = { 41 : "TSL2591", + 56 : "VEML6070", + 60 : "SSD1306", + 98 : "SCD41", + 119: "BME680" } + +i2c = machine.I2C (0, pins = (pins.SDA, pins.SCL)) +uart1 = machine.UART(1, pins = (pins.TX1, pins.RX1), baudrate = 9600) +uart2 = machine.UART(2, pins = (pins.TX2, pins.RX2), baudrate = 9600) + +vr_en = machine.Pin(pins.VR, mode = machine.Pin.OUT) # voltage regulator SHDN pin +vr_en.hold(False) # disable hold from deepsleep +gps_en = machine.Pin(pins.GPS, mode = machine.Pin.OUT) # 2N2907 (PNP) gate pin +gps_en.hold(False) # disable hold from deepsleep + +def find_error(): + addresses = i2c.scan() # check if I2C wires are OK + if not addresses: + return 1 + + for key in sensors: # check if we can ping each I2C sensor + if key not in addresses: + return key + + vr_en.value(1) # enable SDS011 power + gps_en.value(0) # enable GPS power + uart1.write(b'\xaa\xb4\x06\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x06\xab') + time.sleep(2) + + if not uart1.any(): # check if SDS011 is present + return 2 + + if not uart2.any(): # check if NEO-6M is present + return 3 + + return 999 # no clue + +error = find_error() + +vr_en.value(0) # disable power & hold through deepsleep +vr_en.hold(True) +gps_en.value(1) # disable power & hold through deepsleep +gps_en.hold(True) + +from lib.KP26650 import KP26650 +batt = KP26650(pins.Batt, duration = 200, ratio = 2) # measure battery voltage +volt = batt.get_voltage() + +from LoRa import LoRaWAN +lora = LoRaWAN(sf = pycom.nvs_get('sf_h'), fport = 4) # sort out all LoRa related settings (frame count, port, sf) + +from ucollections import OrderedDict +values = OrderedDict() +values['fw'] = pycom.nvs_get('fwversion') % 100 # only keep 2 trailing digits +values['error'] = error if pycom.nvs_get('error') else -error # negative value if this is the first time (soft error) +values['batt'] = volt + +lora.make_frame(values) +lora.send_frame(True) # send frame + +# if we land here from a working state, reboot to try and solve error +if not pycom.nvs_get('error'): + pycom.nvs_set('error', 1) # set in NVRAM that we encountered an error + + pycom.rgbled((255 << 16) + (255 << 8) + 255) # bright white + machine.sleep(2000) + pycom.rgbled(0) # off + + machine.reset() # perform a full reboot + +# if rebooting did not solve the error, blink slow red +red = (128 << 16) +while True: + pycom.rgbled(red) + machine.sleep(500) + pycom.rgbled(0) + machine.sleep(2500) \ No newline at end of file diff --git a/software/lib/BME680.py b/software/lib/BME680.py index 6f4f2a8..07fa1a2 100644 --- a/software/lib/BME680.py +++ b/software/lib/BME680.py @@ -100,37 +100,37 @@ def twos_comp(val, bits = 8): class CalibrationData: """Structure for storing BME680 calibration data.""" - def set_from_array(self, calibration): + def set_from_array(self, cal): # Temperature related coefficients - self.par_t1 = bytes_to_word(calibration[34], calibration[33], signed = False) - self.par_t2 = bytes_to_word(calibration[2], calibration[1]) - self.par_t3 = twos_comp(calibration[3]) + self.par_t1 = bytes_to_word(cal[34], cal[33], signed = False) + self.par_t2 = bytes_to_word(cal[2], cal[1]) + self.par_t3 = twos_comp(cal[3]) # Pressure related coefficients - self.par_p1 = bytes_to_word(calibration[6], calibration[5], signed = False) - self.par_p2 = bytes_to_word(calibration[8], calibration[7]) - self.par_p3 = twos_comp(calibration[9]) - self.par_p4 = bytes_to_word(calibration[12], calibration[11]) - self.par_p5 = bytes_to_word(calibration[14], calibration[13]) - self.par_p6 = twos_comp(calibration[16]) - self.par_p7 = twos_comp(calibration[15]) - self.par_p8 = bytes_to_word(calibration[20], calibration[19]) - self.par_p9 = bytes_to_word(calibration[22], calibration[21]) - self.par_p10 = calibration[23] + self.par_p1 = bytes_to_word(cal[6], cal[5], signed = False) + self.par_p2 = bytes_to_word(cal[8], cal[7]) + self.par_p3 = twos_comp(cal[9]) + self.par_p4 = bytes_to_word(cal[12], cal[11]) + self.par_p5 = bytes_to_word(cal[14], cal[13]) + self.par_p6 = twos_comp(cal[16]) + self.par_p7 = twos_comp(cal[15]) + self.par_p8 = bytes_to_word(cal[20], cal[19]) + self.par_p9 = bytes_to_word(cal[22], cal[21]) + self.par_p10 = cal[23] # Humidity related coefficients - self.par_h1 = (calibration[27] << 4) | (calibration[26] & BIT_H1_DATA_MSK) - self.par_h2 = (calibration[25] << 4) | (calibration[26] >> 4) - self.par_h3 = twos_comp(calibration[28]) - self.par_h4 = twos_comp(calibration[29]) - self.par_h5 = twos_comp(calibration[30]) - self.par_h6 = calibration[31] - self.par_h7 = twos_comp(calibration[32]) + self.par_h1 = (cal[27] << 4) | (cal[26] & BIT_H1_DATA_MSK) + self.par_h2 = (cal[25] << 4) | (cal[26] >> 4) + self.par_h3 = twos_comp(cal[28]) + self.par_h4 = twos_comp(cal[29]) + self.par_h5 = twos_comp(cal[30]) + self.par_h6 = cal[31] + self.par_h7 = twos_comp(cal[32]) # Gas heater related coefficients - self.par_gh1 = twos_comp(calibration[37]) - self.par_gh2 = bytes_to_word(calibration[36], calibration[35]) - self.par_gh3 = twos_comp(calibration[38]) + self.par_gh1 = twos_comp(cal[37]) + self.par_gh2 = bytes_to_word(cal[36], cal[35]) + self.par_gh3 = twos_comp(cal[38]) def set_other(self, heat_range, heat_value, sw_error): """Set other values.""" @@ -179,10 +179,7 @@ def soft_reset(self): time.sleep(POLL_PERIOD_MS / 1000.0) def set_temp_offset(self, value): - """Set temperature offset in celsius. - If set, the temperature t_fine will be increased by given value in celsius. - :param value: Temperature offset in Celsius, eg. 4, -8, 1.25 - """ + """Set temperature offset in celsius.""" if value == 0: self.offset_temp_in_t_fine = 0 else: @@ -198,13 +195,10 @@ def set_temperature_oversample(self, value): self._set_bits(CONF_T_P_MODE_ADDR, OST_MSK, OST_POS, value) def set_filter(self, value): - """Set IIR filter size. - Optionally remove short term fluctuations from the temperature and pressure readings, - increasing their resolution but reducing their bandwidth. + """Set IIR filter size. + Removes short term fluctuations from the temperature and pressure readings. Enabling the IIR filter does not slow down the time a reading takes, but will slow down the BME680s response to changes in temperature and pressure. - When the IIR filter is enabled, the temperature and pressure resolution is effectively 20bit. - When it is disabled, it is 16bit + oversampling-1 bits. """ self._set_bits(CONF_ODR_FILT_ADDR, FILTER_MSK, FILTER_POS, value) @@ -213,8 +207,7 @@ def get_filter(self): def select_gas_heater_profile(self, value): """Set current gas sensor conversion profile. - Select one of the 10 configured heating durations/set points. - :param value: Profile index from 0 to 9 + Select one of the 10 configured heating durations/set points (0 to 9). """ self._set_bits(CONF_ODR_RUN_GAS_NBC_ADDR, NBCONV_MSK, NBCONV_POS, value) @@ -223,16 +216,12 @@ def set_gas_status(self, value): self._set_bits(CONF_ODR_RUN_GAS_NBC_ADDR, RUN_GAS_MSK, RUN_GAS_POS, value) def set_gas_heater_temperature(self, value, nb_profile=0): - """Set gas sensor heater temperature. - :param value: Target temperature in degrees celsius, between 200 and 400 - """ + """Set gas sensor heater temperature (degrees celsius, between 200 and 400).""" temp = int(self._calc_heater_resistance(value)) self._write(RES_HEAT0_ADDR + nb_profile, temp) def set_gas_heater_duration(self, value, nb_profile=0): - """Set gas sensor heater duration. - :param value: Heating duration in milliseconds between 1 ms and 4032 (typical 20~30 ms) - """ + """Set gas sensor heater duration (in milliseconds between 1 ms and 4032 (typical 20~30 ms).""" temp = self._calc_heater_duration(value) self._write(GAS_WAIT0_ADDR + nb_profile, temp) diff --git a/software/lib/KP26650.py b/software/lib/KP26650.py index 0b7f282..f1821ef 100644 --- a/software/lib/KP26650.py +++ b/software/lib/KP26650.py @@ -8,6 +8,7 @@ def __init__(self, pin, duration = 50, ratio = 2): self.adc = adc.channel(pin = pin, attn = machine.ADC.ATTN_11DB) # 0 to 4095 accuracy self.duration = duration # integration time in milliseconds self.ratio = ratio + self.avg_volt = 0 def get_voltage(self): # take 'n' samples over 'duration' time to find average voltage across divider @@ -18,11 +19,10 @@ def get_voltage(self): val += self.adc.voltage() n += 1 - avg_val = val / n # find average measured value - avg_volt = avg_val / 1000 * self.ratio # convert mV -> V, multiply by certain ratio due to voltage divider - return avg_volt + avg_val = val / n # find average measured value + self.avg_volt = avg_val / 1000 * self.ratio # convert mV -> V, multiply by certain ratio due to voltage divider + return self.avg_volt - @staticmethod - def get_percentage( voltage, lb = 3.0, ub = 4.0): + def get_percentage(self, lb, ub): # return a value between 0..100% from lower bound to upper bound - return max(0, min(100, (voltage - lb) / (ub - lb) * 100)) \ No newline at end of file + return max(0, min(100, (self.avg_volt - lb) / (ub - lb) * 100)) \ No newline at end of file diff --git a/software/lib/MQ135.py b/software/lib/MQ135.py deleted file mode 100644 index 64ac67c..0000000 --- a/software/lib/MQ135.py +++ /dev/null @@ -1,74 +0,0 @@ -import machine -import time - -class MQ135(object): - RLOAD = 10.0 # The load resistance on the board - RZERO = 76.63 # Calibration resistance at atmospheric CO2 level - - # Parameters for calculating ppm of CO2 from sensor resistance - PARA = 116.6020682 - PARB = 2.769034857 - - # Parameters to model temperature and humidity dependence - CORA = 0.00035 - CORB = 0.02718 - CORC = 1.39538 - CORD = 0.0018 - CORE = -0.003333333 - CORF = -0.001923077 - CORG = 1.130128205 - - ATMOCO2 = 397.13 # Atmospheric CO2 level for calibration purposes - - def __init__(self, pin, duration = 50): - adc = machine.ADC() - self.adc = adc.channel(pin = pin, attn = machine.ADC.ATTN_11DB) # 0 to 4095 accuracy - self.duration = duration # integration time in milliseconds - - def get_correction_factor(self, temperature, humidity): - """Calculates the correction factor for ambient air temperature and relative humidity - Based on the linearization of the temperature dependency curve - under and above 20 degrees Celsius, asuming a linear dependency on humidity, - provided by Balk77 https://github.com/GeorgK/MQ135/pull/6/files - """ - if temperature < 20: - return self.CORA * temperature * temperature - self.CORB * temperature + self.CORC - (humidity - 33.) * self.CORD - else: - return self.CORE * temperature + self.CORF * humidity + self.CORG - - def get_resistance(self): - """Returns the resistance of the sensor in kOhms // -1 if not value got in pin""" - val = 0 - n = 0 - t1 = time.ticks_ms() - while time.ticks_ms() - t1 < self.duration: - val += self.adc.value() - n += 1 - - avg_val = val / n # find average measured value - if avg_val == 0: - return -1 - - return (4095./avg_val - 1.) * self.RLOAD - - def get_corrected_resistance(self, temperature, humidity): - """Gets the resistance of the sensor corrected for temperature/humidity""" - return self.get_resistance()/ self.get_correction_factor(temperature, humidity) - - def get_corrected_ppm(self, temperature, humidity): - """Returns the ppm of CO2 sensed (assuming only CO2 in the air) - corrected for temperature/humidity""" - g_cr = self.get_corrected_resistance(temperature, humidity) - try: - return self.PARA * ((g_cr / self.RZERO)**-self.PARB) - except: - return 0 - - def get_rzero(self): - """Returns the resistance RZero of the sensor (in kOhms) for calibration purposes""" - return self.get_resistance() * (self.ATMOCO2/self.PARA)**(1./self.PARB) - - def get_corrected_rzero(self, temperature, humidity): - """Returns the resistance RZero of the sensor (in kOhms) for calibration purposes - corrected for temperature/humidity""" - return self.get_corrected_resistance(temperature, humidity) * (self.ATMOCO2/self.PARA)**(1./self.PARB) \ No newline at end of file diff --git a/software/lib/SCD41.py b/software/lib/SCD41.py new file mode 100644 index 0000000..e3ed602 --- /dev/null +++ b/software/lib/SCD41.py @@ -0,0 +1,233 @@ +# modified by Steven Boonstoppel +# from https://github.com/adafruit/Adafruit_CircuitPython_SCD4X/blob/main/adafruit_scd4x.py +# and https://github.com/Sensirion/arduino-i2c-scd4x/blob/master/src/SensirionI2CScd4x.cpp + +import time +import struct + +SCD4X_DEFAULT_ADDR = 0x62 +_SCD4X_REINIT = 0x3646 +_SCD4X_FACTORYRESET = 0x3632 +_SCD4X_FORCEDRECAL = 0x362F +_SCD4X_DATAREADY = 0xE4B8 +_SCD4X_STOPPERIODICMEAS = 0x3F86 +_SCD4X_STARTPERIODICMEAS = 0x21B1 +_SCD4X_STARTLOWPOWERPERIODICMEAS = 0x21AC +_SCD4X_READMEAS = 0xEC05 +_SCD4X_SERIALNUMBER = 0x3682 +_SCD4X_GETTEMPOFFSET = 0x2318 +_SCD4X_SETTEMPOFFSET = 0x241D +_SCD4X_SETPRESSURE = 0xE000 +_SCD4X_PERSISTSETTINGS = 0x3615 +_SCD4X_GETASCE = 0x2313 +_SCD4X_SETASCE = 0x2416 + +_SCD4X_MEASSINGLESHOT = 0x219D +_SCD4X_MEASSINGLESHOTRHT = 0x2196 +_SCD4X_SLEEP = 0x36E0 +_SCD4X_WAKE = 0x36F6 + + +class SCD41: + def __init__(self, i2c, address = SCD4X_DEFAULT_ADDR) -> None: + self.i2c = i2c + self.address = address + self._buffer = bytearray(18) + self._cmd = bytearray(2) + self._crc_buffer = bytearray(2) + + # cached readings + self._temperature = None + self._relative_humidity = None + self._co2 = None + + try: + self.stop_periodic_measurement() + except: + pass + + @property + def CO2(self) -> int: + """Returns the CO2 concentration in PPM (parts per million)""" + if self.data_ready: + self._read_data() + return self._co2 + + @property + def temperature(self) -> float: + """Returns the current temperature in degrees Celsius""" + if self.data_ready: + self._read_data() + return self._temperature + + @property + def relative_humidity(self) -> float: + """Returns the current relative humidity in %rH""" + if self.data_ready: + self._read_data() + return self._relative_humidity + + def reinit(self) -> None: + """Reinitializes the sensor by reloading user settings from EEPROM.""" + self.stop_periodic_measurement() + self._send_command(_SCD4X_REINIT, cmd_delay=0.02) + + def factory_reset(self) -> None: + """Resets all configuration settings stored in the EEPROM and erases the FRC and ASC algorithm history.""" + self.stop_periodic_measurement() + self._send_command(_SCD4X_FACTORYRESET, cmd_delay=1.2) + + def force_calibration(self, target_co2: int) -> None: + """Forces the sensor to recalibrate with a given current CO2""" + self.stop_periodic_measurement() + self._set_command_value(_SCD4X_FORCEDRECAL, target_co2, cmd_delay=0.5) + self._read_reply(3) + correction = struct.unpack_from(">h", self._buffer[0:2])[0] + if correction == 0xFFFF: + raise RuntimeError( + "Forced recalibration failed.\ + Make sure sensor is active for 3 minutes first" + ) + + @property + def self_calibration_enabled(self) -> bool: + """Enables or disables automatic self calibration (ASC). To work correctly, the sensor must + be on and active for 7 days after enabling ASC, and exposed to fresh air for at least 1 hour + per day. + .. note: This value will NOT be saved unless saved with persist_settings().""" + self._send_command(_SCD4X_GETASCE, cmd_delay=0.001) + self._read_reply(3) + return self._buffer[1] == 1 + + @self_calibration_enabled.setter + def self_calibration_enabled(self, enabled: bool) -> None: + self._set_command_value(_SCD4X_SETASCE, enabled) + + def _read_data(self) -> None: + """Reads the temp/hum/co2 from the sensor and caches it""" + self._send_command(_SCD4X_READMEAS, cmd_delay=0.001) + self._read_reply(9) + self._co2 = (self._buffer[0] << 8) | self._buffer[1] + temp = (self._buffer[3] << 8) | self._buffer[4] + self._temperature = -45 + 175 * (temp / 2**16) + humi = (self._buffer[6] << 8) | self._buffer[7] + self._relative_humidity = 100 * (humi / 2**16) + + @property + def data_ready(self) -> bool: + """Check the sensor to see if new data is available""" + self._send_command(_SCD4X_DATAREADY, cmd_delay=0.001) + self._read_reply(3) + return not ((self._buffer[0] & 0x07 == 0) and (self._buffer[1] == 0)) + + @property + def serial_number(self): + """Request a 6-tuple containing the unique serial number for this sensor""" + self._send_command(_SCD4X_SERIALNUMBER, cmd_delay=0.001) + self._read_reply(9) + return ( + self._buffer[0], + self._buffer[1], + self._buffer[3], + self._buffer[4], + self._buffer[6], + self._buffer[7], + ) + + def stop_periodic_measurement(self) -> None: + """Stop measurement mode""" + self._send_command(_SCD4X_STOPPERIODICMEAS, cmd_delay=0.5) + + def start_periodic_measurement(self) -> None: + """Put sensor into working mode, about 5s per measurement""" + self._send_command(_SCD4X_STARTPERIODICMEAS) + + def start_low_periodic_measurement(self) -> None: + """Put sensor into low power working mode, about 30s per measurement.""" + self._send_command(_SCD4X_STARTLOWPOWERPERIODICMEAS) + + def persist_settings(self) -> None: + """Save temperature offset, altitude offset, and selfcal enable settings to EEPROM""" + self._send_command(_SCD4X_PERSISTSETTINGS, cmd_delay=0.8) + + def set_ambient_pressure(self, ambient_pressure: int) -> None: + """Set the ambient pressure in hPa at any time to adjust CO2 calculations""" + if ambient_pressure < 0 or ambient_pressure > 65535: + raise AttributeError("`ambient_pressure` must be from 0~65535 hPascals") + self._set_command_value(_SCD4X_SETPRESSURE, ambient_pressure) + + @property + def temperature_offset(self) -> float: + """Specifies the offset to be added to the reported measurements to account for a bias in + the measured signal. Value is in degrees Celsius with a resolution of 0.01 degrees + .. note: This value will NOT be saved unless saved with persist_settings(). + """ + self._send_command(_SCD4X_GETTEMPOFFSET, cmd_delay=0.001) + self._read_reply(3) + temp = (self._buffer[0] << 8) | self._buffer[1] + return 175.0 * temp / 2**16 + + @temperature_offset.setter + def temperature_offset(self, offset) -> None: + if offset > 374: + raise AttributeError( + "Offset value must be less than or equal to 374 degrees Celsius" + ) + temp = int(offset * 2**16 / 175) + self._set_command_value(_SCD4X_SETTEMPOFFSET, temp) + + def _check_buffer_crc(self, buf: bytearray) -> bool: + for i in range(0, len(buf), 3): + self._crc_buffer[0] = buf[i] + self._crc_buffer[1] = buf[i + 1] + if self._crc8(self._crc_buffer) != buf[i + 2]: + raise RuntimeError("CRC check failed while reading data") + return True + + def _send_command(self, cmd, cmd_delay = 0) -> None: + buffer = bytearray(2) + buffer[0] = (cmd >> 8) & 0xFF + buffer[1] = cmd & 0xFF + self.i2c.writeto(self.address, buffer) + time.sleep(cmd_delay) + + def _set_command_value(self, cmd, value, cmd_delay=0): + self._buffer[0] = (cmd >> 8) & 0xFF + self._buffer[1] = cmd & 0xFF + self._crc_buffer[0] = self._buffer[2] = (value >> 8) & 0xFF + self._crc_buffer[1] = self._buffer[3] = value & 0xFF + self._buffer[4] = self._crc8(self._crc_buffer) + self.i2c.writeto(self.address, self._buffer) + time.sleep(cmd_delay) + + def _read_reply(self, num): + self._buffer = self.i2c.readfrom(self.address, num) + self._check_buffer_crc(self._buffer[0:num]) + + @staticmethod + def _crc8(buffer: bytearray) -> int: + crc = 0xFF + for byte in buffer: + crc ^= byte + for _ in range(8): + if crc & 0x80: + crc = (crc << 1) ^ 0x31 + else: + crc = crc << 1 + return crc & 0xFF # return the bottom 8 bits + + def measure_single_shot(self) -> None: + self._send_command(_SCD4X_MEASSINGLESHOT, cmd_delay=0.001) + + def measure_single_shot_rht(self) -> None: + self._send_command(_SCD4X_MEASSINGLESHOTRHT, cmd_delay=0.05) + + def sleep(self) -> None: + self._send_command(_SCD4X_SLEEP, cmd_delay=0.001) + + def wake(self) -> None: + # sensor does not send an ACK on wake, so discard the error + try: + self._send_command(_SCD4X_WAKE, cmd_delay=0.02) + except: + pass \ No newline at end of file diff --git a/software/lib/micropyGPS.py b/software/lib/micropyGPS.py index 20cd3b7..4fca873 100644 --- a/software/lib/micropyGPS.py +++ b/software/lib/micropyGPS.py @@ -37,12 +37,12 @@ def __init__(self, local_offset=0): self._longitude = [0, 0.0, 'W'] self.speed = [0.0, 0.0, 0.0] self.course = 0.0 - self.altitude = 0.0 + self._altitude = 0.0 # GPS Info self.satellites_in_use = 0 - self.hdop = 0.0 - self.valid = False + self._hdop = 99.99 + self._valid = False self.fix_stat = 0 # Coordinates Translation Functions @@ -59,8 +59,20 @@ def longitude(self): return decimal_degrees @property - def alt(self): - return self.altitude + def altitude(self): + return self._altitude + + @property + def hdop(self): + return self._hdop + + @property + def satellites(self): + return self.satellites_in_use + + @property + def valid(self): + return self._valid # Sentence Parsers def gprmc(self): @@ -148,14 +160,14 @@ def gprmc(self): # Include mph and hm/h self.speed = [spd_knt, spd_knt * 1.151, spd_knt * 1.852] self.course = course - self.valid = True + self._valid = True else: # Clear Position Data if Sentence is 'Invalid' self._latitude = [0, 0.0, 'N'] self._longitude = [0, 0.0, 'W'] self.speed = [0.0, 0.0, 0.0] self.course = 0.0 - self.valid = False + self._valid = False return True @@ -206,12 +218,12 @@ def gpgll(self): # Update Object Data self._latitude = [lat_degs, lat_mins, lat_hemi] self._longitude = [lon_degs, lon_mins, lon_hemi] - self.valid = True + self._valid = True else: # Clear Position Data if Sentence is 'Invalid' self._latitude = [0, 0.0, 'N'] self._longitude = [0, 0.0, 'W'] - self.valid = False + self._valid = False return True @@ -282,12 +294,12 @@ def gpgga(self): # Update Object Data self._latitude = [lat_degs, lat_mins, lat_hemi] self._longitude = [lon_degs, lon_mins, lon_hemi] - self.altitude = altitude + self._altitude = altitude # Update Object Data self.timestamp = [hours, minutes, seconds] self.satellites_in_use = satellites_in_use - self.hdop = hdop + self._hdop = hdop self.fix_stat = fix_stat return True diff --git a/software/pins.py b/software/pins.py new file mode 100644 index 0000000..ec218a4 --- /dev/null +++ b/software/pins.py @@ -0,0 +1,12 @@ +# pin connections +SDA = 'P9' # I2C +SCL = 'P10' # I2C +RX2 = 'P11' # GPS UART +TX2 = 'P3' # GPS UART +Wake = 'P2' # deepsleep interrupt +Batt = 'P16' # battery ADC +Vol = 'P15' # volume ADC +RX1 = 'P19' # PM UART +TX1 = 'P20' # PM UART +VR = 'P21' # voltage regulator enable +GPS = 'P22' # GPS enable \ No newline at end of file diff --git a/software/lib/updateFW.py b/software/updateFW.py similarity index 90% rename from software/lib/updateFW.py rename to software/updateFW.py index ed88552..818f6d5 100644 --- a/software/lib/updateFW.py +++ b/software/updateFW.py @@ -74,15 +74,15 @@ def check_firmware(): return ("New firmware", filesize) def do_firmware(display, filesize): - last_prog = 0 - string = "{:>4}/{} ({:>3}%)".format(size, filesize, last_prog) + size = 0 # number of copied bytes counter + last_prog = 0 # progression tracker + string = "{:>4}/{} ({:>3}%)".format(0, int(filesize / 1000), 0) display.text(string, 1, 31) display.show() try: - with open(secret.file_firmware, "rb") as f: # open new firmware file + with open(secret.file_firmware, "rb") as f: # open new firmware file as binary file buffer = bytearray(BLOCKSIZE) # buffer of 4096 bytes mv = memoryview(buffer) - size = 0 # copied bytes counter pycom.ota_start() # start Over The Air update chunk = f.readinto(buffer) while chunk > 0: @@ -91,7 +91,7 @@ def do_firmware(display, filesize): prog = int(size / filesize * 100) # calculate progress (in %) if prog != last_prog: # if % has changed, update display display.text(string, 1, 31, col = 0) # de-fill previous string - string = "{:>4}/{} ({:>3}%)".format(size, filesize, prog) + string = "{:>4}/{} ({:>3}%)".format(int(size / 1000), int(filesize / 1000), prog) display.text(string, 1, 31) # write current string display.show() last_prog = prog # update old value