From 0dfef5ced1640ecd4983c05f0b8c8dbe8314e2d8 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 5 Sep 2025 18:51:42 +1200 Subject: [PATCH] experiments in AC dimming (moes and robotdyn) --- esphome/esp-breakfastbarleds.yaml | 835 +++++++++++++++++++++++++++ esphome/esp-downstairskitchleds.yaml | 187 ++++-- esphome/esp-loungecabinetleds.yaml | 834 ++++++++++++++++++++++++++ esphome/esp-loungemainsdimmer1.yaml | 198 +++++++ esphome/esp-loungemoesdimmer1.yaml | 195 +++++++ 5 files changed, 2212 insertions(+), 37 deletions(-) create mode 100644 esphome/esp-breakfastbarleds.yaml create mode 100644 esphome/esp-loungecabinetleds.yaml create mode 100644 esphome/esp-loungemainsdimmer1.yaml create mode 100644 esphome/esp-loungemoesdimmer1.yaml diff --git a/esphome/esp-breakfastbarleds.yaml b/esphome/esp-breakfastbarleds.yaml new file mode 100644 index 0000000..75e2071 --- /dev/null +++ b/esphome/esp-breakfastbarleds.yaml @@ -0,0 +1,835 @@ +########################################################################################## +########################################################################################## +# Title: BREAKFAST BAR LEDS (Lounge) +# Hardware: Magichome inline single channel dimmer (ESP8266) +# +# Repo: +# +# v1.0 - 2025-08-17 First setup (and replacement of Tasmota) +# +# ------------------------------------------ +# DEVICE GPIO (Magichome ESP8266) +# ------------------------------------------ +# GPIO02 Blue LED (used for ESPHome status) - I think... can't see the board +# GPIO12 MOSFET output (0 V when switched) +# GPIO13 Green LED (used to display fading status) +# +# ------------------------------------------ +# OPERATION +# ------------------------------------------ +# - Startup/power restore 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-breakfastbarleds" + friendly_name: "Breakfast Bar LEDs" + description_comment: "LED Strip under the breakfast bar, upstairs lounge :: Magichome ESP8266" + device_area: "Lounge" # Allows the ESP device to be automatically linked to an 'Area' in Home Assistant. + + # Project Naming + project_name: "Magichome ESP8266" # Project details + project_version: "v1.0" # 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-breakfastbarleds_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: "lounge-breakfastbarleds" + 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: "12" # 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}" + area: "${device_area}" + on_boot: + priority: 600 + then: + # Keep the HA dropdown in sync with the stored mode + - lambda: |- + switch (id(restart_mode)) { + case 0: id(restart_action).publish_state("Fade up to full"); break; + case 1: id(restart_action).publish_state("Restore Brightness"); break; + case 2: default: id(restart_action).publish_state("Remain Off"); break; + } + + # Mode 0: Fade up to full (obeys fade settings; no on/off flicker) + - if: + condition: + lambda: 'return id(restart_mode) == 0;' + then: + - lambda: 'id(ramp_switch_target_on) = true;' + - script.execute: ramp_on_script + + # Mode 1: Restore Brightness using a scripted ramp 0 -> last non-zero brightness + - if: + condition: + lambda: 'return id(restart_mode) == 1;' + then: + - lambda: |- + float target = id(last_nonzero_brightness_pct); + if (target < 0.0f) target = 0.0f; + if (target > 100.0f) target = 100.0f; + const float minp = (float) id(min_brightness_pct); + const 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; // set scripted target + id(ramp_switch_target_on) = (target > 0.0f); + - if: + condition: + lambda: 'return id(restore_target_pct) > 0.0f;' + then: + - script.stop: ramp_off_script + - script.execute: ramp_to_target_script + else: + - light.turn_off: + id: mosfet_leds + transition_length: 0s + + # Mode 2: Remain Off (no blip; stays off) + - if: + condition: + lambda: 'return id(restart_mode) == 2;' + then: + - script.stop: ramp_on_script + - script.stop: ramp_off_script + - lambda: 'id(ramp_switch_target_on) = false;' + - light.turn_off: + id: mosfet_leds + transition_length: 0s + + platformio_options: + build_unflags: + - -flto + build_flags: + - -fno-lto + - -Wl,--gc-sections + - -ffunction-sections + - -fdata-sections + - -DNDEBUG + +########################################################################################## +# ESP PLATFORM AND FRAMEWORK +# https://esphome.io/components/esp8266.html +# https://esphome.io/components/esp32.html +########################################################################################## +esp8266: + board: esp01_1m + restore_from_flash: true # restore some values on reboot + +mdns: + disabled: false # Disabling will make the build file smaller (and it is still available via static IP) + +preferences: + flash_write_interval: 5min + +########################################################################################## +# 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' + # last non-zero actual 0..100 used, for stable "Restore Brightness" + - id: last_nonzero_brightness_pct + type: float + restore_value: true + initial_value: '0.0' + # target for "Restore Brightness" scripted ramp + - id: restore_target_pct + type: float + restore_value: false + initial_value: '0.0' + # true while a scripted ramp is in progress (to limit flash writes) + - id: is_ramping + type: bool + restore_value: false + initial_value: 'false' + # volatile (non-persistent) last non-zero brightness during ramps + - id: last_nonzero_tmp + type: float + restore_value: false + initial_value: '0.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 down + - 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 +######################################################################################### +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 + # 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: + +########################################################################################## +# 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 (x > 0.5f) { + // Only persist when not ramping; always track a volatile copy + if (!id(is_ramping)) id(last_nonzero_brightness_pct) = x; + id(last_nonzero_tmp) = 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: GPIO12 + frequency: 1000 Hz # higher frequency helps reduce camera banding while filming + +########################################################################################## +# LIGHT COMPONENT +# https://esphome.io/components/light/ +########################################################################################## +light: + - platform: monochromatic + id: mosfet_leds + name: "${friendly_name}" + output: mosfet_pwm + restore_mode: ALWAYS_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: + # Script: ramp up from current level. Obey global max. + - id: ramp_on_script + mode: restart + then: + - lambda: 'id(is_ramping) = true;' # mark ramping + - script.stop: ramp_off_script + # Ensure we start at at least the floor without a visible "pop". + - if: + condition: + lambda: |- + const auto &cv = id(mosfet_leds).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + return (!cv.is_on()) || (cv.get_brightness() + 0.0005f < floor); + then: + - light.turn_on: + id: mosfet_leds + brightness: !lambda 'return id(min_brightness_pct) / 100.0f;' + transition_length: 80ms + # Ramp from current (>= floor) to cap over a fraction of ramp_up_ms. + - light.turn_on: + id: mosfet_leds + brightness: !lambda 'return id(max_brightness_pct) / 100.0f;' + transition_length: !lambda |- + const auto &cv = id(mosfet_leds).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + const float cap = id(max_brightness_pct) / 100.0f; + if (cap <= floor + 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; } // guard degenerate case + + // IMPORTANT: when the light was OFF, treat current as 0 so we don't compute a 0ms jump to cap. + float curr = cv.is_on() ? cv.get_brightness() : 0.0f; + + // If we're below the floor (i.e. coming from OFF), we just did the "floor kick" above. + // Use full ramp_up_ms for a smooth 0 -> cap ramp. + 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; // ramp complete + const auto &cv = id(mosfet_leds).current_values; + if (cv.is_on()) { + float pct = cv.get_brightness() * 100.0f; + if (pct > 0.5f) id(last_nonzero_brightness_pct) = pct; // commit once + } + + # Script: ramp to a specific target (Restore Brightness path) + - id: ramp_to_target_script + mode: restart + then: + - lambda: 'id(is_ramping) = true;' # mark ramping + - script.stop: ramp_off_script + # Ensure we start at the floor cleanly. + - if: + condition: + lambda: |- + const auto &cv = id(mosfet_leds).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + return (!cv.is_on()) || (cv.get_brightness() + 0.0005f < floor); + then: + - light.turn_on: + id: mosfet_leds + brightness: !lambda 'return id(min_brightness_pct) / 100.0f;' + transition_length: 80ms + # Ramp from current (>= floor) to restore_target_pct over a fraction of ramp_up_ms. + - light.turn_on: + id: mosfet_leds + brightness: !lambda |- + float cap_pct = id(restore_target_pct); + float maxp = (float) id(max_brightness_pct); + if (cap_pct > maxp) cap_pct = maxp; + return cap_pct / 100.0f; + transition_length: !lambda |- + const auto &cv = id(mosfet_leds).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + float cap = id(restore_target_pct) / 100.0f; + const float maxp = id(max_brightness_pct) / 100.0f; + if (cap > maxp) cap = maxp; + if (cap <= floor + 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; } // guard + + // Same OFF case handling: if off, treat curr as 0 so we don't collapse the ramp. + float curr = cv.is_on() ? cv.get_brightness() : 0.0f; + + // Coming from OFF/below floor? We just did the "floor kick" above — use full ramp_up_ms. + 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; // ramp complete + const auto &cv = id(mosfet_leds).current_values; + if (cv.is_on()) { + float pct = cv.get_brightness() * 100.0f; + if (pct > 0.5f) id(last_nonzero_brightness_pct) = pct; // commit once + } + + # Script: ramp down from current level to floor, then cleanly cut to OFF + - id: ramp_off_script + mode: restart + then: + - lambda: 'id(is_ramping) = true;' # mark ramping + - script.stop: ramp_on_script + - light.turn_on: + id: mosfet_leds + brightness: !lambda 'return id(min_brightness_pct) / 100.0f;' + transition_length: !lambda |- + const auto &cv = id(mosfet_leds).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + float curr = cv.get_brightness(); + if (curr < floor) curr = floor; + const float denom = (1.0f - floor); + if (denom <= 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; } // guard near-1.0 floor + float frac = (curr - floor) / denom; + if (frac < 0.0f) frac = 0.0f; + if (frac > 1.0f) frac = 1.0f; + id(last_ramp_ms) = (int) (id(ramp_down_ms) * frac); + return (uint32_t) id(last_ramp_ms); + - delay: !lambda 'return (uint32_t) id(last_ramp_ms);' + - light.turn_off: + id: mosfet_leds + transition_length: 150ms + - delay: 150ms + - lambda: |- + id(is_ramping) = false; // ramp complete + 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 diff --git a/esphome/esp-downstairskitchleds.yaml b/esphome/esp-downstairskitchleds.yaml index 663dbd5..1860fc1 100644 --- a/esphome/esp-downstairskitchleds.yaml +++ b/esphome/esp-downstairskitchleds.yaml @@ -5,6 +5,8 @@ # 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.5 - 2025-09-04 Adopt ramped "Restore Brightness" (0→last non-zero), avoid boot blip (ALWAYS_OFF + high-priority on_boot), +# add flash-write coalescing (persist once at end of ramps), guards for edge cases, and flash_write_interval=5min # v1.4 - 2025-08-22 Improved power loss/on actions # 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 @@ -20,7 +22,7 @@ # GPIO13 Green LED (used to display fading status) # # ------------------------------------------ -# OPERATION (as of v1.4) +# OPERATION (as of v1.5) # ------------------------------------------ # 1. General-purpose LED controller. # 2. Designed for the Sinilink XY-WFMS board with a MOSFET output (claimed 5A, 5-36 V DC). @@ -31,7 +33,10 @@ # 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). +# 8. Default behaviour: +# - "Fade up to full": fade from floor to max on boot. +# - "Restore Brightness": fade from floor to the last non-zero brightness on boot (no on/off blip). +# - "Remain Off": stays off on boot. # 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). @@ -64,7 +69,7 @@ substitutions: # Project Naming project_name: "Sinilink.XY-WFMS" # Project details - project_version: "v1.4" # Project version denotes release of the YAML file, allowing checking of deployed vs latest version + project_version: "v1.5" # Project version denotes release of the YAML file, allowing checking of deployed vs latest version # Passwords & Secrets api_key: !secret esp-api_key @@ -100,8 +105,8 @@ packages: 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 + file: common/api_common.yaml + #file: common/api_common_noencryption.yaml vars: local_api_key: "${api_key}" #common_webportal: !include @@ -113,8 +118,8 @@ packages: #common_sntp: !include # file: common/sntp_common.yaml common_general_sensors: !include - #file: common/sensors_common.yaml - file: common/sensors_common_lite.yaml + file: common/sensors_common.yaml + #file: common/sensors_common_lite.yaml vars: local_friendly_name: "${friendly_name}" local_update_interval: "${update_interval}" @@ -129,7 +134,7 @@ esphome: comment: "${description_comment}" area: "${device_area}" on_boot: - priority: -200 + priority: 600 then: # Keep the HA dropdown in sync with the stored mode - lambda: |- @@ -147,14 +152,13 @@ esphome: - lambda: 'id(ramp_switch_target_on) = true;' - script.execute: ramp_on_script - # Mode 1: Restore Brightness using the light's normal defaults - # (uses default_transition_length and on_turn_on handlers) + # Mode 1: Restore Brightness using a scripted ramp 0 -> last non-zero brightness - if: condition: lambda: 'return id(restart_mode) == 1;' then: - lambda: |- - float target = id(last_brightness_pct); + float target = id(last_nonzero_brightness_pct); if (target < 0.0f) target = 0.0f; if (target > 100.0f) target = 100.0f; @@ -164,23 +168,18 @@ esphome: if (target > 0.0f && target < minp) target = minp; if (target > maxp) target = maxp; - id(suppress_slider_sync) = true; - if (target <= 0.0f) { - id(ramp_switch_target_on) = false; - auto call = id(mosfet_leds).make_call(); - call.set_state(false); - call.set_transition_length(0); - call.perform(); - } else { - id(ramp_switch_target_on) = true; - auto call = id(mosfet_leds).make_call(); - call.set_state(true); - call.set_brightness(target / 100.0f); - // No transition_length here: use light.default_transition_length. - call.perform(); - } - - delay: 300ms - - lambda: 'id(suppress_slider_sync) = false;' + id(restore_target_pct) = target; // set scripted target + id(ramp_switch_target_on) = (target > 0.0f); + - if: + condition: + lambda: 'return id(restore_target_pct) > 0.0f;' + then: + - script.stop: ramp_off_script + - script.execute: ramp_to_target_script + else: + - light.turn_off: + id: mosfet_leds + transition_length: 0s # Mode 2: Remain Off (no blip; stays off) - if: @@ -219,6 +218,9 @@ esp8266: mdns: disabled: false # Disabling will make the build file smaller (and it is still available via static IP) +preferences: + flash_write_interval: 5min + ########################################################################################## # GLOBAL VARIABLES # https://esphome.io/components/globals.html @@ -282,6 +284,26 @@ globals: type: int restore_value: false initial_value: '0' + # last non-zero actual 0..100 used, for stable "Restore Brightness" + - id: last_nonzero_brightness_pct + type: float + restore_value: true + initial_value: '0.0' + # target for "Restore Brightness" scripted ramp + - id: restore_target_pct + type: float + restore_value: false + initial_value: '0.0' + # true while a scripted ramp is in progress (to limit flash writes) + - id: is_ramping + type: bool + restore_value: false + initial_value: 'false' + # volatile (non-persistent) last non-zero brightness during ramps + - id: last_nonzero_tmp + type: float + restore_value: false + initial_value: '0.0' ########################################################################################## # LOGGER COMPONENT @@ -303,7 +325,7 @@ mqtt: payload: "${mqtt_local_device_command_ON}" then: - switch.turn_on: mosfet_ramp_switch - # Light control to ramp up + # Light control to ramp down - topic: "${mqtt_local_command_topic}/light/set" payload: "${mqtt_local_device_command_OFF}" then: @@ -333,11 +355,13 @@ switch: lambda: |- return id(ramp_switch_target_on); turn_on_action: - - lambda: 'id(ramp_switch_target_on) = true;' + - 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;' + - lambda: |- + id(ramp_switch_target_on) = false; - script.stop: ramp_on_script - script.execute: ramp_off_script @@ -475,6 +499,11 @@ sensor: - lambda: |- // Remember latest actual output (0..100) for "Restore Brightness" id(last_brightness_pct) = x; + if (x > 0.5f) { + // Only persist when not ramping; always track a volatile copy + if (!id(is_ramping)) id(last_nonzero_brightness_pct) = x; + id(last_nonzero_tmp) = x; + } // If not suppressing sync, update the 0..100 slider only when its INT changes if (!id(suppress_slider_sync)) { @@ -503,7 +532,7 @@ sensor: 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 + 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; @@ -520,7 +549,7 @@ output: - platform: esp8266_pwm id: mosfet_pwm pin: GPIO4 - frequency: 500 Hz # high frequency to avoid audible/visible artifacts + frequency: 1000 Hz # high frequency to avoid audible/visible artifacts - platform: gpio id: green_led_out # Green LED pin: @@ -536,7 +565,7 @@ light: id: mosfet_leds name: "${friendly_name}" output: mosfet_pwm - restore_mode: RESTORE_DEFAULT_OFF + restore_mode: ALWAYS_OFF default_transition_length: 2s icon: mdi:led-strip-variant gamma_correct: "${led_gamma}" @@ -747,6 +776,7 @@ script: - id: ramp_on_script mode: restart then: + - lambda: 'id(is_ramping) = true;' # mark ramping - script.stop: ramp_off_script - script.stop: led_flash_down - script.execute: led_flash_up @@ -770,9 +800,19 @@ script: const auto &cv = id(mosfet_leds).current_values; const float floor = id(min_brightness_pct) / 100.0f; const float cap = id(max_brightness_pct) / 100.0f; + if (cap <= floor + 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; } // guard degenerate case + + // IMPORTANT: when the light was OFF, treat current as 0 so we don't compute a 0ms jump to cap. float curr = cv.is_on() ? cv.get_brightness() : 0.0f; - if (curr < floor) curr = floor; + + // If we're below the floor (i.e. coming from OFF), we just did the "floor kick" above. + // Use full ramp_up_ms for a smooth 0 -> cap ramp. + 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; @@ -781,12 +821,82 @@ script: - delay: !lambda 'return (uint32_t) id(last_ramp_ms);' - script.stop: led_flash_up - output.turn_off: green_led_out + - lambda: |- + id(is_ramping) = false; // ramp complete + const auto &cv = id(mosfet_leds).current_values; + if (cv.is_on()) { + float pct = cv.get_brightness() * 100.0f; + if (pct > 0.5f) id(last_nonzero_brightness_pct) = pct; // commit once + } + # Script: ramp to a specific target (Restore Brightness path) + - id: ramp_to_target_script + mode: restart + then: + - lambda: 'id(is_ramping) = true;' # mark ramping + - script.stop: ramp_off_script + - script.stop: led_flash_down + - script.execute: led_flash_up + # Ensure we start at the floor cleanly. + - if: + condition: + lambda: |- + const auto &cv = id(mosfet_leds).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + return (!cv.is_on()) || (cv.get_brightness() + 0.0005f < floor); + then: + - light.turn_on: + id: mosfet_leds + brightness: !lambda 'return id(min_brightness_pct) / 100.0f;' + transition_length: 80ms + # Ramp from current (>= floor) to restore_target_pct over a fraction of ramp_up_ms. + - light.turn_on: + id: mosfet_leds + brightness: !lambda |- + float cap_pct = id(restore_target_pct); + float maxp = (float) id(max_brightness_pct); + if (cap_pct > maxp) cap_pct = maxp; + return cap_pct / 100.0f; + transition_length: !lambda |- + const auto &cv = id(mosfet_leds).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + float cap = id(restore_target_pct) / 100.0f; + const float maxp = id(max_brightness_pct) / 100.0f; + if (cap > maxp) cap = maxp; + if (cap <= floor + 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; } // guard + + // Same OFF case handling: if off, treat curr as 0 so we don't collapse the ramp. + float curr = cv.is_on() ? cv.get_brightness() : 0.0f; + + // Coming from OFF/below floor? We just did the "floor kick" above — use full ramp_up_ms. + 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);' + - script.stop: led_flash_up + - output.turn_off: green_led_out + - lambda: |- + id(is_ramping) = false; // ramp complete + const auto &cv = id(mosfet_leds).current_values; + if (cv.is_on()) { + float pct = cv.get_brightness() * 100.0f; + if (pct > 0.5f) id(last_nonzero_brightness_pct) = pct; // commit once + } # Script: ramp down from current level to floor, then cleanly cut to OFF - id: ramp_off_script mode: restart then: + - lambda: 'id(is_ramping) = true;' # mark ramping - script.stop: ramp_on_script - script.stop: led_flash_up - script.execute: led_flash_down @@ -796,9 +906,11 @@ script: 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; + float curr = cv.get_brightness(); if (curr < floor) curr = floor; - float frac = (curr - floor) / (1.0f - floor); + const float denom = (1.0f - floor); + if (denom <= 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; } // guard near-1.0 floor + 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); @@ -811,6 +923,7 @@ script: - script.stop: led_flash_down - output.turn_off: green_led_out - lambda: |- + id(is_ramping) = false; // ramp complete auto call = id(mosfet_leds).make_call(); call.set_state(false); call.set_brightness(id(max_brightness_pct) / 100.0f); diff --git a/esphome/esp-loungecabinetleds.yaml b/esphome/esp-loungecabinetleds.yaml new file mode 100644 index 0000000..3e69dee --- /dev/null +++ b/esphome/esp-loungecabinetleds.yaml @@ -0,0 +1,834 @@ +########################################################################################## +########################################################################################## +# Title: Lounge cabinet LEDS (Lounge) +# Hardware: Magichome inline single channel dimmer (ESP8266) +# +# Repo: +# +# v1.0 - 2025-08-17 First setup (and replacement of Tasmota) +# +# ------------------------------------------ +# DEVICE GPIO (Magichome ESP8266) +# ------------------------------------------ +# GPIO02 Blue LED (used for ESPHome status) - I think... can't see the board +# GPIO12 MOSFET output (0 V when switched) +# +# ------------------------------------------ +# OPERATION +# ------------------------------------------ +# - Startup/power restore 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-loungecabinetleds" + friendly_name: "Lounge Cabinet LEDs" + description_comment: "LED Strip inside the display cabinet upstairs lounge :: Magichome ESP8266" + device_area: "Lounge" # Allows the ESP device to be automatically linked to an 'Area' in Home Assistant. + + # Project Naming + project_name: "Magichome ESP8266" # Project details + project_version: "v1.0" # 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-loungecabinetleds_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: "lounge-cabinetleds" + 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: "12" # 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}" + area: "${device_area}" + on_boot: + priority: 600 + then: + # Keep the HA dropdown in sync with the stored mode + - lambda: |- + switch (id(restart_mode)) { + case 0: id(restart_action).publish_state("Fade up to full"); break; + case 1: id(restart_action).publish_state("Restore Brightness"); break; + case 2: default: id(restart_action).publish_state("Remain Off"); break; + } + + # Mode 0: Fade up to full (obeys fade settings; no on/off flicker) + - if: + condition: + lambda: 'return id(restart_mode) == 0;' + then: + - lambda: 'id(ramp_switch_target_on) = true;' + - script.execute: ramp_on_script + + # Mode 1: Restore Brightness using a scripted ramp 0 -> last non-zero brightness + - if: + condition: + lambda: 'return id(restart_mode) == 1;' + then: + - lambda: |- + float target = id(last_nonzero_brightness_pct); + if (target < 0.0f) target = 0.0f; + if (target > 100.0f) target = 100.0f; + const float minp = (float) id(min_brightness_pct); + const 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; // set scripted target + id(ramp_switch_target_on) = (target > 0.0f); + - if: + condition: + lambda: 'return id(restore_target_pct) > 0.0f;' + then: + - script.stop: ramp_off_script + - script.execute: ramp_to_target_script + else: + - light.turn_off: + id: mosfet_leds + transition_length: 0s + + # Mode 2: Remain Off (no blip; stays off) + - if: + condition: + lambda: 'return id(restart_mode) == 2;' + then: + - script.stop: ramp_on_script + - script.stop: ramp_off_script + - lambda: 'id(ramp_switch_target_on) = false;' + - light.turn_off: + id: mosfet_leds + transition_length: 0s + + platformio_options: + build_unflags: + - -flto + build_flags: + - -fno-lto + - -Wl,--gc-sections + - -ffunction-sections + - -fdata-sections + - -DNDEBUG + +########################################################################################## +# ESP PLATFORM AND FRAMEWORK +# https://esphome.io/components/esp8266.html +# https://esphome.io/components/esp32.html +########################################################################################## +esp8266: + board: esp01_1m + restore_from_flash: true # restore some values on reboot + +mdns: + disabled: false # Disabling will make the build file smaller (and it is still available via static IP) + +preferences: + flash_write_interval: 5min + +########################################################################################## +# 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' + # last non-zero actual 0..100 used, for stable "Restore Brightness" + - id: last_nonzero_brightness_pct + type: float + restore_value: true + initial_value: '0.0' + # target for "Restore Brightness" scripted ramp + - id: restore_target_pct + type: float + restore_value: false + initial_value: '0.0' + # true while a scripted ramp is in progress (to limit flash writes) + - id: is_ramping + type: bool + restore_value: false + initial_value: 'false' + # volatile (non-persistent) last non-zero brightness during ramps + - id: last_nonzero_tmp + type: float + restore_value: false + initial_value: '0.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 down + - 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 +######################################################################################### +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 + # 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: + +########################################################################################## +# 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 (x > 0.5f) { + // Only persist when not ramping; always track a volatile copy + if (!id(is_ramping)) id(last_nonzero_brightness_pct) = x; + id(last_nonzero_tmp) = 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: GPIO12 + frequency: 1000 Hz # higher frequency helps reduce camera banding while filming + +########################################################################################## +# LIGHT COMPONENT +# https://esphome.io/components/light/ +########################################################################################## +light: + - platform: monochromatic + id: mosfet_leds + name: "${friendly_name}" + output: mosfet_pwm + restore_mode: ALWAYS_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: + # Script: ramp up from current level. Obey global max. + - id: ramp_on_script + mode: restart + then: + - lambda: 'id(is_ramping) = true;' # mark ramping + - script.stop: ramp_off_script + # Ensure we start at at least the floor without a visible "pop". + - if: + condition: + lambda: |- + const auto &cv = id(mosfet_leds).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + return (!cv.is_on()) || (cv.get_brightness() + 0.0005f < floor); + then: + - light.turn_on: + id: mosfet_leds + brightness: !lambda 'return id(min_brightness_pct) / 100.0f;' + transition_length: 80ms + # Ramp from current (>= floor) to cap over a fraction of ramp_up_ms. + - light.turn_on: + id: mosfet_leds + brightness: !lambda 'return id(max_brightness_pct) / 100.0f;' + transition_length: !lambda |- + const auto &cv = id(mosfet_leds).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + const float cap = id(max_brightness_pct) / 100.0f; + if (cap <= floor + 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; } // guard degenerate case + + // IMPORTANT: when the light was OFF, treat current as 0 so we don't compute a 0ms jump to cap. + float curr = cv.is_on() ? cv.get_brightness() : 0.0f; + + // If we're below the floor (i.e. coming from OFF), we just did the "floor kick" above. + // Use full ramp_up_ms for a smooth 0 -> cap ramp. + 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; // ramp complete + const auto &cv = id(mosfet_leds).current_values; + if (cv.is_on()) { + float pct = cv.get_brightness() * 100.0f; + if (pct > 0.5f) id(last_nonzero_brightness_pct) = pct; // commit once + } + + # Script: ramp to a specific target (Restore Brightness path) + - id: ramp_to_target_script + mode: restart + then: + - lambda: 'id(is_ramping) = true;' # mark ramping + - script.stop: ramp_off_script + # Ensure we start at the floor cleanly. + - if: + condition: + lambda: |- + const auto &cv = id(mosfet_leds).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + return (!cv.is_on()) || (cv.get_brightness() + 0.0005f < floor); + then: + - light.turn_on: + id: mosfet_leds + brightness: !lambda 'return id(min_brightness_pct) / 100.0f;' + transition_length: 80ms + # Ramp from current (>= floor) to restore_target_pct over a fraction of ramp_up_ms. + - light.turn_on: + id: mosfet_leds + brightness: !lambda |- + float cap_pct = id(restore_target_pct); + float maxp = (float) id(max_brightness_pct); + if (cap_pct > maxp) cap_pct = maxp; + return cap_pct / 100.0f; + transition_length: !lambda |- + const auto &cv = id(mosfet_leds).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + float cap = id(restore_target_pct) / 100.0f; + const float maxp = id(max_brightness_pct) / 100.0f; + if (cap > maxp) cap = maxp; + if (cap <= floor + 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; } // guard + + // Same OFF case handling: if off, treat curr as 0 so we don't collapse the ramp. + float curr = cv.is_on() ? cv.get_brightness() : 0.0f; + + // Coming from OFF/below floor? We just did the "floor kick" above — use full ramp_up_ms. + 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; // ramp complete + const auto &cv = id(mosfet_leds).current_values; + if (cv.is_on()) { + float pct = cv.get_brightness() * 100.0f; + if (pct > 0.5f) id(last_nonzero_brightness_pct) = pct; // commit once + } + + # Script: ramp down from current level to floor, then cleanly cut to OFF + - id: ramp_off_script + mode: restart + then: + - lambda: 'id(is_ramping) = true;' # mark ramping + - script.stop: ramp_on_script + - light.turn_on: + id: mosfet_leds + brightness: !lambda 'return id(min_brightness_pct) / 100.0f;' + transition_length: !lambda |- + const auto &cv = id(mosfet_leds).current_values; + const float floor = id(min_brightness_pct) / 100.0f; + float curr = cv.get_brightness(); + if (curr < floor) curr = floor; + const float denom = (1.0f - floor); + if (denom <= 0.0005f) { id(last_ramp_ms) = 0; return (uint32_t) 0; } // guard near-1.0 floor + float frac = (curr - floor) / denom; + if (frac < 0.0f) frac = 0.0f; + if (frac > 1.0f) frac = 1.0f; + id(last_ramp_ms) = (int) (id(ramp_down_ms) * frac); + return (uint32_t) id(last_ramp_ms); + - delay: !lambda 'return (uint32_t) id(last_ramp_ms);' + - light.turn_off: + id: mosfet_leds + transition_length: 150ms + - delay: 150ms + - lambda: |- + id(is_ramping) = false; // ramp complete + 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 diff --git a/esphome/esp-loungemainsdimmer1.yaml b/esphome/esp-loungemainsdimmer1.yaml new file mode 100644 index 0000000..22d5b45 --- /dev/null +++ b/esphome/esp-loungemainsdimmer1.yaml @@ -0,0 +1,198 @@ +########################################################################################## +########################################################################################## +# Lounge Mains Dimmer +# V3.5 2025-07-24 YAML tidyups +########################################################################################## +# Zemismart KS-811 Double push button +# pinout/schematic https://community.home-assistant.io/t/zemismart-ks-811-working-with-esphome/ +# +# NOTES +# - +# +########################################################################################## +########################################################################################## + +########################################################################################## +# 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-loungemainsdimmer1" + friendly_name: "Lounge Lighting Mains Dimmer 1" + description_comment: "tba" + device_area: "Lounge" # Allows ESP device to be automatically linked to an 'Area' in Home Assistant. + + # Project Naming + project_name: "Generic ESP8266.D1 Mini" # Project Details + project_version: "v3.5" # Project V denotes release of yaml file, allowing checking of deployed vs latest version + + # Passwords & Secrets + api_key: !secret esp-api_key # unfortunately you can't use substitutions inside secrets names + ota_pass: !secret esp-ota_pass # unfortunately you can't use substitutions inside secrets names + static_ip_address: !secret esp-loungemainsdimmer1_ip + #mqtt_local_command_main_topic: !secret mqtt_local_command_main_topic + #mqtt_local_status_main_topic: !secret mqtt_local_status_main_topic + + # Device Settings + #relay_icon: "mdi:lightbulb-group" + log_level: "INFO" # Define logging level: NONE, ERROR, WARN, INFO, DEBUG (Default), VERBOSE, VERY_VERBOSE + update_interval: "60s" # update time for for general sensors etc + + # MQTT LOCAL Controls + #mqtt_device_name: "bedroom2-lights" + #mqtt_local_command_topic: "${mqtt_local_command_main_topic}/${mqtt_device_name}" # Topic we will use to command this locally without HA + #mqtt_local_status_topic: "${mqtt_local_status_main_topic}/${mqtt_device_name}" # Topic we will use to view status locally without HA + + # Switch Naming + #switch_1_name: "Main Lights" + #switch_2_name: "Desk Lights" + #switch_3_name: "Nil" + +# D5 = GPIO14 ZC +# D6 = GPIO12 PWM +######################################################################################### +# PACKAGES: Included Common Packages +# https://esphome.io/components/packages.html +########################################################################################## +packages: + common_wifi: !include + file: common/network_common.yaml + vars: + local_device_name: "${device_name}" + local_static_ip_address: "${static_ip_address}" + local_ota_pass: "${ota_pass}" + common_api: !include + file: common/api_common.yaml + vars: + local_api_key: "${api_key}" + #common_webportal: !include + # file: common/webportal_common.yaml + common_mqtt: !include + file: common/mqtt_common.yaml + vars: + local_device_name: "${device_name}" + #common_sntp: !include + # file: common/sntp_common.yaml + common_general_sensors: !include + file: common/sensors_common.yaml + vars: + local_friendly_name: "${friendly_name}" + local_update_interval: "${update_interval}" + +######################################################################################### +# ESPHome +# 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}" + project: + name: "${project_name}" + version: "${project_version}" + #on_boot: + # priority: 200 + # then: + # - switch.turn_on: Relay_2 + +######################################################################################### +# ESP Platform and Framework +# https://esphome.io/components/esp32.html +######################################################################################### +esp8266: + board: esp01_1m + early_pin_init: False # Initialise pins early to known values. Recommended false where switches are involved. Defaults to True. + board_flash_mode: dout # Default is dout + +######################################################################################### +# ESPHome Logging Enable +# https://esphome.io/components/logger.html +############################################# +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) + #esp8266_store_log_strings_in_flash: false + #tx_buffer_size: 64 + +######################################################################################### +# STATUS LED +# https://esphome.io/components/status_led.html +######################################################################################### +status_led: + pin: + number: GPIO2 + inverted: yes + +######################################################################################### +# BINARY SENSORS +# https://esphome.io/components/binary_sensor/ +######################################################################################### +#binary_sensor: +# - platform: gpio +# pin: +# number: GPIO16 +# mode: INPUT +# inverted: True +# name: "Button 1: ${switch_1_name}" +# on_press: +# - switch.toggle: Relay_1 + +# - platform: gpio +# pin: +# number: GPIO05 +# mode: INPUT +# inverted: True +# name: "Button 2: ${switch_2_name}" +# on_press: +# - switch.toggle: Relay_2 + +# KS-811-2 is a double only +# - platform: gpio +# pin: +# number: GPIO4 +# mode: INPUT +# inverted: True +# name: "Button 3: ${switch_3_name}" +# on_press: +# - switch.toggle: Relay_3 + +######################################################################################### +# SWITCH COMPONENT +# https://esphome.io/components/switch/ +######################################################################################### +#switch: +# - platform: gpio +# name: "Relay 1: ${switch_1_name}" +# pin: GPIO13 +# id: Relay_1 + +# - platform: gpio +# name: "Relay 2: ${switch_2_name}" +# pin: GPIO12 +# id: Relay_2 + +# KS-811-2 is a double only +# - platform: gpio +# name: "Relay 3: ${switch_3_name}" +# pin: GPIO14 +# id: Relay_3 + + +output: + - platform: ac_dimmer + id: dimmer1 + gate_pin: GPIO12 + zero_cross_pin: + number: GPIO14 + mode: + input: true + inverted: yes + method: LEADING + init_with_half_cycle: true + +light: + - platform: monochromatic + output: dimmer1 + name: Dimmable Light \ No newline at end of file diff --git a/esphome/esp-loungemoesdimmer1.yaml b/esphome/esp-loungemoesdimmer1.yaml new file mode 100644 index 0000000..aa37d84 --- /dev/null +++ b/esphome/esp-loungemoesdimmer1.yaml @@ -0,0 +1,195 @@ +########################################################################################## +########################################################################################## +# Moes Mains Dimmer +# Mains dimmer 150W single channel - Moes Wi-Fi Dimmer Module +# Module swapped to a ESP-02S module (from a Tuya WB2S) +# +# V1.0 2025-09-05 First Rev +########################################################################################## +# +# NOTES RE TUYA COMMS FOR THIS MODULE +# - Toggle DP1 (switch) to 1/0 for ON/OFF — the dimmer’s MCU does the smooth ramp itself over a few seconds. +# - We set DP2 (brightness) on a 0–1000 scale; this is the actual brightness level your firmware uses. +# - We read DP2 back as a sensor (and also show a % view) so we can see the live target the MCU is using. +# - DP3 is exposed only as an optional minimum-brightness limit; not touched during normal dimming unless you tune the low end. +# - It appears that 11 is the lowest DP3 raw value. Brightness of 1% sets this, and 2% ramps it to 19. +# - 50% brightness is 501, so it is pretty linear (you can't set Gamma, see below - I guess you could script it though) +# - DP4 (mode) exists but left alone (unless you want to experiment with load type/behavior). I can't see it do anything. Disabled by default in HA. +# - gamma_correct supposedly remaps the HA slider (no Tuya traffic) BUT it must be set to 1 or weird behaviour (keeps ramping to 0) +# - setting logs to DEBUG will show what is happening with Tuya commands +# +########################################################################################## +########################################################################################## + +########################################################################################## +# 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-loungemoesdimmer1" + friendly_name: "Lounge Lighting Moes Dimmer 1" + description_comment: "tba" + device_area: "Lounge" # Allows ESP device to be automatically linked to an 'Area' in Home Assistant. + + # Project Naming + project_name: "Generic ESP8266.ESP-02S" # Project Details + project_version: "v1.0" # Project V denotes release of yaml file, allowing checking of deployed vs latest version + + # Passwords & Secrets + api_key: !secret esp-api_key # unfortunately you can't use substitutions inside secrets names + ota_pass: !secret esp-ota_pass # unfortunately you can't use substitutions inside secrets names + static_ip_address: !secret esp-loungemoesdimmer1_ip + + # Device Settings + #relay_icon: "mdi:lightbulb-group" + log_level: "INFO" # Define logging level: NONE, ERROR, WARN, INFO, DEBUG (Default), VERBOSE, VERY_VERBOSE + update_interval: "60s" # update time for for general sensors etc + +######################################################################################### +# PACKAGES: Included Common Packages +# https://esphome.io/components/packages.html +########################################################################################## +# Comment some of these out where not needed, eg MQTT, Web portal, SNTP, General Sensors +########################################################################################## +packages: + common_wifi: !include + file: common/network_common.yaml + vars: + local_device_name: "${device_name}" + local_static_ip_address: "${static_ip_address}" + local_ota_pass: "${ota_pass}" + common_api: !include + file: common/api_common.yaml + vars: + local_api_key: "${api_key}" + #common_webportal: !include + # file: common/webportal_common.yaml + common_mqtt: !include + file: common/mqtt_common.yaml + vars: + local_device_name: "${device_name}" + common_sntp: !include + file: common/sntp_common.yaml + common_general_sensors: !include + file: common/sensors_common.yaml + vars: + local_friendly_name: "${friendly_name}" + local_update_interval: "${update_interval}" + +######################################################################################### +# ESPHome +# 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}" + project: + name: "${project_name}" + version: "${project_version}" + +######################################################################################### +# ESP Platform and Framework +# https://esphome.io/components/esp32.html +######################################################################################### +esp8266: + board: esp01_1m + #early_pin_init: False # Initialise pins early to known values. Recommended false where switches are involved. Defaults to True. + board_flash_mode: dout # Default is dout + +######################################################################################### +# ESPHome Logging Enable +# https://esphome.io/components/logger.html +############################################# +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) + #esp8266_store_log_strings_in_flash: false + #tx_buffer_size: 64 + +######################################################################################### +# STATUS LED +# https://esphome.io/components/status_led.html +######################################################################################### +status_led: + pin: + number: GPIO2 + inverted: yes + +######################################################################################### +# UART COMPONENT +# https://esphome.io/components/uart/ +######################################################################################### +uart: + # Needed for the Tuya component. + # This UART talks to the coprocessing tuya chip + tx_pin: GPIO1 + rx_pin: GPIO3 + baud_rate: 9600 + +######################################################################################### +# TUYA MCU COMPONENT +# https://esphome.io/components/tuya/ +######################################################################################### +tuya: + # logger baud_rate must be 0 + id: tuyamcu + +########################################################################################## +# LIGHT COMPONENT +# https://esphome.io/components/light/ +########################################################################################## +light: + - platform: tuya + name: "Lounge Lighting Moes Dimmer 1" + switch_datapoint: 1 # ON/OFF + dimmer_datapoint: 2 # <-- CHANGED: brightness is DP2 on your unit + #min_value_datapoint: 3 # optional; comment this out if it misbehaves + min_value: 0 + max_value: 1000 + gamma_correct: 1.0 + default_transition_length: 0s + +########################################################################################## +# SENSOR COMPONENT +# https://esphome.io/components/sensor/ +########################################################################################## +sensor: + # The raw value sent over the UART + # Listed as a dignostic, so will appear with the other diags in HA + - platform: tuya + id: dp2_brightness_raw + name: "Brightness (DP2 raw)" + sensor_datapoint: 2 + entity_category: diagnostic + # Raw value as a percentage of max + - platform: template + name: "Brightness (%)" + unit_of_measurement: "%" + accuracy_decimals: 0 + update_interval: 2s + lambda: |- + if (isnan(id(dp2_brightness_raw).state)) return NAN; + return (id(dp2_brightness_raw).state / 1000.0f) * 100.0f; + +########################################################################################## +# SELECT COMPONENT +# https://esphome.io/components/select/ +########################################################################################## +select: + # Experimental + - platform: tuya + id: dp4_mode + name: "DP4 Mode (experiment)" + enum_datapoint: 4 + options: + 0: "Mode 0" + 1: "Mode 1" + 2: "Mode 2" + 3: "Mode 3" + 4: "Mode 4" + 255: "Default/Unknown" + entity_category: config + disabled_by_default: true \ No newline at end of file