
Dual-Zone Peltier Cooler Controller (ESPHome / ESP32) - Architecture and Logic Review
I am building a dual-zone Peltier wine fridge cooling controller using ESPHome on an ESP32. The system regulates two separate thermoelectric cooling (TEC) modules powered by a 13.0V to 14.4V Solar/LiFePO4 battery bank. A Shelly Gen4 unit handles the high-level power control to the main busbars, and the ESP32 receives dedicated power via a USB-C PD board. I am unfortunately at least 2x the cost to replace the darn thing, but I wanted to run the fridge natively on available DC instead of having the inverter run 24x7 to drive dedicated AC 220v controller boards - and one of the original controller boards was fried and driving the zone down to 2°C continously. The fridge was a gift from a good friend who suddenly passed away last January, so I'm defo keeping it...
The primary goal is independent or synchronized thermal management for two zones, using an OLED display and a physical rotary encoder for local management, paired with an MQTT integration into Home Assistant.
The core hardware configuration, current software design, and roadblocks are detailed below. AI disclosure - I have used Gemini to support coding based on my design and components available.
The Hardware Stack
- Overall power 13.0-14.4v from LiFEPO4 system
- Shelly Gen4 handling main power control to busbars
- ESP32 Power supplied thru USBC PD board
- ESP32 USBC 38-pin
- 2x Monster Moto controllers, one for each TEC unit, actively cooled
- 4x NTC thermistors on inside/outside of TEC plates, 10k pullups
- 3x Dallas DS18B20 3-wire thermal sensor
Control and Actuation: The brains of the operation is a 38-pin ESP32 development board. Actuation is handled by two Monster Moto controllers (one per TEC module) that are actively cooled.
Power Delivery: The system is strictly configured for cooling only. Polarity switching is not planned. Because TEC modules do not tolerate raw high-frequency PWM well, a downstream low-pass filter consisting of a smoothing coil and a 6800uF capacitor is placed after the Moto controller outputs. The LEDC base frequency is configured to 10kHz.
Sensors: Thermal monitoring relies on two distinct sensor types. Four 10k NTC thermistors with 10k pullup resistors track the internal cold plates and the external warm-side heatsinks for both zones. Three Dallas DS18B20 1-wire sensors handle auxiliary monitoring: two sit directly under the Monster Moto MOSFET chips to monitor driver temperatures, and one monitors ambient air temperature under the chassis.
Logic, Safety Watchdogs, and UI State Machine
The firmware uses the ESPHome native PID climate platform to adjust the LEDC output level, capped at a maximum duty cycle of 80%. To filter out voltage noise on the analog lines, all NTC sensors use a median filter window.
Because a thermal runaway or a frozen controller can quickly destroy the components, the code enforces strict safety limits. The system drops into a hard `ERROR` state and cuts PWM duty cycles to 0% if the warm heatsinks exceed 60°C, the Moto drivers exceed 60°C, or an NTC sensor persistently (3 strikes concept) drops offline. There is also a runaway watchdog: if a zone calls for more than 70% power for 5 minutes straight without achieving at least a 2°C temperature drop, the system trips.
The physical interface uses an SH1106 1.3-inch OLED display driven by an internal state machine (`ui_state`). It manages the transitions between:
- NORMAL: Displays alternating zone metrics or synchronized statistics.
- SET_MODE: Toggles between independent zone control and `SYNC` mode.
- SET_TEMP_Z1 / SET_TEMP_Z2 / SET_TEMP_SYNC: Dedicated target temperature adjustment windows using a rotary encoder.
Problems Overcome and Dead Ends
Building this out highlighted several edge cases that required architectural revisions:
Persistent Boot Loops: Storing the system state in persistent global memory introduced a dangerous edge case. If the system crashed due to a critical hardware fault (like a disconnected NTC sensor), it would reboot, attempt to initialize the PID loop in the last known active state, immediately fault out, and restart again. This was resolved by intercepting the boot sequence using a custom `on_boot` lambda. If an `ERROR` flag is detected in flash memory, the standard initialization bypasses the operational loops entirely, keeping the PWM lines firmly locked at zero until a manual reset is triggered.
Circuit Protection Deficiencies: Early iterations exposed the vulnerability of the switching components to voltage spikes generated by inductive feedback. Inductive loads on the driver outputs required proper flyback protection. Implementing dedicated diode protection across the output paths resolved the risk of punching holes through the switching hardware.
Level Shifter Assumptions: Initial designs factored in hardware level shifters for the LEDC outputs to the driver boards. Bench testing proved that the ESP32 pins (GPIO4 and GPIO13) can trigger the logic gates cleanly without the added latency and complexity of unnecessary shifting components.
The Error State Trap: Once the watchdog tripped into an `ERROR` mode, the system locked down. Originally, clearing this required an MQTT command or a full power cycle. This was fixed by mapping a multi-purpose physical hardware escape route via the `Run Mode` button logic. If the system is bricked in an error state, a physical press acts as a diagnostic reset, clearing the global error code back to 0 and restoring `STANDBY` mode.
Current Status and Areas for Input
The MQTT reporting and remote target configurations work correctly for individual zones. The local display properly switches between languages and states based on user interaction arrays.
I am looking for feedback on a few areas:
- PID Tuning: The current tuning constants are `kp: 0.02`, `ki: 0.0005`, and `kd: 0.03`. Given the high thermal mass of the cooler chassis combined with the filtering lag of the low-pass LC network, what are the best practices for tuning ESPHome's PID component to minimize overshoot without causing cyclic hunting?
- State Machine Optimization: Managing five distinct UI states and dual-loop sync logic purely inside ESPHome lambdas is getting dense. Are there cleaner ways to structure conditional menus and button-hold actions without writing excessive inline C++ within the YAML file?
- Failsafe Operation: What other failsafe concepts can I add to ensure that the system can operate unattended for days, reporting back regularly to my Home Assistant system on health and overall operation. I am most concerned about thermal runaway and/or a fried component staying in the "on" state - hence using the multiple kill switches and Shelly Gen4 as a last resort.
Looking forward to hearing your thoughts or seeing similar control loops you have built. I can share the HA ESPHome YAML separately or DM if you are interested. Edit - formatting.