#:########################################################################################:# # TITLE: LOUNGE BOOKSHELF LEDS # zorruno.com layout v1.1 2026 #:########################################################################################:# # REPO: # https://home.fox.co.nz/gitea/zorruno/zorruno-homeassistant/src/branch/master/esphome/esp-loungebookshelfleds.yaml #:########################################################################################:# # VERSIONS: # V2.1 2026-03-11 Updated yaml to zorruno layout V1.1 # V2.0 2025-09-15 A bunch of fixes and made it more standard across my devices # V1.0 2025-08-17 First setup (and replacement of Tasmota) #:########################################################################################:# # HARDWARE: # - Magichome LED controller single channel (ESP8266, NOT the later ones with BL602) # - Reference: # https://devices.esphome.io/devices/MagicHome-ZJ-WFMN-C-Single-Color # - Device GPIO (Magichome ESP8266): # - GPIO2 Blue LED (used for ESPHome status) # - GPIO12 MOSFET output (0 V when switched) #:########################################################################################:# # OPERATION NOTES: # - General-purpose LED controller. # - Designed for the Sinilink XY-WFMS board with a MOSFET output (claimed 5A, 5-36 V DC), # but will suit similar MOSFET boards. # - In SUBSTITUTIONS there is a maximum output setting to extend LED life. # - In SUBSTITUTIONS there is a minimum output setting; switches fully OFF at/below the minimum # to avoid low-PWM flicker. # - PWM frequency is set to 1 kHz by default. You can increase it, but higher values caused # resets on the Sinilink device. On ESP32 you can run much higher. # - Min/max output settings are not exposed in Home Assistant/MQTT by default, but could be. # - Packages include common items: network settings, diagnostic entities, MQTT, and optional SNTP. # - Default restart behaviours: # - Fade up to full # - Restore Brightness # - Remain Off # - The green LED flashes while fading (different patterns for up/down). The red LED follows the # output (it shares the MOSFET GPIO). This depends on hardware. # - Fade timing scales with configured values proportionally when starting mid-brightness. # - Exposed in Home Assistant/MQTT: # - Startup action (On, Off, Restore) # - Fade Up / Fade Down / Fade Stop buttons # - Fade Up/Down toggle switch # - Normal On/Off switch using quick ramp up/down # - Fade up/down times (0-60 s) # - Output % (pre-gamma) and PWM % (post-gamma) # - Output Set (1-100, respects min/max) # - Many device diagnostics from the included diagnostics package # - Maximum ON time before automatic fade-down (1-48 h, 0 = no limit) # # MQTT COMMANDS: # - ${mqtt_local_command_topic}/light/set = ON # - ${mqtt_local_command_topic}/light/set = OFF # - State publishes to: # - ${mqtt_local_status_topic}/light/state #:########################################################################################:# # OFFLINE NOTES: # a) Home Assistant offline (network and MQTT online): # - LED control continues locally on the device # - Direct local MQTT commands still work if the broker is available # - Stored brightness/ramp settings and restart behaviour continue to work # # b) MQTT offline (or HA/MQTT offline): # - LED control continues locally on the device # - Physical button control and local ramp logic continue to work # - No MQTT control/state publishing available # # c) Entire WiFi/Network offline: # - LED control continues locally on the device # - Physical button control and local ramp logic continue to work # - Accurate time is not needed; SNTP is not required for operation #:########################################################################################:# #:########################################################################################:# # SUBSTITUTIONS: # Specific device variable substitutions # If NOT using a secrets file, just replace these with the passwords etc (in quotes) #:########################################################################################:# substitutions: # Device Naming device_name: "esp-loungebookshelfleds" # yaml file should be device_name.yaml friendly_name: "Lounge Bookshelf LEDs" description_comment: "LED Strip around the bookshelf in the upstairs lounge :: Magichome ESP8266 (Layout V1.1)" device_area: "Lounge" # Allows the ESP device to be automatically linked to an 'Area' in Home Assistant. # Project Naming project_name: "Magichome Single Colour V1.1 LED Controller.Generic ESP8266" # Project details. Manufacturer before the dot, device after the dot. project_version: "v2.1" # Project version denotes release of the YAML file, allowing checking of deployed vs latest version # Passwords & Secrets (Unfortunately, you can't use substitutions inside secret names) api_key: !secret esp-api_key # If using HA API ota_pass: !secret esp-ota_pass # If passwording OTA updates static_ip_address: !secret esp-loungebookshelfleds_ip # CHANGE THIS current_ip_address: ${static_ip_address} # MQTT LOCAL Controls (This is just my local method of controlling via MQTT, I also use the built in method) mqtt_local_device_name: "lounge-bookshelfleds" mqtt_local_command_main_topic: !secret mqtt_command_main_topic mqtt_local_status_main_topic: !secret mqtt_status_main_topic mqtt_local_command_topic: "${mqtt_local_command_main_topic}/${mqtt_local_device_name}" # Topic we will use to command this locally without HA mqtt_local_status_topic: "${mqtt_local_status_main_topic}/${mqtt_local_device_name}" # Topic we will use to view status locally without HA mqtt_local_device_command_ON: "ON" mqtt_local_device_command_OFF: "OFF" # Device Specific Settings log_level: "NONE" # Define logging level: NONE, ERROR, WARN, INFO, DEBUG (default), VERBOSE, VERY_VERBOSE update_interval: "20s" # Update time for general sensors, etc. led_gamma: "1.2" # Gamma from 1.2-3 is sensible to normalise the LED fading vs PWM minimum_led_output: "1" # % If at this value or below, we will switch it completely off maximum_led_output: "90" # % Maximum output; it is sometimes nice to limit the output for longevity or aesthetics max_on_default_hours: "24" # The maximum time the LEDs will be on, in case they get left on. 0 = no automatic turn-off pwm_frequency: "1000" # PWM output Frequency in Hz. High enough to avoid audible/visible artifacts # Device Specific GPIO (so we can easily update for other devices) device_status_led: GPIO2 device_mosfet_out: GPIO12 device_usr_button: GPIO4 # if no button, this is just a fake/safeish choice so it compiles with the generic yaml device_fading_led: GPIO13 # if no LED, this is just a fake/safeish choice so it compiles with the generic yaml #:########################################################################################:# # PACKAGES: # Included Common Packages # https://esphome.io/components/packages.html #:########################################################################################:# packages: #### WIFI, Network (Static/DHCP/IPV6 etc), Fallback AP, Safemode #### common_wifi: !include file: common/network_common.yaml vars: local_device_name: "${device_name}" local_static_ip_address: "${static_ip_address}" local_current_ip_address: "${current_ip_address}" local_ota_pass: "${ota_pass}" #### HOME ASSISTANT API (choose encryption or no encryption options) #### common_api: !include #file: common/api_common.yaml file: common/api_common_noencryption.yaml vars: local_api_key: "${api_key}" #### MQTT #### common_mqtt: !include file: common/mqtt_common.yaml vars: local_device_name: "${device_name}" #### WEB PORTAL #### #common_webportal: !include common/webportal_common.yaml #### SNTP (Only use if you want/need accurate timeclocks) #### #common_sntp: !include common/sntp_common.yaml #### DIAGNOSTICS Sensors #### diag_basic: !include common/include_basic_diag_sensors.yaml diag_more: !include common/include_more_diag_sensors.yaml diag_debug: !include common/include_debug_diag_sensors.yaml #diag_resetcount: !include common/include_resetcount_diag_sensors.yaml #:########################################################################################:# # ESPHOME CORE CONFIGURATION # https://esphome.io/components/esphome.html #:########################################################################################:# esphome: name: "${device_name}" friendly_name: "${friendly_name}" comment: "${description_comment}" area: "${device_area}" on_boot: # Run later so everything is initialised before we touch states. priority: 200 then: # Keep the HA dropdown in sync with the stored mode - lambda: |- switch (id(restart_mode)) { case 0: id(restart_action).publish_state("Fade up to full"); break; case 1: id(restart_action).publish_state("Restore Brightness"); break; case 2: default: id(restart_action).publish_state("Remain Off"); break; } # Mode 0: Fade up to full (obeys fade settings; no on/off flicker) - if: condition: lambda: "return id(restart_mode) == 0;" then: - lambda: "id(ramp_switch_target_on) = true;" - script.execute: ramp_on_script # Mode 1: Restore Brightness (deferred to avoid races) - if: condition: lambda: "return id(restart_mode) == 1;" then: - script.execute: deferred_restore_brightness # Mode 2: Remain Off (no blip; stays off) - if: condition: lambda: "return id(restart_mode) == 2;" then: - script.stop: ramp_on_script - script.stop: ramp_off_script - lambda: "id(ramp_switch_target_on) = false;" - light.turn_off: id: mosfet_leds transition_length: 0s # Boot complete: allow OFF handlers again - lambda: "id(booting) = false;" # NEW: force indicator OFF after boot logic # (Removed: output.turn_off: green_led_out) # Only if you want to play with build flags... # platformio_options: # build_unflags: # - -flto # build_flags: # - -fno-lto # - -Wl,--gc-sections # - -ffunction-sections # - -fdata-sections # - -DNDEBUG #:########################################################################################:# # ESP PLATFORM AND FRAMEWORK # https://esphome.io/components/esp8266.html # https://esphome.io/components/esp32.html #:########################################################################################:# esp8266: board: esp01_1m restore_from_flash: true # restore some values on reboot mdns: disabled: true # Disabling will make the build file smaller (and device is still available via static IP) preferences: flash_write_interval: 2min # enough time to update values for reboots, but not enough to wear flash #:########################################################################################:# # GLOBAL VARIABLES # https://esphome.io/components/globals.html #:########################################################################################:# globals: # Minimum Brightness % for LEDs (will switch off if <=) - id: min_brightness_pct type: int restore_value: true initial_value: "${minimum_led_output}" # start/finish at X% # Maximum Brightness % for LEDs (should never go beyond this) - id: max_brightness_pct type: int restore_value: false initial_value: "${maximum_led_output}" # hard cap; never exceed this # The maximum time the lights will stay on, in hours. Just in case they are left on. 0 = forever - id: max_on_hours type: int restore_value: true initial_value: "${max_on_default_hours}" # Compile time Gamma - id: led_gamma_f type: float restore_value: false initial_value: ${led_gamma} # numeric at compile time # Default Fading Up Time (Selectable and will be retained) - id: ramp_up_ms # fade-in when turned ON type: int restore_value: true initial_value: "5000" # 5 s # Default Fading Down Time (Selectable and will be retained) - id: ramp_down_ms # fade-out when turned OFF type: int restore_value: true initial_value: "10000" # 10 s # Action on Restart. (0=Fade full, 1=Restore brightness, 2=Remain off) - id: restart_mode type: int restore_value: true initial_value: "0" # default = Fade Up to Full (so can be deployed with no other setup) # Determine last fade direction. # true when you asked the light to end up ON (Ramp Up) # false when you asked the light to end up OFF (Ramp Down) - id: ramp_switch_target_on type: bool restore_value: true initial_value: "false" # Prevent jitter when adjusting the slider - id: suppress_slider_sync type: bool restore_value: false initial_value: "false" # actual 0..100 seen last time, for restart - id: last_brightness_pct type: float restore_value: true initial_value: "0.0" # last published "Output Set (0-100)" integer - id: last_set_pos type: int restore_value: false initial_value: "-1" # helper to keep blink time == transition time - id: last_ramp_ms type: int restore_value: false initial_value: "0" # target for "Restore Brightness" scripted ramp - id: restore_target_pct type: float restore_value: false initial_value: "0.0" # true while a scripted ramp is in progress (to limit flash writes) - id: is_ramping type: bool restore_value: false initial_value: "false" # guards OFF persistence during startup caused by restore_mode: ALWAYS_OFF - id: booting type: bool restore_value: false initial_value: "true" # true only while we are performing a ramp-down that briefly turns the light ON to hit the floor - id: ramping_for_off type: bool restore_value: false initial_value: "false" #:########################################################################################:# # LOGGER COMPONENT # https://esphome.io/components/logger.html # Logs all log messages through the serial port and through MQTT topics. #:########################################################################################:# logger: level: "${log_level}" # INFO Level suggested, or DEBUG for testing baud_rate: 0 # set to 0 for no logging via UART, needed if you are using it for other serial things (eg PZEM, Serial control) #:########################################################################################:# # STATUS LED # https://esphome.io/components/status_led.html #:########################################################################################:# status_led: pin: number: ${device_status_led} inverted: true #:########################################################################################:# # MQTT COMMANDS # This adds device-specific MQTT command triggers to the common MQTT configuration. #:########################################################################################:# mqtt: on_message: # Light control to ramp up - topic: "${mqtt_local_command_topic}/light/set" payload: "${mqtt_local_device_command_ON}" then: - switch.turn_on: mosfet_ramp_switch # Light control to ramp down - topic: "${mqtt_local_command_topic}/light/set" payload: "${mqtt_local_device_command_OFF}" then: - switch.turn_off: mosfet_ramp_switch #:########################################################################################:# # SWITCH COMPONENT # https://esphome.io/components/switch/ #:########################################################################################:# switch: # Ramp-aware ON/OFF for HA (asymmetric, eased; no bounce) - platform: template id: mosfet_ramp_switch name: "${friendly_name} Fade Up-Down" icon: mdi:led-strip-variant lambda: |- return id(ramp_switch_target_on); turn_on_action: - lambda: |- id(ramp_switch_target_on) = true; - script.stop: ramp_off_script - script.execute: ramp_on_script turn_off_action: - lambda: |- id(ramp_switch_target_on) = false; - script.stop: ramp_on_script - script.execute: ramp_off_script #:########################################################################################:# # BUTTON COMPONENT # https://esphome.io/components/button/index.html #:########################################################################################:# button: # Start ramping UP (from current level) - platform: template id: fade_up_button name: "${friendly_name} Fade Up" icon: mdi:arrow-up-bold on_press: - lambda: |- id(ramp_switch_target_on) = true; id(mosfet_ramp_switch).publish_state(true); // reflect in HA immediately - script.stop: ramp_off_script - script.execute: ramp_on_script # Start ramping DOWN (from current level) - platform: template id: fade_down_button name: "${friendly_name} Fade Down" icon: mdi:arrow-down-bold on_press: - lambda: |- id(ramp_switch_target_on) = false; id(mosfet_ramp_switch).publish_state(false); // reflect in HA immediately - script.stop: ramp_on_script - script.execute: ramp_off_script # STOP any ramping (hold current brightness) - platform: template id: fade_stop_button name: "${friendly_name} Fade Stop" icon: mdi:pause on_press: # Stop any pending scripts (and their delayed actions) - script.stop: ramp_on_script - script.stop: ramp_off_script - lambda: |- // We are no longer ramping (up or down) id(ramping_for_off) = false; id(is_ramping) = false; // Freeze the light at its current level by overwriting the transition const auto &cv = id(mosfet_leds).current_values; if (cv.is_on()) { auto call = id(mosfet_leds).make_call(); call.set_state(true); call.set_brightness(cv.get_brightness()); call.set_transition_length(0); call.perform(); } // Persist the exact frozen level so restore survives a reboot if (cv.is_on()) { float pct = cv.get_brightness() * 100.0f; id(last_brightness_pct) = pct; } #:########################################################################################:# # SELECT COMPONENT # https://esphome.io/components/select/index.html #:########################################################################################:# select: - platform: template id: restart_action name: "${friendly_name} Restart Action" icon: mdi:restart optimistic: true options: - "Fade up to full" - "Restore Brightness" - "Remain Off" initial_option: "Restore Brightness" set_action: - lambda: |- if (x == "Fade up to full") { id(restart_mode) = 0; } else if (x == "Restore Brightness") { id(restart_mode) = 1; } else { id(restart_mode) = 2; } #:########################################################################################:# # BINARY SENSORS # https://esphome.io/components/binary_sensor/ #:########################################################################################:# binary_sensor: - platform: gpio id: btn_gpio12 name: "${friendly_name} Button" pin: number: ${device_usr_button} mode: input: true pullup: true inverted: true filters: - delayed_on: 30ms - delayed_off: 30ms on_press: - if: condition: lambda: "return id(ramp_switch_target_on);" then: # Target is currently ON -> press should go OFF (start ramp-down) - lambda: |- id(ramp_switch_target_on) = false; id(mosfet_ramp_switch).publish_state(false); // reflect in HA immediately - script.stop: ramp_on_script - script.execute: ramp_off_script else: # Target is currently OFF -> press should go ON (start ramp-up) - lambda: |- id(ramp_switch_target_on) = true; id(mosfet_ramp_switch).publish_state(true); // reflect in HA immediately - script.stop: ramp_off_script - script.execute: ramp_on_script #:########################################################################################:# # SENSOR COMPONENT # https://esphome.io/components/sensor/ #:########################################################################################:# sensor: - platform: template id: mosfet_output_pct name: "${friendly_name} Output (%)" unit_of_measurement: "%" icon: mdi:percent accuracy_decimals: 0 update_interval: 2s lambda: |- const auto &cv = id(mosfet_leds).current_values; return cv.is_on() ? (float) (int)(cv.get_brightness() * 100.0f + 0.5f) : 0.0f; on_value: then: - lambda: |- // If not suppressing sync, update the 0..100 slider only when its INT changes if (!id(suppress_slider_sync)) { float actual = x; // x is current Output (%) float minp = (float) id(min_brightness_pct); float maxp = (float) id(max_brightness_pct); if (maxp <= minp) maxp = minp + 1.0f; float pos = (actual <= 0.0f) ? 0.0f : ((actual - minp) * 100.0f / (maxp - minp)); if (pos < 0.0f) pos = 0.0f; if (pos > 100.0f) pos = 100.0f; int pos_i = (int) floorf(pos + 0.5f); if (pos_i != id(last_set_pos)) { id(last_set_pos) = pos_i; id(led_output_set_pct).publish_state(pos_i); } } - platform: template id: mosfet_output_pwm_pct name: "${friendly_name} Output PWM (%)" unit_of_measurement: "%" icon: mdi:square-wave accuracy_decimals: 1 update_interval: 2s lambda: |- const auto &cv = id(mosfet_leds).current_values; if (!cv.is_on()) return 0.0f; const float lin = cv.get_brightness(); float pwm = powf(lin, id(led_gamma_f)); if (pwm < 0.0f) pwm = 0.0f; if (pwm > 1.0f) pwm = 1.0f; return pwm * 100.0f; #:########################################################################################:# # OUTPUT COMPONENT # https://esphome.io/components/light/index.html #:########################################################################################:# # An OUTPUT can be binary (0,1) or float, which is any value between 0 and 1. # PWM Outputs such as "ledc" are float. https://esphome.io/components/output/ledc.html #:########################################################################################:# output: - platform: esp8266_pwm id: mosfet_pwm pin: ${device_mosfet_out} frequency: ${pwm_frequency} - platform: gpio id: green_led_out # Green LED pin: number: ${device_fading_led} inverted: false #:########################################################################################:# # LIGHT COMPONENT # https://esphome.io/components/light/ #:########################################################################################:# light: - platform: monochromatic id: mosfet_leds name: "${friendly_name}" output: mosfet_pwm restore_mode: RESTORE_DEFAULT_OFF default_transition_length: 2s icon: mdi:led-strip-variant gamma_correct: "${led_gamma}" # ON: publish state, track intent, arm watchdog if configured on_turn_on: - output.turn_on: green_led_out - mqtt.publish: topic: "${mqtt_local_status_topic}/light/state" payload: "${mqtt_local_device_command_ON}" retain: true - lambda: |- // Do not flip "intent to ON" when we are inside an OFF ramp's floor-kick if (!id(ramping_for_off)) { id(ramp_switch_target_on) = true; } - script.stop: max_on_watchdog - if: condition: lambda: "return id(max_on_hours) > 0;" then: - script.execute: max_on_watchdog # OFF: publish state, track intent, clear watchdog, and persist OFF so Mode 1 will not restore # Guarded by booting flag so the automatic OFF at boot from ALWAYS_OFF does not zero persistence. on_turn_off: - output.turn_off: green_led_out - if: condition: lambda: "return !id(booting);" then: - mqtt.publish: topic: "${mqtt_local_status_topic}/light/state" payload: "${mqtt_local_device_command_OFF}" retain: true - lambda: "id(ramp_switch_target_on) = false;" - script.stop: max_on_watchdog - lambda: |- id(last_brightness_pct) = 0.0f; id(last_set_pos) = 0; id(led_output_set_pct).publish_state(0); # Cap brightness to max_brightness_pct without a visible step on_state: - lambda: |- const float cap = id(max_brightness_pct) / 100.0f; const auto &cv = id(mosfet_leds).current_values; if (!cv.is_on()) return; const float b = cv.get_brightness(); if (b <= cap + 0.001f) return; // no call if already at/under cap auto call = id(mosfet_leds).make_call(); call.set_state(true); call.set_brightness(cap); call.set_transition_length(0); call.perform(); #:########################################################################################:# # NUMBER COMPONENT # https://esphome.io/components/number/ #:########################################################################################:# number: - platform: template id: cfg_ramp_up_s name: "${friendly_name} Fade Up Time (s)" entity_category: config unit_of_measurement: s icon: mdi:timer-sand mode: slider min_value: 0 max_value: 60 step: 1 lambda: |- return (float) id(ramp_up_ms) / 1000.0f; set_action: - lambda: |- int secs = (int) floorf(x + 0.5f); if (secs < 0) secs = 0; if (secs > 60) secs = 60; id(ramp_up_ms) = secs * 1000; id(cfg_ramp_up_s).publish_state((float) secs); - platform: template id: cfg_ramp_down_s name: "${friendly_name} Fade Down Time (s)" entity_category: config unit_of_measurement: s icon: mdi:timer-sand-complete mode: slider min_value: 0 max_value: 60 step: 1 lambda: |- return (float) id(ramp_down_ms) / 1000.0f; set_action: - lambda: |- int secs = (int) floorf(x + 0.5f); if (secs < 0) secs = 0; if (secs > 60) secs = 60; id(ramp_down_ms) = secs * 1000; id(cfg_ramp_down_s).publish_state((float) secs); - platform: template id: led_output_set_pct name: "${friendly_name} Output Set (0-100)" icon: mdi:tune mode: slider min_value: 0 max_value: 100 step: 1 # Show current position mapped into 0..100 across [min..max] lambda: |- const auto &cv = id(mosfet_leds).current_values; float actual = cv.is_on() ? (cv.get_brightness() * 100.0f) : 0.0f; // 0..100 actual float minp = (float) id(min_brightness_pct); float maxp = (float) id(max_brightness_pct); if (maxp <= minp) maxp = minp + 1.0f; // avoid div/0 if (actual <= 0.0f) return 0.0f; // when OFF, show 0 float pos = (actual - minp) * 100.0f / (maxp - minp); if (pos < 0.0f) pos = 0.0f; if (pos > 100.0f) pos = 100.0f; return floorf(pos + 0.5f); // integer set_action: - if: condition: lambda: "return x <= 0.0f;" then: # 0 means OFF - lambda: "id(suppress_slider_sync) = true;" - script.stop: ramp_on_script - script.stop: ramp_off_script - light.turn_off: id: mosfet_leds transition_length: 200ms - lambda: |- id(ramp_switch_target_on) = false; id(led_output_set_pct).publish_state(0); id(last_brightness_pct) = 0.0f; // persist OFF immediately id(last_set_pos) = 0; - delay: 400ms - lambda: "id(suppress_slider_sync) = false;" else: # Map 1..100 - [min..max] and set ON - lambda: |- id(suppress_slider_sync) = true; float pos = x; // 0..100 if (pos < 1.0f) pos = 1.0f; // 0 is OFF if (pos > 100.0f) pos = 100.0f; id(led_output_set_pct).publish_state((int) floorf(pos + 0.5f)); - script.stop: ramp_off_script - script.stop: ramp_on_script - light.turn_on: id: mosfet_leds brightness: !lambda |- float pos = id(led_output_set_pct).state; // 1..100 float minp = (float) id(min_brightness_pct); float maxp = (float) id(max_brightness_pct); if (maxp <= minp) maxp = minp + 1.0f; float out_pct = minp + (pos * (maxp - minp) / 100.0f); if (out_pct > maxp) out_pct = maxp; return out_pct / 100.0f; transition_length: 250ms - lambda: |- id(ramp_switch_target_on) = true; float pos = id(led_output_set_pct).state; // 1..100 float minp = (float) id(min_brightness_pct); float maxp = (float) id(max_brightness_pct); if (maxp <= minp) maxp = minp + 1.0f; float out_pct = minp + (pos * (maxp - minp) / 100.0f); if (out_pct > maxp) out_pct = maxp; id(last_brightness_pct) = out_pct; // persist exact target now - delay: 400ms - lambda: "id(suppress_slider_sync) = false;" - platform: template id: cfg_max_on_hours name: "${friendly_name} Max On (h)" entity_category: config unit_of_measurement: h icon: mdi:timer-cog mode: slider min_value: 0 max_value: 48 step: 1 lambda: |- return (float) id(max_on_hours); set_action: - lambda: |- int hrs = (int) x; if (hrs < 0) hrs = 0; if (hrs > 48) hrs = 48; id(max_on_hours) = hrs; id(cfg_max_on_hours).publish_state((float) hrs); - if: condition: lambda: "return id(mosfet_leds).current_values.is_on();" then: - script.stop: max_on_watchdog - if: condition: lambda: "return id(max_on_hours) > 0;" then: - script.execute: max_on_watchdog #:########################################################################################:# # SCRIPT COMPONENT # https://esphome.io/components/script.html # Scripts can be executed nearly anywhere in your device configuration with a single call. #:########################################################################################:# script: # Script: ramp up from current level. Obey global max. (LED ON while ramping) - id: ramp_on_script mode: restart then: - lambda: "id(is_ramping) = true;" - script.stop: ramp_off_script # Ensure we start at at least the floor without a visible "pop". - if: condition: lambda: |- const auto &cv = id(mosfet_leds).current_values; const float floor = id(min_brightness_pct) / 100.0f; return (!cv.is_on()) || (cv.get_brightness() + 0.0005f < floor); then: - light.turn_on: id: mosfet_leds brightness: !lambda "return id(min_brightness_pct) / 100.0f;" transition_length: 80ms # Ramp from current (>= floor) to cap over a fraction of ramp_up_ms. - light.turn_on: id: mosfet_leds brightness: !lambda "return id(max_brightness_pct) / 100.0f;" transition_length: !lambda |- const auto &cv = id(mosfet_leds).current_values; const float floor = id(min_brightness_pct) / 100.0f; const float cap = id(max_brightness_pct) / 100.0f; if (cap <= floor + 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; } float curr = cv.is_on() ? cv.get_brightness() : 0.0f; if (curr + 0.0005f < floor) { id(last_ramp_ms) = id(ramp_up_ms); return (uint32_t) id(last_ramp_ms); } if (curr > cap) curr = cap; if (curr < floor) curr = floor; float frac = (cap - curr) / (cap - floor); if (frac < 0.0f) frac = 0.0f; if (frac > 1.0f) frac = 1.0f; id(last_ramp_ms) = (int) (id(ramp_up_ms) * frac); return (uint32_t) id(last_ramp_ms); - delay: !lambda "return (uint32_t) id(last_ramp_ms);" - lambda: |- id(is_ramping) = false; const auto &cv = id(mosfet_leds).current_values; if (cv.is_on()) { float pct = cv.get_brightness() * 100.0f; id(last_brightness_pct) = pct; // persist final level } # Script: ramp to a specific target (Restore Brightness path). (LED ON while ramping) - id: ramp_to_target_script mode: restart then: - lambda: "id(is_ramping) = true;" - script.stop: ramp_off_script # Ensure we start at the floor cleanly. - if: condition: lambda: |- const auto &cv = id(mosfet_leds).current_values; const float floor = id(min_brightness_pct) / 100.0f; return (!cv.is_on()) || (cv.get_brightness() + 0.0005f < floor); then: - light.turn_on: id: mosfet_leds brightness: !lambda "return id(min_brightness_pct) / 100.0f;" transition_length: 80ms # Ramp from current (>= floor) to restore_target_pct over a fraction of ramp_up_ms. - light.turn_on: id: mosfet_leds brightness: !lambda |- float cap_pct = id(restore_target_pct); float maxp = (float) id(max_brightness_pct); if (cap_pct > maxp) cap_pct = maxp; return cap_pct / 100.0f; transition_length: !lambda |- const auto &cv = id(mosfet_leds).current_values; const float floor = id(min_brightness_pct) / 100.0f; float cap = id(restore_target_pct) / 100.0f; const float maxp = id(max_brightness_pct) / 100.0f; if (cap > maxp) cap = maxp; if (cap <= floor + 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; } float curr = cv.is_on() ? cv.get_brightness() : 0.0f; if (curr + 0.0005f < floor) { id(last_ramp_ms) = id(ramp_up_ms); return (uint32_t) id(last_ramp_ms); } if (curr > cap) curr = cap; if (curr < floor) curr = floor; float frac = (cap - curr) / (cap - floor); if (frac < 0.0f) frac = 0.0f; if (frac > 1.0f) frac = 1.0f; id(last_ramp_ms) = (int) (id(ramp_up_ms) * frac); return (uint32_t) id(last_ramp_ms); - delay: !lambda "return (uint32_t) id(last_ramp_ms);" - lambda: |- id(is_ramping) = false; const auto &cv = id(mosfet_leds).current_values; if (cv.is_on()) { float pct = cv.get_brightness() * 100.0f; id(last_brightness_pct) = pct; // persist final level } # Script: ramp down from current level to floor, then cleanly cut to OFF. (LED ON while ramping) - id: ramp_off_script mode: restart then: - lambda: |- id(is_ramping) = true; id(ramping_for_off) = true; - script.stop: ramp_on_script - light.turn_on: id: mosfet_leds brightness: !lambda "return id(min_brightness_pct) / 100.0f;" transition_length: !lambda |- const auto &cv = id(mosfet_leds).current_values; const float floor = id(min_brightness_pct) / 100.0f; float curr = cv.get_brightness(); if (curr < floor) curr = floor; const float denom = (1.0f - floor); if (denom <= 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; } float frac = (curr - floor) / denom; if (frac < 0.0f) frac = 0.0f; if (frac > 1.0f) frac = 1.0f; id(last_ramp_ms) = (int) (id(ramp_down_ms) * frac); return (uint32_t) id(last_ramp_ms); - delay: !lambda "return (uint32_t) id(last_ramp_ms);" - light.turn_off: id: mosfet_leds transition_length: 150ms - delay: 150ms - lambda: |- id(is_ramping) = false; id(ramping_for_off) = false; id(last_brightness_pct) = 0.0f; // persist OFF for restore path id(last_set_pos) = 0; id(led_output_set_pct).publish_state(0); - id: max_on_watchdog mode: restart then: - if: condition: lambda: "return id(max_on_hours) > 0;" then: - delay: !lambda "return (uint32_t) (id(max_on_hours) * 3600000UL);" - if: condition: lambda: "return id(mosfet_leds).current_values.is_on();" then: - lambda: |- id(ramp_switch_target_on) = false; id(mosfet_ramp_switch).publish_state(false); - script.stop: ramp_on_script - script.execute: ramp_off_script # Deferred restore to avoid early-boot races and ensure we apply the last saved level - id: deferred_restore_brightness mode: restart then: - delay: 5s # let preferences/globals load and light settle - lambda: |- // Use ONLY the exact last saved brightness. float target = id(last_brightness_pct); // Clamp into [min..max] only if we are restoring a non-zero target float minp = (float) id(min_brightness_pct); float maxp = (float) id(max_brightness_pct); if (target > 0.0f && target < minp) target = minp; if (target > maxp) target = maxp; id(restore_target_pct) = target; - if: condition: lambda: "return id(restore_target_pct) > 0.5f;" then: - lambda: |- id(ramp_switch_target_on) = true; // reflect our intent id(suppress_slider_sync) = true; - script.stop: ramp_off_script - script.execute: ramp_to_target_script - delay: !lambda "return (uint32_t) id(last_ramp_ms);" - lambda: "id(suppress_slider_sync) = false;" else: # Exact last was 0 -> remain OFF - lambda: "id(ramp_switch_target_on) = false;" - light.turn_off: id: mosfet_leds transition_length: 0s # (Removed: output.turn_off: green_led_out) # make sure LED is off if no ramp