The KC Thermostat is an OpenTherm room thermostat (OT master) that pairs with the KC1245 Gateway as a drop-in replacement for the third-party thermostat it previously shipped with. It owns its own control logic and talks OpenTherm to the gateway, which bridges that demand to the real HVAC equipment — no gateway firmware changes required for standard behavior. It's built on the Strux template (ESP-IDF v6.0, ESP32-S3), which provides WiFi, a web UI, OTA updates, and the surrounding application infrastructure.
- WiFi — Station mode with automatic AP fallback (
KC Thermostat-AP) after failed connections - Web UI — React + TypeScript dashboard served from flash, accessible from any browser
- OTA Updates — Dual-partition firmware updates and independent web UI updates, no USB after initial flash
- Live Console — Stream device logs to the browser in real time over WebSocket
- Settings — Key/value store backed by NVS with a dynamic settings UI
- Time Sync — SNTP client with timezone support
- Modular Architecture — Service provider pattern with isolated managers, easy to extend
| Layer | Stack |
|---|---|
| Firmware | C++, ESP-IDF v6.0, FreeRTOS |
| Frontend | React 19, TypeScript, Vite, Tailwind CSS, shadcn/ui |
| Target | ESP32-S3 (8 MB flash, 8 MB octal PSRAM) — DIYLESS OpenTherm Thermostat 3 |
| CI/CD | GitHub Actions — builds firmware + frontend, publishes releases |
Thermostat/
├── main/ # ESP-IDF firmware
│ ├── main.cpp # Boot sequence — just Init() calls
│ ├── Application/ # Application logic (managers)
│ │ ├── ApplicationContext.h # Service locator — owns all managers
│ │ ├── ServiceProvider.h # Dependency injection interface
│ │ ├── CommandManager/ # Command dispatch (WebSocket + HTTP)
│ │ ├── ConsoleManager/ # Log capture + WebSocket broadcast
│ │ ├── NetworkManager/ # WiFi STA/AP with retry and fallback
│ │ ├── SettingsManager/ # NVS key-value store
│ │ ├── SystemManager/ # Device identity, ping/info/reboot
│ │ ├── TimeManager/ # SNTP + timezone
│ │ ├── UpdateManager/ # OTA firmware + www partition
│ │ └── WebServerManager/ # HTTP + WebSocket server
│ ├── hardware/ # Hardware abstraction
│ │ ├── boards/ # One folder per target board (-DBOARD=<name>)
│ │ │ └── diyless_thermostat_3/ # DIYLESS Thermostat 3 (default)
│ │ │ ├── BoardConfig.h # Pin definitions for this board
│ │ │ ├── Board.h/.cpp # The board's Board class — owns all drivers
│ │ │ └── board.cmake # Board build fragment (adds Board.cpp)
│ │ ├── interfaces/ # Role interfaces (application vocabulary)
│ │ │ └── OtLink.h # OpenTherm link role: Ready/Transaction/Recover
│ │ └── drivers/ # Shared drivers, usable by any board
│ │ └── Stm32OpenThermLink.h # STM32 OpenTherm co-processor driver
│ └── lib/ # Reusable utilities
│ ├── common/ # Stream, MemoryStream, BufferStream, Fatal
│ ├── json/ # JsonWriter, JsonReader, JsonScope
│ ├── rtos/ # Task, Mutex, Timer, InitState
│ └── system/ # DateTime, TimeSpan
├── frontend/ # React web UI (Vite + Tailwind + shadcn)
├── www/ # Build output — gzipped, embedded in flash
├── CMakeLists.txt # Root ESP-IDF project config
├── partitions.csv # Flash partition layout
└── sdkconfig.defaults # ESP-IDF defaults
| Folder | Contains | Changes when you... |
|---|---|---|
hardware/boards/<name>/ |
Pin definitions, the board's Board class (owns all driver instances), board.cmake, optional sdkconfig.defaults overlay |
Swap or add a board |
hardware/interfaces/ |
Role interfaces the application speaks (Led) — small, application vocabulary |
Application expects a new capability |
hardware/drivers/ |
Board-independent chip/peripheral drivers implementing the roles | Add a peripheral |
Application/ |
Managers, business logic, orchestration, commands | Add features or change behavior |
lib/ |
RTOS wrappers, JSON, time utilities | Rarely — these are stable building blocks |
Rule of thumb: if the code changes when you swap the board, it belongs in hardware/boards/<name>/. If it's a chip driver several boards could use, it belongs in hardware/drivers/. If it changes when you add a feature, it belongs in Application/.
The target board is selected at configure time with -DBOARD=<name> (default: diyless_thermostat_3). Only the selected board folder is put on the include path, so application code just includes BoardConfig.h or Board.h and gets the right one. The application never changes between boards: it compiles against the Board class's surface, and each board makes itself compatible — with real hardware or a mock. There is no IBoard base class; a board missing something the application uses simply fails to compile. To support a new board:
- Copy
main/hardware/boards/diyless_thermostat_3/tomain/hardware/boards/<your_board>/and editBoardConfig.handBoard.h/Board.cpp(bind each role to a real driver or aMock*one) - Add extra board-only source files to
BOARD_SOURCESin itsboard.cmake(optional) - Add
sdkconfig.defaultsin the board folder if the board needs different flash size, PSRAM, or partitions (optional) - Build with
idf.py -DBOARD=<your_board> build
Shared chip drivers (sensors, displays, expanders) go in main/hardware/drivers/, parameterized through BoardConfig constants so every board can reuse them.
Or use the included dev container (requires Docker + VS Code with the Dev Containers extension).
idf.py set-target esp32s3
idf.py build # default board: diyless_thermostat_3
idf.py -p /dev/ttyUSB0 flash monitorTo build for a different board (see main/hardware/boards/):
idf.py -DBOARD=<name> buildIf pnpm is installed, the frontend is built automatically as part of idf.py build. The React app is compiled, gzipped, and embedded into a FAT partition on flash. No SD card or external storage needed.
If pnpm is not available, the firmware still builds — you just won't have a web UI until you build the frontend manually (cd frontend && pnpm install && pnpm build) and reflash.
If you just want to flash a pre-built release without installing ESP-IDF, you can use the ESP Web Flasher directly from your browser:
- Download the latest
Thermostat-<version>-factory.binfrom GitHub Releases - Open ESP Web Flasher
- Connect your ESP32 via USB
- Select the serial port, set flash offset to
0x0, and upload the factory binary - Click Program — done
This works in Chrome and Edge. No drivers or build tools required.
For frontend development with hot reload against a running device:
cd frontend
pnpm devVite's dev server will proxy WebSocket connections to the device. Edit React components and see changes instantly.
All managers follow the same pattern: they receive a ServiceProvider& reference at construction and initialize via Init(). This gives you dependency injection without a framework.
ApplicationContext (owns everything)
├── ConsoleManager — Captures ESP-IDF logs, broadcasts via WebSocket
├── SettingsManager — NVS read/write behind typed setting objects
├── SystemManager — Device identity, ping/info/reboot commands
├── NetworkManager — WiFi STA/AP with retry and fallback
│ └── WiFiInterface — ESP WiFi abstraction (swappable for Ethernet)
├── TimeManager — SNTP time sync with timezone support
├── CommandManager — Pure dispatcher for commands registered by other managers
├── Board — The selected board's hardware (LED, sensors, buses)
├── UpdateManager — Session-based updates to any partition by label
└── WebServerManager — HTTP + WebSocket server, static file serving
├── StaticFileHandler
└── WebSocketHandler
g_appContext.getConsoleManager().Init();
g_appContext.getSettingsManager().Init();
g_appContext.getSystemManager().Init();
g_appContext.getNetworkManager().Init();
g_appContext.getTimeManager().Init();
g_appContext.getCommandManager().Init();
g_appContext.getBoard().Init();
g_appContext.getUpdateManager().Init();
g_appContext.getWebServerManager().Init();The main.cpp stays clean — just Init() calls. Hardware drivers live in the board's Board class; application managers reach them through getBoard().
Looking for Home Assistant / MQTT? The KC Thermostat deliberately has no MQTT or Home Assistant integration — the KC1245 Gateway owns smart-home integration for the system; the thermostat's own web UI and OpenTherm link cover its device-level logic. (The Strux template this is built on shipped MQTT + HA discovery; that layer was removed for this product and lives on in git history if a fork wants it.)
After initial USB flash, the device can be updated entirely over the web UI:
- Firmware > Application Firmware — Writes to the inactive OTA slot, then reboots into it
- Firmware > WWW Partition — Updates the web UI independently of firmware
The CI pipeline produces three artifacts per release:
| File | Purpose |
|---|---|
Thermostat-<version>-factory.bin |
Full image (bootloader + partitions + app + www) for initial flash |
Thermostat-<version>.bin |
Firmware only, for OTA update via web UI |
Thermostat-<version>-www.bin |
Web UI only, for updating the frontend independently |
- On boot, attempts to connect to the configured WiFi network (stored in NVS)
- Retries up to 3 times on failure
- Falls back to an open access point (
KC Thermostat-AP) if all retries fail - Connect to the AP and access the web UI to configure WiFi credentials
All settings are stored in NVS (non-volatile storage) and configurable through the web UI's Settings page. Each setting is a typed object declared in the manager that owns it and registered in that manager's Init():
// In your manager's header:
inline static StringSetting host_{ "myfeature.host", "My Feature Host", "" };
inline static Int32Setting port_{ "myfeature.port", "My Feature Port", 1883 };
// In your manager's Init():
serviceProvider_.getSettingsManager().Register({ &host_, &port_ });
// Anywhere in the owner — typed, no string keys:
int32_t port = port_.Get(); // NVS value, or the default if unsetThe web UI auto-generates form fields for each registered setting, grouped by key prefix. Adding a new setting is a declaration plus a Register() entry — no central table to edit.
The hardware/ directory contains everything that changes when you swap the board or add a peripheral. It is split into boards/<name>/ (one folder per target board, selected with -DBOARD=<name>) and drivers/ (shared, board-independent drivers).
Each board has its own BoardConfig.h with its pin assignments:
namespace BoardConfig
{
static constexpr int OT_LINK_TX_PIN = 12; // STM32 OpenTherm co-processor TX
static constexpr int OT_LINK_RX_PIN = 11; // STM32 OpenTherm co-processor RX
// Add your pins:
// static constexpr int MODBUS_TX_PIN = 17;
// static constexpr int SPI_MOSI_PIN = 13;
}Each board folder provides a Board class that owns every hardware driver instance (and bus host) and exposes the capability surface the application compiles against. Devices the application addresses by meaning go through small role interfaces in hardware/interfaces/ — the included OtLink role is implemented by Stm32OpenThermLink for OpenTherm communication with the gateway. A driver whose full API the application needs can be exposed directly as an escape hatch.
This is where you add your project-specific hardware:
class Board {
public:
OtLink& GetOtLink() { return ot_link_; }
// Add role accessors, or concrete ones when the app needs the full API:
// TemperatureSensor& GetTemperatureSensor() { return sensor_; }
// DisplayManager& GetDisplay() { return display_; }
private:
Stm32OpenThermLink ot_link_{ BoardConfig::OT_LINK_TX_PIN, BoardConfig::OT_LINK_RX_PIN };
// Aht20Sensor sensor_{ i2c_bus_ };
// DisplayManager display_;
};This is a template — copy it, rename it, and build on top of it:
- Rename the project in
CMakeLists.txt(project(YourProject)),.github/workflows/release.yml, andfrontend/src/config.ts(dev-server host) — the UI itself needs no renaming: it shows the device name and project name reported by the firmware - Update
BoardConfig.h(or add a new board folder underhardware/boards/) with your board's pin assignments - Add hardware drivers in
hardware/drivers/and instantiate them in the board'sBoardclass - Add application logic as new managers in
Application/ - Extend the web UI — add pages in
frontend/src/pages/, register routes in the sidebar - Add settings by declaring typed setting members in the owning manager and registering them in its
Init()(see Settings)
- Create a new directory under
Application/YourManager/ - Implement your manager class, accepting
ServiceProvider&in the constructor - Add it to
ServiceProvider.h(forward declare + virtual getter) - Add it to
ApplicationContext.h(member + getter implementation) - Call
Init()frommain.cpp - Register source files in
main/CMakeLists.txt
Commands are dispatched by CommandManager, but each command lives in the manager that owns its domain. Declare a static command table in your manager and register it in Init():
// In your manager's header:
void Cmd_MyThing(Stream& in, Stream& out);
inline static CommandEntry commands_[] = {
{ "myThing", &InvokeCommand<&MyManager::Cmd_MyThing> },
};
// In Init():
serviceProvider_.getCommandManager().Register(this, commands_);Handlers read the request payload from in and write their complete reply to out — for JSON, construct a JsonReader/JsonObject on the streams. The frontend calls commands via the WebSocket RPC layer in backend.ts; large transfers (uploads/downloads) go through the same commands over POST /api/command.
- Define pins in the board's
hardware/boards/<name>/BoardConfig.h - Create your driver in
hardware/drivers/(e.g.,hardware/drivers/MyDisplay.h), taking pins/buses as constructor parameters. If the application addresses the device by role, add or implement a small role interface inhardware/interfaces/ - Instantiate it in the board's
Boardclass and expose it (role interface or concrete accessor) - Add component dependencies in
main/CMakeLists.txt(IDF built-ins) ormain/idf_component.yml(managed components); board-only source files go in the board'sboard.cmakeviaBOARD_SOURCES
See OtLink.h, Stm32OpenThermLink.h, and Board.h for a complete example.
Released under the MIT License. Built on the Strux template.