diff --git a/ampel-firmware.h b/ampel-firmware.h
index 04190740b8ef6ac37cf342750d63db5207c03a17..530f686c1593a9add43d9687d9d7c5429bcc6218 100644
--- a/ampel-firmware.h
+++ b/ampel-firmware.h
@@ -3,6 +3,7 @@
 /*****************************************************************
  * Libraries                                                     *
  *****************************************************************/
+//TODO: check header dependencies, and simplify if possible.
 #include "config.h"
 #ifndef MEASUREMENT_TIMESTEP
 #  error Missing config.h file. Please copy config.example.h to config.h.
@@ -10,6 +11,9 @@
 #ifdef MQTT
 #  include "mqtt.h"
 #endif
+#ifdef LORAWAN
+#  include "lorawan.h"
+#endif
 
 #include "util.h"
 #include "wifi_util.h"
diff --git a/ampel-firmware.ino b/ampel-firmware.ino
index adcd5b98098670f300bddad1a4ac891f84bdc299..d068d825bd8957b99bef32319d7d5aa9f3e8ab2b 100644
--- a/ampel-firmware.ino
+++ b/ampel-firmware.ino
@@ -99,7 +99,12 @@ void setup() {
     mqtt::initialize("CO2sensors/" + SENSOR_ID);
 #endif
   }
+
   csv_writer::initialize();
+
+#if defined(LORAWAN) && defined(ESP32)
+  lorawan::initialize();
+#endif
 }
 
 /*****************************************************************
@@ -107,6 +112,15 @@ void setup() {
  *****************************************************************/
 
 void loop() {
+#if defined(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();
diff --git a/co2_sensor.cpp b/co2_sensor.cpp
index 14946075e267cf66a3fb48dc6865fcb571ee25b9..51d1997035763169aa6a23e738e4204cfcfdc78d 100644
--- a/co2_sensor.cpp
+++ b/co2_sensor.cpp
@@ -175,11 +175,17 @@ namespace sensor {
       }
 
       logToSerial();
+
+      //TODO: Move the 3 back to ampel-firmware.ino and remove headers from co2_sensor.h
       csv_writer::logIfTimeHasCome(timestamp, co2, temperature, humidity);
 
 #ifdef MQTT
       mqtt::publishIfTimeHasCome(timestamp, co2, temperature, humidity);
 #endif
+
+#if defined(LORAWAN) && defined(ESP32)
+      lorawan::preparePayloadIfTimehasCome();
+#endif
     }
 
     if (should_calibrate) {
diff --git a/co2_sensor.h b/co2_sensor.h
index 624caa138f2a849d0254782bed8c0a6b35ba0258..fc5c051e02d0f60d4c09abbb4ca3bf7b9bac63d9 100644
--- a/co2_sensor.h
+++ b/co2_sensor.h
@@ -13,6 +13,9 @@
 #ifdef MQTT
 #  include "mqtt.h"
 #endif
+#ifdef LORAWAN
+#  include "lorawan.h"
+#endif
 
 namespace config {
   extern uint16_t measurement_timestep; // [s] Value between 2 and 1800 (range for SCD30 sensor)
diff --git a/config.public.h b/config.public.h
index 8e30739d7e11708778701d709ba5c1415284c4ea..d60cfdb572c0635cfc3acf35c30eca68b3a29508 100644
--- a/config.public.h
+++ b/config.public.h
@@ -104,6 +104,31 @@
 #  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
+ */
+
+// 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
+
+// Should data be sent over LoRaWAN?
+// It has been tested with "TTGO ESP32 SX1276 LoRa 868" and will only work with an ESP32 + LoRa modem
+#  define LORAWAN
+// 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
+// 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/led_effects.cpp b/led_effects.cpp
index 3804ee1111cd21cd4e4c5a73b900dec772d046e6..e8523cbc0131310ecb7c200539f039edf5e7230d 100644
--- a/led_effects.cpp
+++ b/led_effects.cpp
@@ -43,11 +43,20 @@ 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() {
diff --git a/lorawan.cpp b/lorawan.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..596889b41d502a165fa1c23cbc423049b5db87bc
--- /dev/null
+++ b/lorawan.cpp
@@ -0,0 +1,198 @@
+#include "lorawan.h"
+#if 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 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;
+      LedEffects::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, but because slow data rates change max TX
+      // size, we don't use it in this example.
+      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;
+      LedEffects::onBoardLEDOff();
+      Serial.println(F("EV_TXCANCELED"));
+      break;
+    case EV_JOIN_TXCOMPLETE:
+      waiting_for_confirmation = false;
+      LedEffects::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) {
+      LedEffects::onBoardLEDOn();
+      Serial.println(F("LoRa - waiting for OTAA confirmation. Freezing every other service!"));
+    }
+  }
+
+  void preparePayload() {
+    // 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(sensor::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(sensor::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(sensor::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 initialize() {
+    Serial.println(F("Starting LoRaWAN."));
+    // 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 preparePayloadIfTimehasCome() {
+    static unsigned long last_sent_at = 0;
+    unsigned long now = seconds();
+    if (now - last_sent_at > config::lorawan_sending_interval) {
+      last_sent_at = now;
+      preparePayload();
+    }
+  }
+}
+
+void onEvent(ev_t ev) {
+  lorawan::onEvent(ev);
+}
+#endif
diff --git a/lorawan.h b/lorawan.h
new file mode 100644
index 0000000000000000000000000000000000000000..74e7917737be0a602987528679536f536143a514
--- /dev/null
+++ b/lorawan.h
@@ -0,0 +1,33 @@
+#ifndef AMPEL_LORAWAN_H_
+#define AMPEL_LORAWAN_H_
+#if 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>
+typedef uint8_t u1_t;
+
+#include "co2_sensor.h"
+#include "led_effects.h"
+#include "config.h"
+
+#include "util.h"
+
+namespace config {
+  extern uint16_t lorawan_sending_interval; // [s]
+}
+
+namespace lorawan {
+  extern bool waiting_for_confirmation;
+  extern bool connected;
+  extern String last_transmission;
+  void initialize();
+  void process();
+  void preparePayloadIfTimehasCome();
+}
+
+#endif
+#endif
diff --git a/mqtt.cpp b/mqtt.cpp
index ff8808b19277b8473738e9a087223180132a5ea2..21eeac94bac1b115316663c064c01001acf729c6 100644
--- a/mqtt.cpp
+++ b/mqtt.cpp
@@ -46,10 +46,10 @@ namespace mqtt {
       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();
     }
diff --git a/platformio.ini b/platformio.ini
index 6893648209a3700095076fb9ff0c5eb55543f6c9..37408192cce461fd4605ba150237ec318a72d209 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -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/util.h b/util.h
index 4e0b64eebd70fba2af14d65ad867d52501826d61..c1fc40a7b7c1e24c024469c5b6ffdecc6805cf5f 100644
--- a/util.h
+++ b/util.h
@@ -24,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/web_server.cpp
index 8b8025163a2de873bae58f04682a639b5d53423b..d2458f5da29f8929bb83764685a45a4369b5008a 100644
--- a/web_server.cpp
+++ b/web_server.cpp
@@ -87,6 +87,11 @@ namespace web_server {
 #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"
+#endif
+#if defined(LORAWAN) && defined(ESP32)
+            "<tr><td>Connected to LoRaWAN?</td><td>%s</td></tr>\n"
+            "<tr><td>Last LoRaWAN transmission</td><td>%s</td></tr>\n"
+            "<tr><td>LoRaWAN publish timestep</td><td>%5d s</td></tr>\n"
 #endif
             "<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"
@@ -196,6 +201,9 @@ namespace web_server {
         csv_writer::last_successful_write.c_str(), config::csv_interval,
 #ifdef MQTT
         mqtt::last_successful_publish.c_str(), config::sending_interval,
+#endif
+#if defined(LORAWAN) && defined(ESP32)
+        lorawan::connected ? "Yes" : "No", 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,
diff --git a/web_server.h b/web_server.h
index 124253d5ce07d5840f01908099e2c442b2e93fe1..1bf4d1c08e60ddcbc851d222b939308515dc2b03 100644
--- a/web_server.h
+++ b/web_server.h
@@ -13,6 +13,9 @@
 #ifdef MQTT
 #  include "mqtt.h"
 #endif
+#ifdef LORAWAN
+#  include "lorawan.h"
+#endif
 
 namespace web_server {
   void initialize();