########################################################################################## ########################################################################################## # DATA CUPBOARD FAN CONTROL # Fan control for Data Cupboard # # Hardware: Yunshan 7-30V 10A Relay Board (HW-622) with ESP8266MOD # # 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 # # 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. ########################################################################################## ########################################################################################## ########################################################################################## # SPECIFIC DEVICE VARIABLE SUBSTITUTIONS ########################################################################################## substitutions: # Device Naming device_name: "esp-datacupboardfans" friendly_name: "Data Cupboard Fans" description_comment: "Fan control for Data Cupboard" device_area: "Data Cupboard" # Project Naming project_name: "Yunshan.HW-622 ESP8266MOD Relay Board" project_version: "v1.3" # Passwords 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 # 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 ########################################################################################## 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 #### 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 ########################################################################################## 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 ########################################################################################## esp8266: board: esp12e restore_from_flash: true preferences: flash_write_interval: 5min mdns: disabled: false ########################################################################################## # LOGGING ########################################################################################## logger: level: "${log_level}" baud_rate: 0 ########################################################################################## # GLOBALS ########################################################################################## globals: # Max temperature tracking - id: g_max_temp_today type: float restore_value: yes initial_value: "-1000.0" - id: g_max_temp_yesterday type: float restore_value: yes initial_value: "-1000.0" - id: g_max_temp_month type: float restore_value: yes initial_value: "-1000.0" - id: g_max_temp_last_month type: float restore_value: yes initial_value: "-1000.0" - id: g_max_temp_year type: float restore_value: yes initial_value: "-1000.0" # Last seen date parts (for rollover) - id: g_last_day type: int restore_value: yes initial_value: "0" - id: g_last_month type: int restore_value: yes initial_value: "0" - id: g_last_year type: int restore_value: yes initial_value: "0" # Fault evaluation grace timing - id: boot_ms type: uint32_t restore_value: no initial_value: "0" - id: relay_last_change_ms type: uint32_t restore_value: no initial_value: "0" ########################################################################################## # INTERVAL (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 (Relay + Override) ########################################################################################## 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 (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 (Door + Fault) ########################################################################################## 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 (GPIO2) ########################################################################################## status_led: pin: number: GPIO2 inverted: true ########################################################################################## # SENSORS (Temp/Humidity + Fan RPM + Max temp templates) ########################################################################################## 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 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 ########################################################################################## 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}; // Requirement as requested: // - Relay OFF => both fans must be > 1800 RPM // - Relay ON => both fans must be > 3000 RPM 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 COMMANDS (device-specific) ########################################################################################## 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