########################################################################################## ########################################################################################## # Title: DOWNSTAIRS KITCHEN - OVER PANTRY LEDS # Hardware: Sinilink Mosfet Board XY-VFMS (ESP8266) # https://devices.esphome.io/devices/Sinilink-XY-VFMS # Repo: https://home.fox.co.nz/gitea/zorruno/zorruno-homeassistant/src/branch/master/esphome/esp-downstairskitchleds.yaml # # V1.1 - 2025-08-18 Full tidyup as general purpose LED strip controller # V1.0 - 2025-08-17 First Setup (and replacement of Tasmota) # # ------------------------------------------ # DEVICE GPIO Sinilink XY-VFMS # ------------------------------------------ # GPIO02 Blue LED (We'll use this for ESPHome status) # GPIO04 Mosfet Output (0V when switched) and Red LED # GPIO12 Toggle Button # GPIO13 Green LED (We'll use this to display fading status) # # ------------------------------------------ # OPERATION (as at V1.1) # ------------------------------------------ # 1. General Purpose LED controller # 2. Designed for a Sinilink XY-VFMS board that has a mosfet output and supposedly will handle # 5A and a DC input of 5V-36V. # 3. Has global setting for MAX % PWM output for the LEDs so you can give them a longer life. # 4. Has a min setting for the LEDs and it will switch off if it goes below that to stop any # flicker at very low PWM outputs. # 5. PWM freq is set to 2kHz, but you could potentially ramp it up. I was getting resets at # higher values with this device, but other devices may be better. Obviously if yoou use # an esp32 you can set it much higher (40kHz I think is the max?) # 6. Min and Max output settings aren't set in Home assistant/MQTT, but you could do this if # needed. With a 1MB flash, it is starting to get tight. I have done minimal optimising # at this stage though. # 7. There are PACKAGES included for the common things such as the network # items, diagnostic entities, MQTT and SNTP (if needed, get them from the repo or use your own) # 8. Default config is to always fade slowly up to full when powered up (so can be deployed with # no network etc) # 9. The green LED on the sinilink flashes whilst fading (differently for up/down). The red LED # follows the output (it is the same GPIO as the MOSFET) # 10.Timing of fades should be based on settings, or a percentage of them if eg already half brightness. # 11.A useful 3D printed case: https://cults3d.com/en/3d-model/tool/snapfit-enclosure-for-esp8266-sinilink-xy-wfms-5v-36v-mosfet-switch-module # 12.Some things you can change in Home Assistant/MQTT # - Start up function # - Up/Down/Stop fade buttons # - A fade up/fade down switch # - Normal on/off switch (quick ramp up/down) # - Setting for fade up and fade times (0-60 seconds) # - Output display of % PWM output # - Ability to set output to any value (1-100, but respects min/max) # - Default has a bunch of device diagnostic in the PACKAGE included (Sensors_Common) # ########################################################################################### ########################################################################################## ########################################################################################## # 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-downstairskitchleds" friendly_name: "Downstairs Kitchen LEDs" description_comment: "Downstairs Kitchen Over Pantry LEDs :: Sinilink XY-WFMS" device_area: "Downstairs Kitchen" # Allows ESP device to be automatically linked to an 'Area' in Home Assistant. # Project Naming project_name: "Sinilink.XY-WFMS" # Project Details project_version: "v1.1" # Project V denotes release of yaml file, allowing checking of deployed vs latest version # Passwords & Secrets api_key: !secret esp-api_key ota_pass: !secret esp-ota_pass static_ip_address: !secret esp-downstairskitchleds_ip # unfortunately you can't use substitutions inside secrets names mqtt_local_command_main_topic: !secret mqtt_local_command_main_topic mqtt_local_status_main_topic: !secret mqtt_local_status_main_topic # Device Settings log_level: "INFO" # Define logging level: NONE, ERROR, WARN, INFO, DEBUG (Default), VERBOSE, VERY_VERBOSE update_interval: "20s" # update time for for general sensors etc # MQTT LOCAL Controls mqtt_local_device_name: "downstairskitchen-pantryleds" 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" # MQTT REMOTE Controls #mqtt_remote_device_name: "downstairskitchen-pantryleds" #mqtt_remote_device_command_topic: "${mqtt_local_command_main_topic}/${mqtt_remote_device_name}/light/set" #mqtt_remote_device_command1: "+" #mqtt_remote_device_command2: "-" #mqtt_remote_device_command3: "0" #mqtt_local_status_topic: "${mqtt_local_status_main_topic}/${mqtt_remote_device_name}/speed/state" # Topic we will use to view status locally without HA # Button Naming & Icons # Switch/Relay Naming & Icons ########################################################################################## # PACKAGES: Included Common Packages # https://esphome.io/components/packages.html ########################################################################################## packages: common_wifi: !include file: common/network_common.yaml vars: local_device_name: "${device_name}" local_static_ip_address: "${static_ip_address}" local_ota_pass: "${ota_pass}" common_api: !include file: common/api_common.yaml vars: local_api_key: "${api_key}" #common_webportal: !include # file: common/webportal_common.yaml common_mqtt: !include file: common/mqtt_common.yaml vars: local_device_name: "${device_name}" #common_sntp: !include # file: common/sntp_common.yaml common_general_sensors: !include file: common/sensors_common.yaml vars: local_friendly_name: "${friendly_name}" local_update_interval: "${update_interval}" ########################################################################################## # ESPHome CORE CONFIGURATION # https://esphome.io/components/esphome.html ########################################################################################## esphome: name: "${device_name}" friendly_name: "${friendly_name}" comment: "${description_comment}" # Appears on the esphome page in HA area: "${device_area}" on_boot: priority: -200 then: - lambda: |- ESP_LOGI("boot", "Last reset reason: %s", ESP.getResetReason().c_str()); // Keep the HA dropdown in sync with the stored mode 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 (respect min/max & ramp time) - 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 quickly - if: condition: lambda: 'return id(restart_mode) == 1;' then: - lambda: |- // Clamp the remembered brightness to valid bounds float target = id(last_brightness_pct); if (target < 0.0f) target = 0.0f; if (target > 100.0f) target = 100.0f; float minp = (float) id(min_brightness_pct); float maxp = (float) id(max_brightness_pct); if (target > 0.0f) { if (target < minp) target = minp; if (target > maxp) target = maxp; } id(suppress_slider_sync) = true; if (target <= 0.0f) { auto call = id(mosfet_leds).make_call(); call.set_state(false); call.set_transition_length(0); call.perform(); id(ramp_switch_target_on) = false; } else { auto call = id(mosfet_leds).make_call(); call.set_state(true); call.set_brightness(target / 100.0f); call.set_transition_length(150); call.perform(); id(ramp_switch_target_on) = true; } - delay: 300ms - lambda: 'id(suppress_slider_sync) = false;' # Mode 2: Remain Off - if: condition: lambda: 'return id(restart_mode) == 2;' then: - script.stop: ramp_on_script - script.stop: ramp_off_script - light.turn_off: id: mosfet_leds transition_length: 0s - lambda: 'id(ramp_switch_target_on) = false;' ########################################################################################## # 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 preferences: flash_write_interval: 5min mdns: disabled: false # Disabling will make the build file smaller (and it is still available via static IP) ########################################################################################## # 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: '3' # start/finish at X% # Maximum Brightness % for LEDs (should never go beyond this) - id: max_brightness_pct type: int restore_value: false initial_value: '90' # hard cap; never exceed this # 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 tirection. # true when you’ve asked the light to end up ON (ramp up) # and false when you’ve asked it to end up OFF - 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' ########################################################################################## # 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) ########################################################################################## # 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 up - topic: "${mqtt_local_command_topic}/light/set" payload: "${mqtt_local_device_command_OFF}" then: - switch.turn_off: mosfet_ramp_switch ######################################################################################### # STATUS LED # https://esphome.io/components/status_led.html ######################################################################################### # SINILINK: Status LED Blue LED on GPIO2, active-low ######################################################################################### status_led: pin: number: GPIO2 inverted: true ########################################################################################## # 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 turn_off) - script.stop: ramp_on_script - script.stop: ramp_off_script - script.stop: led_flash_up - script.stop: led_flash_down - output.turn_off: green_led_out # Cancel the light's transition by commanding the current level with 0 ms, # but DO NOT change the ramp switch state/flag. - lambda: |- 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(); } ######################################################################################### # 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: GPIO12 mode: input: true pullup: true inverted: true filters: - delayed_on: 20ms - delayed_off: 20ms 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: 100ms # consider 200ms if you want fewer updates lambda: |- const auto &cv = id(mosfet_leds).current_values; return cv.is_on() ? (cv.get_brightness() * 100.0f) : 0.0f; on_value: then: - lambda: |- // Remember latest actual output (0..100) for "Restore Brightness" id(last_brightness_pct) = x; // If not suppressing sync, update the 0..100 slider only when its INT changes if (!id(suppress_slider_sync)) { float actual = x; // actual % 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); } } ########################################################################################## # 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: GPIO4 frequency: 2000 Hz # high frequency to avoid audible/visible artifacts - platform: gpio id: green_led_out # Green LED pin: number: GPIO13 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: 1.2 on_turn_on: - mqtt.publish: topic: "${mqtt_local_status_topic}/light/state" payload: "${mqtt_local_device_command_ON}" retain: true - lambda: 'id(ramp_switch_target_on) = true;' on_turn_off: - mqtt.publish: topic: "${mqtt_local_status_topic}/light/state" payload: "${mqtt_local_device_command_OFF}" retain: true - lambda: 'id(ramp_switch_target_on) = false;' on_state: - lambda: |- const float cap = id(max_brightness_pct) / 100.0f; const auto &cv = id(mosfet_leds).current_values; if (cv.is_on() && cv.get_brightness() > cap + 0.001f) { 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); - 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;' - delay: 400ms - lambda: 'id(suppress_slider_sync) = false;' ########################################################################################## # SCRIPT COMPONENT # https://esphome.io/components/script.html # Scripts can be executed nearly anywhere in your device configuration with a single call. ########################################################################################## script: # Blink pattern while ramping UP: quick double-blink, pause, repeat - id: led_flash_up mode: restart then: - while: condition: lambda: 'return true;' then: - output.turn_on: green_led_out - delay: 100ms - output.turn_off: green_led_out - delay: 100ms - output.turn_on: green_led_out - delay: 100ms - output.turn_off: green_led_out - delay: 400ms # Blink pattern while ramping DOWN: steady slow blink - id: led_flash_down mode: restart then: - while: condition: lambda: 'return true;' then: - output.turn_on: green_led_out - delay: 250ms - output.turn_off: green_led_out - delay: 250ms # Script: ramp up from current level. Obey global max. - id: ramp_on_script mode: restart then: - script.stop: ramp_off_script - script.stop: led_flash_down - script.execute: led_flash_up - 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() < floor); then: - light.turn_on: id: mosfet_leds brightness: !lambda 'return id(min_brightness_pct) / 100.0f;' transition_length: 0s - 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; float curr = cv.is_on() ? cv.get_brightness() : 0.0f; if (curr < floor) curr = floor; if (curr > cap) curr = cap; 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);' - script.stop: led_flash_up - output.turn_off: green_led_out # Script: ramp down from current level to floor, then cleanly cut to OFF - id: ramp_off_script mode: restart then: - script.stop: ramp_on_script - script.stop: led_flash_up - script.execute: led_flash_down - 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.is_on() ? cv.get_brightness() : 0.0f; if (curr < floor) curr = floor; float frac = (curr - floor) / (1.0f - floor); 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 - script.stop: led_flash_down - output.turn_off: green_led_out - lambda: |- auto call = id(mosfet_leds).make_call(); call.set_state(false); call.set_brightness(id(max_brightness_pct) / 100.0f); call.perform();