diff --git a/ampel-firmware.h b/ampel-firmware.h index 04190740b8ef6ac37cf342750d63db5207c03a17..530f686c1593a9add43d9687d9d7c5429bcc6218 100644 --- a/ampel-firmware.h +++ b/ampel-firmware.h @@ -3,6 +3,7 @@ /***************************************************************** * Libraries * *****************************************************************/ +//TODO: check header dependencies, and simplify if possible. #include "config.h" #ifndef MEASUREMENT_TIMESTEP # error Missing config.h file. Please copy config.example.h to config.h. @@ -10,6 +11,9 @@ #ifdef MQTT # include "mqtt.h" #endif +#ifdef LORAWAN +# include "lorawan.h" +#endif #include "util.h" #include "wifi_util.h" diff --git a/ampel-firmware.ino b/ampel-firmware.ino index adcd5b98098670f300bddad1a4ac891f84bdc299..d068d825bd8957b99bef32319d7d5aa9f3e8ab2b 100644 --- a/ampel-firmware.ino +++ b/ampel-firmware.ino @@ -99,7 +99,12 @@ void setup() { mqtt::initialize("CO2sensors/" + SENSOR_ID); #endif } + csv_writer::initialize(); + +#if defined(LORAWAN) && defined(ESP32) + lorawan::initialize(); +#endif } /***************************************************************** @@ -107,6 +112,15 @@ void setup() { *****************************************************************/ void loop() { +#if defined(LORAWAN) && defined(ESP32) + //LMIC Library seems to be very sensitive to timing issues, so run it first. + lorawan::process(); + + if (lorawan::waiting_for_confirmation) { + // If node is waiting for join confirmation from Gateway, nothing else should run. + return; + } +#endif //NOTE: Loop should never take more than 1000ms. Split in smaller methods and logic if needed. //TODO: Restart every day or week, in order to not let t0 overflow? uint32_t t0 = millis(); diff --git a/co2_sensor.cpp b/co2_sensor.cpp index 14946075e267cf66a3fb48dc6865fcb571ee25b9..51d1997035763169aa6a23e738e4204cfcfdc78d 100644 --- a/co2_sensor.cpp +++ b/co2_sensor.cpp @@ -175,11 +175,17 @@ namespace sensor { } logToSerial(); + + //TODO: Move the 3 back to ampel-firmware.ino and remove headers from co2_sensor.h csv_writer::logIfTimeHasCome(timestamp, co2, temperature, humidity); #ifdef MQTT mqtt::publishIfTimeHasCome(timestamp, co2, temperature, humidity); #endif + +#if defined(LORAWAN) && defined(ESP32) + lorawan::preparePayloadIfTimehasCome(); +#endif } if (should_calibrate) { diff --git a/co2_sensor.h b/co2_sensor.h index 624caa138f2a849d0254782bed8c0a6b35ba0258..fc5c051e02d0f60d4c09abbb4ca3bf7b9bac63d9 100644 --- a/co2_sensor.h +++ b/co2_sensor.h @@ -13,6 +13,9 @@ #ifdef MQTT # include "mqtt.h" #endif +#ifdef LORAWAN +# include "lorawan.h" +#endif namespace config { extern uint16_t measurement_timestep; // [s] Value between 2 and 1800 (range for SCD30 sensor) diff --git a/config.public.h b/config.public.h index 8e30739d7e11708778701d709ba5c1415284c4ea..d60cfdb572c0635cfc3acf35c30eca68b3a29508 100644 --- a/config.public.h +++ b/config.public.h @@ -104,6 +104,31 @@ # define MQTT_PASSWORD "" # define MQTT_SERVER_FINGERPRINT "EE BC 4B F8 57 E3 D3 E4 07 54 23 1E F0 C8 A1 56 E0 D3 1A 1C" // SHA1 for test.mosquitto.org +/** + * LoRaWAN + */ + +// Region and transceiver type should be specified in: +// * Arduino/libraries/MCCI_LoRaWAN_LMIC_library/project_config/lmic_project_config.h for Arduino IDE +// * platformio.ini for PlatformIO +// See https://github.com/mcci-catena/arduino-lmic#configuration for more information + +// Should data be sent over LoRaWAN? +// It has been tested with "TTGO ESP32 SX1276 LoRa 868" and will only work with an ESP32 + LoRa modem +# define LORAWAN +// How often should measurements be sent over LoRaWAN? +# define LORAWAN_SENDING_INTERVAL 300 // [s] This value should not be too low. See https://www.thethingsnetwork.org/docs/lorawan/duty-cycle.html#maximum-duty-cycle +// This EUI must be in little-endian format, so least-significant-byte first. +// When copying an EUI from ttnctl output, this means to reverse the bytes. +# define LORAWAN_DEVICE_EUI {0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11} +// This should also be in little endian format, see above. +// For TheThingsNetwork issued EUIs the last bytes should be 0xD5, 0xB3, 0x70. +# define LORAWAN_APPLICATION_EUI {0x00, 0x00, 0x00, 0x00, 0x00, 0xD5, 0xB3, 0x70} +// This key should be in big endian format (or, since it is not really a +// number but a block of memory, endianness does not really apply). In +// practice, a key taken from ttnctl can be copied as-is. +# define LORAWAN_APPLICATION_KEY {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 } + /** * NTP */ diff --git a/led_effects.cpp b/led_effects.cpp index 3804ee1111cd21cd4e4c5a73b900dec772d046e6..e8523cbc0131310ecb7c200539f039edf5e7230d 100644 --- a/led_effects.cpp +++ b/led_effects.cpp @@ -43,11 +43,20 @@ namespace LedEffects { } void onBoardLEDOff() { + //NOTE: OFF is LOW on ESP32 and HIGH on ESP8266 :-/ +#ifdef ESP8266 digitalWrite(ONBOARD_LED_PIN, HIGH); +#else + digitalWrite(ONBOARD_LED_PIN, LOW); +#endif } void onBoardLEDOn() { +#ifdef ESP8266 digitalWrite(ONBOARD_LED_PIN, LOW); +#else + digitalWrite(ONBOARD_LED_PIN, HIGH); +#endif } void LEDsOff() { diff --git a/lorawan.cpp b/lorawan.cpp new file mode 100644 index 0000000000000000000000000000000000000000..596889b41d502a165fa1c23cbc423049b5db87bc --- /dev/null +++ b/lorawan.cpp @@ -0,0 +1,198 @@ +#include "lorawan.h" +#if defined(ESP32) + +namespace config { + // Values should be defined in config.h + uint16_t lorawan_sending_interval = LORAWAN_SENDING_INTERVAL; // [s] + + static const u1_t PROGMEM APPEUI[8] = LORAWAN_APPLICATION_EUI; + static const u1_t PROGMEM DEVEUI[8] = LORAWAN_DEVICE_EUI; + static const u1_t PROGMEM APPKEY[16] = LORAWAN_APPLICATION_KEY; +} + +// Payloads will be automatically sent via MQTT by TheThingsNetwork, and can be seen with: +// mosquitto_sub -h eu.thethings.network -t '+/devices/+/up' -u 'APPLICATION-NAME' -P 'ttn-account-v2.4xxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxx' -v +// or encrypted: +// mosquitto_sub -h eu.thethings.network -t '+/devices/+/up' -u 'APPLICATION-NAME' -P 'ttn-account-v2.4xxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxx' -v --cafile mqtt-ca.pem -p 8883 +// -> +// co2ampel-test/devices/esp3a7c94/up {"app_id":"co2ampel-test","dev_id":"esp3a7c94","hardware_serial":"00xxxxxxxx","port":1,"counter":5,"payload_raw":"TJd7","payload_fields":{"co2":760,"rh":61.5,"temp":20.2},"metadata":{"time":"2020-12-23T23:00:51.44020438Z","frequency":867.5,"modulation":"LORA","data_rate":"SF7BW125","airtime":51456000,"coding_rate":"4/5","gateways":[{"gtw_id":"eui-xxxxxxxxxxxxxxxxxx","timestamp":1765406908,"time":"2020-12-23T23:00:51.402519Z","channel":5,"rssi":-64,"snr":7.5,"rf_chain":0,"latitude":22.7,"longitude":114.24,"altitude":450}]}} +// More info : https://www.thethingsnetwork.org/docs/applications/mqtt/quick-start.html + +void os_getArtEui(u1_t *buf) { + memcpy_P(buf, config::APPEUI, 8); +} + +void os_getDevEui(u1_t *buf) { + memcpy_P(buf, config::DEVEUI, 8); +} + +void os_getDevKey(u1_t *buf) { + memcpy_P(buf, config::APPKEY, 16); +} + +namespace lorawan { + bool waiting_for_confirmation = false; + bool connected = false; + String last_transmission = ""; + + void printHex2(unsigned v) { + v &= 0xff; + if (v < 16) + Serial.print('0'); + Serial.print(v, HEX); + } + + void onEvent(ev_t ev) { + Serial.print("LoRa - "); + Serial.print(ntp::getLocalTime()); + Serial.print(" - "); + switch (ev) { + case EV_JOINING: + Serial.println(F("EV_JOINING")); + break; + case EV_JOINED: + waiting_for_confirmation = false; + connected = true; + LedEffects::onBoardLEDOff(); + Serial.println(F("EV_JOINED")); + { + u4_t netid = 0; + devaddr_t devaddr = 0; + u1_t nwkKey[16]; + u1_t artKey[16]; + LMIC_getSessionKeys(&netid, &devaddr, nwkKey, artKey); + Serial.print(F("netid: ")); + Serial.println(netid, DEC); + Serial.print(F("devaddr: ")); + Serial.println(devaddr, HEX); + Serial.print(F("AppSKey: ")); + for (size_t i = 0; i < sizeof(artKey); ++i) { + if (i != 0) + Serial.print("-"); + printHex2(artKey[i]); + } + Serial.println(""); + Serial.print("NwkSKey: "); + for (size_t i = 0; i < sizeof(nwkKey); ++i) { + if (i != 0) + Serial.print("-"); + printHex2(nwkKey[i]); + } + Serial.println(); + } + Serial.println(F("Other services may resume, and will not be frozen anymore.")); + // Disable link check validation (automatically enabled + // during join, but because slow data rates change max TX + // size, we don't use it in this example. + LMIC_setLinkCheckMode(0); + break; + case EV_JOIN_FAILED: + Serial.println(F("EV_JOIN_FAILED")); + break; + case EV_REJOIN_FAILED: + Serial.println(F("EV_REJOIN_FAILED")); + break; + case EV_TXCOMPLETE: + last_transmission = ntp::getLocalTime(); + Serial.println(F("EV_TXCOMPLETE")); + break; + case EV_TXSTART: + waiting_for_confirmation = !connected; + Serial.println(F("EV_TXSTART")); + break; + case EV_TXCANCELED: + waiting_for_confirmation = false; + LedEffects::onBoardLEDOff(); + Serial.println(F("EV_TXCANCELED")); + break; + case EV_JOIN_TXCOMPLETE: + waiting_for_confirmation = false; + LedEffects::onBoardLEDOff(); + Serial.println(F("EV_JOIN_TXCOMPLETE: no JoinAccept.")); + Serial.println(F("Other services may resume.")); + break; + default: + Serial.print(F("LoRa event: ")); + Serial.println((unsigned) ev); + break; + } + if (waiting_for_confirmation) { + LedEffects::onBoardLEDOn(); + Serial.println(F("LoRa - waiting for OTAA confirmation. Freezing every other service!")); + } + } + + void preparePayload() { + // Check if there is not a current TX/RX job running + if (LMIC.opmode & OP_TXRXPEND) { + Serial.println(F("OP_TXRXPEND, not sending")); + } else { + uint8_t buff[3]; + // Mapping CO2 from 0ppm to 5100ppm to [0, 255], with 20ppm increments. + buff[0] = (util::min(util::max(sensor::co2, 0), 5100) + 10) / 20; + // Mapping temperatures from [-10°C, 41°C] to [0, 255], with 0.2°C increment + buff[1] = static_cast<uint8_t>((util::min(util::max(sensor::temperature, -10), 41) + 10.1f) * 5); + // Mapping humidity from [0%, 100%] to [0, 200], with 0.5°C increment (0.4°C would also be possible) + buff[2] = static_cast<uint8_t>(util::min(util::max(sensor::humidity, 0) + 0.25f, 100) * 2); + + Serial.print(F("LoRa - Payload : '")); + printHex2(buff[0]); + Serial.print(" "); + printHex2(buff[1]); + Serial.print(" "); + printHex2(buff[2]); + Serial.print(F("', ")); + Serial.print(buff[0] * 20); + Serial.print(F(" ppm, ")); + Serial.print(buff[1] * 0.2 - 10); + Serial.print(F(" °C, ")); + Serial.print(buff[2] * 0.5); + Serial.println(F(" %.")); + + // Prepare upstream data transmission at the next possible time. + LMIC_setTxData2(1, buff, sizeof(buff), 0); + //NOTE: To decode in TheThingsNetwork: + //function Decoder(bytes, port) { + // return { + // co2: bytes[0] * 20, + // temp: bytes[1] / 5.0 - 10, + // rh: bytes[2] / 2.0 + // }; + //} + } + } + + void initialize() { + Serial.println(F("Starting LoRaWAN.")); + // More info about pin mapping : https://github.com/mcci-catena/arduino-lmic#pin-mapping + // Has been tested successfully with ESP32 TTGO LoRa32 V1, and might work with other ESP32+LoRa boards. + const lmic_pinmap *pPinMap = Arduino_LMIC::GetPinmap_ThisBoard(); + // LMIC init. + os_init_ex(pPinMap); + // Reset the MAC state. Session and pending data transfers will be discarded. + LMIC_reset(); + // Join, but don't send anything yet. + LMIC_startJoining(); + } + + // Checks if OTAA is connected, or if payload should be sent. + // NOTE: while a transaction is in process (i.e. until the TXcomplete event has been received, no blocking code (e.g. delay loops etc.) are allowed, otherwise the LMIC/OS code might miss the event. + // If this rule is not followed, a typical symptom is, that the first send is ok and all following ones end with the TX not complete failure. + void process() { + os_runloop_once(); + } + + void preparePayloadIfTimehasCome() { + static unsigned long last_sent_at = 0; + unsigned long now = seconds(); + if (now - last_sent_at > config::lorawan_sending_interval) { + last_sent_at = now; + preparePayload(); + } + } +} + +void onEvent(ev_t ev) { + lorawan::onEvent(ev); +} +#endif diff --git a/lorawan.h b/lorawan.h new file mode 100644 index 0000000000000000000000000000000000000000..74e7917737be0a602987528679536f536143a514 --- /dev/null +++ b/lorawan.h @@ -0,0 +1,33 @@ +#ifndef AMPEL_LORAWAN_H_ +#define AMPEL_LORAWAN_H_ +#if defined(ESP32) +#include <Arduino.h> +// Requires "MCCI LoRaWAN LMIC library", which will be automatically used with PlatformIO but should be added in "Arduino IDE". +// Tested successfully with v3.2.0 and connected to a thethingsnetwork.org app. +#include <lmic.h> +#include <hal/hal.h> +#include <arduino_lmic_hal_boards.h> +#include <SPI.h> +typedef uint8_t u1_t; + +#include "co2_sensor.h" +#include "led_effects.h" +#include "config.h" + +#include "util.h" + +namespace config { + extern uint16_t lorawan_sending_interval; // [s] +} + +namespace lorawan { + extern bool waiting_for_confirmation; + extern bool connected; + extern String last_transmission; + void initialize(); + void process(); + void preparePayloadIfTimehasCome(); +} + +#endif +#endif diff --git a/mqtt.cpp b/mqtt.cpp index ff8808b19277b8473738e9a087223180132a5ea2..21eeac94bac1b115316663c064c01001acf729c6 100644 --- a/mqtt.cpp +++ b/mqtt.cpp @@ -46,10 +46,10 @@ namespace mqtt { snprintf(payload, sizeof(payload), json_sensor_format, timestamp.c_str(), co2, temperature, humidity); // Topic is the same as clientID. e.g. 'CO2sensors/ESP3d03da' if (mqttClient.publish(publish_topic.c_str(), payload)) { - Serial.println("OK"); + Serial.println(F("OK")); last_successful_publish = ntp::getLocalTime(); } else { - Serial.println("Failed."); + Serial.println(F("Failed.")); } LedEffects::onBoardLEDOff(); } diff --git a/platformio.ini b/platformio.ini index 6893648209a3700095076fb9ff0c5eb55543f6c9..37408192cce461fd4605ba150237ec318a72d209 100644 --- a/platformio.ini +++ b/platformio.ini @@ -21,3 +21,11 @@ platform = espressif32 board = ttgo-lora32-v1 framework = arduino monitor_speed = 115200 + +lib_deps = + MCCI LoRaWAN LMIC library + +build_flags = + -D ARDUINO_LMIC_PROJECT_CONFIG_H_SUPPRESS + -D CFG_eu868=1 + -D CFG_sx1276_radio=1 diff --git a/util.h b/util.h index 4e0b64eebd70fba2af14d65ad867d52501826d61..c1fc40a7b7c1e24c024469c5b6ffdecc6805cf5f 100644 --- a/util.h +++ b/util.h @@ -24,6 +24,18 @@ namespace ntp { String getLocalTime(); } +namespace util { + template<typename Tpa, typename Tpb> + inline auto min(const Tpa &a, const Tpb &b) -> decltype(a < b ? a : b) { + return b < a ? b : a; + } + + template<typename Tpa, typename Tpb> + inline auto max(const Tpa &a, const Tpb &b) -> decltype(b > a ? b : a) { + return b > a ? b : a; + } +} + #define seconds() (millis() / 1000UL) extern uint32_t max_loop_duration; const extern String SENSOR_ID; diff --git a/web_server.cpp b/web_server.cpp index 8b8025163a2de873bae58f04682a639b5d53423b..d2458f5da29f8929bb83764685a45a4369b5008a 100644 --- a/web_server.cpp +++ b/web_server.cpp @@ -87,6 +87,11 @@ namespace web_server { #ifdef MQTT "<tr><td>Last MQTT publish</td><td>%s</td></tr>\n" "<tr><td>MQTT publish timestep</td><td>%5d s</td></tr>\n" +#endif +#if defined(LORAWAN) && defined(ESP32) + "<tr><td>Connected to LoRaWAN?</td><td>%s</td></tr>\n" + "<tr><td>Last LoRaWAN transmission</td><td>%s</td></tr>\n" + "<tr><td>LoRaWAN publish timestep</td><td>%5d s</td></tr>\n" #endif "<tr><td>Temperature offset</td><td>%.1fK</td></tr>\n" //TODO: Read it from sensor? "<tr><td>Local address</td><td><a href='http://%s.local/'>%s.local</a></td></tr>\n" @@ -196,6 +201,9 @@ namespace web_server { csv_writer::last_successful_write.c_str(), config::csv_interval, #ifdef MQTT mqtt::last_successful_publish.c_str(), config::sending_interval, +#endif +#if defined(LORAWAN) && defined(ESP32) + lorawan::connected ? "Yes" : "No", lorawan::last_transmission.c_str(), config::lorawan_sending_interval, #endif config::temperature_offset, SENSOR_ID.c_str(), SENSOR_ID.c_str(), WiFi.localIP().toString().c_str(), WiFi.localIP().toString().c_str(), get_free_heap_size(), available_fs_space, max_loop_duration, BOARD, hh, mm, diff --git a/web_server.h b/web_server.h index 124253d5ce07d5840f01908099e2c442b2e93fe1..1bf4d1c08e60ddcbc851d222b939308515dc2b03 100644 --- a/web_server.h +++ b/web_server.h @@ -13,6 +13,9 @@ #ifdef MQTT # include "mqtt.h" #endif +#ifdef LORAWAN +# include "lorawan.h" +#endif namespace web_server { void initialize();