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 @@
-
\ 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 @@
+
\ 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