Hot water gets repiped in PEX under the house. A supply manifold at the water heaters sends four new loops out to room manifolds (master toilet wall, WIC 03, WIC 02, pantry), plus the existing laundry/powder loop, and each loop comes back to the heaters through its own motorized valve. Home Assistant runs the pump only for the zone someone is about to use, such as when a shower light turns on, and stops once that zone's return pipe is hot. This replaces the current pump that circulates through the whole house.
The old system was two big loops of 1" PEX covering the whole house. They ran through the ceiling with no insulation, with long branches down to the rooms. Each complaint has a physical cause:
| Old | New | |
|---|---|---|
| Loops | 2, whole house | 4 new + 1 kept, one room each |
| Pipe | 1" PEX, bare, in the ceiling | 3/4" master, 1/2" others, 1" wall insulation |
| Water per loop | ~7.5 gal | 0.8–2.2 gal |
| Uncirculated pipe | long branches | manifold → fixture only, inside the wall |
| Circulates | whole house, for hours | one loop, ~1 min, on demand |
| Flow balance | short loop steals flow | one valve open at a time, nothing to balance |
| Heat into the house | ~12,500 Btu/h into the rooms while running | ~0 between uses; crawl space, insulated |
Old-loop figures assume ~250 ft per loop, from the house footprint. Set the real length in the Energy section if you know it. Heat loss uses the Taco chart for 1" PEX-a (25 Btu/h·ft bare at 120°F).
Four loops from one manifold. Heaters → mixing valve → a supply manifold beside the heaters, under the rear deck. Each outlet starts a loop: four new ones, plus a fifth outlet that picks up the existing laundry/powder loop. The loop runs out under the house (3/4" to the master bath for the tub and multiple heads, 1/2" to the single baths and kitchen) to the existing room manifold in the wall, which becomes the middle of the loop. Its outlets keep feeding that room's fixtures as they do today. The loop enters at one end of the manifold, and a new connection at the other end carries it back to the heaters.
The valve is on the far end. Each loop's back leg lands on a return manifold next to the supply manifold. Per loop, in flow order: isolation valve, spring check, temperature sensor on a copper stub, normally-closed motorized valve. That valve is your "turn off the far end" switch. Closed, the loop still delivers hot water to every fixture (it acts like a normal dead-end line), it just can't circulate. Open, and with the pump running, hot water flows around that one loop. After the valves the four loops merge into one ECM pump and a check valve, then feed the cold inlet of both tanks.
Why the valve lives at the heater end. The far end of every loop physically comes home to the heaters, so all four valves, sensors, the pump and the controller sit on one board in one place. Nothing electrical goes in the bathrooms except a button.
Demand, not a timer. A trigger opens that zone's valve. Triggers are a button (the code-compliant one), a bathroom light, or presence. After 5 s the pump starts. The ESP32 stops it when the zone's return hits 102°F or rises 10°F, or after 5 min, whichever comes first. These are the California Title 24 demand-control limits (RA4.4.9). They live in firmware, so a Home Assistant outage can't leave the pump running. The rest of the day nothing moves, so the pipes don't bleed heat into the crawl space.
Safe when it fails. No valve ever sits between the heater and a faucet. A dead controller, valve, or pump means you wait for hot water like a house without recirc. Check valves stop cold water from backfeeding a loop through single-handle faucets.
Thermal battery. Your heaters already go to 150°F on solar surplus. The tank stores that heat. Running the pump through all loops mostly heats the crawl space, so the new design keeps "open all valves + pump" as an optional mode rather than the default. See Controls.
Runs are routed square to the joists with 2–3 ft of drop at each end. A loop's back leg over 80 ft becomes 3/4". Purge is the water in the whole loop (out + back) that the pump pushes before the return sensor sees hot, at ~1–1.2 GPM (1/2" back) or ~2 GPM (3/4"). Out-leg drop is the pressure lost between the heaters and the room manifold at that loop's peak hot draw (master 5 gpm for tub fill + multiple heads, others 2.5 gpm for a 1.8 gpm shower plus a 1.2 gpm lav, both under California flow limits). Under ~10 psi is fine on typical 55–70 psi street pressure; orange means consider 3/4". Parts quantities add 15% waste.
Tick items as you buy them (saved in this browser). opt rows are optional and left out of the core total.
Drag the manifolds, heaters and fixtures on the plan above to match reality. Calibrate the scale against a known dimension (e.g. the 21' master bedroom). Any fixture line with an orange ring runs over 10 ft from its room manifold. Export the JSON. Any loop back leg over 80 ft upsizes to 3/4" automatically.
On a board beside the heaters under the deck: the mixing valve feeds the 1" supply manifold, with an isolation valve on each of the four 3/4" outlets. Mount the return manifold right next to it so each loop's out and back ends sit side by side. Label the outlets Z1–Z5. Z5 reconnects the existing laundry/powder loop: cut it in at the heaters, supply end to outlet 5 and return end to lane 5.
From each supply outlet, run PEX-A out to its room manifold (3/4" to the master, 1/2" to the others): master toilet wall, WIC 03, WIC 02, pantry east wall. Hang it every 32" or closer, leave expansion offsets on long straight runs, and keep PEX 6" or more from any flue. At the room, come up through the floor to the existing wall manifold and connect to its inlet, with an isolation valve below the floor.
The loop has to flow through the manifold, so it needs a way out at the end opposite the inlet. On most manifolds that end is capped or plugged: swap the cap for an adapter to PEX. If the manifold is closed at that end (e.g. a cast one-piece body), tee the return off the last fixture outlet instead. Check each manifold's size and connection type (copper sweat, PEX crimp or ProPEX) before buying adapters. The existing fixture lines stay as they are.
From that new far-end connection, run the back leg down through the floor and home to the return manifold (1/2", or 3/4" if over 80 ft). Label both ends with the loop number and color.
Per loop, in flow order:
Merge into the header, then: pump (arrow toward heater), main check, isolation valve, cold-header tee.
Typically 100 psi (or working pressure) for 15 min with no drop. Confirm the number with the inspector. Get the rough inspection signed off now.
1" wall on the loop-out and back legs, the manifolds and the board, including elbows and tees. Fixture lines get at least 1/2". Buy copper-size ID (PEX OD = CTS). Use elastomeric instead of fiberglass near vents or anywhere rodents nest. Leave the sensor stubs wrapped separately so you can reach them.
Split of duties:
Your existing entities map straight in (I guessed Birdie's bath is Bath 03; swap Z2/Z3 if not):
| Zone | Triggers |
|---|---|
| Z1 Master bath | light.main_bathroom_lights |
| Z2 Bath 03 (WIC 03) | light.birdie_bathroom_lights light.birdies_bathroom_vanity |
| Z3 Bath 02 (WIC 02) | light.guest_bathroom_shower_lights light.guest_bathroom_lights |
| Z4 Kitchen (pantry) | kitchen presence (new), 06:00–21:00 |
| Z5 Laundry + powder (existing loop) | powder light (new); laundry exempt |
esphome:
name: recirc-manifold
esp32:
board: esp32dev
framework: { type: esp-idf }
logger:
api:
ota:
- platform: esphome
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
one_wire:
- platform: gpio
pin: GPIO32 # 4.7k pull-up to 3V3
sensor: # addresses come from the boot log; warm each pipe by hand to map them
- { platform: dallas_temp, address: 0x0000000000000001, id: t_z1, name: "Return Z1 Master bath", update_interval: 2s }
- { platform: dallas_temp, address: 0x0000000000000002, id: t_z2, name: "Return Z2 Bath 03", update_interval: 2s }
- { platform: dallas_temp, address: 0x0000000000000003, id: t_z3, name: "Return Z3 Bath 02", update_interval: 2s }
- { platform: dallas_temp, address: 0x0000000000000004, id: t_z4, name: "Return Z4 Kitchen", update_interval: 2s }
- { platform: dallas_temp, address: 0x0000000000000006, id: t_z5, name: "Return Z5 Laundry+powder (existing)", update_interval: 2s }
- { platform: dallas_temp, address: 0x0000000000000005, id: t_hot, name: "Heater outlet", update_interval: 10s }
switch: # internal: only firmware drives the hardware
- { platform: gpio, pin: GPIO2, id: v1, internal: true, restore_mode: ALWAYS_OFF }
- { platform: gpio, pin: GPIO15, id: v2, internal: true, restore_mode: ALWAYS_OFF }
- { platform: gpio, pin: GPIO5, id: v3, internal: true, restore_mode: ALWAYS_OFF }
- { platform: gpio, pin: GPIO4, id: v4, internal: true, restore_mode: ALWAYS_OFF }
- { platform: gpio, pin: GPIO16, id: v5, internal: true, restore_mode: ALWAYS_OFF } # map pins to your board's relays
- { platform: gpio, pin: GPIO14, id: pump, internal: true, restore_mode: ALWAYS_OFF }
- platform: template # thermal-battery / purge mode: all valves, 15 min cap
name: "Recirc all zones"
id: all_mode
optimistic: true
restore_mode: ALWAYS_OFF
globals:
- { id: req, type: int, initial_value: '0' } # request bitmask from HA
- { id: active, type: int, initial_value: '0' } # zones circulating now
button: # HA presses these
- { platform: template, name: "Demand Z1", on_press: { lambda: 'id(req) |= 1;' } }
- { platform: template, name: "Demand Z2", on_press: { lambda: 'id(req) |= 2;' } }
- { platform: template, name: "Demand Z3", on_press: { lambda: 'id(req) |= 4;' } }
- { platform: template, name: "Demand Z4", on_press: { lambda: 'id(req) |= 8;' } }
- { platform: template, name: "Demand Z5", on_press: { lambda: 'id(req) |= 16;' } }
binary_sensor:
- platform: template
name: "Recirc pump running"
lambda: 'return id(pump).state;'
interval:
- interval: 1s
then:
- lambda: |-
// Title 24 RA4.4.9/10 limits enforced here, independent of HA.
const float MAX_F = 102.0f, RISE_F = 10.0f;
const uint32_t MAX_MS = 300000, ALL_MS = 900000, VALVE_TRAVEL_MS = 5000;
// ponytail: consts, not HA number entities. Upgrade: template `number` if tuning gets frequent.
const int N = 5;
static uint32_t start[N] = {0}, all_start = 0;
static float t0[N];
switch_::Switch *v[N] = {id(v1), id(v2), id(v3), id(v4), id(v5)};
sensor::Sensor *t[N] = {id(t_z1), id(t_z2), id(t_z3), id(t_z4), id(t_z5)};
uint32_t now = millis();
bool ready = false;
if (id(all_mode).state) { // thermal battery: all open, time-capped
if (all_start == 0) { all_start = now; for (auto s : v) s->turn_on(); }
if (now - all_start > ALL_MS) id(all_mode).turn_off();
else ready = (now - all_start > VALVE_TRAVEL_MS);
} else if (all_start != 0) {
all_start = 0; for (int i = 0; i < N; i++) if (!(id(active) & (1<<i))) v[i]->turn_off();
}
for (int i = 0; i < N; i++) {
float f = t[i]->state * 1.8f + 32.0f; // NaN if probe fault
int bit = 1 << i;
if (id(req) & bit) {
id(req) &= ~bit;
if (!(id(active) & bit) && !isnan(f) && f < MAX_F) {
id(active) |= bit; start[i] = now; t0[i] = f; v[i]->turn_on();
ESP_LOGI("recirc", "Z%d start %.1fF", i+1, f);
}
}
if (id(active) & bit) {
bool done = isnan(f) || f >= MAX_F || f >= t0[i] + RISE_F || (now - start[i]) > MAX_MS;
if (done) {
id(active) &= ~bit; if (!id(all_mode).state) v[i]->turn_off();
ESP_LOGI("recirc", "Z%d stop %.1fF after %us", i+1, f, (now-start[i])/1000);
} else if (now - start[i] > VALVE_TRAVEL_MS) {
ready = true; // valve fully open
}
}
}
// Interlock: pump only runs with at least one valve open. Never dead-head.
if (ready && !id(pump).state) id(pump).turn_on();
if (!ready && id(pump).state) id(pump).turn_off();
alias: Recirc Z1 master bath
mode: single
triggers:
- trigger: state # code-compliant manual control
entity_id: event.master_bath_recirc_button
- trigger: state # replaces "Hot Water Pump - On with Main Shower Light"
entity_id: light.main_bathroom_lights
to: "on"
conditions:
- condition: numeric_state # loop still warm -> skip
entity_id: sensor.recirc_manifold_return_z1_master_bath
below: 95
actions:
- action: button.press
target: { entity_id: button.recirc_manifold_demand_z1 }
- delay: "00:15:00" # debounce: mode single swallows repeats
# Z2 Bath 03: light.birdie_bathroom_lights
# Z3 Bath 02: light.guest_bathroom_shower_lights, light.guest_bathroom_lights
# Z4 Kitchen: kitchen presence 06:00-21:00
# Z5 Laundry+powder (existing loop): powder light; laundry is exempt, trigger optional# Edit automation.hot_water_pump_thermal_battery_pw_100_heater_150degf:
actions:
- action: switch.turn_on
target: { entity_id: switch.recirc_manifold_recirc_all_zones } # firmware caps it at 15 min
# then disable automation.hot_water_pump_on_with_main_shower_light and
# unplug/retire switch.hot_water_pump_socket_1 (the Tuya plug).
Runnable check for the firmware: during commissioning, unplug one probe mid-cycle. The log must show Zx stop nanF and the pump relay must drop.
The biggest win is going from 24/7 circulation to demand. Title 24's own performance model charges continuous recirc at 9.8× the distribution energy of a non-recirc system; manual demand is 1.75×. Insulation is the second biggest win. Numbers below use your layout lengths and the Taco heat-loss chart (120°F water, 68°F air; a cold crawl space loses more). Adjust the inputs.
COP defaults to 3.0 on the assumption your A.O. Smith units are heat-pump water heaters. Set 1.0 if they're resistance. All three figures are estimates. NREL modelling found demand recirc roughly energy-neutral against a compact, insulated system with no recirc. The real gain here is against the current 24/7 loop, plus 30–36% less water down the drain while waiting.
Put a Caleffi ThermoSetter 116 (~$236) or Watts CircuitSolver on each return and trigger one demand pump. Each valve throttles its own loop once it's hot. No motorized valves, no ESP32, no firmware.
Why it's not the pick: every trigger heats all four loops, about 4× the heat per event (~30 therms/yr equivalent extra at 5 events/day). The four valves also cost ~$950, against ~$150 for four motorized valves plus sensors. Neither valve fully closes: the Caleffi bottoms out at Cv 0.23 and CircuitSolver bypasses 0.2 GPM.
Grundfos ALPHA HWR-D has a native push-button demand mode (102°F / 5 min). Metlund D'MAND is a turnkey demand pump. Either is a fine one-box answer if you ever give up on zoning. Both treat the house as one loop, which is what you're trying to get away from.
Not an option: Viega ManaBloc home-run manifolds. Viega says they're not for use with recirculation loops.
Prices were gathered from vendor listings in September 2026 and are marked "verify" where a listing hid the price. Code requirements are summarized from the 2022 California Energy Code references above. Confirm with Lafayette building before you buy.