substitutions: device_name: tek-4zone-wall friendly_name: 4 Zone Wall Controller support_url: https://tekonline.com.au/4-zone-wall-controller-setup/ esphome: name: ${device_name} friendly_name: ${friendly_name} comment: Hold-to-dim PWM wall controller v2026-05-07 with unique naming and relay-only spill overlay name_add_mac_suffix: true project: name: tekonline.4zone_wall_controller version: "2026.05.07-v1-hold-dimmer-unique-spill" on_boot: priority: -10 then: - lambda: |- id(requested_zone_1_level) = id(zone_1_level).state; id(requested_zone_2_level) = id(zone_2_level).state; id(requested_zone_3_level) = id(zone_3_level).state; id(requested_zone_4_level) = id(zone_4_level).state; const char *spill = id(spill_zone).current_option(); if (spill != nullptr && strcmp(spill, "Zone 1") == 0) { id(spill_zone_index) = 1; } else if (spill != nullptr && strcmp(spill, "Zone 2") == 0) { id(spill_zone_index) = 2; } else if (spill != nullptr && strcmp(spill, "Zone 3") == 0) { id(spill_zone_index) = 3; } else if (spill != nullptr && strcmp(spill, "Zone 4") == 0) { id(spill_zone_index) = 4; } else { id(spill_zone_index) = 0; } - script.execute: sync_zone_1 - script.execute: sync_zone_2 - script.execute: sync_zone_3 - script.execute: sync_zone_4 - script.execute: evaluate_spill_relays esp32: board: esp32dev framework: type: arduino logger: api: reboot_timeout: 0s ota: platform: esphome web_server: port: 80 improv_serial: next_url: ${support_url}?device={{device_name}}&ip={{ip_address}}&version={{esphome_version}} esp32_improv: authorizer: none wifi: # Set your WiFi credentials in ESPHome secrets.yaml: # wifi_ssid: "YourSSID" # wifi_password: "YourPassword" # fallback_password: "SetMeNow" # ssid: !secret wifi_ssid # password: !secret wifi_password reboot_timeout: 0s ap: ssid: "TekOnline 4 Zone Setup" # password: !secret fallback_password ap_timeout: 15s captive_portal: # 4zoneWallV1 schematic pin map: # BUTTON1 -> GPIO16, LED1 -> GPIO17, OUT_1 -> GPIO12 # BUTTON2 -> GPIO5, LED2 -> GPIO18, OUT_2 -> GPIO14 # BUTTON3 -> GPIO19, LED3 -> GPIO21, OUT_3 -> GPIO27 # BUTTON4 -> GPIO22, LED4 -> GPIO23, OUT_4 -> GPIO26 # # Behavior: # - Short press toggles the zone between 0% and 100%. # - Hold ramps the zone level in 5% steps. # - Each new hold reverses ramp direction, like a single-button dimmer. # - Button LEDs show the effective zone output, including spill. # - Relays follow the requested zone levels, except when spill logic forces the # configured spill zone to 100% because every other zone is off. output: - platform: ledc id: led_1_output pin: GPIO17 frequency: 1000Hz - platform: ledc id: led_2_output pin: GPIO18 frequency: 1000Hz - platform: ledc id: led_3_output pin: GPIO21 frequency: 1000Hz - platform: ledc id: led_4_output pin: GPIO23 frequency: 1000Hz - platform: slow_pwm id: relay_1_output pin: number: GPIO12 inverted: false period: 300s - platform: slow_pwm id: relay_2_output pin: number: GPIO14 inverted: false period: 300s - platform: slow_pwm id: relay_3_output pin: number: GPIO27 inverted: false period: 300s - platform: slow_pwm id: relay_4_output pin: number: GPIO26 inverted: false period: 300s globals: - id: zone_1_dim_direction type: int restore_value: no initial_value: "1" - id: zone_2_dim_direction type: int restore_value: no initial_value: "1" - id: zone_3_dim_direction type: int restore_value: no initial_value: "1" - id: zone_4_dim_direction type: int restore_value: no initial_value: "1" - id: zone_1_hold_active type: bool restore_value: no initial_value: "false" - id: zone_2_hold_active type: bool restore_value: no initial_value: "false" - id: zone_3_hold_active type: bool restore_value: no initial_value: "false" - id: zone_4_hold_active type: bool restore_value: no initial_value: "false" - id: zone_1_last_on_level type: float restore_value: yes initial_value: "100.0" - id: zone_2_last_on_level type: float restore_value: yes initial_value: "100.0" - id: zone_3_last_on_level type: float restore_value: yes initial_value: "100.0" - id: zone_4_last_on_level type: float restore_value: yes initial_value: "100.0" - id: requested_zone_1_level type: float restore_value: no initial_value: "0.0" - id: requested_zone_2_level type: float restore_value: no initial_value: "0.0" - id: requested_zone_3_level type: float restore_value: no initial_value: "0.0" - id: requested_zone_4_level type: float restore_value: no initial_value: "0.0" - id: spill_zone_index type: int restore_value: no initial_value: "0" - id: effective_zone_1_level type: float restore_value: no initial_value: "0.0" - id: effective_zone_2_level type: float restore_value: no initial_value: "0.0" - id: effective_zone_3_level type: float restore_value: no initial_value: "0.0" - id: effective_zone_4_level type: float restore_value: no initial_value: "0.0" select: - platform: template name: "${friendly_name} Spill Zone" id: spill_zone entity_category: config optimistic: true restore_value: true initial_option: "Off" options: - "Off" - Zone 1 - Zone 2 - Zone 3 - Zone 4 set_action: - lambda: |- if (x == "Zone 1") { id(spill_zone_index) = 1; } else if (x == "Zone 2") { id(spill_zone_index) = 2; } else if (x == "Zone 3") { id(spill_zone_index) = 3; } else if (x == "Zone 4") { id(spill_zone_index) = 4; } else { id(spill_zone_index) = 0; } - script.execute: evaluate_spill_relays number: - platform: template name: "${friendly_name} Zone 1 Level" id: zone_1_level optimistic: true restore_value: true initial_value: 0 min_value: 0 max_value: 100 step: 1 mode: slider set_action: - lambda: |- id(requested_zone_1_level) = x; if (x > 0.0f) { id(zone_1_last_on_level) = x; } - script.execute: evaluate_spill_relays - platform: template name: "${friendly_name} Zone 2 Level" id: zone_2_level optimistic: true restore_value: true initial_value: 0 min_value: 0 max_value: 100 step: 1 mode: slider set_action: - lambda: |- id(requested_zone_2_level) = x; if (x > 0.0f) { id(zone_2_last_on_level) = x; } - script.execute: evaluate_spill_relays - platform: template name: "${friendly_name} Zone 3 Level" id: zone_3_level optimistic: true restore_value: true initial_value: 0 min_value: 0 max_value: 100 step: 1 mode: slider set_action: - lambda: |- id(requested_zone_3_level) = x; if (x > 0.0f) { id(zone_3_last_on_level) = x; } - script.execute: evaluate_spill_relays - platform: template name: "${friendly_name} Zone 4 Level" id: zone_4_level optimistic: true restore_value: true initial_value: 0 min_value: 0 max_value: 100 step: 1 mode: slider set_action: - lambda: |- id(requested_zone_4_level) = x; if (x > 0.0f) { id(zone_4_last_on_level) = x; } - script.execute: evaluate_spill_relays script: - id: sync_zone_1 mode: restart then: - lambda: |- id(requested_zone_1_level) = id(zone_1_level).state; id(led_1_output).set_level(id(zone_1_level).state / 100.0f); - id: sync_zone_2 mode: restart then: - lambda: |- id(requested_zone_2_level) = id(zone_2_level).state; id(led_2_output).set_level(id(zone_2_level).state / 100.0f); - id: sync_zone_3 mode: restart then: - lambda: |- id(requested_zone_3_level) = id(zone_3_level).state; id(led_3_output).set_level(id(zone_3_level).state / 100.0f); - id: sync_zone_4 mode: restart then: - lambda: |- id(requested_zone_4_level) = id(zone_4_level).state; id(led_4_output).set_level(id(zone_4_level).state / 100.0f); - id: evaluate_spill_relays mode: restart then: - lambda: |- const bool spill_enabled = id(spill_zone_index) > 0; const bool any_non_spill_on = ((id(spill_zone_index) != 1) && (id(requested_zone_1_level) > 0.0f)) || ((id(spill_zone_index) != 2) && (id(requested_zone_2_level) > 0.0f)) || ((id(spill_zone_index) != 3) && (id(requested_zone_3_level) > 0.0f)) || ((id(spill_zone_index) != 4) && (id(requested_zone_4_level) > 0.0f)); float relay_1_level = id(requested_zone_1_level); float relay_2_level = id(requested_zone_2_level); float relay_3_level = id(requested_zone_3_level); float relay_4_level = id(requested_zone_4_level); if (spill_enabled && !any_non_spill_on) { if (id(spill_zone_index) == 1) relay_1_level = 100.0f; if (id(spill_zone_index) == 2) relay_2_level = 100.0f; if (id(spill_zone_index) == 3) relay_3_level = 100.0f; if (id(spill_zone_index) == 4) relay_4_level = 100.0f; } id(effective_zone_1_level) = relay_1_level; id(effective_zone_2_level) = relay_2_level; id(effective_zone_3_level) = relay_3_level; id(effective_zone_4_level) = relay_4_level; id(led_1_output).set_level(relay_1_level / 100.0f); id(led_2_output).set_level(relay_2_level / 100.0f); id(led_3_output).set_level(relay_3_level / 100.0f); id(led_4_output).set_level(relay_4_level / 100.0f); id(relay_1_output).set_level(1.0f - relay_1_level / 100.0f); id(relay_2_output).set_level(1.0f - relay_2_level / 100.0f); id(relay_3_output).set_level(1.0f - relay_3_level / 100.0f); id(relay_4_output).set_level(1.0f - relay_4_level / 100.0f); - id: zone_1_step mode: restart then: - number.set: id: zone_1_level value: !lambda |- float next = id(zone_1_level).state + (id(zone_1_dim_direction) * 5.0f); if (next < 0.0f) next = 0.0f; if (next > 100.0f) next = 100.0f; return next; - id: zone_2_step mode: restart then: - number.set: id: zone_2_level value: !lambda |- float next = id(zone_2_level).state + (id(zone_2_dim_direction) * 5.0f); if (next < 0.0f) next = 0.0f; if (next > 100.0f) next = 100.0f; return next; - id: zone_3_step mode: restart then: - number.set: id: zone_3_level value: !lambda |- float next = id(zone_3_level).state + (id(zone_3_dim_direction) * 5.0f); if (next < 0.0f) next = 0.0f; if (next > 100.0f) next = 100.0f; return next; - id: zone_4_step mode: restart then: - number.set: id: zone_4_level value: !lambda |- float next = id(zone_4_level).state + (id(zone_4_dim_direction) * 5.0f); if (next < 0.0f) next = 0.0f; if (next > 100.0f) next = 100.0f; return next; - id: zone_1_hold_watch mode: restart then: - delay: 400ms - if: condition: binary_sensor.is_on: button_1 then: - lambda: id(zone_1_hold_active) = true; - while: condition: binary_sensor.is_on: button_1 then: - script.execute: zone_1_step - delay: 150ms - lambda: id(zone_1_dim_direction) = -id(zone_1_dim_direction); - id: zone_2_hold_watch mode: restart then: - delay: 400ms - if: condition: binary_sensor.is_on: button_2 then: - lambda: id(zone_2_hold_active) = true; - while: condition: binary_sensor.is_on: button_2 then: - script.execute: zone_2_step - delay: 150ms - lambda: id(zone_2_dim_direction) = -id(zone_2_dim_direction); - id: zone_3_hold_watch mode: restart then: - delay: 400ms - if: condition: binary_sensor.is_on: button_3 then: - lambda: id(zone_3_hold_active) = true; - while: condition: binary_sensor.is_on: button_3 then: - script.execute: zone_3_step - delay: 150ms - lambda: id(zone_3_dim_direction) = -id(zone_3_dim_direction); - id: zone_4_hold_watch mode: restart then: - delay: 400ms - if: condition: binary_sensor.is_on: button_4 then: - lambda: id(zone_4_hold_active) = true; - while: condition: binary_sensor.is_on: button_4 then: - script.execute: zone_4_step - delay: 150ms - lambda: id(zone_4_dim_direction) = -id(zone_4_dim_direction); binary_sensor: - platform: template name: "${friendly_name} Spill Active" entity_category: diagnostic lambda: |- if (id(spill_zone_index) == 0) { return false; } return ((id(spill_zone_index) == 1) && (id(effective_zone_1_level) > id(requested_zone_1_level))) || ((id(spill_zone_index) == 2) && (id(effective_zone_2_level) > id(requested_zone_2_level))) || ((id(spill_zone_index) == 3) && (id(effective_zone_3_level) > id(requested_zone_3_level))) || ((id(spill_zone_index) == 4) && (id(effective_zone_4_level) > id(requested_zone_4_level))); - platform: template name: "${friendly_name} Zone 1 Relay Active" entity_category: diagnostic lambda: |- return id(effective_zone_1_level) > 0.0f; - platform: template name: "${friendly_name} Zone 2 Relay Active" entity_category: diagnostic lambda: |- return id(effective_zone_2_level) > 0.0f; - platform: template name: "${friendly_name} Zone 3 Relay Active" entity_category: diagnostic lambda: |- return id(effective_zone_3_level) > 0.0f; - platform: template name: "${friendly_name} Zone 4 Relay Active" entity_category: diagnostic lambda: |- return id(effective_zone_4_level) > 0.0f; - platform: gpio name: "${friendly_name} Button 1" id: button_1 pin: number: GPIO16 mode: input: true pullup: true inverted: true filters: - delayed_on: 10ms - delayed_off: 10ms on_press: - lambda: id(zone_1_hold_active) = false; - script.execute: zone_1_hold_watch on_release: - if: condition: lambda: return !id(zone_1_hold_active); then: - number.set: id: zone_1_level value: !lambda |- return id(zone_1_level).state > 0.0f ? 0.0f : id(zone_1_last_on_level); - platform: gpio name: "${friendly_name} Button 2" id: button_2 pin: # GPIO5 is a strapping pin on ESP32, so avoid holding Button 2 during boot. number: GPIO5 mode: input: true pullup: true inverted: true filters: - delayed_on: 10ms - delayed_off: 10ms on_press: - lambda: id(zone_2_hold_active) = false; - script.execute: zone_2_hold_watch on_release: - if: condition: lambda: return !id(zone_2_hold_active); then: - number.set: id: zone_2_level value: !lambda |- return id(zone_2_level).state > 0.0f ? 0.0f : id(zone_2_last_on_level); - platform: gpio name: "${friendly_name} Button 3" id: button_3 pin: number: GPIO19 mode: input: true pullup: true inverted: true filters: - delayed_on: 10ms - delayed_off: 10ms on_press: - lambda: id(zone_3_hold_active) = false; - script.execute: zone_3_hold_watch on_release: - if: condition: lambda: return !id(zone_3_hold_active); then: - number.set: id: zone_3_level value: !lambda |- return id(zone_3_level).state > 0.0f ? 0.0f : id(zone_3_last_on_level); - platform: gpio name: "${friendly_name} Button 4" id: button_4 pin: number: GPIO22 mode: input: true pullup: true inverted: true filters: - delayed_on: 10ms - delayed_off: 10ms on_press: - lambda: id(zone_4_hold_active) = false; - script.execute: zone_4_hold_watch on_release: - if: condition: lambda: return !id(zone_4_hold_active); then: - number.set: id: zone_4_level value: !lambda |- return id(zone_4_level).state > 0.0f ? 0.0f : id(zone_4_last_on_level); - platform: status name: "${friendly_name} Status" sensor: - platform: template name: "${friendly_name} Zone 1 Button Illumination" unit_of_measurement: "%" accuracy_decimals: 0 state_class: measurement entity_category: diagnostic lambda: |- return id(effective_zone_1_level); update_interval: 1s - platform: template name: "${friendly_name} Zone 2 Button Illumination" unit_of_measurement: "%" accuracy_decimals: 0 state_class: measurement entity_category: diagnostic lambda: |- return id(effective_zone_2_level); update_interval: 1s - platform: template name: "${friendly_name} Zone 3 Button Illumination" unit_of_measurement: "%" accuracy_decimals: 0 state_class: measurement entity_category: diagnostic lambda: |- return id(effective_zone_3_level); update_interval: 1s - platform: template name: "${friendly_name} Zone 4 Button Illumination" unit_of_measurement: "%" accuracy_decimals: 0 state_class: measurement entity_category: diagnostic lambda: |- return id(effective_zone_4_level); update_interval: 1s - platform: template name: "${friendly_name} Zone 1 Relay Effective Level" unit_of_measurement: "%" accuracy_decimals: 0 state_class: measurement entity_category: diagnostic lambda: |- return id(effective_zone_1_level); update_interval: 1s - platform: template name: "${friendly_name} Zone 2 Relay Effective Level" unit_of_measurement: "%" accuracy_decimals: 0 state_class: measurement entity_category: diagnostic lambda: |- return id(effective_zone_2_level); update_interval: 1s - platform: template name: "${friendly_name} Zone 3 Relay Effective Level" unit_of_measurement: "%" accuracy_decimals: 0 state_class: measurement entity_category: diagnostic lambda: |- return id(effective_zone_3_level); update_interval: 1s - platform: template name: "${friendly_name} Zone 4 Relay Effective Level" unit_of_measurement: "%" accuracy_decimals: 0 state_class: measurement entity_category: diagnostic lambda: |- return id(effective_zone_4_level); update_interval: 1s - platform: wifi_signal name: "${friendly_name} WiFi Signal" update_interval: 600s - platform: uptime name: "${friendly_name} Uptime" button: - platform: restart name: "${friendly_name} Restart"