diff --git a/ampel-firmware/ampel-firmware.h b/ampel-firmware/ampel-firmware.h
index e38cbfa04e93f2660e3d93a85ce8219249fea872..629e180f589ec0fd08a9e6b0b64ccbf87dcce76a 100644
--- a/ampel-firmware/ampel-firmware.h
+++ b/ampel-firmware/ampel-firmware.h
@@ -33,11 +33,12 @@
 #endif
 
 #include "util.h"
+#include "sensor_console.h"
 #include "co2_sensor.h"
 #include "led_effects.h"
 
-
 void keepServicesAlive();
 void checkFlashButton();
+void checkSerialInput();
 
 #endif
diff --git a/ampel-firmware/ampel-firmware.ino b/ampel-firmware/ampel-firmware.ino
index 7abda47f3a43f1d53af4b49a6f8d3d6e342d54a1..facb1e3874f36a28d389f9f01c403278691a8d7c 100644
--- a/ampel-firmware/ampel-firmware.ino
+++ b/ampel-firmware/ampel-firmware.ino
@@ -56,7 +56,6 @@
  * and define your credentials and parameters in 'config.h'.
  */
 
-
 /*****************************************************************
  * PreInit                                                       *
  *****************************************************************/
@@ -68,7 +67,6 @@ void preinit() {
 #endif
 }
 
-
 /*****************************************************************
  * Setup                                                         *
  *****************************************************************/
@@ -147,6 +145,8 @@ void loop() {
   // Short press for night mode, Long press for calibration.
   checkFlashButton();
 
+  checkSerialInput();
+
   if (sensor::processData()) {
 #ifdef AMPEL_CSV
     csv_writer::logIfTimeHasCome(sensor::timestamp, sensor::co2, sensor::temperature, sensor::humidity);
@@ -166,7 +166,13 @@ void loop() {
     max_loop_duration = duration;
     Serial.print(F("Debug - Max loop duration : "));
     Serial.print(max_loop_duration);
-    Serial.println(" ms.");
+    Serial.println(F(" ms."));
+  }
+}
+
+void checkSerialInput() {
+  while (Serial.available() > 0) {
+    sensor_console::processSerialInput(Serial.read());
   }
 }
 
diff --git a/ampel-firmware/co2_sensor.cpp b/ampel-firmware/co2_sensor.cpp
index 0420890c1deedc6f0c31b3b36d2b75bf5f39a9c1..430b945575fe37c115351a741cf90caffa549e64 100644
--- a/ampel-firmware/co2_sensor.cpp
+++ b/ampel-firmware/co2_sensor.cpp
@@ -5,6 +5,8 @@ namespace config {
   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]
+  int8_t max_deviation_during_calibration = 30; // [ppm]
+  int8_t enough_stable_measurements = 60;
 #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.
@@ -17,7 +19,7 @@ namespace config {
 
 namespace sensor {
   SCD30 scd30;
-  int16_t co2 = 0;
+  uint16_t co2 = 0;
   float temperature = 0;
   float humidity = 0;
   String timestamp = "";
@@ -68,13 +70,25 @@ namespace sensor {
 
     Serial.print(F("Auto-calibration is "));
     Serial.println(config::auto_calibrate_sensor ? "ON." : "OFF.");
+
+    sensor_console::defineIntCommand("co2", setCO2forDebugging, " 1500 (Sets co2 level, for debugging purposes)");
+    sensor_console::defineIntCommand("timer", setTimer, " 30 (Sets measurement interval, in s)");
+    sensor_console::defineCommand("calibrate", startCalibrationProcess, " (Starts calibration process)");
+    sensor_console::defineIntCommand("calibrate", calibrateSensorToSpecificPPM,
+        " 600 (Starts calibration process, to given ppm)");
+    sensor_console::defineIntCommand("calibrate!", calibrateSensorRightNow,
+        " 600 (Calibrates right now, to given ppm)");
+    sensor_console::defineCommand("reset", []() {
+      ESP.restart();
+    }, " (Restarts the sensor)");
+    sensor_console::defineCommand("night_mode", led_effects::toggleNightMode, " (Toggles night mode on/off)");
   }
 
   //NOTE: should timer deviation be used to adjust measurement_timestep?
   void checkTimerDeviation() {
     static int32_t previous_measurement_at = 0;
     int32_t now = millis();
-    Serial.print("Measurement time offset : ");
+    Serial.print(F("Measurement time offset : "));
     Serial.print(now - previous_measurement_at - config::measurement_timestep * 1000);
     Serial.println(" ms.");
     previous_measurement_at = now;
@@ -82,7 +96,8 @@ namespace sensor {
 
   void countStableMeasurements() {
     static int16_t previous_co2 = 0;
-    if (co2 > (previous_co2 - 30) && co2 < (previous_co2 + 30)) {
+    if (co2 > (previous_co2 - config::max_deviation_during_calibration)
+        && co2 < (previous_co2 + config::max_deviation_during_calibration)) {
       stable_measurements++;
       Serial.print(F("Number of stable measurements : "));
       Serial.println(stable_measurements);
@@ -177,7 +192,7 @@ namespace sensor {
     }
 
     if (should_calibrate) {
-      if (stable_measurements == 60) {
+      if (stable_measurements == config::enough_stable_measurements) {
         calibrateAndRestart();
       }
       led_effects::showWaitingLED(waiting_color);
@@ -187,4 +202,39 @@ namespace sensor {
     displayCO2OnLedRing();
     return freshData;
   }
+
+  /*****************************************************************
+   * Callbacks for sensor commands                                 *
+   *****************************************************************/
+  void setCO2forDebugging(int32_t fakeCo2) {
+    Serial.print(F("DEBUG. Setting CO2 to "));
+    co2 = fakeCo2;
+    Serial.println(co2);
+  }
+
+  void setTimer(int32_t timestep) {
+    if (timestep >= 2 && timestep <= 1800) {
+      Serial.print(F("Setting Measurement Interval to : "));
+      Serial.print(timestep);
+      Serial.println("s.");
+      sensor::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(" ppm.");
+      sensor::startCalibrationProcess();
+    }
+  }
+
+  void calibrateSensorRightNow(int32_t calibrationLevel) {
+    stable_measurements = config::enough_stable_measurements;
+    calibrateSensorToSpecificPPM(calibrationLevel);
+  }
 }
diff --git a/ampel-firmware/co2_sensor.h b/ampel-firmware/co2_sensor.h
index f11eb815582358a2732b34ec4afe790773683c0d..dd18bcf312048448ea665ad61b12ccb14b6f27c4 100644
--- a/ampel-firmware/co2_sensor.h
+++ b/ampel-firmware/co2_sensor.h
@@ -7,6 +7,7 @@
 #include "config.h"
 #include "led_effects.h"
 #include "util.h"
+#include "sensor_console.h"
 #include <Wire.h>
 
 namespace config {
@@ -18,7 +19,7 @@ namespace config {
 
 namespace sensor {
   extern SCD30 scd30;
-  extern int16_t co2;
+  extern uint16_t co2;
   extern float temperature;
   extern float humidity;
   extern String timestamp;
@@ -26,5 +27,10 @@ namespace sensor {
   void initialize();
   bool processData();
   void startCalibrationProcess();
+
+  void setCO2forDebugging(int32_t fakeCo2);
+  void setTimer(int32_t timestep);
+  void calibrateSensorToSpecificPPM(int32_t calibrationLevel);
+  void calibrateSensorRightNow(int32_t calibrationLevel);
 }
 #endif
diff --git a/ampel-firmware/csv_writer.cpp b/ampel-firmware/csv_writer.cpp
index 653630335527800481fb4907025e494e2de3fd58..2a7f462a4a750ed7565a8ff8318d6a608cbf12a0 100644
--- a/ampel-firmware/csv_writer.cpp
+++ b/ampel-firmware/csv_writer.cpp
@@ -1,7 +1,5 @@
 #include "csv_writer.h"
 
-//TODO: Allow CSV download via USB Serial, when requested (e.g. via a Python script)
-
 namespace config {
   // Values should be defined in config.h
   uint16_t csv_interval = CSV_INTERVAL; // [s]
@@ -113,9 +111,13 @@ namespace csv_writer {
     Serial.println();
 
     // Open dir folder
-    Serial.println("Filesystem content:");
+    Serial.println(F("Filesystem content:"));
     showFilesystemContent();
     Serial.println();
+
+    sensor_console::defineIntCommand("csv", setCSVinterval, " 60 (Sets CSV writing interval, in s)");
+    sensor_console::defineCommand("format_filesystem", formatFilesystem, " (Deletes the whole filesystem)");
+    sensor_console::defineCommand("show_csv", showCSVContent, " (Displays the complete CSV file on Serial)");
   }
 
   File openOrCreate() {
@@ -161,4 +163,35 @@ namespace csv_writer {
       log(timeStamp, co2, temperature, humidity);
     }
   }
+
+  /*****************************************************************
+   * Callbacks for sensor commands                                 *
+   *****************************************************************/
+  void setCSVinterval(int32_t csv_interval) {
+    config::csv_interval = csv_interval;
+    Serial.print(F("Setting CSV Interval to : "));
+    Serial.print(config::csv_interval);
+    Serial.println("s.");
+    led_effects::showKITTWheel(color::green, 1);
+  }
+
+  void showCSVContent() {
+    Serial.print(F("### "));
+    Serial.print(filename);
+    Serial.println(F(" ###"));
+    File csv_file;
+    if (FS_LIB.exists(filename)) {
+      csv_file = FS_LIB.open(filename, "r");
+      while (csv_file.available()) {
+        Serial.write(csv_file.read());
+      }
+      csv_file.close();
+    }
+    Serial.println(F("################"));
+  }
+
+  void formatFilesystem() {
+    FS_LIB.format();
+    led_effects::showKITTWheel(color::blue, 2);
+  }
 }
diff --git a/ampel-firmware/csv_writer.h b/ampel-firmware/csv_writer.h
index e0e0d5314ae9060ca8c39315737da2a73beb5d99..080d5cdcc2e4b2e5aa17617118120f5c5213dac6 100644
--- a/ampel-firmware/csv_writer.h
+++ b/ampel-firmware/csv_writer.h
@@ -14,6 +14,7 @@
 #include "config.h"
 #include "util.h"
 #include "led_effects.h"
+#include "sensor_console.h"
 
 namespace config {
   extern uint16_t csv_interval; // [s]
@@ -24,6 +25,10 @@ namespace csv_writer {
   void logIfTimeHasCome(const String &timeStamp, const int16_t &co2, const float &temperature, const float &humidity);
   int getAvailableSpace();
   extern const String filename;
+
+  void setCSVinterval(int32_t csv_interval);
+  void showCSVContent();
+  void formatFilesystem();
 }
 
 #endif
diff --git a/ampel-firmware/lorawan.cpp b/ampel-firmware/lorawan.cpp
index 7e027703ebd6505577005de734c3272beb785789..70a02e1c8325a21a42f63402956d497264baf3d8 100644
--- a/ampel-firmware/lorawan.cpp
+++ b/ampel-firmware/lorawan.cpp
@@ -47,11 +47,12 @@ namespace lorawan {
     LMIC_reset();
     // Join, but don't send anything yet.
     LMIC_startJoining();
+    sensor_console::defineIntCommand("lora", setLoRaInterval, " 300 (Sets LoRaWAN sending interval, in s)");
   }
 
   // Checks if OTAA is connected, or if payload should be sent.
   // NOTE: while a transaction is in process (i.e. until the TXcomplete event has been received, no blocking code (e.g. delay loops etc.) are allowed, otherwise the LMIC/OS code might miss the event.
-  // If this rule is not followed, a typical symptom is that the first send is ok and all following ones end with the TX not complete failure.
+  // If this rule is not followed, a typical symptom is that the first send is ok and all following ones end with the 'TX not complete' failure.
   void process() {
     os_runloop_once();
   }
@@ -189,6 +190,17 @@ namespace lorawan {
       preparePayload(co2, temperature, humidity);
     }
   }
+
+  /*****************************************************************
+   * Callbacks for sensor commands                                 *
+   *****************************************************************/
+  void setLoRaInterval(int32_t sending_interval) {
+    config::lorawan_sending_interval = sending_interval;
+    Serial.print(F("Setting LoRa sending interval to : "));
+    Serial.print(config::lorawan_sending_interval);
+    Serial.println("s.");
+    led_effects::showKITTWheel(color::green, 1);
+  }
 }
 
 void onEvent(ev_t ev) {
diff --git a/ampel-firmware/lorawan.h b/ampel-firmware/lorawan.h
index a4e29ab2c89019202aceddb920c26f49978b085b..6cf1226f72f7385e075ff387c160d0dbdefa2ffd 100644
--- a/ampel-firmware/lorawan.h
+++ b/ampel-firmware/lorawan.h
@@ -13,7 +13,7 @@
 #include <SPI.h>
 
 #include "led_effects.h"
-
+#include "sensor_console.h"
 #include "util.h"
 
 namespace config {
@@ -43,6 +43,8 @@ namespace lorawan {
   void initialize();
   void process();
   void preparePayloadIfTimeHasCome(const int16_t &co2, const float &temp, const float &hum);
+
+  void setLoRaInterval(int32_t sending_interval);
 }
 
 #endif
diff --git a/ampel-firmware/mqtt.cpp b/ampel-firmware/mqtt.cpp
index 143ed736ced5b9d94999147922973d4e919a0484..516090744a5ac7c8cfed9cfdee02391b5bb78d4e 100644
--- a/ampel-firmware/mqtt.cpp
+++ b/ampel-firmware/mqtt.cpp
@@ -2,7 +2,7 @@
 
 namespace config {
   // Values should be defined in config.h
-  uint16_t sending_interval = MQTT_SENDING_INTERVAL; // [s]
+  uint16_t mqtt_sending_interval = MQTT_SENDING_INTERVAL; // [s]
   //INFO: Listen to every CO2 sensor which is connected to the server:
   //  mosquitto_sub -h MQTT_SERVER -t 'CO2sensors/#' -p 443 --capath /etc/ssl/certs/ -u "MQTT_USER" -P "MQTT_PASSWORD" -v
   const char *mqtt_server = MQTT_SERVER;
@@ -35,6 +35,10 @@ namespace mqtt {
 #endif
     // mqttClient.setSocketTimeout(config::mqtt_timeout); //NOTE: somehow doesn't seem to have any effect on connect()
     mqttClient.setServer(config::mqtt_server, config::mqtt_port);
+
+    sensor_console::defineIntCommand("mqtt", setMQTTinterval, " 60 (Sets MQTT sending interval, in s)");
+    sensor_console::defineCommand("local_ip", sendInfoAboutLocalNetwork,
+        " (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) {
@@ -55,70 +59,6 @@ namespace mqtt {
     }
   }
 
-  //TODO: Move all those setters to a separate class, which could be used by Serial/MQTT/WebServer
-  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
    *
@@ -133,48 +73,13 @@ namespace mqtt {
     }
     led_effects::onBoardLEDOn();
     Serial.print(F("Message arrived on topic: "));
-    Serial.print(sub_topic);
-    Serial.print(F(". Message: '"));
-    String messageString;
+    Serial.println(sub_topic);
+    char command[length + 1];
     for (unsigned int i = 0; i < length; i++) {
-      Serial.print((char) message[i]);
-      messageString += (char) message[i];
+      command[i] = message[i];
     }
-    Serial.println("'.");
-
-    //TODO: Move this logic to a separate class, which could be used by Serial/MQTT/WebServer
-
-    if (messageString.startsWith("co2 ")) {
-      setCO2forDebugging(messageString);
-    } else if (messageString.startsWith("timer ")) {
-      setTimer(messageString);
-    } else if (messageString == "calibrate") {
-      sensor::startCalibrationProcess();
-    } else if (messageString.startsWith("calibrate ")) {
-      calibrateSensorToSpecificPPM(messageString);
-    } else if (messageString.startsWith("mqtt ")) {
-      setMQTTinterval(messageString);
-    } else if (messageString == "publish") {
-      Serial.println(F("Forcing MQTT publish now."));
-      publish(sensor::timestamp, sensor::co2, sensor::temperature, sensor::humidity);
-#ifdef AMPEL_CSV
-    } else if (messageString.startsWith("csv ")) {
-      setCSVinterval(messageString);
-    } else if (messageString == "format_filesystem") {
-      FS_LIB.format();
-      led_effects::showKITTWheel(color::blue, 2);
-#endif
-    } else if (messageString == "night_mode") {
-      led_effects::toggleNightMode();
-    } else if (messageString == "local_ip") {
-      sendInfoAboutLocalNetwork();
-    } else if (messageString == "reset") {
-      ESP.restart(); // softer than ESP.reset()
-    } else {
-      led_effects::showKITTWheel(color::red, 1);
-      Serial.println(F("Message not supported. Doing nothing."));
-    }
-    delay(50);
+    command[length] = 0;
+    sensor_console::runCommand(command);
     led_effects::onBoardLEDOff();
   }
 
@@ -218,7 +123,7 @@ namespace mqtt {
   void publishIfTimeHasCome(const String &timeStamp, const int16_t &co2, const float &temp, const float &hum) {
     // Send message via MQTT according to sending interval
     unsigned long now = seconds();
-    if (now - last_sent_at > config::sending_interval) {
+    if (now - last_sent_at > config::mqtt_sending_interval) {
       last_sent_at = now;
       publish(timeStamp, co2, temp, hum);
     }
@@ -232,4 +137,28 @@ namespace mqtt {
     mqttClient.loop();
   }
 
+  /*****************************************************************
+   * Callbacks for sensor commands                                 *
+   *****************************************************************/
+  void setMQTTinterval(int32_t sending_interval) {
+    config::mqtt_sending_interval = sending_interval;
+    Serial.print(F("Setting MQTT sending interval to : "));
+    Serial.print(config::mqtt_sending_interval);
+    Serial.println("s.");
+    led_effects::showKITTWheel(color::green, 1);
+  }
+
+  // It can be hard to find the local IP of a sensor if it isn't connected to Serial port, and if mDNS is disabled.
+  // If the sensor can be reach by MQTT, it can answer with info about local_ip and ssid.
+  // The sensor will send the info to "CO2sensors/ESP123456/info".
+  void sendInfoAboutLocalNetwork() {
+    char info_topic[60]; // Should be enough for "CO2sensors/ESP123456/info"
+    snprintf(info_topic, sizeof(info_topic), "%s/info", publish_topic.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);
+  }
 }
diff --git a/ampel-firmware/mqtt.h b/ampel-firmware/mqtt.h
index 2899a9996bbf8d2a484f5fc79b619d9647ee70a7..aaaa95621d10cdf0a8608700654a89b360563989 100644
--- a/ampel-firmware/mqtt.h
+++ b/ampel-firmware/mqtt.h
@@ -4,14 +4,11 @@
 #include <Arduino.h>
 #include "config.h"
 #include "led_effects.h"
-#ifdef AMPEL_CSV
-#  include "csv_writer.h"
-#endif
-#include "co2_sensor.h"
+#include "sensor_console.h"
 #include "src/lib/PubSubClient/src/PubSubClient.h"
 #include "wifi_util.h"
 namespace config {
-  extern uint16_t sending_interval; // [s]
+  extern uint16_t mqtt_sending_interval; // [s]
 }
 namespace mqtt {
   extern String last_successful_publish;
@@ -19,5 +16,8 @@ namespace mqtt {
   void initialize(String &topic);
   void keepConnection();
   void publishIfTimeHasCome(const String &timeStamp, const int16_t &co2, const float &temp, const float &hum);
+
+  void setMQTTinterval(int32_t sending_interval);
+  void sendInfoAboutLocalNetwork();
 }
 #endif
diff --git a/ampel-firmware/sensor_console.cpp b/ampel-firmware/sensor_console.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..ec55534d0ed69c96a820047939ff0b1285db4faa
--- /dev/null
+++ b/ampel-firmware/sensor_console.cpp
@@ -0,0 +1,160 @@
+#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;
+
+  struct Command {
+    const char *name;
+    union {
+      void (*intFunction)(int32_t);
+      void (*voidFunction)(void);
+    };
+    const char *doc;
+    bool has_parameter;
+  };
+
+  Command commands[MAX_COMMANDS];
+
+  //NOTE: Probably possible to DRY (with templates?)
+  void defineCommand(const char *name, void (*function)(void), const char *doc) {
+    if (commands_count < MAX_COMMANDS) {
+      commands[commands_count].name = name;
+      commands[commands_count].voidFunction = function;
+      commands[commands_count].doc = doc;
+      commands[commands_count].has_parameter = false;
+      commands_count++;
+    } else {
+      Serial.println(F("Too many commands have been defined."));
+    }
+  }
+
+  void defineIntCommand(const char *name, void (*function)(int32_t), const char *doc) {
+    if (commands_count < MAX_COMMANDS) {
+      commands[commands_count].name = name;
+      commands[commands_count].intFunction = function;
+      commands[commands_count].doc = doc;
+      commands[commands_count].has_parameter = true;
+      commands_count++;
+    } else {
+      Serial.println(F("Too many commands have been defined."));
+    }
+  }
+
+  /*
+   * Tries to split a string command (e.g. 'mqtt 60' or 'show_csv') into a function_name and an argument.
+   * Returns 0 if both are found, 1 if there is a problem and 2 if no argument is found.
+   */
+  uint8_t parseCommand(const char *command, char *function_name, int32_t &argument) {
+    char split_command[MAX_COMMAND_SIZE];
+    strlcpy(split_command, command, MAX_COMMAND_SIZE);
+    Serial.print(F("Received : '"));
+    Serial.print(command);
+    Serial.println("'");
+    char *arg;
+    char *part1;
+    part1 = strtok(split_command, " ");
+    if (!part1) {
+      // Empty string
+      return 1;
+    }
+    strlcpy(function_name, part1, MAX_COMMAND_SIZE);
+    arg = strtok(NULL, " ");
+    uint8_t code = 0;
+    if (arg) {
+      char *end;
+      argument = strtol(arg, &end, 10);
+      if (*end) {
+        // Second argument isn't a number
+        code = 2;
+      }
+    } else {
+      // No argument
+      code = 2;
+    }
+    return code;
+  }
+
+  /*
+   * 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;
+      runCommand(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;
+    }
+  }
+
+  int compareName(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]), compareName);
+    for (uint8_t i = 0; i < commands_count; i++) {
+      Serial.print("  ");
+      Serial.print(commands[i].name);
+      Serial.print(commands[i].doc);
+      Serial.println(".");
+    }
+    led_effects::showKITTWheel(color::red, 1);
+  }
+
+  /*
+   * Tries to find the corresponding callback for a given command. Name and number of argument should fit.
+   */
+  void runCommand(const char *command) {
+    char function_name[MAX_COMMAND_SIZE];
+    int32_t argument = 0;
+    bool has_argument;
+    has_argument = (parseCommand(command, function_name, argument) == 0);
+
+    for (uint8_t i = 0; i < commands_count; i++) {
+      if (!strcmp(function_name, commands[i].name) && has_argument == commands[i].has_parameter) {
+        Serial.print(F("Calling : "));
+        Serial.print(function_name);
+        if (has_argument) {
+          Serial.print("(");
+          Serial.print(argument);
+          Serial.println(")");
+          commands[i].intFunction(argument);
+        } else {
+          Serial.println("()");
+          commands[i].voidFunction();
+        }
+        return;
+      }
+    }
+    Serial.println(F("Message not supported. Available commands :"));
+    listAvailableCommands();
+  }
+}
diff --git a/ampel-firmware/sensor_console.h b/ampel-firmware/sensor_console.h
new file mode 100644
index 0000000000000000000000000000000000000000..8afa40c34838b2c321b55546a75a34d74360ab6b
--- /dev/null
+++ b/ampel-firmware/sensor_console.h
@@ -0,0 +1,14 @@
+#include <Arduino.h>
+#include "led_effects.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 processSerialInput(const byte in_byte);
+  void runCommand(const char *command);
+  void defineIntCommand(const char *command, void (*function)(int32_t), const char *doc);
+  void defineCommand(const char *command, void (*function)(void), const char *doc);
+}
diff --git a/ampel-firmware/web_server.cpp b/ampel-firmware/web_server.cpp
index 9cba04f27e880890b72310241ab985f652cc1737..c9dfeab5e2546703dc2968f5eefa45799d86282a 100644
--- a/ampel-firmware/web_server.cpp
+++ b/ampel-firmware/web_server.cpp
@@ -22,6 +22,7 @@ namespace web_server {
   const char *script_template;
   void handleWebServerRoot();
   void handlePageNotFound();
+  void handleWebServerCommand();
 
 #ifdef AMPEL_CSV
   void handleDeleteCSV();
@@ -72,16 +73,15 @@ namespace web_server {
             // Show a colored dot on the webpage, with a similar color than on LED Ring.
             "hue=(1-(Math.min(Math.max(parseInt(document.title),500),1600)-500)/1100)*120;\n"
             "document.getElementById('led').style.color=['hsl(',hue,',100%%,50%%)'].join('');\n"
-            "</script>\n");
-
-    body_template =
-        PSTR("<div class='pure-g'>\n"
-            "<div class='pure-u-1' id='graph'></div>\n" // Graph placeholder
+            "</script>\n"
+            "<div class='pure-g'>\n"
+            "<div class='pure-u-1' id='graph'></div>\n"// Graph placeholder
             "</div>\n"
             "<div class='pure-g'>\n"
-            //Sensor table
-            "<table id='table' class='pure-table-striped pure-u-1 pure-u-md-1-2'>\n"
-            "<tr><th colspan='2'>%s</th></tr>\n"
+            "<table id='table' class='pure-table-striped pure-u-1 pure-u-md-1-2'>\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>Temperature</td><td>%.1f&#8451;</td></tr>\n"
             "<tr><td>Humidity</td><td>%.1f%%</td></tr>\n"
@@ -108,6 +108,7 @@ namespace web_server {
 #endif
             "<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>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 IP</td><td><a href='http://%s'>%s</a></td></tr>\n"
             "<tr><td>Free heap space</td><td>%6d bytes</td></tr>\n"
@@ -116,6 +117,7 @@ namespace web_server {
             "<tr><td>Uptime</td><td>%2d d %4d h %02d min %02d s</td></tr>\n"
             "</table>\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
             "<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'/>"
@@ -173,6 +175,7 @@ namespace web_server {
 
     // Web-server
     http.on("/", handleWebServerRoot);
+    http.on("/command", handleWebServerCommand);
 #ifdef AMPEL_CSV
     http.on("/" + csv_writer::filename, handleWebServerCSV);
     http.on("/delete_csv", HTTP_POST, handleDeleteCSV);
@@ -207,9 +210,8 @@ namespace web_server {
 
     //NOTE: Splitting in multiple parts in order to use less RAM
     char content[2000]; // Update if needed
-    // Header size : 1611 - Body size : 1800 - Script size : 1920
+    // INFO - Header size : 1767 - Body size : 1812 - Script size : 1909
 
-    // Header
     snprintf_P(content, sizeof(content), header_template, sensor::co2, SENSOR_ID.c_str(),
         WiFi.localIP().toString().c_str()
 #ifdef AMPEL_CSV
@@ -217,6 +219,8 @@ namespace web_server {
 #endif
         );
 
+    Serial.print(F("INFO - Header size : "));
+    Serial.print(strlen(content));
     http.setContentLength(CONTENT_LENGTH_UNKNOWN);
     http.send_P(200, PSTR("text/html"), content);
 
@@ -227,16 +231,19 @@ namespace web_server {
         csv_writer::last_successful_write.c_str(), config::csv_interval, csv_writer::getAvailableSpace() / 1024,
 #endif
 #ifdef AMPEL_MQTT
-        mqtt::connected ? "Yes" : "No", mqtt::last_successful_publish.c_str(), config::sending_interval,
+        mqtt::connected ? "Yes" : "No", mqtt::last_successful_publish.c_str(), config::mqtt_sending_interval,
 #endif
 #if defined(AMPEL_LORAWAN) && defined(ESP32)
         lorawan::connected ? "Yes" : "No", LMIC_FREQUENCY_PLAN, lorawan::last_transmission.c_str(),
         config::lorawan_sending_interval,
 #endif
-        config::temperature_offset, SENSOR_ID.c_str(), SENSOR_ID.c_str(), WiFi.localIP().toString().c_str(),
-        WiFi.localIP().toString().c_str(), get_free_heap_size(), max_loop_duration, BOARD, dd, hh, mm, ss);
+        config::temperature_offset, config::auto_calibrate_sensor ? "Yes" : "No", SENSOR_ID.c_str(), SENSOR_ID.c_str(),
+        WiFi.localIP().toString().c_str(), WiFi.localIP().toString().c_str(), get_free_heap_size(), max_loop_duration,
+        BOARD, dd, hh, mm, ss);
 
+    Serial.print(F(" - Body size : "));
     http.sendContent(content);
+    Serial.print(strlen(content));
 
     // Script
     snprintf_P(content, sizeof(content), script_template
@@ -245,6 +252,8 @@ namespace web_server {
 #endif
         );
 
+    Serial.print(F(" - Script size : "));
+    Serial.println(strlen(content));
     http.sendContent(content);
   }
 
@@ -275,6 +284,15 @@ namespace web_server {
   }
 #endif
 
+  void handleWebServerCommand() {
+    if (!shouldBeAllowed()) {
+      return http.requestAuthentication(DIGEST_AUTH);
+    }
+    http.sendHeader("Location", "/");
+    http.send(303);
+    sensor_console::runCommand(http.arg("send").c_str());
+  }
+
   void handlePageNotFound() {
     http.send(404, F("text/plain"), F("404: Not found"));
   }
diff --git a/ampel-firmware/web_server.h b/ampel-firmware/web_server.h
index 71c17f0578ac1616f464d0456f25afcfa5930c72..ed1b65dc26dee2dc5dd3d5ce27a6e1e5acea66ff 100644
--- a/ampel-firmware/web_server.h
+++ b/ampel-firmware/web_server.h
@@ -1,5 +1,6 @@
 #ifndef WEB_SERVER_H_
 #define WEB_SERVER_H_
+
 #if defined(ESP8266)
 #  include <ESP8266WebServer.h>
 #elif defined(ESP32)
@@ -9,6 +10,7 @@
 #include "config.h"
 #include "util.h"
 #include "co2_sensor.h"
+#include "sensor_console.h"
 #ifdef AMPEL_CSV
 #  include "csv_writer.h"
 #endif