#:########################################################################################:#
# TITLE: LOUNGE - PELMET LEDS (H801, 2x Monochrome PWM)
# zorruno.com layout v1.1 2026
#:########################################################################################:#
# REPO:
# https://home.fox.co.nz/gitea/zorruno/zorruno-homeassistant/src/branch/master/esphome/esp-loungepelmetleds2.yaml
#:########################################################################################:#
# VERSIONS:
# V2.8 2026-03-11 Updated yaml to zorruno layout V1.1
# V2.7 2025-09-30 A bunch of fixes for final version
# V2.0 2025-09-30 Based on single MOSFET version; adapted to H801 dual-monochrome (A/B)
# V1.x  History from single-channel base maintained in prior comments
#:########################################################################################:#
# HARDWARE:
# - H801 (ESP8266, 5 MOSFET outputs)
# - Used here as 2 independent monochrome PWM channels (A and B)
# - Reference hardware:
#   https://devices.esphome.io/devices/Sinilink-XY-WFMS
# - Device GPIO as used here:
#   - GPIO02  Blue status LED, active-low (ESPHome status)
#   - GPIO15  MOSFET output A (PWM)
#   - GPIO13  MOSFET output B (PWM)
#   - GPIO05  Red LED (used to display status for Channel A)
#   - GPIO01  Green LED (used to display status for Channel B) [UART TX, logger baud=0]
#:########################################################################################:#
# OPERATION NOTES:
# - General-purpose LED controller using an H801 board.
# - Designed for 2 independent monochrome outputs:
#   - Channel A
#   - Channel B
# - Minimum and maximum output limits are defined in SUBSTITUTIONS per channel.
# - Minimum output allows the light to switch fully OFF at or below the threshold to avoid low-PWM flicker.
# - PWM frequency is set in SUBSTITUTIONS.
# - Restart behaviours:
#   - Fade up to full
#   - Restore Brightness
#   - Remain Off
# - Fade timing scales with the configured values.
# - Exposed in Home Assistant for both A and B:
#   - Restart action
#   - Fade Up-Down switch
#   - Fade up/down times
#   - Output percentage
#   - Output Set slider
#   - Shared maximum ON time
# - Fade Up-Down switch auto-syncs to brightness thresholds and does not itself trigger a ramp during sync.
# - This yaml currently uses Home Assistant API and diagnostics packages.
# - MQTT and SNTP packages are currently disabled in this file.
#:########################################################################################:#
# OFFLINE NOTES:
# a) Home Assistant offline (network online):
#    - Device continues to run its local light logic and stored restart behaviour.
#    - Existing light states, watchdog timers, and fades continue locally.
#    - No Home Assistant control is available until API connectivity returns.
#
# b) MQTT offline (or HA/MQTT offline):
#    - MQTT is not enabled in this yaml, so MQTT offline has no direct effect on device logic.
#    - If Home Assistant/API is also offline, control from HA is unavailable.
#
# c) Entire WiFi/Network offline:
#    - Device continues to run its local light logic and stored restart behaviour.
#    - No HA or MQTT control is available.
#    - 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-loungepelmetleds2" # yaml file should be device_name.yaml
  friendly_name: "Lounge Pelmet LEDs"
  description_comment: "Lounge Pelmet LEDs :: H801 dual-channel monochrome (Layout V1.1)"
  device_area: "Lounge" # Allows the ESP device to be automatically linked to an 'Area' in Home Assistant.

  # Project Naming
  project_name: "H801.5xMOSFET" # Manufacturer before the dot, device after the dot.
  project_version: "v2.8"       # Project version denotes release of the YAML file

  # Passwords & Secrets (Unfortunately, you can't use substitutions inside secret names)
  api_key: !secret esp-api_key
  ota_pass: !secret esp-ota_pass
  static_ip_address: !secret esp-loungepelmetleds2_ip
  current_ip_address: ${static_ip_address}

  # Device Specific Settings (shared defaults applied per channel below)
  log_level: "NONE"     # Use INFO while testing; flip back to NONE later
  update_interval: "60s"
  led_gamma: "1.6"
  pwm_frequency: "1000Hz"
  max_on_default_hours: "24"

  # Per-channel output caps (%), internal-only (no HA sliders)
  minimum_led_output_A: "1"
  maximum_led_output_A: "90"
  minimum_led_output_B: "1"
  maximum_led_output_B: "90"

  # Device Specific GPIO
  #device_status_led: GPIO5
  device_mosfet_out_A: GPIO15
  device_mosfet_out_B: GPIO13
  device_fading_led_A: GPIO5
  device_fading_led_B: GPIO1

#:########################################################################################:#
# 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}"
  project:
    name: "${project_name}"
    version: "${project_version}"
  on_boot:
    priority: 200
    then:
      - 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;
          }
      - if:
          condition:
            lambda: 'return id(restart_mode) == 0;'
          then:
            - lambda: 'id(ramp_switch_target_on_A) = true;'
            - lambda: 'id(ramp_switch_target_on_B) = true;'
            - script.execute: ramp_on_script_A
            - script.execute: ramp_on_script_B
      - if:
          condition:
            lambda: 'return id(restart_mode) == 1;'
          then:
            - script.execute: deferred_restore_brightness_A
            - script.execute: deferred_restore_brightness_B
      - if:
          condition:
            lambda: 'return id(restart_mode) == 2;'
          then:
            - script.stop: ramp_on_script_A
            - script.stop: ramp_off_script_A
            - lambda: 'id(ramp_switch_target_on_A) = false;'
            - light.turn_off:
                id: mosfet_leds_A
                transition_length: 0s
            - script.stop: ramp_on_script_B
            - script.stop: ramp_off_script_B
            - lambda: 'id(ramp_switch_target_on_B) = false;'
            - light.turn_off:
                id: mosfet_leds_B
                transition_length: 0s
      - lambda: 'id(booting) = false;'

# 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

mdns:
  disabled: true

preferences:
  flash_write_interval: 5min

#:########################################################################################:#
# GLOBAL VARIABLES:
# https://esphome.io/components/globals.html
#:########################################################################################:#
globals:
  # Per-channel brightness caps (internal; set via YAML substitutions)
  - id: min_brightness_pct_A
    type: int
    restore_value: true
    initial_value: "${minimum_led_output_A}"

  - id: max_brightness_pct_A
    type: int
    restore_value: false
    initial_value: "${maximum_led_output_A}"

  - id: min_brightness_pct_B
    type: int
    restore_value: true
    initial_value: "${minimum_led_output_B}"

  - id: max_brightness_pct_B
    type: int
    restore_value: false
    initial_value: "${maximum_led_output_B}"

  # Max on hours (shared)
  - id: max_on_hours
    type: int
    restore_value: true
    initial_value: "${max_on_default_hours}"

  # Per-channel Fade Up/Down times (seconds -> ms)
  - id: ramp_up_ms_A
    type: int
    restore_value: true
    initial_value: "5000"

  - id: ramp_down_ms_A
    type: int
    restore_value: true
    initial_value: "10000"

  - id: ramp_up_ms_B
    type: int
    restore_value: true
    initial_value: "5000"

  - id: ramp_down_ms_B
    type: int
    restore_value: true
    initial_value: "10000"

  # Restart Action. (0=Fade full, 1=Restore brightness, 2=Remain off)
  - id: restart_mode
    type: int
    restore_value: true
    initial_value: "1"   # align with select initial_option "Restore Brightness"

  # Per-channel runtime state (A)
  - id: ramp_switch_target_on_A
    type: bool
    restore_value: true
    initial_value: "false"

  - id: suppress_slider_sync_A
    type: bool
    restore_value: false
    initial_value: "false"

  - id: suppress_quick_on_A
    type: bool
    restore_value: false
    initial_value: "false"

  - id: last_brightness_pct_A
    type: float
    restore_value: true
    initial_value: "0.0"

  - id: last_set_pos_A
    type: int
    restore_value: false
    initial_value: "-1"

  - id: last_ramp_ms_A
    type: uint32_t
    restore_value: false
    initial_value: "0"

  - id: last_nonzero_brightness_pct_A
    type: float
    restore_value: true
    initial_value: "0.0"

  - id: restore_target_pct_A
    type: float
    restore_value: false
    initial_value: "0.0"

  - id: is_ramping_A
    type: bool
    restore_value: false
    initial_value: "false"

  - id: last_nonzero_tmp_A
    type: float
    restore_value: false
    initial_value: "0.0"

  - id: ramping_for_off_A
    type: bool
    restore_value: false
    initial_value: "false"

  # Per-channel runtime state (B)
  - id: ramp_switch_target_on_B
    type: bool
    restore_value: true
    initial_value: "false"

  - id: suppress_slider_sync_B
    type: bool
    restore_value: false
    initial_value: "false"

  - id: suppress_quick_on_B
    type: bool
    restore_value: false
    initial_value: "false"

  - id: last_brightness_pct_B
    type: float
    restore_value: true
    initial_value: "0.0"

  - id: last_set_pos_B
    type: int
    restore_value: false
    initial_value: "-1"

  - id: last_ramp_ms_B
    type: uint32_t
    restore_value: false
    initial_value: "0"

  - id: last_nonzero_brightness_pct_B
    type: float
    restore_value: true
    initial_value: "0.0"

  - id: restore_target_pct_B
    type: float
    restore_value: false
    initial_value: "0.0"

  - id: is_ramping_B
    type: bool
    restore_value: false
    initial_value: "false"

  - id: last_nonzero_tmp_B
    type: float
    restore_value: false
    initial_value: "0.0"

  - id: ramping_for_off_B
    type: bool
    restore_value: false
    initial_value: "false"

  # Guard for startup behaviour
  - id: booting
    type: bool
    restore_value: false
    initial_value: "true"

  # Compile time Gamma (cached; currently unused since PWM% sensors were removed)
  - id: led_gamma_f
    type: float
    restore_value: false
    initial_value: ${led_gamma}

#:########################################################################################:#
# LOGGER:
# https://esphome.io/components/logger.html
#:########################################################################################:#
logger:
  level: "${log_level}"
  baud_rate: 0

#:########################################################################################:#
# STATUS LED:
# https://esphome.io/components/status_led.html
#:########################################################################################:#
#status_led:
#  pin:
#    number: ${device_status_led}
#    inverted: true

#:########################################################################################:#
# MQTT COMMANDS:
#:########################################################################################:#
#mqtt:
#  on_message handlers commented for efficiency. Uncomment if you need local MQTT control again.
#  on_message:
#    - topic: "${mqtt_local_command_topic_A}/lightA/set"
#      payload: "${mqtt_local_device_command_ON}"
#      then:
#        - switch.turn_on: mosfet_ramp_switch_a
#    - topic: "${mqtt_local_command_topic_A}/lightA/set"
#      payload: "${mqtt_local_device_command_OFF}"
#      then:
#        - switch.turn_off: mosfet_ramp_switch_a
#
#    - topic: "${mqtt_local_command_topic_B}/lightB/set"
#      payload: "${mqtt_local_device_command_ON}"
#      then:
#        - switch.turn_on: mosfet_ramp_switch_b
#    - topic: "${mqtt_local_command_topic_B}/lightB/set"
#      payload: "${mqtt_local_device_command_OFF}"
#      then:
#        - switch.turn_off: mosfet_ramp_switch_b

#:########################################################################################:#
# SWITCH COMPONENT:
# https://esphome.io/components/switch/
#:########################################################################################:#
switch:
  - platform: template
    id: mosfet_ramp_switch_a
    name: "${friendly_name} A Fade Up-Down"
    icon: mdi:led-strip-variant
    lambda: |-
      return id(ramp_switch_target_on_A);
    turn_on_action:
      - lambda: |-
          id(ramp_switch_target_on_A) = true;
      - script.stop: ramp_off_script_A
      - script.execute: ramp_on_script_A
    turn_off_action:
      - lambda: |-
          id(ramp_switch_target_on_A) = false;
      - script.stop: ramp_on_script_A
      - script.execute: ramp_off_script_A

  - platform: template
    id: mosfet_ramp_switch_b
    name: "${friendly_name} B Fade Up-Down"
    icon: mdi:led-strip-variant
    lambda: |-
      return id(ramp_switch_target_on_B);
    turn_on_action:
      - lambda: |-
          id(ramp_switch_target_on_B) = true;
      - script.stop: ramp_off_script_B
      - script.execute: ramp_on_script_B
    turn_off_action:
      - lambda: |-
          id(ramp_switch_target_on_B) = false;
      - script.stop: ramp_on_script_B
      - script.execute: ramp_off_script_B

#:########################################################################################:#
# BUTTON COMPONENT:
# No standalone template button entities in this yaml
#:########################################################################################:#

#:########################################################################################:#
# 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;
          }

#:########################################################################################:#
# SENSOR COMPONENT:
# https://esphome.io/components/sensor/
#:########################################################################################:#
sensor:
  # Channel A: Output % (pre-gamma)
  - platform: template
    id: mosfet_output_pct_A
    name: "${friendly_name} A Output (%)"
    unit_of_measurement: "%"
    icon: mdi:percent
    accuracy_decimals: 0
    update_interval: 200ms
    disabled_by_default: true
    lambda: |-
      const auto &cv = id(mosfet_leds_A).current_values;
      return cv.is_on() ? (cv.get_brightness() * 100.0f) : 0.0f;
    on_value:
      then:
        - lambda: |-
            const auto &cv_now = id(mosfet_leds_A).current_values;
            if (cv_now.is_on()) {
              id(last_brightness_pct_A) = x;
              if (x > 0.5f) {
                if (!id(is_ramping_A)) id(last_nonzero_brightness_pct_A) = x;
                id(last_nonzero_tmp_A) = x;
              }
            } else {
              if (x > 0.5f) id(last_nonzero_tmp_A) = x;
            }
            if (!id(suppress_slider_sync_A)) {
              float actual = x;
              float minp = (float) id(min_brightness_pct_A);
              float maxp = (float) id(max_brightness_pct_A);
              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_A)) {
                id(last_set_pos_A) = pos_i;
                id(led_output_set_pct_A).publish_state(pos_i);
              }
            }
            if (!id(booting) && !id(is_ramping_A)) {
              float cap = (float) id(max_brightness_pct_A);
              float low_th = cap * 0.33f;
              float high_th = cap * 0.66f;
              if (cv_now.is_on()) {
                float b = x;
                if (b < low_th - 0.25f) {
                  if (id(ramp_switch_target_on_A)) {
                    id(ramp_switch_target_on_A) = false;
                    id(mosfet_ramp_switch_a).publish_state(false);
                  }
                } else if (b > high_th + 0.25f) {
                  if (!id(ramp_switch_target_on_A)) {
                    id(ramp_switch_target_on_A) = true;
                    id(mosfet_ramp_switch_a).publish_state(true);
                  }
                }
              }
            }

  # Channel B: Output % (pre-gamma)
  - platform: template
    id: mosfet_output_pct_B
    name: "${friendly_name} B Output (%)"
    unit_of_measurement: "%"
    icon: mdi:percent
    accuracy_decimals: 0
    update_interval: 200ms
    disabled_by_default: true
    lambda: |-
      const auto &cv = id(mosfet_leds_B).current_values;
      return cv.is_on() ? (cv.get_brightness() * 100.0f) : 0.0f;
    on_value:
      then:
        - lambda: |-
            const auto &cv_now = id(mosfet_leds_B).current_values;
            if (cv_now.is_on()) {
              id(last_brightness_pct_B) = x;
              if (x > 0.5f) {
                if (!id(is_ramping_B)) id(last_nonzero_brightness_pct_B) = x;
                id(last_nonzero_tmp_B) = x;
              }
            } else {
              if (x > 0.5f) id(last_nonzero_tmp_B) = x;
            }
            if (!id(suppress_slider_sync_B)) {
              float actual = x;
              float minp = (float) id(min_brightness_pct_B);
              float maxp = (float) id(max_brightness_pct_B);
              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_B)) {
                id(last_set_pos_B) = pos_i;
                id(led_output_set_pct_B).publish_state(pos_i);
              }
            }
            if (!id(booting) && !id(is_ramping_B)) {
              float cap = (float) id(max_brightness_pct_B);
              float low_th = cap * 0.33f;
              float high_th = cap * 0.66f;
              if (cv_now.is_on()) {
                float b = x;
                if (b < low_th - 0.25f) {
                  if (id(ramp_switch_target_on_B)) {
                    id(ramp_switch_target_on_B) = false;
                    id(mosfet_ramp_switch_b).publish_state(false);
                  }
                } else if (b > high_th + 0.25f) {
                  if (!id(ramp_switch_target_on_B)) {
                    id(ramp_switch_target_on_B) = true;
                    id(mosfet_ramp_switch_b).publish_state(true);
                  }
                }
              }
            }

#:########################################################################################:#
# OUTPUT COMPONENT:
# https://esphome.io/components/output/
#:########################################################################################:#
output:
  - platform: esp8266_pwm
    id: mosfet_pwm_A
    pin: ${device_mosfet_out_A}
    frequency: ${pwm_frequency}

  - platform: esp8266_pwm
    id: mosfet_pwm_B
    pin: ${device_mosfet_out_B}
    frequency: ${pwm_frequency}

  - platform: gpio
    id: fading_led_out_A
    pin:
      number: ${device_fading_led_A}
      inverted: false

  - platform: gpio
    id: fading_led_out_B
    pin:
      number: ${device_fading_led_B}
      inverted: false

#:########################################################################################:#
# LIGHT COMPONENT:
# https://esphome.io/components/light/
#:########################################################################################:#
light:
  - platform: monochromatic
    id: mosfet_leds_A
    name: "${friendly_name} A"
    output: mosfet_pwm_A
    restore_mode: RESTORE_DEFAULT_OFF
    default_transition_length: 250ms
    icon: mdi:led-strip-variant
    gamma_correct: "${led_gamma}"

    on_turn_on:
      - if:
          condition:
            lambda: 'return id(max_on_hours) > 0;'
          then:
            - script.stop: max_on_watchdog_A
            - script.execute: max_on_watchdog_A

    on_turn_off:
      - if:
          condition:
            lambda: 'return !id(booting);'
          then:
            - script.stop: max_on_watchdog_A
            - lambda: |-
                id(last_brightness_pct_A) = 0.0f;
                id(last_set_pos_A) = 0;
                id(led_output_set_pct_A).publish_state(0);

    on_state:
      - lambda: |-
          const auto &cv_s = id(mosfet_leds_A).current_values;
          const bool is_on = cv_s.is_on() && (cv_s.get_brightness() > 0.0005f);
          if (is_on) id(fading_led_out_A).turn_on();
          else       id(fading_led_out_A).turn_off();
      - lambda: |-
          const float cap = id(max_brightness_pct_A) / 100.0f;
          const auto &cv = id(mosfet_leds_A).current_values;
          if (!cv.is_on()) return;
          const float b = cv.get_brightness();
          if (b <= cap + 0.002f) return;
          auto call = id(mosfet_leds_A).make_call();
          call.set_state(true);
          call.set_brightness(cap);
          call.set_transition_length(0);
          call.perform();

  - platform: monochromatic
    id: mosfet_leds_B
    name: "${friendly_name} B"
    output: mosfet_pwm_B
    restore_mode: RESTORE_DEFAULT_OFF
    default_transition_length: 250ms
    icon: mdi:led-strip-variant
    gamma_correct: "${led_gamma}"

    on_turn_on:
      - if:
          condition:
            lambda: 'return id(max_on_hours) > 0;'
          then:
            - script.stop: max_on_watchdog_B
            - script.execute: max_on_watchdog_B

    on_turn_off:
      - if:
          condition:
            lambda: 'return !id(booting);'
          then:
            - script.stop: max_on_watchdog_B
            - lambda: |-
                id(last_brightness_pct_B) = 0.0f;
                id(last_set_pos_B) = 0;
                id(led_output_set_pct_B).publish_state(0);

    on_state:
      - lambda: |-
          const auto &cv_s = id(mosfet_leds_B).current_values;
          const bool is_on = cv_s.is_on() && (cv_s.get_brightness() > 0.0005f);
          if (is_on) id(fading_led_out_B).turn_on();
          else       id(fading_led_out_B).turn_off();
      - lambda: |-
          const float cap = id(max_brightness_pct_B) / 100.0f;
          const auto &cv = id(mosfet_leds_B).current_values;
          if (!cv.is_on()) return;
          const float b = cv.get_brightness();
          if (b <= cap + 0.002f) return;
          auto call = id(mosfet_leds_B).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_A
    name: "${friendly_name} A 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_A) / 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_A) = secs * 1000;
          id(cfg_ramp_up_s_A).publish_state((float) secs);

  - platform: template
    id: cfg_ramp_down_s_A
    name: "${friendly_name} A 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_A) / 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_A) = secs * 1000;
          id(cfg_ramp_down_s_A).publish_state((float) secs);

  - platform: template
    id: cfg_ramp_up_s_B
    name: "${friendly_name} B 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_B) / 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_B) = secs * 1000;
          id(cfg_ramp_up_s_B).publish_state((float) secs);

  - platform: template
    id: cfg_ramp_down_s_B
    name: "${friendly_name} B 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_B) / 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_B) = secs * 1000;
          id(cfg_ramp_down_s_B).publish_state((float) secs);

  - platform: template
    id: led_output_set_pct_A
    name: "${friendly_name} A Output Set (0-100)"
    icon: mdi:tune
    mode: slider
    min_value: 0
    max_value: 100
    step: 1
    lambda: |-
      const auto &cv = id(mosfet_leds_A).current_values;
      float actual = cv.is_on() ? (cv.get_brightness() * 100.0f) : 0.0f;
      float minp = (float) id(min_brightness_pct_A);
      float maxp = (float) id(max_brightness_pct_A);
      if (maxp <= minp) maxp = minp + 1.0f;
      if (actual <= 0.0f) return 0.0f;
      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);
    set_action:
      - if:
          condition:
            lambda: 'return x <= 0.0f;'
          then:
            - lambda: 'id(suppress_slider_sync_A) = true;'
            - script.stop: ramp_on_script_A
            - script.stop: ramp_off_script_A
            - light.turn_off:
                id: mosfet_leds_A
                transition_length: 200ms
            - lambda: |-
                id(led_output_set_pct_A).publish_state(0);
                id(last_brightness_pct_A) = 0.0f;
                id(last_set_pos_A) = 0;
            - delay: 400ms
            - lambda: 'id(suppress_slider_sync_A) = false;'
          else:
            - lambda: |-
                id(suppress_slider_sync_A) = true;
                float pos = x;
                if (pos < 1.0f) pos = 1.0f;
                if (pos > 100.0f) pos = 100.0f;
                id(led_output_set_pct_A).publish_state((int) floorf(pos + 0.5f));

                float minp = (float) id(min_brightness_pct_A);
                float maxp = (float) id(max_brightness_pct_A);
                if (maxp <= minp) maxp = minp + 1.0f;
                float out_pct = minp + (pos * (maxp - minp) / 100.0f);
                if (out_pct > maxp) out_pct = maxp;

                id(restore_target_pct_A) = out_pct;

                const auto &cv = id(mosfet_leds_A).current_values;
                float curr = cv.is_on() ? (cv.get_brightness() * 100.0f) : 0.0f;
                id(last_ramp_ms_A) = (out_pct > curr) ? id(ramp_up_ms_A) : id(ramp_down_ms_A);
            - script.stop: ramp_off_script_A
            - script.stop: ramp_on_script_A
            - script.execute: ramp_to_target_script_A
            - delay: !lambda 'return (uint32_t) id(last_ramp_ms_A);'
            - lambda: 'id(suppress_slider_sync_A) = false;'

  - platform: template
    id: led_output_set_pct_B
    name: "${friendly_name} B Output Set (0-100)"
    icon: mdi:tune
    mode: slider
    min_value: 0
    max_value: 100
    step: 1
    lambda: |-
      const auto &cv = id(mosfet_leds_B).current_values;
      float actual = cv.is_on() ? (cv.get_brightness() * 100.0f) : 0.0f;
      float minp = (float) id(min_brightness_pct_B);
      float maxp = (float) id(max_brightness_pct_B);
      if (maxp <= minp) maxp = minp + 1.0f;
      if (actual <= 0.0f) return 0.0f;
      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);
    set_action:
      - if:
          condition:
            lambda: 'return x <= 0.0f;'
          then:
            - lambda: 'id(suppress_slider_sync_B) = true;'
            - script.stop: ramp_on_script_B
            - script.stop: ramp_off_script_B
            - light.turn_off:
                id: mosfet_leds_B
                transition_length: 200ms
            - lambda: |-
                id(led_output_set_pct_B).publish_state(0);
                id(last_brightness_pct_B) = 0.0f;
                id(last_set_pos_B) = 0;
            - delay: 400ms
            - lambda: 'id(suppress_slider_sync_B) = false;'
          else:
            - lambda: |-
                id(suppress_slider_sync_B) = true;
                float pos = x;
                if (pos < 1.0f) pos = 1.0f;
                if (pos > 100.0f) pos = 100.0f;
                id(led_output_set_pct_B).publish_state((int) floorf(pos + 0.5f));

                float minp = (float) id(min_brightness_pct_B);
                float maxp = (float) id(max_brightness_pct_B);
                if (maxp <= minp) maxp = minp + 1.0f;
                float out_pct = minp + (pos * (maxp - minp) / 100.0f);
                if (out_pct > maxp) out_pct = maxp;

                id(restore_target_pct_B) = out_pct;

                const auto &cv = id(mosfet_leds_B).current_values;
                float curr = cv.is_on() ? (cv.get_brightness() * 100.0f) : 0.0f;
                id(last_ramp_ms_B) = (out_pct > curr) ? id(ramp_up_ms_B) : id(ramp_down_ms_B);
            - script.stop: ramp_off_script_B
            - script.stop: ramp_on_script_B
            - script.execute: ramp_to_target_script_B
            - delay: !lambda 'return (uint32_t) id(last_ramp_ms_B);'
            - lambda: 'id(suppress_slider_sync_B) = 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_A).current_values.is_on();'
          then:
            - script.stop: max_on_watchdog_A
            - if:
                condition:
                  lambda: 'return id(max_on_hours) > 0;'
                then:
                  - script.execute: max_on_watchdog_A
      - if:
          condition:
            lambda: 'return id(mosfet_leds_B).current_values.is_on();'
          then:
            - script.stop: max_on_watchdog_B
            - if:
                condition:
                  lambda: 'return id(max_on_hours) > 0;'
                then:
                  - script.execute: max_on_watchdog_B

#:########################################################################################:#
# SCRIPT COMPONENT:
# https://esphome.io/components/script.html
#:########################################################################################:#
script:
  # Ramps: Channel A
  - id: ramp_on_script_A
    mode: restart
    then:
      - lambda: 'id(is_ramping_A) = true;'
      - script.stop: ramp_off_script_A
      - light.turn_on:
          id: mosfet_leds_A
          brightness: !lambda 'return id(max_brightness_pct_A) / 100.0f;'
          transition_length: !lambda 'return (uint32_t) id(ramp_up_ms_A);'
      - delay: !lambda 'return (uint32_t) id(ramp_up_ms_A);'
      - lambda: |-
          id(is_ramping_A) = false;
          const auto &cv = id(mosfet_leds_A).current_values;
          if (cv.is_on()) {
            float pct = cv.get_brightness() * 100.0f;
            id(last_brightness_pct_A) = pct;
            if (pct > 0.5f) id(last_nonzero_brightness_pct_A) = pct;
          }

  - id: ramp_to_target_script_A
    mode: restart
    then:
      - lambda: 'id(is_ramping_A) = true;'
      - script.stop: ramp_off_script_A
      - light.turn_on:
          id: mosfet_leds_A
          brightness: !lambda 'return id(restore_target_pct_A) / 100.0f;'
          transition_length: !lambda 'return (uint32_t) id(last_ramp_ms_A);'
      - delay: !lambda 'return (uint32_t) id(last_ramp_ms_A);'
      - lambda: |-
          id(is_ramping_A) = false;
          const auto &cv2 = id(mosfet_leds_A).current_values;
          if (cv2.is_on()) {
            float pct = cv2.get_brightness() * 100.0f;
            id(last_brightness_pct_A) = pct;
            if (pct > 0.5f) id(last_nonzero_brightness_pct_A) = pct;
          }

  - id: ramp_off_script_A
    mode: restart
    then:
      - lambda: |-
          id(is_ramping_A) = true;
          id(ramping_for_off_A) = true;
      - script.stop: ramp_on_script_A
      - light.turn_off:
          id: mosfet_leds_A
          transition_length: !lambda 'return (uint32_t) id(ramp_down_ms_A);'
      - delay: !lambda 'return (uint32_t) id(ramp_down_ms_A);'
      - lambda: |-
          id(is_ramping_A) = false;
          id(ramping_for_off_A) = false;
          id(last_brightness_pct_A) = 0.0f;
          id(last_set_pos_A) = 0;
          id(led_output_set_pct_A).publish_state(0);

  - id: max_on_watchdog_A
    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_A).current_values.is_on();'
                then:
                  - lambda: |-
                      id(ramp_switch_target_on_A) = false;
                      id(mosfet_ramp_switch_a).publish_state(false);
                  - script.stop: ramp_on_script_A
                  - script.execute: ramp_off_script_A

  - id: deferred_restore_brightness_A
    mode: restart
    then:
      - delay: 5s
      - lambda: |-
          float target = id(last_nonzero_brightness_pct_A);
          float minp = (float) id(min_brightness_pct_A);
          float maxp = (float) id(max_brightness_pct_A);
          if (target > 0.0f && target < minp) target = minp;
          if (target > maxp) target = maxp;
          id(restore_target_pct_A) = target;
          id(last_ramp_ms_A) = id(ramp_up_ms_A);
      - if:
          condition:
            lambda: 'return id(restore_target_pct_A) > 0.5f;'
          then:
            - lambda: |-
                id(ramp_switch_target_on_A) = true;
                id(suppress_slider_sync_A) = true;
                id(suppress_quick_on_A) = true;
            - script.stop: ramp_off_script_A
            - script.execute: ramp_to_target_script_A
            - delay: !lambda 'return (uint32_t) id(last_ramp_ms_A);'
            - lambda: |-
                id(suppress_slider_sync_A) = false;
                id(suppress_quick_on_A) = false;
          else:
            - lambda: 'id(ramp_switch_target_on_A) = false;'
            - light.turn_off:
                id: mosfet_leds_A
                transition_length: 0s

  # Ramps: Channel B
  - id: ramp_on_script_B
    mode: restart
    then:
      - lambda: 'id(is_ramping_B) = true;'
      - script.stop: ramp_off_script_B
      - light.turn_on:
          id: mosfet_leds_B
          brightness: !lambda 'return id(max_brightness_pct_B) / 100.0f;'
          transition_length: !lambda 'return (uint32_t) id(ramp_up_ms_B);'
      - delay: !lambda 'return (uint32_t) id(ramp_up_ms_B);'
      - lambda: |-
          id(is_ramping_B) = false;
          const auto &cv = id(mosfet_leds_B).current_values;
          if (cv.is_on()) {
            float pct = cv.get_brightness() * 100.0f;
            id(last_brightness_pct_B) = pct;
            if (pct > 0.5f) id(last_nonzero_brightness_pct_B) = pct;
          }

  - id: ramp_to_target_script_B
    mode: restart
    then:
      - lambda: 'id(is_ramping_B) = true;'
      - script.stop: ramp_off_script_B
      - light.turn_on:
          id: mosfet_leds_B
          brightness: !lambda 'return id(restore_target_pct_B) / 100.0f;'
          transition_length: !lambda 'return (uint32_t) id(last_ramp_ms_B);'
      - delay: !lambda 'return (uint32_t) id(last_ramp_ms_B);'
      - lambda: |-
          id(is_ramping_B) = false;
          const auto &cv2 = id(mosfet_leds_B).current_values;
          if (cv2.is_on()) {
            float pct = cv2.get_brightness() * 100.0f;
            id(last_brightness_pct_B) = pct;
            if (pct > 0.5f) id(last_nonzero_brightness_pct_B) = pct;
          }

  - id: ramp_off_script_B
    mode: restart
    then:
      - lambda: |-
          id(is_ramping_B) = true;
          id(ramping_for_off_B) = true;
      - script.stop: ramp_on_script_B
      - light.turn_off:
          id: mosfet_leds_B
          transition_length: !lambda 'return (uint32_t) id(ramp_down_ms_B);'
      - delay: !lambda 'return (uint32_t) id(ramp_down_ms_B);'
      - lambda: |-
          id(is_ramping_B) = false;
          id(ramping_for_off_B) = false;
          id(last_brightness_pct_B) = 0.0f;
          id(last_set_pos_B) = 0;
          id(led_output_set_pct_B).publish_state(0);

  - id: max_on_watchdog_B
    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_B).current_values.is_on();'
                then:
                  - lambda: |-
                      id(ramp_switch_target_on_B) = false;
                      id(mosfet_ramp_switch_b).publish_state(false);
                  - script.stop: ramp_on_script_B
                  - script.execute: ramp_off_script_B

  - id: deferred_restore_brightness_B
    mode: restart
    then:
      - delay: 5s
      - lambda: |-
          float target = id(last_nonzero_brightness_pct_B);
          float minp = (float) id(min_brightness_pct_B);
          float maxp = (float) id(max_brightness_pct_B);
          if (target > 0.0f && target < minp) target = minp;
          if (target > maxp) target = maxp;
          id(restore_target_pct_B) = target;
          id(last_ramp_ms_B) = id(ramp_up_ms_B);
      - if:
          condition:
            lambda: 'return id(restore_target_pct_B) > 0.5f;'
          then:
            - lambda: |-
                id(ramp_switch_target_on_B) = true;
                id(suppress_slider_sync_B) = true;
                id(suppress_quick_on_B) = true;
            - script.stop: ramp_off_script_B
            - script.execute: ramp_to_target_script_B
            - delay: !lambda 'return (uint32_t) id(last_ramp_ms_B);'
            - lambda: |-
                id(suppress_slider_sync_B) = false;
                id(suppress_quick_on_B) = false;
          else:
            - lambda: 'id(ramp_switch_target_on_B) = false;'
            - light.turn_off:
                id: mosfet_leds_B
                transition_length: 0s