ESP32-based temperature and humidity controller with web interface for monitoring and controlling environmental conditions. Runs on the Adafruit QT Py ESP32-S2 and supports a wide range of I2C sensors for temperature, humidity, pressure, CO2, air quality, light, and VOC monitoring.
- Real-time monitoring - Temperature and humidity readings from a wide range of I2C sensors
- Temperature control - PID-based temperature control with configurable target
- Web interface - Full control and monitoring from any device on your network
- MQTT integration - Publish sensor data and control commands via MQTT
- Remote syslog - Forward device logs to a remote syslog server (UDP, RFC 3164)
- Data logging - Historical sensor data for trend analysis
- Timers & Alarms - Schedule temperature control actions or notifications
- OTA updates - Update firmware over WiFi from GitHub releases
- Easy setup - Captive portal for WiFi configuration
- mDNS discovery - Access device at
klima-XXXXXX.local - Visual feedback - Status LED indicates device state (solid=on, blink=startup, flash=MQTT transmit)
Primary board: Adafruit QT Py ESP32-S2 (~320 KB internal SRAM + ~2 MB PSRAM)
Matching Housing: 3D-printable housing on MakerWorld
| Specification | Value |
|---|---|
| MCU | ESP32-S2 (single-core @ 240MHz) |
| Internal RAM | ~320 KB (task stacks, FreeRTOS, WiFi, mbedTLS, DMA — all internal-only) |
| PSRAM | ~2 MB on board (verified on-device as psram_size 2094735; available for non-real-time allocations) |
| Flash | 4MB |
| Sensors | Multiple I2C sensors (per-address assignment in settings) |
| I2C pins | SDA: GPIO 8, SCL: GPIO 9 |
| Optional display | Waveshare 1.54" e-paper (200×200, SSD1681) over SPI |
An optional e-paper display can show the current temperature and humidity. It is disabled by default; see E-Paper Display Wiring for the module to buy, the pin-by-pin wiring, and troubleshooting.
Klima-Control ships as a single ESP32-S2 board that talks to a configurable chain of I2C sensors, which lets the same firmware cover a range of deployments by changing only the housing and the sensor chain.
Mounted in a standard 55 mm wall box with an optional e-paper display, the device reads the room's current climate and drives a temperature setpoint that is published over MQTT and exposed in the web UI.
A bare module with a STEMMA QT / Qwiic port can be paired with a chain of plug-and-play sensors via Qwiic / STEMMA QT cables — temperature, humidity, CO2, air quality, light, pressure — without rewiring. Attach the sensors that match the location, then map each response from the I2C scan to a sensor type in the Sensors settings page (see "Sensor configuration" below).
All sensors connect via the Stemma QT / I2C port. Multiple sensors can be connected simultaneously. The same address can carry several sensor modules — how the firmware picks which one is the topic of the next section, "Sensor Configuration".
| Sensor | Category | Measurements | Calculated Values | I2C Address(es) |
|---|---|---|---|---|
| SHT4x | Temperature & Humidity | Temperature, Relative Humidity | Dew Point | 0x44, 0x45 |
| BME680 | Environmental | Temperature, Relative Humidity, Pressure | Dew Point, Sea Level Pressure | 0x76, 0x77 |
| BMP3xx | Pressure | Pressure | Sea Level Pressure | 0x76, 0x77 |
| DPS310 | Pressure | Pressure | Sea Level Pressure | 0x76, 0x77 |
| SCD4x | CO2 | CO2 (ppm) | 0x62 | |
| SGP40 | Air Quality | VOC Index | — | 0x59 |
| BH1750 | Light | Illuminance (lux) | — | 0x23, 0x5C |
| TSL2591 | Light | Illuminance (lux) | — | 0x29 |
| VEML7700 | Light | Illuminance (lux) | — | 0x10 |
| PM25 AQI | Air Quality | PM1.0 / PM2.5 / PM10 concentration (µg/m³), particle counts | — | 0x12 |
Note: The SGP40 VOC sensor requires temperature and relative humidity readings from another sensor (e.g. SHT4x) to compute a calibrated VOC index.
Sensors are not auto-assigned at startup. The firmware holds a per-address
assignment string in NVS (44=SHT4x,77=BME680); at boot each entry is
instantiated as a driver of the given type at the given address.
The Sensors settings page is the place to manage this list:
- Discover addresses: an I2C scan reports which addresses currently respond.
- Pick a type per address: the same address can carry several sensor
modules (e.g.
0x76/0x77is shared byBME680,BMP3xxandDPS310). The scan only knows an address responded — it cannot tell which module is on the other end, so the user picks the type in settings. - Deactivate a module: simply omit its address from the assignment list (or leave the type blank). The module stays wired, but no driver is instantiated for it and it produces no measurements.
Changes are persisted to NVS and take effect on the next boot (the API triggers a restart).
# Build for ESP32-S2
pio run -e adafruit_qtpy_esp32s2
# Upload to device
pio run -e adafruit_qtpy_esp32s2 -t upload
# Monitor serial output (with exception decoder)
pio device monitorException Decoder: The serial monitor runs with the
esp32_exception_decoderfilter enabled (configured viamonitor_filtersinplatformio.ini). This automatically decodes ESP32 crash stack traces — addresses in exception/backtrace output are resolved to function names and source file line numbers using the debug ELF file. The firmware is built withbuild_type = debugto include debug symbols required for decoding.
- Power on the device
- The device enters AP mode and broadcasts a WPA2-PSK-protected WiFi network
Klima XXXXXX(where XXXXXX is the device id, derived from the last 3 bytes of the MAC address). Read the WPA2 passphrase from the captive portal page itself or from the device's boot log over USB serial - Connect to this network using the passphrase (default IP: 192.168.4.1)
- A captive portal opens automatically - enter your WiFi credentials
- The device restarts and connects to your network
- Access the web interface at
klima-XXXXXX.localor check your router for the IP - Configure temperature control settings in the web interface
| Page | Description |
|---|---|
| Control | Monitor current temperature/humidity and set temperature control |
| Settings | Configure WiFi, device name, MQTT, target temperature, and control parameters |
| Timers | Set countdown timers or daily alarms for temperature control actions |
| About | Device information and diagnostics |
The project includes an OpenAPI specification documenting the primary API endpoints with request/response schemas. Interactive HTML documentation is deployed to GitHub Pages.
Note: The OpenAPI spec currently covers the primary endpoints. Additional endpoints (MQTT, OTA, sensors, settings) are documented in the source code.
| Endpoint | Method | Description |
|---|---|---|
/api/status |
GET | Device status, current temperature, humidity, and control state |
/api/temperature/target |
POST | Set target temperature |
/api/control |
GET | Live controller state, gains in force, and control interval |
/api/control/enable |
POST | Enable/disable temperature control |
/api/control/tuning |
POST | Set PID gains and the control interval |
/api/sensors |
GET | Detailed sensor information |
All settings are persisted in ESP32 NVS (Non-Volatile Storage):
- WiFi Config: SSID, password, connection status
- Device Config: Device name, device ID, sensor I2C address, PID gains and control interval
- Temperature Config: Target temperature, control enabled flag
- MQTT Config: Broker address, port, topics (if enabled)
- Syslog Config: Syslog server address, port, enabled flag
src/
main.cpp # Entry point and device initialization
Config.h/cpp # NVS persistence and configuration management
Network.h/cpp # WiFi, mDNS, NTP, and webserver
SensorController.h/cpp # Sensor management and temperature control
SensorDataLogger.h/cpp # Historical data logging
WebServerManager.h/cpp # Web interface and REST API
MqttClient.h/cpp # MQTT connectivity
SyslogOutput.h/cpp # UDP syslog log forwarding
Log.h # Project-level logging macros
StatusLed.h/cpp # Status LED control
TimerScheduler.h/cpp # Timer and alarm management
TouchController.h/cpp # Capacitive touch input
OTAUpdater.h/cpp # Over-the-air firmware updates
CaptivePortal.h/cpp # WiFi setup captive portal
sensor/
Sensor.h # Sensor base class and measurement types
SHT4x.h/cpp # Temperature & humidity sensor
BME680.h/cpp # Environmental sensor (temp, humidity, pressure)
BMP3xx.h/cpp # Pressure sensor
DPS310.h/cpp # Pressure sensor
SCD4x.h/cpp # CO2, temperature & humidity sensor
SGP40.h/cpp # VOC index sensor
BH1750.h/cpp # Ambient light sensor
TSL2591.h/cpp # High-dynamic-range light sensor
VEML7700.h/cpp # Ambient light sensor
PM25.h/cpp # Particulate matter / air quality sensor
DeviceSensor.h/cpp # Device sensor abstraction
task/
SensorMonitor.h/cpp # Sensor reading FreeRTOS task
data/
control.html # Main control page
settings.html # Settings page
timers.html # Timer management
about.html # Device information
tests/
test_status_led/ # Status LED tests
- Single-core design: Optimized for ESP32-S2's single core
- FreeRTOS tasks:
- Network Task - handles WiFi, HTTP, mDNS, NTP, and webserver
- Sensor Monitor Task - reads sensors and updates temperature control
- Thread-safe: Direct method calls and shared data with proper synchronization
- Persistent storage: All settings stored in ESP32 NVS
- Real-time operation: Sensors read every second; the PID recomputes on the configured control interval (60 s by default), while the over-temperature shutoff is evaluated on every sensor cycle
The temperature control system uses:
- PID algorithm - Calculates control output based on temperature error
- Per-driver range validation - Each sensor driver rejects implausible readings before they reach the controller (averaging multiple sensors is intentionally not used: a faulty reading diluted by a healthy one would still contaminate the loop)
- Data logging - All measurements and control states logged for analysis
- Configurable targets - Set any temperature within sensor range
The gains and the control interval are stored in NVS and editable under
Settings → Tuning or via POST /api/control/tuning. Values are validated
on load and fall back per field; the API rejects an out-of-range value rather
than clamping it.
| Field | Range | Default |
|---|---|---|
kp |
0.01–100 | 0.5 |
ki |
0–0.05 | 0.0001 |
kd |
0–600 | 0 |
control_interval_s |
1–600 | 60 |
ki and kd may be zero; kp may not, because a zero proportional gain
disables control while control still reports itself as enabled. The defaults are
derived arithmetically rather than measured, and are consistent with the
autotuner's own Tyreus–Luyben derivation, which produces a PI controller
with kd = 0 — on a floor whose time constant is hours, derivative action
mostly amplifies sensor noise. Expect to revise them after the first converged
autotune run on a real plant.
The control interval decimates only the PID computation. Sensors still read
every second, and both the over-temperature shutoff and the autotuner's sampling
are evaluated on every sensor cycle regardless — a safety limit noticed a
minute late is a weaker guarantee, and coarser autotune sampling would bias its
amplitude measurement. Because elapsed time is measured rather than assumed, the
interval does not change what ki means.
Accepting an autotune result persists the derived gains. The controller adopts
them on its next control tick rather than immediately, so GET /api/control
reports the gains actually in force and is how a change is confirmed.
Optional MQTT support for:
- Publishing current temperature and humidity
- Publishing control state and target temperature
- Subscribing to commands to enable/disable control
- Remote monitoring and integration with home automation systems
Enable it under Settings → MQTT or via POST /api/mqtt.
With the configured prefix {prefix} (default sensors):
| Topic | Payload | Retained |
|---|---|---|
{prefix}/temperature, {prefix}/humidity, {prefix}/… |
Measurement JSON (time, value, unit, sensor, calculated) |
no |
{prefix}/target_temperature |
Setpoint in °C, e.g. 21.5 |
yes |
{prefix}/control_enabled |
ON / OFF |
yes |
{prefix}/control_output |
PID output as a whole-number percent (0–100) |
no |
{prefix}/heating |
Confirmed heating, ON / OFF |
no |
{prefix}/status |
online while connected, offline otherwise |
yes |
Every control-state topic is published as soon as its value changes, once on every (re)connect, and again on every control-loop computation — once per configured control interval (default 60 s) — independently of the measurement publish interval. A subscriber therefore sees the setpoint and demand of each control run even when nothing changed.
Publish to these to change the device (no retain — a retained command would be re-applied on every connect):
| Topic | Payload |
|---|---|
{prefix}/target_temperature/set |
A number in [10.0, 30.0] °C |
{prefix}/control_enabled/set |
ON / OFF |
A setpoint outside that range, or a non-numeric payload, is rejected rather than clamped, so a mistyped value never replaces the stored one. Unknown control payloads are ignored.
The device registers an MQTT Last Will (retained offline on {prefix}/status),
publishes a retained online when it connects and a retained offline before a
clean disconnect. A broker or subscriber therefore sees offline both when the
device is switched off deliberately and when it drops without warning.
Turn on Settings → MQTT → Home Assistant Discovery (or POST /api/mqtt
{"ha_discovery": true}) to have the device announce itself. It publishes one
retained Home Assistant device-discovery message on
homeassistant/device/<device_id>/config describing:
- a
climateentity (current temperature, target temperature, off/heat), - humidity and dew-point sensors,
- a PID-output sensor, and
- a heating binary sensor.
The message is republished after every reconnect, so the entities come back after a broker restart without re-toggling the setting. Turning the toggle off publishes an empty retained payload to the discovery topic, which removes the device from Home Assistant.
MQTT must be enabled for discovery to be published. Retained discovery outlives a firmware downgrade: disable discovery (or clear the topic by hand) before downgrading.
Device logs can be forwarded to a remote syslog server via UDP (RFC 3164). Configure the syslog target in the web interface under Settings or via the /api/syslog endpoint.
- Protocol: UDP syslog (RFC 3164)
- Facility: user (1)
- Hostname: uses the device's mDNS hostname (e.g.
klima-aabbcc) - Severity mapping: E=error(3), W=warning(4), I=info(6), D=debug(7)
- Zero overhead when disabled: no UDP socket allocated, inline
isActive()check
An optional Waveshare 1.54" e-paper module (200×200, SSD1681) shows the current
temperature and relative humidity. Disabled by default; enable it under
Settings → E-Paper Display or via the /api/display endpoint.
The layout is a header band in a small font (KlimaControl top left, the
running firmware version top right), the temperature and humidity, then a
two-line footer carrying the device name, the wall clock, the setpoint, the
controller demand bar and the control-state symbol.
The following mock-ups are rendered pixel-exactly by the firmware's own drawing code via scripts/render_display_mock.py — they show each panel state, including the warning icon that appears in the margin:
When several conditions are active at once, the highest-priority warning wins the single icon slot (overheat → frost → sensor → actuator → humid).
- Zero idle power: e-paper keeps its image with no power between refreshes
- Paged rendering: 625-byte page buffer rather than a 5 KB framebuffer, to keep internal SRAM above the OTA pre-flight gate
- Refresh policy: value hysteresis (±0.1 °C / ±1 %RH), a configurable minimum interval (default 60 s), and a full refresh every 12 partials to clear ghosting
- Version at a glance: the header band sits above the partial-refresh window, so it is repainted only by a full refresh — and therefore holds only compile-time-constant content. After an OTA update the first paint on the new firmware is a full refresh, so the version shown is always the one running
- Fails safe: a disconnected panel is detected after three timed-out refreshes and stops being driven, without touching the saved configuration
See E-Paper Display Wiring for hardware setup.
Firmware updates from GitHub releases:
- Checks for new releases automatically
- Downloads .bin files directly from GitHub
- Safe automatic rollback if update fails
- Zero downtime updates (except brief restart)
See OTA Documentation for detailed setup.
The project includes two types of specifications:
- OpenSpec Documents in
openspec/- spec-driven capability specifications - OpenAPI Specifications in
docs/api/- API interface definitions
The OpenSpec documents in the openspec/ directory define the system architecture, requirements, and behavior, and the spec-driven change workflow used to evolve them. See AGENTS.md for the full workflow. These are validated using the official OpenSpec CLI:
- GitHub Actions automatically validates all OpenSpec documents on push/pull request
- HTML documentation is generated from the Markdown specs
- Validation includes proper formatting, cross-references, and requirement syntax
To validate locally (run from the repo root, where openspec/ lives):
openspec validate --all --strict # or: scripts/validate-openspec.shThe project includes OpenAPI specification files in the docs/api/ directory. When you push changes to these files:
- GitHub Actions automatically validates the OpenAPI specs
- HTML documentation is generated using Redoc
- The documentation is deployed to GitHub Pages
To validate OpenAPI specs locally:
spectral lint docs/api/*.yamlTo generate HTML documentation locally:
redoc-cli bundle docs/api/klimacontrol-api.yaml -o docs/api/index.htmlThe project includes OpenAPI specification files in the docs/api/ directory. When you push changes to these files:
- GitHub Actions automatically validates the OpenAPI specs
- HTML documentation is generated using Redoc
- The documentation is available as a downloadable artifact
To view the API documentation:
- Go to the Actions tab in GitHub
- Find the latest "OpenAPI Validation and Documentation" workflow run
- Download the "api-documentation" artifact
- Open the HTML files in your browser
You can also generate the documentation locally:
# Install required tools
npm install -g redoc-cli
# Generate HTML from OpenAPI spec
redoc-cli bundle docs/api/klimacontrol-api.yaml -o docs/api/index.html# Run all tests
pio test -e native
# Run specific test suite
pio test -e native -f test_status_led
# Verbose output
pio test -e native -vTo track test coverage, install lcov:
# macOS
brew install lcov
# Ubuntu
sudo apt-get install lcovCoverage report is automatically generated after running tests.
- No raw pointers: All dynamic memory managed via
std::unique_ptr - RAII pattern: Resources automatically released when out of scope
- RAM budget: ~320KB internal SRAM + ~2MB PSRAM, currently using ~56KB internal (17.2%)
- Flash budget: 4MB available, currently using ~1MB (25%)
- PSRAM: the board exposes ~2 MB of PSRAM, but task stacks, FreeRTOS / lwIP / WiFi / mbedTLS / DMA allocations remain internal-SRAM-only — the OTA gate and the network-task low-heap restart guard both use
heap_caps_get_free_size(MALLOC_CAP_INTERNAL). - Single-core: All tasks run on one core, optimize for responsiveness
- Stack size: Sensor Monitor task has 6KB stack, Network task has 8KB stack (both HWM-driven; periodic "stack HWM" lines log the live measurement)
- JSON buffer: 512 bytes for API responses
This project is licensed under the Apache License, Version 2.0. See the LICENSE file for the full license text.





