Skip to the content
PCBKitNew board
4 layers234.5 × 81 mmZoneTherm, the printed circuit board in 3D

A normal OpenTherm setup is one thermostat talking to one boiler. ZoneTherm sits in the middle of seven of those conversations at once:

Size
234.5 × 81 mm
Copper layers
4
Thickness
1.6 mm
Parts
239
Nets
214
Pads
752
Tracks, vias
1,316, 198
Pours
24
Routed
Yes
KiCad
KiCad 10.0 (file 20260206)
Created
27 Jul 2026
Updated
11 Sep 2026

Overview

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.

Main components

10

README

README.md
ZoneTherm

ZoneTherm

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.


The idea

A normal OpenTherm setup is one thermostat talking to one boiler. ZoneTherm sits in the middle of seven of those conversations at once:

text
 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 API

To 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.

Things worth knowing before you build one

  • ZoneTherm is not a gateway. There is no upstream boiler connection in the main firmware. It terminates all seven OpenTherm links itself and drives valves; your boiler is expected to be commanded by something else (a valve end-switch loop, or its own thermostat input).
  • A zone whose thermostat has never reported a room temperature keeps its valves closed, as does a disabled zone. There is no fail-open.
  • The same goes for the two ways a valve set can be misconfigured: a thermostat with no valves keeps tracking its band and reporting demand but opens nothing, and a valve claimed by nobody never opens. Both are flagged on the config page and logged at boot rather than silently guessed at.
  • Upgrading from a pre-valve-set firmware leaves the wiring alone: every thermostat comes up owning the one valve on its own channel, exactly as before.
  • 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.

Repository layout

PathWhat 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.

Hardware

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:

ZoneIn (GPIO)Out (GPIO)
121
24241
34039
44847
52019
61817
71615

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:

PartitionOffsetSize
nvs0x900016 KB
otadata0xd0008 KB
ota_00x100003 MB
ota_10x3100003 MB
littlefs0x610000~10 MB

Getting started

1. Build and flash

PlatformIO is the primary path. On Windows, pio may not be on PATH — ~/.platformio/penv/Scripts/platformio.exe is the full path.

bash
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 @ 115200

buildfs/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.

2. First boot

Network bring-up is automatic and layered:

  1. Ethernet (DHCP, zero configuration) is tried first and always wins — if the link comes up at any later point, WiFi is shut down and Ethernet takes over.
  2. No Ethernet link → joins the configured WiFi as a station.
  3. No WiFi configured, or it never gets an IP within 60 s → opens an open provisioning access point 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.

3. Configure

Everything is runtime-configurable — no credentials live in the source tree. Open http://<device>/config (or POST /api/config) and set:

  • hostname
  • WiFi SSID / password
  • MQTT broker host, port, username, password, base topic, discovery prefix
  • season (heating / cooling)
  • valve hysteresis
  • per-zone name, enabled flag, and valve set
  • update repo, check interval, auto-install, include-filesystem

Settings persist in NVS namespace otcfg. POST /api/factory-reset erases it.


Web UI and REST API

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/.

EndpointMethodPurpose
/api/systemGEThostname, ip, mac, network, heap, uptime, cpuFreq, flashSize, sdkVersion, fwVersion, mode
/api/thermostatsGETall seven zones (see below)
/api/configGET / POSTfull runtime config; secrets are masked on read
/api/system/modePOSTset the plant season
/api/thermostat/<1-7>/settempPOSTtemp= — pushes a setpoint to the thermostat via TrOverride
/api/thermostat/<1-7>/setnamePOSTname= — 1–32 characters
/api/thermostat/<1-7>/enablePOSTenabled=true|false
/api/factory-resetPOSTwipe NVS
/api/updateGETupdater status
/api/update/checkPOSTcheck GitHub for a newer release now
/api/update/installPOSTinstall the discovered release
/ota/start, /updateGET / POSTmanual 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:

bash
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]}]}'

MQTT and Home Assistant

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

TopicPayload
thermostat/N/temperaturecurrent room temperature
thermostat/N/setpoint/stateactive setpoint
thermostat/N/mode/stateoff | heat | cool
thermostat/N/actionoff | idle | heating | cooling
thermostat/N/modulation%
thermostat/N/stateJSON attributes blob

Subscribed commands

TopicPayload
thermostat/N/setpoint/commandtarget temperature
thermostat/N/mode/commandoff disables the zone; heat/cool enables it
system/mode/commandheating | 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.


Over-the-air updates

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:

  • The device only installs a strictly newer semver. With 1.0.0 flashed, tag v1.0.1 to actually exercise the updater.
  • Rollback needs one serial flash to arm. 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.
  • Asset filenames are hard-coded device-side. Renaming one silently breaks every deployed controller.

Architecture at a glance

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.
  • A dedicated 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.
  • The main task ticks network, valves, MQTT and the updater at 1 Hz.
  • All shared zone state lives in ZoneRegistry behind a single mutex; callers only ever receive value snapshots, never references into locked state.

Credits and licensing

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:

  • OpenTherm library — ihormelnyk/OpenTherm, MIT, © 2023 Ihor Melnyk. Ported from Arduino to native ESP-IDF GPIO/timer APIs.
  • MCP23017 driver — blemasle/arduino-mcp23017, MIT, © 2017 Bertrand Lemasle. Ported from Arduino Wire to driver/i2c_master.h.

Parts

239
QtyValueFootprintReferences
23BC848CPackage_TO_SOT_SMD:SOT-23Q10, Q14, Q15, Q19, Q20, Q24, Q25, Q29, Q30, Q31, Q33, Q34, Q35, Q36, Q37, Q38 +7
22BC858BPackage_TO_SOT_SMD:SOT-23Q1, Q2, Q3, Q11, Q12, Q13, Q16, Q17, Q18, Q21, Q22, Q23, Q26, Q27, Q28, Q32 +6
1610kResistor_SMD:R_0603_1608MetricR5, R13, R20, R29, R37, R45, R50, R58, R59, R61, R63, R65, R67, R72, R93, R94
151kResistor_SMD:R_0603_1608MetricR6, R14, R21, R30, R38, R46, R49, R51, R56, R57, R60, R62, R64, R66, R73
111N4148WSDiode_SMD:D_SOD-323D8, D9, D10, D15, D16, D17, D18, D19, D20, D21, D22
94k7Resistor_SMD:R_0603_1608MetricR7, R15, R22, R31, R39, R47, R74, R83, R84
8100Resistor_SMD:R_0603_1608MetricR2, R10, R17, R26, R34, R42, R54, R69
8Conn_01x02Connector_Phoenix_MSTB:PhoenixContact_MSTBA_2,5_2-G-5,08_1x02_P5.08mm_HorizontalJ1, J2, J3, J4, J5, J6, J8, J24
8LEDLED_SMD:LED_0805_2012MetricD1, D2, D3, D4, D5, D6, D7, D14
70.1ufCapacitor_SMD:C_0402_1005MetricC1, C10, C18, C19, C20, C21, C22
720kResistor_SMD:R_0603_1608MetricR4, R12, R19, R28, R36, R44, R71
733Resistor_SMD:R_0603_1608MetricR3, R11, R18, R27, R35, R43, R70
733kResistor_SMD:R_0603_1608MetricR1, R9, R16, R25, R33, R41, R68
751Resistor_SMD:R_0603_1608MetricR8, R23, R24, R32, R40, R48, R75
7G5NB-1A-E-24VDCEasyEDA:RELAY-TH_G5NB-1A-E-24VDCJ19, J20, J21, J22, J23, J25, J26
5Conn_01x04Connector_Phoenix_MSTB:PhoenixContact_MSTBA_2,5_4-G-5,08_1x04_P5.08mm_HorizontalJ11, J12, J13, J14, J15
410KResistor_SMD:R_0603_1608MetricR90, R91, R92, R95
449R9Resistor_SMD:R_0402_1005MetricR118, R119, R120, R121
4MountingHoleMountingHole:MountingHole_3.2mm_M3H1, H2, H3, H4
310nfCapacitor_SMD:C_0402_1005MetricC15, C17, C24
218pfCapacitor_SMD:C_0402_1005MetricC26, C27
2330Resistor_SMD:R_0603_1608MetricR52, R85
2330Resistor_SMD:R_0402_1005MetricR123, R124
25.1KResistor_SMD:R_0402_1005MetricR86, R87
26.8nFCapacitor_SMD:C_0402_1005MetricC7, C14
2PC817XNNIP1BPC817:SOP254P960X420-4NQ44, Q45
10.01ufCapacitor_SMD:C_0402_1005MetricC8
10.1ufCapacitor_SMD:C_0603_1608MetricC6
11.5kResistor_SMD:R_0603_1608MetricR55
1100ufCapacitor_SMD:C_1206_3216MetricC13
110RResistor_SMD:R_0402_1005MetricR122
110uf/16vCapacitor_Tantalum_SMD:CP_EIA-3216-18_Kemet-AC23
112.4KResistor_SMD:R_0402_1005MetricR125
113VDiode_SMD:D_SOD-323_HandSolderingD13
11500pfCapacitor_SMD:C_0603_1608MetricC12
1150pfCapacitor_SMD:C_0603_1608MetricC11
115uh/3AInductor_SMD:L_Changjiang_FXL0630L2
11MResistor_SMD:R_0402_1005MetricR126
11ufCapacitor_SMD:C_0603_1608MetricC5
1220Resistor_SMD:R_0603_1608MetricR53
122nfCapacitor_SMD:C_0402_1005MetricC16
122ufCapacitor_SMD:C_0603_1608MetricC2
125MHCrystal:Crystal_SMD_TXC_7M-4Pin_3.2x2.5mmY1
12KResistor_SMD:R_0603_1608MetricR96
13.9KResistor_SMD:R_0603_1608MetricR89
1330 ufCapacitor_SMD:CP_Elec_8x10.5C3
14.7ufCapacitor_SMD:C_0402_1005MetricC25
14.7ufCapacitor_SMD:C_1206_3216MetricC9
14V3Diode_SMD:D_SOD-323_HandSolderingD12
14V7Diode_SMD:D_SOD-323_HandSolderingD11
1549Resistor_SMD:R_0603_1608MetricR88
1BLM18PG121SN1DInductor_SMD:L_0603_1608MetricL3
1BootEasyEDA:SW-SMD_L3.9-W3.0-P4.45SW1
1CH340CPackage_SO:SOIC-16_3.9x9.9mm_P1.27mmU1
1Conn_01x03Connector_Phoenix_MSTB:PhoenixContact_MSTBA_2,5_3-G-5,08_1x03_P5.08mm_HorizontalJ7
1Conn_01x06Connector_Phoenix_MSTB:PhoenixContact_MSTBA_2,5_6-G-5,08_1x06_P5.08mm_HorizontalJ10
1Conn_01x08Connector_Phoenix_MSTB:PhoenixContact_MSTBA_2,5_8-G-5,08_1x08_P5.08mm_HorizontalJ16
1ESP32-S3-WROOM-1RF_Module:ESP32-S3-WROOM-1U4
1FuseFuse:Fuseholder_Cylinder-5x20mm_Schurter_0031_8201_Horizontal_OpenF1
1HLK-20M24Converter_ACDC:Converter_ACDC_Hi-Link_HLK-20MxxPS1
1MCP23017_SOPackage_SO:SOIC-28W_7.5x17.9mm_P1.27mmU2
1MRBS340Diode_SMD:D_SMCD32
1RJ45_Hanrun_HR911105A_HorizontalConnector_RJ:RJ45_Hanrun_HR911105A_HorizontalJ9
1ResetEasyEDA:SW-SMD_L3.9-W3.0-P4.45SW2
1Screw_TerminalTerminalBlock_Altech:Altech_AK100_1x04_P5.00mmJ17
1TPS5430DDAPackage_SO:TI_SO-PowerPAD-8U6
1USB_C_ReceptacleConnector_USB:USB_C_Receptacle_Palconn_UTC16-GJ18
1W5500Package_QFP:LQFP-48_7x7mm_P0.5mmU3

The 236 most common of 239 parts.

Questions

How big is the ZoneTherm PCB?

It measures 234.5 × 81 mm and has 4 copper layers, 1.6 mm thick.

What parts does the ZoneTherm use?

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.

Where can I download the ZoneTherm KiCad files?

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.

Can I use the ZoneTherm design in my own project?

Its repository states no licence, so its author keeps all rights: ask Max-Brants before reusing the design.

Similar boards

  • 2 layers90 × 50 mmHB9TRU Transistor Audion, the printed circuit board in 3D

    HB9TRU Transistor Audion

    USKA-FOS/HB9TRU-Transistor-Audion

    Transistor-based Audion radio for shortwave reception

    33 parts4CC0-1.0
  • 2 layers99 × 57.5 mmClock, the printed circuit board in 3D

    Clock

    dunkelstern/NightClock

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

    42 parts1BSD-3-Clause
  • 2 layers40.7 × 23.5 mmEKG Design, the printed circuit board in 3D

    EKG Design

    TomBrown576/PCB-Design

    56 parts
  • 2 layers200 × 170 mmRouting R1, the printed circuit board in 3D

    Routing R1

    ddasdkimo/rack-atx-controller

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

    698 parts
  • 2 layers126.7 × 121.3 mmBLDC Motor Driver, the printed circuit board in 3D

    BLDC Motor Driver

    CanGulmez/BLDC-Motor-Driver

    2kW BLDC Motor Driver Firmware Development and KiCAD design

    123 partsMIT
  • 2 layers72 × 32 mmKsoloti power midi board, the printed circuit board in 3D

    Ksoloti power midi board

    ksoloti/ksoloti-hardware

    A collection of hardware projects around the Ksoloti Core.

    41 parts3
  • 2 layers117.5 × 104.5 mmPortero bot v2, the printed circuit board in 3D

    Portero bot v2

    Willis75/portero-bot-hardware

    PCB design and ESP32 firmware for Portero Bot gate controller (Claustro4)

    75 parts