My Ultimate LED Strip Controller

Changes

2025-08-22 V1.3 Minor Changes
2025-08-19 V1.0 First version

Quick Links

ESPHome YAML on Gitea for this device
Hardware Module I used, on Aliexpress
3D Printed Case I used, on Thingverse, and Cults3D

Summary

I have a bunch of LED strips around the place – pretty much all single channel/single colour, and 12V or 24V. They are driven by all manner of things, a couple are zigbee switched, some are relay switched from a sonoff or other micro board, but most were PWM switched with devices like H801 (5 channel mosfet switch) or those tuya or magichome style inline LED controllers with the 5.5×2.1mm DC jacks (see the pic on the right. I had modified all the wifi ones with Tasmota, which is usually very fiddly to do on the small inline units). Those inline modules have mostly gone away from esp8266 controllers so it is less fun to put sensible local firmware in them.

Because of my push to “ESPHome for everything”, and also having devices be more standalone, I wanted to create something relatively standard to replace all my controllers with.

Sinilink

I would rather find an existing board than try to build my own, and wanted something smallish, and not too fiddly to work with that had a MOSFET for PWM control. I started working on a plan to just use up some D1 Minis, and a separate MOSFET board in a case (maybe the cheap IRF520?), but a hunt though my project box and I found a couple of these Sinilink XY-WFMS modules, which are about NZ$6 shipped from China.

The advantage of using these was pretty clear immediately as not only were they a MOSFET controller (theoretically capable of 5A control, but I doubt I’d trust that rating), but also I could run any strip from 5V up to 36V. They also have a nice screw terminal for just “power supply in” and “power out” (which I could PWM control). There was really no other I/O I needed.

The WFMS has an ESP-12F module (ESP-8266) so it is easily flashable with ESPHome, which was the goal. It would be nice to have an ESP32 variant, but not necessary in this case (except for possibly being able to improve PWM frequency). Maybe a simple board design would be a project in future.

Sinilink also make a bunch of other modules and I’d also used their inline USB switch previously (XY-WFUSB). This has a nice full plastic (blue clear) case, and would likely be good for PWM 5V LED control, although I’ve only used them for switching on/off USB devices to date (and don’t have any 5V LED strips). They are also even more fiddly to flash! The USB devices of theirs weirdly says “3.5-20V”, but I’m not sure why when it is designed to plug into a USB port.

If you want to try alternatives, I have used a bunch of these H801 modules as they have a case and 5 outputs (for RGB control, but there is no problem using each MOSFET output for other purposes), and have a couple of these on the way to try (2 or 4 MOSFET outputs).

Housing

Speaking of cases, one of the goals was to have it tidily in a case, and I found a great 3D printed case design for it (also here). Someone has spent time to make a clip fit lid and little light pipes for the LEDs, with the push button still useable with a sprung button on top. Nice work. The only thing to watch is that the ESP-12F module must sit a bit higher than their original design as the light pipe for this LED on this is a fraction too long and if the lid is closed with force it will break off. Just a little sanding of the tube fixes this.

Overall Function Wishlist

These are the actions below that I wanted in the LED controller.

  • A General Purpose LED strip controller.
  • A single Home Assistant command should do a slow ramp up/down
  • The LED strip should be able to be controlled to any brightness level.
  • Commands need to be from Home Assistant (as well as ESPHome MQTT, and my simpler bespoke local MQTT setup)
  • Designed for a Sinilink XY-VFMS board that has a mosfet output and supposedly will handle 5A and a DC input of 5V-36V. Could easily be used for other MOSFET boards however.
  • A global setting for Max % LED output for the LEDs so you can give them a longer life.
  • A global min setting for the LEDs and it will switch off if it goes below that to stop any flicker at very low PWM outputs.
  • High PWM freq if possible (low PWM was an issue I faced in Tasmota)
  • Cram as much control as possible in, in case useful for some strips in future.
  • Lots of diagnostics.
  • Default config to always fade slowly up to full when powered up (so can be deployed with no network etc. Switch power on and it ramps up). Some other startup options should be selectable.
  • Timing of fades should be based on selectable timing
  • settings, or a reasonably smart percentage of them if eg already half brightness).
  • Fading up and down needs to be capable at any point with up/down/stop.

Functions Implemented

Some things you can change or view in Home Assistant/MQTT as implemented:

  • Start up function (ramp up to full, previous brightness, off)
  • Up/Down/Stop fade buttons
  • A fade up/fade down toggle switch (which remembers last direction. This is my main on off switch)
  • Standard on/off switch (quick ramp up/down)
  • A setting for fade up and fade down times (from 0-60 seconds for each)
  • An output display of the actual % PWM output (up to the min/max)
  • Ability to set output to any value (1-100, but respects min/max, eg 100 is Max %)
  • Default has a bunch of device diagnostic in the PACKAGE included (Sensors_Common)
  • Some minimal controls directly from MQTT (suits my bespoke setup, were my ESPHome devices send commands to each other directly. In this case a command to turn on and off in this case with fading).

Other notes for the implementation:

  • Designed for a Sinilink XY-VFMS board that has a mosfet output and supposedly will handle 5A and a DC input of 5V-36V. Could easily be used for other MOSFET boards however.
  • Global setting for Max and Min % PWM output. These are globals and aren’t set in Home assistant/MQTT, but you could do this if needed (for my purposes, they are just a one off setting for each strip)
  • 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 you use an ESP32 you could set it much higher (40kHz I think is the max?)
  • With 1MB flash, it is starting to get tight. I have done minimal optimising at this stage though, so more could be squeezed in. Sometimes ESPHome complains about space, but a reboot of the device or a retry allows an upload for some reason.
  • I have PACKAGES included for common things such as the network items, diagnostic entities, MQTT and SNTP (if needed, get them from my repo or just use your own)
  • Default config is to always fade slowly up to full when powered up (so can be deployed with no network etc). Other startup options are selectable.
  • The green LED on the Sinilink flashes whilst fading (different flash pattern for up vs down). The LED was doing nothing, so this is a good use.
  • The red LED follows the output (it is the same GPIO as the MOSFET)
  • Handling of changes in fade direction are relatively smart.

Final Device in Case

ESPHome YAML

You can download the (hopefully) latest YAML from my git repository if it is up (it isn’t always). I’ve pasted some YAML below, but it may not be up to date. Remember as above it includes PACKAGE files not shown here, but you can grab mine from the git repo or create your own for standard YAML sections such as Wifi etc. The YAML code below is pretty well commented (as usual for me…) so should be mostly self explanatory.

##########################################################################################
##########################################################################################
# Title:    DOWNSTAIRS KITCHEN - OVER PANTRY LEDS
# Hardware: Sinilink MOSFET Board XY-WFMS (ESP8266)  — sometimes listed as “XY-VFMS”
#           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.3 - 2025-08-22 Added a "max on time” setting (1-48 h, 0 = no limit)
# v1.2 - 2025-08-21 Added defaults to “Device Specific Settings” in substitutions & a PWM % view
# v1.1 - 2025-08-18 Full tidy-up as general-purpose LED strip controller
# v1.0 - 2025-08-17 First setup (and replacement of Tasmota)
#
# ------------------------------------------
# DEVICE GPIO (Sinilink XY-WFMS)
# ------------------------------------------
# GPIO02  Blue LED (used for ESPHome status)
# GPIO04  MOSFET output (0 V when switched) and Red LED
# GPIO12  Toggle button
# GPIO13  Green LED (used to display fading status)
#
# ------------------------------------------
# OPERATION (as of v1.3)
# ------------------------------------------
# 1. General-purpose LED controller. 
# 2. Designed for the Sinilink XY-WFMS board with a MOSFET output (claimed 5A, 5-36 V DC).
# 3. Global setting for MAX % output to extend LED life.
# 4. Minimum output setting; switches fully OFF at/below the minimum to avoid low-PWM flicker.
# 5. PWM frequency is set to 500 Hz by default. You can increase it, but higher values caused
#    resets on this device. On ESP32 you can run much higher (~40 kHz).
# 6. Min/Max output settings are not exposed in Home Assistant/MQTT by default, but can be.
#    With a 1 MB flash, space is tight and only minimal optimisation has been done so far.
# 7. PACKAGES include common items: network settings, diagnostic entities, MQTT, and SNTP (optional).
# 8. Default behaviour is to fade slowly up to full at power-up (so it can run with no network).
# 9. The green LED flashes while fading (different patterns for up/down). The red LED follows the
#    output (it shares the MOSFET GPIO).
# 10. Fade timing scales with the configured values (proportionally when starting mid-brightness).
# 11. Useful 3D-printed case: https://cults3d.com/en/3d-model/tool/snapfit-enclosure-for-esp8266-sinilink-xy-wfms-5v-36v-mosfet-switch-module
# 12. Exposed in Home Assistant/MQTT:
#     - Startup action
#     - Fade Up / Fade Down / Fade Stop buttons
#     - Fade Up/Down switch
#     - Normal On/Off switch (quick ramp up/down)
#     - Fade up/down times (0-60s)
#     - Output % (pre-gamma) and PWM % (post-gamma)
#     - Output Set (1-100, respects min/max)
#     - Device diagnostics (from the included package)
#     - Maximum 'on' time before automatic fade-down (1-48 h, 0 = no limit)
#
##########################################################################################
##########################################################################################


##########################################################################################
# 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 the ESP device to be automatically linked to an 'Area' in Home Assistant.

  # Project Naming
  project_name: "Sinilink.XY-WFMS" # Project details
  project_version: "v1.3"  # Project version denotes release of the 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 secret names
  mqtt_local_command_main_topic: !secret mqtt_local_command_main_topic
  mqtt_local_status_main_topic: !secret mqtt_local_status_main_topic
  
  # 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"

  # 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'll 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: "6" # The maximum time the LEDs will be on, in case they get left on. 0 = no automatic turn-off

##########################################################################################
# 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
    file: common/api_common_noencryption.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
    file: common/sensors_common_lite.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;'
  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

#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: "${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}'
  # 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'        

##########################################################################################    
# 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: 250ms   # 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);
              }
            }
  - platform: template
    id: mosfet_output_pwm_pct
    name: "${friendly_name} Output PWM (%)"
    unit_of_measurement: "%"
    icon: mdi:square-wave
    accuracy_decimals: 1
    update_interval: 250ms
    lambda: |-
      const auto &cv = id(mosfet_leds).current_values;
      if (!cv.is_on()) return 0.0f;
      const float lin = cv.get_brightness();        // 0..1 (linear brightness)
      const float gamma = atof("${led_gamma}");     // parse substitution string → float
      float pwm = powf(lin, gamma);                 // approx PWM duty after gamma
      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: GPIO4
    frequency: 500 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: "${led_gamma}"
    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;'
      - script.stop: max_on_watchdog
      - if:
          condition:
            lambda: 'return id(max_on_hours) > 0;'
          then:
            - script.execute: max_on_watchdog
    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;'
      - script.stop: max_on_watchdog
    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;'

  - 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:
  # 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();
  - 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

Other Links

A Case design for an Node MCU (ESP8266) and an IRF520 Mosfet module

Leave a Reply

Your email address will not be published. Required fields are marked *

Post comment