##########################################################################################
##########################################################################################
# MASTER BEDROOM BEDSIDE LAMP BULB
# File:  esp-bed1bedsideathombulb.yaml
#
# v1.1 - 2026-02-21 Correct GPIO mapping + use RGBCT platform (Athom LB01)
# v1.0 - 2026-02-21 Initial version (merged from preferred dimmer + Athom GPIO map)
#
# Hardware: Athom 15W E27 Bulb LB01 (RGB + CT Brightness + CT control)
#
# NOTES
# - This keeps the "preferred control" ramping / min-max / watchdog / restore logic.
# - All ramping and "Output Set (0-100)" controls FORCE the bulb into Warm White endpoint
#   (so the dimming behavior mainly drives the CT Brightness channel at 3000K).
# - You can still control full RGB and CT from HA using the normal light entity.
# - Max On watchdog turns the whole bulb OFF at the time limit (all channels).
##########################################################################################
##########################################################################################

##########################################################################################
# SPECIFIC DEVICE VARIABLE SUBSTITUTIONS
##########################################################################################
substitutions:
  # Device Naming
  device_name: "esp-bed1bedsideathombulb" # yaml file should be device_name.yaml
  friendly_name: "Master Bedroom Bedside Lamp Bulb"
  description_comment: "Master Bedroom Bedside lamp using RGB-CW 15W Bulb"
  device_area: "Master Bedroom"

  # Project Naming
  project_name: "Athom 15W E27 Bulb LB01 RGBCT.Athom"
  project_version: "v1.1"

  # Passwords & Secrets (Unfortunately, you can't use substitutions inside secret names)
  #api_key: !secret esp-api_key
  api_key: !secret esp-athombulbflasher_api_key
  #ota_pass: !secret esp-ota_pass
  ota_pass: !secret esp-athombulbflasher_ota_pass
  static_ip_address: !secret esp-bed1bedsideathombulb_ip

  # If we are changing IP addresses, you must update the current IP address here, otherwise it remains
  #current_ip_address: 192.168.2.121
  current_ip_address: ${static_ip_address}

  # MQTT LOCAL Controls (optional local control method)
  mqtt_local_device_name: "bed1-bedsidelamp"
  mqtt_local_command_main_topic: !secret mqtt_command_main_topic
  mqtt_local_status_main_topic: !secret mqtt_status_main_topic
  mqtt_local_command_topic: "${mqtt_local_command_main_topic}/${mqtt_local_device_name}"
  mqtt_local_status_topic: "${mqtt_local_status_main_topic}/${mqtt_local_device_name}"
  mqtt_local_device_command_ON: "ON"
  mqtt_local_device_command_OFF: "OFF"

  # Device Specific Settings
  log_level:            "NONE"   # NONE, ERROR, WARN, INFO, DEBUG, VERBOSE, VERY_VERBOSE
  update_interval:      "20s"
  led_gamma:            "1.2"
  minimum_led_output:   "1"      # % If at this value or below, we'll switch it completely off
  maximum_led_output:   "90"     # % Hard cap
  max_on_default_hours: "24"     # 0 = no automatic turn-off
  pwm_frequency:        "1000"   # Hz

  # Warm endpoint for this bulb (spec range is 3000K-6000K)
  bulb_warm_kelvin:     "3000K"
  bulb_cold_kelvin:     "6000K"

  # RGBCT endpoints must be given as mireds in the rgbct component
  # 6000K = 166.7 mireds, 3000K = 333.3 mireds
  bulb_cold_mireds:     "167 mireds"
  bulb_warm_mireds:     "333 mireds"

  # Athom LB01 GPIO mapping (correct)
  bulb_red_pin:               GPIO4
  bulb_green_pin:             GPIO12
  bulb_blue_pin:              GPIO14
  bulb_ct_brightness_pin:     GPIO5
  bulb_ct_pin:                GPIO13

##########################################################################################
# PACKAGES: Included Common Packages
##########################################################################################
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_ota_pass: "${ota_pass}"
      local_current_ip_address: "${current_ip_address}"

  #### HOME ASSISTANT API ####
  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}"

  #### 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

##########################################################################################
# ESPHome CORE CONFIGURATION
##########################################################################################
esphome:
  name: "${device_name}"
  friendly_name: "${friendly_name}"
  comment: "${description_comment}"
  area: "${device_area}"
  project:
    name: "${project_name}"
    version: "${project_version}"

  on_boot:
    # Run later so everything is initialised before we touch states.
    priority: 200
    then:
      # Keep the HA dropdown in sync with the stored mode
      - lambda: |-
          switch (id(restart_mode)) {
            case 0: id(restart_action).publish_state("Fade up to full"); break;
            case 1: id(restart_action).publish_state("Restore Brightness"); break;
            case 2: default: id(restart_action).publish_state("Remain Off"); break;
          }

      # Mode 0: Fade up to full (obeys fade settings; forces Warm endpoint)
      - if:
          condition:
            lambda: 'return id(restart_mode) == 0;'
          then:
            - lambda: 'id(ramp_switch_target_on) = true;'
            - script.execute: ramp_on_script

      # Mode 1: Restore Brightness (deferred; forces Warm endpoint)
      - if:
          condition:
            lambda: 'return id(restart_mode) == 1;'
          then:
            - script.execute: deferred_restore_brightness

      # Mode 2: Remain Off
      - if:
          condition:
            lambda: 'return id(restart_mode) == 2;'
          then:
            - script.stop: ramp_on_script
            - script.stop: ramp_off_script
            - lambda: 'id(ramp_switch_target_on) = false;'
            - light.turn_off:
                id: bulb_light
                transition_length: 0s

      # Boot complete: allow OFF handlers again
      - lambda: 'id(booting) = false;'

##########################################################################################
# ESP PLATFORM AND FRAMEWORK
##########################################################################################
esp8266:
  board: esp8285
  restore_from_flash: true

mdns:
  disabled: true

preferences:
  flash_write_interval: 2min

##########################################################################################
# GLOBAL VARIABLES
##########################################################################################
globals:
  - id: min_brightness_pct
    type: int
    restore_value: true
    initial_value: "${minimum_led_output}"

  - id: max_brightness_pct
    type: int
    restore_value: false
    initial_value: "${maximum_led_output}"

  - id: max_on_hours
    type: int
    restore_value: true
    initial_value: "${max_on_default_hours}"

  - id: led_gamma_f
    type: float
    restore_value: false
    initial_value: ${led_gamma}

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

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

  - id: restart_mode
    type: int
    restore_value: true
    initial_value: "0" # 0=Fade full, 1=Restore brightness, 2=Remain off

  - id: ramp_switch_target_on
    type: bool
    restore_value: true
    initial_value: "false"

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

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

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

  - id: last_ramp_ms
    type: int
    restore_value: false
    initial_value: "0"

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

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

  - id: booting
    type: bool
    restore_value: false
    initial_value: "true"

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

##########################################################################################
# LOGGER
##########################################################################################
logger:
  level: "${log_level}"
  baud_rate: 0

##########################################################################################
# MQTT COMMANDS (adds device-specific triggers to common MQTT)
##########################################################################################
mqtt:
  on_message:
    - topic: "${mqtt_local_command_topic}/light/set"
      payload: "${mqtt_local_device_command_ON}"
      then:
        - switch.turn_on: bulb_ramp_switch

    - topic: "${mqtt_local_command_topic}/light/set"
      payload: "${mqtt_local_device_command_OFF}"
      then:
        - switch.turn_off: bulb_ramp_switch

##########################################################################################
# SWITCH
##########################################################################################
switch:
  - platform: template
    id: bulb_ramp_switch
    name: "${friendly_name} Fade Up-Down"
    icon: mdi:lightbulb-on-outline
    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
##########################################################################################
button:
  - 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(bulb_ramp_switch).publish_state(true);
      - script.stop: ramp_off_script
      - script.execute: ramp_on_script

  - 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(bulb_ramp_switch).publish_state(false);
      - script.stop: ramp_on_script
      - script.execute: ramp_off_script

  - platform: template
    id: fade_stop_button
    name: "${friendly_name} Fade Stop"
    icon: mdi:pause
    on_press:
      - script.stop: ramp_on_script
      - script.stop: ramp_off_script
      - lambda: |-
          id(ramping_for_off) = false;
          id(is_ramping) = false;

          const auto &cv = id(bulb_light).current_values;
          if (cv.is_on()) {
            auto call = id(bulb_light).make_call();
            call.set_state(true);
            call.set_brightness(cv.get_brightness());
            call.set_transition_length(0);
            call.perform();
          }

          if (cv.is_on()) {
            float pct = cv.get_brightness() * 100.0f;
            id(last_brightness_pct) = pct;
          }

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

##########################################################################################
# SENSORS
##########################################################################################
sensor:
  - platform: template
    id: bulb_output_pct
    name: "${friendly_name} Output (%)"
    unit_of_measurement: "%"
    icon: mdi:percent
    accuracy_decimals: 0
    update_interval: 2s
    lambda: |-
      const auto &cv = id(bulb_light).current_values;
      return cv.is_on() ? (float) (int)(cv.get_brightness() * 100.0f + 0.5f) : 0.0f;

    on_value:
      then:
        - lambda: |-
            if (!id(suppress_slider_sync)) {
              float actual = x;
              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: bulb_output_pwm_pct
    name: "${friendly_name} Output PWM (%)"
    unit_of_measurement: "%"
    icon: mdi:square-wave
    accuracy_decimals: 1
    update_interval: 2s
    lambda: |-
      const auto &cv = id(bulb_light).current_values;
      if (!cv.is_on()) return 0.0f;
      const float lin = cv.get_brightness();
      float pwm = powf(lin, id(led_gamma_f));
      if (pwm < 0.0f) pwm = 0.0f;
      if (pwm > 1.0f) pwm = 1.0f;
      return pwm * 100.0f;

##########################################################################################
# OUTPUTS: 5 x PWM channels for the Athom RGBCT bulb
##########################################################################################
output:
  - platform: esp8266_pwm
    id: output_red
    pin: ${bulb_red_pin}
    frequency: ${pwm_frequency}

  - platform: esp8266_pwm
    id: output_green
    pin: ${bulb_green_pin}
    frequency: ${pwm_frequency}

  - platform: esp8266_pwm
    id: output_blue
    pin: ${bulb_blue_pin}
    frequency: ${pwm_frequency}

  # White brightness (CT Brightness)
  - platform: esp8266_pwm
    id: output_white_brightness
    pin: ${bulb_ct_brightness_pin}
    frequency: ${pwm_frequency}

  # Color temperature control (CT), inverted
  - platform: esp8266_pwm
    id: output_ct
    pin: ${bulb_ct_pin}
    frequency: ${pwm_frequency}
    inverted: true

##########################################################################################
# LIGHT: RGBCT (correct for this bulb)
##########################################################################################
light:
  - platform: rgbct
    id: bulb_light
    name: "${friendly_name}"
    icon: mdi:lightbulb

    red: output_red
    green: output_green
    blue: output_blue
    white_brightness: output_white_brightness
    color_temperature: output_ct

    cold_white_color_temperature: ${bulb_cold_mireds}
    warm_white_color_temperature: ${bulb_warm_mireds}

    # Safety: do not allow RGB and CT whites simultaneously
    color_interlock: true

    # Important: keep OFF at boot to avoid instant restore flashes; boot script handles behavior.
    restore_mode: ALWAYS_OFF

    default_transition_length: 2s
    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: |-
          if (!id(ramping_for_off)) {
            id(ramp_switch_target_on) = true;
          }
      - script.stop: max_on_watchdog
      - if:
          condition:
            lambda: 'return id(max_on_hours) > 0;'
          then:
            - script.execute: max_on_watchdog

    on_turn_off:
      - if:
          condition:
            lambda: 'return !id(booting);'
          then:
            - mqtt.publish:
                topic: "${mqtt_local_status_topic}/light/state"
                payload: "${mqtt_local_device_command_OFF}"
                retain: true
            - lambda: 'id(ramp_switch_target_on) = false;'
            - script.stop: max_on_watchdog
            - lambda: |-
                id(last_brightness_pct) = 0.0f;
                id(last_set_pos) = 0;
                id(led_output_set_pct).publish_state(0);

    # Cap brightness to max_brightness_pct without a visible step
    on_state:
      - lambda: |-
          const float cap = id(max_brightness_pct) / 100.0f;
          const auto &cv = id(bulb_light).current_values;
          if (!cv.is_on()) return;
          const float b = cv.get_brightness();
          if (b <= cap + 0.001f) return;

          auto call = id(bulb_light).make_call();
          call.set_state(true);
          call.set_brightness(cap);
          call.set_transition_length(0);
          call.perform();

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

  # This is the preferred everyday dimmer slider (FORCES Warm endpoint)
  - 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
    lambda: |-
      const auto &cv = id(bulb_light).current_values;
      float actual = cv.is_on() ? (cv.get_brightness() * 100.0f) : 0.0f;
      float minp = (float) id(min_brightness_pct);
      float maxp = (float) id(max_brightness_pct);
      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) = true;'
            - script.stop: ramp_on_script
            - script.stop: ramp_off_script
            - light.turn_off:
                id: bulb_light
                transition_length: 200ms
            - lambda: |-
                id(ramp_switch_target_on) = false;
                id(led_output_set_pct).publish_state(0);
                id(last_brightness_pct) = 0.0f;
                id(last_set_pos) = 0;
            - delay: 400ms
            - lambda: 'id(suppress_slider_sync) = false;'
          else:
            - lambda: |-
                id(suppress_slider_sync) = true;
                float pos = x;
                if (pos < 1.0f) pos = 1.0f;
                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: bulb_light
                color_temperature: "${bulb_warm_kelvin}"
                brightness: !lambda |-
                  float pos = id(led_output_set_pct).state;
                  float minp = (float) id(min_brightness_pct);
                  float maxp = (float) id(max_brightness_pct);
                  if (maxp <= minp) maxp = minp + 1.0f;
                  float out_pct = minp + (pos * (maxp - minp) / 100.0f);
                  if (out_pct > maxp) out_pct = maxp;
                  return out_pct / 100.0f;
                transition_length: 250ms
            - lambda: |-
                id(ramp_switch_target_on) = true;
                float pos = id(led_output_set_pct).state;
                float minp = (float) id(min_brightness_pct);
                float maxp = (float) id(max_brightness_pct);
                if (maxp <= minp) maxp = minp + 1.0f;
                float out_pct = minp + (pos * (maxp - minp) / 100.0f);
                if (out_pct > maxp) out_pct = maxp;
                id(last_brightness_pct) = out_pct;
            - 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(bulb_light).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

##########################################################################################
# SCRIPTS
##########################################################################################
script:
  - id: ramp_on_script
    mode: restart
    then:
      - lambda: 'id(is_ramping) = true;'
      - script.stop: ramp_off_script

      - if:
          condition:
            lambda: |-
              const auto &cv = id(bulb_light).current_values;
              const float floor = id(min_brightness_pct) / 100.0f;
              return (!cv.is_on()) || (cv.get_brightness() + 0.0005f < floor);
          then:
            - light.turn_on:
                id: bulb_light
                color_temperature: "${bulb_warm_kelvin}"
                brightness: !lambda 'return id(min_brightness_pct) / 100.0f;'
                transition_length: 80ms

      - light.turn_on:
          id: bulb_light
          color_temperature: "${bulb_warm_kelvin}"
          brightness: !lambda 'return id(max_brightness_pct) / 100.0f;'
          transition_length: !lambda |-
            const auto &cv = id(bulb_light).current_values;
            const float floor = id(min_brightness_pct) / 100.0f;
            const float cap  = id(max_brightness_pct) / 100.0f;
            if (cap <= floor + 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; }

            float curr = cv.is_on() ? cv.get_brightness() : 0.0f;

            if (curr + 0.0005f < floor) {
              id(last_ramp_ms) = id(ramp_up_ms);
              return (uint32_t) id(last_ramp_ms);
            }
            if (curr > cap)   curr = cap;
            if (curr < floor) curr = floor;

            float frac = (cap - curr) / (cap - floor);
            if (frac < 0.0f) frac = 0.0f;
            if (frac > 1.0f) frac = 1.0f;

            id(last_ramp_ms) = (int) (id(ramp_up_ms) * frac);
            return (uint32_t) id(last_ramp_ms);

      - delay: !lambda 'return (uint32_t) id(last_ramp_ms);'
      - lambda: |-
          id(is_ramping) = false;
          const auto &cv = id(bulb_light).current_values;
          if (cv.is_on()) {
            float pct = cv.get_brightness() * 100.0f;
            id(last_brightness_pct) = pct;
          }

  - id: ramp_to_target_script
    mode: restart
    then:
      - lambda: 'id(is_ramping) = true;'
      - script.stop: ramp_off_script

      - if:
          condition:
            lambda: |-
              const auto &cv = id(bulb_light).current_values;
              const float floor = id(min_brightness_pct) / 100.0f;
              return (!cv.is_on()) || (cv.get_brightness() + 0.0005f < floor);
          then:
            - light.turn_on:
                id: bulb_light
                color_temperature: "${bulb_warm_kelvin}"
                brightness: !lambda 'return id(min_brightness_pct) / 100.0f;'
                transition_length: 80ms

      - light.turn_on:
          id: bulb_light
          color_temperature: "${bulb_warm_kelvin}"
          brightness: !lambda |-
            float cap_pct = id(restore_target_pct);
            float maxp    = (float) id(max_brightness_pct);
            if (cap_pct > maxp) cap_pct = maxp;
            return cap_pct / 100.0f;
          transition_length: !lambda |-
            const auto &cv = id(bulb_light).current_values;
            const float floor = id(min_brightness_pct) / 100.0f;

            float cap  = id(restore_target_pct) / 100.0f;
            const float maxp = id(max_brightness_pct) / 100.0f;
            if (cap > maxp) cap = maxp;

            if (cap <= floor + 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; }

            float curr = cv.is_on() ? cv.get_brightness() : 0.0f;

            if (curr + 0.0005f < floor) {
              id(last_ramp_ms) = id(ramp_up_ms);
              return (uint32_t) id(last_ramp_ms);
            }

            if (curr > cap)   curr = cap;
            if (curr < floor) curr = floor;

            float frac = (cap - curr) / (cap - floor);
            if (frac < 0.0f) frac = 0.0f;
            if (frac > 1.0f) frac = 1.0f;

            id(last_ramp_ms) = (int) (id(ramp_up_ms) * frac);
            return (uint32_t) id(last_ramp_ms);

      - delay: !lambda 'return (uint32_t) id(last_ramp_ms);'
      - lambda: |-
          id(is_ramping) = false;
          const auto &cv = id(bulb_light).current_values;
          if (cv.is_on()) {
            float pct = cv.get_brightness() * 100.0f;
            id(last_brightness_pct) = pct;
          }

  - id: ramp_off_script
    mode: restart
    then:
      - lambda: |-
          id(is_ramping) = true;
          id(ramping_for_off) = true;
      - script.stop: ramp_on_script

      - light.turn_on:
          id: bulb_light
          color_temperature: "${bulb_warm_kelvin}"
          brightness: !lambda 'return id(min_brightness_pct) / 100.0f;'
          transition_length: !lambda |-
            const auto &cv = id(bulb_light).current_values;
            const float floor = id(min_brightness_pct) / 100.0f;
            float curr = cv.get_brightness();
            if (curr < floor) curr = floor;

            const float denom = (1.0f - floor);
            if (denom <= 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; }

            float frac = (curr - floor) / denom;
            if (frac < 0.0f) frac = 0.0f;
            if (frac > 1.0f) frac = 1.0f;

            id(last_ramp_ms) = (int) (id(ramp_down_ms) * frac);
            return (uint32_t) id(last_ramp_ms);

      - delay: !lambda 'return (uint32_t) id(last_ramp_ms);'
      - light.turn_off:
          id: bulb_light
          transition_length: 150ms
      - delay: 150ms

      - lambda: |-
          id(is_ramping) = false;
          id(ramping_for_off) = false;
          id(last_brightness_pct) = 0.0f;
          id(last_set_pos) = 0;
          id(led_output_set_pct).publish_state(0);

  - id: max_on_watchdog
    mode: restart
    then:
      - if:
          condition:
            lambda: 'return id(max_on_hours) > 0;'
          then:
            - delay: !lambda 'return (uint32_t) (id(max_on_hours) * 3600000UL);'
            - if:
                condition:
                  lambda: 'return id(bulb_light).current_values.is_on();'
                then:
                  - lambda: |-
                      id(ramp_switch_target_on) = false;
                      id(bulb_ramp_switch).publish_state(false);
                  - script.stop: ramp_on_script
                  - script.execute: ramp_off_script

  - id: deferred_restore_brightness
    mode: restart
    then:
      - delay: 5s
      - lambda: |-
          float target = id(last_brightness_pct);

          float minp = (float) id(min_brightness_pct);
          float maxp = (float) id(max_brightness_pct);
          if (target > 0.0f && target < minp) target = minp;
          if (target > maxp)                  target = maxp;

          id(restore_target_pct) = target;

      - if:
          condition:
            lambda: 'return id(restore_target_pct) > 0.5f;'
          then:
            - lambda: |-
                id(ramp_switch_target_on) = true;
                id(suppress_slider_sync)  = true;
            - script.stop: ramp_off_script
            - script.execute: ramp_to_target_script
            - delay: !lambda 'return (uint32_t) id(last_ramp_ms);'
            - lambda: 'id(suppress_slider_sync) = false;'
          else:
            - lambda: 'id(ramp_switch_target_on) = false;'
            - light.turn_off:
                id: bulb_light
                transition_length: 0s