new version 0_2_2 from stuttgart

This commit is contained in:
Jens Noack 2021-11-15 13:45:36 +01:00
parent 0514952897
commit c1b71e9829
32 changed files with 1049 additions and 501 deletions

2
.gitignore vendored
View file

@ -3,8 +3,6 @@
.pio/build .pio/build
.vscode .vscode
*.ino.cpp *.ino.cpp
config.h
config.hftstuttgart.h
.project .project
.cproject .cproject
.settings .settings

View file

@ -2,29 +2,11 @@ image: python:3.6
stages: stages:
- build - build
- release
before_script: before_script:
- "pip install -U platformio" - "pip install -U platformio"
- "cp ampel-firmware/config.public.h ampel-firmware/config.h" - "cp ampel-firmware/config.public.h ampel-firmware/config.h"
esp8266: job:
stage: build stage: build
script: script: "platformio run"
- "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

25
CHANGELOG.md Normal file
View file

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

View file

@ -1,6 +1,6 @@
# CO<sub>2</sub> Ampel # CO<sub>2</sub> Ampel
*CO<sub>2</sub> Ampel* is an open-source project, written in C++ for ESP8266 or ESP32. *CO<sub>2</sub> 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 CO<sub>2</sub> concentration (in ppm), and displays it on an LED ring. It measures the current CO<sub>2</sub> concentration (in ppm), and displays it on an LED ring.
@ -12,20 +12,20 @@ The *CO<sub>2</sub> Ampel* can:
* Display CO2 concentration on LED ring. * Display CO2 concentration on LED ring.
* Allow calibration. * Allow calibration.
* Get current time over NTP * Get current time over [NTP](https://en.wikipedia.org/wiki/Network_Time_Protocol)
* Send data over MQTT. * Send data over [MQTT](https://en.wikipedia.org/wiki/MQTT).
* Send data over LoRaWAN. * Send data over [LoRaWAN](https://en.wikipedia.org/wiki/LoRa#LoRaWAN).
* Display measurements and configuration on a small website. * 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 ## 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*) * [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" * [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. See the [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).
## Software Requirements ## Software Requirements
@ -68,27 +68,49 @@ make upload board=esp32 && make monitor # For ESP32
* *Upload* * *Upload*
* *Tools > Serial Monitor* * *Tools > Serial Monitor*
## Available commands
In Arduino IDE *Serial Monitor* or PlatformIO *Monitor*, type `help` + <kbd>Enter</kbd> 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 ## Authors
* Eric Duminil * Eric Duminil (HfT Stuttgart)
* Robert Otto * Robert Otto (HfT Stuttgart)
* Myriam Guedey * Myriam Guedey (HfT Stuttgart)
* Tobias Gabriel Erhart * Tobias Gabriel Erhart (HfT Stuttgart)
* Jonas Stave * Jonas Stave (HfT Stuttgart)
* Michael Käppler
Hochschule für Technik Stuttgart
## Modifications by
* Jens Noack
MakerLab Murnau e.V.
## Contributing ## 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 ## 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/) [GPLv3](https://choosealicense.com/licenses/gpl-3.0/)

View file

@ -20,6 +20,12 @@
# ifdef AMPEL_HTTP # ifdef AMPEL_HTTP
# include "web_server.h" # include "web_server.h"
# endif # endif
# if defined(ESP8266)
//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>
# endif
#endif #endif
#ifdef AMPEL_LORAWAN #ifdef AMPEL_LORAWAN
@ -27,17 +33,8 @@
#endif #endif
#include "util.h" #include "util.h"
#include "sensor_console.h"
#include "co2_sensor.h" #include "co2_sensor.h"
#include "led_effects.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 <ESP8266mDNS.h>
#elif defined(ESP32)
# include <ESPmDNS.h>
#endif
void keepServicesAlive();
void checkFlashButton();
#endif #endif

View file

@ -43,6 +43,7 @@
* Myriam Guedey * Myriam Guedey
* Tobias Gabriel Erhart * Tobias Gabriel Erhart
* Jonas Stave * Jonas Stave
* Michael Käppler
*/ */
/***************************************************************** /*****************************************************************
@ -61,22 +62,31 @@
void setup() { void setup() {
led_effects::setupOnBoardLED(); led_effects::setupOnBoardLED();
led_effects::onBoardLEDOff(); led_effects::onBoardLEDOff();
Serial.begin(BAUDS); 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::setupRing();
led_effects::showRainbowWheel(5000);
sensor::initialize(); sensor::initialize();
Serial.print(F("Sensor ID: ")); #ifdef AMPEL_CSV
Serial.println(SENSOR_ID); csv_writer::initialize(ampel.sensorId);
Serial.print(F("Board : ")); #endif
Serial.println(BOARD);
#ifdef AMPEL_WIFI #ifdef AMPEL_WIFI
WiFiConnect(SENSOR_ID); wifi::connect(ampel.sensorId);
Serial.print(F("WiFi - Status: ")); Serial.print(F("WiFi - Status: "));
Serial.println(WiFi.status()); Serial.println(WiFi.status());
@ -88,7 +98,7 @@ void setup() {
ntp::initialize(); 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); MDNS.addService("http", "tcp", 80);
Serial.println(F("mDNS responder started")); Serial.println(F("mDNS responder started"));
} else { } else {
@ -96,24 +106,26 @@ void setup() {
} }
# ifdef AMPEL_MQTT # ifdef AMPEL_MQTT
mqtt::initialize("CO2sensors/" + SENSOR_ID); mqtt::initialize(ampel.sensorId);
# endif # endif
} }
#endif #endif
#ifdef AMPEL_CSV
csv_writer::initialize();
#endif
#if defined(AMPEL_LORAWAN) && defined(ESP32) #if defined(AMPEL_LORAWAN) && defined(ESP32)
lorawan::initialize(); lorawan::initialize();
#endif #endif
} }
/*****************************************************************
* Helper functions *
*****************************************************************/
void keepServicesAlive();
void checkFlashButton();
void checkSerialInput();
/***************************************************************** /*****************************************************************
* Main loop * * Main loop *
*****************************************************************/ *****************************************************************/
void loop() { void loop() {
#if defined(AMPEL_LORAWAN) && defined(ESP32) #if defined(AMPEL_LORAWAN) && defined(ESP32)
//LMIC Library seems to be very sensitive to timing issues, so run it first. //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. // Short press for night mode, Long press for calibration.
checkFlashButton(); checkFlashButton();
checkSerialInput();
if (sensor::processData()) { if (sensor::processData()) {
#ifdef AMPEL_CSV #ifdef AMPEL_CSV
csv_writer::logIfTimeHasCome(sensor::timestamp, sensor::co2, sensor::temperature, sensor::humidity); csv_writer::logIfTimeHasCome(sensor::timestamp, sensor::co2, sensor::temperature, sensor::humidity);
@ -148,11 +162,17 @@ void loop() {
} }
uint32_t duration = millis() - t0; uint32_t duration = millis() - t0;
if (duration > max_loop_duration) { if (duration > ampel.max_loop_duration) {
max_loop_duration = duration; ampel.max_loop_duration = duration;
Serial.print(F("Debug - Max loop duration : ")); Serial.print(F("Debug - Max loop duration : "));
Serial.print(max_loop_duration); Serial.print(ampel.max_loop_duration);
Serial.println(" ms."); 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. * If long press, start calibration process.
*/ */
void checkFlashButton() { void checkFlashButton() {
if (!digitalRead(buttonPin)) { // Button has been pressed if (!digitalRead(0)) { // Button has been pressed
led_effects::onBoardLEDOn(); led_effects::onBoardLEDOn();
delay(300); delay(300);
if (digitalRead(buttonPin)) { if (digitalRead(0)) {
Serial.println(F("Flash has been pressed for a short time. Should toggle night mode.")); Serial.println(F("Flash has been pressed for a short time. Should toggle night mode."));
led_effects::toggleNightMode(); led_effects::toggleNightMode();
} else { } else {
Serial.println(F("Flash has been pressed for a long time. Keep it pressed for calibration.")); 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(); sensor::startCalibrationProcess();
led_effects::showKITTWheel(color::red, 2);
} }
} }
led_effects::onBoardLEDOff(); led_effects::onBoardLEDOff();

View file

@ -1,10 +1,17 @@
#include "co2_sensor.h" #include "co2_sensor.h"
namespace config { namespace config {
// Values should be defined in config.h // UPPERCASE values should be defined in config.h
uint16_t measurement_timestep = MEASUREMENT_TIMESTEP; // [s] Value between 2 and 1800 (range for SCD30 sensor) 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] const uint16_t altitude_above_sea_level = ALTITUDE_ABOVE_SEA_LEVEL; // [m]
uint16_t co2_calibration_level = ATMOSPHERIC_CO2_CONCENTRATION; // [ppm] 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 #ifdef TEMPERATURE_OFFSET
// Residual heat from CO2 sensor seems to be high enough to change the temperature reading. How much should it be 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. // NOTE: Sign isn't relevant. The returned temperature will always be shifted down.
@ -12,23 +19,46 @@ namespace config {
#else #else
const float temperature_offset = -3.0; // [K] Temperature measured by sensor is usually at least 3K too high. const float temperature_offset = -3.0; // [K] Temperature measured by sensor is usually at least 3K too high.
#endif #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 { namespace sensor {
SCD30 scd30; SCD30 scd30;
int16_t co2 = 0; uint16_t co2 = 0;
float temperature = 0; float temperature = 0;
float humidity = 0; float humidity = 0;
String timestamp = ""; char timestamp[23];
int16_t stable_measurements = 0; 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() { void initialize() {
#if defined(ESP8266) #if defined(ESP8266)
Wire.begin(12, 14); // ESP8266 - SDA: D6, SCL: D5; Wire.begin(12, 14); // ESP8266 - D6, D5;
#endif #endif
#if defined(ESP32) #if defined(ESP32)
Wire.begin(21, 22); // 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) * SDA --- SDA (GPIO21) //NOTE: GPIO1 would be more convenient (right next to GPO3)
*/ */
#endif #endif
Serial.println();
scd30.enableDebugging(); // Prints firmware version in the console.
// CO2 if (!scd30.begin(config::auto_calibrate_sensor)) {
if (scd30.begin(config::auto_calibrate_sensor) == false) { Serial.println(F("ERROR - CO2 sensor not detected. Please check wiring!"));
Serial.println("Air sensor not detected. Please check wiring. Freezing..."); led_effects::showKITTWheel(color::red, 30);
while (1) { ESP.restart();
led_effects::showWaitingLED(color::red);
}
} }
// SCD30 has its own timer. // Changes of the SCD30's measurement timestep do not come into effect
//NOTE: The timer seems to be inaccurate, though, possibly depending on voltage. Should it be offset? // before the next measurement takes place. That means that after a hard reset
Serial.println(); // of the ESP the SCD30 sometimes needs a long time until switching back to 2 s
Serial.print(F("Setting SCD30 timestep to ")); // for acclimatization. Resetting it after startup seems to fix this behaviour.
Serial.print(config::measurement_timestep); scd30.reset();
Serial.println(" s.");
scd30.setMeasurementInterval(config::measurement_timestep); // [s]
Serial.print(F("Setting temperature offset to -")); Serial.print(F("Setting temperature offset to -"));
Serial.print(abs(config::temperature_offset)); 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. scd30.setTemperatureOffset(abs(config::temperature_offset)); // setTemperatureOffset only accepts positive numbers, but shifts the temperature down.
delay(100); delay(100);
Serial.print(F("Temperature offset is : -")); Serial.print(F("Temperature offset is : -"));
Serial.print(scd30.getTemperatureOffset()); Serial.print(scd30.getTemperatureOffset());
Serial.println(" K"); Serial.println(F(" K"));
Serial.print(F("Auto-calibration is ")); Serial.print(F("Auto-calibration is "));
Serial.println(config::auto_calibrate_sensor ? "ON." : "OFF."); 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? bool hasSensorSettled() {
void checkTimerDeviation() { static uint16_t last_co2 = 0;
static int32_t previous_measurement_at = 0; uint16_t delta;
int32_t now = millis(); delta = abs(co2 - last_co2);
Serial.print("Measurement time offset : "); last_co2 = co2;
Serial.print(now - previous_measurement_at - config::measurement_timestep * 1000); // We assume the sensor has acclimated to the environment if measurements
Serial.println(" ms."); // change less than a specified percentage of the current value.
previous_measurement_at = now; return (co2 > 0 && delta < ((uint32_t) co2 * config::max_deviation_during_bootup / 100));
} }
void countStableMeasurements() { bool enoughStableMeasurements() {
static int16_t previous_co2 = 0; 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++; stable_measurements++;
Serial.print(F("Number of stable measurements : ")); Serial.print(F("Number of stable measurements : "));
Serial.println(stable_measurements); Serial.print(stable_measurements);
waiting_color = color::green; Serial.print(F(" / "));
Serial.println(config::stable_measurements_before_calibration);
switchState(PREPARE_CALIBRATION_STABLE);
} else { } else {
stable_measurements = 0; stable_measurements = 0;
waiting_color = color::red; switchState(PREPARE_CALIBRATION_UNSTABLE);
} }
previous_co2 = co2; previous_co2 = co2;
return (stable_measurements == config::stable_measurements_before_calibration);
} }
void startCalibrationProcess() { void startCalibrationProcess() {
/** From the sensor documentation: /** From the sensor documentation:
* For best results, the sensor has to be run in a stable environment in continuous mode at * Before applying FRC, SCD30 needs to be operated for 2 minutes with the desired measurement period in continuous mode.
* 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.")); Serial.print(F("Setting SCD30 timestep to "));
scd30.setMeasurementInterval(MEASUREMENT_TIMESTEP); // [s] The change will only take effect after next measurement. 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("Waiting until the measurements are stable for at least 2 minutes."));
Serial.println(F("It could take a very long time.")); Serial.println(F("It could take a very long time."));
should_calibrate = true; switchState(PREPARE_CALIBRATION_UNSTABLE);
time_calaibration_started = millis();
} }
void calibrateAndRestart() { void calibrate() {
Serial.print(F("Calibrating SCD30 now...")); Serial.print(F("Calibrating SCD30 now..."));
scd30.setAltitudeCompensation(config::altitude_above_sea_level); scd30.setAltitudeCompensation(config::altitude_above_sea_level);
scd30.setForcedRecalibrationFactor(config::co2_calibration_level); scd30.setForcedRecalibrationFactor(config::co2_calibration_level);
Serial.println(F(" Done!")); Serial.println(F(" Done!"));
Serial.println(F("Sensor calibrated.")); 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() { void logToSerial() {
@ -127,29 +179,86 @@ namespace sensor {
Serial.println(humidity, 1); Serial.println(humidity, 1);
} }
void displayCO2OnLedRing() { void switchState(state new_state) {
int16_t co2_int = co2; if (new_state == current_state) {
if (co2_int < CALIBRATE_LEVEL) {
// Sensor should be calibrated.
led_effects::showWaitingLED(color::magenta);
return; return;
} }
if(co2_int < 400) { if (config::debug_sensor_states) {
co2_int = 400; 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). * 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) { if (co2 < config::co2_alert_threshold) {
led_effects::displayCO2color(co2_int); led_effects::displayCO2color(co2);
led_effects::breathe(co2_int); delay(100);
} else { } else {
// >= 2000: entire ring blinks red // Display a flashing led ring, if concentration exceeds a specific value
led_effects::redAlert(); 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. /** 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) * 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(); bool freshData = scd30.dataAvailable();
if (freshData) { if (freshData) {
// checkTimerDeviation(); ntp::getLocalTime(timestamp);
timestamp = ntp::getLocalTime();
co2 = scd30.getCO2(); co2 = scd30.getCO2();
temperature = scd30.getTemperature(); temperature = scd30.getTemperature();
humidity = scd30.getHumidity(); 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. switchStateForCurrentPPM();
if (co2 <= 0) {
// No measurement yet. Waiting.
led_effects::showWaitingLED(color::blue);
return false;
}
/** // Log every time fresh data is available.
* Fresh data. Log it and send it if needed.
*/
if (freshData) {
if (should_calibrate) {
if(millis() - time_calaibration_started > 60000)
{
countStableMeasurements();
}
}
logToSerial(); logToSerial();
} }
if (should_calibrate) { showState();
if (stable_measurements == 60) {
calibrateAndRestart();
}
led_effects::showWaitingLED(waiting_color);
return false;
}
displayCO2OnLedRing(); // Report data for further processing only if the data is reliable
return freshData; // (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."));
} }
} }

View file

@ -7,24 +7,32 @@
#include "config.h" #include "config.h"
#include "led_effects.h" #include "led_effects.h"
#include "util.h" #include "util.h"
#include "sensor_console.h"
#include <Wire.h> #include <Wire.h>
namespace config { 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 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. extern const float temperature_offset; // [K] Sign isn't relevant.
} }
namespace sensor { namespace sensor {
extern SCD30 scd30; extern SCD30 scd30;
extern int16_t co2; extern uint16_t co2;
extern float temperature; extern float temperature;
extern float humidity; extern float humidity;
extern String timestamp; extern char timestamp[];
void initialize(); void initialize();
bool processData(); bool processData();
void startCalibrationProcess(); 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 #endif

View file

@ -1,6 +1,5 @@
#ifndef CONFIG_H_INCLUDED #ifndef CONFIG_H_INCLUDED
# define CONFIG_H_INCLUDED #define CONFIG_H_INCLUDED
#define buttonPin 0 #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. // 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 // 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_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_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_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") // # define AMPEL_LORAWAN // Should data be sent over LoRaWAN? (Requires ESP32 + LoRa modem, and "MCCI LoRaWAN LMIC library")
/** /**
* WIFI * WIFI
*/ */
# define WIFI_SSID "" # define WIFI_SSID "MY_SSID"
# define WIFI_PASSWORD "" # define WIFI_PASSWORD "P4SSW0RD"
# define WIFI_TIMEOUT 30 // [s] # define WIFI_TIMEOUT 30 // [s]
/** /**
@ -33,8 +32,10 @@
*/ */
// How often should measurement be performed, and displayed? // How often should measurement be performed, and displayed?
//NOTE: SCD30 timer does not seem to be very precise. Variations may occur. //WARNING: On some sensors, measurements become very unreliable when timestep is set to 2s.
# define MEASUREMENT_TIMESTEP 2 // [s] Value between 2 and 1800 (range for SCD30 sensor) //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 ? // 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 // 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? // 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. // 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 // Altitude above sea level
// Used for CO2 calibration // Used for CO2 calibration
// here: Stuttgart, Schellingstr. 24. (Source: Google Earth) // 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. // The reference CO2 concentration has to be within the range 400 ppm ≤ cref(CO2) ≤ 2000 ppm.
// Used for CO2 calibration // Used for CO2 calibration
// here : measured concentration in Stuttgart // here : measured concentration in Stuttgart
# define ATMOSPHERIC_CO2_CONCENTRATION 430 // [ppm] # define ATMOSPHERIC_CO2_CONCENTRATION 425 // [ppm]
// Should the sensor try to calibrate itself? // 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. // 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 * LEDs
*/ */
// LED brightness, which can vary between min and max brightness ("LED breathing") // LED brightness, which can vary between min and max brightness ("LED breathing")
// max_brightness should be between 0 and 255. // MAX_BRIGHTNESS must be defined, and should be between 0 and 255.
// min_brightness should be between 0 and max_brightness
# define MAX_BRIGHTNESS 128 # 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 # 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 * WEB SERVER
@ -76,8 +80,8 @@
*/ */
// Define empty strings in order to disable authentication, or remove the constants altogether. // Define empty strings in order to disable authentication, or remove the constants altogether.
# define HTTP_USER "" # define HTTP_USER "co2ampel"
# define HTTP_PASSWORD "" # define HTTP_PASSWORD "my_password"
/** /**
* MQTT * MQTT
@ -106,11 +110,11 @@
*/ */
# define ALLOW_MQTT_COMMANDS false # 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 // 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 // Set to 0 if you want to send values after each measurement
// # define MQTT_SENDING_INTERVAL MEASUREMENT_TIMESTEP * 5 // [s] # define MQTT_SENDING_INTERVAL MEASUREMENT_TIMESTEP * 5 // [s]
# define MQTT_SENDING_INTERVAL 60 // [s] //# define MQTT_SENDING_INTERVAL 60 // [s]
# define MQTT_SERVER "test.mosquitto.org" // MQTT server URL or IP address # define MQTT_SERVER "test.mosquitto.org" // MQTT server URL or IP address
# define MQTT_PORT 8883 # define MQTT_PORT 8883
# define MQTT_USER "" # define MQTT_USER ""
@ -149,7 +153,7 @@
*/ */
# define NTP_SERVER "pool.ntp.org" # 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 * Others

View file

@ -1,14 +1,12 @@
#include "csv_writer.h" #include "csv_writer.h"
//TODO: Allow CSV download via USB Serial, when requested (e.g. via a Python script)
namespace config { namespace config {
// Values should be defined in config.h // Values should be defined in config.h
uint16_t csv_interval = CSV_INTERVAL; // [s] uint16_t csv_interval = CSV_INTERVAL; // [s]
} }
namespace csv_writer { namespace csv_writer {
unsigned long last_written_at = 0; unsigned long last_written_at = 0;
String last_successful_write = ""; char last_successful_write[23];
#if defined(ESP8266) #if defined(ESP8266)
/** /**
@ -80,13 +78,16 @@ namespace csv_writer {
} }
#endif #endif
const String filename = "/" + SENSOR_ID + ".csv"; char filename[15]; // "/ESPxxxxxx.csv\0"
int getAvailableSpace() { int getAvailableSpace() {
return getTotalSpace() - getUsedSpace(); return getTotalSpace() - getUsedSpace();
} }
void initialize() { void initialize(const char *sensorId) {
snprintf(filename, sizeof(filename), "/%s.csv", sensorId);
Serial.println();
Serial.print(F("Initializing FS...")); Serial.print(F("Initializing FS..."));
if (mountFS()) { if (mountFS()) {
Serial.println(F("done.")); Serial.println(F("done."));
@ -113,9 +114,13 @@ namespace csv_writer {
Serial.println(); Serial.println();
// Open dir folder // Open dir folder
Serial.println("Filesystem content:"); Serial.println(F("Filesystem content:"));
showFilesystemContent(); showFilesystemContent();
Serial.println(); 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() { File openOrCreate() {
@ -130,11 +135,11 @@ namespace csv_writer {
return csv_file; 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(); led_effects::onBoardLEDOn();
File csv_file = openOrCreate(); File csv_file = openOrCreate();
char csv_line[42]; 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) { if (csv_file) {
size_t written_bytes = csv_file.print(csv_line); size_t written_bytes = csv_file.print(csv_line);
csv_file.close(); csv_file.close();
@ -143,7 +148,7 @@ namespace csv_writer {
} else { } else {
Serial.print(F("CSV - Wrote : ")); Serial.print(F("CSV - Wrote : "));
Serial.print(csv_line); Serial.print(csv_line);
last_successful_write = ntp::getLocalTime(); ntp::getLocalTime(last_successful_write);
} }
updateFsInfo(); updateFsInfo();
delay(50); delay(50);
@ -154,11 +159,42 @@ namespace csv_writer {
led_effects::onBoardLEDOff(); 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(); unsigned long now = seconds();
if (now - last_written_at > config::csv_interval) { if (now - last_written_at > config::csv_interval) {
last_written_at = now; last_written_at = now;
log(timeStamp, co2, temperature, humidity); 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);
}
} }

View file

@ -14,16 +14,21 @@
#include "config.h" #include "config.h"
#include "util.h" #include "util.h"
#include "led_effects.h" #include "led_effects.h"
#include "sensor_console.h"
namespace config { namespace config {
extern uint16_t csv_interval; // [s] extern uint16_t csv_interval; // [s]
} }
namespace csv_writer { namespace csv_writer {
extern String last_successful_write; extern char last_successful_write[];
void initialize(); void initialize(const char *sensorId);
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);
int getAvailableSpace(); int getAvailableSpace();
extern const String filename; extern char filename[];
void setCSVinterval(int32_t csv_interval);
void showCSVContent();
void formatFilesystem();
} }
#endif #endif

View file

@ -4,17 +4,36 @@
*****************************************************************/ *****************************************************************/
namespace config { namespace config {
const uint8_t max_brightness = MAX_BRIGHTNESS; const uint8_t max_brightness = MAX_BRIGHTNESS;
#if defined(MIN_BRIGHTNESS)
const uint8_t min_brightness = 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 #else
} const uint8_t min_brightness = MAX_BRIGHTNESS;
#endif
/*****************************************************************
* Configuration (calculated from above values) *
*****************************************************************/
namespace config //NOTE: Use a class instead? NightMode could then be another state.
{
const uint8_t brightness_amplitude = config::max_brightness - config::min_brightness; 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) #if defined(ESP8266)
@ -25,20 +44,7 @@ const int NEOPIXELS_PIN = 5;
const int NEOPIXELS_PIN = 23; const int NEOPIXELS_PIN = 23;
#endif #endif
const int NUMPIXELS = 12; Adafruit_NeoPixel pixels(config::led_count, NEOPIXELS_PIN, NEO_GRB + NEO_KHZ800);
//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);
namespace led_effects { namespace led_effects {
//On-board LED on D4, aka GPIO02 //On-board LED on D4, aka GPIO02
@ -71,10 +77,19 @@ namespace led_effects {
onBoardLEDOff(); 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() { void setupRing() {
pixels.begin(); pixels.begin();
pixels.setBrightness(config::max_brightness); pixels.setBrightness(config::max_brightness);
LEDsOff(); 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() { void toggleNightMode() {
@ -89,14 +104,15 @@ namespace led_effects {
//NOTE: basically one iteration of KITT wheel //NOTE: basically one iteration of KITT wheel
void showWaitingLED(uint32_t color) { void showWaitingLED(uint32_t color) {
using namespace config;
delay(80); delay(80);
if (config::night_mode) { if (night_mode) {
return; return;
} }
static uint16_t kitt_offset = 0; static uint16_t kitt_offset = 0;
pixels.clear(); pixels.clear();
for (int j = config::kitt_tail; j >= 0; j--) { for (int j = kitt_tail; j >= 0; j--) {
int ledNumber = abs((kitt_offset - j + NUMPIXELS) % (2 * NUMPIXELS) - NUMPIXELS) % NUMPIXELS; // Triangular function 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.setPixelColor(ledNumber, color * pixels.gamma8(255 - j * 76) / 255);
} }
pixels.show(); pixels.show();
@ -108,7 +124,7 @@ namespace led_effects {
// Takes approximately 1s for each direction. // Takes approximately 1s for each direction.
void showKITTWheel(uint32_t color, uint16_t duration_s) { void showKITTWheel(uint32_t color, uint16_t duration_s) {
pixels.setBrightness(config::max_brightness); 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); 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. * 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) { uint8_t getLedBrightness(uint16_t co2, int ledId) {
if (co2 >= CO2_TICKS[ledId + 1]) { if (co2 >= config::co2_ticks[ledId + 1]) {
return 255; return 255;
} else { } 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. // Show partial LED if co2 more than halfway between ticks.
return 27; // Brightness isn't linear, so 27 / 255 looks much brighter than 10% return 27; // Brightness isn't linear, so 27 / 255 looks much brighter than 10%
} else { } 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. * 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; return;
} }
pixels.setBrightness(config::max_brightness); 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); 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(); 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) { if (config::night_mode) {
return; return;
} }
static uint16_t wheel_offset = 0; static uint16_t wheel_offset = 0;
static uint16_t sine_offset = 0;
unsigned long t0 = millis(); unsigned long t0 = millis();
pixels.setBrightness(config::max_brightness); pixels.setBrightness(config::max_brightness);
while (millis() - t0 < duration_ms) { while (millis() - t0 < duration_ms) {
for (int i = 0; i < NUMPIXELS; i++) { for (int i = 0; i < config::led_count; i++) {
pixels.setPixelColor(i, pixels.ColorHSV(i * 65535 / NUMPIXELS + wheel_offset)); pixels.setPixelColor(i, pixels.ColorHSV(i * 65535 / config::led_count + wheel_offset));
wheel_offset += hue_increment; wheel_offset += (pixels.sine8(sine_offset++ / 50) - 127) / 2;
} }
pixels.show(); pixels.show();
delay(10); 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. * Displays a complete blue circle, and starts removing LEDs one by one.
* Can be used for calibration, e.g. when countdown is 0. Does not work in night mode. * 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) { if (config::night_mode) {
Serial.println(F("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. 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.fill(color::blue);
pixels.show(); pixels.show();
int countdown; 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.setPixelColor(countdown, color::black);
pixels.show(); pixels.show();
Serial.println(countdown); Serial.println(countdown);
delay(500); delay(500);
} }
return countdown; return countdown < 0;
} }
} }

View file

@ -2,6 +2,7 @@
#define LED_EFFECTS_H_INCLUDED #define LED_EFFECTS_H_INCLUDED
#include <Arduino.h> #include <Arduino.h>
#include "config.h" #include "config.h"
#include "sensor_console.h"
// Adafruit NeoPixel (Arduino library for controlling single-wire-based LED pixels and strip) // Adafruit NeoPixel (Arduino library for controlling single-wire-based LED pixels and strip)
// https://github.com/adafruit/Adafruit_NeoPixel // https://github.com/adafruit/Adafruit_NeoPixel
@ -25,11 +26,10 @@ namespace led_effects {
void setupRing(); void setupRing();
void redAlert(); void redAlert();
void breathe(int16_t co2); bool countdownToZero();
int countdownToZero();
void showWaitingLED(uint32_t color); void showWaitingLED(uint32_t color);
void showKITTWheel(uint32_t color, uint16_t duration_s = 2); 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); void displayCO2color(uint16_t co2);
} }
#endif #endif

View file

@ -33,7 +33,7 @@ void os_getDevKey(u1_t *buf) {
namespace lorawan { namespace lorawan {
bool waiting_for_confirmation = false; bool waiting_for_confirmation = false;
bool connected = false; bool connected = false;
String last_transmission = ""; char last_transmission[23] = "";
void initialize() { void initialize() {
Serial.println(F("Starting LoRaWAN. Frequency plan : " LMIC_FREQUENCY_PLAN " MHz.")); Serial.println(F("Starting LoRaWAN. Frequency plan : " LMIC_FREQUENCY_PLAN " MHz."));
@ -47,11 +47,12 @@ namespace lorawan {
LMIC_reset(); LMIC_reset();
// Join, but don't send anything yet. // Join, but don't send anything yet.
LMIC_startJoining(); 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. // 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. // 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() { void process() {
os_runloop_once(); os_runloop_once();
} }
@ -64,8 +65,10 @@ namespace lorawan {
} }
void onEvent(ev_t ev) { void onEvent(ev_t ev) {
char current_time[23];
ntp::getLocalTime(current_time);
Serial.print("LoRa - "); Serial.print("LoRa - ");
Serial.print(ntp::getLocalTime()); Serial.print(current_time);
Serial.print(" - "); Serial.print(" - ");
switch (ev) { switch (ev) {
case EV_JOINING: case EV_JOINING:
@ -93,7 +96,7 @@ namespace lorawan {
printHex2(artKey[i]); printHex2(artKey[i]);
} }
Serial.println(); Serial.println();
Serial.print(" NwkSKey: "); Serial.print(F(" NwkSKey: "));
for (size_t i = 0; i < sizeof(nwkKey); ++i) { for (size_t i = 0; i < sizeof(nwkKey); ++i) {
if (i != 0) if (i != 0)
Serial.print("-"); Serial.print("-");
@ -112,7 +115,7 @@ namespace lorawan {
Serial.println(F("EV_REJOIN_FAILED")); Serial.println(F("EV_REJOIN_FAILED"));
break; break;
case EV_TXCOMPLETE: case EV_TXCOMPLETE:
last_transmission = ntp::getLocalTime(); ntp::getLocalTime(last_transmission);
Serial.println(F("EV_TXCOMPLETE")); Serial.println(F("EV_TXCOMPLETE"));
break; break;
case EV_TXSTART: case EV_TXSTART:
@ -189,6 +192,17 @@ namespace lorawan {
preparePayload(co2, temperature, humidity); 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) { void onEvent(ev_t ev) {

View file

@ -13,7 +13,7 @@
#include <SPI.h> #include <SPI.h>
#include "led_effects.h" #include "led_effects.h"
#include "sensor_console.h"
#include "util.h" #include "util.h"
namespace config { namespace config {
@ -39,10 +39,12 @@ namespace config {
namespace lorawan { namespace lorawan {
extern bool waiting_for_confirmation; extern bool waiting_for_confirmation;
extern bool connected; extern bool connected;
extern String last_transmission; extern char last_transmission[];
void initialize(); void initialize();
void process(); void process();
void preparePayloadIfTimeHasCome(const int16_t &co2, const float &temp, const float &hum); void preparePayloadIfTimeHasCome(const int16_t &co2, const float &temp, const float &hum);
void setLoRaInterval(int32_t sending_interval);
} }
#endif #endif

View file

@ -2,7 +2,7 @@
namespace config { namespace config {
// Values should be defined in config.h // 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: //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 // 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; const char *mqtt_server = MQTT_SERVER;
@ -23,31 +23,34 @@ namespace mqtt {
unsigned long last_failed_at = 0; unsigned long last_failed_at = 0;
bool connected = false; bool connected = false;
String publish_topic; char publish_topic[21]; // e.g. "CO2sensors/ESPxxxxxx\0"
const char *json_sensor_format; 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}"); json_sensor_format = PSTR("{\"time\":\"%s\", \"co2\":%d, \"temp\":%.1f, \"rh\":%.1f}");
publish_topic = topic; snprintf(publish_topic, sizeof(publish_topic), "CO2sensors/%s", sensorId);
#if defined(ESP8266) // The sensor doesn't check the fingerprint of the MQTT broker, because otherwise this fingerprint should be updated
espClient.setInsecure(); // Sorry, we don't want to flash the sensors every 3 months. // on the sensor every 3 months. The connection can still be encrypted, though:
#endif espClient.setInsecure(); // If not available for ESP32, please update Arduino IDE / PlatformIO
// mqttClient.setSocketTimeout(config::mqtt_timeout); //NOTE: somehow doesn't seem to have any effect on connect()
mqttClient.setServer(config::mqtt_server, config::mqtt_port); 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 &timestamp, 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()) { if (WiFi.status() == WL_CONNECTED && mqttClient.connected()) {
led_effects::onBoardLEDOn(); led_effects::onBoardLEDOn();
Serial.print(F("MQTT - Publishing message ... ")); Serial.print(F("MQTT - Publishing message ... "));
char payload[75]; // Should be enough for json... 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' // 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")); Serial.println(F("OK"));
last_successful_publish = ntp::getLocalTime(); ntp::getLocalTime(last_successful_publish);
} else { } else {
Serial.println(F("Failed.")); 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 * Allows sensor to be controlled by commands over MQTT
* *
@ -132,46 +72,13 @@ namespace mqtt {
} }
led_effects::onBoardLEDOn(); led_effects::onBoardLEDOn();
Serial.print(F("Message arrived on topic: ")); Serial.print(F("Message arrived on topic: "));
Serial.print(sub_topic); Serial.println(sub_topic);
Serial.print(F(". Message: '")); char command[length + 1];
String messageString;
for (unsigned int i = 0; i < length; i++) { for (unsigned int i = 0; i < length; i++) {
Serial.print((char) message[i]); command[i] = message[i];
messageString += (char) message[i];
} }
Serial.println("'."); command[length] = 0;
sensor_console::execute(command);
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);
led_effects::onBoardLEDOff(); led_effects::onBoardLEDOff();
} }
@ -188,7 +95,7 @@ namespace mqtt {
led_effects::onBoardLEDOn(); led_effects::onBoardLEDOn();
// Wait for connection, at most 15s (default) // 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(); led_effects::onBoardLEDOff();
connected = mqttClient.connected(); connected = mqttClient.connected();
@ -196,7 +103,7 @@ namespace mqtt {
if (connected) { if (connected) {
if (config::allow_mqtt_commands) { if (config::allow_mqtt_commands) {
char control_topic[60]; // Should be enough for "CO2sensors/ESPd03cc5/control" 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.subscribe(control_topic);
mqttClient.setCallback(controlSensorCallback); 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 // Send message via MQTT according to sending interval
unsigned long now = seconds(); 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; last_sent_at = now;
publish(timeStamp, co2, temp, hum); publish(timestamp, co2, temp, hum);
} }
} }
@ -229,4 +136,28 @@ namespace mqtt {
mqttClient.loop(); 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);
}
} }

View file

@ -4,20 +4,22 @@
#include <Arduino.h> #include <Arduino.h>
#include "config.h" #include "config.h"
#include "led_effects.h" #include "led_effects.h"
#ifdef AMPEL_CSV #include "sensor_console.h"
# include "csv_writer.h"
#endif
#include "co2_sensor.h"
#include "src/lib/PubSubClient/src/PubSubClient.h" #include "src/lib/PubSubClient/src/PubSubClient.h"
#include "wifi_util.h" #include "wifi_util.h"
namespace config { namespace config {
extern uint16_t sending_interval; // [s] extern uint16_t mqtt_sending_interval; // [s]
} }
namespace mqtt { namespace mqtt {
extern String last_successful_publish; extern char last_successful_publish[];
extern bool connected; extern bool connected;
void initialize(String &topic); void initialize(const char *sensorId);
void keepConnection(); 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 #endif

View file

@ -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();
}
}

View file

@ -0,0 +1,20 @@
#ifndef SENSOR_CONSOLE_H_INCLUDED
#define SENSOR_CONSOLE_H_INCLUDED
#include <Arduino.h>
/** 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

View file

@ -152,19 +152,17 @@ int NTPClient::getSeconds() {
return (this->getEpochTime() % 60); 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 long rawTime = secs ? secs : this->getEpochTime();
unsigned int hours = (rawTime % 86400L) / 3600; unsigned int hours = (rawTime % 86400L) / 3600;
unsigned int minutes = (rawTime % 3600) / 60; unsigned int minutes = (rawTime % 3600) / 60;
unsigned int seconds = rawTime % 60; unsigned int seconds = rawTime % 60;
char formatted_time[9]; snprintf(formatted_time, 9, "%02d:%02d:%02d", hours, minutes, seconds);
snprintf(formatted_time, sizeof(formatted_time), "%02d:%02d:%02d", hours, minutes, seconds);
return String(formatted_time);
} }
// Based on https://github.com/PaulStoffregen/Time/blob/master/Time.cpp // 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 rawTime = (secs ? secs : this->getEpochTime()) / 86400L; // in days
unsigned long days = 0, year = 1970; unsigned long days = 0, year = 1970;
uint8_t month; uint8_t month;
@ -187,11 +185,9 @@ String NTPClient::getFormattedDate(unsigned long secs) {
month++; // jan is month 1 month++; // jan is month 1
rawTime++; // first day is day 1 rawTime++; // first day is day 1
char formatted_date[23]; char formatted_time[9];
snprintf(formatted_date, sizeof(formatted_date), "%4lu-%02d-%02lu %s%+03d", this->getFormattedTime(formatted_time, secs);
year, month, rawTime, this->getFormattedTime(secs).c_str(), this->_timeOffset / 3600); snprintf(formatted_date, 23, "%4lu-%02d-%02lu %s%+03d", year, month, rawTime, formatted_time, this->_timeOffset / 3600);
return String(formatted_date);
} }
void NTPClient::end() { void NTPClient::end() {

View file

@ -80,7 +80,7 @@ class NTPClient {
/** /**
* @return secs argument (or 0 for current time) formatted like `hh:mm:ss` * @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 * @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 * @return secs argument (or 0 for current date) formatted to ISO 8601
* like `2004-02-12T15:19:21+00:00` * 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 * Stops the underlying UDP client

View file

@ -34,7 +34,7 @@ Code
The MIT License (MIT) 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 Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View file

@ -24,6 +24,7 @@ Thanks to!
* [awatterott](https://github.com/awatterott) for adding [getAltitudeCompensation()](https://github.com/sparkfun/SparkFun_SCD30_Arduino_Library/pull/18) * [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 * [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) * [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 Repository Contents
------------------- -------------------
@ -43,8 +44,6 @@ License Information
This product is _**open source**_! 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. 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. Distributed as-is; no warranty is given.

View file

@ -1,5 +1,5 @@
name=SparkFun SCD30 Arduino Library name=SparkFun SCD30 Arduino Library
version=1.0.11 version=1.0.13
author=SparkFun Electronics author=SparkFun Electronics
maintainer=SparkFun Electronics <sparkfun.com> maintainer=SparkFun Electronics <sparkfun.com>
sentence=Library for the Sensirion SCD30 CO2 Sensor sentence=Library for the Sensirion SCD30 CO2 Sensor

View file

@ -266,6 +266,8 @@ bool SCD30::readMeasurement()
if (_i2cPort->endTransmission() != 0) if (_i2cPort->endTransmission() != 0)
return (0); //Sensor did not ACK return (0); //Sensor did not ACK
delay(3);
const uint8_t receivedBytes = _i2cPort->requestFrom((uint8_t)SCD30_ADDRESS, (uint8_t)18); const uint8_t receivedBytes = _i2cPort->requestFrom((uint8_t)SCD30_ADDRESS, (uint8_t)18);
bool error = false; bool error = false;
if (_i2cPort->available()) if (_i2cPort->available())
@ -358,6 +360,8 @@ bool SCD30::getSettingValue(uint16_t registerAddress, uint16_t *val)
if (_i2cPort->endTransmission() != 0) if (_i2cPort->endTransmission() != 0)
return (false); //Sensor did not ACK return (false); //Sensor did not ACK
delay(3);
_i2cPort->requestFrom((uint8_t)SCD30_ADDRESS, (uint8_t)3); // Request data and CRC _i2cPort->requestFrom((uint8_t)SCD30_ADDRESS, (uint8_t)3); // Request data and CRC
if (_i2cPort->available()) if (_i2cPort->available())
{ {
@ -389,6 +393,8 @@ uint16_t SCD30::readRegister(uint16_t registerAddress)
if (_i2cPort->endTransmission() != 0) if (_i2cPort->endTransmission() != 0)
return (0); //Sensor did not ACK return (0); //Sensor did not ACK
delay(3);
_i2cPort->requestFrom((uint8_t)SCD30_ADDRESS, (uint8_t)2); _i2cPort->requestFrom((uint8_t)SCD30_ADDRESS, (uint8_t)2);
if (_i2cPort->available()) if (_i2cPort->available())
{ {

View file

@ -57,9 +57,10 @@
#define COMMAND_STOP_MEAS 0x0104 #define COMMAND_STOP_MEAS 0x0104
#define COMMAND_READ_FW_VER 0xD100 #define COMMAND_READ_FW_VER 0xD100
typedef union { typedef union
byte array[4]; {
float value; byte array[4];
float value;
} ByteToFl; // paulvha } ByteToFl; // paulvha
class SCD30 class SCD30
@ -69,9 +70,9 @@ public:
bool begin(bool autoCalibrate) { return begin(Wire, autoCalibrate); } bool begin(bool autoCalibrate) { return begin(Wire, autoCalibrate); }
#ifdef USE_TEENSY3_I2C_LIB #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 #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 #endif
void enableDebugging(Stream &debugPort = Serial); //Turn on debug printing. If user doesn't specify then Serial will be used. 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 // based on paulvha
bool getSettingValue(uint16_t registerAddress, uint16_t *val); bool getSettingValue(uint16_t registerAddress, uint16_t *val);
bool getForcedRecalibration(uint16_t *val) {return(getSettingValue(COMMAND_SET_FORCED_RECALIBRATION_FACTOR, 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 getMeasurementInterval(uint16_t *val) { return (getSettingValue(COMMAND_SET_MEASUREMENT_INTERVAL, val)); }
bool getTemperatureOffset(uint16_t *val) {return(getSettingValue(COMMAND_SET_TEMPERATURE_OFFSET, 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 getAltitudeCompensation(uint16_t *val) { return (getSettingValue(COMMAND_SET_ALTITUDE_COMPENSATION, val)); }
bool getFirmwareVersion(uint16_t *val) {return(getSettingValue(COMMAND_READ_FW_VER, val));} bool getFirmwareVersion(uint16_t *val) { return (getSettingValue(COMMAND_READ_FW_VER, val)); }
uint16_t getCO2(void); uint16_t getCO2(void);
float getHumidity(void); float getHumidity(void);
@ -115,12 +116,11 @@ public:
uint8_t computeCRC8(uint8_t data[], uint8_t len); uint8_t computeCRC8(uint8_t data[], uint8_t len);
private: private:
//Variables //Variables
#ifdef USE_TEENSY3_I2C_LIB #ifdef USE_TEENSY3_I2C_LIB
i2c_t3 *_i2cPort; //The generic connection to user's chosen I2C hardware i2c_t3 *_i2cPort; //The generic connection to user's chosen I2C hardware
#else #else
TwoWire *_i2cPort; //The generic connection to user's chosen I2C hardware TwoWire *_i2cPort; //The generic connection to user's chosen I2C hardware
#endif #endif
//Global main datums //Global main datums
float co2 = 0; float co2 = 0;
@ -136,6 +136,5 @@ private:
//Debug //Debug
Stream *_debugPort; //The stream to send debug messages to if enabled. Usually Serial. Stream *_debugPort; //The stream to send debug messages to if enabled. Usually Serial.
boolean _printDebug = false; //Flag to print debugging variables boolean _printDebug = false; //Flag to print debugging variables
}; };
#endif #endif

View file

@ -5,41 +5,96 @@ namespace config {
const long utc_offset_in_seconds = UTC_OFFSET_IN_SECONDS; // UTC+1 const long utc_offset_in_seconds = UTC_OFFSET_IN_SECONDS; // UTC+1
} }
// Get last 3 bytes of ESP MAC (worldwide unique) #if defined(ESP8266)
String macToID() { const char *current_board = "ESP8266";
uint8_t mac[6]; # if !defined(AMPEL_WIFI)
WiFi.macAddress(mac); void preinit() {
String result; // WiFi would be initialized otherwise (on ESP8266), even if unused.
for (int i = 3; i < 6; i++) { // see https://github.com/esp8266/Arduino/issues/2111#issuecomment-224251391
if (mac[i] < 16) ESP8266WiFiClass::preinitWiFiOff();
result += '0';
result += String(mac[i], HEX);
}
result.toLowerCase();
return result;
} }
# 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 //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 // ifdefs could be used to define functions specific to ESP32, e.g. with configTime
namespace ntp { namespace ntp {
WiFiUDP ntpUDP; WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP, config::ntp_server, config::utc_offset_in_seconds, 60000UL); NTPClient timeClient(ntpUDP, config::ntp_server, config::utc_offset_in_seconds, 60000UL);
bool connected_at_least_once = false;
void initialize() { void initialize() {
timeClient.begin(); timeClient.begin();
} }
void update() { void update() {
timeClient.update(); connected_at_least_once |= timeClient.update();
} }
String getLocalTime() { void getLocalTime(char *timestamp) {
return timeClient.getFormattedDate(); 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") char sensorId[10]; // e.g "ESPxxxxxx\0"
// See more https://cpp4arduino.com/2020/02/07/how-to-format-strings-without-the-string-class.html char macAddress[18]; // e.g "XX:XX:XX:XX:XX:XX\0"
const String SENSOR_ID = "ESP" + macToID(); 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;

View file

@ -2,26 +2,25 @@
#define AMPEL_UTIL_H_INCLUDED #define AMPEL_UTIL_H_INCLUDED
#include <Arduino.h> #include <Arduino.h>
#include "config.h" #include "config.h"
#include "sensor_console.h"
#include <WiFiUdp.h> // required for NTP #include <WiFiUdp.h> // required for NTP
#include "src/lib/NTPClient-master/NTPClient.h" // NTP #include "src/lib/NTPClient-master/NTPClient.h" // NTP
#if defined(ESP8266) #if defined(ESP8266)
# define BOARD "ESP8266"
# include <ESP8266WiFi.h> // required to get MAC address # include <ESP8266WiFi.h> // 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) #elif defined(ESP32)
# define BOARD "ESP32"
# include <WiFi.h> // required to get MAC address # include <WiFi.h> // required to get MAC address
# define get_free_heap_size() esp_get_free_heap_size() # define esp_get_max_free_block_size() ESP.getMaxAllocHeap() //largest block of heap that can be allocated.
#else # define esp_get_heap_fragmentation() "?" // apparently not available for ESP32
# define BOARD "Unknown"
#endif #endif
namespace ntp { namespace ntp {
void initialize(); void initialize();
void update(); void update();
String getLocalTime(); void getLocalTime(char *timestamp);
} }
namespace util { namespace util {
@ -35,10 +34,21 @@ namespace util {
return b > a ? b : a; 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. //NOTE: Only use seconds() for duration comparison, not timestamps comparison. Otherwise, problems happen when millis roll over.
#define seconds() (millis() / 1000UL) #define seconds() (millis() / 1000UL)
extern uint32_t max_loop_duration;
const extern String SENSOR_ID;
#endif #endif

View file

@ -22,6 +22,7 @@ namespace web_server {
const char *script_template; const char *script_template;
void handleWebServerRoot(); void handleWebServerRoot();
void handlePageNotFound(); void handlePageNotFound();
void handleWebServerCommand();
#ifdef AMPEL_CSV #ifdef AMPEL_CSV
void handleDeleteCSV(); void handleDeleteCSV();
@ -58,13 +59,13 @@ namespace web_server {
"</head>\n" "</head>\n"
"<body>\n" "<body>\n"
"<div class='pure-g'><div class='pure-u-1'><div class='pure-menu'><p class='pure-menu-heading'>MakerLab Murnau e.V. CO<sub>2</sub> Ampel</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" "<div class='pure-u-1'><ul class='pure-menu pure-menu-horizontal pure-menu-list'>\n"
"<li class='pure-menu-item'><a href='#table' class='pure-menu-link'>Info</a></li>\n" "<li class='pure-menu-item'><a href='#table' class='pure-menu-link'>Info</a></li>\n"
#ifdef AMPEL_CSV #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='#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='#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" "<li class='pure-menu-item'><a href='%s' class='pure-menu-link'>Download CSV</a></li>\n"
#endif #endif
"<li class='pure-menu-item' id='led'>&#11044;</li>\n" // LED "<li class='pure-menu-item' id='led'>&#11044;</li>\n" // LED
"</ul></div></div>\n" "</ul></div></div>\n"
@ -72,16 +73,15 @@ namespace web_server {
// Show a colored dot on the webpage, with a similar color than on LED Ring. // 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" "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" "document.getElementById('led').style.color=['hsl(',hue,',100%%,50%%)'].join('');\n"
"</script>\n"); "</script>\n"
"<div class='pure-g'>\n"
body_template = "<div class='pure-u-1' id='graph'></div>\n"// Graph placeholder
PSTR("<div class='pure-g'>\n"
"<div class='pure-u-1' id='graph'></div>\n" // Graph placeholder
"</div>\n" "</div>\n"
"<div class='pure-g'>\n" "<div class='pure-g'>\n"
//Sensor table "<table id='table' class='pure-table-striped pure-u-1 pure-u-md-1-2'>\n");
"<table id='table' class='pure-table-striped pure-u-1 pure-u-md-1-2'>\n"
"<tr><th colspan='2'>%s</th></tr>\n" body_template =
PSTR("<tr><th colspan='2'>%s</th></tr>\n"
"<tr><td>CO<sub>2</sub> concentration</td><td>%5d ppm</td></tr>\n" "<tr><td>CO<sub>2</sub> concentration</td><td>%5d ppm</td></tr>\n"
"<tr><td>Temperature</td><td>%.1f&#8451;</td></tr>\n" "<tr><td>Temperature</td><td>%.1f&#8451;</td></tr>\n"
"<tr><td>Humidity</td><td>%.1f%%</td></tr>\n" "<tr><td>Humidity</td><td>%.1f%%</td></tr>\n"
@ -108,14 +108,19 @@ namespace web_server {
#endif #endif
"<tr><th colspan='2'>Sensor</th></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>Temperature offset</td><td>%.1fK</td></tr>\n" //TODO: Read it from sensor?
"<tr><td>Auto-calibration?</td><td>%s</td></tr>\n"
"<tr><td>Local address</td><td><a href='http://%s.local/'>%s.local</a></td></tr>\n" "<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>Local IP</td><td><a href='http://%s'>%s</a></td></tr>\n"
"<tr><td>MAC</td><td>%s</td></tr>\n"
"<tr><td>Free heap space</td><td>%6d bytes</td></tr>\n" "<tr><td>Free heap space</td><td>%6d bytes</td></tr>\n"
"<tr><td>Largest heap block</td><td>%6d bytes</td></tr>\n"
"<tr><td>Max loop duration</td><td>%5d ms</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>Board</td><td>%s</td></tr>\n"
"<tr><td>Ampel firmware</td><td>%s</td></tr>\n"
"<tr><td>Uptime</td><td>%2d d %4d h %02d min %02d s</td></tr>\n" "<tr><td>Uptime</td><td>%2d d %4d h %02d min %02d s</td></tr>\n"
"</table>\n" "</table>\n"
"<div id='log' class='pure-u-1 pure-u-md-1-2'></div>\n" "<div id='log' class='pure-u-1 pure-u-md-1-2'></div>\n"
"<form action='/command'><input type='text' id='send' name='send'><input type='submit' value='Send'></form>\n"
#ifdef AMPEL_CSV #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');\">" "<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'/>" "<input type='submit' value='Delete CSV'/>"
@ -130,7 +135,7 @@ namespace web_server {
#ifdef AMPEL_CSV #ifdef AMPEL_CSV
"<script>\n" "<script>\n"
"document.body.style.cursor = 'default';\n" "document.body.style.cursor = 'default';\n"
"fetch('./%s',{credentials:'include'})\n" "fetch('%s',{credentials:'include'})\n"
".then(response=>response.text())\n" ".then(response=>response.text())\n"
".then(csvText=>csvToTable(csvText))\n" ".then(csvText=>csvToTable(csvText))\n"
".then(htmlTable=>addLogTableToPage(htmlTable))\n" ".then(htmlTable=>addLogTableToPage(htmlTable))\n"
@ -173,15 +178,16 @@ namespace web_server {
// Web-server // Web-server
http.on("/", handleWebServerRoot); http.on("/", handleWebServerRoot);
http.on("/command", handleWebServerCommand);
#ifdef AMPEL_CSV #ifdef AMPEL_CSV
http.on("/" + csv_writer::filename, handleWebServerCSV); http.on(csv_writer::filename, handleWebServerCSV); //NOTE: csv_writer should have been initialized first.
http.on("/delete_csv", HTTP_POST, handleDeleteCSV); http.on("/delete_csv", HTTP_POST, handleDeleteCSV);
#endif #endif
http.onNotFound(handlePageNotFound); http.onNotFound(handlePageNotFound);
http.begin(); http.begin();
Serial.print(F("You can access this sensor via http://")); Serial.print(F("You can access this sensor via http://"));
Serial.print(SENSOR_ID); Serial.print(ampel.sensorId);
Serial.print(F(".local (might be unstable) or http://")); Serial.print(F(".local (might be unstable) or http://"));
Serial.println(WiFi.localIP()); Serial.println(WiFi.localIP());
} }
@ -207,44 +213,49 @@ namespace web_server {
//NOTE: Splitting in multiple parts in order to use less RAM //NOTE: Splitting in multiple parts in order to use less RAM
char content[2000]; // Update if needed char content[2000]; // Update if needed
// Header size : 1611 - Body size : 1800 - Script size : 1920 // INFO - Header size : 1767 - Body size : 1991 - Script size : 1909
// Header snprintf_P(content, sizeof(content), header_template, sensor::co2, ampel.sensorId, wifi::local_ip
snprintf_P(content, sizeof(content), header_template, sensor::co2, SENSOR_ID.c_str(),
WiFi.localIP().toString().c_str()
#ifdef AMPEL_CSV #ifdef AMPEL_CSV
, csv_writer::filename.c_str() , csv_writer::filename
#endif #endif
); );
// Serial.print(F("INFO - Header size : "));
// Serial.print(strlen(content));
http.setContentLength(CONTENT_LENGTH_UNKNOWN); http.setContentLength(CONTENT_LENGTH_UNKNOWN);
http.send_P(200, PSTR("text/html"), content); http.send_P(200, PSTR("text/html"), content);
// Body // Body
snprintf_P(content, sizeof(content), body_template, SENSOR_ID.c_str(), sensor::co2, sensor::temperature, snprintf_P(content, sizeof(content), body_template, ampel.sensorId, sensor::co2, sensor::temperature,
sensor::humidity, sensor::timestamp.c_str(), config::measurement_timestep, sensor::humidity, sensor::timestamp, config::measurement_timestep,
#ifdef AMPEL_CSV #ifdef AMPEL_CSV
csv_writer::last_successful_write.c_str(), config::csv_interval, csv_writer::getAvailableSpace() / 1024, csv_writer::last_successful_write, config::csv_interval, csv_writer::getAvailableSpace() / 1024,
#endif #endif
#ifdef AMPEL_MQTT #ifdef AMPEL_MQTT
mqtt::connected ? "Yes" : "No", mqtt::last_successful_publish.c_str(), config::sending_interval, mqtt::connected ? "Yes" : "No", mqtt::last_successful_publish, config::mqtt_sending_interval,
#endif #endif
#if defined(AMPEL_LORAWAN) && defined(ESP32) #if defined(AMPEL_LORAWAN) && defined(ESP32)
lorawan::connected ? "Yes" : "No", LMIC_FREQUENCY_PLAN, lorawan::last_transmission.c_str(), lorawan::connected ? "Yes" : "No", LMIC_FREQUENCY_PLAN, lorawan::last_transmission,
config::lorawan_sending_interval, config::lorawan_sending_interval,
#endif #endif
config::temperature_offset, SENSOR_ID.c_str(), SENSOR_ID.c_str(), WiFi.localIP().toString().c_str(), config::temperature_offset, config::auto_calibrate_sensor ? "Yes" : "No", ampel.sensorId, ampel.sensorId,
WiFi.localIP().toString().c_str(), get_free_heap_size(), max_loop_duration, BOARD, dd, hh, mm, ss); wifi::local_ip, wifi::local_ip, ampel.macAddress, ESP.getFreeHeap(), esp_get_max_free_block_size(),
ampel.max_loop_duration, ampel.board, ampel.version, dd, hh, mm, ss);
// Serial.print(F(" - Body size : "));
// Serial.print(strlen(content));
http.sendContent(content); http.sendContent(content);
// Script // Script
snprintf_P(content, sizeof(content), script_template snprintf_P(content, sizeof(content), script_template
#ifdef AMPEL_CSV #ifdef AMPEL_CSV
, csv_writer::filename.c_str(), SENSOR_ID.c_str() , csv_writer::filename, ampel.sensorId
#endif #endif
); );
// Serial.print(F(" - Script size : "));
// Serial.println(strlen(content));
http.sendContent(content); http.sendContent(content);
} }
@ -255,7 +266,9 @@ namespace web_server {
} }
if (FS_LIB.exists(csv_writer::filename)) { if (FS_LIB.exists(csv_writer::filename)) {
fs::File csv_file = FS_LIB.open(csv_writer::filename, "r"); fs::File csv_file = FS_LIB.open(csv_writer::filename, "r");
http.sendHeader("Content-Length", String(csv_file.size())); char csv_size[10];
snprintf(csv_size, sizeof(csv_size), "%d", csv_file.size());
http.sendHeader("Content-Length", csv_size);
http.streamFile(csv_file, F("text/csv")); http.streamFile(csv_file, F("text/csv"));
csv_file.close(); csv_file.close();
} else { } else {
@ -267,14 +280,23 @@ namespace web_server {
if (!shouldBeAllowed()) { if (!shouldBeAllowed()) {
return http.requestAuthentication(DIGEST_AUTH); return http.requestAuthentication(DIGEST_AUTH);
} }
Serial.print("Removing CSV file..."); Serial.print(F("Removing CSV file..."));
FS_LIB.remove(csv_writer::filename); FS_LIB.remove(csv_writer::filename);
Serial.println(" Done!"); Serial.println(F(" Done!"));
http.sendHeader("Location", "/"); http.sendHeader("Location", "/");
http.send(303); http.send(303);
} }
#endif #endif
void handleWebServerCommand() {
if (!shouldBeAllowed()) {
return http.requestAuthentication(DIGEST_AUTH);
}
http.sendHeader("Location", "/");
http.send(303);
sensor_console::execute(http.arg("send").c_str());
}
void handlePageNotFound() { void handlePageNotFound() {
http.send(404, F("text/plain"), F("404: Not found")); http.send(404, F("text/plain"), F("404: Not found"));
} }

View file

@ -1,5 +1,6 @@
#ifndef WEB_SERVER_H_ #ifndef WEB_SERVER_H_
#define WEB_SERVER_H_ #define WEB_SERVER_H_
#if defined(ESP8266) #if defined(ESP8266)
# include <ESP8266WebServer.h> # include <ESP8266WebServer.h>
#elif defined(ESP32) #elif defined(ESP32)
@ -8,7 +9,9 @@
#include "config.h" #include "config.h"
#include "util.h" #include "util.h"
#include "wifi_util.h"
#include "co2_sensor.h" #include "co2_sensor.h"
#include "sensor_console.h"
#ifdef AMPEL_CSV #ifdef AMPEL_CSV
# include "csv_writer.h" # include "csv_writer.h"
#endif #endif

View file

@ -12,34 +12,69 @@ namespace config {
#endif #endif
} }
// Initialize Wi-Fi namespace wifi {
void WiFiConnect(const String &hostname) { char local_ip[16]; // "255.255.255.255\0"
//NOTE: WiFi Multi could allow multiple SSID and passwords.
WiFi.persistent(false); // Don't write user & password to Flash. void scanNetworks() {
WiFi.mode(WIFI_STA); // Set ESP to be a WiFi-client only Serial.println();
Serial.println(F("WiFi - Scanning..."));
bool async = false;
bool showHidden = true;
int n = WiFi.scanNetworks(async, showHidden);
for (int i = 0; i < n; ++i) {
Serial.print(F(" * '"));
Serial.print(WiFi.SSID(i));
Serial.print(F("' ("));
int16_t quality = 2 * (100 + WiFi.RSSI(i));
Serial.print(util::min(util::max(quality, 0), 100));
Serial.println(F(" %)"));
}
Serial.println(F("Done!"));
Serial.println();
}
void showLocalIp() {
Serial.print(F("WiFi - Local IP : "));
Serial.println(wifi::local_ip);
Serial.print(F("WiFi - SSID : "));
Serial.println(WIFI_SSID);
}
// Initialize Wi-Fi
void connect(const char *hostname) {
sensor_console::defineCommand("wifi_scan", scanNetworks, F("(Scans available WiFi networks)"));
sensor_console::defineCommand("local_ip", showLocalIp, F("(Displays local IP and current SSID)"));
//NOTE: WiFi Multi could allow multiple SSID and passwords.
WiFi.persistent(false); // Don't write user & password to Flash.
WiFi.mode(WIFI_STA); // Set ESP to be a WiFi-client only
#if defined(ESP8266) #if defined(ESP8266)
WiFi.hostname(hostname); WiFi.hostname(hostname);
#elif defined(ESP32) #elif defined(ESP32)
WiFi.setHostname(hostname.c_str()); WiFi.setHostname(hostname);
#endif #endif
Serial.print(F("WiFi - Connecting to ")); Serial.print(F("WiFi - Connecting to "));
Serial.println(config::wifi_ssid); Serial.println(config::wifi_ssid);
WiFi.begin(config::wifi_ssid, config::wifi_password); WiFi.begin(config::wifi_ssid, config::wifi_password);
// Wait for connection, at most wifi_timeout seconds // Wait for connection, at most wifi_timeout seconds
for (int i = 0; i <= config::wifi_timeout && (WiFi.status() != WL_CONNECTED); i++) { for (int i = 0; i <= config::wifi_timeout && (WiFi.status() != WL_CONNECTED); i++) {
led_effects::showRainbowWheel(); led_effects::showRainbowWheel();
Serial.print("."); Serial.print(".");
} }
if (WiFi.status() == WL_CONNECTED) { if (WiFi.status() == WL_CONNECTED) {
led_effects::showKITTWheel(color::green); led_effects::showKITTWheel(color::green);
Serial.println(); Serial.println();
Serial.print(F("WiFi - Connected! IP address: ")); Serial.print(F("WiFi - Connected! IP address: "));
Serial.println(WiFi.localIP()); IPAddress address = WiFi.localIP();
} else { snprintf(local_ip, sizeof(local_ip), "%d.%d.%d.%d", address[0], address[1], address[2], address[3]);
//TODO: Allow sensor to work as an Access Point, in order to define SSID & password? Serial.println(local_ip);
led_effects::showKITTWheel(color::red); } else {
Serial.println(F("Connection to WiFi failed")); //TODO: Allow sensor to work as an Access Point, in order to define SSID & password?
led_effects::showKITTWheel(color::red);
Serial.println(F("Connection to WiFi failed"));
}
} }
} }

View file

@ -5,6 +5,9 @@
#include "util.h" #include "util.h"
#include "led_effects.h" #include "led_effects.h"
void WiFiConnect(const String &hostname); namespace wifi {
extern char local_ip[];
void connect(const char *hostname);
}
#endif #endif