diff --git a/README.md b/README.md index e5421538c55f09946567ae3698882450de7884f1..9389e016950113580ab266e21e5178403af5d2ae 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,18 @@ It measures the current CO<sub>2</sub> concentration (in ppm), and displays it o The room should be ventilated as soon as one LED turns red. +## Features + +The *CO<sub>2</sub> Ampel* can: + +* Display CO2 concentration on LED ring. +* Allow calibration. +* Get current time over NTP +* Send data over MQTT. +* Send data over LoRaWAN. +* Display measurements and configuration on a small website. +* Log data to a CSV file, directly on the ESP flash memory. + ## Hardware Requirements * [ESP8266](https://en.wikipedia.org/wiki/ESP8266) or [ESP32](https://en.wikipedia.org/wiki/ESP32) microcontroller (this project has been tested with *ESP8266 ESP-12 WIFI* and *TTGO ESP32 SX1276 LoRa*) diff --git a/ampel-firmware.h b/ampel-firmware/ampel-firmware.h similarity index 63% rename from ampel-firmware.h rename to ampel-firmware/ampel-firmware.h index f749605fa9621cf51e1447be815294672c306016..5c47dec1cc21cbb38963d9f31fe2a08743ea851a 100644 --- a/ampel-firmware.h +++ b/ampel-firmware/ampel-firmware.h @@ -5,25 +5,33 @@ *****************************************************************/ #include "config.h" #ifndef MEASUREMENT_TIMESTEP -# error Missing config.h file. Please copy config.example.h to config.h. +# error Missing config.h file. Please copy config.public.h to config.h. #endif -#ifdef MQTT -# include "mqtt.h" + +#ifdef AMPEL_CSV +# include "csv_writer.h" #endif -#include "util.h" -#include "wifi_util.h" -#include "co2_sensor.h" +#ifdef AMPEL_WIFI +# include "wifi_util.h" +# ifdef AMPEL_MQTT +# include "mqtt.h" +# endif +# ifdef AMPEL_HTTP +# include "web_server.h" +# endif +#endif -#ifdef HTTP -# include "web_server.h" +#ifdef AMPEL_LORAWAN +# include "lorawan.h" #endif +#include "util.h" +#include "co2_sensor.h" #include "led_effects.h" -#include "csv_writer.h" #if defined(ESP8266) -//allows sensor to be seen as SENSOR_ID.local, from the local network. For example : espd05cc9.local +//allows sensor to be seen as SENSOR_ID.local, from the local network. For example : espd03cc5.local # include <ESP8266mDNS.h> #elif defined(ESP32) # include <ESPmDNS.h> diff --git a/ampel-firmware.ino b/ampel-firmware/ampel-firmware.ino similarity index 74% rename from ampel-firmware.ino rename to ampel-firmware/ampel-firmware.ino index 45d548acfbba23b09b20bb8a5f1a67174881a030..2713e2dc76413d27a673f9ac1e63b29b3c3cdbe3 100644 --- a/ampel-firmware.ino +++ b/ampel-firmware/ampel-firmware.ino @@ -59,14 +59,14 @@ * Setup * *****************************************************************/ void setup() { - LedEffects::setupOnBoardLED(); - LedEffects::onBoardLEDOff(); + led_effects::setupOnBoardLED(); + led_effects::onBoardLEDOff(); Serial.begin(BAUDS); - pinMode(0, INPUT); // Flash button (used for forced calibration) + pinMode(0, INPUT); // Flash button (used for forced calibration) - LedEffects::setupRing(); + led_effects::setupRing(); sensor::initialize(); @@ -75,6 +75,7 @@ void setup() { Serial.print(F("Board : ")); Serial.println(BOARD); +#ifdef AMPEL_WIFI // Try to connect to Wi-Fi WiFiConnect(SENSOR_ID); @@ -82,9 +83,9 @@ void setup() { Serial.println(WiFi.status()); if (WiFi.status() == WL_CONNECTED) { -#ifdef HTTP +# ifdef AMPEL_HTTP web_server::initialize(); -#endif +# endif ntp::initialize(); @@ -95,11 +96,19 @@ void setup() { Serial.println(F("Error setting up MDNS responder!")); } -#ifdef MQTT +# ifdef AMPEL_MQTT mqtt::initialize("CO2sensors/" + SENSOR_ID); -#endif +# endif } +#endif + +#ifdef AMPEL_CSV csv_writer::initialize(); +#endif + +#if defined(AMPEL_LORAWAN) && defined(ESP32) + lorawan::initialize(); +#endif } /***************************************************************** @@ -107,73 +116,36 @@ void setup() { *****************************************************************/ void loop() { +#if defined(AMPEL_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(); - /** - * USER INTERACTION - */ keepServicesAlive(); + // Short press for night mode, Long press for calibration. checkFlashButton(); - /** - * GET DATA - */ - bool freshData = sensor::scd30.dataAvailable(); // Alternative : close to time-step AND dataAvailable, to avoid asking the sensor too often. - - if (freshData) { - sensor::co2 = sensor::scd30.getCO2(); - sensor::temperature = sensor::scd30.getTemperature(); - sensor::humidity = sensor::scd30.getHumidity(); - } - - //NOTE: Data is available, but it's sometimes erroneous: the sensor outputs zero ppm but non-zero temperature and non-zero humidity. - if (sensor::co2 <= 0) { - // No measurement yet. Waiting. - LedEffects::showWaitingLED(color::blue); - return; - } - - /** - * Fresh data. Show it and send it if needed. - */ - - if (freshData) { - sensor::timestamp = ntp::getLocalTime(); - Serial.println(sensor::timestamp); - - Serial.print(F("co2(ppm): ")); - Serial.print(sensor::co2); - Serial.print(F(" temp(C): ")); - Serial.print(sensor::temperature); - Serial.print(F(" humidity(%): ")); - Serial.println(sensor::humidity); - + if (sensor::processData()) { +#ifdef AMPEL_CSV csv_writer::logIfTimeHasCome(sensor::timestamp, sensor::co2, sensor::temperature, sensor::humidity); +#endif -#ifdef MQTT +#if defined(AMPEL_WIFI) && defined(AMPEL_MQTT) mqtt::publishIfTimeHasCome(sensor::timestamp, sensor::co2, sensor::temperature, sensor::humidity); #endif - } - - if (sensor::co2 < 250) { - // Sensor should be calibrated. - LedEffects::showWaitingLED(color::magenta); - return; - } - /** - * Display data, even if it's "old" (with breathing). - * Those effects include a short delay. - */ - - if (sensor::co2 < 2000) { - LedEffects::displayCO2color(sensor::co2); - LedEffects::breathe(sensor::co2); - } else { // >= 2000: entire ring blinks red - LedEffects::redAlert(); +#if defined(AMPEL_LORAWAN) && defined(ESP32) + lorawan::preparePayloadIfTimeHasCome(sensor::co2, sensor::temperature, sensor::humidity); +#endif } uint32_t duration = millis() - t0; @@ -193,34 +165,36 @@ void loop() { */ void checkFlashButton() { if (!digitalRead(0)) { // Button has been pressed - LedEffects::onBoardLEDOn(); + led_effects::onBoardLEDOn(); delay(300); if (digitalRead(0)) { Serial.println(F("Flash has been pressed for a short time. Should toggle night mode.")); - LedEffects::toggleNightMode(); + led_effects::toggleNightMode(); } else { Serial.println(F("Flash has been pressed for a long time. Keep it pressed for calibration.")); - if (LedEffects::countdownToZero() < 0) { + if (led_effects::countdownToZero() < 0) { sensor::startCalibrationProcess(); } } - LedEffects::onBoardLEDOff(); + led_effects::onBoardLEDOff(); } } void keepServicesAlive() { +#ifdef AMPEL_WIFI if (WiFi.status() == WL_CONNECTED) { -#if defined(ESP8266) +# if defined(ESP8266) //NOTE: Sadly, there seems to be a bug in the current MDNS implementation. // It stops working after 2 minutes. And forcing a restart leads to a memory leak. MDNS.update(); -#endif +# endif ntp::update(); // NTP client has its own timer. It will connect to NTP server every 60s. -#ifdef HTTP +# ifdef AMPEL_HTTP web_server::update(); -#endif -#ifdef MQTT +# endif +# ifdef AMPEL_MQTT mqtt::keepConnection(); // MQTT client has its own timer. It will keep alive every 15s. -#endif +# endif } +#endif } diff --git a/ampel-firmware/co2_sensor.cpp b/ampel-firmware/co2_sensor.cpp new file mode 100644 index 0000000000000000000000000000000000000000..e9c40c79c322383bd9f771203e33ef7abc02b14a --- /dev/null +++ b/ampel-firmware/co2_sensor.cpp @@ -0,0 +1,189 @@ +#include "co2_sensor.h" + +namespace config { + // Values should be defined in config.h + uint16_t measurement_timestep = MEASUREMENT_TIMESTEP; // [s] Value between 2 and 1800 (range for SCD30 sensor) + const uint16_t altitude_above_sea_level = ALTITUDE_ABOVE_SEA_LEVEL; // [m] + uint16_t co2_calibration_level = ATMOSPHERIC_CO2_CONCENTRATION; // [ppm] +#ifdef TEMPERATURE_OFFSET + // Residual heat from CO2 sensor seems to be high enough to change the temperature reading. How much should it be offset? + // NOTE: Sign isn't relevant. The returned temperature will always be shifted down. + const float temperature_offset = TEMPERATURE_OFFSET; // [K] +#else + const float temperature_offset = -3.0; // [K] Temperature measured by sensor is usually at least 3K too high. +#endif + const bool auto_calibrate_sensor = AUTO_CALIBRATE_SENSOR; // [true / false] +} + +namespace sensor { + SCD30 scd30; + int16_t co2 = 0; + float temperature = 0; + float humidity = 0; + String timestamp = ""; + int16_t stable_measurements = 0; + uint32_t waiting_color = color::blue; + bool should_calibrate = false; + + void initialize() { +#if defined(ESP8266) + Wire.begin(12, 14); // ESP8266 - D6, D5; +#endif +#if defined(ESP32) + Wire.begin(21, 22); // ESP32 + /** + * SCD30 ESP32 + * VCC --- 3V3 + * GND --- GND + * SCL --- SCL (GPIO22) //NOTE: GPIO3 Would be more convenient (right next to GND) + * SDA --- SDA (GPIO21) //NOTE: GPIO1 would be more convenient (right next to GPO3) + */ +#endif + + // CO2 + if (scd30.begin(config::auto_calibrate_sensor) == false) { + Serial.println("Air sensor not detected. Please check wiring. Freezing..."); + while (1) { + led_effects::showWaitingLED(color::red); + } + } + + // SCD30 has its own timer. + //NOTE: The timer seems to be inaccurate, though, possibly depending on voltage. Should it be offset? + Serial.println(); + Serial.print(F("Setting SCD30 timestep to ")); + Serial.print(config::measurement_timestep); + Serial.println(" s."); + scd30.setMeasurementInterval(config::measurement_timestep); // [s] + + Serial.print(F("Setting temperature offset to -")); + Serial.print(abs(config::temperature_offset)); + Serial.println(" K."); + scd30.setTemperatureOffset(abs(config::temperature_offset)); // setTemperatureOffset only accepts positive numbers, but shifts the temperature down. + delay(100); + + Serial.print(F("Temperature offset is : -")); + Serial.print(scd30.getTemperatureOffset()); + Serial.println(" K"); + + Serial.print(F("Auto-calibration is ")); + Serial.println(config::auto_calibrate_sensor ? "ON." : "OFF."); + } + + //NOTE: should timer deviation be used to adjust measurement_timestep? + void checkTimerDeviation() { + static int32_t previous_measurement_at = 0; + int32_t now = millis(); + Serial.print("Measurement time offset : "); + Serial.print(now - previous_measurement_at - config::measurement_timestep * 1000); + Serial.println(" ms."); + previous_measurement_at = now; + } + + void countStableMeasurements() { + static int16_t previous_co2 = 0; + if (co2 > (previous_co2 - 30) && co2 < (previous_co2 + 30)) { + stable_measurements++; + Serial.print(F("Number of stable measurements : ")); + Serial.println(stable_measurements); + waiting_color = color::green; + } else { + stable_measurements = 0; + waiting_color = color::red; + } + previous_co2 = co2; + } + + void startCalibrationProcess() { + /** From the sensor documentation: + * For best results, the sensor has to be run in a stable environment in continuous mode at + * a measurement rate of 2s for at least two minutes before applying the FRC command and sending the reference value. + */ + Serial.println(F("Setting SCD30 timestep to 2s, prior to calibration.")); + scd30.setMeasurementInterval(2); // [s] The change will only take effect after next measurement. + Serial.println(F("Waiting until the measurements are stable for at least 2 minutes.")); + Serial.println(F("It could take a very long time.")); + should_calibrate = true; + } + + void calibrateAndRestart() { + Serial.print(F("Calibrating SCD30 now...")); + scd30.setAltitudeCompensation(config::altitude_above_sea_level); + scd30.setForcedRecalibrationFactor(config::co2_calibration_level); + Serial.println(F(" Done!")); + Serial.println(F("Sensor calibrated.")); + ESP.restart(); // softer than ESP.reset + } + + void logToSerial() { + Serial.println(timestamp); + Serial.print(F("co2(ppm): ")); + Serial.print(co2); + Serial.print(F(" temp(C): ")); + Serial.print(temperature, 1); + Serial.print(F(" humidity(%): ")); + Serial.println(humidity, 1); + } + + void displayCO2OnLedRing() { + if (co2 < 250) { + // Sensor should be calibrated. + led_effects::showWaitingLED(color::magenta); + return; + } + /** + * Display data, even if it's "old" (with breathing). + * Those effects include a short delay. + */ + if (co2 < 2000) { + led_effects::displayCO2color(co2); + led_effects::breathe(co2); + } else { + // >= 2000: entire ring blinks red + led_effects::redAlert(); + } + } + + /** Gets fresh data if available, checks calibration status, displays CO2 levels. + * Returns true if fresh data is available, for further processing (e.g. MQTT, CSV or LoRa) + */ + bool processData() { + bool freshData = scd30.dataAvailable(); + + if (freshData) { + // checkTimerDeviation(); + timestamp = ntp::getLocalTime(); + co2 = scd30.getCO2(); + temperature = scd30.getTemperature(); + humidity = scd30.getHumidity(); + } + + //NOTE: Data is available, but it's sometimes erroneous: the sensor outputs zero ppm but non-zero temperature and non-zero humidity. + if (co2 <= 0) { + // No measurement yet. Waiting. + led_effects::showWaitingLED(color::blue); + return false; + } + + /** + * Fresh data. Log it and send it if needed. + */ + if (freshData) { + if (should_calibrate) { + countStableMeasurements(); + } + logToSerial(); + } + + if (should_calibrate) { + if (stable_measurements == 60) { + calibrateAndRestart(); + } + led_effects::showWaitingLED(waiting_color); + return false; + } + + displayCO2OnLedRing(); + return freshData; + } +} diff --git a/co2_sensor.h b/ampel-firmware/co2_sensor.h similarity index 79% rename from co2_sensor.h rename to ampel-firmware/co2_sensor.h index 97ea97f2c8b7762c7bab0470d293a558ad854481..f11eb815582358a2732b34ec4afe790773683c0d 100644 --- a/co2_sensor.h +++ b/ampel-firmware/co2_sensor.h @@ -6,13 +6,13 @@ #include "src/lib/SparkFun_SCD30_Arduino_Library/src/SparkFun_SCD30_Arduino_Library.h" // From: http://librarymanager/All#SparkFun_SCD30 #include "config.h" #include "led_effects.h" -#include "csv_writer.h" // To close filesystem before restart. +#include "util.h" #include <Wire.h> namespace config { - extern uint16_t measurement_timestep; // [s] Value between 2 and 1800 (range for SCD30 sensor) + extern uint16_t measurement_timestep; // [s] Value between 2 and 1800 (range for SCD30 sensor) extern const bool auto_calibrate_sensor; // [true / false] - extern uint16_t co2_calibration_level; // [ppm] + extern uint16_t co2_calibration_level; // [ppm] extern const float temperature_offset; // [K] Sign isn't relevant. } @@ -24,6 +24,7 @@ namespace sensor { extern String timestamp; void initialize(); + bool processData(); void startCalibrationProcess(); } #endif diff --git a/config.public.h b/ampel-firmware/config.public.h similarity index 53% rename from config.public.h rename to ampel-firmware/config.public.h index 729fd87f1abb310dd18e113149fdc3e620f5e426..6b87993ce03f4489ae242a48b2c6f2c87afe6e63 100644 --- a/config.public.h +++ b/ampel-firmware/config.public.h @@ -3,14 +3,24 @@ // This file is a config template, and can be copied to config.h. Please don't save any important password in this template. +/** + * SERVICES + */ + +// Comment or remove those lines if you want to disable the corresponding services +# define AMPEL_WIFI // Should ESP connect to WiFi? It allows the Ampel to get time from an NTP server. +# define AMPEL_HTTP // Should HTTP web server be started? (AMPEL_WIFI should be enabled too) +# define AMPEL_MQTT // Should data be sent over MQTT? (AMPEL_WIFI should be enabled too) +# define AMPEL_CSV // Should data be logged as CSV, on the ESP flash memory? +// # define AMPEL_LORAWAN // Should data be sent over LoRaWAN? (Requires ESP32 + LoRa modem, and "MCCI LoRaWAN LMIC library") + /** * WIFI */ -// Setting WIFI_SSID to "NO_WIFI" will disable WiFi completely, and all other dependent services (MQTT, HTTP, NTP, ...) -# define WIFI_SSID "NO_WIFI" +# define WIFI_SSID "MY_SSID" # define WIFI_PASSWORD "P4SSW0RD" -# define WIFI_TIMEOUT 20 // [s] +# define WIFI_TIMEOUT 30 // [s] /** * Sensor @@ -20,15 +30,10 @@ //NOTE: SCD30 timer does not seem to be very precise. Variations may occur. # define MEASUREMENT_TIMESTEP 60 // [s] Value between 2 and 1800 (range for SCD30 sensor) -// How often measurements should be sent to MQTT server? -// Probably a good idea to use a multiple of MEASUREMENT_TIMESTEP, so that averages can be calculated -// Set to 0 if you want to send values after each measurement -// # define SENDING_INTERVAL MEASUREMENT_TIMESTEP * 5 // [s] -# define SENDING_INTERVAL 300 // [s] - // How often should measurements be appended to CSV ? // Probably a good idea to use a multiple of MEASUREMENT_TIMESTEP, so that averages can be calculated // Set to 0 if you want to send values after each measurement +// WARNING: Writing too often might damage the ESP memory # define CSV_INTERVAL 300 // [s] // Residual heat from CO2 sensor seems to be high enough to change the temperature reading. How much should it be offset? @@ -64,29 +69,26 @@ * available at http://local_ip, with user HTTP_USER and password HTTP_PASSWORD */ -# define HTTP // Comment or remove this line if you want to disable HTTP webserver - // Define empty strings in order to disable authentication, or remove the constants altogether. # define HTTP_USER "co2ampel" # define HTTP_PASSWORD "my_password" /** - * MQTT SERVER + * MQTT */ -# define MQTT // Comment or remove this line if you want to disable MQTT /* - * If MQTT is enabled, co2ampel will publish data every SENDING_INTERVAL seconds. + * If AMPEL_MQTT is enabled, co2ampel will publish data every MQTT_SENDING_INTERVAL seconds. * An MQTT subscriber can then get the data from the corresponding broker, either encrypted or unencrypted: * * ⯠mosquitto_sub -h 'test.mosquitto.org' -p 8883 -t 'CO2sensors/#' --cafile mosquitto.org.crt -v - * CO2sensors/ESPd05cc9 {"time":"2020-12-13 13:14:37+01", "co2":571, "temp":18.9, "rh":50.9} - * CO2sensors/ESPd05cc9 {"time":"2020-12-13 13:14:48+01", "co2":573, "temp":18.9, "rh":50.2} + * CO2sensors/ESPd03cc5 {"time":"2020-12-13 13:14:37+01", "co2":571, "temp":18.9, "rh":50.9} + * CO2sensors/ESPd03cc5 {"time":"2020-12-13 13:14:48+01", "co2":573, "temp":18.9, "rh":50.2} * ... * * ⯠mosquitto_sub -h 'test.mosquitto.org' -t 'CO2sensors/#' -v - * CO2sensors/ESPd05cc9 {"time":"2020-12-13 13:15:09+01", "co2":568, "temp":18.9, "rh":50.1} - * CO2sensors/ESPd05cc9 {"time":"2020-12-13 13:15:20+01", "co2":572, "temp":18.9, "rh":50.3} + * CO2sensors/ESPd03cc5 {"time":"2020-12-13 13:15:09+01", "co2":568, "temp":18.9, "rh":50.1} + * CO2sensors/ESPd03cc5 {"time":"2020-12-13 13:15:20+01", "co2":572, "temp":18.9, "rh":50.3} * ... */ @@ -98,12 +100,45 @@ */ # define ALLOW_MQTT_COMMANDS false +// How often measurements should be sent to MQTT server? +// Probably a good idea to use a multiple of MEASUREMENT_TIMESTEP, so that averages can be calculated +// Set to 0 if you want to send values after each measurement +// # define MQTT_SENDING_INTERVAL MEASUREMENT_TIMESTEP * 5 // [s] +# define MQTT_SENDING_INTERVAL 60 // [s] # define MQTT_SERVER "test.mosquitto.org" // MQTT server URL or IP address # define MQTT_PORT 8883 # define MQTT_USER "" # 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 + */ + +// 1) Requires "MCCI LoRaWAN LMIC library", which will be automatically used with PlatformIO but should be added in "Arduino IDE". +// 2) 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 +// 3) It has been tested with "TTGO ESP32 SX1276 LoRa 868" and will only work with an ESP32 + LoRa modem +// 4) In order to use LoRaWAN, a gateway should be close to the co2ampel, and an account, an application and a device should be registered, +// e.g. on https://www.thethingsnetwork.org/docs/applications/ +// 5) The corresponding keys should be defined in LORAWAN_DEVICE_EUI, LORAWAN_APPLICATION_EUI and LORAWAN_APPLICATION_KEY +// 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 + +// WARNING: If AMPEL_LORAWAN is enabled, you need to modify the 3 following constants! +// 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/csv_writer.cpp b/ampel-firmware/csv_writer.cpp similarity index 73% rename from csv_writer.cpp rename to ampel-firmware/csv_writer.cpp index 8e2ac43de65186af53b0409d06c52bcf77392037..81b7278a3aa08819fee0474ef8af8c24cfdc0b96 100644 --- a/csv_writer.cpp +++ b/ampel-firmware/csv_writer.cpp @@ -1,5 +1,7 @@ #include "csv_writer.h" +//TODO: Allow CSV download via USB Serial, when requested (e.g. via a Python script) + namespace config { // Values should be defined in config.h uint16_t csv_interval = CSV_INTERVAL; // [s] @@ -45,9 +47,7 @@ namespace csv_writer { } } } -#endif - -#if defined(ESP32) +#elif defined(ESP32) /** * SPECIFIC FUNCTIONS FOR SPIFFS */ @@ -83,7 +83,6 @@ namespace csv_writer { const String filename = "/" + SENSOR_ID + ".csv"; int getAvailableSpace() { - //TODO : Check if too low? return getTotalSpace() - getUsedSpace(); } @@ -135,32 +134,36 @@ namespace csv_writer { return csv_file; } - void logIfTimeHasCome(const String &timeStamp, int16_t co2, float temp, float hum) { + void log(const String &timeStamp, const int16_t &co2, const float &temperature, const float &humidity) { + led_effects::onBoardLEDOn(); + File csv_file = openOrCreate(); + char csv_line[42]; + snprintf(csv_line, sizeof(csv_line), "%s;%d;%.1f;%.1f\r\n", timeStamp.c_str(), co2, temperature, humidity); + if (csv_file) { + size_t written_bytes = csv_file.print(csv_line); + csv_file.close(); + if (written_bytes == 0) { + Serial.println(F("Nothing written. Disk full?")); + } else { + Serial.println(F("Wrote file content:")); + Serial.print(csv_line); + last_successful_write = ntp::getLocalTime(); + } + updateFsInfo(); + delay(50); + } else { + //NOTE: Can it ever happen that outfile is false? + Serial.println(F("Problem on create file!")); + } + led_effects::onBoardLEDOff(); + } + + void logIfTimeHasCome(const String &timeStamp, const int16_t &co2, const float &temperature, const float &humidity) { unsigned long now = seconds(); //TODO: Write average since last CSV write? if (now - last_written_at > config::csv_interval) { last_written_at = now; - LedEffects::onBoardLEDOn(); - File csv_file = openOrCreate(); - char csv_line[42]; - snprintf(csv_line, sizeof(csv_line), "%s;%d;%.1f;%.1f\r\n", timeStamp.c_str(), co2, temp, hum); - if (csv_file) { - size_t written_bytes = csv_file.print(csv_line); - csv_file.close(); - if (written_bytes == 0) { - Serial.println(F("Nothing written. Disk full?")); - } else { - Serial.println(F("Wrote file content:")); - Serial.print(csv_line); - last_successful_write = ntp::getLocalTime(); - } - updateFsInfo(); - delay(50); - } else { - //NOTE: Can it ever happen that outfile is false? - Serial.println(F("Problem on create file!")); - } - LedEffects::onBoardLEDOff(); + log(timeStamp, co2, temperature, humidity); } } } diff --git a/csv_writer.h b/ampel-firmware/csv_writer.h similarity index 72% rename from csv_writer.h rename to ampel-firmware/csv_writer.h index 6478d88dd47e3985807701f8dfba09c3e824ea85..e0e0d5314ae9060ca8c39315737da2a73beb5d99 100644 --- a/csv_writer.h +++ b/ampel-firmware/csv_writer.h @@ -11,15 +11,17 @@ # error Board should be either ESP8266 or ESP832 #endif -#include "led_effects.h" #include "config.h" +#include "util.h" +#include "led_effects.h" + namespace config { - extern uint16_t csv_interval; // [s] + extern uint16_t csv_interval; // [s] } namespace csv_writer { extern String last_successful_write; void initialize(); - void logIfTimeHasCome(const String &timeStamp, int16_t co2, float temp, float hum); + void logIfTimeHasCome(const String &timeStamp, const int16_t &co2, const float &temperature, const float &humidity); int getAvailableSpace(); extern const String filename; } diff --git a/led_effects.cpp b/ampel-firmware/led_effects.cpp similarity index 75% rename from led_effects.cpp rename to ampel-firmware/led_effects.cpp index 2b15d6fcf2c7325f5b35c4b14cd2073f35191a69..1410a1475ec6dbae0ef5912373555837636dedd9 100644 --- a/led_effects.cpp +++ b/ampel-firmware/led_effects.cpp @@ -11,34 +11,29 @@ namespace config { /***************************************************************** * Configuration (calculated from above values) * *****************************************************************/ -namespace config //TODO: Use a class instead. NightMode could then be another state. +namespace config //NOTE: Use a class instead? NightMode could then be another state. { - const float average_brightness = 0.5 * (config::max_brightness + config::min_brightness); - const float brightness_amplitude = 0.5 * (config::max_brightness - config::min_brightness); + const uint8_t brightness_amplitude = config::max_brightness - config::min_brightness; bool night_mode = false; } -// Adafruit NeoPixel (Arduino library for controlling single-wire-based LED pixels and strip) -// https://github.com/adafruit/Adafruit_NeoPixel -// Documentation : http://adafruit.github.io/Adafruit_NeoPixel/html/class_adafruit___neo_pixel.html - -// NeoPixels on GPIO05, aka D1 on ESP8266 or 5 on ESP32. +#if defined(ESP8266) +// NeoPixels on GPIO05, aka D1 on ESP8266. const int NEOPIXELS_PIN = 5; +#elif defined(ESP32) +// NeoPixels on GPIO23 on ESP32. To avoid conflict with LoRa_SCK on TTGO. +const int NEOPIXELS_PIN = 23; +#endif + const int NUMPIXELS = 12; //NOTE: One value has been prepended, to make calculations easier and avoid out of bounds index. const uint16_t CO2_TICKS[NUMPIXELS + 1] = { 0, 500, 600, 700, 800, 900, 1000, 1200, 1400, 1600, 1800, 2000, 2200 }; // [ppm] // For a given LED, which color should be displayed? First LED will be pure green (hue angle 120°), // last 4 LEDs will be pure red (hue angle 0°), LEDs in-between will be yellowish. -const uint16_t LED_HUES[NUMPIXELS] = { 21845, 19114, 16383, 13653, 10922, 8191, 5461, 2730, 0, 0, 0, 0 }; // [hue angle] +const uint16_t LED_HUES[NUMPIXELS] = { 21845U, 19114U, 16383U, 13653U, 10922U, 8191U, 5461U, 2730U, 0, 0, 0, 0 }; // [hue angle] Adafruit_NeoPixel pixels(NUMPIXELS, NEOPIXELS_PIN, NEO_GRB + NEO_KHZ800); -namespace counter { - uint16_t wheel_offset = 0; - uint16_t kitt_offset = 0; - uint16_t breathing_offset = 0; -} // namespace counter - -namespace LedEffects { +namespace led_effects { //On-board LED on D4, aka GPIO02 const int ONBOARD_LED_PIN = 2; @@ -47,25 +42,39 @@ 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() { + pixels.clear(); + pixels.show(); + onBoardLEDOff(); } void setupRing() { pixels.begin(); pixels.setBrightness(config::max_brightness); - pixels.clear(); + LEDsOff(); } void toggleNightMode() { config::night_mode = !config::night_mode; if (config::night_mode) { Serial.println(F("NIGHT MODE!")); - pixels.clear(); - pixels.show(); + LEDsOff(); } else { Serial.println(F("DAY MODE!")); } @@ -77,13 +86,14 @@ namespace LedEffects { if (config::night_mode) { return; } + static uint16_t kitt_offset = 0; pixels.clear(); for (int j = config::kitt_tail; j >= 0; j--) { - int ledNumber = abs((counter::kitt_offset - j + NUMPIXELS) % (2 * NUMPIXELS) - NUMPIXELS) % NUMPIXELS; // Triangular function + int ledNumber = abs((kitt_offset - j + NUMPIXELS) % (2 * NUMPIXELS) - NUMPIXELS) % NUMPIXELS; // Triangular function pixels.setPixelColor(ledNumber, color * pixels.gamma8(255 - j * 76) / 255); } pixels.show(); - counter::kitt_offset += 1; + kitt_offset++; } // Start K.I.T.T. led effect. Red color as default. @@ -129,16 +139,17 @@ namespace LedEffects { pixels.show(); } - void showRainbowWheel(int duration_s, uint16_t hue_increment) { + void showRainbowWheel(int duration_ms, uint16_t hue_increment) { if (config::night_mode) { return; } - unsigned long t0 = seconds(); + static uint16_t wheel_offset = 0; + unsigned long t0 = millis(); pixels.setBrightness(config::max_brightness); - while (seconds() < t0 + duration_s) { + while (millis() < t0 + duration_ms) { for (int i = 0; i < NUMPIXELS; i++) { - pixels.setPixelColor(i, pixels.ColorHSV(i * 65535 / NUMPIXELS + counter::wheel_offset)); - counter::wheel_offset += hue_increment; + pixels.setPixelColor(i, pixels.ColorHSV(i * 65535 / NUMPIXELS + wheel_offset)); + wheel_offset += hue_increment; } pixels.show(); delay(10); @@ -163,14 +174,14 @@ namespace LedEffects { void breathe(int16_t co2) { if (!config::night_mode) { - //TODO: use integer sine - pixels.setBrightness( - static_cast<int>(config::average_brightness - + cos(counter::breathing_offset * 0.1) * config::brightness_amplitude)); + static uint16_t breathing_offset = 0; + uint16_t brightness = config::min_brightness + + pixels.sine8(breathing_offset) * config::brightness_amplitude / 255; + pixels.setBrightness(brightness); pixels.show(); - counter::breathing_offset += 1; + breathing_offset += 3; // breathing speed. +3 looks like slow human breathing. } - delay(co2 > 1600 ? 50 : 100); // faster breathing for higher CO2 values + delay(co2 > 1600 ? 50 : 100); // faster breathing for higher CO2 values } /** @@ -179,7 +190,7 @@ namespace LedEffects { */ int countdownToZero() { if (config::night_mode) { - Serial.println("Night mode. Not doing anything."); + Serial.println(F("Night mode. Not doing anything.")); delay(1000); // Wait for a while, to avoid coming back to this function too many times when button is pressed. return 1; } diff --git a/led_effects.h b/ampel-firmware/led_effects.h similarity index 65% rename from led_effects.h rename to ampel-firmware/led_effects.h index b074f53e163c309947591654ed3d827e3175207d..03aee3dc24a02dc403b0e20105c3013f678488e5 100644 --- a/led_effects.h +++ b/ampel-firmware/led_effects.h @@ -1,8 +1,11 @@ #ifndef LED_EFFECTS_H_INCLUDED #define LED_EFFECTS_H_INCLUDED #include <Arduino.h> -#include "util.h" #include "config.h" + +// Adafruit NeoPixel (Arduino library for controlling single-wire-based LED pixels and strip) +// https://github.com/adafruit/Adafruit_NeoPixel +// Documentation : http://adafruit.github.io/Adafruit_NeoPixel/html/class_adafruit___neo_pixel.html #include "src/lib/Adafruit_NeoPixel/Adafruit_NeoPixel.h" namespace color { @@ -13,11 +16,12 @@ namespace color { const uint32_t magenta = 0xFF00FF; } -namespace LedEffects { +namespace led_effects { void setupOnBoardLED(); void onBoardLEDOff(); void onBoardLEDOn(); void toggleNightMode(); + void LEDsOff(); void setupRing(); void redAlert(); @@ -25,7 +29,7 @@ namespace LedEffects { int countdownToZero(); void showWaitingLED(uint32_t color); void showKITTWheel(uint32_t color, uint16_t duration_s = 2); - void showRainbowWheel(int duration_s = 1, uint16_t hue_increment = 50); + void showRainbowWheel(int duration_ms = 1000, uint16_t hue_increment = 50); void displayCO2color(uint16_t co2); } #endif diff --git a/ampel-firmware/lorawan.cpp b/ampel-firmware/lorawan.cpp new file mode 100644 index 0000000000000000000000000000000000000000..75363a0337ce887c860d38fb7c5a940bed3e642f --- /dev/null +++ b/ampel-firmware/lorawan.cpp @@ -0,0 +1,197 @@ +#include "lorawan.h" +#if defined(AMPEL_LORAWAN) && 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 initialize() { + Serial.println(F("Starting LoRaWAN. Frequency plan : " LMIC_FREQUENCY_PLAN " MHz.")); + + // 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 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; + led_effects::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) + 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; + led_effects::onBoardLEDOff(); + Serial.println(F("EV_TXCANCELED")); + break; + case EV_JOIN_TXCOMPLETE: + waiting_for_confirmation = false; + led_effects::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) { + led_effects::onBoardLEDOn(); + Serial.println(F("LoRa - waiting for OTAA confirmation. Freezing every other service!")); + } + } + + void preparePayload(int16_t co2, float temperature, float humidity) { + // 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(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(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(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 preparePayloadIfTimeHasCome(const int16_t &co2, const float &temperature, const float &humidity) { + static unsigned long last_sent_at = 0; + unsigned long now = seconds(); + if (connected && (now > last_sent_at + config::lorawan_sending_interval)) { + last_sent_at = now; + preparePayload(co2, temperature, humidity); + } + } +} + +void onEvent(ev_t ev) { + lorawan::onEvent(ev); +} +#endif diff --git a/ampel-firmware/lorawan.h b/ampel-firmware/lorawan.h new file mode 100644 index 0000000000000000000000000000000000000000..a4e29ab2c89019202aceddb920c26f49978b085b --- /dev/null +++ b/ampel-firmware/lorawan.h @@ -0,0 +1,49 @@ +#ifndef AMPEL_LORAWAN_H_ +#define AMPEL_LORAWAN_H_ + +#include "config.h" + +#if defined(AMPEL_LORAWAN) && 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> + +#include "led_effects.h" + +#include "util.h" + +namespace config { + extern uint16_t lorawan_sending_interval; // [s] +} + +#if defined(CFG_eu868) +# define LMIC_FREQUENCY_PLAN "Europe 868" +#elif defined(CFG_us915) +# define LMIC_FREQUENCY_PLAN "US 915" +#elif defined(CFG_au915) +# define LMIC_FREQUENCY_PLAN "Australia 915" +#elif defined(CFG_as923) +# define LMIC_FREQUENCY_PLAN "Asia 923" +#elif defined(CFG_kr920) +# define LMIC_FREQUENCY_PLAN "Korea 920" +#elif defined(CFG_in866) +# define LMIC_FREQUENCY_PLAN "India 866" +#else +# error "Region should be specified" +#endif + +namespace lorawan { + extern bool waiting_for_confirmation; + extern bool connected; + extern String last_transmission; + void initialize(); + void process(); + void preparePayloadIfTimeHasCome(const int16_t &co2, const float &temp, const float &hum); +} + +#endif +#endif diff --git a/mqtt.cpp b/ampel-firmware/mqtt.cpp similarity index 87% rename from mqtt.cpp rename to ampel-firmware/mqtt.cpp index 8ae62759c24532713495e24bc6f5151262eac5db..29f609fdf0751bbcebcc0b501c08e12d7212b6a6 100644 --- a/mqtt.cpp +++ b/ampel-firmware/mqtt.cpp @@ -2,7 +2,7 @@ namespace config { // Values should be defined in config.h - uint16_t sending_interval = SENDING_INTERVAL; // [s] + uint16_t sending_interval = MQTT_SENDING_INTERVAL; // [s] //INFO: Listen to every CO2 sensor which is connected to the server: // mosquitto_sub -h MQTT_SERVER -t 'CO2sensors/#' -p 443 --capath /etc/ssl/certs/ -u "MQTT_USER" -P "MQTT_PASSWORD" -v const char *mqtt_server = MQTT_SERVER; @@ -22,6 +22,7 @@ PubSubClient mqttClient(espClient); namespace mqtt { unsigned long last_sent_at = 0; unsigned long last_failed_at = 0; + bool connected = false; String publish_topic; const char *json_sensor_format; @@ -39,19 +40,19 @@ namespace mqtt { void publish(const String ×tamp, int16_t co2, float temperature, float humidity) { if (WiFi.status() == WL_CONNECTED && mqttClient.connected()) { - LedEffects::onBoardLEDOn(); + led_effects::onBoardLEDOn(); Serial.print(F("Publishing MQTT message ... ")); char payload[75]; // Should be enough for json... 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(); + led_effects::onBoardLEDOff(); } } @@ -64,7 +65,7 @@ namespace mqtt { Serial.println("s."); sensor::scd30.setMeasurementInterval(messageString.toInt()); config::measurement_timestep = messageString.toInt(); - LedEffects::showKITTWheel(color::green, 1); + led_effects::showKITTWheel(color::green, 1); } } @@ -74,19 +75,21 @@ namespace mqtt { Serial.print(F("Setting Sending Interval to : ")); Serial.print(config::sending_interval); Serial.println("s."); - LedEffects::showKITTWheel(color::green, 1); + led_effects::showKITTWheel(color::green, 1); } +#ifdef AMPEL_CSV void setCSVinterval(String messageString) { messageString.replace("csv ", ""); config::csv_interval = messageString.toInt(); Serial.print(F("Setting CSV Interval to : ")); Serial.print(config::csv_interval); Serial.println("s."); - LedEffects::showKITTWheel(color::green, 1); + led_effects::showKITTWheel(color::green, 1); } +#endif - void calibrateSensor(String messageString) { + void calibrateSensorToSpecificPPM(String messageString) { messageString.replace("calibrate ", ""); long int calibrationLevel = messageString.toInt(); if (calibrationLevel >= 400 && calibrationLevel <= 2000) { @@ -106,7 +109,7 @@ namespace mqtt { } void sendInfoAboutLocalNetwork() { - char info_topic[60]; // Should be enough for "CO2sensors/ESPd05cc9/info" + char info_topic[60]; // Should be enough for "CO2sensors/ESPd03cc5/info" snprintf(info_topic, sizeof(info_topic), "%s/info", publish_topic.c_str()); char payload[75]; // Should be enough for info json... @@ -128,7 +131,7 @@ namespace mqtt { if (length == 0) { return; } - LedEffects::onBoardLEDOn(); + led_effects::onBoardLEDOn(); Serial.print(F("Message arrived on topic: ")); Serial.print(sub_topic); Serial.print(F(". Message: '")); @@ -143,32 +146,34 @@ namespace mqtt { setCO2forDebugging(messageString); } else if (messageString.startsWith("timer ")) { setTimer(messageString); + } else if (messageString == "calibrate") { + sensor::startCalibrationProcess(); } else if (messageString.startsWith("calibrate ")) { - calibrateSensor(messageString); -// config::atmospheric_co2_concentration + calibrateSensorToSpecificPPM(messageString); } else if (messageString.startsWith("mqtt ")) { setMQTTinterval(messageString); - } else if (messageString.startsWith("csv ")) { - setCSVinterval(messageString); } else if (messageString == "publish") { Serial.println(F("Forcing MQTT publish now.")); publish(sensor::timestamp, sensor::co2, sensor::temperature, sensor::humidity); +#ifdef AMPEL_CSV + } else if (messageString.startsWith("csv ")) { + setCSVinterval(messageString); } else if (messageString == "format_filesystem") { FS_LIB.format(); - LedEffects::showKITTWheel(color::blue, 2); + led_effects::showKITTWheel(color::blue, 2); +#endif } else if (messageString == "night_mode") { - LedEffects::toggleNightMode(); + led_effects::toggleNightMode(); } else if (messageString == "local_ip") { sendInfoAboutLocalNetwork(); } else if (messageString == "reset") { - FS_LIB.end(); - ESP.restart(); + ESP.restart(); // softer than ESP.reset() } else { - LedEffects::showKITTWheel(color::red, 1); + led_effects::showKITTWheel(color::red, 1); Serial.println(F("Message not supported. Doing nothing.")); } delay(50); - LedEffects::onBoardLEDOff(); + led_effects::onBoardLEDOff(); } void reconnect() { @@ -182,15 +187,16 @@ namespace mqtt { } Serial.print(F("Attempting MQTT connection...")); - LedEffects::onBoardLEDOn(); + led_effects::onBoardLEDOn(); // Wait for connection, at most 15s (default) mqttClient.connect(publish_topic.c_str(), config::mqtt_user, config::mqtt_password); - LedEffects::onBoardLEDOff(); + led_effects::onBoardLEDOff(); + + connected = mqttClient.connected(); - if (mqttClient.connected()) { - //TODO: Send local IP? + if (connected) { if (config::allow_mqtt_commands) { - char control_topic[60]; // Should be enough for "CO2sensors/ESPd05cc9/control" + char control_topic[60]; // Should be enough for "CO2sensors/ESPd03cc5/control" snprintf(control_topic, sizeof(control_topic), "%s/control", publish_topic.c_str()); mqttClient.subscribe(control_topic); mqttClient.setCallback(controlSensorCallback); @@ -207,7 +213,7 @@ namespace mqtt { } } - void publishIfTimeHasCome(const String &timeStamp, int16_t co2, float temp, float hum) { + void publishIfTimeHasCome(const String &timeStamp, const int16_t &co2, const float &temp, const float &hum) { // Send message via MQTT according to sending interval unsigned long now = seconds(); //TODO: Send average since last MQTT message? diff --git a/mqtt.h b/ampel-firmware/mqtt.h similarity index 61% rename from mqtt.h rename to ampel-firmware/mqtt.h index 6e43644336620607188b34b1bccfe650141eb546..2899a9996bbf8d2a484f5fc79b619d9647ee70a7 100644 --- a/mqtt.h +++ b/ampel-firmware/mqtt.h @@ -4,17 +4,20 @@ #include <Arduino.h> #include "config.h" #include "led_effects.h" -#include "csv_writer.h" +#ifdef AMPEL_CSV +# include "csv_writer.h" +#endif #include "co2_sensor.h" #include "src/lib/PubSubClient/src/PubSubClient.h" #include "wifi_util.h" namespace config { - extern uint16_t sending_interval; // [s] + extern uint16_t sending_interval; // [s] } namespace mqtt { extern String last_successful_publish; + extern bool connected; void initialize(String &topic); void keepConnection(); - void publishIfTimeHasCome(const String &timeStamp, int16_t co2, float temp, float hum); + void publishIfTimeHasCome(const String &timeStamp, const int16_t &co2, const float &temp, const float &hum); } #endif diff --git a/src/lib/Adafruit_NeoPixel/Adafruit_NeoPixel.cpp b/ampel-firmware/src/lib/Adafruit_NeoPixel/Adafruit_NeoPixel.cpp similarity index 100% rename from src/lib/Adafruit_NeoPixel/Adafruit_NeoPixel.cpp rename to ampel-firmware/src/lib/Adafruit_NeoPixel/Adafruit_NeoPixel.cpp diff --git a/src/lib/Adafruit_NeoPixel/Adafruit_NeoPixel.h b/ampel-firmware/src/lib/Adafruit_NeoPixel/Adafruit_NeoPixel.h similarity index 100% rename from src/lib/Adafruit_NeoPixel/Adafruit_NeoPixel.h rename to ampel-firmware/src/lib/Adafruit_NeoPixel/Adafruit_NeoPixel.h diff --git a/src/lib/Adafruit_NeoPixel/CONTRIBUTING.md b/ampel-firmware/src/lib/Adafruit_NeoPixel/CONTRIBUTING.md similarity index 100% rename from src/lib/Adafruit_NeoPixel/CONTRIBUTING.md rename to ampel-firmware/src/lib/Adafruit_NeoPixel/CONTRIBUTING.md diff --git a/src/lib/Adafruit_NeoPixel/COPYING b/ampel-firmware/src/lib/Adafruit_NeoPixel/COPYING similarity index 100% rename from src/lib/Adafruit_NeoPixel/COPYING rename to ampel-firmware/src/lib/Adafruit_NeoPixel/COPYING diff --git a/src/lib/Adafruit_NeoPixel/README.md b/ampel-firmware/src/lib/Adafruit_NeoPixel/README.md similarity index 100% rename from src/lib/Adafruit_NeoPixel/README.md rename to ampel-firmware/src/lib/Adafruit_NeoPixel/README.md diff --git a/src/lib/Adafruit_NeoPixel/esp.c b/ampel-firmware/src/lib/Adafruit_NeoPixel/esp.c similarity index 100% rename from src/lib/Adafruit_NeoPixel/esp.c rename to ampel-firmware/src/lib/Adafruit_NeoPixel/esp.c diff --git a/src/lib/Adafruit_NeoPixel/esp8266.c b/ampel-firmware/src/lib/Adafruit_NeoPixel/esp8266.c similarity index 100% rename from src/lib/Adafruit_NeoPixel/esp8266.c rename to ampel-firmware/src/lib/Adafruit_NeoPixel/esp8266.c diff --git a/src/lib/Adafruit_NeoPixel/kendyte_k210.c b/ampel-firmware/src/lib/Adafruit_NeoPixel/kendyte_k210.c similarity index 100% rename from src/lib/Adafruit_NeoPixel/kendyte_k210.c rename to ampel-firmware/src/lib/Adafruit_NeoPixel/kendyte_k210.c diff --git a/src/lib/Adafruit_NeoPixel/keywords.txt b/ampel-firmware/src/lib/Adafruit_NeoPixel/keywords.txt similarity index 100% rename from src/lib/Adafruit_NeoPixel/keywords.txt rename to ampel-firmware/src/lib/Adafruit_NeoPixel/keywords.txt diff --git a/src/lib/Adafruit_NeoPixel/library.properties b/ampel-firmware/src/lib/Adafruit_NeoPixel/library.properties similarity index 100% rename from src/lib/Adafruit_NeoPixel/library.properties rename to ampel-firmware/src/lib/Adafruit_NeoPixel/library.properties diff --git a/src/lib/NTPClient-master/.travis.yml b/ampel-firmware/src/lib/NTPClient-master/.travis.yml similarity index 100% rename from src/lib/NTPClient-master/.travis.yml rename to ampel-firmware/src/lib/NTPClient-master/.travis.yml diff --git a/src/lib/NTPClient-master/CHANGELOG b/ampel-firmware/src/lib/NTPClient-master/CHANGELOG similarity index 100% rename from src/lib/NTPClient-master/CHANGELOG rename to ampel-firmware/src/lib/NTPClient-master/CHANGELOG diff --git a/src/lib/NTPClient-master/NTPClient.cpp b/ampel-firmware/src/lib/NTPClient-master/NTPClient.cpp similarity index 100% rename from src/lib/NTPClient-master/NTPClient.cpp rename to ampel-firmware/src/lib/NTPClient-master/NTPClient.cpp diff --git a/src/lib/NTPClient-master/NTPClient.h b/ampel-firmware/src/lib/NTPClient-master/NTPClient.h similarity index 100% rename from src/lib/NTPClient-master/NTPClient.h rename to ampel-firmware/src/lib/NTPClient-master/NTPClient.h diff --git a/src/lib/NTPClient-master/README.md b/ampel-firmware/src/lib/NTPClient-master/README.md similarity index 100% rename from src/lib/NTPClient-master/README.md rename to ampel-firmware/src/lib/NTPClient-master/README.md diff --git a/src/lib/NTPClient-master/keywords.txt b/ampel-firmware/src/lib/NTPClient-master/keywords.txt similarity index 100% rename from src/lib/NTPClient-master/keywords.txt rename to ampel-firmware/src/lib/NTPClient-master/keywords.txt diff --git a/src/lib/NTPClient-master/library.json b/ampel-firmware/src/lib/NTPClient-master/library.json similarity index 100% rename from src/lib/NTPClient-master/library.json rename to ampel-firmware/src/lib/NTPClient-master/library.json diff --git a/src/lib/NTPClient-master/library.properties b/ampel-firmware/src/lib/NTPClient-master/library.properties similarity index 100% rename from src/lib/NTPClient-master/library.properties rename to ampel-firmware/src/lib/NTPClient-master/library.properties diff --git a/src/lib/PubSubClient/CHANGES.txt b/ampel-firmware/src/lib/PubSubClient/CHANGES.txt similarity index 100% rename from src/lib/PubSubClient/CHANGES.txt rename to ampel-firmware/src/lib/PubSubClient/CHANGES.txt diff --git a/src/lib/PubSubClient/LICENSE.txt b/ampel-firmware/src/lib/PubSubClient/LICENSE.txt similarity index 100% rename from src/lib/PubSubClient/LICENSE.txt rename to ampel-firmware/src/lib/PubSubClient/LICENSE.txt diff --git a/src/lib/PubSubClient/README.md b/ampel-firmware/src/lib/PubSubClient/README.md similarity index 100% rename from src/lib/PubSubClient/README.md rename to ampel-firmware/src/lib/PubSubClient/README.md diff --git a/src/lib/PubSubClient/keywords.txt b/ampel-firmware/src/lib/PubSubClient/keywords.txt similarity index 100% rename from src/lib/PubSubClient/keywords.txt rename to ampel-firmware/src/lib/PubSubClient/keywords.txt diff --git a/src/lib/PubSubClient/library.json b/ampel-firmware/src/lib/PubSubClient/library.json similarity index 100% rename from src/lib/PubSubClient/library.json rename to ampel-firmware/src/lib/PubSubClient/library.json diff --git a/src/lib/PubSubClient/library.properties b/ampel-firmware/src/lib/PubSubClient/library.properties similarity index 100% rename from src/lib/PubSubClient/library.properties rename to ampel-firmware/src/lib/PubSubClient/library.properties diff --git a/src/lib/PubSubClient/src/PubSubClient.cpp b/ampel-firmware/src/lib/PubSubClient/src/PubSubClient.cpp similarity index 100% rename from src/lib/PubSubClient/src/PubSubClient.cpp rename to ampel-firmware/src/lib/PubSubClient/src/PubSubClient.cpp diff --git a/src/lib/PubSubClient/src/PubSubClient.h b/ampel-firmware/src/lib/PubSubClient/src/PubSubClient.h similarity index 100% rename from src/lib/PubSubClient/src/PubSubClient.h rename to ampel-firmware/src/lib/PubSubClient/src/PubSubClient.h diff --git a/src/lib/SparkFun_SCD30_Arduino_Library/LICENSE.md b/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/LICENSE.md similarity index 100% rename from src/lib/SparkFun_SCD30_Arduino_Library/LICENSE.md rename to ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/LICENSE.md diff --git a/src/lib/SparkFun_SCD30_Arduino_Library/README.md b/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/README.md similarity index 100% rename from src/lib/SparkFun_SCD30_Arduino_Library/README.md rename to ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/README.md diff --git a/src/lib/SparkFun_SCD30_Arduino_Library/documents/Sensirion_CO2_Sensors_SCD30_Preliminary-Datasheet.pdf b/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/documents/Sensirion_CO2_Sensors_SCD30_Preliminary-Datasheet.pdf similarity index 100% rename from src/lib/SparkFun_SCD30_Arduino_Library/documents/Sensirion_CO2_Sensors_SCD30_Preliminary-Datasheet.pdf rename to ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/documents/Sensirion_CO2_Sensors_SCD30_Preliminary-Datasheet.pdf diff --git a/src/lib/SparkFun_SCD30_Arduino_Library/keywords.txt b/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/keywords.txt similarity index 100% rename from src/lib/SparkFun_SCD30_Arduino_Library/keywords.txt rename to ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/keywords.txt diff --git a/src/lib/SparkFun_SCD30_Arduino_Library/library.properties b/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/library.properties similarity index 100% rename from src/lib/SparkFun_SCD30_Arduino_Library/library.properties rename to ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/library.properties diff --git a/src/lib/SparkFun_SCD30_Arduino_Library/src/SparkFun_SCD30_Arduino_Library.cpp b/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/src/SparkFun_SCD30_Arduino_Library.cpp similarity index 100% rename from src/lib/SparkFun_SCD30_Arduino_Library/src/SparkFun_SCD30_Arduino_Library.cpp rename to ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/src/SparkFun_SCD30_Arduino_Library.cpp diff --git a/src/lib/SparkFun_SCD30_Arduino_Library/src/SparkFun_SCD30_Arduino_Library.h b/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/src/SparkFun_SCD30_Arduino_Library.h similarity index 100% rename from src/lib/SparkFun_SCD30_Arduino_Library/src/SparkFun_SCD30_Arduino_Library.h rename to ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/src/SparkFun_SCD30_Arduino_Library.h diff --git a/util.cpp b/ampel-firmware/util.cpp similarity index 100% rename from util.cpp rename to ampel-firmware/util.cpp diff --git a/util.h b/ampel-firmware/util.h similarity index 56% rename from util.h rename to ampel-firmware/util.h index 140ff672a6093985a246d64c7c2f1195aee1a288..0ffe5eb4d35d2243512035c07e09c1d44464fea8 100644 --- a/util.h +++ b/ampel-firmware/util.h @@ -2,16 +2,17 @@ #define AMPEL_UTIL_H_INCLUDED #include <Arduino.h> #include "config.h" -#include "wifi_util.h" // To get MAC -#include <WiFiUdp.h> //required for NTP +#include <WiFiUdp.h> // required for NTP #include "src/lib/NTPClient-master/NTPClient.h" // NTP #if defined(ESP8266) # define BOARD "ESP8266" +# include <ESP8266WiFi.h> // required to get MAC address # define get_free_heap_size() system_get_free_heap_size() #elif defined(ESP32) # define BOARD "ESP32" +# include <WiFi.h> // required to get MAC address # define get_free_heap_size() esp_get_free_heap_size() #else # define BOARD "Unknown" @@ -23,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/ampel-firmware/web_server.cpp similarity index 57% rename from web_server.cpp rename to ampel-firmware/web_server.cpp index 04e7bd802c200d0f1475ad95bc3fae7d3cb2547b..7501263d6abebc3c0849726ecf65eb1cce6f28c9 100644 --- a/web_server.cpp +++ b/ampel-firmware/web_server.cpp @@ -21,14 +21,16 @@ namespace web_server { const char *body_template; const char *script_template; void handleWebServerRoot(); - void handleWebServerCSV(); void handlePageNotFound(); + +#ifdef AMPEL_CSV void handleDeleteCSV(); + void handleWebServerCSV(); +#endif #if defined(ESP8266) ESP8266WebServer http(80); // Create a webserver object that listens for HTTP request on port 80 -#endif -#if defined(ESP32) +#elif defined(ESP32) WebServer http(80); #endif @@ -41,9 +43,9 @@ namespace web_server { PSTR("<!doctype html><html lang=en>" "<head>\n" "<title>%d ppm - CO2 SENSOR - %s - %s</title>\n" - "<meta charset='UTF-8'>" + "<meta charset='UTF-8'>\n" // HfT Favicon - "<link rel='icon' type='image/png' sizes='16x16' href='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAHtJREFUOE9jvMnA+5+BAsBIFQMkl85h+P3kGcOb8jqwW+TPH2H4de0GA29UGNxtfx49YWCRk0HwHz5iuKegwwB2AS4DkA2F6VR6cAWsEQbgBqDY9vARw/ejJ+Au+LxsFcPz6BSwHpwGYPMCSS6gyAAKYhESiKMGjPgwAADopHVhn5ynEwAAAABJRU5ErkJggg=='/>" + "<link rel='icon' type='image/png' sizes='16x16' href='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAHtJREFUOE9jvMnA+5+BAsBIFQMkl85h+P3kGcOb8jqwW+TPH2H4de0GA29UGNxtfx49YWCRk0HwHz5iuKegwwB2AS4DkA2F6VR6cAWsEQbgBqDY9vARw/ejJ+Au+LxsFcPz6BSwHpwGYPMCSS6gyAAKYhESiKMGjPgwAADopHVhn5ynEwAAAABJRU5ErkJggg=='/>\n" // Responsive grid: "<link rel='stylesheet' href='https://unpkg.com/purecss@2.0.3/build/pure-min.css'>\n" "<link rel='stylesheet' href='https://unpkg.com/purecss@2.0.3/build/grids-responsive-min.css'>\n" @@ -56,13 +58,21 @@ namespace web_server { "</head>\n" "<body>\n" - "<div class='pure-g'><div class='pure-u-1'><div class='pure-menu'><p class='pure-menu-heading'>HfT-Stuttgart CO<sub>2</sub> sensor</p></div></div>\n" + "<div class='pure-g'><div class='pure-u-1'><div class='pure-menu'><p class='pure-menu-heading'>HfT-Stuttgart CO<sub>2</sub> Ampel</p></div></div>\n" "<div class='pure-u-1'><ul class='pure-menu pure-menu-horizontal pure-menu-list'>\n" - "<li class='pure-menu-item'><a href='#graph' class='pure-menu-link'>Graph</a></li>\n" "<li class='pure-menu-item'><a href='#table' class='pure-menu-link'>Info</a></li>\n" +#ifdef AMPEL_CSV + "<li class='pure-menu-item'><a href='#graph' class='pure-menu-link'>Graph</a></li>\n" "<li class='pure-menu-item'><a href='#log' class='pure-menu-link'>Log</a></li>\n" "<li class='pure-menu-item'><a href='./%s' class='pure-menu-link'>Download CSV</a></li>\n" - "</ul></div></div>\n"); +#endif + "<li class='pure-menu-item' id='led'>⬤</li>\n" // LED + "</ul></div></div>\n" + "<script>\n" + // Show a colored dot on the webpage, with a similar color than on LED Ring. + "hue=(1-(Math.min(Math.max(parseInt(document.title),500),1600)-500)/1100)*120;\n" + "document.getElementById('led').style.color=['hsl(',hue,',100%%,50%%)'].join('');\n" + "</script>\n"); body_template = PSTR("<div class='pure-g'>\n" @@ -71,81 +81,102 @@ namespace web_server { "<div class='pure-g'>\n" //Sensor table "<table id='table' class='pure-table-striped pure-u-1 pure-u-md-1-2'>\n" - "<tr><th>Sensor</th><th>%s</th></tr>\n" + "<tr><th colspan='2'>%s</th></tr>\n" "<tr><td>CO<sub>2</sub> concentration</td><td>%5d ppm</td></tr>\n" "<tr><td>Temperature</td><td>%.1f℃</td></tr>\n" "<tr><td>Humidity</td><td>%.1f%%</td></tr>\n" "<tr><td>Last measurement</td><td>%s</td></tr>\n" "<tr><td>Measurement timestep</td><td>%5d s</td></tr>\n" - "<tr><td>Last CSV write</td><td>%s</td></tr>\n" - "<tr><td>CSV timestep</td><td>%5d s</td></tr>\n" -#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" +#ifdef AMPEL_CSV + "<tr><th colspan='2'>CSV</th></tr>\n" + "<tr><td>Last write</td><td>%s</td></tr>\n" + "<tr><td>Timestep</td><td>%5d s</td></tr>\n" + "<tr><td>Available drive space</td><td>%d kB</td></tr>\n" +#endif +#ifdef AMPEL_MQTT + "<tr><th colspan='2'>MQTT</th></tr>\n" + "<tr><td>Connected?</td><td>%s</td></tr>\n" + "<tr><td>Last publish</td><td>%s</td></tr>\n" + "<tr><td>Timestep</td><td>%5d s</td></tr>\n" +#endif +#if defined(AMPEL_LORAWAN) && defined(ESP32) + "<tr><th colspan='2'>LoRaWAN</th></tr>\n" + "<tr><td>Connected?</td><td>%s</td></tr>\n" + "<tr><td>Frequency</td><td>%s MHz</td></tr>\n" + "<tr><td>Last transmission</td><td>%s</td></tr>\n" + "<tr><td>Timestep</td><td>%5d s</td></tr>\n" #endif - "<tr><td>Temperature offset</td><td>%.1fK</td></tr>\n" + "<tr><th colspan='2'>Sensor</th></tr>\n" + "<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" "<tr><td>Local IP</td><td><a href='http://%s'>%s</a></td></tr>\n" "<tr><td>Free heap space</td><td>%6d bytes</td></tr>\n" - "<tr><td>Available drive space</td><td>%d kB</td></tr>\n" "<tr><td>Max loop duration</td><td>%5d ms</td></tr>\n" "<tr><td>Board</td><td>%s</td></tr>\n" "<tr><td>Uptime</td><td>%4d h %02d min %02d s</td></tr>\n" "</table>\n" - // CSV placeholder "<div id='log' class='pure-u-1 pure-u-md-1-2'></div>\n" +#ifdef AMPEL_CSV "<form action='/delete_csv' method='POST' onsubmit=\"return confirm('Are you really sure you want to delete all data?') && (document.body.style.cursor = 'wait');\">" "<input type='submit' value='Delete CSV'/>" "</form>\n" +#endif "</div>\n"); - script_template = PSTR("<script>\n" - "document.body.style.cursor = 'default';\n" - "fetch('./%s',{credentials:'include'})\n" - // Get CSV, fill table and fill diagram - ".then(response=>response.text())\n" - ".then(csvText=>csvToTable(csvText))\n" - ".then(htmlTable=>addLogTableToPage(htmlTable))\n" - ".then(_=>Plotly.newPlot('graph',data,layout,{displaylogo:false}))\n" - ".catch(e=>console.error(e));\n" - "xs=[];\n" - "data=[{x:xs,y:[],type:'scatter',name:'CO<sub>2</sub>',line:{color:'#2ca02c'}},\n" - "{x:xs,y:[],type:'scatter',name:'Temperature',yaxis:'y2',line:{color:'#ff7f0e',dash:'dot'}},\n" - "{x:xs,y:[],type:'scatter',name:'Humidity',yaxis:'y3',line:{color:'#1f77b4',dash:'dot'}}];\n" - "layout={height:600,title:'%s',legend:{xanchor:'right',x:0.2,y:1.0},\n" - "xaxis:{domain:[0.0,0.85]},yaxis:{ticksuffix:'ppm',range:[0,2000],dtick:200},\n" - "yaxis2:{overlaying:'y',side:'right',ticksuffix:'°C',position:0.9,anchor:'free',range:[0,30],dtick:3},\n" - "yaxis3:{overlaying:'y',side:'right',ticksuffix:'%%',position:0.95,anchor:'free',range:[0,100],dtick:10}\n" - "};\n" - "function csvToTable(csvText) {\n" - "csvText=csvText.trim();\n" - "lines=csvText.split('\\n');\n" - "table=document.createElement('table');\n" - "table.className='pure-table-striped';\n" - "n=lines.length;\n" - "lines.forEach((line,i)=>{\n" - "fields=line.split(';');\n" - "xs.push(fields[0]);\n" - "data[0]['y'].push(fields[1]);\n" - "data[1]['y'].push(fields[2]);\n" - "data[2]['y'].push(fields[3]);\n" - "if(i>4 && i<n-12){if(i==5){fields=['...','...','...','...']}else{return;}}\n" - "row=document.createElement('tr');\n" - "fields.forEach((field,index)=>{\n" - "cell=document.createElement(i<2?'th':'td');\n" - "cell.appendChild(document.createTextNode(field));\n" - "row.appendChild(cell);});\n" - "table.appendChild(row);});\n" - "return table;}\n" - "function addLogTableToPage(table){document.getElementById('log').appendChild(table);}\n" - "</script>\n" - "</body>\n" - "</html>"); + script_template = + PSTR( + "<a href='https://transfer.hft-stuttgart.de/gitlab/co2ampel/ampel-firmware' target='_blank'>Source code</a>\n" + "<a href='https://transfer.hft-stuttgart.de/gitlab/co2ampel/ampel-documentation' target='_blank'>Documentation</a>\n" +#ifdef AMPEL_CSV + "<script>\n" + "document.body.style.cursor = 'default';\n" + "fetch('./%s',{credentials:'include'})\n" + ".then(response=>response.text())\n" + ".then(csvText=>csvToTable(csvText))\n" + ".then(htmlTable=>addLogTableToPage(htmlTable))\n" + ".then(_=>Plotly.newPlot('graph',data,layout,{displaylogo:false}))\n" + ".catch(e=>console.error(e));\n" + "xs=[];\n" + "data=[{x:xs,y:[],type:'scatter',name:'CO<sub>2</sub>',line:{color:'#2ca02c'}},\n" + "{x:xs,y:[],type:'scatter',name:'Temperature',yaxis:'y2',line:{color:'#ff7f0e',dash:'dot'}},\n" + "{x:xs,y:[],type:'scatter',name:'Humidity',yaxis:'y3',line:{color:'#1f77b4',dash:'dot'}}];\n" + "layout={height:600,title:'%s',legend:{xanchor:'right',x:0.2,y:1.0},\n" + "xaxis:{domain:[0.0,0.85]},yaxis:{ticksuffix:'ppm',range:[0,2000],dtick:200},\n" + "yaxis2:{overlaying:'y',side:'right',ticksuffix:'°C',position:0.9,anchor:'free',range:[0,30],dtick:3},\n" + "yaxis3:{overlaying:'y',side:'right',ticksuffix:'%%',position:0.95,anchor:'free',range:[0,100],dtick:10}\n" + "};\n" + "function csvToTable(csvText) {\n" + "csvText=csvText.trim();\n" + "lines=csvText.split('\\n');\n" + "table=document.createElement('table');\n" + "table.className='pure-table-striped';\n" + "n=lines.length;\n" + "lines.forEach((line,i)=>{\n" + "fields=line.split(';');\n" + "xs.push(fields[0]);\n" + "data[0]['y'].push(fields[1]);\n" + "data[1]['y'].push(fields[2]);\n" + "data[2]['y'].push(fields[3]);\n" + "if(i>4 && i<n-12){if(i==5){fields=['...','...','...','...']}else{return;}}\n" + "row=document.createElement('tr');\n" + "fields.forEach((field,index)=>{\n" + "cell=document.createElement(i<2?'th':'td');\n" + "cell.appendChild(document.createTextNode(field));\n" + "row.appendChild(cell);});\n" + "table.appendChild(row);});\n" + "return table;}\n" + "function addLogTableToPage(table){document.getElementById('log').appendChild(table);}\n" + "</script>\n" +#endif + "</body>\n" + "</html>"); // Web-server http.on("/", handleWebServerRoot); +#ifdef AMPEL_CSV http.on("/" + csv_writer::filename, handleWebServerCSV); http.on("/delete_csv", HTTP_POST, handleDeleteCSV); +#endif http.onNotFound(handlePageNotFound); http.begin(); @@ -171,14 +202,18 @@ namespace web_server { ss -= hh * 3600; uint8_t mm = ss / 60; ss -= mm * 60; - uint16_t available_fs_space = csv_writer::getAvailableSpace() / 1024; //NOTE: Splitting in multiple parts in order to use less RAM char content[2000]; // Update if needed - // Header size : 1383 - Body size : 1246 - Script size : 1648 + // Header size : 1611 - Body size : 1800 - Script size : 1920 + // Header snprintf_P(content, sizeof(content), header_template, sensor::co2, SENSOR_ID.c_str(), - WiFi.localIP().toString().c_str(), csv_writer::filename.c_str()); + WiFi.localIP().toString().c_str() +#ifdef AMPEL_CSV + , csv_writer::filename.c_str() +#endif + ); http.setContentLength(CONTENT_LENGTH_UNKNOWN); http.send_P(200, PSTR("text/html"), content); @@ -186,22 +221,32 @@ namespace web_server { // Body snprintf_P(content, sizeof(content), body_template, SENSOR_ID.c_str(), sensor::co2, sensor::temperature, sensor::humidity, sensor::timestamp.c_str(), config::measurement_timestep, - csv_writer::last_successful_write.c_str(), config::csv_interval, -#ifdef MQTT - mqtt::last_successful_publish.c_str(), config::sending_interval, +#ifdef AMPEL_CSV + csv_writer::last_successful_write.c_str(), config::csv_interval, csv_writer::getAvailableSpace() / 1024, +#endif +#ifdef AMPEL_MQTT + mqtt::connected ? "Yes" : "No", mqtt::last_successful_publish.c_str(), config::sending_interval, +#endif +#if defined(AMPEL_LORAWAN) && defined(ESP32) + lorawan::connected ? "Yes" : "No", LMIC_FREQUENCY_PLAN, 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, - ss); + WiFi.localIP().toString().c_str(), get_free_heap_size(), max_loop_duration, BOARD, hh, mm, ss); http.sendContent(content); // Script - snprintf_P(content, sizeof(content), script_template, csv_writer::filename.c_str(), SENSOR_ID.c_str()); + snprintf_P(content, sizeof(content), script_template +#ifdef AMPEL_CSV + , csv_writer::filename.c_str(), SENSOR_ID.c_str() +#endif + ); http.sendContent(content); } +#ifdef AMPEL_CSV void handleWebServerCSV() { if (!shouldBeAllowed()) { return http.requestAuthentication(DIGEST_AUTH); @@ -226,6 +271,7 @@ namespace web_server { http.sendHeader("Location", "/"); http.send(303); } +#endif void handlePageNotFound() { http.send(404, F("text/plain"), F("404: Not found")); diff --git a/web_server.h b/ampel-firmware/web_server.h similarity index 69% rename from web_server.h rename to ampel-firmware/web_server.h index 69a298138416e50af41babca4bee8767a2eed94a..71c17f0578ac1616f464d0456f25afcfa5930c72 100644 --- a/web_server.h +++ b/ampel-firmware/web_server.h @@ -2,18 +2,22 @@ #define WEB_SERVER_H_ #if defined(ESP8266) # include <ESP8266WebServer.h> -#endif -#if defined(ESP32) +#elif defined(ESP32) # include <WebServer.h> #endif #include "config.h" #include "util.h" #include "co2_sensor.h" -#include "csv_writer.h" -#ifdef MQTT +#ifdef AMPEL_CSV +# include "csv_writer.h" +#endif +#ifdef AMPEL_MQTT # include "mqtt.h" #endif +#ifdef AMPEL_LORAWAN +# include "lorawan.h" +#endif namespace web_server { void initialize(); diff --git a/wifi_util.cpp b/ampel-firmware/wifi_util.cpp similarity index 64% rename from wifi_util.cpp rename to ampel-firmware/wifi_util.cpp index 5399143d4cef07977f4e3115776d685a1fcb126e..60fd8fa691c98a0db45c33aff73caff0194cf496 100644 --- a/wifi_util.cpp +++ b/ampel-firmware/wifi_util.cpp @@ -2,16 +2,11 @@ namespace config { // WiFi config. See 'config.h' if you want to modify those values. -#ifdef WIFI_SSID const char *wifi_ssid = WIFI_SSID; const char *wifi_password = WIFI_PASSWORD; -#else - const char *wifi_ssid = "NO_WIFI"; - const char *wifi_password = ""; -#endif #ifdef WIFI_TIMEOUT - const uint8_t wifi_timeout = WIFI_TIMEOUT; // [s] Will try to connect during wifi_timeout seconds before failing. + const uint8_t wifi_timeout = WIFI_TIMEOUT; // [s] Will try to connect during wifi_timeout seconds before failing. #else const uint8_t wifi_timeout = 60; // [s] Will try to connect during wifi_timeout seconds before failing. #endif @@ -20,18 +15,11 @@ namespace config { // Initialize Wi-Fi void WiFiConnect(const String &hostname) { //NOTE: WiFi Multi could allow multiple SSID and passwords. - if (strcmp(config::wifi_ssid, "NO_WIFI") == 0) { - Serial.println("Please change WIFI_SSID in config.h if you want to connect."); - WiFi.disconnect(true); - WiFi.mode(WIFI_OFF); - return; - } WiFi.persistent(false); // Don't write user & password to Flash. - WiFi.mode(WIFI_STA); // Set ESP8266 to be a WiFi-client only + WiFi.mode(WIFI_STA); // Set ESP to be a WiFi-client only #if defined(ESP8266) WiFi.hostname(hostname); -#endif -#if defined(ESP32) +#elif defined(ESP32) WiFi.setHostname(hostname.c_str()); #endif @@ -41,16 +29,17 @@ void WiFiConnect(const String &hostname) { // Wait for connection, at most wifi_timeout seconds for (int i = 0; i <= config::wifi_timeout && (WiFi.status() != WL_CONNECTED); i++) { - LedEffects::showRainbowWheel(); + led_effects::showRainbowWheel(); Serial.print("."); } if (WiFi.status() == WL_CONNECTED) { - LedEffects::showKITTWheel(color::green); + led_effects::showKITTWheel(color::green); Serial.println(); Serial.print("\nWiFi connected, IP address: "); Serial.println(WiFi.localIP()); } else { - LedEffects::showKITTWheel(color::red); + //TODO: Allow sensor to work as an Access Point, in order to define SSID & password? + led_effects::showKITTWheel(color::red); Serial.println("\nConnection to WiFi failed"); } } diff --git a/ampel-firmware/wifi_util.h b/ampel-firmware/wifi_util.h new file mode 100644 index 0000000000000000000000000000000000000000..7520ed78e070cf552500441f4828bb7d339392b8 --- /dev/null +++ b/ampel-firmware/wifi_util.h @@ -0,0 +1,10 @@ +#ifndef WIFI_UTIL_H_INCLUDED +#define WIFI_UTIL_H_INCLUDED + +#include "config.h" +#include "util.h" +#include "led_effects.h" + +void WiFiConnect(const String &hostname); + +#endif diff --git a/co2_sensor.cpp b/co2_sensor.cpp deleted file mode 100644 index c6836b847a0941a0147a42b87b777fd0bcc0be22..0000000000000000000000000000000000000000 --- a/co2_sensor.cpp +++ /dev/null @@ -1,86 +0,0 @@ -#include "co2_sensor.h" - -namespace config { - // Values should be defined in config.h - uint16_t measurement_timestep = MEASUREMENT_TIMESTEP; // [s] Value between 2 and 1800 (range for SCD30 sensor) - const uint16_t altitude_above_sea_level = ALTITUDE_ABOVE_SEA_LEVEL; // [m] - uint16_t co2_calibration_level = ATMOSPHERIC_CO2_CONCENTRATION; // [ppm] -#ifdef TEMPERATURE_OFFSET - // Residual heat from CO2 sensor seems to be high enough to change the temperature reading. How much should it be offset? - // NOTE: Sign isn't relevant. The returned temperature will always be shifted down. - const float temperature_offset = TEMPERATURE_OFFSET; // [K] -#else - const float temperature_offset = -3.0; // [K] Temperature measured by sensor is usually at least 3K too high. -#endif - const bool auto_calibrate_sensor = AUTO_CALIBRATE_SENSOR; // [true / false] -} - -namespace sensor { - SCD30 scd30; - int16_t co2 = 0; - float temperature = 0; - float humidity = 0; - String timestamp = ""; - - void initialize() { -#if defined(ESP8266) - Wire.begin(12, 14); // ESP8266 - D6, D5; -#endif -#if defined(ESP32) - Wire.begin(21, 22); // ESP32 - /** - * SCD30 ESP32 - * VCC --- 3V3 - * GND --- GND - * SCL --- SCL (GPIO22) //NOTE: GPIO3 Would be more convenient (right next to GND) - * SDA --- SDA (GPIO21) //NOTE: GPIO1 would be more convenient (right next to GPO3) - */ -#endif - - // CO2 - if (scd30.begin(config::auto_calibrate_sensor) == false) { - Serial.println("Air sensor not detected. Please check wiring. Freezing..."); - while (1) { - LedEffects::showWaitingLED(color::red); - } - } - - // SCD30 has its own timer. - Serial.println("\nSetting SCD30 timestep to " + String(config::measurement_timestep) + " s."); - scd30.setMeasurementInterval(config::measurement_timestep); // [s] - - Serial.print("Setting temperature offset to -"); - Serial.print(abs(config::temperature_offset)); - Serial.println(" K."); - scd30.setTemperatureOffset(abs(config::temperature_offset)); // setTemperatureOffset only accepts positive numbers, but shifts the temperature down. - - Serial.print("Temperature offset is : -"); - Serial.print(scd30.getTemperatureOffset()); - Serial.println(" K"); - - Serial.print("Auto-calibration is "); - Serial.println(config::auto_calibrate_sensor ? "ON." : "OFF."); - } - -// Force SCD30 calibration with countdown. - void startCalibrationProcess() { - /** From the sensor documentation: - * For best results, the sensor has to be run in a stable environment in continuous mode at - * a measurement rate of 2s for at least two minutes before applying the FRC command and sending the reference value. - */ - Serial.println("Setting SCD30 timestep to 2s, prior to calibration."); - scd30.setMeasurementInterval(2); // [s] The change will only take effect after next measurement. - LedEffects::showKITTWheel(color::blue, config::measurement_timestep); - Serial.println("Waiting 2 minutes."); - LedEffects::showKITTWheel(color::blue, 120); - Serial.print("Starting SCD30 calibration..."); - scd30.setAltitudeCompensation(config::altitude_above_sea_level); - scd30.setForcedRecalibrationFactor(config::co2_calibration_level); - Serial.println(" Done!"); - Serial.println("Sensor calibrated."); - Serial.println("Sensor will now restart."); - LedEffects::showKITTWheel(color::green, 5); - FS_LIB.end(); - ESP.restart(); - } -} diff --git a/platformio.ini b/platformio.ini index 6893648209a3700095076fb9ff0c5eb55543f6c9..becf69fc263e19f956d1cf7a7ff4701dd7322247 100644 --- a/platformio.ini +++ b/platformio.ini @@ -8,7 +8,7 @@ ; http://docs.platformio.org/page/projectconf.html [platformio] -src_dir = ./ +src_dir = ampel-firmware [env:esp8266] platform = espressif8266 @@ -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/wifi_util.h b/wifi_util.h deleted file mode 100644 index d6303fbf0c50f7ff62d2c0f583767c40bc912eda..0000000000000000000000000000000000000000 --- a/wifi_util.h +++ /dev/null @@ -1,13 +0,0 @@ -#ifndef WIFI_UTIL_H_INCLUDED -# define WIFI_UTIL_H_INCLUDED -# if defined(ESP8266) -# include <ESP8266WiFi.h> -# elif defined(ESP32) -# include <WiFi.h> -# endif - -#include "led_effects.h" -#include "config.h" -void WiFiConnect(const String &hostname); - -#endif