diff --git a/README.md b/README.md
index 744c18fd15511019511627056c40b7f2b2ab7b31..09bcf3a3850bf2f4ff6606508b5ff05a1c07c805 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
 # 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.
 
@@ -12,11 +12,11 @@ The *CO<sub>2</sub> Ampel* can:
 
 * Display CO2 concentration on LED ring.
 * Allow calibration.
-* Get current time over NTP
-* Send data over MQTT.
-* Send data over LoRaWAN.
+* Get current time over [NTP](https://en.wikipedia.org/wiki/Network_Time_Protocol)
+* Send data over [MQTT](https://en.wikipedia.org/wiki/MQTT).
+* Send data over [LoRaWAN](https://en.wikipedia.org/wiki/LoRa#LoRaWAN).
 * Display measurements and configuration on a small website.
-* Log data to a CSV file, directly on the ESP flash memory.
+* Log data to a [CSV](https://en.wikipedia.org/wiki/Comma-separated_values) file, directly on the ESP flash memory.
 
 ## Hardware Requirements
 
@@ -85,5 +85,4 @@ make upload board=esp32 && make monitor # For ESP32
 ## License
 
 Copyright © 2021, [HfT Stuttgart](https://www.hft-stuttgart.de/)
-
 [GPLv3](https://choosealicense.com/licenses/gpl-3.0/)
diff --git a/ampel-firmware/co2_sensor.cpp b/ampel-firmware/co2_sensor.cpp
index 5dff6765fb7c4a6b19d9dc631af15c3474d9840b..5795697b05eb325ae2f4e8822f5f994bcffa043a 100644
--- a/ampel-firmware/co2_sensor.cpp
+++ b/ampel-firmware/co2_sensor.cpp
@@ -15,6 +15,7 @@ namespace config {
   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
 }
 
 namespace sensor {
@@ -24,12 +25,39 @@ namespace sensor {
   float humidity = 0;
   char timestamp[23];
   int16_t stable_measurements = 0;
-  uint32_t waiting_color = color::blue;
-  bool should_calibrate = false;
+
+  /**
+   * Define sensor states
+   * INITIAL -> initial state
+   * BOOTUP -> state after initializing the sensor, i.e. after scd.begin()
+   * 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 -> forced calibration was initiated, waiting for stable measurements
+   * CALIBRATION -> the sensor does calibrate itself
+   */
+  enum state {
+    INITIAL,
+    BOOTUP,
+    READY,
+    NEEDS_CALIBRATION,
+    PREPARE_CALIBRATION_UNSTABLE,
+    PREPARE_CALIBRATION_STABLE,
+    CALIBRATION
+  };
+  const char *state_names[] = {
+      "INITIAL",
+      "BOOTUP",
+      "READY",
+      "NEEDS_CALIBRATION",
+      "PREPARE_CALIBRATION_UNSTABLE",
+      "PREPARE_CALIBRATION_STABLE",
+      "CALIBRATION" };
+  state current_state = INITIAL;
+  void switchState(state);
 
   void initialize() {
 #if defined(ESP8266)
-    Wire.begin(12, 14);  // ESP8266 - D6, D5;
+    Wire.begin(12, 14); // ESP8266 - D6, D5;
 #endif
 #if defined(ESP32)
     Wire.begin(21, 22); // ESP32
@@ -41,17 +69,19 @@ namespace sensor {
      *  SDA --- SDA (GPIO21) //NOTE: GPIO1 would be more convenient (right next to GPO3)
      */
 #endif
+    Serial.println();
+    scd30.enableDebugging(); // Prints firmware version in the console.
 
-    // CO2
-    if (scd30.begin(config::auto_calibrate_sensor) == false) {
+    if (!scd30.begin(config::auto_calibrate_sensor)) {
       Serial.println(F("ERROR - CO2 sensor not detected. Please check wiring!"));
       led_effects::showKITTWheel(color::red, 30);
       ESP.restart();
     }
 
+    switchState(BOOTUP);
+
     // SCD30 has its own timer.
     //NOTE: The timer seems to be inaccurate, though, possibly depending on voltage. Should it be offset?
-    Serial.println();
     Serial.print(F("Setting SCD30 timestep to "));
     Serial.print(config::measurement_timestep);
     Serial.println(" s.");
@@ -91,19 +121,21 @@ namespace sensor {
     previous_measurement_at = now;
   }
 
-  void countStableMeasurements() {
+  bool countStableMeasurements() {
+    // Returns true, if a sufficient number of stable measurements has been observed.
     static int16_t previous_co2 = 0;
     if (co2 > (previous_co2 - config::max_deviation_during_calibration)
         && co2 < (previous_co2 + config::max_deviation_during_calibration)) {
       stable_measurements++;
       Serial.print(F("Number of stable measurements : "));
       Serial.println(stable_measurements);
-      waiting_color = color::green;
+      switchState(PREPARE_CALIBRATION_STABLE);
     } else {
       stable_measurements = 0;
-      waiting_color = color::red;
+      switchState(PREPARE_CALIBRATION_UNSTABLE);
     }
     previous_co2 = co2;
+    return (stable_measurements == config::enough_stable_measurements);
   }
 
   void startCalibrationProcess() {
@@ -115,10 +147,11 @@ namespace sensor {
     scd30.setMeasurementInterval(2); // [s] The change will only take effect after next measurement.
     Serial.println(F("Waiting until the measurements are stable for at least 2 minutes."));
     Serial.println(F("It could take a very long time."));
-    should_calibrate = true;
+    switchState(PREPARE_CALIBRATION_UNSTABLE);
   }
 
   void calibrateAndRestart() {
+    switchState(CALIBRATION);
     Serial.print(F("Calibrating SCD30 now..."));
     scd30.setAltitudeCompensation(config::altitude_above_sea_level);
     scd30.setForcedRecalibrationFactor(config::co2_calibration_level);
@@ -137,12 +170,41 @@ namespace sensor {
     Serial.println(humidity, 1);
   }
 
-  void displayCO2OnLedRing() {
-    if (co2 < 250) {
-      // Sensor should be calibrated.
-      led_effects::showWaitingLED(color::magenta);
+  void switchState(state new_state) {
+    if (new_state == current_state) {
       return;
     }
+    if (config::debug_sensor_states) {
+      Serial.print(F("Changing sensor state: "));
+      Serial.print(state_names[current_state]);
+      Serial.print(" -> ");
+      Serial.println(state_names[new_state]);
+    }
+    current_state = new_state;
+  }
+
+  void switchStateForCurrentPPM() {
+    if (co2 == 0) {
+      // NOTE: Data is available, but it's sometimes erroneous: the sensor outputs
+      // zero ppm but non-zero temperature and non-zero humidity.
+      Serial.println(F("Invalid sensor data - CO2 concentration supposedly 0 ppm"));
+      switchState(BOOTUP);
+    } else if ((current_state == PREPARE_CALIBRATION_UNSTABLE) || (current_state == PREPARE_CALIBRATION_STABLE)) {
+      // Check for pre-calibration states first, because we do not want to
+      // leave them before calibration is done.
+      bool ready_for_calibration = countStableMeasurements();
+      if (ready_for_calibration) {
+        calibrateAndRestart();
+      }
+    } 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).
      * A short delay is required in order to let background tasks run on the ESP8266.
@@ -157,6 +219,30 @@ namespace sensor {
     }
   }
 
+  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;
+    case CALIBRATION: // Nothing to do, will restart soon.
+      break;
+    default:
+      Serial.println(F("Encountered unknown sensor state")); // This should not happen.
+    }
+  }
+
   /** Gets fresh data if available, checks calibration status, displays CO2 levels.
    * Returns true if fresh data is available, for further processing (e.g. MQTT, CSV or LoRa)
    */
@@ -169,34 +255,15 @@ namespace sensor {
       co2 = scd30.getCO2();
       temperature = scd30.getTemperature();
       humidity = scd30.getHumidity();
-    }
 
-    //NOTE: Data is available, but it's sometimes erroneous: the sensor outputs zero ppm but non-zero temperature and non-zero humidity.
-    if (co2 <= 0) {
-      // No measurement yet. Waiting.
-      led_effects::showWaitingLED(color::blue);
-      return false;
-    }
+      switchStateForCurrentPPM();
 
-    /**
-     * Fresh data. Log it and send it if needed.
-     */
-    if (freshData) {
-      if (should_calibrate) {
-        countStableMeasurements();
-      }
+      // Log every time fresh data is available.
       logToSerial();
     }
 
-    if (should_calibrate) {
-      if (stable_measurements == config::enough_stable_measurements) {
-        calibrateAndRestart();
-      }
-      led_effects::showWaitingLED(waiting_color);
-      return false;
-    }
+    showState();
 
-    displayCO2OnLedRing();
     return freshData;
   }
 
@@ -207,6 +274,7 @@ namespace sensor {
     Serial.print(F("DEBUG. Setting CO2 to "));
     co2 = fakeCo2;
     Serial.println(co2);
+    switchStateForCurrentPPM();
   }
 
   void setAutoCalibration(int32_t autoCalibration) {
diff --git a/ampel-firmware/csv_writer.cpp b/ampel-firmware/csv_writer.cpp
index 7e30d44da9531d5a4022ce3aec2a3a5a9d51c987..a83bed666455545d2d5cb7c68a3d583d88f7b40a 100644
--- a/ampel-firmware/csv_writer.cpp
+++ b/ampel-firmware/csv_writer.cpp
@@ -87,6 +87,7 @@ namespace csv_writer {
   void initialize(const char *sensorId) {
     snprintf(filename, sizeof(filename), "/%s.csv", sensorId);
 
+    Serial.println();
     Serial.print(F("Initializing FS..."));
     if (mountFS()) {
       Serial.println(F("done."));