Commit 8efb0a30 authored by Eric Duminil's avatar Eric Duminil
Browse files

Merge branch 'experimental/iotwebconfig' into develop

Current state seems to be stable enough for develop
parents 34496cfe 6ea33a0e
Pipeline #5893 passed with stage
in 2 minutes and 22 seconds
......@@ -6,6 +6,34 @@ It measures the current CO<sub>2</sub> concentration (in ppm), and displays it o
The room should be ventilated as soon as one LED turns red.
## Ampel web-conf
Ampel-firmware + [IotWebConf](https://github.com/prampec/IotWebConf).
This is beta software, and bugs are expected! There is not much free RAM left on ESP8266, so the ESP might crash randomly. :-/
* You probably need to update `config.h`. There's a new template in `config.public.h`.
* Every parameter can be set in the web-configuration, so you don't have to modify `config.h` once it's been updated.
* Flash
* Start the *Ampel*
* It should blink turquoise, to indicate *Access Point* mode
* Connect with a laptop/smartphone to ESPxxxxxx WiFi.
* Open a browser, it should bring you to the *Ampel* web-page.
* The usual web-page will be shown. You can click on "Configuration".
* You'll have to set an *Ampel* password with at least 8 characters.
* You can set WiFi SSID + Password if you want. It will then try to connect if you disconnect from the *Access Point*.
* If you don't specify SSID + Password, the *Ampel* will simply stay in *Access Point* mode.
* The *Ampel* password will be required if a connection fails, and the *Access Point* starts again.
* The *Ampel* password will be needed (with user 'admin') to connect to the web-page once the *Ampel* is connected to WiFi.
* It might be necessary to reset the *Ampel* after changing important parameters.
* You can rename the *Ampel*. This name will be used instead of ESPxxxxxx for CSV files and the mDNS address. You'll get a new CSV file after renaming, which can be convenient, e.g. to indicate at which location the measurements were made.
* If you forgot the password, you can send `reset_config` as command, and `reset` the ampel.
* If you disabled WiFi, you can enable it again with `wifi 1`.
* It's also possible to specify WiFi SSID and password in the console, with `ssid wifi_ssid_here` and `pwd wifi_password_here` commands.
* You can enable CSV by clicking on "+CSV", or disable it by clicking on "Disable CSV".
* The same applies to MQTT and LoRaWAN.
* If you disable a set, the parameters are still saved, and will appear again once the service is enabled.
## Features
The *CO<sub>2</sub> Ampel* can:
......@@ -72,11 +100,12 @@ make upload board=esp32 && make monitor # For ESP32
In Arduino IDE *Serial Monitor* or PlatformIO *Monitor*, type `help` + <kbd>Enter</kbd> in order to list the available commands:
* `ap 0/1` (Disables/enables access point).
* `auto_calibrate 0/1` (Disables/enables autocalibration).
* `calibrate` (Starts calibration process).
* `calibrate 600` (Starts calibration process, to given ppm).
* `calibrate` (Starts calibration process).
* `calibrate! 600` (Calibrates right now, to given ppm).
* `co2 1500` (Sets CO<sub>2</sub> level, for debugging purposes).
* `co2 1500` (Sets CO<sub>2</sub> level, for debugging).
* `color 0xFF0015` (Shows color, specified as RGB, for debugging).
* `csv 60` (Sets CSV writing interval, in s).
* `format_filesystem` (Deletes the whole filesystem).
......@@ -85,12 +114,17 @@ In Arduino IDE *Serial Monitor* or PlatformIO *Monitor*, type `help` + <kbd>Ente
* `local_ip` (Displays local IP and current SSID).
* `lora 300` (Sets LoRaWAN sending interval, in s).
* `mqtt 60` (Sets MQTT sending interval, in s).
* `pwd abc` (Sets WiFi password to 'abc').
* `reset` (Restarts the ESP).
* `reset_config` (Resets the complete IotWeb config).
* `reset_scd` (Resets SCD30).
* `save_config` (Saves the config to EEPROM).
* `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).
* `ssid name` (Sets SSID to 'name').
* `timer 30` (Sets measurement interval, in s).
* `wifi 0/1` (Turns Wifi on/off).
* `wifi_scan` (Scans available WiFi networks).
The commands can be sent via the Serial interface, from the webpage or via MQTT.
......
......@@ -3,34 +3,17 @@
/*****************************************************************
* Libraries *
*****************************************************************/
#include "config.h"
#ifndef MEASUREMENT_TIMESTEP
# error Missing config.h file. Please copy config.public.h to config.h.
#endif
#ifdef AMPEL_CSV
# include "csv_writer.h"
#endif
//NOTE: Too many headers. Move them to include/ folder?
#include "web_config.h" // Needed for offline config too.
#ifdef AMPEL_WIFI
# include "wifi_util.h"
# ifdef AMPEL_MQTT
# include "mqtt.h"
# endif
# ifdef AMPEL_HTTP
# include "web_server.h"
# endif
# if defined(ESP8266)
//allows sensor to be seen as SENSOR_ID.local, from the local network. For example : espd03cc5.local
# include <ESP8266mDNS.h>
# elif defined(ESP32)
# include <ESPmDNS.h>
# endif
#endif
#include "csv_writer.h"
#ifdef AMPEL_LORAWAN
# include "lorawan.h"
#endif
#include "wifi_util.h"
#include "mqtt.h"
#include "web_server.h"
#include "lorawan.h"
#include "util.h"
#include "ntp.h"
......@@ -38,4 +21,9 @@
#include "co2_sensor.h"
#include "led_effects.h"
void wifiConnected();
void wifiFailed();
void keepServicesAlive();
void checkFlashButton();
#endif
......@@ -63,13 +63,21 @@ void setup() {
led_effects::setupOnBoardLED();
led_effects::onBoardLEDOff();
Serial.begin(BAUDS);
Serial.begin(config::bauds);
web_config::initialize();
web_config::setWifiConnectionCallback(wifiConnected);
web_config::setWifiFailCallback(wifiFailed);
pinMode(0, INPUT); // Flash button (used for forced calibration)
Serial.println();
Serial.print(F("Sensor ID: "));
Serial.println(ampel.sensorId);
Serial.print(F("Name : "));
Serial.println(config::ampel_name());
Serial.print(F("MAC : "));
Serial.println(ampel.macAddress);
Serial.print(F("Board : "));
......@@ -81,56 +89,36 @@ void setup() {
sensor::initialize();
#ifdef AMPEL_CSV
csv_writer::initialize(ampel.sensorId);
#endif
#ifdef AMPEL_WIFI
wifi::connect(ampel.sensorId);
csv_writer::initialize(config::ampel_name());
if (wifi::connected()) {
# ifdef AMPEL_HTTP
web_server::initialize();
# endif
ntp::initialize();
ntp::initialize();
if (MDNS.begin(ampel.sensorId)) { // Start the mDNS responder for SENSOR_ID.local
MDNS.addService("http", "tcp", 80);
Serial.println(F("mDNS responder started"));
} else {
Serial.println(F("Error setting up MDNS responder!"));
}
# ifdef AMPEL_MQTT
mqtt::initialize(ampel.sensorId);
# endif
if (config::is_wifi_on) {
wifi::defineCommands();
web_server::definePages();
wifi::tryConnection();
}
#endif
#if defined(AMPEL_LORAWAN) && defined(ESP32)
lorawan::initialize();
#if defined(ESP32)
if (config::is_lorawan_active()) {
lorawan::initialize();
}
#endif
}
/*****************************************************************
* Helper functions *
*****************************************************************/
void keepServicesAlive();
void checkFlashButton();
void checkSerialInput();
/*****************************************************************
* Main loop *
*****************************************************************/
void loop() {
#if defined(AMPEL_LORAWAN) && defined(ESP32)
//LMIC Library seems to be very sensitive to timing issues, so run it first.
lorawan::process();
if (lorawan::waiting_for_confirmation) {
// If node is waiting for join confirmation from Gateway, nothing else should run.
return;
#if defined(ESP32)
if (config::is_lorawan_active()) {
//LMIC Library seems to be very sensitive to timing issues, so run it first.
lorawan::process();
if (lorawan::waiting_for_confirmation) {
// If node is waiting for join confirmation from Gateway, nothing else should run.
return;
}
}
#endif
//NOTE: Loop should never take more than 1000ms. Split in smaller methods and logic if needed.
......@@ -142,19 +130,21 @@ void loop() {
// Short press for night mode, Long press for calibration.
checkFlashButton();
checkSerialInput();
sensor_console::checkSerialInput();
if (sensor::processData()) {
#ifdef AMPEL_CSV
csv_writer::logIfTimeHasCome(sensor::timestamp, sensor::co2, sensor::temperature, sensor::humidity);
#endif
if (config::is_csv_active()) {
csv_writer::logIfTimeHasCome(sensor::timestamp, sensor::co2, sensor::temperature, sensor::humidity);
}
#if defined(AMPEL_WIFI) && defined(AMPEL_MQTT)
mqtt::publishIfTimeHasCome(sensor::timestamp, sensor::co2, sensor::temperature, sensor::humidity);
#endif
if (config::is_wifi_on && config::is_mqtt_active()) {
mqtt::publishIfTimeHasCome(sensor::timestamp, sensor::co2, sensor::temperature, sensor::humidity);
}
#if defined(AMPEL_LORAWAN) && defined(ESP32)
lorawan::preparePayloadIfTimeHasCome(sensor::co2, sensor::temperature, sensor::humidity);
#if defined(ESP32)
if (config::is_lorawan_active()) {
lorawan::preparePayloadIfTimeHasCome(sensor::co2, sensor::temperature, sensor::humidity);
}
#endif
}
......@@ -167,12 +157,39 @@ void loop() {
}
}
void checkSerialInput() {
while (Serial.available() > 0) {
sensor_console::processSerialInput(Serial.read());
/*****************************************************************
* Callbacks *
*****************************************************************/
void wifiConnected() {
led_effects::showKITTWheel(color::green);
Serial.println();
Serial.print(F("WiFi - Connected! IP address: "));
IPAddress address = WiFi.localIP();
snprintf(wifi::local_ip, sizeof(wifi::local_ip), "%d.%d.%d.%d", address[0], address[1], address[2], address[3]);
Serial.println(wifi::local_ip);
ntp::connect();
if (config::is_mqtt_active()) {
mqtt::initialize(ampel.sensorId);
}
Serial.print(F("You can access this sensor via http://"));
Serial.print(config::ampel_name());
Serial.print(F(".local (might be unstable) or http://"));
Serial.println(WiFi.localIP());
}
void wifiFailed() {
Serial.print(F("WiFi - Could not connect to "));
Serial.println(config::selected_ssid());
led_effects::showKITTWheel(color::red);
}
/*****************************************************************
* Helper functions *
*****************************************************************/
/**
* Checks if flash button has been pressed:
* If not, do nothing.
......@@ -186,6 +203,7 @@ void checkFlashButton() {
if (digitalRead(0)) {
Serial.println(F("Flash has been pressed for a short time. Should toggle night mode."));
led_effects::toggleNightMode();
//NOTE: Start Access Point instead?
} else {
Serial.println(F("Flash has been pressed for a long time. Keep it pressed for calibration."));
if (led_effects::countdownToZero()) {
......@@ -199,20 +217,13 @@ void checkFlashButton() {
}
void keepServicesAlive() {
#ifdef AMPEL_WIFI
if (wifi::connected()) {
# if defined(ESP8266)
//NOTE: Sadly, there seems to be a bug in the current MDNS implementation.
// It stops working after 2 minutes. And forcing a restart leads to a memory leak.
MDNS.update();
# endif
ntp::update(); // NTP client has its own timer. It will connect to NTP server every 60s.
# ifdef AMPEL_HTTP
web_server::update();
# endif
# ifdef AMPEL_MQTT
mqtt::keepConnection(); // MQTT client has its own timer. It will keep alive every 15s.
# endif
if (config::is_wifi_on) {
web_config::update();
if (wifi::connected()) {
ntp::update(); // NTP client has its own timer. It will connect to NTP server every 60s.
if (config::is_mqtt_active()) {
mqtt::keepConnection(); // MQTT client has its own timer. It will keep alive every 15s.
}
}
}
#endif
}
#include "co2_sensor.h"
#include "config.h"
#include "web_config.h"
#include "ntp.h"
#include "led_effects.h"
#include "sensor_console.h"
......@@ -11,25 +11,12 @@
#include "src/lib/SparkFun_SCD30_Arduino_Library/src/SparkFun_SCD30_Arduino_Library.h" // From: http://librarymanager/All#SparkFun_SCD30
namespace config {
// UPPERCASE values should be defined in config.h
uint16_t measurement_timestep = MEASUREMENT_TIMESTEP; // [s] Value between 2 and 1800 (range for SCD30 sensor).
const uint16_t altitude_above_sea_level = ALTITUDE_ABOVE_SEA_LEVEL; // [m]
uint16_t co2_calibration_level = ATMOSPHERIC_CO2_CONCENTRATION; // [ppm]
const uint16_t measurement_timestep_bootup = 5; // [s] Measurement timestep during acclimatization.
const uint8_t max_deviation_during_bootup = 20; // [%]
const int8_t max_deviation_during_calibration = 30; // [ppm]
const int16_t timestep_during_calibration = 10; // [s] WARNING: Measurements can be unreliable for timesteps shorter than 10s.
const int8_t stable_measurements_before_calibration = 120 / timestep_during_calibration; // [-] Stable measurements during at least 2 minutes.
const uint16_t co2_alert_threshold = 2000; // [ppm] Display a flashing led ring, if concentration exceeds this value
#ifdef TEMPERATURE_OFFSET
// Residual heat from CO2 sensor seems to be high enough to change the temperature reading. How much should it be offset?
// NOTE: Sign isn't relevant. The returned temperature will always be shifted down.
const float temperature_offset = TEMPERATURE_OFFSET; // [K]
#else
const float temperature_offset = -3.0; // [K] Temperature measured by sensor is usually at least 3K too high.
#endif
bool auto_calibrate_sensor = AUTO_CALIBRATE_SENSOR; // [true / false]
const bool debug_sensor_states = false; // If true, log state transitions over serial console
}
......@@ -116,7 +103,7 @@ namespace sensor {
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("co2", setCO2forDebugging, F("1500 (Sets co2 level, for debugging)"));
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,
......@@ -243,7 +230,7 @@ namespace sensor {
delay(100);
} else {
// Display a flashing led ring, if concentration exceeds a specific value
led_effects::redAlert();
led_effects::alert(color::red);
}
}
......
......@@ -3,13 +3,6 @@
#include <stdint.h> // For uint16_t
namespace config {
extern uint16_t measurement_timestep; // [s] Value between 2 and 1800 (range for SCD30 sensor)
extern bool auto_calibrate_sensor; // [true / false]
extern uint16_t co2_calibration_level; // [ppm]
extern const float temperature_offset; // [K] Sign isn't relevant.
}
namespace sensor {
extern uint16_t co2;
extern float temperature;
......
#ifndef CONFIG_H_INCLUDED
# define CONFIG_H_INCLUDED
// 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.
// NOTE: Every parameter can be modified and saved later via the web-config.
// Some of those parameters can also be modified via commands in the Serial monitor :
// e.g. 'wifi 0' to turn WiFi off, or 'csv 60' to log data in csv every minute.
/***
* AMPEL
*/
// You can rename the Ampel if you want.
// This name will be used for CSV files and the mDNS address.
// You'll get a new CSV file after renaming, which can be convenient, e.g. after moving
// the ampel to another room.
// If left empty, the name will be ESPxxxxxx, where xxxxxx represent the last half of the MAC address.
# define AMPEL_NAME ""
/**
* SERVICES
*/
// Comment or remove those lines if you want to disable the corresponding services
# define AMPEL_WIFI // Should ESP connect to WiFi? It allows the Ampel to get time from an NTP server.
# define AMPEL_HTTP // Should HTTP web server be started? (AMPEL_WIFI should be enabled too)
# define AMPEL_MQTT // Should data be sent over MQTT? (AMPEL_WIFI should be enabled too)
# define AMPEL_CSV // Should data be logged as CSV, on the ESP flash memory?
// # define AMPEL_LORAWAN // Should data be sent over LoRaWAN? (Requires ESP32 + LoRa modem, and "MCCI LoRaWAN LMIC library")
// Define the default for corresponding services. They can be enabled/disabled later in the web-config.
# define AMPEL_WIFI true // Should ESP connect to WiFi? It allows the Ampel to get time from an NTP server.
# define AMPEL_MQTT true // Should data be sent over MQTT? (AMPEL_WIFI should be enabled too)
# define AMPEL_CSV true // Should data be logged as CSV, on the ESP flash memory?
# define AMPEL_LORAWAN false // Should data be sent over LoRaWAN? (Requires ESP32 + LoRa modem, and "MCCI LoRaWAN LMIC library")
/**
* WIFI
*/
# define WIFI_SSID "MY_SSID"
# define WIFI_PASSWORD "P4SSW0RD"
// SSID and PASSWORD need to be defined, but can be empty.
# define WIFI_SSID ""
# define WIFI_PASSWORD ""
# define WIFI_TIMEOUT 30 // [s]
/**
......@@ -69,13 +93,14 @@
# define LED_COUNT 12
/**
* WEB SERVER
* available at http://local_ip, with user HTTP_USER and password HTTP_PASSWORD
* AMPEL PASSWORD
* will be used for Access Point (without username), and for web-server available at http://local_ip
* with user 'admin', without quotes.
*/
// Define empty strings in order to disable authentication, or remove the constants altogether.
# define HTTP_USER "co2ampel"
# define HTTP_PASSWORD "my_password"
// If left empty, the password will be set during the first configuration, via access point.
// In order to be set successfully, it should have at least 8 characters.
# define AMPEL_PASSWORD ""
/**
* MQTT
......@@ -119,36 +144,35 @@
*/
// 1) Requires "MCCI LoRaWAN LMIC library", which will be automatically used with PlatformIO but should be added in "Arduino IDE".
// 2) If you need to, region and transceiver type can be specified in lorawan.cpp. Default is "Europe 868"
// 2) Region and transceiver type should be specified in:
// * Arduino/libraries/MCCI_LoRaWAN_LMIC_library/project_config/lmic_project_config.h for Arduino IDE
// * platformio.ini for PlatformIO
// See https://github.com/mcci-catena/arduino-lmic#configuration for more information
// 3) It has been tested with "TTGO ESP32 SX1276 LoRa 868" and will only work with an ESP32 + LoRa modem
// 4) In order to use LoRaWAN, a gateway should be close to the co2ampel, and an account, an application and a device should be registered,
// e.g. on https://www.thethingsnetwork.org/docs/applications/
// e.g. on https://www.thethingsindustries.com/docs/integrations/
// 5) The corresponding keys should be defined in LORAWAN_DEVICE_EUI, LORAWAN_APPLICATION_EUI and LORAWAN_APPLICATION_KEY
// How often should measurements be sent over LoRaWAN?
# define LORAWAN_SENDING_INTERVAL 300 // [s] This value should not be too low. See https://www.thethingsnetwork.org/docs/lorawan/duty-cycle.html#maximum-duty-cycle
// WARNING: If AMPEL_LORAWAN is enabled, you need to modify the 3 following constants!
// This EUI must be in little-endian format, so least-significant-byte first.
// When copying an EUI from ttnctl output, this means to reverse the bytes.
# define LORAWAN_DEVICE_EUI {0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11}
// This should also be in little endian format, see above.
// For TheThingsNetwork issued EUIs the last bytes should be 0xD5, 0xB3, 0x70.
# define LORAWAN_APPLICATION_EUI {0x00, 0x00, 0x00, 0x00, 0x00, 0xD5, 0xB3, 0x70}
// This key should be in big endian format (or, since it is not really a
// number but a block of memory, endianness does not really apply). In
// practice, a key taken from ttnctl can be copied as-is.
# define LORAWAN_APPLICATION_KEY {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }
// WARNING: If AMPEL_LORAWAN is true, you need to modify the 3 following constants
// They are written as hexadecimal strings, and will be parsed in the correct order.
// This EUI must be in big-endian format, so most-significant-byte first.
// You can copy the string from TheThingsNetwork as-is, without reversing the bytes.
// For TheThingsNetwork issued EUIs the string should start with "70B3D5..."
# define LORAWAN_DEVICE_EUI "70B3D57ED004CB17"
// This should also be in big-endian format, and can be copied as is from TheThingsNetwork.
# define LORAWAN_APPLICATION_EUI "0102030405060708"
// This should also be in big-endian format, and can be copied as is from TheThingsNetwork.
# define LORAWAN_APPLICATION_KEY "9D06308E20B974919DA6404E063BE01D"
/**
* NTP
*/
# define NTP_SERVER "pool.ntp.org"
# define UTC_OFFSET_IN_SECONDS 7200 // [s] 3600 for UTC+1, 7200 for UTC+1 and daylight saving time
/**
* Others
*/
# define BAUDS 115200 // Transmission rate
# define UTC_OFFSET 1 // [h] +1 for Paris/Berlin, -5 for NYC
# define DAYLIGHT_SAVING_TIME false // true in summer, false in winter
#endif
#include "csv_writer.h"
#include "config.h"
#include "web_config.h"
#include "ntp.h"
#include "led_effects.h"
#include "sensor_console.h"
namespace config {
// Values should be defined in config.h
uint16_t csv_interval = CSV_INTERVAL; // [s]
}
namespace csv_writer {
unsigned long last_written_at = 0;
char last_successful_write[23];
......@@ -83,14 +79,14 @@ namespace csv_writer {
}
#endif
char filename[15]; // "/ESPxxxxxx.csv\0"
char filename[20]; // e.g. "/ESPxxxxxx.csv\0"
int getAvailableSpace() {
return getTotalSpace() - getUsedSpace();
}
void initialize(const char *sensorId) {
snprintf(filename, sizeof(filename), "/%s.csv", sensorId);
void initialize(const char *basename) {
snprintf(filename, sizeof(filename), "/%.14s.csv", basename);
Serial.println();
Serial.print(F("Initializing FS..."));
......@@ -184,6 +180,7 @@ namespace csv_writer {
}
void showCSVContent() {
//TODO: Now that ampel_name can be set, should show the content of every csv
Serial.print(F("### "));
Serial.print(filename);
Serial.println(F(" ###"));
......
......@@ -10,13 +10,11 @@
#else
# error Board should be either ESP8266 or ESP832
#endif
//NOTE: LittleFS will be available for Arduino esp32 core v2
namespace config {
extern uint16_t csv_interval; // [s]
}
namespace csv_writer {
extern char last_successful_write[];
void initialize(const char *sensorId);
void initialize(const char *basename);
void logIfTimeHasCome(const char *timestamp, const int16_t &co2, const float &temperature, const float &humidity);
int getAvailableSpace();
extern char filename[];
......
#include "led_effects.h"
#include "config.h"
#include "web_config.h"
#include "sensor_console.h"
// Adafruit NeoPixel (Arduino library for controlling single-wire-based LED pixels and strip)
......@@ -12,37 +12,14 @@
* Configuration *
*****************************************************************/
namespace config {
const uint8_t max_brightness = MAX_BRIGHTNESS;
#if defined(MIN_BRIGHTNESS)
const uint8_t min_brightness = MIN_BRIGHTNESS;
#else
const uint8_t min_brightness = MAX_BRIGHTNESS;
#endif
const uint8_t brightness_amplitude = config::max_brightness - config::min_brightness;
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 display_led = true; // Will be set to false during "night mode".
#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 };