diff --git a/README.md b/README.md
index e5421538c55f09946567ae3698882450de7884f1..9389e016950113580ab266e21e5178403af5d2ae 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,18 @@ 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.
 
+## Features
+
+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.
+* Display measurements and configuration on a small website.
+* Log data to a CSV file, directly on the ESP flash memory.
+
 ## 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*)
diff --git a/ampel-firmware.h b/ampel-firmware/ampel-firmware.h
similarity index 63%
rename from ampel-firmware.h
rename to ampel-firmware/ampel-firmware.h
index f749605fa9621cf51e1447be815294672c306016..5c47dec1cc21cbb38963d9f31fe2a08743ea851a 100644
--- a/ampel-firmware.h
+++ b/ampel-firmware/ampel-firmware.h
@@ -5,25 +5,33 @@
  *****************************************************************/
 #include "config.h"
 #ifndef MEASUREMENT_TIMESTEP
-#  error Missing config.h file. Please copy config.example.h to config.h.
+#  error Missing config.h file. Please copy config.public.h to config.h.
 #endif
-#ifdef MQTT
-#  include "mqtt.h"
+
+#ifdef AMPEL_CSV
+#  include "csv_writer.h"
 #endif
 
-#include "util.h"
-#include "wifi_util.h"
-#include "co2_sensor.h"
+#ifdef AMPEL_WIFI
+#  include "wifi_util.h"
+#  ifdef AMPEL_MQTT
+#    include "mqtt.h"
+#  endif
+#  ifdef AMPEL_HTTP
+#    include "web_server.h"
+#  endif
+#endif
 
-#ifdef HTTP
-#  include "web_server.h"
+#ifdef AMPEL_LORAWAN
+#  include "lorawan.h"
 #endif
 
+#include "util.h"
+#include "co2_sensor.h"
 #include "led_effects.h"
-#include "csv_writer.h"
 
 #if defined(ESP8266)
-//allows sensor to be seen as SENSOR_ID.local, from the local network. For example : espd05cc9.local
+//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>
diff --git a/ampel-firmware.ino b/ampel-firmware/ampel-firmware.ino
similarity index 74%
rename from ampel-firmware.ino
rename to ampel-firmware/ampel-firmware.ino
index 45d548acfbba23b09b20bb8a5f1a67174881a030..2713e2dc76413d27a673f9ac1e63b29b3c3cdbe3 100644
--- a/ampel-firmware.ino
+++ b/ampel-firmware/ampel-firmware.ino
@@ -59,14 +59,14 @@
  * Setup                                                         *
  *****************************************************************/
 void setup() {
-  LedEffects::setupOnBoardLED();
-  LedEffects::onBoardLEDOff();
+  led_effects::setupOnBoardLED();
+  led_effects::onBoardLEDOff();
 
   Serial.begin(BAUDS);
 
-  pinMode(0, INPUT);  // Flash button (used for forced calibration)
+  pinMode(0, INPUT); // Flash button (used for forced calibration)
 
-  LedEffects::setupRing();
+  led_effects::setupRing();
 
   sensor::initialize();
 
@@ -75,6 +75,7 @@ void setup() {
   Serial.print(F("Board    : "));
   Serial.println(BOARD);
 
+#ifdef AMPEL_WIFI
   // Try to connect to Wi-Fi
   WiFiConnect(SENSOR_ID);
 
@@ -82,9 +83,9 @@ void setup() {
   Serial.println(WiFi.status());
 
   if (WiFi.status() == WL_CONNECTED) {
-#ifdef HTTP
+#  ifdef AMPEL_HTTP
     web_server::initialize();
-#endif
+#  endif
 
     ntp::initialize();
 
@@ -95,11 +96,19 @@ void setup() {
       Serial.println(F("Error setting up MDNS responder!"));
     }
 
-#ifdef MQTT
+#  ifdef AMPEL_MQTT
     mqtt::initialize("CO2sensors/" + SENSOR_ID);
-#endif
+#  endif
   }
+#endif
+
+#ifdef AMPEL_CSV
   csv_writer::initialize();
+#endif
+
+#if defined(AMPEL_LORAWAN) && defined(ESP32)
+  lorawan::initialize();
+#endif
 }
 
 /*****************************************************************
@@ -107,73 +116,36 @@ void setup() {
  *****************************************************************/
 
 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;
+  }
+#endif
   //NOTE: Loop should never take more than 1000ms. Split in smaller methods and logic if needed.
   //TODO: Restart every day or week, in order to not let t0 overflow?
   uint32_t t0 = millis();
 
-  /**
-   * USER INTERACTION
-   */
   keepServicesAlive();
+
   // Short press for night mode, Long press for calibration.
   checkFlashButton();
 
-  /**
-   * GET DATA
-   */
-  bool freshData = sensor::scd30.dataAvailable(); // Alternative : close to time-step AND dataAvailable, to avoid asking the sensor too often.
-
-  if (freshData) {
-    sensor::co2 = sensor::scd30.getCO2();
-    sensor::temperature = sensor::scd30.getTemperature();
-    sensor::humidity = sensor::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 (sensor::co2 <= 0) {
-    // No measurement yet. Waiting.
-    LedEffects::showWaitingLED(color::blue);
-    return;
-  }
-
-  /**
-   * Fresh data. Show it and send it if needed.
-   */
-
-  if (freshData) {
-    sensor::timestamp = ntp::getLocalTime();
-    Serial.println(sensor::timestamp);
-
-    Serial.print(F("co2(ppm): "));
-    Serial.print(sensor::co2);
-    Serial.print(F(" temp(C): "));
-    Serial.print(sensor::temperature);
-    Serial.print(F(" humidity(%): "));
-    Serial.println(sensor::humidity);
-
+  if (sensor::processData()) {
+#ifdef AMPEL_CSV
     csv_writer::logIfTimeHasCome(sensor::timestamp, sensor::co2, sensor::temperature, sensor::humidity);
+#endif
 
-#ifdef MQTT
+#if defined(AMPEL_WIFI) && defined(AMPEL_MQTT)
     mqtt::publishIfTimeHasCome(sensor::timestamp, sensor::co2, sensor::temperature, sensor::humidity);
 #endif
-  }
-
-  if (sensor::co2 < 250) {
-    // Sensor should be calibrated.
-    LedEffects::showWaitingLED(color::magenta);
-    return;
-  }
 
-  /**
-   * Display data, even if it's "old" (with breathing).
-   * Those effects include a short delay.
-   */
-
-  if (sensor::co2 < 2000) {
-    LedEffects::displayCO2color(sensor::co2);
-    LedEffects::breathe(sensor::co2);
-  } else {  // >= 2000: entire ring blinks red
-    LedEffects::redAlert();
+#if defined(AMPEL_LORAWAN) && defined(ESP32)
+    lorawan::preparePayloadIfTimeHasCome(sensor::co2, sensor::temperature, sensor::humidity);
+#endif
   }
 
   uint32_t duration = millis() - t0;
@@ -193,34 +165,36 @@ void loop() {
  */
 void checkFlashButton() {
   if (!digitalRead(0)) { // Button has been pressed
-    LedEffects::onBoardLEDOn();
+    led_effects::onBoardLEDOn();
     delay(300);
     if (digitalRead(0)) {
       Serial.println(F("Flash has been pressed for a short time. Should toggle night mode."));
-      LedEffects::toggleNightMode();
+      led_effects::toggleNightMode();
     } else {
       Serial.println(F("Flash has been pressed for a long time. Keep it pressed for calibration."));
-      if (LedEffects::countdownToZero() < 0) {
+      if (led_effects::countdownToZero() < 0) {
         sensor::startCalibrationProcess();
       }
     }
-    LedEffects::onBoardLEDOff();
+    led_effects::onBoardLEDOff();
   }
 }
 
 void keepServicesAlive() {
+#ifdef AMPEL_WIFI
   if (WiFi.status() == WL_CONNECTED) {
-#if defined(ESP8266)
+#  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
+#  endif
     ntp::update(); // NTP client has its own timer. It will connect to NTP server every 60s.
-#ifdef HTTP
+#  ifdef AMPEL_HTTP
     web_server::update();
-#endif
-#ifdef MQTT
+#  endif
+#  ifdef AMPEL_MQTT
     mqtt::keepConnection(); // MQTT client has its own timer. It will keep alive every 15s.
-#endif
+#  endif
   }
+#endif
 }
diff --git a/ampel-firmware/co2_sensor.cpp b/ampel-firmware/co2_sensor.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..e9c40c79c322383bd9f771203e33ef7abc02b14a
--- /dev/null
+++ b/ampel-firmware/co2_sensor.cpp
@@ -0,0 +1,189 @@
+#include "co2_sensor.h"
+
+namespace config {
+  // Values should be defined in config.h
+  uint16_t measurement_timestep = MEASUREMENT_TIMESTEP; // [s] Value between 2 and 1800 (range for SCD30 sensor)
+  const uint16_t altitude_above_sea_level = ALTITUDE_ABOVE_SEA_LEVEL; // [m]
+  uint16_t co2_calibration_level = ATMOSPHERIC_CO2_CONCENTRATION; // [ppm]
+#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
+  const bool auto_calibrate_sensor = AUTO_CALIBRATE_SENSOR; // [true / false]
+}
+
+namespace sensor {
+  SCD30 scd30;
+  int16_t co2 = 0;
+  float temperature = 0;
+  float humidity = 0;
+  String timestamp = "";
+  int16_t stable_measurements = 0;
+  uint32_t waiting_color = color::blue;
+  bool should_calibrate = false;
+
+  void initialize() {
+#if defined(ESP8266)
+    Wire.begin(12, 14);  // ESP8266 - D6, D5;
+#endif
+#if defined(ESP32)
+    Wire.begin(21, 22); // ESP32
+    /**
+     *  SCD30   ESP32
+     *  VCC --- 3V3
+     *  GND --- GND
+     *  SCL --- SCL (GPIO22) //NOTE: GPIO3 Would be more convenient (right next to GND)
+     *  SDA --- SDA (GPIO21) //NOTE: GPIO1 would be more convenient (right next to GPO3)
+     */
+#endif
+
+    // CO2
+    if (scd30.begin(config::auto_calibrate_sensor) == false) {
+      Serial.println("Air sensor not detected. Please check wiring. Freezing...");
+      while (1) {
+        led_effects::showWaitingLED(color::red);
+      }
+    }
+
+    // SCD30 has its own timer.
+    //NOTE: The timer seems to be inaccurate, though, possibly depending on voltage. Should it be offset?
+    Serial.println();
+    Serial.print(F("Setting SCD30 timestep to "));
+    Serial.print(config::measurement_timestep);
+    Serial.println(" s.");
+    scd30.setMeasurementInterval(config::measurement_timestep); // [s]
+
+    Serial.print(F("Setting temperature offset to -"));
+    Serial.print(abs(config::temperature_offset));
+    Serial.println(" K.");
+    scd30.setTemperatureOffset(abs(config::temperature_offset)); // setTemperatureOffset only accepts positive numbers, but shifts the temperature down.
+    delay(100);
+
+    Serial.print(F("Temperature offset is : -"));
+    Serial.print(scd30.getTemperatureOffset());
+    Serial.println(" K");
+
+    Serial.print(F("Auto-calibration is "));
+    Serial.println(config::auto_calibrate_sensor ? "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(now - previous_measurement_at - config::measurement_timestep * 1000);
+    Serial.println(" ms.");
+    previous_measurement_at = now;
+  }
+
+  void countStableMeasurements() {
+    static int16_t previous_co2 = 0;
+    if (co2 > (previous_co2 - 30) && co2 < (previous_co2 + 30)) {
+      stable_measurements++;
+      Serial.print(F("Number of stable measurements : "));
+      Serial.println(stable_measurements);
+      waiting_color = color::green;
+    } else {
+      stable_measurements = 0;
+      waiting_color = color::red;
+    }
+    previous_co2 = co2;
+  }
+
+  void startCalibrationProcess() {
+    /** From the sensor documentation:
+     * For best results, the sensor has to be run in a stable environment in continuous mode at
+     * a measurement rate of 2s for at least two minutes before applying the FRC command and sending the reference value.
+     */
+    Serial.println(F("Setting SCD30 timestep to 2s, prior to calibration."));
+    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;
+  }
+
+  void calibrateAndRestart() {
+    Serial.print(F("Calibrating SCD30 now..."));
+    scd30.setAltitudeCompensation(config::altitude_above_sea_level);
+    scd30.setForcedRecalibrationFactor(config::co2_calibration_level);
+    Serial.println(F(" Done!"));
+    Serial.println(F("Sensor calibrated."));
+    ESP.restart(); // softer than ESP.reset
+  }
+
+  void logToSerial() {
+    Serial.println(timestamp);
+    Serial.print(F("co2(ppm): "));
+    Serial.print(co2);
+    Serial.print(F(" temp(C): "));
+    Serial.print(temperature, 1);
+    Serial.print(F(" humidity(%): "));
+    Serial.println(humidity, 1);
+  }
+
+  void displayCO2OnLedRing() {
+    if (co2 < 250) {
+      // Sensor should be calibrated.
+      led_effects::showWaitingLED(color::magenta);
+      return;
+    }
+    /**
+     * Display data, even if it's "old" (with breathing).
+     * Those effects include a short delay.
+     */
+    if (co2 < 2000) {
+      led_effects::displayCO2color(co2);
+      led_effects::breathe(co2);
+    } else {
+      // >= 2000: entire ring blinks red
+      led_effects::redAlert();
+    }
+  }
+
+  /** 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)
+   */
+  bool processData() {
+    bool freshData = scd30.dataAvailable();
+
+    if (freshData) {
+      // checkTimerDeviation();
+      timestamp = ntp::getLocalTime();
+      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;
+    }
+
+    /**
+     * Fresh data. Log it and send it if needed.
+     */
+    if (freshData) {
+      if (should_calibrate) {
+        countStableMeasurements();
+      }
+      logToSerial();
+    }
+
+    if (should_calibrate) {
+      if (stable_measurements == 60) {
+        calibrateAndRestart();
+      }
+      led_effects::showWaitingLED(waiting_color);
+      return false;
+    }
+
+    displayCO2OnLedRing();
+    return freshData;
+  }
+}
diff --git a/co2_sensor.h b/ampel-firmware/co2_sensor.h
similarity index 79%
rename from co2_sensor.h
rename to ampel-firmware/co2_sensor.h
index 97ea97f2c8b7762c7bab0470d293a558ad854481..f11eb815582358a2732b34ec4afe790773683c0d 100644
--- a/co2_sensor.h
+++ b/ampel-firmware/co2_sensor.h
@@ -6,13 +6,13 @@
 #include "src/lib/SparkFun_SCD30_Arduino_Library/src/SparkFun_SCD30_Arduino_Library.h"  // From: http://librarymanager/All#SparkFun_SCD30
 #include "config.h"
 #include "led_effects.h"
-#include "csv_writer.h" // To close filesystem before restart.
+#include "util.h"
 #include <Wire.h>
 
 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 uint16_t co2_calibration_level;  // [ppm]
+  extern uint16_t co2_calibration_level; // [ppm]
   extern const float temperature_offset; // [K] Sign isn't relevant.
 }
 
@@ -24,6 +24,7 @@ namespace sensor {
   extern String timestamp;
 
   void initialize();
+  bool processData();
   void startCalibrationProcess();
 }
 #endif
diff --git a/config.public.h b/ampel-firmware/config.public.h
similarity index 53%
rename from config.public.h
rename to ampel-firmware/config.public.h
index 729fd87f1abb310dd18e113149fdc3e620f5e426..6b87993ce03f4489ae242a48b2c6f2c87afe6e63 100644
--- a/config.public.h
+++ b/ampel-firmware/config.public.h
@@ -3,14 +3,24 @@
 
 // This file is a config template, and can be copied to config.h. Please don't save any important password in this template.
 
+/**
+ * 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")
+
 /**
  * WIFI
  */
 
-// Setting WIFI_SSID to "NO_WIFI" will disable WiFi completely, and all other dependent services (MQTT, HTTP, NTP, ...)
-#  define WIFI_SSID     "NO_WIFI"
+#  define WIFI_SSID     "MY_SSID"
 #  define WIFI_PASSWORD "P4SSW0RD"
-#  define WIFI_TIMEOUT  20 // [s]
+#  define WIFI_TIMEOUT  30 // [s]
 
 /**
  * Sensor
@@ -20,15 +30,10 @@
 //NOTE: SCD30 timer does not seem to be very precise. Variations may occur.
 #  define MEASUREMENT_TIMESTEP 60 // [s] Value between 2 and 1800 (range for SCD30 sensor)
 
-// How often measurements should be sent to MQTT server?
-// Probably a good idea to use a multiple of MEASUREMENT_TIMESTEP, so that averages can be calculated
-// Set to 0 if you want to send values after each measurement
-// #  define SENDING_INTERVAL MEASUREMENT_TIMESTEP * 5 // [s]
-#  define SENDING_INTERVAL 300 // [s]
-
 // 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
 // Set to 0 if you want to send values after each measurement
+// WARNING: Writing too often might damage the ESP memory
 #  define CSV_INTERVAL 300 // [s]
 
 // Residual heat from CO2 sensor seems to be high enough to change the temperature reading. How much should it be offset?
@@ -64,29 +69,26 @@
  * available at http://local_ip, with user HTTP_USER and password HTTP_PASSWORD
  */
 
-#  define HTTP // Comment or remove this line if you want to disable HTTP webserver
-
 // Define empty strings in order to disable authentication, or remove the constants altogether.
 #  define HTTP_USER "co2ampel"
 #  define HTTP_PASSWORD "my_password"
 
 /**
- * MQTT SERVER
+ * MQTT
  */
-#  define MQTT // Comment or remove this line if you want to disable MQTT
 
 /*
- * If MQTT is enabled, co2ampel will publish data every SENDING_INTERVAL seconds.
+ * If AMPEL_MQTT is enabled, co2ampel will publish data every MQTT_SENDING_INTERVAL seconds.
  * An MQTT subscriber can then get the data from the corresponding broker, either encrypted or unencrypted:
  *
  *  ❯ mosquitto_sub -h 'test.mosquitto.org' -p 8883 -t 'CO2sensors/#' --cafile mosquitto.org.crt -v
- *  CO2sensors/ESPd05cc9 {"time":"2020-12-13 13:14:37+01", "co2":571, "temp":18.9, "rh":50.9}
- *  CO2sensors/ESPd05cc9 {"time":"2020-12-13 13:14:48+01", "co2":573, "temp":18.9, "rh":50.2}
+ *  CO2sensors/ESPd03cc5 {"time":"2020-12-13 13:14:37+01", "co2":571, "temp":18.9, "rh":50.9}
+ *  CO2sensors/ESPd03cc5 {"time":"2020-12-13 13:14:48+01", "co2":573, "temp":18.9, "rh":50.2}
  *  ...
  *
  *  ❯ mosquitto_sub -h 'test.mosquitto.org' -t 'CO2sensors/#' -v
- *  CO2sensors/ESPd05cc9 {"time":"2020-12-13 13:15:09+01", "co2":568, "temp":18.9, "rh":50.1}
- *  CO2sensors/ESPd05cc9 {"time":"2020-12-13 13:15:20+01", "co2":572, "temp":18.9, "rh":50.3}
+ *  CO2sensors/ESPd03cc5 {"time":"2020-12-13 13:15:09+01", "co2":568, "temp":18.9, "rh":50.1}
+ *  CO2sensors/ESPd03cc5 {"time":"2020-12-13 13:15:20+01", "co2":572, "temp":18.9, "rh":50.3}
  *  ...
  */
 
@@ -98,12 +100,45 @@
  */
 #  define ALLOW_MQTT_COMMANDS false
 
+// How often measurements should be sent to MQTT server?
+// Probably a good idea to use a multiple of MEASUREMENT_TIMESTEP, so that averages can be calculated
+// Set to 0 if you want to send values after each measurement
+// #  define MQTT_SENDING_INTERVAL MEASUREMENT_TIMESTEP * 5 // [s]
+#  define MQTT_SENDING_INTERVAL 60 // [s]
 #  define MQTT_SERVER "test.mosquitto.org"  // MQTT server URL or IP address
 #  define MQTT_PORT 8883
 #  define MQTT_USER ""
 #  define MQTT_PASSWORD ""
 #  define MQTT_SERVER_FINGERPRINT "EE BC 4B F8 57 E3 D3 E4 07 54 23 1E F0 C8 A1 56 E0 D3 1A 1C" // SHA1 for test.mosquitto.org
 
+/**
+ * LoRaWAN
+ */
+
+// 1) Requires "MCCI LoRaWAN LMIC library", which will be automatically used with PlatformIO but should be added in "Arduino IDE".
+// 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/
+// 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 }
+
 /**
  * NTP
  */
diff --git a/csv_writer.cpp b/ampel-firmware/csv_writer.cpp
similarity index 73%
rename from csv_writer.cpp
rename to ampel-firmware/csv_writer.cpp
index 8e2ac43de65186af53b0409d06c52bcf77392037..81b7278a3aa08819fee0474ef8af8c24cfdc0b96 100644
--- a/csv_writer.cpp
+++ b/ampel-firmware/csv_writer.cpp
@@ -1,5 +1,7 @@
 #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]
@@ -45,9 +47,7 @@ namespace csv_writer {
       }
     }
   }
-#endif
-
-#if defined(ESP32)
+#elif defined(ESP32)
   /**
    * SPECIFIC FUNCTIONS FOR SPIFFS
    */
@@ -83,7 +83,6 @@ namespace csv_writer {
   const String filename = "/" + SENSOR_ID + ".csv";
 
   int getAvailableSpace() {
-    //TODO : Check if too low?
     return getTotalSpace() - getUsedSpace();
   }
 
@@ -135,32 +134,36 @@ namespace csv_writer {
     return csv_file;
   }
 
-  void logIfTimeHasCome(const String &timeStamp, int16_t co2, float temp, float hum) {
+  void log(const String &timeStamp, const int16_t &co2, const float &temperature, const float &humidity) {
+    led_effects::onBoardLEDOn();
+    File csv_file = openOrCreate();
+    char csv_line[42];
+    snprintf(csv_line, sizeof(csv_line), "%s;%d;%.1f;%.1f\r\n", timeStamp.c_str(), co2, temperature, humidity);
+    if (csv_file) {
+      size_t written_bytes = csv_file.print(csv_line);
+      csv_file.close();
+      if (written_bytes == 0) {
+        Serial.println(F("Nothing written. Disk full?"));
+      } else {
+        Serial.println(F("Wrote file content:"));
+        Serial.print(csv_line);
+        last_successful_write = ntp::getLocalTime();
+      }
+      updateFsInfo();
+      delay(50);
+    } else {
+      //NOTE: Can it ever happen that outfile is false?
+      Serial.println(F("Problem on create file!"));
+    }
+    led_effects::onBoardLEDOff();
+  }
+
+  void logIfTimeHasCome(const String &timeStamp, const int16_t &co2, const float &temperature, const float &humidity) {
     unsigned long now = seconds();
     //TODO: Write average since last CSV write?
     if (now - last_written_at > config::csv_interval) {
       last_written_at = now;
-      LedEffects::onBoardLEDOn();
-      File csv_file = openOrCreate();
-      char csv_line[42];
-      snprintf(csv_line, sizeof(csv_line), "%s;%d;%.1f;%.1f\r\n", timeStamp.c_str(), co2, temp, hum);
-      if (csv_file) {
-        size_t written_bytes = csv_file.print(csv_line);
-        csv_file.close();
-        if (written_bytes == 0) {
-          Serial.println(F("Nothing written. Disk full?"));
-        } else {
-          Serial.println(F("Wrote file content:"));
-          Serial.print(csv_line);
-          last_successful_write = ntp::getLocalTime();
-        }
-        updateFsInfo();
-        delay(50);
-      } else {
-        //NOTE: Can it ever happen that outfile is false?
-        Serial.println(F("Problem on create file!"));
-      }
-      LedEffects::onBoardLEDOff();
+      log(timeStamp, co2, temperature, humidity);
     }
   }
 }
diff --git a/csv_writer.h b/ampel-firmware/csv_writer.h
similarity index 72%
rename from csv_writer.h
rename to ampel-firmware/csv_writer.h
index 6478d88dd47e3985807701f8dfba09c3e824ea85..e0e0d5314ae9060ca8c39315737da2a73beb5d99 100644
--- a/csv_writer.h
+++ b/ampel-firmware/csv_writer.h
@@ -11,15 +11,17 @@
 #  error Board should be either ESP8266 or ESP832
 #endif
 
-#include "led_effects.h"
 #include "config.h"
+#include "util.h"
+#include "led_effects.h"
+
 namespace config {
-  extern uint16_t csv_interval;  // [s]
+  extern uint16_t csv_interval; // [s]
 }
 namespace csv_writer {
   extern String last_successful_write;
   void initialize();
-  void logIfTimeHasCome(const String &timeStamp, int16_t co2, float temp, float hum);
+  void logIfTimeHasCome(const String &timeStamp, const int16_t &co2, const float &temperature, const float &humidity);
   int getAvailableSpace();
   extern const String filename;
 }
diff --git a/led_effects.cpp b/ampel-firmware/led_effects.cpp
similarity index 75%
rename from led_effects.cpp
rename to ampel-firmware/led_effects.cpp
index 2b15d6fcf2c7325f5b35c4b14cd2073f35191a69..1410a1475ec6dbae0ef5912373555837636dedd9 100644
--- a/led_effects.cpp
+++ b/ampel-firmware/led_effects.cpp
@@ -11,34 +11,29 @@ namespace config {
 /*****************************************************************
  * Configuration  (calculated from above values)                 *
  *****************************************************************/
-namespace config  //TODO: Use a class instead. NightMode could then be another state.
+namespace config //NOTE: Use a class instead? NightMode could then be another state.
 {
-  const float average_brightness = 0.5 * (config::max_brightness + config::min_brightness);
-  const float brightness_amplitude = 0.5 * (config::max_brightness - config::min_brightness);
+  const uint8_t brightness_amplitude = config::max_brightness - config::min_brightness;
   bool night_mode = false;
 }
 
-// Adafruit NeoPixel (Arduino library for controlling single-wire-based LED pixels and strip)
-// https://github.com/adafruit/Adafruit_NeoPixel
-// Documentation : http://adafruit.github.io/Adafruit_NeoPixel/html/class_adafruit___neo_pixel.html
-
-// NeoPixels on GPIO05, aka D1 on ESP8266 or 5 on ESP32.
+#if defined(ESP8266)
+// NeoPixels on GPIO05, aka D1 on ESP8266.
 const int NEOPIXELS_PIN = 5;
+#elif defined(ESP32)
+// NeoPixels on GPIO23 on ESP32. To avoid conflict with LoRa_SCK on TTGO.
+const int NEOPIXELS_PIN = 23;
+#endif
+
 const int NUMPIXELS = 12;
 //NOTE: One value has been prepended, to make calculations easier and avoid out of bounds index.
 const uint16_t CO2_TICKS[NUMPIXELS + 1] = { 0, 500, 600, 700, 800, 900, 1000, 1200, 1400, 1600, 1800, 2000, 2200 }; // [ppm]
 // 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.
-const uint16_t LED_HUES[NUMPIXELS] = { 21845, 19114, 16383, 13653, 10922, 8191, 5461, 2730, 0, 0, 0, 0 }; // [hue angle]
+const uint16_t LED_HUES[NUMPIXELS] = { 21845U, 19114U, 16383U, 13653U, 10922U, 8191U, 5461U, 2730U, 0, 0, 0, 0 }; // [hue angle]
 Adafruit_NeoPixel pixels(NUMPIXELS, NEOPIXELS_PIN, NEO_GRB + NEO_KHZ800);
 
-namespace counter {
-  uint16_t wheel_offset = 0;
-  uint16_t kitt_offset = 0;
-  uint16_t breathing_offset = 0;
-}  // namespace counter
-
-namespace LedEffects {
+namespace led_effects {
   //On-board LED on D4, aka GPIO02
   const int ONBOARD_LED_PIN = 2;
 
@@ -47,25 +42,39 @@ namespace LedEffects {
   }
 
   void onBoardLEDOff() {
+    //NOTE: OFF is LOW on ESP32 and HIGH on ESP8266 :-/
+#ifdef ESP8266
     digitalWrite(ONBOARD_LED_PIN, HIGH);
+#else
+    digitalWrite(ONBOARD_LED_PIN, LOW);
+#endif
   }
 
   void onBoardLEDOn() {
+#ifdef ESP8266
     digitalWrite(ONBOARD_LED_PIN, LOW);
+#else
+    digitalWrite(ONBOARD_LED_PIN, HIGH);
+#endif
+  }
+
+  void LEDsOff() {
+    pixels.clear();
+    pixels.show();
+    onBoardLEDOff();
   }
 
   void setupRing() {
     pixels.begin();
     pixels.setBrightness(config::max_brightness);
-    pixels.clear();
+    LEDsOff();
   }
 
   void toggleNightMode() {
     config::night_mode = !config::night_mode;
     if (config::night_mode) {
       Serial.println(F("NIGHT MODE!"));
-      pixels.clear();
-      pixels.show();
+      LEDsOff();
     } else {
       Serial.println(F("DAY MODE!"));
     }
@@ -77,13 +86,14 @@ namespace LedEffects {
     if (config::night_mode) {
       return;
     }
+    static uint16_t kitt_offset = 0;
     pixels.clear();
     for (int j = config::kitt_tail; j >= 0; j--) {
-      int ledNumber = abs((counter::kitt_offset - j + NUMPIXELS) % (2 * NUMPIXELS) - NUMPIXELS) % NUMPIXELS; // Triangular function
+      int ledNumber = abs((kitt_offset - j + NUMPIXELS) % (2 * NUMPIXELS) - NUMPIXELS) % NUMPIXELS; // Triangular function
       pixels.setPixelColor(ledNumber, color * pixels.gamma8(255 - j * 76) / 255);
     }
     pixels.show();
-    counter::kitt_offset += 1;
+    kitt_offset++;
   }
 
   // Start K.I.T.T. led effect. Red color as default.
@@ -129,16 +139,17 @@ namespace LedEffects {
     pixels.show();
   }
 
-  void showRainbowWheel(int duration_s, uint16_t hue_increment) {
+  void showRainbowWheel(int duration_ms, uint16_t hue_increment) {
     if (config::night_mode) {
       return;
     }
-    unsigned long t0 = seconds();
+    static uint16_t wheel_offset = 0;
+    unsigned long t0 = millis();
     pixels.setBrightness(config::max_brightness);
-    while (seconds() < t0 + duration_s) {
+    while (millis() < t0 + duration_ms) {
       for (int i = 0; i < NUMPIXELS; i++) {
-        pixels.setPixelColor(i, pixels.ColorHSV(i * 65535 / NUMPIXELS + counter::wheel_offset));
-        counter::wheel_offset += hue_increment;
+        pixels.setPixelColor(i, pixels.ColorHSV(i * 65535 / NUMPIXELS + wheel_offset));
+        wheel_offset += hue_increment;
       }
       pixels.show();
       delay(10);
@@ -163,14 +174,14 @@ namespace LedEffects {
 
   void breathe(int16_t co2) {
     if (!config::night_mode) {
-      //TODO: use integer sine
-      pixels.setBrightness(
-          static_cast<int>(config::average_brightness
-              + cos(counter::breathing_offset * 0.1) * config::brightness_amplitude));
+      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();
-      counter::breathing_offset += 1;
+      breathing_offset += 3; // breathing speed. +3 looks like slow human breathing.
     }
-    delay(co2 > 1600 ? 50 : 100);  // faster breathing for higher CO2 values
+    delay(co2 > 1600 ? 50 : 100); // faster breathing for higher CO2 values
   }
 
   /**
@@ -179,7 +190,7 @@ namespace LedEffects {
    */
   int countdownToZero() {
     if (config::night_mode) {
-      Serial.println("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.
       return 1;
     }
diff --git a/led_effects.h b/ampel-firmware/led_effects.h
similarity index 65%
rename from led_effects.h
rename to ampel-firmware/led_effects.h
index b074f53e163c309947591654ed3d827e3175207d..03aee3dc24a02dc403b0e20105c3013f678488e5 100644
--- a/led_effects.h
+++ b/ampel-firmware/led_effects.h
@@ -1,8 +1,11 @@
 #ifndef LED_EFFECTS_H_INCLUDED
 #define LED_EFFECTS_H_INCLUDED
 #include <Arduino.h>
-#include "util.h"
 #include "config.h"
+
+// Adafruit NeoPixel (Arduino library for controlling single-wire-based LED pixels and strip)
+// https://github.com/adafruit/Adafruit_NeoPixel
+// Documentation : http://adafruit.github.io/Adafruit_NeoPixel/html/class_adafruit___neo_pixel.html
 #include "src/lib/Adafruit_NeoPixel/Adafruit_NeoPixel.h"
 
 namespace color {
@@ -13,11 +16,12 @@ namespace color {
   const uint32_t magenta = 0xFF00FF;
 }
 
-namespace LedEffects {
+namespace led_effects {
   void setupOnBoardLED();
   void onBoardLEDOff();
   void onBoardLEDOn();
   void toggleNightMode();
+  void LEDsOff();
 
   void setupRing();
   void redAlert();
@@ -25,7 +29,7 @@ namespace LedEffects {
   int countdownToZero();
   void showWaitingLED(uint32_t color);
   void showKITTWheel(uint32_t color, uint16_t duration_s = 2);
-  void showRainbowWheel(int duration_s = 1, uint16_t hue_increment = 50);
+  void showRainbowWheel(int duration_ms = 1000, uint16_t hue_increment = 50);
   void displayCO2color(uint16_t co2);
 }
 #endif
diff --git a/ampel-firmware/lorawan.cpp b/ampel-firmware/lorawan.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..75363a0337ce887c860d38fb7c5a940bed3e642f
--- /dev/null
+++ b/ampel-firmware/lorawan.cpp
@@ -0,0 +1,197 @@
+#include "lorawan.h"
+#if defined(AMPEL_LORAWAN) && defined(ESP32)
+
+namespace config {
+  // Values should be defined in config.h
+  uint16_t lorawan_sending_interval = LORAWAN_SENDING_INTERVAL; // [s]
+
+  static const u1_t PROGMEM APPEUI[8] = LORAWAN_APPLICATION_EUI;
+  static const u1_t PROGMEM DEVEUI[8] = LORAWAN_DEVICE_EUI;
+  static const u1_t PROGMEM APPKEY[16] = LORAWAN_APPLICATION_KEY;
+}
+
+// Payloads will be automatically sent via MQTT by TheThingsNetwork, and can be seen with:
+//   mosquitto_sub -h eu.thethings.network -t '+/devices/+/up' -u 'APPLICATION-NAME' -P 'ttn-account-v2.4xxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxx' -v
+//      or encrypted:
+//   mosquitto_sub -h eu.thethings.network -t '+/devices/+/up' -u 'APPLICATION-NAME' -P 'ttn-account-v2.4xxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxx' -v --cafile mqtt-ca.pem -p 8883
+// ->
+//   co2ampel-test/devices/esp3a7c94/up {"app_id":"co2ampel-test","dev_id":"esp3a7c94","hardware_serial":"00xxxxxxxx","port":1,"counter":5,"payload_raw":"TJd7","payload_fields":{"co2":760,"rh":61.5,"temp":20.2},"metadata":{"time":"2020-12-23T23:00:51.44020438Z","frequency":867.5,"modulation":"LORA","data_rate":"SF7BW125","airtime":51456000,"coding_rate":"4/5","gateways":[{"gtw_id":"eui-xxxxxxxxxxxxxxxxxx","timestamp":1765406908,"time":"2020-12-23T23:00:51.402519Z","channel":5,"rssi":-64,"snr":7.5,"rf_chain":0,"latitude":22.7,"longitude":114.24,"altitude":450}]}}
+// More info : https://www.thethingsnetwork.org/docs/applications/mqtt/quick-start.html
+
+void os_getArtEui(u1_t *buf) {
+  memcpy_P(buf, config::APPEUI, 8);
+}
+
+void os_getDevEui(u1_t *buf) {
+  memcpy_P(buf, config::DEVEUI, 8);
+}
+
+void os_getDevKey(u1_t *buf) {
+  memcpy_P(buf, config::APPKEY, 16);
+}
+
+namespace lorawan {
+  bool waiting_for_confirmation = false;
+  bool connected = false;
+  String last_transmission = "";
+
+  void initialize() {
+    Serial.println(F("Starting LoRaWAN. Frequency plan : " LMIC_FREQUENCY_PLAN " MHz."));
+
+    // More info about pin mapping : https://github.com/mcci-catena/arduino-lmic#pin-mapping
+    // Has been tested successfully with ESP32 TTGO LoRa32 V1, and might work with other ESP32+LoRa boards.
+    const lmic_pinmap *pPinMap = Arduino_LMIC::GetPinmap_ThisBoard();
+    // LMIC init.
+    os_init_ex(pPinMap);
+    // Reset the MAC state. Session and pending data transfers will be discarded.
+    LMIC_reset();
+    // Join, but don't send anything yet.
+    LMIC_startJoining();
+  }
+
+  // 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.
+  void process() {
+    os_runloop_once();
+  }
+
+  void printHex2(unsigned v) {
+    v &= 0xff;
+    if (v < 16)
+      Serial.print('0');
+    Serial.print(v, HEX);
+  }
+
+  void onEvent(ev_t ev) {
+    Serial.print("LoRa - ");
+    Serial.print(ntp::getLocalTime());
+    Serial.print(" - ");
+    switch (ev) {
+    case EV_JOINING:
+      Serial.println(F("EV_JOINING"));
+      break;
+    case EV_JOINED:
+      waiting_for_confirmation = false;
+      connected = true;
+      led_effects::onBoardLEDOff();
+      Serial.println(F("EV_JOINED"));
+      {
+        u4_t netid = 0;
+        devaddr_t devaddr = 0;
+        u1_t nwkKey[16];
+        u1_t artKey[16];
+        LMIC_getSessionKeys(&netid, &devaddr, nwkKey, artKey);
+        Serial.print(F("netid: "));
+        Serial.println(netid, DEC);
+        Serial.print(F("devaddr: "));
+        Serial.println(devaddr, HEX);
+        Serial.print(F("AppSKey: "));
+        for (size_t i = 0; i < sizeof(artKey); ++i) {
+          if (i != 0)
+            Serial.print("-");
+          printHex2(artKey[i]);
+        }
+        Serial.println("");
+        Serial.print("NwkSKey: ");
+        for (size_t i = 0; i < sizeof(nwkKey); ++i) {
+          if (i != 0)
+            Serial.print("-");
+          printHex2(nwkKey[i]);
+        }
+        Serial.println();
+      }
+      Serial.println(F("Other services may resume, and will not be frozen anymore."));
+      // Disable link check validation (automatically enabled during join)
+      LMIC_setLinkCheckMode(0);
+      break;
+    case EV_JOIN_FAILED:
+      Serial.println(F("EV_JOIN_FAILED"));
+      break;
+    case EV_REJOIN_FAILED:
+      Serial.println(F("EV_REJOIN_FAILED"));
+      break;
+    case EV_TXCOMPLETE:
+      last_transmission = ntp::getLocalTime();
+      Serial.println(F("EV_TXCOMPLETE"));
+      break;
+    case EV_TXSTART:
+      waiting_for_confirmation = !connected;
+      Serial.println(F("EV_TXSTART"));
+      break;
+    case EV_TXCANCELED:
+      waiting_for_confirmation = false;
+      led_effects::onBoardLEDOff();
+      Serial.println(F("EV_TXCANCELED"));
+      break;
+    case EV_JOIN_TXCOMPLETE:
+      waiting_for_confirmation = false;
+      led_effects::onBoardLEDOff();
+      Serial.println(F("EV_JOIN_TXCOMPLETE: no JoinAccept."));
+      Serial.println(F("Other services may resume."));
+      break;
+    default:
+      Serial.print(F("LoRa event: "));
+      Serial.println((unsigned) ev);
+      break;
+    }
+    if (waiting_for_confirmation) {
+      led_effects::onBoardLEDOn();
+      Serial.println(F("LoRa - waiting for OTAA confirmation. Freezing every other service!"));
+    }
+  }
+
+  void preparePayload(int16_t co2, float temperature, float humidity) {
+    // Check if there is not a current TX/RX job running
+    if (LMIC.opmode & OP_TXRXPEND) {
+      Serial.println(F("OP_TXRXPEND, not sending"));
+    } else {
+      uint8_t buff[3];
+      // Mapping CO2 from 0ppm to 5100ppm to [0, 255], with 20ppm increments.
+      buff[0] = (util::min(util::max(co2, 0), 5100) + 10) / 20;
+      // Mapping temperatures from [-10°C, 41°C] to [0, 255], with 0.2°C increment
+      buff[1] = static_cast<uint8_t>((util::min(util::max(temperature, -10), 41) + 10.1f) * 5);
+      // Mapping humidity from [0%, 100%] to [0, 200], with 0.5°C increment (0.4°C would also be possible)
+      buff[2] = static_cast<uint8_t>(util::min(util::max(humidity, 0) + 0.25f, 100) * 2);
+
+      Serial.print(F("LoRa - Payload : '"));
+      printHex2(buff[0]);
+      Serial.print(" ");
+      printHex2(buff[1]);
+      Serial.print(" ");
+      printHex2(buff[2]);
+      Serial.print(F("', "));
+      Serial.print(buff[0] * 20);
+      Serial.print(F(" ppm, "));
+      Serial.print(buff[1] * 0.2 - 10);
+      Serial.print(F(" °C, "));
+      Serial.print(buff[2] * 0.5);
+      Serial.println(F(" %."));
+
+      // Prepare upstream data transmission at the next possible time.
+      LMIC_setTxData2(1, buff, sizeof(buff), 0);
+      //NOTE: To decode in TheThingsNetwork:
+      //function Decoder(bytes, port) {
+      //  return {
+      //      co2:  bytes[0] * 20,
+      //      temp: bytes[1] / 5.0 - 10,
+      //      rh:   bytes[2] / 2.0
+      //  };
+      //}
+    }
+  }
+
+  void preparePayloadIfTimeHasCome(const int16_t &co2, const float &temperature, const float &humidity) {
+    static unsigned long last_sent_at = 0;
+    unsigned long now = seconds();
+    if (connected && (now > last_sent_at + config::lorawan_sending_interval)) {
+      last_sent_at = now;
+      preparePayload(co2, temperature, humidity);
+    }
+  }
+}
+
+void onEvent(ev_t ev) {
+  lorawan::onEvent(ev);
+}
+#endif
diff --git a/ampel-firmware/lorawan.h b/ampel-firmware/lorawan.h
new file mode 100644
index 0000000000000000000000000000000000000000..a4e29ab2c89019202aceddb920c26f49978b085b
--- /dev/null
+++ b/ampel-firmware/lorawan.h
@@ -0,0 +1,49 @@
+#ifndef AMPEL_LORAWAN_H_
+#define AMPEL_LORAWAN_H_
+
+#include "config.h"
+
+#if defined(AMPEL_LORAWAN) && defined(ESP32)
+#include <Arduino.h>
+// Requires "MCCI LoRaWAN LMIC library", which will be automatically used with PlatformIO but should be added in "Arduino IDE".
+// Tested successfully with v3.2.0 and connected to a thethingsnetwork.org app.
+#include <lmic.h>
+#include <hal/hal.h>
+#include <arduino_lmic_hal_boards.h>
+#include <SPI.h>
+
+#include "led_effects.h"
+
+#include "util.h"
+
+namespace config {
+  extern uint16_t lorawan_sending_interval; // [s]
+}
+
+#if defined(CFG_eu868)
+#  define LMIC_FREQUENCY_PLAN "Europe 868"
+#elif defined(CFG_us915)
+#  define LMIC_FREQUENCY_PLAN "US 915"
+#elif defined(CFG_au915)
+#  define LMIC_FREQUENCY_PLAN "Australia 915"
+#elif defined(CFG_as923)
+#  define LMIC_FREQUENCY_PLAN "Asia 923"
+#elif defined(CFG_kr920)
+#  define LMIC_FREQUENCY_PLAN "Korea 920"
+#elif defined(CFG_in866)
+#  define LMIC_FREQUENCY_PLAN "India 866"
+#else
+#  error "Region should be specified"
+#endif
+
+namespace lorawan {
+  extern bool waiting_for_confirmation;
+  extern bool connected;
+  extern String last_transmission;
+  void initialize();
+  void process();
+  void preparePayloadIfTimeHasCome(const int16_t &co2, const float &temp, const float &hum);
+}
+
+#endif
+#endif
diff --git a/mqtt.cpp b/ampel-firmware/mqtt.cpp
similarity index 87%
rename from mqtt.cpp
rename to ampel-firmware/mqtt.cpp
index 8ae62759c24532713495e24bc6f5151262eac5db..29f609fdf0751bbcebcc0b501c08e12d7212b6a6 100644
--- a/mqtt.cpp
+++ b/ampel-firmware/mqtt.cpp
@@ -2,7 +2,7 @@
 
 namespace config {
   // Values should be defined in config.h
-  uint16_t sending_interval = SENDING_INTERVAL; // [s]
+  uint16_t 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;
@@ -22,6 +22,7 @@ PubSubClient mqttClient(espClient);
 namespace mqtt {
   unsigned long last_sent_at = 0;
   unsigned long last_failed_at = 0;
+  bool connected = false;
 
   String publish_topic;
   const char *json_sensor_format;
@@ -39,19 +40,19 @@ namespace mqtt {
 
   void publish(const String &timestamp, int16_t co2, float temperature, float humidity) {
     if (WiFi.status() == WL_CONNECTED && mqttClient.connected()) {
-      LedEffects::onBoardLEDOn();
+      led_effects::onBoardLEDOn();
       Serial.print(F("Publishing MQTT message ... "));
 
       char payload[75]; // Should be enough for json...
       snprintf(payload, sizeof(payload), json_sensor_format, timestamp.c_str(), co2, temperature, humidity);
       // Topic is the same as clientID. e.g. 'CO2sensors/ESP3d03da'
       if (mqttClient.publish(publish_topic.c_str(), payload)) {
-        Serial.println("OK");
+        Serial.println(F("OK"));
         last_successful_publish = ntp::getLocalTime();
       } else {
-        Serial.println("Failed.");
+        Serial.println(F("Failed."));
       }
-      LedEffects::onBoardLEDOff();
+      led_effects::onBoardLEDOff();
     }
   }
 
@@ -64,7 +65,7 @@ namespace mqtt {
       Serial.println("s.");
       sensor::scd30.setMeasurementInterval(messageString.toInt());
       config::measurement_timestep = messageString.toInt();
-      LedEffects::showKITTWheel(color::green, 1);
+      led_effects::showKITTWheel(color::green, 1);
     }
   }
 
@@ -74,19 +75,21 @@ namespace mqtt {
     Serial.print(F("Setting Sending Interval to : "));
     Serial.print(config::sending_interval);
     Serial.println("s.");
-    LedEffects::showKITTWheel(color::green, 1);
+    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.");
-    LedEffects::showKITTWheel(color::green, 1);
+    led_effects::showKITTWheel(color::green, 1);
   }
+#endif
 
-  void calibrateSensor(String messageString) {
+  void calibrateSensorToSpecificPPM(String messageString) {
     messageString.replace("calibrate ", "");
     long int calibrationLevel = messageString.toInt();
     if (calibrationLevel >= 400 && calibrationLevel <= 2000) {
@@ -106,7 +109,7 @@ namespace mqtt {
   }
 
   void sendInfoAboutLocalNetwork() {
-    char info_topic[60]; // Should be enough for "CO2sensors/ESPd05cc9/info"
+    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...
@@ -128,7 +131,7 @@ namespace mqtt {
     if (length == 0) {
       return;
     }
-    LedEffects::onBoardLEDOn();
+    led_effects::onBoardLEDOn();
     Serial.print(F("Message arrived on topic: "));
     Serial.print(sub_topic);
     Serial.print(F(". Message: '"));
@@ -143,32 +146,34 @@ namespace mqtt {
       setCO2forDebugging(messageString);
     } else if (messageString.startsWith("timer ")) {
       setTimer(messageString);
+    } else if (messageString == "calibrate") {
+      sensor::startCalibrationProcess();
     } else if (messageString.startsWith("calibrate ")) {
-      calibrateSensor(messageString);
-//      config::atmospheric_co2_concentration
+      calibrateSensorToSpecificPPM(messageString);
     } else if (messageString.startsWith("mqtt ")) {
       setMQTTinterval(messageString);
-    } else if (messageString.startsWith("csv ")) {
-      setCSVinterval(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();
-      LedEffects::showKITTWheel(color::blue, 2);
+      led_effects::showKITTWheel(color::blue, 2);
+#endif
     } else if (messageString == "night_mode") {
-      LedEffects::toggleNightMode();
+      led_effects::toggleNightMode();
     } else if (messageString == "local_ip") {
       sendInfoAboutLocalNetwork();
     } else if (messageString == "reset") {
-      FS_LIB.end();
-      ESP.restart();
+      ESP.restart(); // softer than ESP.reset()
     } else {
-      LedEffects::showKITTWheel(color::red, 1);
+      led_effects::showKITTWheel(color::red, 1);
       Serial.println(F("Message not supported. Doing nothing."));
     }
     delay(50);
-    LedEffects::onBoardLEDOff();
+    led_effects::onBoardLEDOff();
   }
 
   void reconnect() {
@@ -182,15 +187,16 @@ namespace mqtt {
     }
     Serial.print(F("Attempting MQTT connection..."));
 
-    LedEffects::onBoardLEDOn();
+    led_effects::onBoardLEDOn();
     // Wait for connection, at most 15s (default)
     mqttClient.connect(publish_topic.c_str(), config::mqtt_user, config::mqtt_password);
-    LedEffects::onBoardLEDOff();
+    led_effects::onBoardLEDOff();
+
+    connected = mqttClient.connected();
 
-    if (mqttClient.connected()) {
-      //TODO: Send local IP?
+    if (connected) {
       if (config::allow_mqtt_commands) {
-        char control_topic[60]; // Should be enough for "CO2sensors/ESPd05cc9/control"
+        char control_topic[60]; // Should be enough for "CO2sensors/ESPd03cc5/control"
         snprintf(control_topic, sizeof(control_topic), "%s/control", publish_topic.c_str());
         mqttClient.subscribe(control_topic);
         mqttClient.setCallback(controlSensorCallback);
@@ -207,7 +213,7 @@ namespace mqtt {
     }
   }
 
-  void publishIfTimeHasCome(const String &timeStamp, int16_t co2, float temp, float hum) {
+  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();
     //TODO: Send average since last MQTT message?
diff --git a/mqtt.h b/ampel-firmware/mqtt.h
similarity index 61%
rename from mqtt.h
rename to ampel-firmware/mqtt.h
index 6e43644336620607188b34b1bccfe650141eb546..2899a9996bbf8d2a484f5fc79b619d9647ee70a7 100644
--- a/mqtt.h
+++ b/ampel-firmware/mqtt.h
@@ -4,17 +4,20 @@
 #include <Arduino.h>
 #include "config.h"
 #include "led_effects.h"
-#include "csv_writer.h"
+#ifdef AMPEL_CSV
+#  include "csv_writer.h"
+#endif
 #include "co2_sensor.h"
 #include "src/lib/PubSubClient/src/PubSubClient.h"
 #include "wifi_util.h"
 namespace config {
-  extern uint16_t sending_interval;  // [s]
+  extern uint16_t sending_interval; // [s]
 }
 namespace mqtt {
   extern String last_successful_publish;
+  extern bool connected;
   void initialize(String &topic);
   void keepConnection();
-  void publishIfTimeHasCome(const String &timeStamp, int16_t co2, float temp, float hum);
+  void publishIfTimeHasCome(const String &timeStamp, const int16_t &co2, const float &temp, const float &hum);
 }
 #endif
diff --git a/src/lib/Adafruit_NeoPixel/Adafruit_NeoPixel.cpp b/ampel-firmware/src/lib/Adafruit_NeoPixel/Adafruit_NeoPixel.cpp
similarity index 100%
rename from src/lib/Adafruit_NeoPixel/Adafruit_NeoPixel.cpp
rename to ampel-firmware/src/lib/Adafruit_NeoPixel/Adafruit_NeoPixel.cpp
diff --git a/src/lib/Adafruit_NeoPixel/Adafruit_NeoPixel.h b/ampel-firmware/src/lib/Adafruit_NeoPixel/Adafruit_NeoPixel.h
similarity index 100%
rename from src/lib/Adafruit_NeoPixel/Adafruit_NeoPixel.h
rename to ampel-firmware/src/lib/Adafruit_NeoPixel/Adafruit_NeoPixel.h
diff --git a/src/lib/Adafruit_NeoPixel/CONTRIBUTING.md b/ampel-firmware/src/lib/Adafruit_NeoPixel/CONTRIBUTING.md
similarity index 100%
rename from src/lib/Adafruit_NeoPixel/CONTRIBUTING.md
rename to ampel-firmware/src/lib/Adafruit_NeoPixel/CONTRIBUTING.md
diff --git a/src/lib/Adafruit_NeoPixel/COPYING b/ampel-firmware/src/lib/Adafruit_NeoPixel/COPYING
similarity index 100%
rename from src/lib/Adafruit_NeoPixel/COPYING
rename to ampel-firmware/src/lib/Adafruit_NeoPixel/COPYING
diff --git a/src/lib/Adafruit_NeoPixel/README.md b/ampel-firmware/src/lib/Adafruit_NeoPixel/README.md
similarity index 100%
rename from src/lib/Adafruit_NeoPixel/README.md
rename to ampel-firmware/src/lib/Adafruit_NeoPixel/README.md
diff --git a/src/lib/Adafruit_NeoPixel/esp.c b/ampel-firmware/src/lib/Adafruit_NeoPixel/esp.c
similarity index 100%
rename from src/lib/Adafruit_NeoPixel/esp.c
rename to ampel-firmware/src/lib/Adafruit_NeoPixel/esp.c
diff --git a/src/lib/Adafruit_NeoPixel/esp8266.c b/ampel-firmware/src/lib/Adafruit_NeoPixel/esp8266.c
similarity index 100%
rename from src/lib/Adafruit_NeoPixel/esp8266.c
rename to ampel-firmware/src/lib/Adafruit_NeoPixel/esp8266.c
diff --git a/src/lib/Adafruit_NeoPixel/kendyte_k210.c b/ampel-firmware/src/lib/Adafruit_NeoPixel/kendyte_k210.c
similarity index 100%
rename from src/lib/Adafruit_NeoPixel/kendyte_k210.c
rename to ampel-firmware/src/lib/Adafruit_NeoPixel/kendyte_k210.c
diff --git a/src/lib/Adafruit_NeoPixel/keywords.txt b/ampel-firmware/src/lib/Adafruit_NeoPixel/keywords.txt
similarity index 100%
rename from src/lib/Adafruit_NeoPixel/keywords.txt
rename to ampel-firmware/src/lib/Adafruit_NeoPixel/keywords.txt
diff --git a/src/lib/Adafruit_NeoPixel/library.properties b/ampel-firmware/src/lib/Adafruit_NeoPixel/library.properties
similarity index 100%
rename from src/lib/Adafruit_NeoPixel/library.properties
rename to ampel-firmware/src/lib/Adafruit_NeoPixel/library.properties
diff --git a/src/lib/NTPClient-master/.travis.yml b/ampel-firmware/src/lib/NTPClient-master/.travis.yml
similarity index 100%
rename from src/lib/NTPClient-master/.travis.yml
rename to ampel-firmware/src/lib/NTPClient-master/.travis.yml
diff --git a/src/lib/NTPClient-master/CHANGELOG b/ampel-firmware/src/lib/NTPClient-master/CHANGELOG
similarity index 100%
rename from src/lib/NTPClient-master/CHANGELOG
rename to ampel-firmware/src/lib/NTPClient-master/CHANGELOG
diff --git a/src/lib/NTPClient-master/NTPClient.cpp b/ampel-firmware/src/lib/NTPClient-master/NTPClient.cpp
similarity index 100%
rename from src/lib/NTPClient-master/NTPClient.cpp
rename to ampel-firmware/src/lib/NTPClient-master/NTPClient.cpp
diff --git a/src/lib/NTPClient-master/NTPClient.h b/ampel-firmware/src/lib/NTPClient-master/NTPClient.h
similarity index 100%
rename from src/lib/NTPClient-master/NTPClient.h
rename to ampel-firmware/src/lib/NTPClient-master/NTPClient.h
diff --git a/src/lib/NTPClient-master/README.md b/ampel-firmware/src/lib/NTPClient-master/README.md
similarity index 100%
rename from src/lib/NTPClient-master/README.md
rename to ampel-firmware/src/lib/NTPClient-master/README.md
diff --git a/src/lib/NTPClient-master/keywords.txt b/ampel-firmware/src/lib/NTPClient-master/keywords.txt
similarity index 100%
rename from src/lib/NTPClient-master/keywords.txt
rename to ampel-firmware/src/lib/NTPClient-master/keywords.txt
diff --git a/src/lib/NTPClient-master/library.json b/ampel-firmware/src/lib/NTPClient-master/library.json
similarity index 100%
rename from src/lib/NTPClient-master/library.json
rename to ampel-firmware/src/lib/NTPClient-master/library.json
diff --git a/src/lib/NTPClient-master/library.properties b/ampel-firmware/src/lib/NTPClient-master/library.properties
similarity index 100%
rename from src/lib/NTPClient-master/library.properties
rename to ampel-firmware/src/lib/NTPClient-master/library.properties
diff --git a/src/lib/PubSubClient/CHANGES.txt b/ampel-firmware/src/lib/PubSubClient/CHANGES.txt
similarity index 100%
rename from src/lib/PubSubClient/CHANGES.txt
rename to ampel-firmware/src/lib/PubSubClient/CHANGES.txt
diff --git a/src/lib/PubSubClient/LICENSE.txt b/ampel-firmware/src/lib/PubSubClient/LICENSE.txt
similarity index 100%
rename from src/lib/PubSubClient/LICENSE.txt
rename to ampel-firmware/src/lib/PubSubClient/LICENSE.txt
diff --git a/src/lib/PubSubClient/README.md b/ampel-firmware/src/lib/PubSubClient/README.md
similarity index 100%
rename from src/lib/PubSubClient/README.md
rename to ampel-firmware/src/lib/PubSubClient/README.md
diff --git a/src/lib/PubSubClient/keywords.txt b/ampel-firmware/src/lib/PubSubClient/keywords.txt
similarity index 100%
rename from src/lib/PubSubClient/keywords.txt
rename to ampel-firmware/src/lib/PubSubClient/keywords.txt
diff --git a/src/lib/PubSubClient/library.json b/ampel-firmware/src/lib/PubSubClient/library.json
similarity index 100%
rename from src/lib/PubSubClient/library.json
rename to ampel-firmware/src/lib/PubSubClient/library.json
diff --git a/src/lib/PubSubClient/library.properties b/ampel-firmware/src/lib/PubSubClient/library.properties
similarity index 100%
rename from src/lib/PubSubClient/library.properties
rename to ampel-firmware/src/lib/PubSubClient/library.properties
diff --git a/src/lib/PubSubClient/src/PubSubClient.cpp b/ampel-firmware/src/lib/PubSubClient/src/PubSubClient.cpp
similarity index 100%
rename from src/lib/PubSubClient/src/PubSubClient.cpp
rename to ampel-firmware/src/lib/PubSubClient/src/PubSubClient.cpp
diff --git a/src/lib/PubSubClient/src/PubSubClient.h b/ampel-firmware/src/lib/PubSubClient/src/PubSubClient.h
similarity index 100%
rename from src/lib/PubSubClient/src/PubSubClient.h
rename to ampel-firmware/src/lib/PubSubClient/src/PubSubClient.h
diff --git a/src/lib/SparkFun_SCD30_Arduino_Library/LICENSE.md b/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/LICENSE.md
similarity index 100%
rename from src/lib/SparkFun_SCD30_Arduino_Library/LICENSE.md
rename to ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/LICENSE.md
diff --git a/src/lib/SparkFun_SCD30_Arduino_Library/README.md b/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/README.md
similarity index 100%
rename from src/lib/SparkFun_SCD30_Arduino_Library/README.md
rename to ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/README.md
diff --git a/src/lib/SparkFun_SCD30_Arduino_Library/documents/Sensirion_CO2_Sensors_SCD30_Preliminary-Datasheet.pdf b/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/documents/Sensirion_CO2_Sensors_SCD30_Preliminary-Datasheet.pdf
similarity index 100%
rename from src/lib/SparkFun_SCD30_Arduino_Library/documents/Sensirion_CO2_Sensors_SCD30_Preliminary-Datasheet.pdf
rename to ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/documents/Sensirion_CO2_Sensors_SCD30_Preliminary-Datasheet.pdf
diff --git a/src/lib/SparkFun_SCD30_Arduino_Library/keywords.txt b/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/keywords.txt
similarity index 100%
rename from src/lib/SparkFun_SCD30_Arduino_Library/keywords.txt
rename to ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/keywords.txt
diff --git a/src/lib/SparkFun_SCD30_Arduino_Library/library.properties b/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/library.properties
similarity index 100%
rename from src/lib/SparkFun_SCD30_Arduino_Library/library.properties
rename to ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/library.properties
diff --git a/src/lib/SparkFun_SCD30_Arduino_Library/src/SparkFun_SCD30_Arduino_Library.cpp b/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/src/SparkFun_SCD30_Arduino_Library.cpp
similarity index 100%
rename from src/lib/SparkFun_SCD30_Arduino_Library/src/SparkFun_SCD30_Arduino_Library.cpp
rename to ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/src/SparkFun_SCD30_Arduino_Library.cpp
diff --git a/src/lib/SparkFun_SCD30_Arduino_Library/src/SparkFun_SCD30_Arduino_Library.h b/ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/src/SparkFun_SCD30_Arduino_Library.h
similarity index 100%
rename from src/lib/SparkFun_SCD30_Arduino_Library/src/SparkFun_SCD30_Arduino_Library.h
rename to ampel-firmware/src/lib/SparkFun_SCD30_Arduino_Library/src/SparkFun_SCD30_Arduino_Library.h
diff --git a/util.cpp b/ampel-firmware/util.cpp
similarity index 100%
rename from util.cpp
rename to ampel-firmware/util.cpp
diff --git a/util.h b/ampel-firmware/util.h
similarity index 56%
rename from util.h
rename to ampel-firmware/util.h
index 140ff672a6093985a246d64c7c2f1195aee1a288..0ffe5eb4d35d2243512035c07e09c1d44464fea8 100644
--- a/util.h
+++ b/ampel-firmware/util.h
@@ -2,16 +2,17 @@
 #define AMPEL_UTIL_H_INCLUDED
 #include <Arduino.h>
 #include "config.h"
-#include "wifi_util.h" // To get MAC
 
-#include <WiFiUdp.h> //required for NTP
+#include <WiFiUdp.h> // required for NTP
 #include "src/lib/NTPClient-master/NTPClient.h" // NTP
 
 #if defined(ESP8266)
 #  define BOARD "ESP8266"
+#  include <ESP8266WiFi.h> // required to get MAC address
 #  define get_free_heap_size() system_get_free_heap_size()
 #elif defined(ESP32)
 #  define BOARD "ESP32"
+#  include <WiFi.h> // required to get MAC address
 #  define get_free_heap_size() esp_get_free_heap_size()
 #else
 #  define BOARD "Unknown"
@@ -23,6 +24,18 @@ namespace ntp {
   String getLocalTime();
 }
 
+namespace util {
+  template<typename Tpa, typename Tpb>
+  inline auto min(const Tpa &a, const Tpb &b) -> decltype(a < b ? a : b) {
+    return b < a ? b : a;
+  }
+
+  template<typename Tpa, typename Tpb>
+  inline auto max(const Tpa &a, const Tpb &b) -> decltype(b > a ? b : a) {
+    return b > a ? b : a;
+  }
+}
+
 #define seconds() (millis() / 1000UL)
 extern uint32_t max_loop_duration;
 const extern String SENSOR_ID;
diff --git a/web_server.cpp b/ampel-firmware/web_server.cpp
similarity index 57%
rename from web_server.cpp
rename to ampel-firmware/web_server.cpp
index 04e7bd802c200d0f1475ad95bc3fae7d3cb2547b..7501263d6abebc3c0849726ecf65eb1cce6f28c9 100644
--- a/web_server.cpp
+++ b/ampel-firmware/web_server.cpp
@@ -21,14 +21,16 @@ namespace web_server {
   const char *body_template;
   const char *script_template;
   void handleWebServerRoot();
-  void handleWebServerCSV();
   void handlePageNotFound();
+
+#ifdef AMPEL_CSV
   void handleDeleteCSV();
+  void handleWebServerCSV();
+#endif
 
 #if defined(ESP8266)
   ESP8266WebServer http(80); // Create a webserver object that listens for HTTP request on port 80
-#endif
-#if defined(ESP32)
+#elif defined(ESP32)
   WebServer http(80);
 #endif
 
@@ -41,9 +43,9 @@ namespace web_server {
         PSTR("<!doctype html><html lang=en>"
             "<head>\n"
             "<title>%d ppm - CO2 SENSOR - %s - %s</title>\n"
-            "<meta charset='UTF-8'>"
+            "<meta charset='UTF-8'>\n"
             // HfT Favicon
-            "<link rel='icon' type='image/png' sizes='16x16' href='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAHtJREFUOE9jvMnA+5+BAsBIFQMkl85h+P3kGcOb8jqwW+TPH2H4de0GA29UGNxtfx49YWCRk0HwHz5iuKegwwB2AS4DkA2F6VR6cAWsEQbgBqDY9vARw/ejJ+Au+LxsFcPz6BSwHpwGYPMCSS6gyAAKYhESiKMGjPgwAADopHVhn5ynEwAAAABJRU5ErkJggg=='/>"
+            "<link rel='icon' type='image/png' sizes='16x16' href='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAHtJREFUOE9jvMnA+5+BAsBIFQMkl85h+P3kGcOb8jqwW+TPH2H4de0GA29UGNxtfx49YWCRk0HwHz5iuKegwwB2AS4DkA2F6VR6cAWsEQbgBqDY9vARw/ejJ+Au+LxsFcPz6BSwHpwGYPMCSS6gyAAKYhESiKMGjPgwAADopHVhn5ynEwAAAABJRU5ErkJggg=='/>\n"
             // Responsive grid:
             "<link rel='stylesheet' href='https://unpkg.com/purecss@2.0.3/build/pure-min.css'>\n"
             "<link rel='stylesheet' href='https://unpkg.com/purecss@2.0.3/build/grids-responsive-min.css'>\n"
@@ -56,13 +58,21 @@ namespace web_server {
             "</head>\n"
             "<body>\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> sensor</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"
-            "<li class='pure-menu-item'><a href='#graph' class='pure-menu-link'>Graph</a></li>\n"
             "<li class='pure-menu-item'><a href='#table' class='pure-menu-link'>Info</a></li>\n"
+#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='#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"
-            "</ul></div></div>\n");
+#endif
+            "<li class='pure-menu-item' id='led'>&#11044;</li>\n" // LED
+            "</ul></div></div>\n"
+            "<script>\n"
+            // 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"
@@ -71,81 +81,102 @@ namespace web_server {
             "<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>Sensor</th><th>%s</th></tr>\n"
+            "<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"
             "<tr><td>Last measurement</td><td>%s</td></tr>\n"
             "<tr><td>Measurement timestep</td><td>%5d s</td></tr>\n"
-            "<tr><td>Last CSV write</td><td>%s</td></tr>\n"
-            "<tr><td>CSV timestep</td><td>%5d s</td></tr>\n"
-#ifdef MQTT
-            "<tr><td>Last MQTT publish</td><td>%s</td></tr>\n"
-            "<tr><td>MQTT publish timestep</td><td>%5d s</td></tr>\n"
+#ifdef AMPEL_CSV
+            "<tr><th colspan='2'>CSV</th></tr>\n"
+            "<tr><td>Last write</td><td>%s</td></tr>\n"
+            "<tr><td>Timestep</td><td>%5d s</td></tr>\n"
+            "<tr><td>Available drive space</td><td>%d kB</td></tr>\n"
+#endif
+#ifdef AMPEL_MQTT
+            "<tr><th colspan='2'>MQTT</th></tr>\n"
+            "<tr><td>Connected?</td><td>%s</td></tr>\n"
+            "<tr><td>Last publish</td><td>%s</td></tr>\n"
+            "<tr><td>Timestep</td><td>%5d s</td></tr>\n"
+#endif
+#if defined(AMPEL_LORAWAN) && defined(ESP32)
+            "<tr><th colspan='2'>LoRaWAN</th></tr>\n"
+            "<tr><td>Connected?</td><td>%s</td></tr>\n"
+            "<tr><td>Frequency</td><td>%s MHz</td></tr>\n"
+            "<tr><td>Last transmission</td><td>%s</td></tr>\n"
+            "<tr><td>Timestep</td><td>%5d s</td></tr>\n"
 #endif
-            "<tr><td>Temperature offset</td><td>%.1fK</td></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>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"
-            "<tr><td>Available drive space</td><td>%d kB</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>Uptime</td><td>%4d h %02d min %02d s</td></tr>\n"
             "</table>\n"
-            // CSV placeholder
             "<div id='log' class='pure-u-1 pure-u-md-1-2'></div>\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'/>"
             "</form>\n"
+#endif
             "</div>\n");
 
-    script_template = PSTR("<script>\n"
-        "document.body.style.cursor = 'default';\n"
-        "fetch('./%s',{credentials:'include'})\n"
-        // Get CSV, fill table and fill diagram
-        ".then(response=>response.text())\n"
-        ".then(csvText=>csvToTable(csvText))\n"
-        ".then(htmlTable=>addLogTableToPage(htmlTable))\n"
-        ".then(_=>Plotly.newPlot('graph',data,layout,{displaylogo:false}))\n"
-        ".catch(e=>console.error(e));\n"
-        "xs=[];\n"
-        "data=[{x:xs,y:[],type:'scatter',name:'CO<sub>2</sub>',line:{color:'#2ca02c'}},\n"
-        "{x:xs,y:[],type:'scatter',name:'Temperature',yaxis:'y2',line:{color:'#ff7f0e',dash:'dot'}},\n"
-        "{x:xs,y:[],type:'scatter',name:'Humidity',yaxis:'y3',line:{color:'#1f77b4',dash:'dot'}}];\n"
-        "layout={height:600,title:'%s',legend:{xanchor:'right',x:0.2,y:1.0},\n"
-        "xaxis:{domain:[0.0,0.85]},yaxis:{ticksuffix:'ppm',range:[0,2000],dtick:200},\n"
-        "yaxis2:{overlaying:'y',side:'right',ticksuffix:'°C',position:0.9,anchor:'free',range:[0,30],dtick:3},\n"
-        "yaxis3:{overlaying:'y',side:'right',ticksuffix:'%%',position:0.95,anchor:'free',range:[0,100],dtick:10}\n"
-        "};\n"
-        "function csvToTable(csvText) {\n"
-        "csvText=csvText.trim();\n"
-        "lines=csvText.split('\\n');\n"
-        "table=document.createElement('table');\n"
-        "table.className='pure-table-striped';\n"
-        "n=lines.length;\n"
-        "lines.forEach((line,i)=>{\n"
-        "fields=line.split(';');\n"
-        "xs.push(fields[0]);\n"
-        "data[0]['y'].push(fields[1]);\n"
-        "data[1]['y'].push(fields[2]);\n"
-        "data[2]['y'].push(fields[3]);\n"
-        "if(i>4 && i<n-12){if(i==5){fields=['...','...','...','...']}else{return;}}\n"
-        "row=document.createElement('tr');\n"
-        "fields.forEach((field,index)=>{\n"
-        "cell=document.createElement(i<2?'th':'td');\n"
-        "cell.appendChild(document.createTextNode(field));\n"
-        "row.appendChild(cell);});\n"
-        "table.appendChild(row);});\n"
-        "return table;}\n"
-        "function addLogTableToPage(table){document.getElementById('log').appendChild(table);}\n"
-        "</script>\n"
-        "</body>\n"
-        "</html>");
+    script_template =
+        PSTR(
+            "<a href='https://transfer.hft-stuttgart.de/gitlab/co2ampel/ampel-firmware' target='_blank'>Source code</a>\n"
+                "<a href='https://transfer.hft-stuttgart.de/gitlab/co2ampel/ampel-documentation' target='_blank'>Documentation</a>\n"
+#ifdef AMPEL_CSV
+            "<script>\n"
+            "document.body.style.cursor = 'default';\n"
+            "fetch('./%s',{credentials:'include'})\n"
+            ".then(response=>response.text())\n"
+            ".then(csvText=>csvToTable(csvText))\n"
+            ".then(htmlTable=>addLogTableToPage(htmlTable))\n"
+            ".then(_=>Plotly.newPlot('graph',data,layout,{displaylogo:false}))\n"
+            ".catch(e=>console.error(e));\n"
+            "xs=[];\n"
+            "data=[{x:xs,y:[],type:'scatter',name:'CO<sub>2</sub>',line:{color:'#2ca02c'}},\n"
+            "{x:xs,y:[],type:'scatter',name:'Temperature',yaxis:'y2',line:{color:'#ff7f0e',dash:'dot'}},\n"
+            "{x:xs,y:[],type:'scatter',name:'Humidity',yaxis:'y3',line:{color:'#1f77b4',dash:'dot'}}];\n"
+            "layout={height:600,title:'%s',legend:{xanchor:'right',x:0.2,y:1.0},\n"
+            "xaxis:{domain:[0.0,0.85]},yaxis:{ticksuffix:'ppm',range:[0,2000],dtick:200},\n"
+            "yaxis2:{overlaying:'y',side:'right',ticksuffix:'°C',position:0.9,anchor:'free',range:[0,30],dtick:3},\n"
+            "yaxis3:{overlaying:'y',side:'right',ticksuffix:'%%',position:0.95,anchor:'free',range:[0,100],dtick:10}\n"
+            "};\n"
+            "function csvToTable(csvText) {\n"
+            "csvText=csvText.trim();\n"
+            "lines=csvText.split('\\n');\n"
+            "table=document.createElement('table');\n"
+            "table.className='pure-table-striped';\n"
+            "n=lines.length;\n"
+            "lines.forEach((line,i)=>{\n"
+            "fields=line.split(';');\n"
+            "xs.push(fields[0]);\n"
+            "data[0]['y'].push(fields[1]);\n"
+            "data[1]['y'].push(fields[2]);\n"
+            "data[2]['y'].push(fields[3]);\n"
+            "if(i>4 && i<n-12){if(i==5){fields=['...','...','...','...']}else{return;}}\n"
+            "row=document.createElement('tr');\n"
+            "fields.forEach((field,index)=>{\n"
+            "cell=document.createElement(i<2?'th':'td');\n"
+            "cell.appendChild(document.createTextNode(field));\n"
+            "row.appendChild(cell);});\n"
+            "table.appendChild(row);});\n"
+            "return table;}\n"
+            "function addLogTableToPage(table){document.getElementById('log').appendChild(table);}\n"
+            "</script>\n"
+#endif
+            "</body>\n"
+            "</html>");
 
     // Web-server
     http.on("/", handleWebServerRoot);
+#ifdef AMPEL_CSV
     http.on("/" + csv_writer::filename, handleWebServerCSV);
     http.on("/delete_csv", HTTP_POST, handleDeleteCSV);
+#endif
     http.onNotFound(handlePageNotFound);
     http.begin();
 
@@ -171,14 +202,18 @@ namespace web_server {
     ss -= hh * 3600;
     uint8_t mm = ss / 60;
     ss -= mm * 60;
-    uint16_t available_fs_space = csv_writer::getAvailableSpace() / 1024;
 
     //NOTE: Splitting in multiple parts in order to use less RAM
     char content[2000]; // Update if needed
-    // Header size : 1383 - Body size : 1246 - Script size : 1648
+    // Header size : 1611 - Body size : 1800 - Script size : 1920
+
     // Header
     snprintf_P(content, sizeof(content), header_template, sensor::co2, SENSOR_ID.c_str(),
-        WiFi.localIP().toString().c_str(), csv_writer::filename.c_str());
+        WiFi.localIP().toString().c_str()
+#ifdef AMPEL_CSV
+        , csv_writer::filename.c_str()
+#endif
+        );
 
     http.setContentLength(CONTENT_LENGTH_UNKNOWN);
     http.send_P(200, PSTR("text/html"), content);
@@ -186,22 +221,32 @@ namespace web_server {
     // Body
     snprintf_P(content, sizeof(content), body_template, SENSOR_ID.c_str(), sensor::co2, sensor::temperature,
         sensor::humidity, sensor::timestamp.c_str(), config::measurement_timestep,
-        csv_writer::last_successful_write.c_str(), config::csv_interval,
-#ifdef MQTT
-        mqtt::last_successful_publish.c_str(), config::sending_interval,
+#ifdef AMPEL_CSV
+        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,
+#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(), available_fs_space, max_loop_duration, BOARD, hh, mm,
-        ss);
+        WiFi.localIP().toString().c_str(), get_free_heap_size(), max_loop_duration, BOARD, hh, mm, ss);
 
     http.sendContent(content);
 
     // Script
-    snprintf_P(content, sizeof(content), script_template, csv_writer::filename.c_str(), SENSOR_ID.c_str());
+    snprintf_P(content, sizeof(content), script_template
+#ifdef AMPEL_CSV
+        , csv_writer::filename.c_str(), SENSOR_ID.c_str()
+#endif
+        );
 
     http.sendContent(content);
   }
 
+#ifdef AMPEL_CSV
   void handleWebServerCSV() {
     if (!shouldBeAllowed()) {
       return http.requestAuthentication(DIGEST_AUTH);
@@ -226,6 +271,7 @@ namespace web_server {
     http.sendHeader("Location", "/");
     http.send(303);
   }
+#endif
 
   void handlePageNotFound() {
     http.send(404, F("text/plain"), F("404: Not found"));
diff --git a/web_server.h b/ampel-firmware/web_server.h
similarity index 69%
rename from web_server.h
rename to ampel-firmware/web_server.h
index 69a298138416e50af41babca4bee8767a2eed94a..71c17f0578ac1616f464d0456f25afcfa5930c72 100644
--- a/web_server.h
+++ b/ampel-firmware/web_server.h
@@ -2,18 +2,22 @@
 #define WEB_SERVER_H_
 #if defined(ESP8266)
 #  include <ESP8266WebServer.h>
-#endif
-#if defined(ESP32)
+#elif defined(ESP32)
 #  include <WebServer.h>
 #endif
 
 #include "config.h"
 #include "util.h"
 #include "co2_sensor.h"
-#include "csv_writer.h"
-#ifdef MQTT
+#ifdef AMPEL_CSV
+#  include "csv_writer.h"
+#endif
+#ifdef AMPEL_MQTT
 #  include "mqtt.h"
 #endif
+#ifdef AMPEL_LORAWAN
+#  include "lorawan.h"
+#endif
 
 namespace web_server {
   void initialize();
diff --git a/wifi_util.cpp b/ampel-firmware/wifi_util.cpp
similarity index 64%
rename from wifi_util.cpp
rename to ampel-firmware/wifi_util.cpp
index 5399143d4cef07977f4e3115776d685a1fcb126e..60fd8fa691c98a0db45c33aff73caff0194cf496 100644
--- a/wifi_util.cpp
+++ b/ampel-firmware/wifi_util.cpp
@@ -2,16 +2,11 @@
 
 namespace config {
   // WiFi config. See 'config.h' if you want to modify those values.
-#ifdef WIFI_SSID
   const char *wifi_ssid = WIFI_SSID;
   const char *wifi_password = WIFI_PASSWORD;
-#else
-  const char *wifi_ssid = "NO_WIFI";
-  const char *wifi_password = "";
-#endif
 
 #ifdef WIFI_TIMEOUT
-  const uint8_t wifi_timeout = WIFI_TIMEOUT;  // [s] Will try to connect during wifi_timeout seconds before failing.
+  const uint8_t wifi_timeout = WIFI_TIMEOUT; // [s] Will try to connect during wifi_timeout seconds before failing.
 #else
   const uint8_t wifi_timeout = 60;  // [s] Will try to connect during wifi_timeout seconds before failing.
 #endif
@@ -20,18 +15,11 @@ namespace config {
 // Initialize Wi-Fi
 void WiFiConnect(const String &hostname) {
   //NOTE: WiFi Multi could allow multiple SSID and passwords.
-  if (strcmp(config::wifi_ssid, "NO_WIFI") == 0) {
-    Serial.println("Please change WIFI_SSID in config.h if you want to connect.");
-    WiFi.disconnect(true);
-    WiFi.mode(WIFI_OFF);
-    return;
-  }
   WiFi.persistent(false); // Don't write user & password to Flash.
-  WiFi.mode(WIFI_STA);  // Set ESP8266 to be a WiFi-client only
+  WiFi.mode(WIFI_STA); // Set ESP to be a WiFi-client only
 #if defined(ESP8266)
     WiFi.hostname(hostname);
-#endif
-#if defined(ESP32)
+#elif defined(ESP32)
   WiFi.setHostname(hostname.c_str());
 #endif
 
@@ -41,16 +29,17 @@ void WiFiConnect(const String &hostname) {
 
   // Wait for connection, at most wifi_timeout seconds
   for (int i = 0; i <= config::wifi_timeout && (WiFi.status() != WL_CONNECTED); i++) {
-    LedEffects::showRainbowWheel();
+    led_effects::showRainbowWheel();
     Serial.print(".");
   }
   if (WiFi.status() == WL_CONNECTED) {
-    LedEffects::showKITTWheel(color::green);
+    led_effects::showKITTWheel(color::green);
     Serial.println();
     Serial.print("\nWiFi connected, IP address: ");
     Serial.println(WiFi.localIP());
   } else {
-    LedEffects::showKITTWheel(color::red);
+    //TODO: Allow sensor to work as an Access Point, in order to define SSID & password?
+    led_effects::showKITTWheel(color::red);
     Serial.println("\nConnection to WiFi failed");
   }
 }
diff --git a/ampel-firmware/wifi_util.h b/ampel-firmware/wifi_util.h
new file mode 100644
index 0000000000000000000000000000000000000000..7520ed78e070cf552500441f4828bb7d339392b8
--- /dev/null
+++ b/ampel-firmware/wifi_util.h
@@ -0,0 +1,10 @@
+#ifndef WIFI_UTIL_H_INCLUDED
+#define WIFI_UTIL_H_INCLUDED
+
+#include "config.h"
+#include "util.h"
+#include "led_effects.h"
+
+void WiFiConnect(const String &hostname);
+
+#endif
diff --git a/co2_sensor.cpp b/co2_sensor.cpp
deleted file mode 100644
index c6836b847a0941a0147a42b87b777fd0bcc0be22..0000000000000000000000000000000000000000
--- a/co2_sensor.cpp
+++ /dev/null
@@ -1,86 +0,0 @@
-#include "co2_sensor.h"
-
-namespace config {
-  // Values should be defined in config.h
-  uint16_t measurement_timestep = MEASUREMENT_TIMESTEP;  // [s] Value between 2 and 1800 (range for SCD30 sensor)
-  const uint16_t altitude_above_sea_level = ALTITUDE_ABOVE_SEA_LEVEL;  // [m]
-  uint16_t co2_calibration_level = ATMOSPHERIC_CO2_CONCENTRATION;  // [ppm]
-#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
-  const bool auto_calibrate_sensor = AUTO_CALIBRATE_SENSOR;  // [true / false]
-}
-
-namespace sensor {
-  SCD30 scd30;
-  int16_t co2 = 0;
-  float temperature = 0;
-  float humidity = 0;
-  String timestamp = "";
-
-  void initialize() {
-#if defined(ESP8266)
-    Wire.begin(12, 14);  // ESP8266 - D6, D5;
-#endif
-#if defined(ESP32)
-    Wire.begin(21, 22);  // ESP32
-    /**
-     *  SCD30   ESP32
-     *  VCC --- 3V3
-     *  GND --- GND
-     *  SCL --- SCL (GPIO22) //NOTE: GPIO3 Would be more convenient (right next to GND)
-     *  SDA --- SDA (GPIO21) //NOTE: GPIO1 would be more convenient (right next to GPO3)
-     */
-#endif
-
-    // CO2
-    if (scd30.begin(config::auto_calibrate_sensor) == false) {
-      Serial.println("Air sensor not detected. Please check wiring. Freezing...");
-      while (1) {
-        LedEffects::showWaitingLED(color::red);
-      }
-    }
-
-    // SCD30 has its own timer.
-    Serial.println("\nSetting SCD30 timestep to " + String(config::measurement_timestep) + " s.");
-    scd30.setMeasurementInterval(config::measurement_timestep);  // [s]
-
-    Serial.print("Setting temperature offset to -");
-    Serial.print(abs(config::temperature_offset));
-    Serial.println(" K.");
-    scd30.setTemperatureOffset(abs(config::temperature_offset)); // setTemperatureOffset only accepts positive numbers, but shifts the temperature down.
-
-    Serial.print("Temperature offset is : -");
-    Serial.print(scd30.getTemperatureOffset());
-    Serial.println(" K");
-
-    Serial.print("Auto-calibration is ");
-    Serial.println(config::auto_calibrate_sensor ? "ON." : "OFF.");
-  }
-
-// Force SCD30 calibration with countdown.
-  void startCalibrationProcess() {
-    /** From the sensor documentation:
-     * For best results, the sensor has to be run in a stable environment in continuous mode at
-     * a measurement rate of 2s for at least two minutes before applying the FRC command and sending the reference value.
-     */
-    Serial.println("Setting SCD30 timestep to 2s, prior to calibration.");
-    scd30.setMeasurementInterval(2);  // [s] The change will only take effect after next measurement.
-    LedEffects::showKITTWheel(color::blue, config::measurement_timestep);
-    Serial.println("Waiting 2 minutes.");
-    LedEffects::showKITTWheel(color::blue, 120);
-    Serial.print("Starting SCD30 calibration...");
-    scd30.setAltitudeCompensation(config::altitude_above_sea_level);
-    scd30.setForcedRecalibrationFactor(config::co2_calibration_level);
-    Serial.println(" Done!");
-    Serial.println("Sensor calibrated.");
-    Serial.println("Sensor will now restart.");
-    LedEffects::showKITTWheel(color::green, 5);
-    FS_LIB.end();
-    ESP.restart();
-  }
-}
diff --git a/platformio.ini b/platformio.ini
index 6893648209a3700095076fb9ff0c5eb55543f6c9..becf69fc263e19f956d1cf7a7ff4701dd7322247 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -8,7 +8,7 @@
 ; http://docs.platformio.org/page/projectconf.html
 
 [platformio]
-src_dir = ./
+src_dir = ampel-firmware
 
 [env:esp8266]
 platform = espressif8266
@@ -21,3 +21,11 @@ platform = espressif32
 board = ttgo-lora32-v1
 framework = arduino
 monitor_speed = 115200
+
+lib_deps =
+    MCCI LoRaWAN LMIC library
+
+build_flags =
+    -D ARDUINO_LMIC_PROJECT_CONFIG_H_SUPPRESS
+    -D CFG_eu868=1
+    -D CFG_sx1276_radio=1
diff --git a/wifi_util.h b/wifi_util.h
deleted file mode 100644
index d6303fbf0c50f7ff62d2c0f583767c40bc912eda..0000000000000000000000000000000000000000
--- a/wifi_util.h
+++ /dev/null
@@ -1,13 +0,0 @@
-#ifndef WIFI_UTIL_H_INCLUDED
-#  define WIFI_UTIL_H_INCLUDED
-#  if defined(ESP8266)
-#    include <ESP8266WiFi.h>
-#  elif defined(ESP32)
-#    include <WiFi.h>
-#  endif
-
-#include "led_effects.h"
-#include "config.h"
-void WiFiConnect(const String &hostname);
-
-#endif