Files
zorruno-homeassistant/esphome/esp-flysprayer1.yaml
T
2026-01-24 14:06:52 +13:00

693 lines
27 KiB
YAML

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