From 081984dd6fa171cc1e756c61bdb691baafcec73d Mon Sep 17 00:00:00 2001 From: StevenCellist Date: Tue, 16 Aug 2022 17:11:03 +0200 Subject: [PATCH] Update to v2.5 (stable release!?) Months of trouble.. but this is a stable release without anymore bugs! --- .gitignore | 4 +- README.md | 42 +- ...ematic_Meet_je_leefomgeving_2022-08-15.pdf | Bin 45542 -> 44347 bytes Schematic_Meet_je_leefomgeving_2022-08-15.svg | 5 + software/collect_gps.py | 29 + software/collect_sensors.py | 72 ++ software/counter.txt | 1 + software/lib/BME680.py | 749 +++++++++--------- software/lib/KP26650.py | 28 + software/lib/MAX4466.py | 30 + software/lib/MQ135.py | 55 +- software/lib/SSD1306.py | 138 ++-- software/lib/TSL2591.py | 89 +-- software/lib/VEML6070.py | 104 +-- software/lib/cayenneLPP.py | 32 +- software/lib/desktop.ini | 4 + software/lib/micropyGPS.py | 73 +- software/main.py | 416 ++++------ software/settings.py | 26 +- 19 files changed, 922 insertions(+), 975 deletions(-) rename Schematic_Meet_je_leefomgeving_2022-01-26.pdf => Schematic_Meet_je_leefomgeving_2022-08-15.pdf (62%) create mode 100644 Schematic_Meet_je_leefomgeving_2022-08-15.svg create mode 100644 software/collect_gps.py create mode 100644 software/collect_sensors.py create mode 100644 software/counter.txt create mode 100644 software/lib/KP26650.py create mode 100644 software/lib/MAX4466.py create mode 100644 software/lib/desktop.ini diff --git a/.gitignore b/.gitignore index c4933ce..35e9d46 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ __pycache__/ *.py[cod] *$py.class -pymakr.conf \ No newline at end of file +pymakr.conf + +**secret.py \ No newline at end of file diff --git a/README.md b/README.md index e386611..b5d32e4 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,20 @@ # Meet je leefomgeving -In deze repository vindt u de meest recente software die op de meetkastjes gebruikt wordt. -Het prototype hiervan is ontworpen door Klaas van Laveren in samenwerking met ICR3ATE en vervolgens volledig verder ontwikkeld door Steven Boonstoppel. +In deze repository is de meest recente software te vinden die op de meetkastjes gebruikt wordt. +Het prototype hiervan is ontworpen door Klaas van Laveren in samenwerking met ICR3ATE en vervolgens volledig doorontwikkeld door Steven Boonstoppel. ## Algemene opzet -De constructie van de software is als volgt: -Bij het opstarten wordt boot.py uitgevoerd. Als allereerste wordt daar de LED en WiFi onderdrukt. -Daarna wordt main.py uitgevoerd. Vervolgens wordt de GPS module geactiveerd als op de externe knop is gedrukt door de gebruiker. Deze probeert om een locatie te krijgen; zodra dat gelukt is wordt de module weer uitgeschakeld. -ls het kastje uit slaapstand opgewaakt is, betekent dat vervolgens dat er meetwaarden van de vorige ronde aanwezig zijn. Deze worden uit RAM gehaald. Vervolgens wordt LoRa opgestart. Ook hiervan kan informatie uit RAM gehaald worden als het kastje uit slaapstand komt. Is dit niet het geval, dan probeert het kastje om het netwerk te joinen. Waarden van een eventuele vorige ronde worden nu verzonden over LoRa, waarna de LoRa informatie weer wordt opgeslagen in RAM voor de volgende sessie. -Dan starten de SDS011 en MQ135 die beiden 30 seconden tijd nodig hebben om hun metingen te stabiliseren. Terwijl deze warmdraaien, worden voor de gebruiker de waarden die zojuist verzonden zijn ook op het display weergegeven. Tijdens dit deel van de software wordt gebruik gemaakt van *lightsleep*: een efficiënte slaapstand waarbij alle informatie intact blijft. Zodra de 30 seconden voorbij zijn worden beide sensoren gemeten en in slaapstand gezet. -Vervolgens worden alle anderen sensoren gestart en worden hun relevante waarden gemeten, waarna ook zij weer in slaapstand gezet worden. Dan worden alle waarden opgeslagen in RAM, waarna het kastje in *deepsleep* gaat. De waarden van deze sessie worden pas tijdens de volgende verzonden. -Wordt tijdens deepsleep op de knop gedrukt, dan waakt het kastje op en wordt de GPS-module geactiveerd om de locatie vast te zetten. Aangezien dit slechts bij verplaatsing hoeft te gebeuren, wordt dit ook maar eenmalig uitgevoerd. En zo zijn we weer vooraan teruggekomen. +De focus van de kastjes ligt uiteraard op uithoudingsvermogen. Praktisch betekent dat dat elke sensor zo kort mogelijk actief is en de stroomsterkte geminimaliseerd is. +Met dat doel voor ogen is de volgende constructie opgezet: +De kastjes worden zes keer per uur 'wakker' uit een diepe slaapstand. Twee keer daarvan worden alle sensoren (behalve GPS) gebruikt, dus inclusief de CO2- en fijnstofsensoren. Deze laatste twee sensoren moeten beide circa 30 seconden actief zijn om een goede meetwaarde te genereren. Om te voorkomen dat deze sensoren 'voor niets' meten, worden de berichtjes bij deze twee sessies op maximale zendkracht verzonden: SF12. +De andere vier keer dat de kastjes wakker worden, staan ze zo kort mogelijk aan: de CO2- en fijnstofsensoren worden dus niet ingeschakeld (net als GPS). Omdat er niet altijd op SF12 verzonden mag worden (zie verderop), worden deze berichtjes verzonden op SF10. Het is mooi meegenomen als deze berichtjes aankomen, maar niet al te erg als dat niet gebeurt. +Eén keer per dag wordt de GPS-module geactiveerd. De kastjes zullen nauwelijks verplaatsen, dus is één keer per dag afdoende. Soms is het GPS-bereik echter vrij slecht vanwege obstakels, dus wordt er een timeout gebruikt van 120 seconden: wordt er binnen die tijd geen locatie gevonden, schakelt GPS weer uit. + +Zodra een meetcycles voltooid is toont het kastje eerst de gemeten waarden op het display waarbij gebruik gemaakt wordt van een *lightsleep* met verminderd stroomverbruik; daarna gaat het kastje in *deepsleep* waarbij nagenoeg alle componenten uitgeschakeld zijn: alleen de drukknop aan de zijkant van het kastje wordt nog gemonitord. Wordt die knop ingedrukt, dan wordt er een geforceerde meting op SF12 uitgevoerd. Dit helpt bijvoorbeeld bij het debuggen of testen van bereik, of bij bepaalde opdrachten waarbij leerlingen vaker een meting zouden willen doen dan het standaard-interval van 10 minuten. ## LoRa, The Things Network en Cayenne Low Power Payload -De data van de kastjes wordt verzonden via het LoRa (Long Range) protocol. De kastjes fungeren als *end node* en communiceren met de antennes bovenop de Veense middelbare scholen (en eventueel andere actieve binnen het bereik). Daarvoor kan gebruik gemaakt worden van verschillende data-rates met hun eigen voordelen. -De antennes en daarmee de kastjes zijn aangesloten op het The Things Network (TTN). Deze ondersteunt standaard SF9 of SF12 (respectievelijk data rates 3 en 0). Hoe lager de data rate, hoe groter het bereik. SF7 en SF8 zijn gelimiteerd tot 235 bytes, SF9 tot 128 bytes, en SF10 t/m SF12 tot 51 bytes. Helaas is het niet toegestaan om hardcoded alleen gebruik te maken van SF11 en/of SF12; apparaten die dit gebruik worden pro-actief geblokkeerd. Hoe hoger de Spreading Factor, hoe groter het bereik en hoe meer airtime en stroom het kost om de berichten te versturen. [Achtergrondinformatie](https://www.thethingsnetwork.org/forum/t/fair-use-policy-explained/1300). -Voor het versturen van de LoRa berichten wordt gebruik gemaakt van CayenneLPP. Deze library ondersteunt op een compacte en efficiënte manier een aantal datatypen en geeft via myDevices een mooie webcompanion (webhook integratie nodig op TTN) waar alle data in figuren uit te lezen is met 30 dagen dataretention. In de huidige configuratie is de payload size van de CayenneLPP berichten 43 of 54 bytes afhankelijk van wel of geen GPS (12 channel bytes, 12 sensortype bytes en 30 databytes). Dit maakt het gebruik van SF10 en hoger onmogelijk, dus houden we voor nu SF9 aan. Mogelijkheden om te verhogen naar SF10 en misschien SF11 of SF12 worden onderzocht, maar kunnen niet zomaar ingesteld worden. +De data van de kastjes wordt verzonden via het LoRa (Long Range) protocol. De kastjes fungeren als *end node* en communiceren met de antennes bovenop het Ichthus College en eventuele andere antennes in de omgeving (Scherpenzeel, Aalst, ..). Daarvoor kan gebruik gemaakt worden van verschillende data-rates met hun eigen voordelen. +De antennes en daarmee de kastjes zijn aangesloten op het The Things Network (TTN). Deze ondersteunt standaard SF7 t/m SF12 (respectievelijk data rates 5 t/m 0). Hoe lager de data rate, hoe groter het bereik. SF7 en SF8 zijn gelimiteerd tot 235 bytes, SF9 tot 128 bytes, en SF10 t/m SF12 tot 51 bytes. Helaas is het niet toegestaan om hardcoded alleen gebruik te maken van SF11 en/of SF12; apparaten die dit gebruik worden pro-actief geblokkeerd. Hoe hoger de Spreading Factor, hoe groter het bereik en hoe meer airtime en stroom het kost om de berichten te versturen. [Achtergrondinformatie](https://www.thethingsnetwork.org/forum/t/fair-use-policy-explained/1300). +Voor het versturen van de LoRa berichten wordt gebruik gemaakt van CayenneLPP. Deze library ondersteunt op een compacte en efficiënte manier een aantal datatypen en geeft via myDevices een mooie webcompanion (webhook integratie nodig op TTN) waar alle data in figuren uit te lezen is met 30 dagen dataretention. In de huidige configuratie is de payload size van de CayenneLPP berichten 30, 41 of 42 bytes afhankelijk van de actieve componenten. Zodoende is gekozen om SF10 en SF12 te gebruiken. ## Hardware Microcontroller: [Pycom LoPy4](https://pycom.io/product/lopy4/) op [Expansion Board v3(.1)](https://pycom.io/product/expansion-board-3-0/) @@ -40,19 +40,11 @@ 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 -Zie de figuur hieronder voor het stroomgebruik van de huidige versie software. De gemiddelde stroomsterkte tijdens activiteit is 105 mA; in deepsleep 1.6 mA. -De vermoedde accuduur is (ruim) drie weken, waarbij het zonnepaneel buiten beschouwing wordt gelaten. +***Verouderd: v2.0 i.t.t. huidige v2.5*** +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 [dit bestand](Schematic_Meet_je_leefomgeving_2022-01-26.pdf) voor de opbouw van het circuit in de sensorkastjes. - -## Libraries -De libraries in deze repository zijn overgenomen van bestaande Circuit- of MicroPython en vervolgens zo ver als mogelijk gestript om de import-tijden beperkt te houden. CircuitPython libraries zijn waar nodig omgebouwd om met MicroPython te kunnen gebruiken. -[SSD1306 MicroPython](https://github.com/adafruit/micropython-adafruit-ssd1306/blob/master/ssd1306.py) -[VEML6070 CircuitPython](https://github.com/adafruit/Adafruit_CircuitPython_VEML6070/blob/main/adafruit_veml6070.py) -[TSL2591 CircuitPython](https://github.com/adafruit/Adafruit_CircuitPython_TSL2591/blob/main/adafruit_tsl2591.py) -[BME680 CircuitPython](https://github.com/adafruit/Adafruit_CircuitPython_BME680/blob/main/adafruit_bme680.py) -[MQ135 MicroPython](https://github.com/rubfi/MQ135/blob/master/mq135.py) -[SDS011 MicroPython](https://github.com/alexmrqt/micropython-sds011/blob/master/sds011.py) -[GPS decoder MicroPython](https://github.com/inmcm/micropyGPS/blob/master/micropyGPS.py) +Zie de figuur voor de opbouw van het circuit in de sensorkastjes. +![Schematic v2.5 15-08-2022](Schematic_Meet_je_leefomgeving_2022-08-15.svg) \ No newline at end of file diff --git a/Schematic_Meet_je_leefomgeving_2022-01-26.pdf b/Schematic_Meet_je_leefomgeving_2022-08-15.pdf similarity index 62% rename from Schematic_Meet_je_leefomgeving_2022-01-26.pdf rename to Schematic_Meet_je_leefomgeving_2022-08-15.pdf index b332b702c0f53e5a9b9157072749daf48af62d13..9c2bf6a5fbdc7a44bfd798664230f82eb3a315ad 100644 GIT binary patch literal 44347 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 +NO.1AABBCCDD1122334455TITLE:Meet je leefomgevingREV:2.5Date:15-08-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_0X77VCC1GND2SCL3SDA4SDO5CS6TemperatuurVOC2N2907PNPC3B1E2 \ No newline at end of file diff --git a/software/collect_gps.py b/software/collect_gps.py new file mode 100644 index 0000000..8e26464 --- /dev/null +++ b/software/collect_gps.py @@ -0,0 +1,29 @@ +import machine +import time + +def run_gps(timeout = 120): + from lib.micropyGPS import MicropyGPS + + 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', 'P4'), 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 \ No newline at end of file diff --git a/software/collect_sensors.py b/software/collect_sensors.py new file mode 100644 index 0000000..c96ab76 --- /dev/null +++ b/software/collect_sensors.py @@ -0,0 +1,72 @@ +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_bus, all_sensors, t_wake = 30): + + values = {} # collection of all values, to be returned + + bme680 = BME680(i2c = i2c_bus, address = 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(): + 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_bus, address = 56) # UV sensor (0.4 / 0.0 mA) (0x38) + veml6070.wake() + time.sleep(0.2) # sensor stabilization time (required!!) + values['uv'] = veml6070.uv_raw + values['uv'] = veml6070.uv_raw # first poll may fail so do it twice + veml6070.sleep() + + tsl2591 = TSL2591(i2c = i2c_bus, address = 41) # lux sensor (0.4 / 0.0 mA) (0x29) + tsl2591.wake() + 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 ~30 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/counter.txt b/software/counter.txt new file mode 100644 index 0000000..c227083 --- /dev/null +++ b/software/counter.txt @@ -0,0 +1 @@ +0 \ No newline at end of file diff --git a/software/lib/BME680.py b/software/lib/BME680.py index b3bbf9b..6f4f2a8 100644 --- a/software/lib/BME680.py +++ b/software/lib/BME680.py @@ -1,421 +1,416 @@ -import time +# Adaptation of Pimoroni library by Steven Boonstoppel import math -from micropython import const +import time -try: - import struct -except ImportError: - import ustruct as struct +POLL_PERIOD_MS = 10 +SOFT_RESET_CMD = 0xb6 +SOFT_RESET_ADDR = 0xe0 + +ADDR_RES_HEAT_VAL_ADDR = 0x00 +ADDR_RES_HEAT_RANGE_ADDR = 0x02 +ADDR_RANGE_SW_ERR_ADDR = 0x04 + +FIELD0_ADDR = 0x1d +RES_HEAT0_ADDR = 0x5a +GAS_WAIT0_ADDR = 0x64 + +CONF_ODR_RUN_GAS_NBC_ADDR = 0x71 +CONF_OS_H_ADDR = 0x72 +CONF_T_P_MODE_ADDR = 0x74 +CONF_ODR_FILT_ADDR = 0x75 + +COEFF_ADDR1 = 0x89 +COEFF_ADDR2 = 0xe1 + +ENABLE_GAS_MEAS_LOW = 0x01 + +# Over-sampling settings +OS_NONE = 0 +OS_1X = 1 +OS_2X = 2 +OS_4X = 3 +OS_8X = 4 +OS_16X = 5 + +# IIR filter settings +FILTER_SIZE_0 = 0 +FILTER_SIZE_1 = 1 +FILTER_SIZE_3 = 2 +FILTER_SIZE_7 = 3 +FILTER_SIZE_15 = 4 +FILTER_SIZE_31 = 5 +FILTER_SIZE_63 = 6 +FILTER_SIZE_127 = 7 + +# Power mode settings +SLEEP_MODE = 0 +FORCED_MODE = 1 + +# Mask definitions +NBCONV_MSK = 0X0F +FILTER_MSK = 0X1C +OST_MSK = 0XE0 +OSP_MSK = 0X1C +OSH_MSK = 0X07 +HCTRL_MSK = 0x08 +RUN_GAS_MSK = 0x30 +MODE_MSK = 0x03 +RHRANGE_MSK = 0x30 +RSERROR_MSK = 0xf0 +NEW_DATA_MSK = 0x80 +GAS_INDEX_MSK = 0x0f +GAS_RANGE_MSK = 0x0f +GASM_VALID_MSK = 0x20 +HEAT_STAB_MSK = 0x10 +BIT_H1_DATA_MSK = 0x0F + +# Bit position definitions for sensor settings +FILTER_POS = 2 +OST_POS = 5 +OSP_POS = 2 +OSH_POS = 0 +RUN_GAS_POS = 4 +MODE_POS = 0 +NBCONV_POS = 0 + +# Look up tables for the possible gas range values +lookupTable1 = [2147483647, 2147483647, 2147483647, 2147483647, + 2147483647, 2126008810, 2147483647, 2130303777, 2147483647, + 2147483647, 2143188679, 2136746228, 2147483647, 2126008810, + 2147483647, 2147483647] + +lookupTable2 = [4096000000, 2048000000, 1024000000, 512000000, + 255744255, 127110228, 64000000, 32258064, + 16016016, 8000000, 4000000, 2000000, + 1000000, 500000, 250000, 125000] + +def bytes_to_word(msb, lsb, bits = 16, signed = True): + """Convert a most and least significant byte into a word.""" + word = (msb << 8) | lsb + if signed: + word = twos_comp(word, bits) + return word + +def twos_comp(val, bits = 8): + """Convert two bytes into a two's compliment signed word.""" + if val & (1 << (bits - 1)) != 0: + val = val - (1 << bits) + return val + +class CalibrationData: + """Structure for storing BME680 calibration data.""" + + def set_from_array(self, calibration): + # 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]) + + # 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] + + # 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]) + + # 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]) + + def set_other(self, heat_range, heat_value, sw_error): + """Set other values.""" + self.res_heat_range = (heat_range & RHRANGE_MSK) // 16 + self.res_heat_val = heat_value + self.range_sw_err = (sw_error & RSERROR_MSK) // 16 + +class BME680: + + def __init__(self, i2c, address): + + self.address = address + self.i2c = i2c -_BME680_CHIPID = const(0x61) + self.power_mode = None + self.calibration = CalibrationData() + + self.soft_reset() + self.set_power_mode(SLEEP_MODE) + + self._get_calibration_data() + + self.set_humidity_oversample(OS_8X) + self.set_pressure_oversample(OS_8X) + self.set_temperature_oversample(OS_8X) + self.set_filter(FILTER_SIZE_3) + self.set_gas_status(ENABLE_GAS_MEAS_LOW) + self.set_temp_offset(0) + self.get_sensor_data() + + def _get_calibration_data(self): + """Retrieve the sensor calibration data and store it in .calibration_data.""" + calibration = self._read(COEFF_ADDR1, 25) + calibration += self._read(COEFF_ADDR2, 16) + + heat_range = self._read(ADDR_RES_HEAT_RANGE_ADDR, 1) + heat_value = twos_comp(self._read(ADDR_RES_HEAT_VAL_ADDR, 1)) + sw_error = twos_comp(self._read(ADDR_RANGE_SW_ERR_ADDR, 1)) + + self.calibration.set_from_array(calibration) + self.calibration.set_other(heat_range, heat_value, sw_error) + + def soft_reset(self): + """Trigger a soft reset.""" + self._write(SOFT_RESET_ADDR, SOFT_RESET_CMD) + 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 + """ + if value == 0: + self.offset_temp_in_t_fine = 0 + else: + self.offset_temp_in_t_fine = int(math.copysign((((int(abs(value) * 100)) << 8) - 128) / 5, value)) + + def set_humidity_oversample(self, value): + self._set_bits(CONF_OS_H_ADDR, OSH_MSK, OSH_POS, value) + + def set_pressure_oversample(self, value): + self._set_bits(CONF_T_P_MODE_ADDR, OSP_MSK, OSP_POS, value) + + 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. + 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) + + def get_filter(self): + return (self._read(CONF_ODR_FILT_ADDR, 1) & FILTER_MSK) >> FILTER_POS + + 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 + """ + self._set_bits(CONF_ODR_RUN_GAS_NBC_ADDR, NBCONV_MSK, NBCONV_POS, value) + + def set_gas_status(self, value): + """Enable/disable gas sensor.""" + 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 + """ + 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) + """ + temp = self._calc_heater_duration(value) + self._write(GAS_WAIT0_ADDR + nb_profile, temp) -_BME680_REG_CHIPID = const(0xD0) -_BME680_BME680_COEFF_ADDR1 = const(0x89) -_BME680_BME680_COEFF_ADDR2 = const(0xE1) -_BME680_BME680_RES_HEAT_0 = const(0x5A) -_BME680_BME680_GAS_WAIT_0 = const(0x64) + def set_power_mode(self, value, blocking=True): + """Set power mode.""" + if value not in (SLEEP_MODE, FORCED_MODE): + raise ValueError('Power mode should be one of SLEEP_MODE or FORCED_MODE') -_BME680_REG_SOFTRESET = const(0xE0) -_BME680_REG_CTRL_GAS = const(0x71) -_BME680_REG_CTRL_HUM = const(0x72) -_BME680_REG_CTRL_MEAS = const(0x74) -_BME680_REG_CONFIG = const(0x75) + self.power_mode = value -_BME680_REG_MEAS_STATUS = const(0x1D) + self._set_bits(CONF_T_P_MODE_ADDR, MODE_MSK, MODE_POS, value) -_BME680_SAMPLERATES = (0, 1, 2, 4, 8, 16) -_BME680_FILTERSIZES = (0, 1, 3, 7, 15, 31, 63, 127) + while blocking and self.get_power_mode() != self.power_mode: + time.sleep(POLL_PERIOD_MS / 1000.0) -_BME680_RUNGAS = const(0x10) + def get_power_mode(self): + """Get power mode.""" + self.power_mode = self._read(CONF_T_P_MODE_ADDR, 1) + return self.power_mode -CONF_T_P_MODE_ADDR = _BME680_REG_CTRL_MEAS -MODE_MSK = 0x03 -MODE_POS = 0 -POLL_PERIOD_MS = 10 + def get_sensor_data(self): + """Get sensor data""" + self.set_power_mode(FORCED_MODE) -_LOOKUP_TABLE_1 = ( - 2147483647.0, - 2147483647.0, - 2147483647.0, - 2147483647.0, - 2147483647.0, - 2126008810.0, - 2147483647.0, - 2130303777.0, - 2147483647.0, - 2147483647.0, - 2143188679.0, - 2136746228.0, - 2147483647.0, - 2126008810.0, - 2147483647.0, - 2147483647.0, -) - -_LOOKUP_TABLE_2 = ( - 4096000000.0, - 2048000000.0, - 1024000000.0, - 512000000.0, - 255744255.0, - 127110228.0, - 64000000.0, - 32258064.0, - 16016016.0, - 8000000.0, - 4000000.0, - 2000000.0, - 1000000.0, - 500000.0, - 250000.0, - 125000.0, -) - - -def _read24(arr): - """Parse an unsigned 24-bit value as a floating point and return it.""" - ret = 0.0 - for b in arr: - ret *= 256.0 - ret += float(b & 0xFF) - return ret - -class I2CDevice: - - def __init__(self, i2c, device_address, probe=True): + for _ in range(10): + status = self._read(FIELD0_ADDR, 1) - self.i2c = i2c - self.device_address = device_address + if (status & NEW_DATA_MSK) == 0: + time.sleep(POLL_PERIOD_MS / 1000.0) + continue - if probe: - self.__probe_for_device() + regs = self._read(FIELD0_ADDR, 17) - def readinto(self, buf): - self.i2c.readfrom_into(self.device_address, buf) + self.status = regs[0] & NEW_DATA_MSK + # Contains the nb_profile used to obtain the current measurement + self.gas_index = regs[0] & GAS_INDEX_MSK + self.meas_index = regs[1] - def write(self, buf): - self.i2c.writeto(self.device_address, buf) + self.adc_pres = (regs[2] << 12) | (regs[3] << 4) | (regs[4] >> 4) + self.adc_temp = (regs[5] << 12) | (regs[6] << 4) | (regs[7] >> 4) + self.adc_hum = (regs[8] << 8) | regs[9] + self.adc_gas_res_low = (regs[13] << 2) | (regs[14] >> 6) + self.gas_range_l = regs[14] & GAS_RANGE_MSK - def __enter__(self): - return self + self.status |= regs[14] & GASM_VALID_MSK + self.status |= regs[14] & HEAT_STAB_MSK - def __exit__(self, exc_type, exc_val, exc_tb): - return False + self.heat_stable = (self.status & HEAT_STAB_MSK) > 0 - def __probe_for_device(self): - try: - self.i2c.writeto(self.device_address, b"") - except OSError: - # some OS's dont like writing an empty bytesting... - # Retry by reading a byte - try: - result = bytearray(1) - self.i2c.readfrom_into(self.device_address, result) - except OSError: - # pylint: disable=raise-missing-from - raise ValueError("No I2C device at address: 0x%x" % self.device_address) - # pylint: enable=raise-missing-from - -class Adafruit_BME680: - """Driver from BME680 air quality sensor - - :param int refresh_rate: Maximum number of readings per second. Faster property reads - will be from the previous reading.""" - - def __init__(self, *, refresh_rate=10): - """Check the BME680 was found, read the coefficients and enable the sensor for continuous - reads.""" - self._write(_BME680_REG_SOFTRESET, [0xB6]) - time.sleep(0.05) - - # Check device ID. - chip_id = self._read_byte(_BME680_REG_CHIPID) - if chip_id != _BME680_CHIPID: - raise RuntimeError("Failed to find BME680! Chip ID 0x%x" % chip_id) - - self._read_calibration() - - # set up heater - self._write(_BME680_BME680_RES_HEAT_0, [0x73]) - self._write(_BME680_BME680_GAS_WAIT_0, [0x65]) - - self.sea_level_pressure = 1013.25 - """Pressure in hectoPascals at sea level. Used to calibrate :attr:`altitude`.""" - - # Default oversampling and filter register values. - self._pressure_oversample = 0b011 - self._temp_oversample = 0b100 - self._humidity_oversample = 0b010 - self._filter = 0b010 - - self._adc_pres = None - self._adc_temp = None - self._adc_hum = None - self._adc_gas = None - self._gas_range = None - self._t_fine = None - - self._last_reading = 0 - self._min_refresh_time = 1 / refresh_rate + self.ambient_temperature = self.temperature - @property - def pressure_oversample(self): - """The oversampling for pressure sensor""" - return _BME680_SAMPLERATES[self._pressure_oversample] - - @pressure_oversample.setter - def pressure_oversample(self, sample_rate): - if sample_rate in _BME680_SAMPLERATES: - self._pressure_oversample = _BME680_SAMPLERATES.index(sample_rate) - else: - raise RuntimeError("Invalid oversample") + return True - @property - def humidity_oversample(self): - """The oversampling for humidity sensor""" - return _BME680_SAMPLERATES[self._humidity_oversample] - - @humidity_oversample.setter - def humidity_oversample(self, sample_rate): - if sample_rate in _BME680_SAMPLERATES: - self._humidity_oversample = _BME680_SAMPLERATES.index(sample_rate) - else: - raise RuntimeError("Invalid oversample") + return False - @property - def temperature_oversample(self): - """The oversampling for temperature sensor""" - return _BME680_SAMPLERATES[self._temp_oversample] - - @temperature_oversample.setter - def temperature_oversample(self, sample_rate): - if sample_rate in _BME680_SAMPLERATES: - self._temp_oversample = _BME680_SAMPLERATES.index(sample_rate) - else: - raise RuntimeError("Invalid oversample") + def _set_bits(self, register, mask, position, value): + """Mask out and set one or more bits in a register.""" + temp = self._read(register, 1) + temp &= ~mask + temp |= value << position + self._write(register, temp) - @property - def filter_size(self): - """The filter size for the built in IIR filter""" - return _BME680_FILTERSIZES[self._filter] - - @filter_size.setter - def filter_size(self, size): - if size in _BME680_FILTERSIZES: - self._filter = _BME680_FILTERSIZES.index(size) - else: - raise RuntimeError("Invalid size") + def _write(self, register, value): + buffer = bytearray(2) + buffer[0] = register + buffer[1] = value + self.i2c.writeto(self.address, buffer) + + def _read(self, register, length): + self.i2c.writeto(self.address, bytes([register & 0xFF])) + result = self.i2c.readfrom(self.address, length) + if length <= 2: + return int.from_bytes(result, 'little') + return result @property def temperature(self): - """The compensated temperature in degrees Celsius.""" - self._perform_reading() - calc_temp = ((self._t_fine * 5) + 128) / 256 + """Convert the raw temperature to degrees C using calibration_data.""" + var1 = (self.adc_temp >> 3) - (self.calibration.par_t1 << 1) + var2 = (var1 * self.calibration.par_t2) >> 11 + var3 = ((var1 >> 1) * (var1 >> 1)) >> 12 + var3 = ((var3) * (self.calibration.par_t3 << 4)) >> 14 + + # Save teperature data for pressure calculations + self.calibration.t_fine = (var2 + var3) + self.offset_temp_in_t_fine + calc_temp = (((self.calibration.t_fine * 5) + 128) >> 8) + return calc_temp / 100 @property def pressure(self): - """The barometric pressure in hectoPascals""" - self._perform_reading() - var1 = (self._t_fine / 2) - 64000 - var2 = ((var1 / 4) * (var1 / 4)) / 2048 - var2 = (var2 * self._pressure_calibration[5]) / 4 - var2 = var2 + (var1 * self._pressure_calibration[4] * 2) - var2 = (var2 / 4) + (self._pressure_calibration[3] * 65536) - var1 = ((((var1 / 4) * (var1 / 4)) / 8192) * (self._pressure_calibration[2] * 32)/ 8) + ((self._pressure_calibration[1] * var1) / 2) - var1 = var1 / 262144 - var1 = ((32768 + var1) * self._pressure_calibration[0]) / 32768 - calc_pres = 1048576 - self._adc_pres - calc_pres = (calc_pres - (var2 / 4096)) * 3125 - calc_pres = (calc_pres / var1) * 2 - var1 = (self._pressure_calibration[8] * (((calc_pres / 8) * (calc_pres / 8)) / 8192)) / 4096 - var2 = ((calc_pres / 4) * self._pressure_calibration[7]) / 8192 - var3 = (((calc_pres / 256) ** 3) * self._pressure_calibration[9]) / 131072 - calc_pres += (var1 + var2 + var3 + (self._pressure_calibration[6] * 128)) / 16 - return calc_pres / 100 + """Convert the raw pressure using calibration data.""" + var1 = ((self.calibration.t_fine) >> 1) - 64000 + var2 = ((((var1 >> 2) * (var1 >> 2)) >> 11) * self.calibration.par_p6) >> 2 + var2 = var2 + ((var1 * self.calibration.par_p5) << 1) + var2 = (var2 >> 2) + (self.calibration.par_p4 << 16) + var1 = (((((var1 >> 2) * (var1 >> 2)) >> 13) * + ((self.calibration.par_p3 << 5)) >> 3) + + ((self.calibration.par_p2 * var1) >> 1)) + var1 = var1 >> 18 + + var1 = ((32768 + var1) * self.calibration.par_p1) >> 15 + calc_pressure = 1048576 - self.adc_pres + calc_pressure = ((calc_pressure - (var2 >> 12)) * (3125)) + + if calc_pressure >= (1 << 31): + calc_pressure = ((calc_pressure // var1) << 1) + else: + calc_pressure = ((calc_pressure << 1) // var1) - @property - def relative_humidity(self): - """The relative humidity in RH %""" - return self.humidity + var1 = (self.calibration.par_p9 * (((calc_pressure >> 3) * + (calc_pressure >> 3)) >> 13)) >> 12 + var2 = ((calc_pressure >> 2) * + self.calibration.par_p8) >> 13 + var3 = ((calc_pressure >> 8) * (calc_pressure >> 8) * + (calc_pressure >> 8) * + self.calibration.par_p10) >> 17 + + calc_pressure = (calc_pressure) + ((var1 + var2 + var3 + + (self.calibration.par_p7 << 7)) >> 4) + + return calc_pressure / 100 @property def humidity(self): - """The relative humidity in RH %""" - self._perform_reading() - temp_scaled = ((self._t_fine * 5) + 128) / 256 - var1 = (self._adc_hum - (self._humidity_calibration[0] * 16)) - ( - (temp_scaled * self._humidity_calibration[2]) / 200) - var2 = (self._humidity_calibration[1] - * (((temp_scaled * self._humidity_calibration[3]) / 100) - + (((temp_scaled * ((temp_scaled * self._humidity_calibration[4]) / 100)) / 64) / 100) + 16384)) / 1024 + """Convert the raw humidity using calibration data.""" + temp_scaled = ((self.calibration.t_fine * 5) + 128) >> 8 + var1 = (self.adc_hum - ((self.calibration.par_h1 * 16))) -\ + (((temp_scaled * self.calibration.par_h3) // (100)) >> 1) + var2 = (self.calibration.par_h2 * + (((temp_scaled * self.calibration.par_h4) // (100)) + + (((temp_scaled * ((temp_scaled * self.calibration.par_h5) // (100))) >> 6) // + (100)) + (1 * 16384))) >> 10 var3 = var1 * var2 - var4 = self._humidity_calibration[5] * 128 - var4 = (var4 + ((temp_scaled * self._humidity_calibration[6]) / 100)) / 16 - var5 = ((var3 / 16384) * (var3 / 16384)) / 1024 - var6 = (var4 * var5) / 2 - calc_hum = (((var3 + var6) / 1024) * 1000) / 4096 - calc_hum /= 1000 # get back to RH - - calc_hum = min(calc_hum, 100) - calc_hum = max(calc_hum, 0) - return calc_hum + var4 = self.calibration.par_h6 << 7 + var4 = ((var4) + ((temp_scaled * self.calibration.par_h7) // (100))) >> 4 + var5 = ((var3 >> 14) * (var3 >> 14)) >> 10 + var6 = (var4 * var5) >> 1 + calc_hum = (((var3 + var6) >> 10) * (1000)) >> 12 - @property - def resistance(self): - """The gas resistance in ohms""" - self._perform_reading() - var1 = ((1340 + (5 * self._sw_err)) * (_LOOKUP_TABLE_1[self._gas_range])) / 65536 - var2 = ((self._adc_gas * 32768) - 16777216) + var1 - var3 = (_LOOKUP_TABLE_2[self._gas_range] * var1) / 512 - calc_gas_res = (var3 + (var2 / 2)) / var2 - return int(calc_gas_res) + return min(max(calc_hum, 0), 100000) / 1000 @property def gas(self): - #(self, temp, hum_rel, tVOC, hum_abs_base, counter, baseline, polls) - #"""The TVOC in ppb based on - #https://github.com/juergs/ESPEasy_BME680_TVOC/blob/master/lib/js_bme680.cpp""" - #hum_abs = self.absolute_humidity(temp, hum_rel) - #res = sum([self.resistance for _ in range(polls)])/polls # get average resistance from multiple readings - #if hum_abs_base == 0 or hum_abs < hum_abs_base: - # hum_abs_base = hum_abs - #else: - # hum_abs_base += 0.2 * (hum_abs - hum_abs_base) - #counter, baseline = self.auto_baseline_correction(res, hum_abs, counter, baseline) - #ratio = baseline / (res * hum_abs_base * 7) - #tVOC_new = 1250 * math.log(ratio) + 125 - #tVOC = tVOC + 0.3*(tVOC_new - tVOC) - #print(tVOC, tVOC_new, temp, hum_rel, res, hum_abs, baseline, counter, hum_abs_base, ratio) - #return int(tVOC), hum_abs_base, counter, baseline - return self.resistance - - def absolute_humidity(self, temp, hum_rel): - """The absolute humidity in weird but correct format""" - sdd = 6.1078 * pow(10, (7.5*temp) / (237.3 + temp)) - dd = hum_rel / 100 * sdd - return 216.687*dd / (273.15 + temp) - - def auto_baseline_correction(self, res, hum_abs, counter, baseline): - """Helper function for TVOC calculation""" - base_new = res * hum_abs * 7 - - if counter > 5: - baseline = base_new - counter = 0 - elif base_new > baseline: - counter += 1 - else: - counter = 0 - return counter, baseline - - def _perform_reading(self): - """Perform a single-shot reading from the sensor and fill internal data structure for - calculations""" - if time.ticks_ms() / 1000 - self._last_reading < self._min_refresh_time: - return - - # set filter - self._write(_BME680_REG_CONFIG, [self._filter << 2]) - # turn on temp oversample & pressure oversample - self._write( - _BME680_REG_CTRL_MEAS, - [(self._temp_oversample << 5) | (self._pressure_oversample << 2)], - ) - # turn on humidity oversample - self._write(_BME680_REG_CTRL_HUM, [self._humidity_oversample]) - # gas measurements enabled - self._write(_BME680_REG_CTRL_GAS, [_BME680_RUNGAS]) - - ctrl = self._read_byte(_BME680_REG_CTRL_MEAS) - ctrl = (ctrl & 0xFC) | 0x01 # enable single shot! - self._write(_BME680_REG_CTRL_MEAS, [ctrl]) - new_data = False - while not new_data: - data = self._read(_BME680_REG_MEAS_STATUS, 15) - new_data = data[0] & 0x80 != 0 - time.sleep(0.05) - self._last_reading = time.ticks_ms() / 1000 - - self._adc_pres = _read24(data[2:5]) / 16 - self._adc_temp = _read24(data[5:8]) / 16 - self._adc_hum = struct.unpack(">H", bytes(data[8:10]))[0] - self._adc_gas = int(struct.unpack(">H", bytes(data[13:15]))[0] / 64) - self._gas_range = data[14] & 0x0F - - var1 = (self._adc_temp / 8) - (self._temp_calibration[0] * 2) - var2 = (var1 * self._temp_calibration[1]) / 2048 - var3 = ((var1 / 2) * (var1 / 2)) / 4096 - var3 = (var3 * self._temp_calibration[2] * 16) / 16384 - self._t_fine = int(var2 + var3) - - def _read_calibration(self): - """Read & save the calibration coefficients""" - coeff = self._read(_BME680_BME680_COEFF_ADDR1, 25) - coeff += self._read(_BME680_BME680_COEFF_ADDR2, 16) - - coeff = list(struct.unpack("> 16 + var2 = (((self.adc_gas_res_low << 15) - (16777216)) + var1) + var3 = ((lookupTable2[self.gas_range_l] * var1) >> 9) + calc_gas_res = ((var3 + (var2 >> 1)) / var2) - self.power_mode = value + if calc_gas_res < 0: + calc_gas_res = (1 << 32) + calc_gas_res - self._set_bits(CONF_T_P_MODE_ADDR, MODE_MSK, MODE_POS, value) + return calc_gas_res - while blocking and self.get_power_mode() != self.power_mode: - time.sleep(POLL_PERIOD_MS / 1000.0) + def _calc_heater_resistance(self, temperature): + """Convert raw heater resistance using calibration data.""" + temperature = min(max(temperature, 200), 400) - def get_power_mode(self): - """Get power mode.""" - self.power_mode = self._read(CONF_T_P_MODE_ADDR, 1) - return self.power_mode + var1 = ((self.ambient_temperature * self.calibration.par_gh3) / 1000) * 256 + var2 = (self.calibration.par_gh1 + 784) * (((((self.calibration.par_gh2 + 154009) * temperature * 5) / 100) + 3276800) / 10) + var3 = var1 + (var2 / 2) + var4 = (var3 / (self.calibration.res_heat_range + 4)) + var5 = (131 * self.calibration.res_heat_val) + 65536 + heatr_res_x100 = (((var4 / var5) - 250) * 34) + heatr_res = ((heatr_res_x100 + 50) / 100) - def _set_bits(self, register, mask, position, value): - """Mask out and set one or more bits in a register.""" - temp = self._read(register, 1)[0] - temp &= ~mask - temp |= value << position - self._write(register, [temp]) + return heatr_res -class BME680(Adafruit_BME680): - def __init__(self, i2c, address=0x77, *, refresh_rate=10): - self._i2c = I2CDevice(i2c, address) - super().__init__(refresh_rate=refresh_rate) + def _calc_heater_duration(self, duration): + """Calculate correct value for heater duration setting from milliseconds.""" + if duration < 0xfc0: + factor = 0 - def _read(self, register, length): - with self._i2c as i2c: - i2c.write(bytes([register & 0xFF])) - result = bytearray(length) - i2c.readinto(result) - return result - - def _write(self, register, values): - with self._i2c as i2c: - buffer = bytearray(2 * len(values)) - for i, value in enumerate(values): - buffer[2 * i] = register + i - buffer[2 * i + 1] = value - i2c.write(buffer) \ No newline at end of file + while duration > 0x3f: + duration /= 4 + factor += 1 + + return int(duration + (factor * 64)) + + return 0xff \ No newline at end of file diff --git a/software/lib/KP26650.py b/software/lib/KP26650.py new file mode 100644 index 0000000..0b7f282 --- /dev/null +++ b/software/lib/KP26650.py @@ -0,0 +1,28 @@ +# Created by Steven Boonstoppel +import machine +import time + +class KP26650: + def __init__(self, pin, duration = 50, ratio = 2): + 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 + self.ratio = ratio + + def get_voltage(self): + # take 'n' samples over 'duration' time to find average voltage across divider + val = 0 + n = 0 + t1 = time.ticks_ms() + while time.ticks_ms() - t1 < self.duration: + 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 + + @staticmethod + def get_percentage( voltage, lb = 3.0, ub = 4.0): + # 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 diff --git a/software/lib/MAX4466.py b/software/lib/MAX4466.py new file mode 100644 index 0000000..1be53c0 --- /dev/null +++ b/software/lib/MAX4466.py @@ -0,0 +1,30 @@ +# Created by Steven Boonstoppel +import machine +import time +import math + +class MAX4466: + def __init__(self, pin, duration = 500): + 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 + self.sens_dB = 44 # factory sensitivity -44 dB re 1V/Pa (https://cdn-shop.adafruit.com/datasheets/CMA-4544PF-W.pdf) + self.sens_v = 0.00631 # equivalent of above in V/Pa + self.gain = 25 # amp gain (fully anticlockwise) + + def get_volume(self): + # take large number of samples over 'duration' time to find largest sound pressure (>13000 samples per second measured) + sigMin = 4095 + sigMax = 0 + t1 = time.ticks_ms() + while time.ticks_ms() - t1 < self.duration: + val = self.adc.voltage() # get current voltage + sigMin = min(sigMin, val) # save lowest peak + sigMax = max(sigMax, val) # save highest peak + + # calculation from https://forums.adafruit.com/viewtopic.php?f=8&t=100462 + peakToPeak = sigMax - sigMin + volts = peakToPeak / 1000 * 0.707 # divide to get voltage, calculate RMS voltage + dB = 20 * (math.log(volts / self.sens_v) / math.log(10)) # this is pure physics (plus log_e conversion to log_10) + dBspl = 1.5 * dB + 94 - self.sens_dB - self.gain - 15 # 94 is default offset, and 1.5 and -15 are abnormal physics but yield far better results + return dBspl \ No newline at end of file diff --git a/software/lib/MQ135.py b/software/lib/MQ135.py index dfa35e5..64ac67c 100644 --- a/software/lib/MQ135.py +++ b/software/lib/MQ135.py @@ -1,14 +1,12 @@ -import math -from machine import ADC +import machine +import time class MQ135(object): - """ Class for dealing with MQ13 Gas Sensors """ - # The load resistance on the board - RLOAD = 10.0 - # Calibration resistance at atmospheric CO2 level - RZERO = 76.63 + 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 + PARA = 116.6020682 PARB = 2.769034857 # Parameters to model temperature and humidity dependence @@ -19,13 +17,13 @@ class MQ135(object): CORE = -0.003333333 CORF = -0.001923077 CORG = 1.130128205 + + ATMOCO2 = 397.13 # Atmospheric CO2 level for calibration purposes - # Atmospheric CO2 level for calibration purposes - ATMOCO2 = 397.13 - - - def __init__(self, pin): - self.pin = pin + 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 @@ -33,21 +31,25 @@ def get_correction_factor(self, temperature, humidity): 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 - - return self.CORE * temperature + self.CORF * humidity + self.CORG + 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""" - adc = ADC() - co2_pin = adc.channel(pin = self.pin, attn=3) - value = co2_pin.value()/4 - if value == 0: + 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 (1023./value - 1.) * self.RLOAD + return (4095./avg_val - 1.) * self.RLOAD def get_corrected_resistance(self, temperature, humidity): """Gets the resistance of the sensor corrected for temperature/humidity""" @@ -58,16 +60,15 @@ def get_corrected_ppm(self, temperature, humidity): corrected for temperature/humidity""" g_cr = self.get_corrected_resistance(temperature, humidity) try: - val = self.PARA * math.pow((g_cr / self.RZERO), -self.PARB) + return self.PARA * ((g_cr / self.RZERO)**-self.PARB) except: - val = 0 - return val + return 0 def get_rzero(self): """Returns the resistance RZero of the sensor (in kOhms) for calibration purposes""" - return self.get_resistance() * math.pow((self.ATMOCO2/self.PARA), (1./self.PARB)) + 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) * math.pow((self.ATMOCO2/self.PARA), (1./self.PARB)) \ No newline at end of file + 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/SSD1306.py b/software/lib/SSD1306.py index 40eedb0..3464155 100644 --- a/software/lib/SSD1306.py +++ b/software/lib/SSD1306.py @@ -1,102 +1,112 @@ -# MicroPython SSD1306 OLED driver, I2C interface +# Adaptation of CircuitPython library by Steven Boonstoppel +from micropython import const import framebuf # register definitions -SET_CONTRAST = const(0x81) -SET_ENTIRE_ON = const(0xa4) -SET_NORM_INV = const(0xa6) -SET_DISP = const(0xae) -SET_MEM_ADDR = const(0x20) -SET_COL_ADDR = const(0x21) -SET_PAGE_ADDR = const(0x22) +SET_CONTRAST = const(0x81) +SET_ENTIRE_ON = const(0xA4) +SET_NORM_INV = const(0xA6) +SET_DISP = const(0xAE) +SET_MEM_ADDR = const(0x20) +SET_COL_ADDR = const(0x21) +SET_PAGE_ADDR = const(0x22) SET_DISP_START_LINE = const(0x40) -SET_SEG_REMAP = const(0xa0) -SET_MUX_RATIO = const(0xa8) -SET_COM_OUT_DIR = const(0xc0) -SET_DISP_OFFSET = const(0xd3) -SET_COM_PIN_CFG = const(0xda) -SET_DISP_CLK_DIV = const(0xd5) -SET_PRECHARGE = const(0xd9) -SET_VCOM_DESEL = const(0xdb) -SET_CHARGE_PUMP = const(0x8d) +SET_SEG_REMAP = const(0xA0) +SET_MUX_RATIO = const(0xA8) +SET_IREF_SELECT = const(0xAD) +SET_COM_OUT_DIR = const(0xC0) +SET_DISP_OFFSET = const(0xD3) +SET_COM_PIN_CFG = const(0xDA) +SET_DISP_CLK_DIV = const(0xD5) +SET_PRECHARGE = const(0xD9) +SET_VCOM_DESEL = const(0xDB) +SET_CHARGE_PUMP = const(0x8D) -class SSD1306_DRIVER: - def __init__(self, width, height, external_vcc): +class SSD1306: + def __init__(self, width, height, i2c, address = 0x3C): + self.i2c = i2c + self.address = address + self.buffer = bytearray(((height // 8) * width) + 1) + self.buffer[0] = 0x40 # Set first byte of data buffer to Co=0, D/C=1 + self.framebuf = framebuf.FrameBuffer1(memoryview(self.buffer)[1:], width, height) + self.width = width self.height = height - self.external_vcc = external_vcc self.pages = self.height // 8 + self._power = False self.poweron() self.init_display() - def init_display(self): + def write_cmd(self, cmd) -> None: + buffer = bytearray(2) + buffer[0] = 0x80 + buffer[1] = cmd + self.i2c.writeto(self.address, buffer) + + def write_framebuf(self) -> None: + self.i2c.writeto(self.address, self.buffer) + + def init_display(self) -> None: for cmd in ( - SET_DISP | 0x00, # off + SET_DISP | 0x00, # off # address setting - SET_MEM_ADDR, 0x00, # horizontal + SET_MEM_ADDR, 0x00, # Horizontal Addressing Mode # resolution and layout SET_DISP_START_LINE | 0x00, - SET_SEG_REMAP | 0x01, # column addr 127 mapped to SEG0 + SET_SEG_REMAP | 0x01, # column addr 127 mapped to SEG0 SET_MUX_RATIO, self.height - 1, - SET_COM_OUT_DIR | 0x08, # scan from COM[N] to COM0 + SET_COM_OUT_DIR | 0x08, # scan from COM[N] to COM0 SET_DISP_OFFSET, 0x00, - SET_COM_PIN_CFG, 0x02 if self.height == 32 else 0x12, + SET_COM_PIN_CFG, 0x02 if self.width > 2 * self.height else 0x12, # timing and driving scheme SET_DISP_CLK_DIV, 0x80, - SET_PRECHARGE, 0x22 if self.external_vcc else 0xf1, - SET_VCOM_DESEL, 0x30, # 0.83*Vcc + SET_PRECHARGE, 0xF1, + SET_VCOM_DESEL, 0x30, # 0.83*Vcc # display - SET_CONTRAST, 0xff, # maximum - SET_ENTIRE_ON, # output follows RAM contents - SET_NORM_INV, # not inverted + SET_CONTRAST, 0xFF, # maximum + SET_ENTIRE_ON, # output follows RAM contents + SET_NORM_INV, # not inverted + SET_IREF_SELECT, 0x30, # enable internal IREF during display on # charge pump - SET_CHARGE_PUMP, 0x10 if self.external_vcc else 0x14, - SET_DISP | 0x01): # on + SET_CHARGE_PUMP, 0x14, + SET_DISP | 0x01): # display on self.write_cmd(cmd) self.fill(0) self.show() - def poweron(self): + def poweron(self) -> None: + "Reset device and turn on the display." self.write_cmd(SET_DISP | 0x01) + self._power = True - def poweroff(self): + def poweroff(self) -> None: + """Turn off the display (nothing visible)""" self.write_cmd(SET_DISP | 0x00) + self._power = False + + @property + def power(self) -> bool: + """True if the display is currently powered on, otherwise False""" + return self._power + + def contrast(self, contrast: int) -> None: + """Adjust the contrast""" + self.write_cmd(SET_CONTRAST) + self.write_cmd(contrast) - def show(self): - x0 = 0 - x1 = self.width - 1 - if self.width == 64: - # displays with width of 64 pixels are shifted by 32 - x0 += 32 - x1 += 32 + def show(self) -> None: + xpos0 = 0 + xpos1 = self.width - 1 self.write_cmd(SET_COL_ADDR) - self.write_cmd(x0) - self.write_cmd(x1) + self.write_cmd(xpos0) + self.write_cmd(xpos1) self.write_cmd(SET_PAGE_ADDR) self.write_cmd(0) self.write_cmd(self.pages - 1) self.write_framebuf() - + def fill(self, col): self.framebuf.fill(col) def text(self, string, x, y, col=1): - self.framebuf.text(string, x, y, col) - -class SSD1306(SSD1306_DRIVER): - def __init__(self, width, height, i2c, addr=0x3c, external_vcc=False): - self.i2c = i2c - self.addr = addr - self.temp = bytearray(2) - self.buffer = bytearray(((height // 8) * width) + 1) - self.buffer[0] = 0x40 # Set first byte of data buffer to Co=0, D/C=1 - self.framebuf = framebuf.FrameBuffer1(memoryview(self.buffer)[1:], width, height) - super().__init__(width, height, external_vcc) - - def write_cmd(self, cmd): - self.temp[0] = 0x80 # Co=1, D/C#=0 - self.temp[1] = cmd - self.i2c.writeto(self.addr, self.temp) - - def write_framebuf(self): - self.i2c.writeto(self.addr, self.buffer) + self.framebuf.text(string, x, y, col) \ No newline at end of file diff --git a/software/lib/TSL2591.py b/software/lib/TSL2591.py index f2c2c67..d22c504 100644 --- a/software/lib/TSL2591.py +++ b/software/lib/TSL2591.py @@ -1,4 +1,4 @@ -# tsl2591 lux sensor interface by jfisher adapted by Icr3ate +# TSL2591 lux sensor interface modified by Steven Boonstoppel import time VISIBLE = 2 @@ -46,68 +46,56 @@ GAIN_HIGH = 0x20 GAIN_MAX = 0x30 -def _bytes_to_int(data): - return data[0] + (data[1]<<8) - -class SMBusEmulator: - __slots__ = ('i2c',) - def __init__(self, i2c_bus): - self.i2c = i2c_bus - - def write_byte_data(self, addr, cmd, val): - buf = bytes([cmd, val]) - self.i2c.writeto(addr, buf) - - def read_word_data(self, addr, cmd): - assert cmd < 256 - buf = bytes([cmd]) - self.i2c.writeto(addr, buf) - data = self.i2c.readfrom(addr, 4) - return _bytes_to_int(data) - class TSL2591: def __init__( self, i2c, address, + sensor_id = 0x50, integration=INTEGRATIONTIME_100MS, gain=GAIN_LOW ): - self.i2c_bus = i2c - self.i2c_address = address - self.bus = SMBusEmulator(i2c) + self.sensor_id = sensor_id + self.address = address + self.i2c = i2c self.integration_time = integration self.gain = gain self.set_timing(self.integration_time) self.set_gain(self.gain) - self.sleep() + + def _write(self, register, value): + buffer = bytearray(2) + buffer[0] = register + buffer[1] = value + self.i2c.writeto(self.address, buffer) + + def _read(self, register, length): + self.i2c.writeto(self.address, bytes([register & 0xFF])) + result = self.i2c.readfrom(self.address, length) + if length <= 2: + return int.from_bytes(result, 'little') + return result def set_timing(self, integration): - self.wake() self.integration_time = integration - self.bus.write_byte_data( - self.i2c_address, + self._write( COMMAND_BIT | REGISTER_CONTROL, self.integration_time | self.gain ) - self.sleep() def set_gain(self, gain): - self.wake() self.gain = gain - self.bus.write_byte_data( - self.i2c_address, + self._write( COMMAND_BIT | REGISTER_CONTROL, self.integration_time | self.gain ) - self.sleep() - def calculate_lux(self): + @property + def lux(self): full, ir = self.get_full_luminosity() - if (full == 0xFFFF) | (ir == 0xFFFF): return 0 - + case_integ = { INTEGRATIONTIME_100MS: 100., INTEGRATIONTIME_200MS: 200., @@ -141,31 +129,38 @@ def calculate_lux(self): return max([lux1, lux2]) def wake(self): - self.bus.write_byte_data( - self.i2c_address, + self._write( COMMAND_BIT | REGISTER_ENABLE, ENABLE_POWERON | ENABLE_AEN | ENABLE_AIEN ) def sleep(self): - self.bus.write_byte_data( - self.i2c_address, + self._write( COMMAND_BIT | REGISTER_ENABLE, ENABLE_POWEROFF ) def get_full_luminosity(self): - self.wake() - time.sleep(0.120*self.integration_time) - full = self.bus.read_word_data( - self.i2c_address, COMMAND_BIT | REGISTER_CHAN0_LOW - ) - ir = self.bus.read_word_data( - self.i2c_address, COMMAND_BIT | REGISTER_CHAN1_LOW + time.sleep(0.12 * self.integration_time) + full = self._read( + COMMAND_BIT | REGISTER_CHAN0_LOW, 2 ) - self.sleep() + ir = self._read( + COMMAND_BIT | REGISTER_CHAN1_LOW, 2 + ) return full, ir + def get_luminosity(self, channel): + full, ir = self.get_full_luminosity() + if channel == FULLSPECTRUM: + return full + elif channel == INFRARED: + return ir + elif channel == VISIBLE: + return full - ir + else: + return 0 + def sample(self): full, ir = self.get_full_luminosity() return self.calculate_lux(full, ir) diff --git a/software/lib/VEML6070.py b/software/lib/VEML6070.py index f9f7e22..08ac46f 100644 --- a/software/lib/VEML6070.py +++ b/software/lib/VEML6070.py @@ -1,9 +1,3 @@ -from micropython import const - -# Set I2C addresses: -_VEML6070_ADDR_ARA = const(0x18 >> 1) -_VEML6070_ADDR_CMD = const(0x70 >> 1) - # Integration Time dictionary. [0] is the byte setting; [1] is the risk # level divisor. _VEML6070_INTEGRATION_TIME = { @@ -22,93 +16,46 @@ "EXTREME": [2055, 9999], } -class I2CDevice: - def __init__(self, i2c, device_address, probe=True): - self.i2c = i2c - self.device_address = device_address - if probe: - self.__probe_for_device() - - def readinto(self, buf): - self.i2c.readfrom_into(self.device_address, buf) - - def write(self, buf): - self.i2c.writeto(self.device_address, buf) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - return False - - def __probe_for_device(self): - try: - self.i2c.writeto(self.device_address, b"") - except OSError: - # some OS's dont like writing an empty bytesting... - # Retry by reading a byte - try: - result = bytearray(1) - self.i2c.readfrom_into(self.device_address, result) - except OSError: - # pylint: disable=raise-missing-from - raise ValueError("No I2C device at address: 0x%x" % self.device_address) - # pylint: enable=raise-missing-from - class VEML6070: - def __init__(self, i2c, address, _veml6070_it="VEML6070_1_T", ack=False): - # Check if the IT is valid - if _veml6070_it not in _VEML6070_INTEGRATION_TIME: - raise ValueError( - "Integration Time invalid. Valid values are: ", - _VEML6070_INTEGRATION_TIME.keys(), - ) - - # Check if ACK is valid - if ack not in (True, False): - raise ValueError("ACK must be 'True' or 'False'.") + def __init__(self, i2c, address, ack = False): # Passed checks; set self values self._ack = int(ack) self._ack_thd = 0x00 - self._it = _veml6070_it + self._it = "VEML6070_1_T" # Latch the I2C addresses - self.i2c_cmd = I2CDevice(i2c, _VEML6070_ADDR_CMD) self.i2c = i2c - self.address = address - - # Initialize the VEML6070 - ara_buf = bytearray(1) - try: - with I2CDevice(i2c, _VEML6070_ADDR_ARA) as ara: - ara.readinto(ara_buf) - except ValueError: # the ARA address is never valid? datasheet error? - pass + self.address_cmd = address + self.address_l = address + self.address_h = address + 1 + self.buf = bytearray(1) self.buf[0] = ( self._ack << 5 | _VEML6070_INTEGRATION_TIME[self._it][0] << 2 | 0x02 ) - with self.i2c_cmd as i2c_cmd: - i2c_cmd.write(self.buf) + + self._write(self.buf) + + def _write(self, buffer): + self.i2c.writeto(self.address_cmd, buffer) + + def _read(self, address, length): + return self.i2c.readfrom(address, length) @property def uv_raw(self): - buflow = self.i2c.readfrom(self.address,1) - bufhigh = self.i2c.readfrom(self.address+1,1) - buf = bufhigh[0] <<8 - buf |= buflow[0] + buflow = self._read(self.address_l, 1) + bufhigh = self._read(self.address_h, 1) + + # poll a second time: this looks like BS but it is necessary? :( + buflow = self._read(self.address_l, 1) + bufhigh = self._read(self.address_h, 1) - return buf + return bufhigh[0] << 8 | buflow[0] @property def integration_time(self): - """ - The Integration Time of the sensor, which is the refresh interval of the - sensor. The higher the refresh interval, the more accurate the reading is (at - the cost of less sampling). The available settings are: :const:`VEML6070_HALF_T`, - :const:`VEML6070_1_T`, :const:`VEML6070_2_T`, :const:`VEML6070_4_T`. - """ return self._it @integration_time.setter @@ -126,8 +73,7 @@ def integration_time(self, new_it): | _VEML6070_INTEGRATION_TIME[new_it][0] << 2 | 0x02 ) - with self.i2c_cmd as i2c_cmd: - i2c_cmd.write(self.buf) + self._write(self.buf) def sleep(self): """ @@ -135,8 +81,7 @@ def sleep(self): of 1uA while in shutdown. """ self.buf[0] = 0x03 - with self.i2c_cmd as i2c_cmd: - i2c_cmd.write(self.buf) + self._write(self.buf) def wake(self): """ @@ -148,8 +93,7 @@ def wake(self): | _VEML6070_INTEGRATION_TIME[self._it][0] << 2 | 0x02 ) - with self.i2c_cmd as i2c_cmd: - i2c_cmd.write(self.buf) + self._write(self.buf) def get_index(self, buf): adjusted = int(buf/1) diff --git a/software/lib/cayenneLPP.py b/software/lib/cayenneLPP.py index 354b2db..d3d8aa3 100644 --- a/software/lib/cayenneLPP.py +++ b/software/lib/cayenneLPP.py @@ -1,11 +1,12 @@ """ -CayenneLPP module. +CayenneLPP module (https://github.com/jojo-/py-cayenne-lpp) The constants have the format NAME_SENSOR = (LPP id, Data size) where LPP id is the IPSO id - 3200 and Data size is the number of bytes that must be used to encode the reading from the sensor. https://mydevices.com/cayenne/docs/lora/#lora-cayenne-low-power-payload-overview +https://techlibrary.hpe.com/docs/otlink-wo/IPSO-Object-Reference-Guide.html#IPSOObjectReferenceGuide-IPSOObjects(Seealso,) """ import struct @@ -30,20 +31,13 @@ class CayenneLPP: sensor is defined by: [CHANNEL, SENSOR TYPE, DATA]. """ - def __init__(self, size = 11, sock = None): - - if size < 3: - size = 3 - + def __init__(self, size, sock): self.size = size self.payload = bytes() self.socket = sock def is_within_size_limit(self, a_size): - """ - Check if adding data will result in a payload size below size - """ - + """ Check if adding data will result in a payload size below size """ if (len(self.payload) + a_size + 2) <= self.size: # + 2 for 1 channel byte and 1 sensortype byte return True return False @@ -58,19 +52,11 @@ def get_size(self): return len(self.payload) def send(self, reset_payload = False): - """ - Args: - reset_payload: Indicates whether the payload must be reset after - the transmission (i.e. if a socket is defined). - """ - - if self.socket is None: - return False - else: - self.socket.send(self.payload) - if reset_payload: - self.reset_payload() - return True + """ reset_payload: Indicates whether the payload must be reset after the transmission. """ + self.socket.send(self.payload) + if reset_payload: + self.reset_payload() + return True def add_analog_input(self, value, channel = 3): # Resolution: 0.01, signed. diff --git a/software/lib/desktop.ini b/software/lib/desktop.ini new file mode 100644 index 0000000..ab17096 --- /dev/null +++ b/software/lib/desktop.ini @@ -0,0 +1,4 @@ +[ViewState] +Mode= +Vid= +FolderType=Documents diff --git a/software/lib/micropyGPS.py b/software/lib/micropyGPS.py index ecffa41..20cd3b7 100644 --- a/software/lib/micropyGPS.py +++ b/software/lib/micropyGPS.py @@ -3,7 +3,6 @@ # Copyright (c) 2017 Michael Calvin McCoy (calvin.mccoy@protonmail.com) # The MIT License (MIT) - see LICENSE file """ -import utime class MicropyGPS(object): """GPS NMEA Sentence Parser. Creates object that stores all relevant GPS data and statistics. @@ -26,12 +25,6 @@ def __init__(self, local_offset=0): self.gps_segments = [] self.crc_xor = 0 self.char_count = 0 - self.fix_time = 0 - - # Sentence Statistics - self.crc_fails = 0 - self.clean_sentences = 0 - self.parsed_sentences = 0 # Data From Sentences # Time @@ -42,25 +35,15 @@ def __init__(self, local_offset=0): # Position/Motion self._latitude = [0, 0.0, 'N'] self._longitude = [0, 0.0, 'W'] - self.coord_format = 'ddm' self.speed = [0.0, 0.0, 0.0] self.course = 0.0 self.altitude = 0.0 - self.geoid_height = 0.0 # GPS Info - self.satellites_in_view = 0 self.satellites_in_use = 0 - self.satellites_used = [] - self.last_sv_sentence = 0 - self.total_sv_sentences = 0 - self.satellite_data = dict() self.hdop = 0.0 - self.pdop = 0.0 - self.vdop = 0.0 self.valid = False self.fix_stat = 0 - self.fix_type = 1 # Coordinates Translation Functions @property @@ -167,9 +150,6 @@ def gprmc(self): self.course = course self.valid = True - # Update Last Fix Time - self.new_fix_time() - else: # Clear Position Data if Sentence is 'Invalid' self._latitude = [0, 0.0, 'N'] self._longitude = [0, 0.0, 'W'] @@ -228,9 +208,6 @@ def gpgll(self): self._longitude = [lon_degs, lon_mins, lon_hemi] self.valid = True - # Update Last Fix Time - self.new_fix_time() - else: # Clear Position Data if Sentence is 'Invalid' self._latitude = [0, 0.0, 'N'] self._longitude = [0, 0.0, 'W'] @@ -299,16 +276,13 @@ def gpgga(self): # Altitude / Height Above Geoid try: altitude = float(self.gps_segments[9]) - geoid_height = float(self.gps_segments[11]) except ValueError: altitude = 0 - geoid_height = 0 # Update Object Data self._latitude = [lat_degs, lat_mins, lat_hemi] self._longitude = [lon_degs, lon_mins, lon_hemi] self.altitude = altitude - self.geoid_height = geoid_height # Update Object Data self.timestamp = [hours, minutes, seconds] @@ -316,16 +290,8 @@ def gpgga(self): self.hdop = hdop self.fix_stat = fix_stat - # If Fix is GOOD, update fix timestamp - if fix_stat: - self.new_fix_time() - return True - ########################################## - # Data Stream Handler Functions - ########################################## - def new_sentence(self): """Adjust Object Flags in Preparation for a New Sentence""" self.gps_segments = [''] @@ -380,8 +346,6 @@ def update(self, new_char): final_crc = int(self.gps_segments[self.active_segment], 16) if self.crc_xor == final_crc: valid_sentence = True - else: - self.crc_fails += 1 except ValueError: pass # CRC Value was deformed and could not have been correct @@ -391,7 +355,6 @@ def update(self, new_char): # If a Valid Sentence Was received and it's a supported sentence, then parse it!! if valid_sentence: - self.clean_sentences += 1 # Increment clean sentences received self.sentence_active = False # Clear Active Processing Flag if self.gps_segments[0] in self.supported_sentences: @@ -400,7 +363,6 @@ def update(self, new_char): if self.supported_sentences[self.gps_segments[0]](self): # Let host know that the GPS object was updated by returning parsed sentence type - self.parsed_sentences += 1 return self.gps_segments[0] # Check that the sentence buffer isn't filling up with Garage waiting for the sentence to complete @@ -410,42 +372,13 @@ def update(self, new_char): # Tell Host no new sentence was parsed return None - def new_fix_time(self): - """Updates a high resolution counter with current time when fix is updated. Currently only triggered from - GGA, GSA and RMC sentences""" - self.fix_time = utime.ticks_ms() - - ######################################### - # User Helper Functions - # These functions make working with the GPS object data easier - ######################################### - - def date_string(self, century='20'): + def date_string(self, century = 20): """ 01/11/2014 (DD/MM/YYYY) - :param century: int delineating the century the GPS data is from (19 for 19XX, 20 for 20XX) - :return: date_string string with short format date + :return: date_string string with short format date with padded zeroes """ - # Add leading zeros to day string if necessary - if self.date[0] < 10: - day = '0' + str(self.date[0]) - else: - day = str(self.date[0]) - - # Add leading zeros to month string if necessary - if self.date[1] < 10: - month = '0' + str(self.date[1]) - else: - month = str(self.date[1]) - - # Add leading zeros to year string if necessary - if self.date[2] < 10: - year = '0' + str(self.date[2]) - else: - year = str(self.date[2]) - - return day + '/' + month + '/' + year + return "{:0>2}/{:0>2}/{}{:0>2}".format(self.date[0], self.date[1], century, self.date[2]) # All the currently supported NMEA sentences supported_sentences = {'GPRMC': gprmc, 'GLRMC': gprmc, diff --git a/software/main.py b/software/main.py index b6fbe0d..0a33837 100644 --- a/software/main.py +++ b/software/main.py @@ -1,266 +1,186 @@ import machine import settings - -from lib.micropyGPS import MicropyGPS -gps_en = machine.Pin('P22', mode=machine.Pin.OUT) # 2N2907 (PNP) gate pin -gps_en.hold(False) # disable hold from deepsleep -gps_en.value(1) # keep disabled -gps = MicropyGPS() # create GPS object - +import time from lib.SSD1306 import SSD1306 -i2c_bus = machine.I2C(0, machine.I2C.MASTER) # create I2C object and initialize (inactive) display -display = SSD1306(128, 64, i2c_bus) -display.poweroff() - -#%% get GPS location if pushbutton was pressed -wake_reason = machine.wake_reason()[0] # tuple of (wake_reason, GPIO_list) -if wake_reason == machine.PIN_WAKE: +wake_reason = machine.wake_reason()[0] # tuple of (wake_reason, GPIO_list) +wake_time = time.ticks_ms() + +i2c_bus = machine.I2C(0) # create I2C object +display = SSD1306(128, 64, i2c_bus) # initialize display (4.4 / 0.0 mA) +display.text("MJLO-" + str(settings.NODE), 1, 1) +display.text("Hello world!", 1, 11) +display.show() + +""" This part is only executed if DEBUG == True """ +if settings.DEBUG == True: + + PRINT = { + 'volt' : lambda volt : print("Accu:", volt), + 'temp' : lambda temp : print("Temp:", temp), + 'pres' : lambda pres : print("Druk:", pres), + 'humi' : lambda humi : print("Vocht:", humi), + 'volu' : lambda volu : print("Volume:", volu), + 'lx' : lambda lx : print("Licht:", lx), + 'uv' : lambda uv : print("UV:", uv), + 'voc' : lambda voc : print("VOC:", voc), + 'co2' : lambda co2 : print("CO2:", co2), + 'pm25' : lambda pm25 : print("PM2.5:", pm25), + 'pm10' : lambda pm10 : print("PM10:", pm10), + 'gps' : lambda lat, long, alt : print("NB", lat, "OL", long, "H", alt), + 'perc' : lambda perc : print("Accu%:", perc) + } - gps_en.value(0) # enable GPS power - com2 = machine.UART(2, pins=('P3', 'P4'), baudrate=9600) # GPS communication - - display.poweron() - display.fill(0) - display.text("GPS gestart!", 1, 1) - display.show() - - t1 = time.time() - while True: - while com2.any(): # wait for incoming communication - print('.', end = '') - my_sentence = com2.readline() # read NMEA sentence - for x in my_sentence: - gps.update(chr(x)) # decode it through micropyGPS - - if (gps.latitude > 0 and gps.longitude > 0) or time.time() - t1 > 60: # once we found valid data, power off GPS module and show data on display - gps_en.value(1) # disable GPS power - - print("GPS: NB %f, OL %f, H %f" % (gps.latitude, gps.longitude, gps.altitude)) - display.text("GPS locatie:", 1, 11) - display.text("NB: %f" % gps.latitude, 1, 21) - display.text("OL: %f" % gps.longitude, 1, 31) - display.text("H: %f" % gps.altitude, 1, 41) - display.show() - machine.sleep(settings.DISPLAY_TIME) - display.poweroff() - break - -#%% retrieve sensor values from RTC memory -import struct -rtc = machine.RTC() # get access to RTC module for deepsleep memory + 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['GPS'](loc['lat'], loc['long'], loc['alt']) -delimiter = b'\x21\x21' # TODO might need better delimiter, but this has never given any errors yet + from collect_sensors import run_collection + values = run_collection(i2c_bus = i2c_bus, all_sensors = True, t_wake = settings.T_WAKE) -if wake_reason == machine.RTC_WAKE or wake_reason == machine.PIN_WAKE: - # restore values of previous cycle from psRAM - previous_values = rtc.memory() - previous_values = previous_values.split(delimiter) - batt_volt = struct.unpack('f', previous_values[0])[0] # unpack returns tuple (unpacked_value,) so we select [0] everytime - temperature = struct.unpack('f', previous_values[1])[0] - pressure = struct.unpack('f', previous_values[2])[0] - humidity = struct.unpack('f', previous_values[3])[0] - dBm = struct.unpack('f', previous_values[4])[0] - light = struct.unpack('i', previous_values[5])[0] - uv_raw = struct.unpack('i', previous_values[6])[0] - tVOC = struct.unpack('i', previous_values[7])[0] - co2 = struct.unpack('i', previous_values[8])[0] - pm_25 = struct.unpack('f', previous_values[9])[0] - pm_100 = struct.unpack('f', previous_values[10])[0] + for key in values: + PRINT[key](values[key]) - # calculate approx. battery percentage, assuming a range from 3.1 to 4.1 Volts on the battery - batt_perc = (batt_volt - 3.1) * 100 + awake_time = time.ticks_ms() - wake_time # time in seconds the program has been running + push_button = machine.Pin('P23', mode = machine.Pin.IN, pull = machine.Pin.PULL_DOWN) # initialize wake-up pin + machine.pin_sleep_wakeup(['P23'], mode = machine.WAKEUP_ANY_HIGH, enable_pull = True) # set wake-up pin as trigger + machine.deepsleep(settings.T_DEBUG * 1000 - awake_time) # deepsleep for remainder of the interval time -#%% set up LoRa and send values over CayenneLPP +""" This part is only executed if DEBUG == False """ import network import socket -import ubinascii -import time from lib.cayenneLPP import CayenneLPP -if settings.DEBUG_MODE == False: - lora = network.LoRa(mode = network.LoRa.LORAWAN, region = network.LoRa.EU868, sf = settings.LORA_SF) # create LoRa object +lora = network.LoRa(mode = network.LoRa.LORAWAN, region = network.LoRa.EU868) # create LoRa object +LORA_CNT = 0 # default LoRa frame counter +if wake_reason == machine.RTC_WAKE or wake_reason == machine.PIN_WAKE: # if woken up from deepsleep (timer or button).. + lora.nvram_restore() # ..restore the LoRa information from nvRAM + try: + with open('/flash/counter.txt', 'r') as file: + LORA_CNT = int(file.readline()) # try to restore LoRa frame counter from deepsleep + except: + print("Failed to read counter file, reset counter to 0") + +use_gps = False +# once a day, enable GPS (when rest(no. of messages * interval) / day < interval) (but not if the button was pressed) +if (LORA_CNT * settings.T_INTERVAL) % 86400 < settings.T_INTERVAL and wake_reason != machine.PIN_WAKE: + use_gps = True + +all_sensors = False +# every FRACTION'th message or if the button was pushed, use all sensors (but not if GPS is used) +if (LORA_CNT % settings.FRACTION == 0 or wake_reason == machine.PIN_WAKE) and use_gps == False: + all_sensors = True + +LORA_SF = settings.SF_LOW +# 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 = settings.SF_HIGH + +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_CNT == 0: + import secret + + if settings.LORA_MODE == 'OTAA': + lora.join(activation = network.LoRa.OTAA, auth = secret.auth(settings.LORA_MODE, settings.NODE), dr = LORA_DR) + + if settings.LORA_MODE == 'ABP': + lora.join(activation = network.LoRa.ABP, auth = secret.auth(settings.LORA_MODE, settings.NODE), dr = LORA_DR) - if wake_reason != machine.RTC_WAKE and wake_reason != machine.PIN_WAKE: - #app_eui = ubinascii.unhexlify(settings.APP_EUI) - #app_key = ubinascii.unhexlify(settings.APP_KEY) - #lora.join(activation = network.LoRa.OTAA, auth = (app_eui, app_key), timeout = 0, dr = settings.LORA_DR) # join with SF9 - - dev_addr = struct.unpack(">l", ubinascii.unhexlify('260BD834'))[0] - nwk_swkey = ubinascii.unhexlify('ABA8C83456AA7F0537BD1998017F77F0') - app_swkey = ubinascii.unhexlify('0C4A1DD8684BFC394705D06963E1E690') - lora.join(activation = network.LoRa.ABP, auth = (dev_addr, nwk_swkey, app_swkey), dr = settings.LORA_DR) - - display.poweron() - display.text("Verbinden...", 1, 1) - display.show() - - print("Verbinden met LoRaWAN", end = '') - while not lora.has_joined(): - print('.', end = '') - time.sleep(0.5) - - display.text("Verbonden!", 1, 11) - display.show() - - # if we woke from deepsleep, i.e. there are sensor values present, send them over LoRa - if wake_reason == machine.RTC_WAKE or wake_reason == machine.PIN_WAKE: - lora.nvram_restore() # restore the LoRa information from nvRAM - s = socket.socket(socket.AF_LORA, socket.SOCK_RAW) # create a LoRa socket. - s.setsockopt(socket.SOL_LORA, socket.SO_DR, settings.LORA_DR) # set the LoRaWAN data rate - s.setblocking(True) - - # create cayenne LPP packet - if wake_reason == machine.PIN_WAKE: - lpp = CayenneLPP(size = 53, sock = s) # set payload size; 42 base + 11 GPS - lpp.add_gps(gps.latitude, gps.longitude, gps.altitude, channel = 11) # 1+1+9=11: 0.0001, 0.0001 and 0.01 accurate resp. - else: - lpp = CayenneLPP(size = 42, sock = s) - - lpp.add_analog_input(batt_volt, channel = 0) # 1+1+2=4: 0.01 V accurate TODO humidity?? - lpp.add_temperature(temperature, channel = 1) # 1+1+2=4: 0.1 C accurate - lpp.add_barometric_pressure(pressure, channel = 2) # 1+1+2=4: 0.1 hPa accurate - lpp.add_relative_humidity(humidity, channel = 3) # 1+1+1=3: 0.5% accurate (range 0-100) - lpp.add_relative_humidity(dBm, channel = 4) # 1+1+1=3: 0.5 accurate (range 0-80) - lpp.add_luminosity(light, channel = 5) # 1+1+2=4: 1 lux accurate (range 0-65536) - lpp.add_luminosity(uv_raw, channel = 6) # 1+1+2=4: 1 lux accurate (range 0-9999) TODO humidity?? - lpp.add_luminosity(tVOC, channel = 7) # 1+1+2=4: 1 ppb accurate (range 0-1187) - lpp.add_luminosity(co2, channel = 8) # 1+1+2=4: 1 ppm accurate (range 0-65536) - lpp.add_barometric_pressure(pm_25, channel = 9) # 1+1+2=4: 0.1 ug/m3 accurate (range 0.0-999.9) TODO humidity?? - lpp.add_barometric_pressure(pm_100, channel = 10) # 1+1+2=4: 0.1 ug/m3 accurate (range 0.0-999.9) TODO humidity?? - lpp.send(reset_payload = True) - - # we don't need LoRa anymore so we save LoRa object to NVRAM and profit from machine.sleep's reduced power consumption - lora.nvram_save() - -#%% start PM and CO2 sensor -from lib.SDS011 import SDS011 -from lib.MQ135 import MQ135 - -mq135_en = machine.Pin('P8', mode=machine.Pin.OUT) # MQ135 VIN pin -mq135_en.hold(False) # disable hold from deepsleep -mq135_en.value(1) # preheat -mq135 = MQ135('P16') # CO2 sensor - # active: 40 mA, sleep: 0.0 mA - -sds011_en = machine.Pin('P21', mode=machine.Pin.OUT) # voltage regulator SHDN pin -sds011_en.hold(False) # disable hold from deepsleep -sds011_en.value(1) # start fan and laser -com = machine.UART(1, pins=('P20', 'P19'), baudrate=9600) # UART communication to SDS011 -sds011 = SDS011(com) # fine particle sensor - -machine.sleep(settings.PEAK_TIME * 1000) # DO NOT even attempt to do anything after enabling the voltage regulator - -#%% show sensor values on display -if wake_reason == machine.RTC_WAKE or wake_reason == machine.PIN_WAKE: - if settings.DEBUG_MODE == True: - print("Luchtdruk: %.2f" % pressure) - print("Temperatuur: %.2f " % temperature) - print("Luchtvochtigheid: %d" % humidity) - print("Fijnstof 2.5, 10: %.2f, %.2f" % (pm_25, pm_100)) - print("Volume: %d" % dBm) - print("VOC, CO2: %d %d" % (tVOC, co2)) - print("UV index: %d" % uv_raw) - print("Lux: %d" % light) - print("Voltage: %.3f, +/-%d %%" % (batt_volt, batt_perc)) - display.poweron() - display.fill(0) - display.text("Temp: %.2f" % temperature, 1, 1) - display.text("Druk: %.2f " % pressure, 1, 11) - display.text("Vocht: %d %%" % humidity, 1, 21) - display.text("Lux: %d" % light, 1, 31) - display.text("UV: %s" % uv_raw, 1, 41) - display.text("Accu: +/-%d %%" % batt_perc, 1, 54) - display.show() - machine.sleep(settings.DISPLAY_TIME * 1000) - display.fill(0) - display.text("Stof 2.5: %.2f" % pm_25, 1, 1) - display.text("Stof 10: %.2f" % pm_100, 1, 11) - display.text("VOC: %d" % tVOC, 1, 21) - display.text("CO2: %d" % co2, 1, 31) - display.text("Volume: %d" % dBm, 1, 41) - display.text("Accu: +/-%d %%" % batt_perc, 1, 54) - display.show() - machine.sleep(settings.DISPLAY_TIME * 1000) - display.fill(0) - display.poweroff() - - machine.sleep((settings.WAKE_TIME - settings.PEAK_TIME - 2*settings.DISPLAY_TIME) * 1000) # residue of stabilization time -else: - machine.sleep((settings.WAKE_TIME - settings.PEAK_TIME) * 1000) - -t1 = time.time() -while (not sds011.read()) and (time.time() - t1 < 5): # try to get a response from SDS011 within 5 seconds - pass - -pm_25 = sds011.pm25 -pm_100 = sds011.pm10 -sds011_en.value(0) # disable voltage regulator -sds011_en.hold(True) # hold pin low during deepsleep - - -#%% get other sensors' values and put them to sleep immediately after -from lib.VEML6070 import VEML6070 -from lib.TSL2591 import TSL2591 -from lib.BME680 import BME680 - -bme680 = BME680(i2c=i2c_bus, address=0x77) # temp, pres, hum, VOC sensor - # active: 22 mA, sleep: 0.0 mA -bme680.temperature_oversample = 16 # maximum accuracy -bme680.humidity_oversample = 16 -bme680.pressure_oversample = 16 - -temperature = bme680.temperature -humidity = bme680.relative_humidity -pressure = bme680.pressure -tVOC = bme680.gas -bme680.set_power_mode(0) - -polls = 3 # poll some sensors multiple times for more reliable values - -co2 = sum([mq135.get_corrected_ppm(temperature, humidity) for _ in range(polls)]) / polls -mq135_en.value(0) # disable heating element -mq135_en.hold(True) # hold pin low during deepsleep - -veml6070 = VEML6070(i2c=i2c_bus, address=56) # UV sensor - # active: 0.0 mA, sleep: 0.0 mA -uv_raw = sum([veml6070.uv_raw for _ in range(polls)]) / polls -uv_raw = max(1, uv_raw) # prevent sending zeroes over Cayenne which messes with the decoding -veml6070.sleep() - -tsl2591 = TSL2591(i2c=i2c_bus, address=0x29) # lux sensor - # active: 0.0 mA, sleep: 0.0 mA -light = sum([tsl2591.calculate_lux() for _ in range(polls)]) / polls -light = max(1, light) # prevent sending zeroes over Cayenne which messes with the decoding -tsl2591.sleep() - -volt_pin = machine.ADC().channel(pin = 'P15', attn=machine.ADC.ATTN_11DB) # analog voltage sensor for battery level -batt_volt = sum([volt_pin.voltage() * 2 / 1000 for _ in range(polls)]) / polls - -max4466 = machine.ADC().channel(pin = 'P18', attn=machine.ADC.ATTN_11DB) # analog loudness sensor - # active: 0.3 mA, sleep: 0.3 mA (always on) -loudness_voltage = sum([max4466.voltage() / 1000 for _ in range(polls)]) / polls -dBm = (40 * loudness_voltage - 20) # https://forum.arduino.cc/t/dbm-from-max4466/398552 TODO calibrate!!! - + # don't need to wait for has_joined(): when joining the network, GPS is enabled next which takes much longer + +# create Cayenne-formatted LoRa message (payload either 41 or 42 bytes so stick to 42 either way) +lpp = CayenneLPP(size = 42, sock = s) + +LPP_ADD = { # routine for adding data to Cayenne message + 'volt' : lambda volt : lpp.add_analog_input(volt, channel = 0), # 4 (2) bits + 'temp' : lambda temp : lpp.add_temperature(temp, channel = 1), # 4 (2) bits + 'pres' : lambda pres : lpp.add_barometric_pressure(pres, channel = 2), # 4 (2) bits + 'humi' : lambda humi : lpp.add_relative_humidity(humi, channel = 3), # 3 (1) bits + 'volu' : lambda volu : lpp.add_relative_humidity(volu, channel = 4), # 3 (1) bits + 'lx' : lambda lx : lpp.add_luminosity(lx, channel = 5), # 4 (2) bits + 'uv' : lambda uv : lpp.add_luminosity(uv, channel = 6), # 4 (2) bits + 'voc' : lambda voc : lpp.add_luminosity(voc, channel = 7), # 4 (2) bits + 'co2' : lambda co2 : lpp.add_luminosity(co2, channel = 8), # 4 (2) bits + 'pm25' : lambda pm25 : lpp.add_barometric_pressure(pm25, channel = 9), # 4 (2) bits + 'pm10' : lambda pm10 : lpp.add_barometric_pressure(pm10, channel = 10), # 4 (2) bits + 'gps' : lambda lat, long, alt : lpp.add_gps(lat, long, alt, channel = 11), # 11 (3/3/3) bits + 'perc' : lambda perc : lora.set_battery_level(perc), # set level for MAC command +} + +DISPLAY_TEXT = { # routine for displaying text on OLED display + 'volt' : lambda volt : None, + 'temp' : lambda temp : display.text("Temp: " + str(round(temp, 1)) + " C", 1, 1), + 'pres' : lambda pres : display.text("Druk: " + str(round(pres, 1)) + " hPa", 1, 11), + 'humi' : lambda humi : display.text("Vocht: " + str(round(humi, 1)) + " %", 1, 21), + 'lx' : lambda lx : display.text("Licht: " + str(int(lx)) + " lx", 1, 31), + 'uv' : lambda uv : display.text("UV: " + str(int(uv)), 1, 41), + + 'volu' : lambda volu : display.text("Volume: " + str(int(volu)) + " dB", 1, 1), + 'voc' : lambda voc : display.text("VOC: " + str(int(voc)) + " Ohm", 1, 11), + 'co2' : lambda co2 : display.text("CO2: " + str(int(co2)) + " ppm", 1, 21), + 'pm25' : lambda pm25 : display.text("PM2.5: " + str(pm25) + " ppm", 1, 31), + 'pm10' : lambda pm10 : display.text("PM10: " + str(pm10) + " ppm", 1, 41), + + 'perc' : lambda perc : display.text("Accu: " + str(int(perc)) + " %", 1, 54), +} -#%% hibernate, but restart to GPS mode if push button is pressed -# write all values to psRAM to send during next wake -memory_string = ( struct.pack('f', float(batt_volt)) + delimiter - + struct.pack('f', float(temperature)) + delimiter - + struct.pack('f', float(pressure)) + delimiter - + struct.pack('f', float(humidity)) + delimiter - + struct.pack('f', float(dBm)) + delimiter - + struct.pack('i', int(light)) + delimiter - + struct.pack('i', int(uv_raw)) + delimiter - + struct.pack('i', int(tVOC)) + delimiter - + struct.pack('i', int(co2)) + delimiter - + struct.pack('f', float(pm_25)) + delimiter - + struct.pack('f', float(pm_100))) -rtc.memory(memory_string) +display.poweroff() -gps_en.hold(True) +# run gps routine if enabled and add values to Cayenne message +if use_gps: + from collect_gps import run_gps + loc = run_gps() + LPP_ADD['gps'](loc['lat'], loc['long'], loc['alt']) + +# run sensor routine and add values to Cayenne message +from collect_sensors import run_collection +values = run_collection(i2c_bus = i2c_bus, all_sensors = all_sensors, t_wake = settings.T_WAKE) +for key in values: + LPP_ADD[key](values[key]) + +# send Cayenne message and reset payload +lpp.send(reset_payload = True) + +# store LoRa context in non-volatile RAM (should be using wear leveling) +lora.nvram_save() + +# store LoRa frame counter to flash (should be VERY r/w resistant >> RTC memory, millions of cycles) +with open('/flash/counter.txt', 'w') as file: + file.write(str(LORA_CNT + 1)) + +# write all values to display in two series +display.poweron() +display.fill(0) +DISPLAY_TEXT['temp'](values['temp']) +DISPLAY_TEXT['pres'](values['pres']) +DISPLAY_TEXT['humi'](values['humi']) +DISPLAY_TEXT['lx'](values['lx']) +DISPLAY_TEXT['uv'](values['uv']) +DISPLAY_TEXT['perc'](values['perc']) +display.show() +machine.sleep(settings.T_DISPLAY * 1000) +display.fill(0) +DISPLAY_TEXT['volu'](values['volu']) +DISPLAY_TEXT['voc'](values['voc']) +DISPLAY_TEXT['perc'](values['perc']) +if all_sensors == True: + DISPLAY_TEXT['co2'](values['co2']) + DISPLAY_TEXT['pm25'](values['pm25']) + DISPLAY_TEXT['pm10'](values['pm10']) +display.show() +machine.sleep(settings.T_DISPLAY * 1000) +display.poweroff() -push_button = machine.Pin('P23', mode=machine.Pin.IN, pull=machine.Pin.PULL_DOWN) -machine.pin_sleep_wakeup(['P23'], mode=machine.WAKEUP_ANY_HIGH, enable_pull=True) -machine.deepsleep(settings.SLEEP_TIME * 1000) \ No newline at end of file +# set up for deepsleep +awake_time = time.ticks_ms() - wake_time # time in seconds the program has been running +push_button = machine.Pin('P23', mode = machine.Pin.IN, pull = machine.Pin.PULL_DOWN) # initialize wake-up pin +machine.pin_sleep_wakeup(['P23'], mode = machine.WAKEUP_ANY_HIGH, enable_pull = True) # set wake-up pin as trigger +machine.deepsleep(settings.T_INTERVAL * 1000 - awake_time) # deepsleep for remainder of the interval time \ No newline at end of file diff --git a/software/settings.py b/software/settings.py index 90d377b..5d956d3 100644 --- a/software/settings.py +++ b/software/settings.py @@ -1,16 +1,16 @@ -## Here we store settings, credentials and constants. +# LoRa config +LORA_MODE = 'ABP' +NODE = 18 -# LoRa config info -APP_EUI = '30AEA47870B40000' -APP_KEY = '93F032DE681792632F0B928B555DAAB8' -LORA_SF = 12 # LoRa Spreading Factor (7 to 12) -LORA_DR = 12 - LORA_SF # Data Rate complement of Spreading Factor (counted inversely from 0 to 5) +SF_LOW = 10 # lower Spreading Factor for Blind ADR +SF_HIGH = 12 # upper Spreading Factor for Blind ADR +FRACTION = 3 # every FRACTION'th message is sent on SF_HIGH, others on SF_LOW -MEASURE_INTERVAL = 60 # measure every ... seconds -BOOT_TIME = 3 # time in seconds (approx) it takes to start SDS011 from poweron -WAKE_TIME = 32 # time in seconds that the SDS011 and MQ135 need to stabilize -PEAK_TIME = 6 # time in seconds to wait after starting voltage regulator -DISPLAY_TIME = 6 # time in seconds to display values on the internal display -SLEEP_TIME = MEASURE_INTERVAL - BOOT_TIME - WAKE_TIME +# time config +T_INTERVAL = 600 # measure every ... seconds +T_DISPLAY = 10 # time to show a page of values on the display +T_WAKE = 28 # time for CO2 and PM sensor to wake and stabilize -DEBUG_MODE = False \ No newline at end of file +# debug config +DEBUG = False +T_DEBUG = 60 # measure every ... seconds (debug only) \ No newline at end of file