#:########################################################################################:#
# TITLE: FLY SPRAYER - SINILINK XY-WFUSB
# zorruno.com layout v1.1 2026
#:########################################################################################:#
# REPO:
# https://home.fox.co.nz/gitea/zorruno/zorruno-homeassistant/src/branch/master/esphome/esp-flysprayer1.yaml
#:########################################################################################:#
# VERSIONS:
# v1.1 2026-02-25 Updated yaml to zorruno layout V1.1
# v1.0 2025-05-30 Initial Version
#:########################################################################################:#
# HARDWARE:
# - Sinilink XY-WFUSB (ESP8266/ESP8285)
# - Device link: https://www.aliexpress.com/item/1005005815472269.html
# - GPIO (from Tasmota template):
#   - Button = GPIO4
#   - Relay  = GPIO5
#   - Led_i  = GPIO14 (Red LED, inverted)
#   - LedLink= GPIO16 (Green LED, status)
#:########################################################################################:#
# OPERATION NOTES:
# - Purpose:
#   - Pulse a MOSFET/relay with 5 V to trigger a fly spray can at selectable intervals.
#   - The sprayer has its own timers, but it fires whenever it is started up; we utilize that.
# - Safety / Reliability:
#   - Cooldown is enforced so it never double-fires too quickly (sprayer has some memory).
#   - Interval can be randomized by +- a percentage (jitter) to avoid a perfectly regular pattern.
# - Scheduling:
#   - Operation modes: Night only / Day only / 24 Hours / On / Off.
#   - Night window spans across midnight (night_start_h -> night_end_h).
# - Indicators:
#   - Red LED (GPIO14, inverted) pulses slowly when idle; goes solid ON during spray.
#   - Green LED (GPIO16) is used as the ESPHome status LED.
# - Counters / UI:
#   - Total spray count (resettable).
#   - Daily spray count (resets at local midnight when time is valid).
#   - Countdown until next spray (seconds + friendly text).
#:########################################################################################:#
# OFFLINE NOTES:
# a) Home Assistant OFFLINE, WiFi OK
# - Device continues operating locally (spray loop, cooldown, LEDs, counters).
# - You can still trigger "Spray Now" via the local button; HA UI will update when HA returns.
#
# b) MQTT OFFLINE (or broker unreachable)
# - No device-specific MQTT commands are used in this YAML (MQTT section is commented out),
#   so behavior is unchanged.
#
# c) Entire WiFi/Network OFFLINE
# - Accurate time IS needed for Night/Day modes and daily counter reset (SNTP required).
# - If time is invalid, this YAML is conservative for Night/Day modes (skips those cycles).
#   24 Hours / On still allows operation, but daily reset will not occur until time is valid.
#:########################################################################################:#


#:########################################################################################:#
# SUBSTITUTIONS: Specific device variable substitutions
# If NOT using a secrets file, just replace these with the passwords etc (in quotes)
#:########################################################################################:#
substitutions:
  # Device Naming
  device_name: "esp-flysprayer1"
  friendly_name: "Fly Sprayer"
  description_comment: "Control of generic automatic fly sprayer on a configurable interval (Layout V1.1)"
  device_area: "Lounge"

  # Project Naming
  project_name: "Generic ESP8266.XY-WFUSB"
  project_version: "v1.1"

  # Passwords & Secrets
  api_key: !secret esp-api_key
  ota_pass: !secret esp-ota_pass
  static_ip_address: !secret esp-flysprayer1_ip

  # If we are changing IP addresses, you must update the current IP address here, otherwise it remains
  # Don't forget to switch it back when changed.
  current_ip_address: ${static_ip_address}

  # Device Settings
  log_level: "INFO"
  update_interval: "60s"

  # 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}"
      local_current_ip_address: "${current_ip_address}"

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

  #### 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
# 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
#:########################################################################################:#
esp8266:
  board: esp8285
  early_pin_init: false
  board_flash_mode: dout

preferences:
  flash_write_interval: 20s

#:########################################################################################:#
# GLOBALS
# 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
# https://esphome.io/components/logger.html
#:########################################################################################:#
logger:
  level: "${log_level}"
  #baud_rate: 0

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

#:########################################################################################:#
# MQTT COMMANDS
# (No device-specific MQTT triggers are enabled in this file currently)
#:########################################################################################:#
#mqtt:
#  on_message:

#:########################################################################################:#
# SWITCH
# 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
# 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
# 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) - triggers 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

#:########################################################################################:#
# SENSORS
# 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
    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 when time is valid)
  - 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 SENSORS
# https://esphome.io/components/text_sensor/
#:########################################################################################:#
text_sensor:
  - platform: template
    id: next_spray_in_text
    name: "Next Spray In"
    icon: mdi:clock-outline
    update_interval: never

#:########################################################################################:#
# OUTPUT
# https://esphome.io/components/output/
#:########################################################################################:#
output:
  - platform: esp8266_pwm
    id: red_led_pwm
    pin:
      number: ${pin_led_red}
      inverted: true

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

#:########################################################################################:#
# SCRIPTS
# https://esphome.io/components/script.html
#:########################################################################################:#
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));
      - 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, 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
                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);
                }

            # 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;
                      id(next_spray_seconds).publish_state(sec);

                      uint32_t min = (sec + 59u) / 60u;
                      char buf[16];
                      snprintf(buf, sizeof(buf), "%u min", (unsigned) min);
                      std::string countdown_txt(buf);

                      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) {
                          id(last_day) = d; // first valid time after boot
                        } else if (id(last_day) != d) {
                          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;
                    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);
                    }

                    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