#:########################################################################################:#
# TITLE: DATA CUPBOARD FAN CONTROL
# zorruno.com layout v1.1 2026
#:########################################################################################:#
# REPO:
# https://home.fox.co.nz/gitea/zorruno/zorruno-homeassistant/src/branch/master/esphome/esp-datacupboardfans.yaml
#:########################################################################################:#
# VERSIONS:
# V1.3 2026-02-20 Remove invalid allow_other_uses on pulse_counter pins (ESPHome 2026.2.x)
# V1.2 2026-02-20 Use common SNTP package, and publish fan fault/status via interval script
# V1.1 2026-02-20 Fix template binary_sensor update method for ESPHome 2026.2.x
# V1.0 2026-02-20 Initial Version
#:########################################################################################:#
# HARDWARE:
# Yunshan 7-30V 10A Relay Board (HW-622) with ESP8266MOD
#:########################################################################################:#
# OPERATION NOTES:
# - Fan tach inputs are on GPIO1/GPIO3 (UART pins). Logger UART is disabled (baud_rate: 0).
# - DHT22 data is on GPIO0 (boot-strap pin). Usually OK, but if you ever get boot issues,
#   move the DHT22 to a different GPIO if your board allows.
# - Using common/sntp_common.yaml for time and diagnostic time text sensors.
# - Thermostat logic uses hysteresis thresholds (On temp / Off temp) plus a manual override.
# - Fan fault is evaluated periodically, with grace windows after boot and relay changes.
#:########################################################################################:#
# MQTT COMMANDS:
# Base topic: ${mqtt_main_topic}
# - ${mqtt_main_topic}/relay/set     : ON,OFF
# - ${mqtt_main_topic}/override/set  : ON,OFF
#:########################################################################################:#
# OFFLINE NOTES:
# a) Home Assistant OFFLINE, but Network and MQTT ONLINE
#    - Fan control logic still runs on-device (thermostat + override)
#    - MQTT commands still work and fan status/fault still publishes via MQTT (common_mqtt)
# b) MQTT OFFLINE (but WiFi/Network and HA API ONLINE)
#    - Fan control logic still runs on-device (thermostat + override)
#    - HA can still control entities via API, but MQTT command topics will not work
# c) Entire WiFi/Network OFFLINE
#    - Fan control logic still runs on-device (thermostat + override)
#    - Timeclocks will drift without network (SNTP). Time of day can be reset by powering off then on again at 12pm (midday)
#:########################################################################################:#

#:########################################################################################:#
# 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-datacupboardfans"
  friendly_name: "Data Cupboard Fans"
  description_comment: "Fan control for Data Cupboard (Layout V1.1)"
  device_area: "Data Cupboard"

  # Project Naming
  project_name: "Yunshan.HW-622 ESP8266MOD Relay Board"
  project_version: "v1.3"

  # Passwords and Secrets (Unfortunately, you cannot use substitutions inside secret names)
  api_key: !secret esp-api_key
  ota_pass: !secret esp-ota_pass
  static_ip_address: !secret esp-datacupboardfans_ip
  mqtt_command_main_topic: !secret mqtt_command_main_topic

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

  # MQTT Controls
  mqtt_device_name: "data-cupboard-fans"
  mqtt_main_topic: "${mqtt_command_main_topic}/${mqtt_device_name}"

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

  # Names
  relay_name: "Data Cupboard Fans Relay"
  override_name: "Override Fans On"
  door_name: "Data Cupboard Door Status"
  temp_name: "Data Cupboard Temperature"
  hum_name: "Data Cupboard Humidity"
  fan1_rpm_name: "Data Cupboard Fan1 RPM"
  fan2_rpm_name: "Data Cupboard Fan2 RPM"
  fan_status_name: "Data Cupboard Fan Status"

  # Fan tach assumptions (typical PC fan = 2 pulses per revolution)
  fan_pulses_per_rev: "2"

  # Fault thresholds
  rpm_min_when_relay_off: "950"
  rpm_min_when_relay_on: "2300"

  # Default thermostat thresholds
  default_temp_on: "35"
  default_temp_off: "28"

#:########################################################################################:#
# 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 ####
  common_api: !include
    file: common/api_common.yaml
    vars:
      local_api_key: "${api_key}"

  #### MQTT ####
  common_mqtt: !include
    file: common/mqtt_common.yaml
    vars:
      local_device_name: "${device_name}"

  #### SNTP ####
  common_sntp: !include
    file: common/sntp_common.yaml

  #### DIAGNOSTICS Sensors ####
  diag_basic: !include common/include_basic_diag_sensors.yaml
  diag_more: !include common/include_more_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:
    priority: -100
    then:
      - lambda: |-
          id(boot_ms) = millis();
          id(relay_last_change_ms) = millis();
      - delay: 2s
      - script.execute: apply_fan_control
      - script.execute: evaluate_fan_fault

#:########################################################################################:#
# ESP PLATFORM AND FRAMEWORK:
# https://esphome.io/components/esp8266/
# https://esphome.io/components/esp32/
#:########################################################################################:#
esp8266:
  board: esp12e
  restore_from_flash: true

preferences:
  flash_write_interval: 5min

mdns:
  disabled: false

#:########################################################################################:#
# LOGGING:
# https://esphome.io/components/logger.html
#:########################################################################################:#
logger:
  level: "${log_level}"
  baud_rate: 0

#:########################################################################################:#
# GLOBAL VARIABLES:
# https://esphome.io/components/globals.html
#:########################################################################################:#
globals:
  # Max temperature tracking
  - id: g_max_temp_today
    type: float
    restore_value: true
    initial_value: "-1000.0"

  - id: g_max_temp_yesterday
    type: float
    restore_value: true
    initial_value: "-1000.0"

  - id: g_max_temp_month
    type: float
    restore_value: true
    initial_value: "-1000.0"

  - id: g_max_temp_last_month
    type: float
    restore_value: true
    initial_value: "-1000.0"

  - id: g_max_temp_year
    type: float
    restore_value: true
    initial_value: "-1000.0"

  # Last seen date parts (for rollover)
  - id: g_last_day
    type: int
    restore_value: true
    initial_value: "0"

  - id: g_last_month
    type: int
    restore_value: true
    initial_value: "0"

  - id: g_last_year
    type: int
    restore_value: true
    initial_value: "0"

  # Fault evaluation grace timing
  - id: boot_ms
    type: uint32_t
    restore_value: false
    initial_value: "0"

  - id: relay_last_change_ms
    type: uint32_t
    restore_value: false
    initial_value: "0"

#:########################################################################################:#
# INTERVAL:
# https://esphome.io/components/interval.html
#:########################################################################################:#
# Periodic evaluations
# NOTE: common_sntp also defines its own interval (60s) for its fallback clock.
interval:
  - interval: 5s
    then:
      - script.execute: evaluate_fan_fault

#:########################################################################################:#
# SWITCHES:
# https://esphome.io/components/switch/
#:########################################################################################:#
switch:
  # Relay output (GPIO4) - powers both fans
  - platform: gpio
    name: "${relay_name}"
    id: fan_relay
    pin: GPIO4
    restore_mode: RESTORE_DEFAULT_OFF
    icon: "mdi:fan"
    on_turn_on:
      then:
        - lambda: |-
            id(relay_last_change_ms) = millis();
        - script.execute: apply_fan_control
        - script.execute: evaluate_fan_fault
    on_turn_off:
      then:
        - lambda: |-
            id(relay_last_change_ms) = millis();
        - script.execute: apply_fan_control
        - script.execute: evaluate_fan_fault

  # Override switch (forces fans ON, bypasses thermostat logic)
  - platform: template
    name: "${override_name}"
    id: override_fans_on
    restore_mode: RESTORE_DEFAULT_OFF
    optimistic: true
    icon: "mdi:fan-plus"
    entity_category: config
    turn_on_action:
      - switch.turn_on: fan_relay
      - script.execute: apply_fan_control
      - script.execute: evaluate_fan_fault
    turn_off_action:
      - script.execute: apply_fan_control
      - script.execute: evaluate_fan_fault

#:########################################################################################:#
# NUMBERS:
# https://esphome.io/components/number/
#:########################################################################################:#
# On/Off thresholds with hysteresis
number:
  - platform: template
    name: "Fans On Temperature"
    id: temp_on_threshold
    unit_of_measurement: "degC"
    min_value: 25
    max_value: 40
    step: 0.5
    mode: slider
    restore_value: true
    initial_value: ${default_temp_on}
    optimistic: true
    entity_category: config
    icon: "mdi:thermometer-chevron-up"
    set_action:
      - script.execute: apply_fan_control

  - platform: template
    name: "Fans Off Temperature"
    id: temp_off_threshold
    unit_of_measurement: "degC"
    min_value: 25
    max_value: 40
    step: 0.5
    mode: slider
    restore_value: true
    initial_value: ${default_temp_off}
    optimistic: true
    entity_category: config
    icon: "mdi:thermometer-chevron-down"
    set_action:
      - script.execute: apply_fan_control

#:########################################################################################:#
# BINARY SENSORS:
# https://esphome.io/components/binary_sensor/
#:########################################################################################:#
binary_sensor:
  # Door status on GPIO5
  - platform: gpio
    name: "${door_name}"
    id: door_status
    device_class: door
    pin:
      number: GPIO5
      mode: INPUT_PULLUP
      inverted: false
    filters:
      - delayed_on: 50ms
      - delayed_off: 50ms

  # Fan fault boolean (published by script)
  - platform: template
    name: "Data Cupboard Fan Fault"
    id: fan_fault
    device_class: problem

#:########################################################################################:#
# STATUS LED:
# https://esphome.io/components/status_led.html
#:########################################################################################:#
status_led:
  pin:
    number: GPIO2
    inverted: true

#:########################################################################################:#
# SENSORS:
# https://esphome.io/components/sensor/
#:########################################################################################:#
sensor:
  # DHT22 on GPIO0
  - platform: dht
    pin: GPIO0
    model: DHT22
    update_interval: 30s
    temperature:
      name: "${temp_name}"
      id: cupboard_temp
      unit_of_measurement: "degC"
      accuracy_decimals: 1
      on_value:
        then:
          - script.execute: update_temp_maxima
          - script.execute: apply_fan_control
    humidity:
      name: "${hum_name}"
      id: cupboard_humidity
      unit_of_measurement: "%"
      accuracy_decimals: 1

  # Fan1 tach pulses on GPIO1 (UART TX pin)
  - platform: pulse_counter
    pin:
      number: GPIO1
      mode: INPUT_PULLUP
      inverted: false
    id: fan1_rpm
    name: "${fan1_rpm_name}"
    unit_of_measurement: "rpm"
    accuracy_decimals: 0
    update_interval: 10s
    count_mode:
      rising_edge: DISABLE
      falling_edge: INCREMENT
    filters:
      # pulse_counter is pulses/minute; typical PC fan tach = 2 pulses per revolution
      - lambda: |-
          return x / (float) ${fan_pulses_per_rev};
      - clamp:
          min_value: 0
          max_value: 20000

  # Fan2 tach pulses on GPIO3 (UART RX pin)
  - platform: pulse_counter
    pin:
      number: GPIO3
      mode: INPUT_PULLUP
      inverted: false
    id: fan2_rpm
    name: "${fan2_rpm_name}"
    unit_of_measurement: "rpm"
    accuracy_decimals: 0
    update_interval: 10s
    count_mode:
      rising_edge: DISABLE
      falling_edge: INCREMENT
    filters:
      - lambda: |-
          return x / (float) ${fan_pulses_per_rev};
      - clamp:
          min_value: 0
          max_value: 20000

  # Max temperature sensors (derived from cupboard_temp)
  - platform: template
    name: "Data Cupboard Max Temp Today"
    id: max_temp_today
    unit_of_measurement: "degC"
    accuracy_decimals: 1
    update_interval: 60s
    lambda: |-
      return id(g_max_temp_today);

  - platform: template
    name: "Data Cupboard Max Temp Yesterday"
    id: max_temp_yesterday
    unit_of_measurement: "degC"
    accuracy_decimals: 1
    update_interval: 60s
    lambda: |-
      return id(g_max_temp_yesterday);

  - platform: template
    name: "Data Cupboard Max Temp This Month"
    id: max_temp_month
    unit_of_measurement: "degC"
    accuracy_decimals: 1
    update_interval: 60s
    lambda: |-
      return id(g_max_temp_month);

  - platform: template
    name: "Data Cupboard Max Temp Last Month"
    id: max_temp_last_month
    unit_of_measurement: "degC"
    accuracy_decimals: 1
    update_interval: 60s
    lambda: |-
      return id(g_max_temp_last_month);

  - platform: template
    name: "Data Cupboard Max Temp This Year"
    id: max_temp_year
    unit_of_measurement: "degC"
    accuracy_decimals: 1
    update_interval: 60s
    lambda: |-
      return id(g_max_temp_year);

#:########################################################################################:#
# TEXT SENSORS:
# https://esphome.io/components/text_sensor/
#:########################################################################################:#
# Normal/Fault status - published by script
# NOTE: common_sntp also defines its own text_sensors (time status etc).
text_sensor:
  - platform: template
    name: "${fan_status_name}"
    id: fan_status
    icon: "mdi:fan-alert"

#:########################################################################################:#
# SCRIPTS:
# https://esphome.io/components/script.html
#:########################################################################################:#
script:
  # Apply fan control logic (override + hysteresis thresholds)
  - id: apply_fan_control
    mode: queued
    then:
      - lambda: |-
          const float t = id(cupboard_temp).state;
          if (isnan(t)) {
            return;
          }

          float on_t = id(temp_on_threshold).state;
          float off_t = id(temp_off_threshold).state;

          // Guard against bad settings (keep at least 0.5C hysteresis)
          if (off_t >= on_t) {
            off_t = on_t - 0.5f;
          }

          if (id(override_fans_on).state) {
            if (!id(fan_relay).state) {
              id(fan_relay).turn_on();
            }
            return;
          }

          // Hysteresis-based control
          if (!id(fan_relay).state) {
            if (t >= on_t) {
              id(fan_relay).turn_on();
            }
          } else {
            if (t <= off_t) {
              id(fan_relay).turn_off();
            }
          }

  # Evaluate and publish fan fault and status
  - id: evaluate_fan_fault
    mode: restart
    then:
      - lambda: |-
          const uint32_t now_ms = millis();

          // Grace window after boot / relay change to allow RPM to stabilize
          const bool in_grace =
            (now_ms - id(boot_ms) < 20000) ||
            (now_ms - id(relay_last_change_ms) < 15000);

          if (in_grace) {
            id(fan_fault).publish_state(false);
            id(fan_status).publish_state("Normal");
            return;
          }

          const float r1 = id(fan1_rpm).state;
          const float r2 = id(fan2_rpm).state;

          if (isnan(r1) || isnan(r2)) {
            id(fan_fault).publish_state(true);
            id(fan_status).publish_state("Fault");
            return;
          }

          const float req = id(fan_relay).state ? ${rpm_min_when_relay_on} : ${rpm_min_when_relay_off};

          const bool fault = (r1 < req) || (r2 < req);

          id(fan_fault).publish_state(fault);
          id(fan_status).publish_state(fault ? "Fault" : "Normal");

  # Update daily/monthly/yearly maxima based on current temperature and current date
  - id: update_temp_maxima
    mode: queued
    then:
      - lambda: |-
          const float t = id(cupboard_temp).state;
          if (isnan(t)) {
            return;
          }

          auto now = id(sntp_time).now();
          if (!now.is_valid()) {
            // Time not valid yet: still track as rolling maxima
            if (t > id(g_max_temp_today)) id(g_max_temp_today) = t;
            if (t > id(g_max_temp_month)) id(g_max_temp_month) = t;
            if (t > id(g_max_temp_year)) id(g_max_temp_year) = t;
          } else {
            // Initialize date tracking on first valid time
            if (id(g_last_year) == 0) {
              id(g_last_year) = now.year;
              id(g_last_month) = now.month;
              id(g_last_day) = now.day_of_month;

              id(g_max_temp_today) = t;
              id(g_max_temp_yesterday) = t;
              id(g_max_temp_month) = t;
              id(g_max_temp_last_month) = t;
              id(g_max_temp_year) = t;
            } else {
              // Year rollover
              if (now.year != id(g_last_year)) {
                id(g_last_year) = now.year;
                id(g_max_temp_year) = t;
              }

              // Month rollover
              if (now.month != id(g_last_month)) {
                id(g_max_temp_last_month) = id(g_max_temp_month);
                id(g_max_temp_month) = t;
                id(g_last_month) = now.month;
              }

              // Day rollover
              if (now.day_of_month != id(g_last_day)) {
                id(g_max_temp_yesterday) = id(g_max_temp_today);
                id(g_max_temp_today) = t;
                id(g_last_day) = now.day_of_month;
              }

              // Update running maxima
              if (t > id(g_max_temp_today)) id(g_max_temp_today) = t;
              if (t > id(g_max_temp_month)) id(g_max_temp_month) = t;
              if (t > id(g_max_temp_year)) id(g_max_temp_year) = t;
            }
          }

          // Publish template sensors promptly
          id(max_temp_today).publish_state(id(g_max_temp_today));
          id(max_temp_yesterday).publish_state(id(g_max_temp_yesterday));
          id(max_temp_month).publish_state(id(g_max_temp_month));
          id(max_temp_last_month).publish_state(id(g_max_temp_last_month));
          id(max_temp_year).publish_state(id(g_max_temp_year));

#:########################################################################################:#
# MQTT (DEVICE-SPECIFIC COMMAND SUBSCRIPTIONS):
#:########################################################################################:#
mqtt:
  on_message:
    # Relay control
    - topic: "${mqtt_main_topic}/relay/set"
      payload: "ON"
      then:
        - switch.turn_on: fan_relay
    - topic: "${mqtt_main_topic}/relay/set"
      payload: "OFF"
      then:
        - switch.turn_off: fan_relay

    # Override control
    - topic: "${mqtt_main_topic}/override/set"
      payload: "ON"
      then:
        - switch.turn_on: override_fans_on
    - topic: "${mqtt_main_topic}/override/set"
      payload: "OFF"
      then:
        - switch.turn_off: override_fans_on