########################################################################################## ########################################################################################## # FLY SPRAYER - Sinilink XY-WFUSB (ESP8266/ESP8285) - ESPHome # # V1.0 2025-05-30 Initial Version # # Device: Sinilink XY-WFUSB module https://www.aliexpress.com/item/1005005815472269.html # GPIO: Tasmota template: Button=GPIO4, Relay=GPIO5, Led_i=GPIO14, LedLink=GPIO16 # # Purpose: # - Pulse a MOSFET/relay with 5 V to trigger a fly spray can at selectable intervals. # - Sprayer has its own timers, but it fires whenever it is started up, which we utilise here. # - Enforce a cooldown so it never double-fires too quickly (Sprayer has some memory) # - Operation window select: Night only / Day only / 24 Hours / On / Off. # - Randomize each interval by +- a percentage to avoid a perfectly regular pattern. # - Red LED (GPIO14, inverted) pulses slowly when idle; goes solid ON during spray. # - Green LED (GPIO16) as standard ESPHome status LED. # - Has a resettable total spray count, and a daily spray count # - has a countdown until next spray # ########################################################################################## ########################################################################################## ########################################################################################## # 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-flysprayer1" friendly_name: "Fly Sprayer" description_comment: "Control of generic automatic fly sprayer on a configurable interval" device_area: "Lounge" # Allows ESP device to be automatically linked to an 'Area' in Home Assistant. # Project Naming project_name: "Generic ESP8266.XY-WFUSB" # 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-flysprayer1_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 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 Controls #mqtt_device_name: "lounge-flyspray" #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 # Hardware pins (XY-WFUSB per your Tasmota screen) pin_button: 4 # GPIO4 -> momentary button (optional local trigger) pin_relay: 5 # GPIO5 -> MOSFET/relay output pin_led_red: 14 # GPIO14 -> Red LED (inverted) pin_led_green: 16 # GPIO16 -> Green status LED (WiFi/status) relay_inverted: false # Set to "true" if your MOSFET stage is active LOW # Timing configuration relay_activation_ms: 5000 # Relay Activation Time (ms). Nominally 5 seconds. relay_cooldown_ms: 30000 # Relay Cool Down Period (ms). Minimum gap between sprays. # Operation windows (local 24h). Night is from night_start_h to night_end_h across midnight. night_start_h: 21 # 21:00 (9pm) night_end_h: 7 # 07:00 (7am) # Interval jitter (+- percent of selected interval) interval_jitter_pct: 10 # Default +-10% ######################################################################################### # PACKAGES: Included Common Packages # https://esphome.io/components/packages.html ########################################################################################## packages: #### WIFI, Network (Static/DHCP/IPV6 etc), Fallback AP, Safemode #### common_wifi: !include file: common/network_common.yaml vars: local_device_name: "${device_name}" local_static_ip_address: "${static_ip_address}" local_ota_pass: "${ota_pass}" #### HOME ASSISTANT API (choose encryption or no encryption options) #### common_api: !include file: common/api_common.yaml #file: common/api_common_noencryption.yaml vars: local_api_key: "${api_key}" #### MQTT #### common_mqtt: !include file: common/mqtt_common.yaml vars: local_device_name: "${device_name}" #### WEB PORTAL #### #common_webportal: !include common/webportal_common.yaml #### SNTP (Only use if you want/need accurate timeclocks) #### common_sntp: !include common/sntp_common.yaml #### DIAGNOSTICS Sensors #### diag_basic: !include common/include_basic_diag_sensors.yaml diag_more: !include common/include_more_diag_sensors.yaml #diag_debug: !include common/include_debug_diag_sensors.yaml #diag_resetcount: !include common/include_resetcount_diag_sensors.yaml ########################################################################################## # ESPHome CORE CONFIGURATION # https://esphome.io/components/esphome.html ########################################################################################## esphome: name: "${device_name}" friendly_name: "${friendly_name}" comment: "${description_comment}" area: "${device_area}" project: name: "${project_name}" version: "${project_version}" on_boot: # Early: make hardware safe/pretty - priority: -10 then: - switch.turn_off: relay_out - light.turn_on: id: red_led brightness: 50% effect: red_idle_pulse # Late: after components & restores are ready, publish real values and start loop - priority: 800 then: - delay: 200ms - lambda: |- // Publish restored counts id(spray_count_sensor).publish_state((float) id(spray_count)); id(spray_count_today_sensor).publish_state(id(spray_count_today)); // Reset countdown display id(next_spray_seconds).publish_state(0); id(next_spray_in_text).publish_state("Ready"); - script.execute: spray_loop ########################################################################################## # ESP PLATFORM AND FRAMEWORK # https://esphome.io/components/esp8266.html # https://esphome.io/components/esp32.html ########################################################################################## esp8266: board: esp8285 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 preferences: flash_write_interval: 20s ########################################################################################## # GLOBAL VARIABLES # https://esphome.io/components/globals.html ########################################################################################## globals: # millisecond tick when we last fired successfully - id: last_fire_ms type: uint32_t restore_value: no initial_value: '0' - id: last_wait_ms type: uint32_t restore_value: no initial_value: '60000' - id: window_allowed type: bool restore_value: no initial_value: 'false' # absolute millis() when the next spray countdown ends - id: next_target_ms type: uint32_t restore_value: no initial_value: '0' # total successful sprays (persists across reboots) - id: spray_count type: uint32_t restore_value: yes initial_value: '0' # total sprays since local midnight - id: spray_count_today type: uint32_t restore_value: yes initial_value: '0' # day-of-month snapshot to detect midnight rollover - id: last_day type: int restore_value: yes initial_value: '-1' ########################################################################################## # 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) #esp8266_store_log_strings_in_flash: false #tx_buffer_size: 64 ########################################################################################## # 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: ${pin_led_green} inverted: false ########################################################################################## # SWITCH COMPONENT # https://esphome.io/components/switch/ ########################################################################################## switch: - platform: gpio id: relay_out name: "Spray Relay" icon: mdi:relay pin: number: ${pin_relay} inverted: ${relay_inverted} restore_mode: RESTORE_DEFAULT_OFF internal: true on_turn_on: # Ensure red LED goes solid during activation (script also enforces) - light.turn_on: id: red_led brightness: 100% effect: none on_turn_off: # Return to idle pulsing when relay is off - light.turn_on: id: red_led brightness: 50% effect: red_idle_pulse ################################################################################################# # BUTTON COMPONENT # https://esphome.io/components/button/index.html ################################################################################################# button: - platform: template id: spray_now name: "Spray Now" icon: mdi:spray on_press: then: - script.execute: try_spray_now - platform: template id: reset_spray_count name: "Reset Spray Count" icon: mdi:backup-restore on_press: then: - lambda: |- id(spray_count) = 0; id(spray_count_sensor).publish_state(0); ######################################################################################### # SELECT COMPONENT # https://esphome.io/components/select/index.html ######################################################################################### select: - platform: template id: spray_interval name: "${friendly_name} Spray Interval" icon: mdi:timer optimistic: true restore_value: true initial_option: "20 minutes" options: - "5 minutes" - "10 minutes" - "20 minutes" - "30 minutes" - "1 hour" - "2 hours" - "4 hours" on_value: then: - logger.log: format: "Spray Interval changed to %s - rescheduling" args: [ 'id(spray_interval).current_option().c_str()' ] - script.execute: spray_loop - platform: template id: spray_operation name: "${friendly_name} Operation" icon: mdi:toggle-switch optimistic: true restore_value: true initial_option: "On" options: - "Off" - "Night Only" - "Day Only" - "24 Hours" - "On" on_value: then: - logger.log: format: "Operation changed to %s - rescheduling" args: [ 'id(spray_operation).current_option().c_str()' ] - script.execute: spray_loop ########################################################################################## # BINARY SENSORS # https://esphome.io/components/binary_sensor/ ########################################################################################## binary_sensor: # Front panel/local button (GPIO4) - single press triggers a Spray Now (honors cooldown) - platform: gpio id: btn_local pin: number: ${pin_button} mode: INPUT_PULLUP inverted: true name: "Local Button Press" icon: mdi:spray internal: true on_press: then: - button.press: spray_now ########################################################################################## # SENSOR COMPONENT # https://esphome.io/components/sensor/ ########################################################################################## sensor: # Next spray remaining time in seconds (updated by the scheduler) - platform: template id: next_spray_seconds name: "Next Spray Seconds to go" icon: mdi:timer-sand unit_of_measurement: "s" accuracy_decimals: 0 #entity_category: diagnostic update_interval: never # Total sprays counter (persisted; updated on each spray and when reset) - platform: template id: spray_count_sensor name: "Spray Count Total" icon: mdi:counter unit_of_measurement: "" accuracy_decimals: 0 state_class: total_increasing update_interval: never filters: - round: 0 # Sprays since local midnight (auto-resets at midnight) - platform: template id: spray_count_today_sensor name: "${friendly_name} Spray Count Today" icon: mdi:counter unit_of_measurement: "" accuracy_decimals: 0 state_class: measurement update_interval: never filters: - round: 0 ########################################################################################## # TEXT SENSOR COMPONENT # https://esphome.io/components/text_sensor/ ########################################################################################## text_sensor: - platform: template id: next_spray_in_text name: "Next Spray In" icon: mdi:clock-outline #entity_category: diagnostic update_interval: never ########################################################################################## # 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: red_led_pwm pin: number: ${pin_led_red} inverted: true # Tasmota showed Led_i on GPIO14 ########################################################################################## # LIGHT COMPONENT # https://esphome.io/components/light/ ########################################################################################## light: - platform: monochromatic id: red_led name: "${friendly_name} Red LED" icon: mdi:led-on output: red_led_pwm default_transition_length: 300ms internal: true effects: - pulse: name: red_idle_pulse transition_length: 1500ms update_interval: 1500ms ########################################################################################## # NUMBER COMPONENT # https://esphome.io/components/number/ ########################################################################################## ########################################################################################## # SCRIPT COMPONENT # https://esphome.io/components/script.html # Scripts can be executed nearly anywhere in your device configuration with a single call. ########################################################################################## # SCRIPTS - main loop, cooldown-safe trigger, and actual spray action ########################################################################################## script: # Cooldown-checked "Spray Now" - id: try_spray_now mode: restart then: - if: condition: lambda: |- const uint32_t now = millis(); return (now - id(last_fire_ms)) >= (uint32_t) ${relay_cooldown_ms}; then: - logger.log: "Spray: activating relay (within allowed period)." - lambda: |- id(last_fire_ms) = millis(); - script.execute: do_spray else: - logger.log: "Spray request ignored: Fired during cool down period." # Actual relay action for the configured activation time - id: do_spray mode: restart then: - lambda: |- id(spray_count)++; id(spray_count_sensor).publish_state((float) id(spray_count)); id(spray_count_today)++; id(spray_count_today_sensor).publish_state(id(spray_count_today)); // publish as int - switch.turn_on: relay_out - light.turn_on: id: red_led brightness: 100% effect: none - delay: !lambda "return (uint32_t) ${relay_activation_ms};" - switch.turn_off: relay_out - light.turn_on: id: red_led brightness: 50% effect: red_idle_pulse # Scheduler loop - waits a randomized interval, updates countdown text, checks window, then tries to spray - id: spray_loop mode: restart then: - while: condition: lambda: "return true;" then: # 1) Compute the base interval and randomized wait - lambda: |- auto s = id(spray_interval).current_option(); uint32_t base_sec = 3600; // default 1 hour if (s == "5 minutes") base_sec = 5 * 60; else if (s == "10 minutes") base_sec = 10 * 60; else if (s == "20 minutes") base_sec = 20 * 60; else if (s == "30 minutes") base_sec = 30 * 60; else if (s == "1 hour") base_sec = 60 * 60; else if (s == "2 hours") base_sec = 2 * 60 * 60; else if (s == "4 hours") base_sec = 4 * 60 * 60; // Jitter +-pct const uint32_t pct = ${interval_jitter_pct}; const uint32_t jitter = (base_sec * pct) / 100; const uint32_t span = (jitter * 2u) + 1u; const uint32_t rnd = random_uint32() % span; // 0..(2*jitter) const int32_t delta = (int32_t) rnd - (int32_t) jitter; // -jitter..+jitter int32_t next_sec = (int32_t) base_sec + delta; if (next_sec < 1) next_sec = 1; ESP_LOGI("spray", "Next interval: base=%u s, jitter=%u%%, wait=%ld s", base_sec, pct, (long) next_sec); id(last_wait_ms) = (uint32_t) next_sec * 1000u; id(next_target_ms) = millis() + id(last_wait_ms); // Publish an initial countdown snapshot (minutes only for text; seconds kept for numeric sensor) uint32_t rem_ms = id(next_target_ms) > millis() ? (id(next_target_ms) - millis()) : 0; uint32_t sec = (rem_ms + 999u) / 1000u; // seconds, rounded up id(next_spray_seconds).publish_state(sec); uint32_t min = (sec + 59u) / 60u; // minutes, ceiling char buf[16]; snprintf(buf, sizeof(buf), "%u min", (unsigned) min); std::string countdown_txt(buf); // Override text for specific modes auto op = id(spray_operation).current_option(); char await_buf[16]; if (op == "Off") { id(next_spray_in_text).publish_state("Disabled"); } else if (op == "Night Only") { bool in_window = false; if (id(sntp_time).now().is_valid()) { auto n = id(sntp_time).now(); int hour = n.hour; int nh = ${night_start_h}; int eh = ${night_end_h}; if (nh > eh) in_window = (hour >= nh) || (hour < eh); else in_window = (hour >= nh) && (hour < eh); } if (!in_window) { snprintf(await_buf, sizeof(await_buf), "%02d:00", ${night_start_h}); id(next_spray_in_text).publish_state(std::string("Awaiting ") + await_buf); } else { id(next_spray_in_text).publish_state(countdown_txt); } } else if (op == "Day Only") { bool in_window = false; if (id(sntp_time).now().is_valid()) { auto n = id(sntp_time).now(); int hour = n.hour; int nh = ${night_start_h}; int eh = ${night_end_h}; bool is_night; if (nh > eh) is_night = (hour >= nh) || (hour < eh); else is_night = (hour >= nh) && (hour < eh); in_window = !is_night; // day is the opposite of night window } if (!in_window) { snprintf(await_buf, sizeof(await_buf), "%02d:00", ${night_end_h}); id(next_spray_in_text).publish_state(std::string("Awaiting ") + await_buf); } else { id(next_spray_in_text).publish_state(countdown_txt); } } else { id(next_spray_in_text).publish_state(countdown_txt); } # 2) Tick the countdown once per second until the target time - while: condition: lambda: "return millis() < id(next_target_ms);" then: - lambda: |- uint32_t rem_ms = id(next_target_ms) > millis() ? (id(next_target_ms) - millis()) : 0; uint32_t sec = (rem_ms + 999u) / 1000u; // seconds, rounded up id(next_spray_seconds).publish_state(sec); uint32_t min = (sec + 59u) / 60u; // minutes, ceiling char buf[16]; snprintf(buf, sizeof(buf), "%u min", (unsigned) min); std::string countdown_txt(buf); // Override text for specific modes auto op = id(spray_operation).current_option(); char await_buf[16]; if (op == "Off") { id(next_spray_in_text).publish_state("Disabled"); } else if (op == "Night Only") { bool in_window = false; if (id(sntp_time).now().is_valid()) { auto n = id(sntp_time).now(); int hour = n.hour; int nh = ${night_start_h}; int eh = ${night_end_h}; if (nh > eh) in_window = (hour >= nh) || (hour < eh); else in_window = (hour >= nh) && (hour < eh); } if (!in_window) { snprintf(await_buf, sizeof(await_buf), "%02d:00", ${night_start_h}); id(next_spray_in_text).publish_state(std::string("Awaiting ") + await_buf); } else { id(next_spray_in_text).publish_state(countdown_txt); } } else if (op == "Day Only") { bool in_window = false; if (id(sntp_time).now().is_valid()) { auto n = id(sntp_time).now(); int hour = n.hour; int nh = ${night_start_h}; int eh = ${night_end_h}; bool is_night; if (nh > eh) is_night = (hour >= nh) || (hour < eh); else is_night = (hour >= nh) && (hour < eh); in_window = !is_night; } if (!in_window) { snprintf(await_buf, sizeof(await_buf), "%02d:00", ${night_end_h}); id(next_spray_in_text).publish_state(std::string("Awaiting ") + await_buf); } else { id(next_spray_in_text).publish_state(countdown_txt); } } else { id(next_spray_in_text).publish_state(countdown_txt); } // Midnight rollover check (via sntp_time) if (id(sntp_time).now().is_valid()) { auto n = id(sntp_time).now(); int d = n.day_of_month; if (id(last_day) == -1) { // First valid time after boot: initialise without reset id(last_day) = d; } else if (id(last_day) != d) { // New day -> reset today's counter id(last_day) = d; id(spray_count_today) = 0; id(spray_count_today_sensor).publish_state(0); } } - delay: 1s # 3) Check operation window; if allowed, attempt spray (cooldown enforced inside) - lambda: |- auto op = id(spray_operation).current_option(); bool allowed = false; if (op == "Off") { allowed = false; } else if (op == "24 Hours" || op == "On") { allowed = true; } else { if (!id(sntp_time).now().is_valid()) { allowed = false; // no time yet; be conservative } else { auto n = id(sntp_time).now(); int hour = n.hour; // 0..23 int nh = ${night_start_h}; int eh = ${night_end_h}; bool is_night; if (nh > eh) { // window crosses midnight (e.g., 21:00..07:00) is_night = (hour >= nh) || (hour < eh); } else { is_night = (hour >= nh) && (hour < eh); } if (op == "Night Only") allowed = is_night; else if (op == "Day Only") allowed = !is_night; } } if (allowed) ESP_LOGI("spray", "Window OK: attempting spray."); else ESP_LOGI("spray", "Window blocked: skipping this cycle."); id(window_allowed) = allowed; - if: condition: lambda: "return id(window_allowed);" then: - script.execute: try_spray_now