
Module dual powerstage tiled
Squantor/DiySolderingStation
Do it yourself soldering station

A normal OpenTherm setup is one thermostat talking to one boiler. ZoneTherm sits in the middle of seven of those conversations at once:
ZoneTherm is an open-source printed circuit board by Max-Brants, from the Max-Brants/ZoneTherm repository on GitHub. It is a 4-layer board of 234.5 × 81 mm with 239 parts on 214 nets, routed with 1,316 tracks and 198 vias.
It is built around the CH340C, ESP32-S3-WROOM-1-N8 and HLK-20M24, with the TPS5430DDA, W5500 and BC848C among its other parts. Its parts include 98 resistors, 47 transistors, 26 capacitors, 26 connectors and 23 diodes. It belongs with the ESP32 boards of this library.
A 7-zone OpenTherm heating/cooling controller. ESP32-S3 firmware, KiCad hardware, and a Svelte web UI for a house where every room has its own OpenTherm room thermostat but the plant has a single manifold of zone valves.
Version 1.0.0.
A normal OpenTherm setup is one thermostat talking to one boiler. ZoneTherm sits in the middle of seven of those conversations at once:
Room thermostat 1 ──OpenTherm──┐
Room thermostat 2 ──OpenTherm──┤
... ├──► ZoneTherm ──I²C──► MCP23017 ──► 7 zone valves
Room thermostat 7 ──OpenTherm──┘ │
├──► Ethernet / WiFi ──► MQTT ──► Home Assistant
└──► Web UI + REST APITo each thermostat, ZoneTherm pretends to be a boiler. It answers every OpenTherm request the thermostat makes, so the thermostat behaves exactly as it would on a real appliance — it shows a flame, it runs its own schedule, it lets you turn the dial. While answering, ZoneTherm captures the two numbers that matter: the room temperature the thermostat measured, and the setpoint it is asking for.
It then makes the only decision that is actually its own: for each thermostat,
should heat be flowing? That is a bang-bang comparison with a symmetric
hysteresis band, and it lives in exactly one function
(ClimateLogic::valveWanted).
Each thermostat drives a valve set — one valve, or several. A room with
three underfloor loops has three actuators and one thermostat, and they open
and close together in a single I²C write.
ValvePlan::solve turns the seven per-room
answers into the one byte the expander wants. A valve belongs to at most one
thermostat, which the web UI enforces by greying out valves already taken and
the API enforces by rejecting the save.
One season — heating or cooling — applies to the whole plant, because there is one set of pipes. Individual zones can be disabled, but they cannot disagree about which direction the water is going.
Setpoints can also be pushed the other way. A setpoint sent from Home Assistant
or the web UI is written back to the room thermostat over OpenTherm's
TrOverride mechanism, so the wall unit shows the new target too.
boilerTemp, flameOn, fault and friends exist in the zone state and are
parsed off the wire, but only modulation is currently exported as a Home
Assistant sensor.| Path | What it is |
|---|---|
| Code/ | ESP32-S3 firmware (ESP-IDF via PlatformIO). See Code/README.md for the architecture write-up. |
| Code/webui/ | Svelte 5 + Vite + TypeScript single-page app, built into Code/data/ and flashed as a LittleFS image. |
| pcb/ | KiCad 8 project ZoneTherm.kicad_pro — seven OpenTherm slave interfaces, W5500 Ethernet, MCP23017 valve bank. Fabrication outputs in GERBER/, production/, jlcpcb/. |
| enclosure/ | Three-part printed case, generated from the KiCad board by a script rather than measured off it. See enclosure/README.md — read the mains section before printing one. |
| .github/workflows/ | Release workflow that builds and publishes the OTA assets. |
Board: ESP32-S3-DevKitC-1 (16 MB flash, no PSRAM required).
Pinout — from Code/src/app/Pins.h, matching the
Thermos_In_X / Thermos_Out_X nets on the schematic:
| Zone | In (GPIO) | Out (GPIO) |
|---|---|---|
| 1 | 2 | 1 |
| 2 | 42 | 41 |
| 3 | 40 | 39 |
| 4 | 48 | 47 |
| 5 | 20 | 19 |
| 6 | 18 | 17 |
| 7 | 16 | 15 |
I²C to the MCP23017 valve expander: SDA = 4, SCL = 5.
Ethernet is a W5500 on SPI — see U3 on ZoneTherm.kicad_pcb.
Flash layout (Code/partitions.csv) — dual OTA app
slots plus a large filesystem, which is what makes both firmware and web UI
updatable over the air:
| Partition | Offset | Size |
|---|---|---|
nvs | 0x9000 | 16 KB |
otadata | 0xd000 | 8 KB |
ota_0 | 0x10000 | 3 MB |
ota_1 | 0x310000 | 3 MB |
littlefs | 0x610000 | ~10 MB |
PlatformIO is the primary path. On Windows, pio may not be on PATH —
~/.platformio/penv/Scripts/platformio.exe is the full path.
cd Code
pio run # build firmware
pio run -t upload # flash over USB
pio run -t buildfs # build the web UI into data/ and pack a LittleFS image
pio run -t uploadfs # flash the filesystem
pio run -t monitor # serial console @ 115200buildfs/uploadfs run webui_build.py first, so the
image always contains the current webui/src rather than a stale data/.
Raw ESP-IDF (idf.py build) also works.
Flash both the firmware and the filesystem on a new board. Firmware alone boots but serves no web UI.
Network bring-up is automatic and layered:
ZoneTherm-<ID> at http://192.168.4.1/.<ID> is the last three bytes of the MAC in hex, so each controller is
distinct on a shared network.
The device also advertises itself over mDNS as zonetherm-<ID>.local.
Everything is runtime-configurable — no credentials live in the source
tree. Open http://<device>/config (or POST /api/config) and set:
Settings persist in NVS namespace otcfg. POST /api/factory-reset erases it.
The SPA is served from LittleFS at / (zones), /diagnostics (thermostat
links and controller state; /thermostats is the old name and still works),
/config (settings) and /update. /restart and a 404 page are standalone
HTML so they still render while the device is rebooting or when assets are
missing. The visual identity is documented in brand/.
| Endpoint | Method | Purpose |
|---|---|---|
/api/system | GET | hostname, ip, mac, network, heap, uptime, cpuFreq, flashSize, sdkVersion, fwVersion, mode |
/api/thermostats | GET | all seven zones (see below) |
/api/config | GET / POST | full runtime config; secrets are masked on read |
/api/system/mode | POST | set the plant season |
/api/thermostat/<1-7>/settemp | POST | temp= — pushes a setpoint to the thermostat via TrOverride |
/api/thermostat/<1-7>/setname | POST | name= — 1–32 characters |
/api/thermostat/<1-7>/enable | POST | enabled=true|false |
/api/factory-reset | POST | wipe NVS |
/api/update | GET | updater status |
/api/update/check | POST | check GitHub for a newer release now |
/api/update/install | POST | install the discovered release |
/ota/start, /update | GET / POST | manual firmware/filesystem image upload |
Each zone in /api/thermostats carries: id, name, enabled, status
(Active/Inactive), currentTemp, setpoint, modulation,
valves, valveOpen, action, errorCode, totalRequests, failedRequests.
valves is the set this thermostat drives, as 1-based valve numbers —
"valves": [1, 2, 3] for a room with three loops. valveOpen means at least
one of them is open, so it is false for a thermostat that owns none. The same
array is read and written by /api/config; a POST that gives one valve to two
thermostats is rejected with 400 and changes nothing:
curl -X POST http://zonetherm.local/api/config -H 'Content-Type: application/json' \
-d '{"zones":[{"id":1,"valves":[1,2,3]},{"id":2,"valves":[4,5]}]}'Set a broker on /config and ZoneTherm publishes Home Assistant MQTT
auto-discovery itself — no YAML. You get one climate entity plus a
modulation sensor per zone.
Base topic defaults to zonetherm/<ID> and is configurable. Per zone N
(1–7):
Published state
| Topic | Payload |
|---|---|
thermostat/N/temperature | current room temperature |
thermostat/N/setpoint/state | active setpoint |
thermostat/N/mode/state | off | heat | cool |
thermostat/N/action | off | idle | heating | cooling |
thermostat/N/modulation | % |
thermostat/N/state | JSON attributes blob |
Subscribed commands
| Topic | Payload |
|---|---|
thermostat/N/setpoint/command | target temperature |
thermostat/N/mode/command | off disables the zone; heat/cool enables it |
system/mode/command | heating | cooling |
Plant-wide: system/mode/state and status are retained; status is also
the last-will topic, so an unplugged controller shows as unavailable in Home
Assistant rather than silently freezing at its last values.
Zone state is republished every 30 s, and immediately on a change.
A zone's HA climate mode maps onto the zone's enabled flag: off = disabled,
heat/cool = enabled under the current season. You cannot put one zone into
cooling while the rest heat — that is a plant-level setting.
ZoneTherm can pull its own releases from GitHub. Configure the repo
(owner/name) and interval on /config; the default is a 24 h check with
notify-only behaviour, so nothing installs without a click unless you
enable auto-install.
Version discovery deliberately avoids the GitHub REST API: this board has
no PSRAM, and a /releases/latest JSON body would have to be buffered and
parsed on a heap already shared with the HTTP server, MQTT and the OpenTherm
engine. Instead the updater requests https://github.com/<repo>/releases/latest
with redirects disabled and reads the tag out of the 302 Location header —
one request, zero body bytes, no rate limit, and it works for hand-made
releases. Assets then live at predictable
.../releases/download/<tag>/{firmware.bin,littlefs.bin} URLs.
Pushing a v* tag runs release.yml, which
stamps the tag into Version.h, builds both images, trims the ~10 MB of erased
padding off littlefs.bin, and publishes them with a SHA256SUMS.txt.
Two constraints worth knowing:
v1.0.1 to actually exercise the updater.CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE
is set, and an OTA image boots on probation — UpdateService only confirms it
once the network is up, so a release that cannot get online reverts instead of
stranding the controller. But the revert logic lives in the bootloader, which
cannot be delivered over the air. Devices flashed before this option was added
keep the old bootloader until reflashed over USB.Full write-up in Code/README.md. The short version:
Main.cpp is the composition root — every object is built there and wired by
reference. No singletons.domain/ is pure logic with no ESP-IDF includes — the frame codec, the request
responder, the zone registry, the per-room valve decision and the valve-set
aggregation — so it can be compiled off-device.ot_task (core 1, priority 15) owns all OpenTherm I/O. Responses
are transmitted 40 ms after the request by deadline rather than by
sleeping, so one channel cannot stall the other six.ZoneRegistry behind a single mutex; callers
only ever receive value snapshots, never references into locked state.ZoneTherm is released under the MIT License — see LICENSE.
Third-party code lives in Code/src/vendor/. Both libraries keep their upstream
register maps, frame logic and public APIs; only the hardware touchpoints were
rewritten for native ESP-IDF:
Wire to driver/i2c_master.h.| Qty | Value | Footprint | References |
|---|---|---|---|
| 23 | BC848C | Package_TO_SOT_SMD:SOT-23 | Q10, Q14, Q15, Q19, Q20, Q24, Q25, Q29, Q30, Q31, Q33, Q34, Q35, Q36, Q37, Q38 +7 |
| 22 | BC858B | Package_TO_SOT_SMD:SOT-23 | Q1, Q2, Q3, Q11, Q12, Q13, Q16, Q17, Q18, Q21, Q22, Q23, Q26, Q27, Q28, Q32 +6 |
| 16 | 10k | Resistor_SMD:R_0603_1608Metric | R5, R13, R20, R29, R37, R45, R50, R58, R59, R61, R63, R65, R67, R72, R93, R94 |
| 15 | 1k | Resistor_SMD:R_0603_1608Metric | R6, R14, R21, R30, R38, R46, R49, R51, R56, R57, R60, R62, R64, R66, R73 |
| 11 | 1N4148WS | Diode_SMD:D_SOD-323 | D8, D9, D10, D15, D16, D17, D18, D19, D20, D21, D22 |
| 9 | 4k7 | Resistor_SMD:R_0603_1608Metric | R7, R15, R22, R31, R39, R47, R74, R83, R84 |
| 8 | 100 | Resistor_SMD:R_0603_1608Metric | R2, R10, R17, R26, R34, R42, R54, R69 |
| 8 | Conn_01x02 | Connector_Phoenix_MSTB:PhoenixContact_MSTBA_2,5_2-G-5,08_1x02_P5.08mm_Horizontal | J1, J2, J3, J4, J5, J6, J8, J24 |
| 8 | LED | LED_SMD:LED_0805_2012Metric | D1, D2, D3, D4, D5, D6, D7, D14 |
| 7 | 0.1uf | Capacitor_SMD:C_0402_1005Metric | C1, C10, C18, C19, C20, C21, C22 |
| 7 | 20k | Resistor_SMD:R_0603_1608Metric | R4, R12, R19, R28, R36, R44, R71 |
| 7 | 33 | Resistor_SMD:R_0603_1608Metric | R3, R11, R18, R27, R35, R43, R70 |
| 7 | 33k | Resistor_SMD:R_0603_1608Metric | R1, R9, R16, R25, R33, R41, R68 |
| 7 | 51 | Resistor_SMD:R_0603_1608Metric | R8, R23, R24, R32, R40, R48, R75 |
| 7 | G5NB-1A-E-24VDC | EasyEDA:RELAY-TH_G5NB-1A-E-24VDC | J19, J20, J21, J22, J23, J25, J26 |
| 5 | Conn_01x04 | Connector_Phoenix_MSTB:PhoenixContact_MSTBA_2,5_4-G-5,08_1x04_P5.08mm_Horizontal | J11, J12, J13, J14, J15 |
| 4 | 10K | Resistor_SMD:R_0603_1608Metric | R90, R91, R92, R95 |
| 4 | 49R9 | Resistor_SMD:R_0402_1005Metric | R118, R119, R120, R121 |
| 4 | MountingHole | MountingHole:MountingHole_3.2mm_M3 | H1, H2, H3, H4 |
| 3 | 10nf | Capacitor_SMD:C_0402_1005Metric | C15, C17, C24 |
| 2 | 18pf | Capacitor_SMD:C_0402_1005Metric | C26, C27 |
| 2 | 330 | Resistor_SMD:R_0603_1608Metric | R52, R85 |
| 2 | 330 | Resistor_SMD:R_0402_1005Metric | R123, R124 |
| 2 | 5.1K | Resistor_SMD:R_0402_1005Metric | R86, R87 |
| 2 | 6.8nF | Capacitor_SMD:C_0402_1005Metric | C7, C14 |
| 2 | PC817XNNIP1B | PC817:SOP254P960X420-4N | Q44, Q45 |
| 1 | 0.01uf | Capacitor_SMD:C_0402_1005Metric | C8 |
| 1 | 0.1uf | Capacitor_SMD:C_0603_1608Metric | C6 |
| 1 | 1.5k | Resistor_SMD:R_0603_1608Metric | R55 |
| 1 | 100uf | Capacitor_SMD:C_1206_3216Metric | C13 |
| 1 | 10R | Resistor_SMD:R_0402_1005Metric | R122 |
| 1 | 10uf/16v | Capacitor_Tantalum_SMD:CP_EIA-3216-18_Kemet-A | C23 |
| 1 | 12.4K | Resistor_SMD:R_0402_1005Metric | R125 |
| 1 | 13V | Diode_SMD:D_SOD-323_HandSoldering | D13 |
| 1 | 1500pf | Capacitor_SMD:C_0603_1608Metric | C12 |
| 1 | 150pf | Capacitor_SMD:C_0603_1608Metric | C11 |
| 1 | 15uh/3A | Inductor_SMD:L_Changjiang_FXL0630 | L2 |
| 1 | 1M | Resistor_SMD:R_0402_1005Metric | R126 |
| 1 | 1uf | Capacitor_SMD:C_0603_1608Metric | C5 |
| 1 | 220 | Resistor_SMD:R_0603_1608Metric | R53 |
| 1 | 22nf | Capacitor_SMD:C_0402_1005Metric | C16 |
| 1 | 22uf | Capacitor_SMD:C_0603_1608Metric | C2 |
| 1 | 25MH | Crystal:Crystal_SMD_TXC_7M-4Pin_3.2x2.5mm | Y1 |
| 1 | 2K | Resistor_SMD:R_0603_1608Metric | R96 |
| 1 | 3.9K | Resistor_SMD:R_0603_1608Metric | R89 |
| 1 | 330 uf | Capacitor_SMD:CP_Elec_8x10.5 | C3 |
| 1 | 4.7uf | Capacitor_SMD:C_0402_1005Metric | C25 |
| 1 | 4.7uf | Capacitor_SMD:C_1206_3216Metric | C9 |
| 1 | 4V3 | Diode_SMD:D_SOD-323_HandSoldering | D12 |
| 1 | 4V7 | Diode_SMD:D_SOD-323_HandSoldering | D11 |
| 1 | 549 | Resistor_SMD:R_0603_1608Metric | R88 |
| 1 | BLM18PG121SN1D | Inductor_SMD:L_0603_1608Metric | L3 |
| 1 | Boot | EasyEDA:SW-SMD_L3.9-W3.0-P4.45 | SW1 |
| 1 | CH340C | Package_SO:SOIC-16_3.9x9.9mm_P1.27mm | U1 |
| 1 | Conn_01x03 | Connector_Phoenix_MSTB:PhoenixContact_MSTBA_2,5_3-G-5,08_1x03_P5.08mm_Horizontal | J7 |
| 1 | Conn_01x06 | Connector_Phoenix_MSTB:PhoenixContact_MSTBA_2,5_6-G-5,08_1x06_P5.08mm_Horizontal | J10 |
| 1 | Conn_01x08 | Connector_Phoenix_MSTB:PhoenixContact_MSTBA_2,5_8-G-5,08_1x08_P5.08mm_Horizontal | J16 |
| 1 | ESP32-S3-WROOM-1 | RF_Module:ESP32-S3-WROOM-1 | U4 |
| 1 | Fuse | Fuse:Fuseholder_Cylinder-5x20mm_Schurter_0031_8201_Horizontal_Open | F1 |
| 1 | HLK-20M24 | Converter_ACDC:Converter_ACDC_Hi-Link_HLK-20Mxx | PS1 |
| 1 | MCP23017_SO | Package_SO:SOIC-28W_7.5x17.9mm_P1.27mm | U2 |
| 1 | MRBS340 | Diode_SMD:D_SMC | D32 |
| 1 | RJ45_Hanrun_HR911105A_Horizontal | Connector_RJ:RJ45_Hanrun_HR911105A_Horizontal | J9 |
| 1 | Reset | EasyEDA:SW-SMD_L3.9-W3.0-P4.45 | SW2 |
| 1 | Screw_Terminal | TerminalBlock_Altech:Altech_AK100_1x04_P5.00mm | J17 |
| 1 | TPS5430DDA | Package_SO:TI_SO-PowerPAD-8 | U6 |
| 1 | USB_C_Receptacle | Connector_USB:USB_C_Receptacle_Palconn_UTC16-G | J18 |
| 1 | W5500 | Package_QFP:LQFP-48_7x7mm_P0.5mm | U3 |
The 236 most common of 239 parts.
It measures 234.5 × 81 mm and has 4 copper layers, 1.6 mm thick.
239 parts, built around the CH340C, ESP32-S3-WROOM-1-N8, HLK-20M24 and TPS5430DDA. The full list of its parts is on this page, with links to each part's symbol and footprint where PCBKit's library has them.
From the Max-Brants/ZoneTherm repository on GitHub, at the commit this page was made from: the board is pcb/ZoneTherm.kicad_pcb and its schematic pcb/Master_interface.kicad_sch. PCBKit opens the board in the browser, with nothing to install.
Its repository states no licence, so its author keeps all rights: ask Max-Brants before reusing the design.

Squantor/DiySolderingStation
Do it yourself soldering station

USKA-FOS/HB9TRU-Transistor-Audion
Transistor-based Audion radio for shortwave reception

dunkelstern/NightClock
Small SMD based night clock with RTC and battery/supercap backup


ddasdkimo/rack-atx-controller
10-host isolated ATX rack controller hardware planning, interactive 3D model and wiring documentation

CanGulmez/BLDC-Motor-Driver
2kW BLDC Motor Driver Firmware Development and KiCAD design

ksoloti/ksoloti-hardware
A collection of hardware projects around the Ksoloti Core.

Willis75/portero-bot-hardware
PCB design and ESP32 firmware for Portero Bot gate controller (Claustro4)