From c1b71e9829b38c404867c0477064f45f787cc012 Mon Sep 17 00:00:00 2001 From: Jens Noack Date: Mon, 15 Nov 2021 13:45:36 +0100 Subject: [PATCH] new version 0_2_2 from stuttgart --- .gitignore | 2 - .gitlab-ci.yml | 22 +- CHANGELOG.md | 25 ++ README.md | 70 ++-- ampel-firmware/ampel-firmware.h | 17 +- ampel-firmware/ampel-firmware.ino | 66 ++-- ampel-firmware/co2_sensor.cpp | 312 +++++++++++++----- ampel-firmware/co2_sensor.h | 14 +- ampel-firmware/{config.public.h => config.h} | 46 +-- ampel-firmware/csv_writer.cpp | 56 +++- ampel-firmware/csv_writer.h | 13 +- ampel-firmware/led_effects.cpp | 128 ++++--- ampel-firmware/led_effects.h | 6 +- ampel-firmware/lorawan.cpp | 24 +- ampel-firmware/lorawan.h | 6 +- ampel-firmware/mqtt.cpp | 169 +++------- ampel-firmware/mqtt.h | 18 +- ampel-firmware/sensor_console.cpp | 188 +++++++++++ ampel-firmware/sensor_console.h | 20 ++ .../src/lib/NTPClient-master/NTPClient.cpp | 16 +- .../src/lib/NTPClient-master/NTPClient.h | 4 +- .../SparkFun_SCD30_Arduino_Library/LICENSE.md | 2 +- .../SparkFun_SCD30_Arduino_Library/README.md | 3 +- .../library.properties | 2 +- .../src/SparkFun_SCD30_Arduino_Library.cpp | 6 + .../src/SparkFun_SCD30_Arduino_Library.h | 25 +- ampel-firmware/util.cpp | 93 ++++-- ampel-firmware/util.h | 28 +- ampel-firmware/web_server.cpp | 80 +++-- ampel-firmware/web_server.h | 3 + ampel-firmware/wifi_util.cpp | 81 +++-- ampel-firmware/wifi_util.h | 5 +- 32 files changed, 1049 insertions(+), 501 deletions(-) create mode 100644 CHANGELOG.md rename ampel-firmware/{config.public.h => config.h} (78%) create mode 100644 ampel-firmware/sensor_console.cpp create mode 100644 ampel-firmware/sensor_console.h diff --git a/.gitignore b/.gitignore index 4512bf9..77976f5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,6 @@ .pio/build .vscode *.ino.cpp -config.h -config.hftstuttgart.h .project .cproject .settings diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f7c5464..7fc9cae 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,29 +2,11 @@ image: python:3.6 stages: - build - - release before_script: - "pip install -U platformio" - "cp ampel-firmware/config.public.h ampel-firmware/config.h" -esp8266: +job: stage: build - script: - - "platformio run --environment esp8266" - - cp .pio/build/esp8266/firmware.bin esp8266.bin - artifacts: - paths: - - esp8266.bin - expire_in: 1 week - -release: - image: inetprocess/gitlab-release - stage: release - only: - - tags - dependencies: - - esp8266 - script: - - gitlab-release --message '' ./*.bin - + script: "platformio run" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e07d2aa --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Change Log + +## [v0.2.2](https://transfer.hft-stuttgart.de/gitlab/co2ampel/ampel-firmware/-/releases/v0.2.2) 2021/10/01 + +* MAC address is now shown at startup and via HTTP + +## [v0.2.1](https://transfer.hft-stuttgart.de/gitlab/co2ampel/ampel-firmware/-/releases/v0.2.1) 2021/06/06 + +* BUGFIX: Sensor would keep on calibrating after successful calibration. + +## [v0.2.0](https://transfer.hft-stuttgart.de/gitlab/co2ampel/ampel-firmware/-/releases/v0.2.0) 2021/06/06 + +* BUGFIX: Calibration was not applied correctly (Thanks Michael Käppler for bug finding & fixing!) +* MQTT works again for ESP32 (board needs to be updated in Arduino IDE/PlaftormIO) + +## [v0.1.0](https://transfer.hft-stuttgart.de/gitlab/co2ampel/ampel-firmware/-/releases/v0.1.0) 2021/05/19 + +* More stable calibration, with 10s timestep +* Allow commands from Serial/MQTT/Webserver +* Many bugfixes + +## [v0.0.9](https://transfer.hft-stuttgart.de/gitlab/co2ampel/ampel-firmware/-/releases/v0.0.9) 2020/12/15 + +* First CO2 Ampel release. +* Basic functionality diff --git a/README.md b/README.md index bb739c3..6d9f82e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CO2 Ampel -*CO2 Ampel* is an open-source project, written in C++ for ESP8266 or ESP32. +*CO2 Ampel* is an open-source project, written in C++ for [ESP8266](https://en.wikipedia.org/wiki/ESP8266) or [ESP32](https://en.wikipedia.org/wiki/ESP32). It measures the current CO2 concentration (in ppm), and displays it on an LED ring. @@ -12,20 +12,20 @@ The *CO2 Ampel* can: * Display CO2 concentration on LED ring. * Allow calibration. -* Get current time over NTP -* Send data over MQTT. -* Send data over LoRaWAN. +* Get current time over [NTP](https://en.wikipedia.org/wiki/Network_Time_Protocol) +* Send data over [MQTT](https://en.wikipedia.org/wiki/MQTT). +* Send data over [LoRaWAN](https://en.wikipedia.org/wiki/LoRa#LoRaWAN). * Display measurements and configuration on a small website. -* Log data to a CSV file, directly on the ESP flash memory. +* Log data to a [CSV](https://en.wikipedia.org/wiki/Comma-separated_values) file, directly on the ESP flash memory. +* Accept many interactive commands. ## 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*) * [Sensirion SCD30](https://www.sensirion.com/en/environmental-sensors/carbon-dioxide-sensors/carbon-dioxide-sensors-co2/) "Sensor Module for HVAC and Indoor Air Quality Applications" -* [NeoPixel Ring - 12](https://www.adafruit.com/product/1643) +* [NeoPixel Ring - 12](https://www.adafruit.com/product/1643), or [NeoPixel Ring - 16](https://www.adafruit.com/product/1463) (experimental) -See the [original documentation](https://transfer.hft-stuttgart.de/gitlab/co2ampel/ampel-documentation) for more info. -Or our Wiki [MakerLab Wiki CO2 Ampel](https://wiki.makerlab-murnau.de/books/co2-ampel). +See the [documentation](https://transfer.hft-stuttgart.de/gitlab/co2ampel/ampel-documentation) for more info. ## Software Requirements @@ -68,27 +68,49 @@ make upload board=esp32 && make monitor # For ESP32 * *Upload* * *Tools > Serial Monitor* +## Available commands + +In Arduino IDE *Serial Monitor* or PlatformIO *Monitor*, type `help` + Enter in order to list the available commands: + +* `auto_calibrate 0/1` (Disables/enables autocalibration). +* `calibrate` (Starts calibration process). +* `calibrate 600` (Starts calibration process, to given ppm). +* `calibrate! 600` (Calibrates right now, to given ppm). +* `co2 1500` (Sets co2 level, for debugging purposes). +* `color 0xFF0015` (Shows color, specified as RGB, for debugging). +* `csv 60` (Sets CSV writing interval, in s). +* `format_filesystem` (Deletes the whole filesystem). +* `free` (Displays available heap space). +* `local_ip` (Displays local IP and current SSID). +* `lora 300` (Sets LoRaWAN sending interval, in s). +* `mqtt 60` (Sets MQTT sending interval, in s). +* `night_mode` (Toggles night mode on/off). +* `reset` (Restarts the ESP). +* `reset_scd` (Resets SCD30). +* `send_local_ip` (Sends local IP and SSID via MQTT. Can be useful to find sensor). +* `set_time 1618829570` (Sets time to the given UNIX time). +* `show_csv` (Displays the complete CSV file on Serial). +* `timer 30` (Sets measurement interval, in s). +* `wifi_scan` (Scans available WiFi networks). + +The commands can be sent via the Serial interface, from the webpage or via MQTT. + ## Authors - * Eric Duminil - * Robert Otto - * Myriam Guedey - * Tobias Gabriel Erhart - * Jonas Stave - -Hochschule für Technik Stuttgart - -## Modifications by - -* Jens Noack - -MakerLab Murnau e.V. + * Eric Duminil (HfT Stuttgart) + * Robert Otto (HfT Stuttgart) + * Myriam Guedey (HfT Stuttgart) + * Tobias Gabriel Erhart (HfT Stuttgart) + * Jonas Stave (HfT Stuttgart) + * Michael Käppler ## Contributing -Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. + +* Merge requests are welcome, and should be based on the `develop` branch. +* The `develop` branch gets merged into the `master` once it has been sufficiently tested. +* For major changes, please open an issue first to discuss what you would like to change. ## License -Copyright © 2020, [HfT Stuttgart](https://www.hft-stuttgart.de/) - +Copyright © 2021, [HfT Stuttgart](https://www.hft-stuttgart.de/) [GPLv3](https://choosealicense.com/licenses/gpl-3.0/) diff --git a/ampel-firmware/ampel-firmware.h b/ampel-firmware/ampel-firmware.h index 5c47dec..41fe46b 100644 --- a/ampel-firmware/ampel-firmware.h +++ b/ampel-firmware/ampel-firmware.h @@ -20,6 +20,12 @@ # ifdef AMPEL_HTTP # include "web_server.h" # endif +# if defined(ESP8266) + //allows sensor to be seen as SENSOR_ID.local, from the local network. For example : espd03cc5.local +# include +# elif defined(ESP32) +# include +# endif #endif #ifdef AMPEL_LORAWAN @@ -27,17 +33,8 @@ #endif #include "util.h" +#include "sensor_console.h" #include "co2_sensor.h" #include "led_effects.h" -#if defined(ESP8266) -//allows sensor to be seen as SENSOR_ID.local, from the local network. For example : espd03cc5.local -# include -#elif defined(ESP32) -# include -#endif - -void keepServicesAlive(); -void checkFlashButton(); - #endif diff --git a/ampel-firmware/ampel-firmware.ino b/ampel-firmware/ampel-firmware.ino index 4e4bcf5..a5c9070 100644 --- a/ampel-firmware/ampel-firmware.ino +++ b/ampel-firmware/ampel-firmware.ino @@ -43,6 +43,7 @@ * Myriam Guedey * Tobias Gabriel Erhart * Jonas Stave + * Michael Käppler */ /***************************************************************** @@ -61,22 +62,31 @@ void setup() { led_effects::setupOnBoardLED(); led_effects::onBoardLEDOff(); - + Serial.begin(BAUDS); - pinMode(0, INPUT); // Flash button (used for forced calibration) + pinMode(buttonPin, INPUT); // Flash button (used for forced calibration) + + Serial.println(); + Serial.print(F("Sensor ID: ")); + Serial.println(ampel.sensorId); + Serial.print(F("MAC : ")); + Serial.println(ampel.macAddress); + Serial.print(F("Board : ")); + Serial.println(ampel.board); + Serial.print(F("Firmware : ")); + Serial.println(ampel.version); led_effects::setupRing(); - + led_effects::showRainbowWheel(5000); sensor::initialize(); - Serial.print(F("Sensor ID: ")); - Serial.println(SENSOR_ID); - Serial.print(F("Board : ")); - Serial.println(BOARD); +#ifdef AMPEL_CSV + csv_writer::initialize(ampel.sensorId); +#endif #ifdef AMPEL_WIFI - WiFiConnect(SENSOR_ID); + wifi::connect(ampel.sensorId); Serial.print(F("WiFi - Status: ")); Serial.println(WiFi.status()); @@ -88,7 +98,7 @@ void setup() { ntp::initialize(); - if (MDNS.begin(SENSOR_ID.c_str())) { // Start the mDNS responder for SENSOR_ID.local + if (MDNS.begin(ampel.sensorId)) { // Start the mDNS responder for SENSOR_ID.local MDNS.addService("http", "tcp", 80); Serial.println(F("mDNS responder started")); } else { @@ -96,24 +106,26 @@ void setup() { } # ifdef AMPEL_MQTT - mqtt::initialize("CO2sensors/" + SENSOR_ID); + mqtt::initialize(ampel.sensorId); # endif } #endif -#ifdef AMPEL_CSV - csv_writer::initialize(); -#endif - #if defined(AMPEL_LORAWAN) && defined(ESP32) lorawan::initialize(); #endif } +/***************************************************************** + * Helper functions * + *****************************************************************/ +void keepServicesAlive(); +void checkFlashButton(); +void checkSerialInput(); + /***************************************************************** * Main loop * *****************************************************************/ - void loop() { #if defined(AMPEL_LORAWAN) && defined(ESP32) //LMIC Library seems to be very sensitive to timing issues, so run it first. @@ -133,6 +145,8 @@ void loop() { // Short press for night mode, Long press for calibration. checkFlashButton(); + checkSerialInput(); + if (sensor::processData()) { #ifdef AMPEL_CSV csv_writer::logIfTimeHasCome(sensor::timestamp, sensor::co2, sensor::temperature, sensor::humidity); @@ -148,11 +162,17 @@ void loop() { } uint32_t duration = millis() - t0; - if (duration > max_loop_duration) { - max_loop_duration = duration; + if (duration > ampel.max_loop_duration) { + ampel.max_loop_duration = duration; Serial.print(F("Debug - Max loop duration : ")); - Serial.print(max_loop_duration); - Serial.println(" ms."); + Serial.print(ampel.max_loop_duration); + Serial.println(F(" ms.")); + } +} + +void checkSerialInput() { + while (Serial.available() > 0) { + sensor_console::processSerialInput(Serial.read()); } } @@ -163,16 +183,18 @@ void loop() { * If long press, start calibration process. */ void checkFlashButton() { - if (!digitalRead(buttonPin)) { // Button has been pressed + if (!digitalRead(0)) { // Button has been pressed led_effects::onBoardLEDOn(); delay(300); - if (digitalRead(buttonPin)) { + if (digitalRead(0)) { Serial.println(F("Flash has been pressed for a short time. Should toggle night mode.")); led_effects::toggleNightMode(); } else { Serial.println(F("Flash has been pressed for a long time. Keep it pressed for calibration.")); - if (led_effects::countdownToZero() < 0) { + if (led_effects::countdownToZero()) { + Serial.println(F("You can now release the button.")); sensor::startCalibrationProcess(); + led_effects::showKITTWheel(color::red, 2); } } led_effects::onBoardLEDOff(); diff --git a/ampel-firmware/co2_sensor.cpp b/ampel-firmware/co2_sensor.cpp index 7e45f14..25a7102 100644 --- a/ampel-firmware/co2_sensor.cpp +++ b/ampel-firmware/co2_sensor.cpp @@ -1,10 +1,17 @@ #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) + // UPPERCASE 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] + const uint16_t measurement_timestep_bootup = 5; // [s] Measurement timestep during acclimatization. + const uint8_t max_deviation_during_bootup = 20; // [%] + const int8_t max_deviation_during_calibration = 30; // [ppm] + const int16_t timestep_during_calibration = 10; // [s] WARNING: Measurements can be unreliable for timesteps shorter than 10s. + const int8_t stable_measurements_before_calibration = 120 / timestep_during_calibration; // [-] Stable measurements during at least 2 minutes. + const uint16_t co2_alert_threshold = 2000; // [ppm] Display a flashing led ring, if concentration exceeds this value + #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. @@ -12,23 +19,46 @@ namespace config { #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] + bool auto_calibrate_sensor = AUTO_CALIBRATE_SENSOR; // [true / false] + const bool debug_sensor_states = false; // If true, log state transitions over serial console } namespace sensor { SCD30 scd30; - int16_t co2 = 0; + uint16_t co2 = 0; float temperature = 0; float humidity = 0; - String timestamp = ""; + char timestamp[23]; int16_t stable_measurements = 0; - uint32_t waiting_color = color::blue; - bool should_calibrate = false; - unsigned long time_calaibration_started = millis(); + + /** + * Define sensor states + * BOOTUP -> initial state, until first >0 ppm values are returned + * READY -> sensor does output valid information (> 0 ppm) and no other condition takes place + * NEEDS_CALIBRATION -> sensor measurements are too low (< 250 ppm) + * PREPARE_CALIBRATION_UNSTABLE -> forced calibration was initiated, last measurements were too far apart + * PREPARE_CALIBRATION_STABLE -> forced calibration was initiated, last measurements were close to each others + */ + enum state { + BOOTUP, + READY, + NEEDS_CALIBRATION, + PREPARE_CALIBRATION_UNSTABLE, + PREPARE_CALIBRATION_STABLE + }; + const char *state_names[] = { + "BOOTUP", + "READY", + "NEEDS_CALIBRATION", + "PREPARE_CALIBRATION_UNSTABLE", + "PREPARE_CALIBRATION_STABLE" }; + + state current_state = BOOTUP; + void switchState(state); void initialize() { #if defined(ESP8266) - Wire.begin(12, 14); // ESP8266 - SDA: D6, SCL: D5; + Wire.begin(12, 14); // ESP8266 - D6, D5; #endif #if defined(ESP32) Wire.begin(21, 22); // ESP32 @@ -40,81 +70,103 @@ namespace sensor { * SDA --- SDA (GPIO21) //NOTE: GPIO1 would be more convenient (right next to GPO3) */ #endif + Serial.println(); + scd30.enableDebugging(); // Prints firmware version in the console. - // 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); - } + if (!scd30.begin(config::auto_calibrate_sensor)) { + Serial.println(F("ERROR - CO2 sensor not detected. Please check wiring!")); + led_effects::showKITTWheel(color::red, 30); + ESP.restart(); } - // 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] + // Changes of the SCD30's measurement timestep do not come into effect + // before the next measurement takes place. That means that after a hard reset + // of the ESP the SCD30 sometimes needs a long time until switching back to 2 s + // for acclimatization. Resetting it after startup seems to fix this behaviour. + scd30.reset(); Serial.print(F("Setting temperature offset to -")); Serial.print(abs(config::temperature_offset)); - Serial.println(" K."); + Serial.println(F(" 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.println(F(" K")); Serial.print(F("Auto-calibration is ")); Serial.println(config::auto_calibrate_sensor ? "ON." : "OFF."); + + // 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_bootup); + Serial.println(F(" s during acclimatization.")); + scd30.setMeasurementInterval(config::measurement_timestep_bootup); // [s] + + sensor_console::defineIntCommand("co2", setCO2forDebugging, F("1500 (Sets co2 level, for debugging purposes)")); + sensor_console::defineIntCommand("timer", setTimer, F("30 (Sets measurement interval, in s)")); + sensor_console::defineCommand("calibrate", startCalibrationProcess, F("(Starts calibration process)")); + sensor_console::defineIntCommand("calibrate", calibrateSensorToSpecificPPM, + F("600 (Starts calibration process, to given ppm)")); + sensor_console::defineIntCommand("calibrate!", calibrateSensorRightNow, + F("600 (Calibrates right now, to given ppm)")); + sensor_console::defineIntCommand("auto_calibrate", setAutoCalibration, F("0/1 (Disables/enables autocalibration)")); + sensor_console::defineCommand("reset_scd", resetSCD, F("(Resets SCD30)")); } - //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; + bool hasSensorSettled() { + static uint16_t last_co2 = 0; + uint16_t delta; + delta = abs(co2 - last_co2); + last_co2 = co2; + // We assume the sensor has acclimated to the environment if measurements + // change less than a specified percentage of the current value. + return (co2 > 0 && delta < ((uint32_t) co2 * config::max_deviation_during_bootup / 100)); } - void countStableMeasurements() { + bool enoughStableMeasurements() { static int16_t previous_co2 = 0; - if (co2 > (previous_co2 - 30) && co2 < (previous_co2 + 30)) { + if (co2 > (previous_co2 - config::max_deviation_during_calibration) + && co2 < (previous_co2 + config::max_deviation_during_calibration)) { stable_measurements++; Serial.print(F("Number of stable measurements : ")); - Serial.println(stable_measurements); - waiting_color = color::green; + Serial.print(stable_measurements); + Serial.print(F(" / ")); + Serial.println(config::stable_measurements_before_calibration); + switchState(PREPARE_CALIBRATION_STABLE); } else { stable_measurements = 0; - waiting_color = color::red; + switchState(PREPARE_CALIBRATION_UNSTABLE); } previous_co2 = co2; + return (stable_measurements == config::stable_measurements_before_calibration); } 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. + * Before applying FRC, SCD30 needs to be operated for 2 minutes with the desired measurement period in continuous mode. */ - Serial.println(F("Setting SCD30 timestep to 2s, prior to calibration.")); - scd30.setMeasurementInterval(MEASUREMENT_TIMESTEP); // [s] The change will only take effect after next measurement. + Serial.print(F("Setting SCD30 timestep to ")); + Serial.print(config::timestep_during_calibration); + Serial.println(F("s, prior to calibration.")); + scd30.setMeasurementInterval(config::timestep_during_calibration); // [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; - time_calaibration_started = millis(); + switchState(PREPARE_CALIBRATION_UNSTABLE); } - void calibrateAndRestart() { + void calibrate() { 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 + switchState(BOOTUP); // In order to stop the calibration and select the desired timestep. + //WARNING: Do not reset the ampel or the SCD30! + //At least one measurement needs to happen in order for the calibration to be correctly applied. } void logToSerial() { @@ -127,29 +179,86 @@ namespace sensor { Serial.println(humidity, 1); } - void displayCO2OnLedRing() { - int16_t co2_int = co2; - if (co2_int < CALIBRATE_LEVEL) { - // Sensor should be calibrated. - led_effects::showWaitingLED(color::magenta); + void switchState(state new_state) { + if (new_state == current_state) { return; } - if(co2_int < 400) { - co2_int = 400; + if (config::debug_sensor_states) { + Serial.print(F("Changing sensor state: ")); + Serial.print(state_names[current_state]); + Serial.print(F(" -> ")); + Serial.println(state_names[new_state]); } + current_state = new_state; + } + + void switchStateForCurrentPPM() { + if (current_state == BOOTUP) { + if (!hasSensorSettled()) { + return; + } + switchState(READY); + Serial.println(F("Sensor acclimatization finished.")); + Serial.print(F("Setting SCD30 timestep to ")); + Serial.print(config::measurement_timestep); + Serial.println(F(" s.")); + if (config::measurement_timestep < 10) { + Serial.println(F("WARNING: Timesteps shorter than 10s can lead to unreliable measurements!")); + } + scd30.setMeasurementInterval(config::measurement_timestep); // [s] + } + + // Check for pre-calibration states first, because we do not want to + // leave them before calibration is done. + if ((current_state == PREPARE_CALIBRATION_UNSTABLE) || (current_state == PREPARE_CALIBRATION_STABLE)) { + if (enoughStableMeasurements()) { + calibrate(); + } + } else if (co2 < 250) { + // Sensor should be calibrated. + switchState(NEEDS_CALIBRATION); + } else { + switchState(READY); + } + } + + void displayCO2OnLedRing() { /** * Display data, even if it's "old" (with breathing). - * Those effects include a short delay. + * A short delay is required in order to let background tasks run on the ESP8266. + * see https://github.com/esp8266/Arduino/issues/3241#issuecomment-301290392 */ - if (co2_int < 2000) { - led_effects::displayCO2color(co2_int); - led_effects::breathe(co2_int); + if (co2 < config::co2_alert_threshold) { + led_effects::displayCO2color(co2); + delay(100); } else { - // >= 2000: entire ring blinks red + // Display a flashing led ring, if concentration exceeds a specific value led_effects::redAlert(); } } + void showState() { + switch (current_state) { + case BOOTUP: + led_effects::showWaitingLED(color::blue); + break; + case READY: + displayCO2OnLedRing(); + break; + case NEEDS_CALIBRATION: + led_effects::showWaitingLED(color::magenta); + break; + case PREPARE_CALIBRATION_UNSTABLE: + led_effects::showWaitingLED(color::red); + break; + case PREPARE_CALIBRATION_STABLE: + led_effects::showWaitingLED(color::green); + break; + default: + Serial.println(F("Encountered unknown sensor state")); // This should not happen. + } + } + /** 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) */ @@ -157,42 +266,75 @@ namespace sensor { bool freshData = scd30.dataAvailable(); if (freshData) { - // checkTimerDeviation(); - timestamp = ntp::getLocalTime(); + ntp::getLocalTime(timestamp); 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; - } + switchStateForCurrentPPM(); - /** - * Fresh data. Log it and send it if needed. - */ - if (freshData) { - if (should_calibrate) { - if(millis() - time_calaibration_started > 60000) - { - countStableMeasurements(); - } - } + // Log every time fresh data is available. logToSerial(); } - if (should_calibrate) { - if (stable_measurements == 60) { - calibrateAndRestart(); - } - led_effects::showWaitingLED(waiting_color); - return false; - } + showState(); - displayCO2OnLedRing(); - return freshData; + // Report data for further processing only if the data is reliable + // (state 'READY') or manual calibration is necessary (state 'NEEDS_CALIBRATION'). + return freshData && (current_state == READY || current_state == NEEDS_CALIBRATION); + } + + /***************************************************************** + * Callbacks for sensor commands * + *****************************************************************/ + void setCO2forDebugging(int32_t fakeCo2) { + Serial.print(F("DEBUG. Setting CO2 to ")); + co2 = fakeCo2; + Serial.println(co2); + switchStateForCurrentPPM(); + } + + void setAutoCalibration(int32_t autoCalibration) { + config::auto_calibrate_sensor = autoCalibration; + scd30.setAutoSelfCalibration(autoCalibration); + Serial.print(F("Setting auto-calibration to : ")); + Serial.println(autoCalibration ? F("On.") : F("Off.")); + } + + void setTimer(int32_t timestep) { + if (timestep >= 2 && timestep <= 1800) { + Serial.print(F("Setting Measurement Interval to : ")); + Serial.print(timestep); + Serial.println(F("s (change will only be applied after next measurement).")); + scd30.setMeasurementInterval(timestep); + config::measurement_timestep = timestep; + led_effects::showKITTWheel(color::green, 1); + } + } + + void calibrateSensorToSpecificPPM(int32_t calibrationLevel) { + if (calibrationLevel >= 400 && calibrationLevel <= 2000) { + Serial.print(F("Force calibration, at ")); + config::co2_calibration_level = calibrationLevel; + Serial.print(config::co2_calibration_level); + Serial.println(F(" ppm.")); + startCalibrationProcess(); + } + } + + void calibrateSensorRightNow(int32_t calibrationLevel) { + if (calibrationLevel >= 400 && calibrationLevel <= 2000) { + Serial.print(F("Force calibration, right now, at ")); + config::co2_calibration_level = calibrationLevel; + Serial.print(config::co2_calibration_level); + Serial.println(F(" ppm.")); + calibrate(); + } + } + + void resetSCD() { + Serial.print(F("Resetting SCD30...")); + scd30.reset(); + Serial.println(F("done.")); } } diff --git a/ampel-firmware/co2_sensor.h b/ampel-firmware/co2_sensor.h index f11eb81..d9eff87 100644 --- a/ampel-firmware/co2_sensor.h +++ b/ampel-firmware/co2_sensor.h @@ -7,24 +7,32 @@ #include "config.h" #include "led_effects.h" #include "util.h" +#include "sensor_console.h" #include namespace config { extern uint16_t measurement_timestep; // [s] Value between 2 and 1800 (range for SCD30 sensor) - extern const bool auto_calibrate_sensor; // [true / false] + extern bool auto_calibrate_sensor; // [true / false] extern uint16_t co2_calibration_level; // [ppm] extern const float temperature_offset; // [K] Sign isn't relevant. } namespace sensor { extern SCD30 scd30; - extern int16_t co2; + extern uint16_t co2; extern float temperature; extern float humidity; - extern String timestamp; + extern char timestamp[]; void initialize(); bool processData(); void startCalibrationProcess(); + + void setCO2forDebugging(int32_t fakeCo2); + void setTimer(int32_t timestep); + void calibrateSensorToSpecificPPM(int32_t calibrationLevel); + void calibrateSensorRightNow(int32_t calibrationLevel); + void setAutoCalibration(int32_t autoCalibration); + void resetSCD(); } #endif diff --git a/ampel-firmware/config.public.h b/ampel-firmware/config.h similarity index 78% rename from ampel-firmware/config.public.h rename to ampel-firmware/config.h index 4f6d153..5c4a9a6 100644 --- a/ampel-firmware/config.public.h +++ b/ampel-firmware/config.h @@ -1,6 +1,5 @@ #ifndef CONFIG_H_INCLUDED -# define CONFIG_H_INCLUDED - +#define CONFIG_H_INCLUDED #define buttonPin 0 // This file is a config template, and can be copied to config.h. Please don't save any important password in this template. @@ -14,18 +13,18 @@ */ // 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_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_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 */ -# define WIFI_SSID "" -# define WIFI_PASSWORD "" +# define WIFI_SSID "MY_SSID" +# define WIFI_PASSWORD "P4SSW0RD" # define WIFI_TIMEOUT 30 // [s] /** @@ -33,8 +32,10 @@ */ // How often should measurement be performed, and displayed? -//NOTE: SCD30 timer does not seem to be very precise. Variations may occur. -# define MEASUREMENT_TIMESTEP 2 // [s] Value between 2 and 1800 (range for SCD30 sensor) +//WARNING: On some sensors, measurements become very unreliable when timestep is set to 2s. +//NOTE: 10s or longer should be fine in order to get reliable results. +//NOTE: SCD30 timer does not seem to be very precise. Time variations may occur. +# define MEASUREMENT_TIMESTEP 5 // [s] Value between 2 and 1800 (range for SCD30 sensor) // 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 @@ -44,31 +45,34 @@ // 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. -# define TEMPERATURE_OFFSET 0 // [K] +# define TEMPERATURE_OFFSET -3 // [K] // Altitude above sea level // Used for CO2 calibration // here: Stuttgart, Schellingstr. 24. (Source: Google Earth) -# define ALTITUDE_ABOVE_SEA_LEVEL 660 // [m] +# define ALTITUDE_ABOVE_SEA_LEVEL 600 // [m] // The reference CO2 concentration has to be within the range 400 ppm ≤ cref(CO2) ≤ 2000 ppm. // Used for CO2 calibration // here : measured concentration in Stuttgart -# define ATMOSPHERIC_CO2_CONCENTRATION 430 // [ppm] +# define ATMOSPHERIC_CO2_CONCENTRATION 425 // [ppm] // Should the sensor try to calibrate itself? // Sensirion recommends 7 days of continuous readings with at least 1 hour a day of 'fresh air' for self-calibration to complete. -# define AUTO_CALIBRATE_SENSOR false // [true / false] +# define AUTO_CALIBRATE_SENSOR true // [true / false] /** * LEDs */ // LED brightness, which can vary between min and max brightness ("LED breathing") -// max_brightness should be between 0 and 255. -// min_brightness should be between 0 and max_brightness +// MAX_BRIGHTNESS must be defined, and should be between 0 and 255. # define MAX_BRIGHTNESS 128 +// MIN_BRIGHTNESS, if defined, should be between 0 and MAX_BRIGHTNESS - 1 +// If MIN_BRIGHTNESS is not set, or if it is set to MAX_BRIGHTNESS, breathing is disabled. # define MIN_BRIGHTNESS 60 +// How many LEDs in the ring? 12 and 16 are currently supported. If undefined, 12 is used as default. +# define LED_COUNT 12 /** * WEB SERVER @@ -76,8 +80,8 @@ */ // Define empty strings in order to disable authentication, or remove the constants altogether. -# define HTTP_USER "" -# define HTTP_PASSWORD "" +# define HTTP_USER "co2ampel" +# define HTTP_PASSWORD "my_password" /** * MQTT @@ -106,11 +110,11 @@ */ # define ALLOW_MQTT_COMMANDS false -// How often measurements should be sent to MQTT server? +// How often should measurements 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_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 "" @@ -149,7 +153,7 @@ */ # define NTP_SERVER "pool.ntp.org" -# define UTC_OFFSET_IN_SECONDS 3600 // [s] 3600 for UTC+1 +# define UTC_OFFSET_IN_SECONDS 7200 // [s] 3600 for UTC+1, 7200 for UTC+1 and daylight saving time /** * Others diff --git a/ampel-firmware/csv_writer.cpp b/ampel-firmware/csv_writer.cpp index 6536303..d0828a4 100644 --- a/ampel-firmware/csv_writer.cpp +++ b/ampel-firmware/csv_writer.cpp @@ -1,14 +1,12 @@ #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] } namespace csv_writer { unsigned long last_written_at = 0; - String last_successful_write = ""; + char last_successful_write[23]; #if defined(ESP8266) /** @@ -80,13 +78,16 @@ namespace csv_writer { } #endif - const String filename = "/" + SENSOR_ID + ".csv"; + char filename[15]; // "/ESPxxxxxx.csv\0" int getAvailableSpace() { return getTotalSpace() - getUsedSpace(); } - void initialize() { + void initialize(const char *sensorId) { + snprintf(filename, sizeof(filename), "/%s.csv", sensorId); + + Serial.println(); Serial.print(F("Initializing FS...")); if (mountFS()) { Serial.println(F("done.")); @@ -113,9 +114,13 @@ namespace csv_writer { Serial.println(); // Open dir folder - Serial.println("Filesystem content:"); + Serial.println(F("Filesystem content:")); showFilesystemContent(); Serial.println(); + + sensor_console::defineIntCommand("csv", setCSVinterval, F("60 (Sets CSV writing interval, in s)")); + sensor_console::defineCommand("format_filesystem", formatFilesystem, F("(Deletes the whole filesystem)")); + sensor_console::defineCommand("show_csv", showCSVContent, F("(Displays the complete CSV file on Serial)")); } File openOrCreate() { @@ -130,11 +135,11 @@ namespace csv_writer { return csv_file; } - void log(const String &timeStamp, const int16_t &co2, const float &temperature, const float &humidity) { + void log(const char *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); + snprintf(csv_line, sizeof(csv_line), "%s;%d;%.1f;%.1f\r\n", timestamp, co2, temperature, humidity); if (csv_file) { size_t written_bytes = csv_file.print(csv_line); csv_file.close(); @@ -143,7 +148,7 @@ namespace csv_writer { } else { Serial.print(F("CSV - Wrote : ")); Serial.print(csv_line); - last_successful_write = ntp::getLocalTime(); + ntp::getLocalTime(last_successful_write); } updateFsInfo(); delay(50); @@ -154,11 +159,42 @@ namespace csv_writer { led_effects::onBoardLEDOff(); } - void logIfTimeHasCome(const String &timeStamp, const int16_t &co2, const float &temperature, const float &humidity) { + void logIfTimeHasCome(const char *timeStamp, const int16_t &co2, const float &temperature, const float &humidity) { unsigned long now = seconds(); if (now - last_written_at > config::csv_interval) { last_written_at = now; log(timeStamp, co2, temperature, humidity); } } + + /***************************************************************** + * Callbacks for sensor commands * + *****************************************************************/ + void setCSVinterval(int32_t csv_interval) { + config::csv_interval = csv_interval; + Serial.print(F("Setting CSV Interval to : ")); + Serial.print(config::csv_interval); + Serial.println("s."); + led_effects::showKITTWheel(color::green, 1); + } + + void showCSVContent() { + Serial.print(F("### ")); + Serial.print(filename); + Serial.println(F(" ###")); + File csv_file; + if (FS_LIB.exists(filename)) { + csv_file = FS_LIB.open(filename, "r"); + while (csv_file.available()) { + Serial.write(csv_file.read()); + } + csv_file.close(); + } + Serial.println(F("######################")); + } + + void formatFilesystem() { + FS_LIB.format(); + led_effects::showKITTWheel(color::blue, 2); + } } diff --git a/ampel-firmware/csv_writer.h b/ampel-firmware/csv_writer.h index e0e0d53..f85f346 100644 --- a/ampel-firmware/csv_writer.h +++ b/ampel-firmware/csv_writer.h @@ -14,16 +14,21 @@ #include "config.h" #include "util.h" #include "led_effects.h" +#include "sensor_console.h" namespace config { extern uint16_t csv_interval; // [s] } namespace csv_writer { - extern String last_successful_write; - void initialize(); - void logIfTimeHasCome(const String &timeStamp, const int16_t &co2, const float &temperature, const float &humidity); + extern char last_successful_write[]; + void initialize(const char *sensorId); + void logIfTimeHasCome(const char *timestamp, const int16_t &co2, const float &temperature, const float &humidity); int getAvailableSpace(); - extern const String filename; + extern char filename[]; + + void setCSVinterval(int32_t csv_interval); + void showCSVContent(); + void formatFilesystem(); } #endif diff --git a/ampel-firmware/led_effects.cpp b/ampel-firmware/led_effects.cpp index f654e77..1a7eb4a 100644 --- a/ampel-firmware/led_effects.cpp +++ b/ampel-firmware/led_effects.cpp @@ -4,17 +4,36 @@ *****************************************************************/ namespace config { const uint8_t max_brightness = MAX_BRIGHTNESS; +#if defined(MIN_BRIGHTNESS) const uint8_t min_brightness = MIN_BRIGHTNESS; - const int kitt_tail = 3; // How many dimmer LEDs follow in K.I.T.T. wheel -} - -/***************************************************************** - * Configuration (calculated from above values) * - *****************************************************************/ -namespace config //NOTE: Use a class instead? NightMode could then be another state. -{ +#else + const uint8_t min_brightness = MAX_BRIGHTNESS; +#endif const uint8_t brightness_amplitude = config::max_brightness - config::min_brightness; - bool night_mode = false; + const int kitt_tail = 3; // How many dimmer LEDs follow in K.I.T.T. wheel + const uint16_t poor_air_quality_ppm = 1600; // Above this threshold, LED breathing effect is faster. + bool night_mode = false; //NOTE: Use a class instead? NightMode could then be another state. + +#if !defined(LED_COUNT) +# define LED_COUNT 12 +#endif + + const uint16_t led_count = LED_COUNT; + +#if LED_COUNT == 12 + //NOTE: One value has been prepended, to make calculations easier and avoid out of bounds index. + const uint16_t co2_ticks[led_count + 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°), + // LEDs >= 1600ppm will be pure red (hue angle 0°), LEDs in-between will be yellowish. + const uint16_t led_hues[led_count] = { 21845U, 19114U, 16383U, 13653U, 10922U, 8191U, 5461U, 2730U, 0, 0, 0, 0 }; // [hue angle] +#elif LED_COUNT == 16 + const uint16_t co2_ticks[led_count + 1] = { 0, 500, 600, 700, 800, 900, 1000, 1100, 1200, + 1300, 1400, 1500, 1600, 1700, 1800, 2000, 2200 }; // [ppm] + const uint16_t led_hues[led_count] = {21845U, 19859U, 17873U, 15887U, 13901U, 11915U, 9929U, 7943U, + 5957U, 3971U, 1985U, 0, 0, 0, 0, 0}; // [hue angle] +#else +# error "Only 12 and 16 LEDs rings are currently supported." +#endif } #if defined(ESP8266) @@ -25,20 +44,7 @@ const int NEOPIXELS_PIN = 5; 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] -// const uint16_t CO2_TICKS[NUMPIXELS + 1] = { 0, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 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. -// For reference, this python code can be used to generate the array -// NUMPIXELS = 12 -// RED_LEDS = 4 -// hues = [ (2**16-1) // 3 * max(NUMPIXELS - RED_LEDS - i, 0) // (NUMPIXELS - RED_LEDS) for i in range(NUMPIXELS) ] -// '{' + ', '.join([str(hue) + ('U' if hue else '') for hue in hues]) + '}; // [hue angle]' -const uint16_t LED_HUES[NUMPIXELS] = { 21845U, 19114U, 16383U, 13653U, 10922U, 8191U, 5461U, 2730U, 0, 0, 0, 0 }; // [hue angle] -// const uint16_t LED_HUES[NUMPIXELS] = { 21845U, 20024U, 18204U, 16383U, 14563U, 12742U, 10922U, 9102U, 7281U, 5461U, 3640U, 1820U, 0, 0, 0, 0 }; // [hue angle] -Adafruit_NeoPixel pixels(NUMPIXELS, NEOPIXELS_PIN, NEO_GRB + NEO_KHZ800); +Adafruit_NeoPixel pixels(config::led_count, NEOPIXELS_PIN, NEO_GRB + NEO_KHZ800); namespace led_effects { //On-board LED on D4, aka GPIO02 @@ -71,10 +77,19 @@ namespace led_effects { onBoardLEDOff(); } + void showColor(int32_t color) { + config::night_mode = true; // In order to avoid overwriting the desired color next time CO2 is displayed + pixels.setBrightness(255); + pixels.fill(color); + pixels.show(); + } + void setupRing() { pixels.begin(); pixels.setBrightness(config::max_brightness); LEDsOff(); + sensor_console::defineCommand("night_mode", toggleNightMode, F("(Toggles night mode on/off)")); + sensor_console::defineIntCommand("color", showColor, F("0xFF0015 (Shows color, specified as RGB, for debugging)")); } void toggleNightMode() { @@ -89,14 +104,15 @@ namespace led_effects { //NOTE: basically one iteration of KITT wheel void showWaitingLED(uint32_t color) { + using namespace config; delay(80); - if (config::night_mode) { + if (night_mode) { return; } static uint16_t kitt_offset = 0; pixels.clear(); - for (int j = config::kitt_tail; j >= 0; j--) { - int ledNumber = abs((kitt_offset - j + NUMPIXELS) % (2 * NUMPIXELS) - NUMPIXELS) % NUMPIXELS; // Triangular function + for (int j = kitt_tail; j >= 0; j--) { + int ledNumber = abs((kitt_offset - j + led_count) % (2 * led_count) - led_count) % led_count; // Triangular function pixels.setPixelColor(ledNumber, color * pixels.gamma8(255 - j * 76) / 255); } pixels.show(); @@ -108,7 +124,7 @@ namespace led_effects { // Takes approximately 1s for each direction. void showKITTWheel(uint32_t color, uint16_t duration_s) { pixels.setBrightness(config::max_brightness); - for (int i = 0; i < duration_s * NUMPIXELS; ++i) { + for (int i = 0; i < duration_s * config::led_count; ++i) { showWaitingLED(color); } } @@ -118,10 +134,10 @@ namespace led_effects { * For example, for 1500ppm, every LED between 0 and 7 (500 -> 1400ppm) should be on, LED at 8 (1600ppm) should be half-on. */ uint8_t getLedBrightness(uint16_t co2, int ledId) { - if (co2 >= CO2_TICKS[ledId + 1]) { + if (co2 >= config::co2_ticks[ledId + 1]) { return 255; } else { - if (2 * co2 >= CO2_TICKS[ledId] + CO2_TICKS[ledId + 1]) { + if (2 * co2 >= config::co2_ticks[ledId] + config::co2_ticks[ledId + 1]) { // Show partial LED if co2 more than halfway between ticks. return 27; // Brightness isn't linear, so 27 / 255 looks much brighter than 10% } else { @@ -131,6 +147,17 @@ namespace led_effects { } } + /** + * If enabled, slowly varies the brightness between MAX_BRIGHTNESS & MIN_BRIGHTNESS. + */ + void breathe(int16_t co2) { + static uint8_t breathing_offset = 0; + uint16_t brightness = config::min_brightness + pixels.sine8(breathing_offset) * config::brightness_amplitude / 255; + pixels.setBrightness(brightness); + pixels.show(); + breathing_offset += co2 > config::poor_air_quality_ppm ? 6 : 3; // breathing speed. +3 looks like slow human breathing. + } + /** * Fills the whole ring with green, yellow, orange or black, depending on co2 input and CO2_TICKS. */ @@ -139,24 +166,28 @@ namespace led_effects { return; } pixels.setBrightness(config::max_brightness); - for (int ledId = 0; ledId < NUMPIXELS; ++ledId) { + for (int ledId = 0; ledId < config::led_count; ++ledId) { uint8_t brightness = getLedBrightness(co2, ledId); - pixels.setPixelColor(ledId, pixels.ColorHSV(LED_HUES[ledId], 255, brightness)); + pixels.setPixelColor(ledId, pixels.ColorHSV(config::led_hues[ledId], 255, brightness)); } pixels.show(); + if (config::brightness_amplitude > 0) { + breathe(co2); + } } - void showRainbowWheel(uint16_t duration_ms, uint16_t hue_increment) { + void showRainbowWheel(uint16_t duration_ms) { if (config::night_mode) { return; } static uint16_t wheel_offset = 0; + static uint16_t sine_offset = 0; unsigned long t0 = millis(); pixels.setBrightness(config::max_brightness); while (millis() - t0 < duration_ms) { - for (int i = 0; i < NUMPIXELS; i++) { - pixels.setPixelColor(i, pixels.ColorHSV(i * 65535 / NUMPIXELS + wheel_offset)); - wheel_offset += hue_increment; + for (int i = 0; i < config::led_count; i++) { + pixels.setPixelColor(i, pixels.ColorHSV(i * 65535 / config::led_count + wheel_offset)); + wheel_offset += (pixels.sine8(sine_offset++ / 50) - 127) / 2; } pixels.show(); delay(10); @@ -179,37 +210,28 @@ namespace led_effects { } } - void breathe(int16_t co2) { - if (!config::night_mode) { - 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(); - breathing_offset += 3; // breathing speed. +3 looks like slow human breathing. - } - delay(co2 > 1600 ? 50 : 100); // faster breathing for higher CO2 values - } - /** - * Displays a complete blue circle, and starts removing LEDs one by one. Returns the number of remaining LEDs. - * Can be used for calibration, e.g. when countdown is 0. Does not work in night mode. + * Displays a complete blue circle, and starts removing LEDs one by one. + * Does nothing in night mode and returns false then. Returns true if + * the countdown has finished. Can be used for calibration, e.g. when countdown is 0. + * NOTE: This function is blocking and returns only after the button has + * been released or after every LED has been turned off. */ - int countdownToZero() { + bool countdownToZero() { if (config::night_mode) { 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; + return false; } pixels.fill(color::blue); pixels.show(); int countdown; - for (countdown = NUMPIXELS; countdown >= 0 && !digitalRead(0); countdown--) { + for (countdown = config::led_count; countdown >= 0 && !digitalRead(0); countdown--) { pixels.setPixelColor(countdown, color::black); pixels.show(); Serial.println(countdown); delay(500); } - return countdown; + return countdown < 0; } } diff --git a/ampel-firmware/led_effects.h b/ampel-firmware/led_effects.h index d34a9bf..8f04d83 100644 --- a/ampel-firmware/led_effects.h +++ b/ampel-firmware/led_effects.h @@ -2,6 +2,7 @@ #define LED_EFFECTS_H_INCLUDED #include #include "config.h" +#include "sensor_console.h" // Adafruit NeoPixel (Arduino library for controlling single-wire-based LED pixels and strip) // https://github.com/adafruit/Adafruit_NeoPixel @@ -25,11 +26,10 @@ namespace led_effects { void setupRing(); void redAlert(); - void breathe(int16_t co2); - int countdownToZero(); + bool countdownToZero(); void showWaitingLED(uint32_t color); void showKITTWheel(uint32_t color, uint16_t duration_s = 2); - void showRainbowWheel(uint16_t duration_ms = 1000, uint16_t hue_increment = 50); + void showRainbowWheel(uint16_t duration_ms = 1000); void displayCO2color(uint16_t co2); } #endif diff --git a/ampel-firmware/lorawan.cpp b/ampel-firmware/lorawan.cpp index 7e02770..746893b 100644 --- a/ampel-firmware/lorawan.cpp +++ b/ampel-firmware/lorawan.cpp @@ -33,7 +33,7 @@ void os_getDevKey(u1_t *buf) { namespace lorawan { bool waiting_for_confirmation = false; bool connected = false; - String last_transmission = ""; + char last_transmission[23] = ""; void initialize() { Serial.println(F("Starting LoRaWAN. Frequency plan : " LMIC_FREQUENCY_PLAN " MHz.")); @@ -47,11 +47,12 @@ namespace lorawan { LMIC_reset(); // Join, but don't send anything yet. LMIC_startJoining(); + sensor_console::defineIntCommand("lora", setLoRaInterval, F("300 (Sets LoRaWAN sending interval, in s)")); } // 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. + // 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(); } @@ -64,8 +65,10 @@ namespace lorawan { } void onEvent(ev_t ev) { + char current_time[23]; + ntp::getLocalTime(current_time); Serial.print("LoRa - "); - Serial.print(ntp::getLocalTime()); + Serial.print(current_time); Serial.print(" - "); switch (ev) { case EV_JOINING: @@ -93,7 +96,7 @@ namespace lorawan { printHex2(artKey[i]); } Serial.println(); - Serial.print(" NwkSKey: "); + Serial.print(F(" NwkSKey: ")); for (size_t i = 0; i < sizeof(nwkKey); ++i) { if (i != 0) Serial.print("-"); @@ -112,7 +115,7 @@ namespace lorawan { Serial.println(F("EV_REJOIN_FAILED")); break; case EV_TXCOMPLETE: - last_transmission = ntp::getLocalTime(); + ntp::getLocalTime(last_transmission); Serial.println(F("EV_TXCOMPLETE")); break; case EV_TXSTART: @@ -189,6 +192,17 @@ namespace lorawan { preparePayload(co2, temperature, humidity); } } + + /***************************************************************** + * Callbacks for sensor commands * + *****************************************************************/ + void setLoRaInterval(int32_t sending_interval) { + config::lorawan_sending_interval = sending_interval; + Serial.print(F("Setting LoRa sending interval to : ")); + Serial.print(config::lorawan_sending_interval); + Serial.println("s."); + led_effects::showKITTWheel(color::green, 1); + } } void onEvent(ev_t ev) { diff --git a/ampel-firmware/lorawan.h b/ampel-firmware/lorawan.h index a4e29ab..45f462b 100644 --- a/ampel-firmware/lorawan.h +++ b/ampel-firmware/lorawan.h @@ -13,7 +13,7 @@ #include #include "led_effects.h" - +#include "sensor_console.h" #include "util.h" namespace config { @@ -39,10 +39,12 @@ namespace config { namespace lorawan { extern bool waiting_for_confirmation; extern bool connected; - extern String last_transmission; + extern char last_transmission[]; void initialize(); void process(); void preparePayloadIfTimeHasCome(const int16_t &co2, const float &temp, const float &hum); + + void setLoRaInterval(int32_t sending_interval); } #endif diff --git a/ampel-firmware/mqtt.cpp b/ampel-firmware/mqtt.cpp index 2e71ae2..8c61696 100644 --- a/ampel-firmware/mqtt.cpp +++ b/ampel-firmware/mqtt.cpp @@ -2,7 +2,7 @@ namespace config { // Values should be defined in config.h - uint16_t sending_interval = MQTT_SENDING_INTERVAL; // [s] + uint16_t mqtt_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; @@ -23,31 +23,34 @@ namespace mqtt { unsigned long last_failed_at = 0; bool connected = false; - String publish_topic; + char publish_topic[21]; // e.g. "CO2sensors/ESPxxxxxx\0" const char *json_sensor_format; - String last_successful_publish = ""; + char last_successful_publish[23] = ""; - void initialize(String &topic) { + void initialize(const char *sensorId) { json_sensor_format = PSTR("{\"time\":\"%s\", \"co2\":%d, \"temp\":%.1f, \"rh\":%.1f}"); - publish_topic = topic; -#if defined(ESP8266) - espClient.setInsecure(); // Sorry, we don't want to flash the sensors every 3 months. -#endif - // mqttClient.setSocketTimeout(config::mqtt_timeout); //NOTE: somehow doesn't seem to have any effect on connect() + snprintf(publish_topic, sizeof(publish_topic), "CO2sensors/%s", sensorId); + // The sensor doesn't check the fingerprint of the MQTT broker, because otherwise this fingerprint should be updated + // on the sensor every 3 months. The connection can still be encrypted, though: + espClient.setInsecure(); // If not available for ESP32, please update Arduino IDE / PlatformIO mqttClient.setServer(config::mqtt_server, config::mqtt_port); + + sensor_console::defineIntCommand("mqtt", setMQTTinterval, F("60 (Sets MQTT sending interval, in s)")); + sensor_console::defineCommand("send_local_ip", sendInfoAboutLocalNetwork, + F("(Sends local IP and SSID via MQTT. Can be useful to find sensor)")); } - void publish(const String ×tamp, int16_t co2, float temperature, float humidity) { + void publish(const char *timestamp, int16_t co2, float temperature, float humidity) { if (WiFi.status() == WL_CONNECTED && mqttClient.connected()) { led_effects::onBoardLEDOn(); Serial.print(F("MQTT - Publishing message ... ")); char payload[75]; // Should be enough for json... - snprintf(payload, sizeof(payload), json_sensor_format, timestamp.c_str(), co2, temperature, humidity); + snprintf(payload, sizeof(payload), json_sensor_format, timestamp, co2, temperature, humidity); // Topic is the same as clientID. e.g. 'CO2sensors/ESP3d03da' - if (mqttClient.publish(publish_topic.c_str(), payload)) { + if (mqttClient.publish(publish_topic, payload)) { Serial.println(F("OK")); - last_successful_publish = ntp::getLocalTime(); + ntp::getLocalTime(last_successful_publish); } else { Serial.println(F("Failed.")); } @@ -55,69 +58,6 @@ namespace mqtt { } } - void setTimer(String messageString) { - messageString.replace("timer ", ""); - int timestep = messageString.toInt(); - if (timestep >= 2 && timestep <= 1800) { - Serial.print(F("Setting Measurement Interval to : ")); - Serial.print(timestep); - Serial.println("s."); - sensor::scd30.setMeasurementInterval(messageString.toInt()); - config::measurement_timestep = messageString.toInt(); - led_effects::showKITTWheel(color::green, 1); - } - } - - void setMQTTinterval(String messageString) { - messageString.replace("mqtt ", ""); - config::sending_interval = messageString.toInt(); - Serial.print(F("Setting Sending Interval to : ")); - Serial.print(config::sending_interval); - Serial.println("s."); - 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."); - led_effects::showKITTWheel(color::green, 1); - } -#endif - - void calibrateSensorToSpecificPPM(String messageString) { - messageString.replace("calibrate ", ""); - long int calibrationLevel = messageString.toInt(); - if (calibrationLevel >= 400 && calibrationLevel <= 2000) { - Serial.print(F("Force calibration, at ")); - config::co2_calibration_level = messageString.toInt(); - Serial.print(config::co2_calibration_level); - Serial.println(" ppm."); - sensor::startCalibrationProcess(); - } - } - - void setCO2forDebugging(String messageString) { - Serial.print(F("DEBUG. Setting CO2 to ")); - messageString.replace("co2 ", ""); - sensor::co2 = messageString.toInt(); - Serial.println(sensor::co2); - } - - void sendInfoAboutLocalNetwork() { - 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... - const char *json_info_format = PSTR("{\"local_ip\":\"%s\", \"ssid\":\"%s\"}"); - snprintf(payload, sizeof(payload), json_info_format, WiFi.localIP().toString().c_str(), WiFi.SSID().c_str()); - - mqttClient.publish(info_topic, payload); - } - /** * Allows sensor to be controlled by commands over MQTT * @@ -132,46 +72,13 @@ namespace mqtt { } led_effects::onBoardLEDOn(); Serial.print(F("Message arrived on topic: ")); - Serial.print(sub_topic); - Serial.print(F(". Message: '")); - String messageString; + Serial.println(sub_topic); + char command[length + 1]; for (unsigned int i = 0; i < length; i++) { - Serial.print((char) message[i]); - messageString += (char) message[i]; + command[i] = message[i]; } - Serial.println("'."); - - if (messageString.startsWith("co2 ")) { - setCO2forDebugging(messageString); - } else if (messageString.startsWith("timer ")) { - setTimer(messageString); - } else if (messageString == "calibrate") { - sensor::startCalibrationProcess(); - } else if (messageString.startsWith("calibrate ")) { - calibrateSensorToSpecificPPM(messageString); - } else if (messageString.startsWith("mqtt ")) { - setMQTTinterval(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(); - led_effects::showKITTWheel(color::blue, 2); -#endif - } else if (messageString == "night_mode") { - led_effects::toggleNightMode(); - } else if (messageString == "local_ip") { - sendInfoAboutLocalNetwork(); - } else if (messageString == "reset") { - ESP.restart(); // softer than ESP.reset() - } else { - led_effects::showKITTWheel(color::red, 1); - Serial.println(F("Message not supported. Doing nothing.")); - } - delay(50); + command[length] = 0; + sensor_console::execute(command); led_effects::onBoardLEDOff(); } @@ -188,7 +95,7 @@ namespace mqtt { led_effects::onBoardLEDOn(); // Wait for connection, at most 15s (default) - mqttClient.connect(publish_topic.c_str(), config::mqtt_user, config::mqtt_password); + mqttClient.connect(publish_topic, config::mqtt_user, config::mqtt_password); led_effects::onBoardLEDOff(); connected = mqttClient.connected(); @@ -196,7 +103,7 @@ namespace mqtt { if (connected) { if (config::allow_mqtt_commands) { char control_topic[60]; // Should be enough for "CO2sensors/ESPd03cc5/control" - snprintf(control_topic, sizeof(control_topic), "%s/control", publish_topic.c_str()); + snprintf(control_topic, sizeof(control_topic), "%s/control", publish_topic); mqttClient.subscribe(control_topic); mqttClient.setCallback(controlSensorCallback); } @@ -212,12 +119,12 @@ namespace mqtt { } } - void publishIfTimeHasCome(const String &timeStamp, const int16_t &co2, const float &temp, const float &hum) { + void publishIfTimeHasCome(const char *timestamp, const int16_t &co2, const float &temp, const float &hum) { // Send message via MQTT according to sending interval unsigned long now = seconds(); - if (now - last_sent_at > config::sending_interval) { + if (now - last_sent_at > config::mqtt_sending_interval) { last_sent_at = now; - publish(timeStamp, co2, temp, hum); + publish(timestamp, co2, temp, hum); } } @@ -229,4 +136,28 @@ namespace mqtt { mqttClient.loop(); } + /***************************************************************** + * Callbacks for sensor commands * + *****************************************************************/ + void setMQTTinterval(int32_t sending_interval) { + config::mqtt_sending_interval = sending_interval; + Serial.print(F("Setting MQTT sending interval to : ")); + Serial.print(config::mqtt_sending_interval); + Serial.println("s."); + led_effects::showKITTWheel(color::green, 1); + } + + // It can be hard to find the local IP of a sensor if it isn't connected to Serial port, and if mDNS is disabled. + // If the sensor can be reach by MQTT, it can answer with info about local_ip and ssid. + // The sensor will send the info to "CO2sensors/ESP123456/info". + void sendInfoAboutLocalNetwork() { + char info_topic[60]; // Should be enough for "CO2sensors/ESP123456/info" + snprintf(info_topic, sizeof(info_topic), "%s/info", publish_topic); + + char payload[75]; // Should be enough for info json... + const char *json_info_format = PSTR("{\"local_ip\":\"%s\", \"ssid\":\"%s\"}"); + snprintf(payload, sizeof(payload), json_info_format, wifi::local_ip, WIFI_SSID); + + mqttClient.publish(info_topic, payload); + } } diff --git a/ampel-firmware/mqtt.h b/ampel-firmware/mqtt.h index 2899a99..4d10883 100644 --- a/ampel-firmware/mqtt.h +++ b/ampel-firmware/mqtt.h @@ -4,20 +4,22 @@ #include #include "config.h" #include "led_effects.h" -#ifdef AMPEL_CSV -# include "csv_writer.h" -#endif -#include "co2_sensor.h" +#include "sensor_console.h" #include "src/lib/PubSubClient/src/PubSubClient.h" #include "wifi_util.h" + namespace config { - extern uint16_t sending_interval; // [s] + extern uint16_t mqtt_sending_interval; // [s] } + namespace mqtt { - extern String last_successful_publish; + extern char last_successful_publish[]; extern bool connected; - void initialize(String &topic); + void initialize(const char *sensorId); void keepConnection(); - void publishIfTimeHasCome(const String &timeStamp, const int16_t &co2, const float &temp, const float &hum); + void publishIfTimeHasCome(const char *timestamp, const int16_t &co2, const float &temp, const float &hum); + + void setMQTTinterval(int32_t sending_interval); + void sendInfoAboutLocalNetwork(); } #endif diff --git a/ampel-firmware/sensor_console.cpp b/ampel-firmware/sensor_console.cpp new file mode 100644 index 0000000..5fc3377 --- /dev/null +++ b/ampel-firmware/sensor_console.cpp @@ -0,0 +1,188 @@ +#include "sensor_console.h" + +namespace sensor_console { + const uint8_t MAX_COMMANDS = 20; + const uint8_t MAX_COMMAND_SIZE = 30; + + uint8_t commands_count = 0; + + enum input_type { + NONE, + INT32, + STRING + }; + + struct Command { + const char *name; + union { + void (*voidFunction)(); + void (*intFunction)(int32_t); + void (*strFunction)(char*); + }; + const char *doc; + input_type parameter_type; + }; + + struct CommandLine { + char function_name[MAX_COMMAND_SIZE]; + input_type argument_type; + int32_t int_argument; + char str_argument[MAX_COMMAND_SIZE]; + }; + + Command commands[MAX_COMMANDS]; + + bool addCommand(const char *name, const __FlashStringHelper *doc_fstring) { + if (commands_count < MAX_COMMANDS) { + commands[commands_count].name = name; + commands[commands_count].doc = (const char*) doc_fstring; + return true; + } else { + Serial.println(F("Too many commands have been defined.")); + return false; + } + } + + void defineCommand(const char *name, void (*function)(), const __FlashStringHelper *doc_fstring) { + if (addCommand(name, doc_fstring)) { + commands[commands_count].voidFunction = function; + commands[commands_count++].parameter_type = NONE; + } + } + + void defineIntCommand(const char *name, void (*function)(int32_t), const __FlashStringHelper *doc_fstring) { + if (addCommand(name, doc_fstring)) { + commands[commands_count].intFunction = function; + commands[commands_count++].parameter_type = INT32; + } + } + + void defineStringCommand(const char *name, void (*function)(char*), const __FlashStringHelper *doc_fstring) { + if (addCommand(name, doc_fstring)) { + commands[commands_count].strFunction = function; + commands[commands_count++].parameter_type = STRING; + } + } + + /* + * Tries to split a string command (e.g. 'mqtt 60' or 'show_csv') into + * a CommandLine struct (function_name, argument_type and argument) + */ + void parseCommand(const char *command, CommandLine &command_line) { + if (strlen(command) == 0) { + Serial.println(F("Received empty command")); + command_line.argument_type = NONE; + return; + } + + char *first_space; + first_space = strchr(command, ' '); + + if (first_space == NULL) { + command_line.argument_type = NONE; + strlcpy(command_line.function_name, command, MAX_COMMAND_SIZE); + return; + } + + strlcpy(command_line.function_name, command, first_space - command + 1); + strlcpy(command_line.str_argument, first_space + 1, MAX_COMMAND_SIZE - (first_space - command) - 1); + + char *end; + command_line.int_argument = strtol(command_line.str_argument, &end, 0); // Accepts 123 or 0xFF00FF + + if (*end) { + command_line.argument_type = STRING; + } else { + command_line.argument_type = INT32; + } + } + + int compareCommandNames(const void *s1, const void *s2) { + struct Command *c1 = (struct Command*) s1; + struct Command *c2 = (struct Command*) s2; + return strcmp(c1->name, c2->name); + } + + void listAvailableCommands() { + qsort(commands, commands_count, sizeof(commands[0]), compareCommandNames); + for (uint8_t i = 0; i < commands_count; i++) { + Serial.print(F(" ")); + Serial.print(commands[i].name); + Serial.print(F(" ")); + Serial.print(commands[i].doc); + Serial.println(F(".")); + } + } + + /* + * Saves bytes from Serial.read() until enter is pressed, and tries to run the corresponding command. + * http://www.gammon.com.au/serial + */ + void processSerialInput(const byte input_byte) { + static char input_line[MAX_COMMAND_SIZE]; + static unsigned int input_pos = 0; + switch (input_byte) { + case '\n': // end of text + Serial.println(); + input_line[input_pos] = 0; + execute(input_line); + input_pos = 0; + break; + case '\r': // discard carriage return + break; + case '\b': // backspace + if (input_pos > 0) { + input_pos--; + Serial.print(F("\b \b")); + } + break; + default: + if (input_pos == 0) { + Serial.print(F("> ")); + } + // keep adding if not full ... allow for terminating null byte + if (input_pos < (MAX_COMMAND_SIZE - 1)) { + input_line[input_pos++] = input_byte; + Serial.print((char) input_byte); + } + break; + } + } + + /* + * Tries to find the corresponding callback for a given command. Name and parameter type should fit. + */ + void execute(const char *command_str) { + CommandLine input; + parseCommand(command_str, input); + for (uint8_t i = 0; i < commands_count; i++) { + if (!strcmp(input.function_name, commands[i].name) && input.argument_type == commands[i].parameter_type) { + Serial.print(F("Calling : ")); + Serial.print(input.function_name); + switch (input.argument_type) { + case NONE: + Serial.println(F("()")); + commands[i].voidFunction(); + return; + case INT32: + Serial.print(F("(")); + Serial.print(input.int_argument); + Serial.println(F(")")); + commands[i].intFunction(input.int_argument); + return; + case STRING: + Serial.print(F("('")); + Serial.print(input.str_argument); + Serial.println(F("')")); + commands[i].strFunction(input.str_argument); + return; + } + } + } + Serial.print(F("'")); + Serial.print(command_str); + Serial.println(F("' not supported. Available commands :")); + listAvailableCommands(); + } + +} diff --git a/ampel-firmware/sensor_console.h b/ampel-firmware/sensor_console.h new file mode 100644 index 0000000..5cf4450 --- /dev/null +++ b/ampel-firmware/sensor_console.h @@ -0,0 +1,20 @@ +#ifndef SENSOR_CONSOLE_H_INCLUDED +#define SENSOR_CONSOLE_H_INCLUDED +#include + +/** Other scripts can use this namespace, in order to define commands, via callbacks. + * Those callbacks can then be used to send commands to the sensor (reset, calibrate, night mode, ...) + * The callbacks can either have no parameter, or one int32_t parameter. + */ + +namespace sensor_console { + void defineCommand(const char *name, void (*function)(), const __FlashStringHelper *doc_fstring); + void defineIntCommand(const char *name, void (*function)(int32_t), const __FlashStringHelper *doc_fstring); + void defineStringCommand(const char *name, void (*function)(char*), const __FlashStringHelper *doc_fstring); + + void processSerialInput(const byte in_byte); + + void execute(const char *command_line); +} + +#endif diff --git a/ampel-firmware/src/lib/NTPClient-master/NTPClient.cpp b/ampel-firmware/src/lib/NTPClient-master/NTPClient.cpp index 048a82a..1b52de5 100644 --- a/ampel-firmware/src/lib/NTPClient-master/NTPClient.cpp +++ b/ampel-firmware/src/lib/NTPClient-master/NTPClient.cpp @@ -152,19 +152,17 @@ int NTPClient::getSeconds() { return (this->getEpochTime() % 60); } -String NTPClient::getFormattedTime(unsigned long secs) { +void NTPClient::getFormattedTime(char *formatted_time, unsigned long secs) { unsigned long rawTime = secs ? secs : this->getEpochTime(); unsigned int hours = (rawTime % 86400L) / 3600; unsigned int minutes = (rawTime % 3600) / 60; unsigned int seconds = rawTime % 60; - char formatted_time[9]; - snprintf(formatted_time, sizeof(formatted_time), "%02d:%02d:%02d", hours, minutes, seconds); - return String(formatted_time); + snprintf(formatted_time, 9, "%02d:%02d:%02d", hours, minutes, seconds); } // Based on https://github.com/PaulStoffregen/Time/blob/master/Time.cpp -String NTPClient::getFormattedDate(unsigned long secs) { +void NTPClient::getFormattedDate(char *formatted_date, unsigned long secs) { unsigned long rawTime = (secs ? secs : this->getEpochTime()) / 86400L; // in days unsigned long days = 0, year = 1970; uint8_t month; @@ -187,11 +185,9 @@ String NTPClient::getFormattedDate(unsigned long secs) { month++; // jan is month 1 rawTime++; // first day is day 1 - char formatted_date[23]; - snprintf(formatted_date, sizeof(formatted_date), "%4lu-%02d-%02lu %s%+03d", - year, month, rawTime, this->getFormattedTime(secs).c_str(), this->_timeOffset / 3600); - - return String(formatted_date); + char formatted_time[9]; + this->getFormattedTime(formatted_time, secs); + snprintf(formatted_date, 23, "%4lu-%02d-%02lu %s%+03d", year, month, rawTime, formatted_time, this->_timeOffset / 3600); } void NTPClient::end() { diff --git a/ampel-firmware/src/lib/NTPClient-master/NTPClient.h b/ampel-firmware/src/lib/NTPClient-master/NTPClient.h index ad45070..3349dbb 100644 --- a/ampel-firmware/src/lib/NTPClient-master/NTPClient.h +++ b/ampel-firmware/src/lib/NTPClient-master/NTPClient.h @@ -80,7 +80,7 @@ class NTPClient { /** * @return secs argument (or 0 for current time) formatted like `hh:mm:ss` */ - String getFormattedTime(unsigned long secs = 0); + void getFormattedTime(char *formatted_time, unsigned long secs = 0); /** * @return time in seconds since Jan. 1, 1970 @@ -91,7 +91,7 @@ class NTPClient { * @return secs argument (or 0 for current date) formatted to ISO 8601 * like `2004-02-12T15:19:21+00:00` */ - String getFormattedDate(unsigned long secs = 0); + void getFormattedDate(char *formatted_date, unsigned long secs = 0); /** * Stops the underlying UDP client diff --git a/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/LICENSE.md b/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/LICENSE.md index e64bd4e..3723868 100644 --- a/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/LICENSE.md +++ b/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/LICENSE.md @@ -34,7 +34,7 @@ Code The MIT License (MIT) -Copyright (c) 2016 SparkFun Electronics +Copyright (c) 2020 SparkFun Electronics Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/README.md b/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/README.md index 2f7c69c..9be1a0e 100644 --- a/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/README.md +++ b/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/README.md @@ -24,6 +24,7 @@ Thanks to! * [awatterott](https://github.com/awatterott) for adding [getAltitudeCompensation()](https://github.com/sparkfun/SparkFun_SCD30_Arduino_Library/pull/18) * [jogi-k](https://github.com/jogi-k) for adding [teensy i2clib](https://github.com/sparkfun/SparkFun_SCD30_Arduino_Library/pull/19) support * [paulvha](https://github.com/paulvha) for the suggestions and corrections in [his version of the library](https://github.com/paulvha/scd30) +* [yamamaya](https://github.com/yamamaya) for the [3ms delay](https://github.com/sparkfun/SparkFun_SCD30_Arduino_Library/pull/24) Repository Contents ------------------- @@ -43,8 +44,6 @@ License Information This product is _**open source**_! -Various bits of the code have different licenses applied. Anything SparkFun wrote is beerware; if you see me (or any other SparkFun employee) at the local, and you've found our code helpful, please buy us a round! - Please use, reuse, and modify these files as you see fit. Please maintain attribution to SparkFun Electronics and release anything derivative under the same license. Distributed as-is; no warranty is given. diff --git a/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/library.properties b/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/library.properties index a02d235..1e00449 100644 --- a/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/library.properties +++ b/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/library.properties @@ -1,5 +1,5 @@ name=SparkFun SCD30 Arduino Library -version=1.0.11 +version=1.0.13 author=SparkFun Electronics maintainer=SparkFun Electronics sentence=Library for the Sensirion SCD30 CO2 Sensor diff --git a/ampel-firmware/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 index c2a4230..3d4ed45 100644 --- a/ampel-firmware/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 @@ -266,6 +266,8 @@ bool SCD30::readMeasurement() if (_i2cPort->endTransmission() != 0) return (0); //Sensor did not ACK + delay(3); + const uint8_t receivedBytes = _i2cPort->requestFrom((uint8_t)SCD30_ADDRESS, (uint8_t)18); bool error = false; if (_i2cPort->available()) @@ -358,6 +360,8 @@ bool SCD30::getSettingValue(uint16_t registerAddress, uint16_t *val) if (_i2cPort->endTransmission() != 0) return (false); //Sensor did not ACK + delay(3); + _i2cPort->requestFrom((uint8_t)SCD30_ADDRESS, (uint8_t)3); // Request data and CRC if (_i2cPort->available()) { @@ -389,6 +393,8 @@ uint16_t SCD30::readRegister(uint16_t registerAddress) if (_i2cPort->endTransmission() != 0) return (0); //Sensor did not ACK + delay(3); + _i2cPort->requestFrom((uint8_t)SCD30_ADDRESS, (uint8_t)2); if (_i2cPort->available()) { diff --git a/ampel-firmware/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 index 3f39996..0104d09 100644 --- a/ampel-firmware/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 @@ -57,9 +57,10 @@ #define COMMAND_STOP_MEAS 0x0104 #define COMMAND_READ_FW_VER 0xD100 -typedef union { - byte array[4]; - float value; +typedef union +{ + byte array[4]; + float value; } ByteToFl; // paulvha class SCD30 @@ -69,9 +70,9 @@ public: bool begin(bool autoCalibrate) { return begin(Wire, autoCalibrate); } #ifdef USE_TEENSY3_I2C_LIB - bool begin(i2c_t3 &wirePort = Wire, bool autoCalibrate=true, bool measBegin=true); //By default use Wire port + bool begin(i2c_t3 &wirePort = Wire, bool autoCalibrate = false, bool measBegin = true); //By default use Wire port #else - bool begin(TwoWire &wirePort = Wire, bool autoCalibrate=true, bool measBegin=true); //By default use Wire port + bool begin(TwoWire &wirePort = Wire, bool autoCalibrate = false, bool measBegin = true); //By default use Wire port #endif void enableDebugging(Stream &debugPort = Serial); //Turn on debug printing. If user doesn't specify then Serial will be used. @@ -82,11 +83,11 @@ public: // based on paulvha bool getSettingValue(uint16_t registerAddress, uint16_t *val); - bool getForcedRecalibration(uint16_t *val) {return(getSettingValue(COMMAND_SET_FORCED_RECALIBRATION_FACTOR, val));} - bool getMeasurementInterval(uint16_t *val) {return(getSettingValue(COMMAND_SET_MEASUREMENT_INTERVAL, val));} - bool getTemperatureOffset(uint16_t *val) {return(getSettingValue(COMMAND_SET_TEMPERATURE_OFFSET, val));} - bool getAltitudeCompensation(uint16_t *val) {return(getSettingValue(COMMAND_SET_ALTITUDE_COMPENSATION, val));} - bool getFirmwareVersion(uint16_t *val) {return(getSettingValue(COMMAND_READ_FW_VER, val));} + bool getForcedRecalibration(uint16_t *val) { return (getSettingValue(COMMAND_SET_FORCED_RECALIBRATION_FACTOR, val)); } + bool getMeasurementInterval(uint16_t *val) { return (getSettingValue(COMMAND_SET_MEASUREMENT_INTERVAL, val)); } + bool getTemperatureOffset(uint16_t *val) { return (getSettingValue(COMMAND_SET_TEMPERATURE_OFFSET, val)); } + bool getAltitudeCompensation(uint16_t *val) { return (getSettingValue(COMMAND_SET_ALTITUDE_COMPENSATION, val)); } + bool getFirmwareVersion(uint16_t *val) { return (getSettingValue(COMMAND_READ_FW_VER, val)); } uint16_t getCO2(void); float getHumidity(void); @@ -115,12 +116,11 @@ public: uint8_t computeCRC8(uint8_t data[], uint8_t len); private: - //Variables #ifdef USE_TEENSY3_I2C_LIB i2c_t3 *_i2cPort; //The generic connection to user's chosen I2C hardware #else - TwoWire *_i2cPort; //The generic connection to user's chosen I2C hardware + TwoWire *_i2cPort; //The generic connection to user's chosen I2C hardware #endif //Global main datums float co2 = 0; @@ -136,6 +136,5 @@ private: //Debug Stream *_debugPort; //The stream to send debug messages to if enabled. Usually Serial. boolean _printDebug = false; //Flag to print debugging variables - }; #endif diff --git a/ampel-firmware/util.cpp b/ampel-firmware/util.cpp index 31df19d..73f5376 100644 --- a/ampel-firmware/util.cpp +++ b/ampel-firmware/util.cpp @@ -5,41 +5,96 @@ namespace config { const long utc_offset_in_seconds = UTC_OFFSET_IN_SECONDS; // UTC+1 } -// Get last 3 bytes of ESP MAC (worldwide unique) -String macToID() { - uint8_t mac[6]; - WiFi.macAddress(mac); - String result; - for (int i = 3; i < 6; i++) { - if (mac[i] < 16) - result += '0'; - result += String(mac[i], HEX); - } - result.toLowerCase(); - return result; +#if defined(ESP8266) +const char *current_board = "ESP8266"; +# if !defined(AMPEL_WIFI) +void preinit() { + // WiFi would be initialized otherwise (on ESP8266), even if unused. + // see https://github.com/esp8266/Arduino/issues/2111#issuecomment-224251391 + ESP8266WiFiClass::preinitWiFiOff(); } +# endif +#elif defined(ESP32) +const char *current_board = "ESP32"; +#else +const char *current_board = "UNKNOWN"; +#endif //NOTE: ESP32 sometimes couldn't access the NTP server, and every loop would take +1000ms // ifdefs could be used to define functions specific to ESP32, e.g. with configTime namespace ntp { WiFiUDP ntpUDP; NTPClient timeClient(ntpUDP, config::ntp_server, config::utc_offset_in_seconds, 60000UL); + bool connected_at_least_once = false; void initialize() { timeClient.begin(); } void update() { - timeClient.update(); + connected_at_least_once |= timeClient.update(); } - String getLocalTime() { - return timeClient.getFormattedDate(); + void getLocalTime(char *timestamp) { + timeClient.getFormattedDate(timestamp); + } + + void setLocalTime(int32_t unix_seconds) { + char time[23]; + timeClient.getFormattedDate(time); + Serial.print(F("Current time : ")); + Serial.println(time); + if (connected_at_least_once) { + Serial.println(F("NTP update already happened. Not changing anything.")); + return; + } + Serial.print(F("Setting UNIX time to : ")); + Serial.println(unix_seconds); + timeClient.setEpochTime(unix_seconds - seconds()); + timeClient.getFormattedDate(time); + Serial.print(F("Current time : ")); + Serial.println(time); } } -uint32_t max_loop_duration = 0; +void Ampel::showFreeSpace() { + Serial.print(F("Free heap space : ")); + Serial.print(ESP.getFreeHeap()); + Serial.println(F(" bytes.")); + Serial.print(F("Max free block size : ")); + Serial.print(esp_get_max_free_block_size()); + Serial.println(F(" bytes.")); + Serial.print(F("Heap fragmentation : ")); + Serial.print(esp_get_heap_fragmentation()); + Serial.println(F(" %")); +} -//FIXME: Remove every instance of Strings, to avoid heap fragmentation problems. (Start: "Free heap space : 17104 bytes") -// See more https://cpp4arduino.com/2020/02/07/how-to-format-strings-without-the-string-class.html -const String SENSOR_ID = "ESP" + macToID(); +char sensorId[10]; // e.g "ESPxxxxxx\0" +char macAddress[18]; // e.g "XX:XX:XX:XX:XX:XX\0" +uint8_t mac[6]; + +char* getMacString() { + WiFi.macAddress(mac); + // Get all 6 bytes of ESP MAC + snprintf(macAddress, sizeof(macAddress), "%02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], + mac[5]); + return macAddress; +} + +char* getSensorId() { + WiFi.macAddress(mac); + // Get last 3 bytes of ESP MAC (worldwide unique) + snprintf(sensorId, sizeof(sensorId), "ESP%02x%02x%02x", mac[3], mac[4], mac[5]); + return sensorId; +} + +Ampel::Ampel() : + board(current_board), sensorId(getSensorId()), macAddress(getMacString()), max_loop_duration(0) { + sensor_console::defineIntCommand("set_time", ntp::setLocalTime, F("1618829570 (Sets time to the given UNIX time)")); + sensor_console::defineCommand("free", Ampel::showFreeSpace, F("(Displays available heap space)")); + sensor_console::defineCommand("reset", []() { + ESP.restart(); + }, F("(Restarts the ESP)")); +} + +Ampel ampel; diff --git a/ampel-firmware/util.h b/ampel-firmware/util.h index 2997933..5d13bae 100644 --- a/ampel-firmware/util.h +++ b/ampel-firmware/util.h @@ -2,26 +2,25 @@ #define AMPEL_UTIL_H_INCLUDED #include #include "config.h" +#include "sensor_console.h" #include // required for NTP #include "src/lib/NTPClient-master/NTPClient.h" // NTP #if defined(ESP8266) -# define BOARD "ESP8266" # include // required to get MAC address -# define get_free_heap_size() system_get_free_heap_size() +# define esp_get_max_free_block_size() ESP.getMaxFreeBlockSize() +# define esp_get_heap_fragmentation() ESP.getHeapFragmentation() #elif defined(ESP32) -# define BOARD "ESP32" # include // required to get MAC address -# define get_free_heap_size() esp_get_free_heap_size() -#else -# define BOARD "Unknown" +# define esp_get_max_free_block_size() ESP.getMaxAllocHeap() //largest block of heap that can be allocated. +# define esp_get_heap_fragmentation() "?" // apparently not available for ESP32 #endif namespace ntp { void initialize(); void update(); - String getLocalTime(); + void getLocalTime(char *timestamp); } namespace util { @@ -35,10 +34,21 @@ namespace util { return b > a ? b : a; } } +class Ampel { +private: + static void showFreeSpace(); +public: + const char *version = "v0.2.3-DEV"; // Update manually after significant changes. + const char *board; + const char *sensorId; + const char *macAddress; + uint32_t max_loop_duration; + Ampel(); +}; + +extern Ampel ampel; //NOTE: Only use seconds() for duration comparison, not timestamps comparison. Otherwise, problems happen when millis roll over. #define seconds() (millis() / 1000UL) -extern uint32_t max_loop_duration; -const extern String SENSOR_ID; #endif diff --git a/ampel-firmware/web_server.cpp b/ampel-firmware/web_server.cpp index 8180ff4..75897bc 100644 --- a/ampel-firmware/web_server.cpp +++ b/ampel-firmware/web_server.cpp @@ -22,6 +22,7 @@ namespace web_server { const char *script_template; void handleWebServerRoot(); void handlePageNotFound(); + void handleWebServerCommand(); #ifdef AMPEL_CSV void handleDeleteCSV(); @@ -58,13 +59,13 @@ namespace web_server { "\n" "\n" - "

MakerLab Murnau e.V. CO2 Ampel

\n" + "

HfT-Stuttgart CO2 Ampel

\n" "
\n" @@ -72,16 +73,15 @@ namespace web_server { // 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" - "\n"); - - body_template = - PSTR("
\n" - "
\n" // Graph placeholder + "\n" + "
\n" + "
\n"// Graph placeholder "
\n" "
\n" - //Sensor table - "\n" - "\n" + "
%s
\n"); + + body_template = + PSTR("\n" "\n" "\n" "\n" @@ -108,14 +108,19 @@ namespace web_server { #endif "\n" "\n" //TODO: Read it from sensor? + "\n" "\n" "\n" + "\n" "\n" + "\n" "\n" "\n" + "\n" "\n" "
%s
CO2 concentration%5d ppm
Temperature%.1f℃
Humidity%.1f%%
Sensor
Temperature offset%.1fK
Auto-calibration?%s
Local address%s.local
Local IP%s
MAC%s
Free heap space%6d bytes
Largest heap block%6d bytes
Max loop duration%5d ms
Board%s
Ampel firmware%s
Uptime%2d d %4d h %02d min %02d s
\n" "
\n" + "
\n" #ifdef AMPEL_CSV "
" "" @@ -130,7 +135,7 @@ namespace web_server { #ifdef AMPEL_CSV "