############################################################################### # KC868-A4 — Drive Gate (v6 — self-monitoring watchdogs) # Hardware: Eagle 1000 / CO-Z T329 V3.0 board # # ── v6 (2026-07-16) ─────────────────────────────────────────────────────────── # 1. STOP-before-reverse: mid-travel open/close commands pulse STP first — # the T329 silently DISCARDS a CL pulse while driving open (proven live # 2026-07-16: close cmd during opening = no-op, gate sat open until # auto-close fired at 105s). # 2. Travel-time sensors: "Last Open Time" / "Last Close Time" (measured # baseline 2026-07-16: open 20.4-23.5s, close 21.1-21.2s, limit-to-limit). # 3. Travel Fault: motion started (a limit released) but no limit reached # within 35s. Caveat: an RF/wall STOP mid-travel false-trips this (KC868 # can't see those) — it self-clears on the next completed travel. # 4. Command Fault: cover command pulsed but the T329 never acted on it # (origin limit never released within 10s). Catches the exact 2026-07-16 # failure mode where CLOSE did nothing. # 5. Limit Switch Fault: both limits active simultaneously = wiring fault. # # ── RELAY MAP — each relay momentarily pulses ONE T329 trigger to COM ───────── # Relay 1 (GPIO2) -> T329 OP (KEY group) = OPEN # Relay 2 (GPIO15) -> T329 CL (KEY group) = CLOSE (dog-lockout fires this) # Relay 3 (GPIO5) -> T329 STP (KEY group) = STOP # Relay 4 (GPIO4) -> T329 FSW + COM = IR-beam bypass (disable wet IR) # # ^ This is the fix for "open/stop/close all fire the same relay": the old # config pulsed one shared relay for every cover action. Now each direction # has its own relay + its own pulse script. # # ── INPUT MAP — opto "dry contact" inputs (IN- is internal to the PCB) ──────── # IN1 (GPIO36) -> OPEN limit = spare tab of the OPEN microswitch # IN2 (GPIO39) -> CLOSE limit = spare tab of the CLOSE microswitch # IN3 (GPIO27) -> Reed sensor = gate panel physically at the closed post # IN4 (GPIO14) -> FSW / IR-beam state monitor (jumpered onto the R4/FSW line) # # NOTE: limit inputs read the microswitch SPARE throw (not the T329 O.P/C.L # sense lines) to avoid the T329's ~5V back-feed. If the cover shows the gate # open/closed BACKWARDS, just flip `inverted:` on that binary_sensor. # # ── POWER ───────────────────────────────────────────────────────────────────── # KC868 VIN = its OWN 12V supply on Shelly Pro 4PM SW3 (id 3) — NOT the T329 # V+ rail — so the controller stays online when the dog-lockout cuts operator # power (Shelly SW0/id 0). All COM/GND on one bus, including T329 COM. # R1-R4 COM are daisy-chained and returned to the bus on one wire. ############################################################################### substitutions: name: drive-gate friendly_name: "Drive Gate" esphome: name: ${name} friendly_name: ${friendly_name} comment: "KC868-A4 - Eagle 1000 / CO-Z T329 V3.0 - OP/CL/STP + FSW" esp32: board: esp32dev # ── Connectivity ────────────────────────────────────────────────────────────── wifi: ssid: !secret wifi_ssid password: !secret wifi_password ap: ssid: "Drive-Gate Fallback" password: !secret ap_password captive_portal: logger: level: INFO api: encryption: key: !secret api_key ota: - platform: esphome password: !secret ota_password # Built-in web UI at http:/// (handy for pulse-testing each relay) # Basic-auth added 2026-07-01: without it, ANYONE on the IoT VLAN could pulse # the gate relays, clear the control lock, bypass the IR beam, or restore # power mid-lockout. Credentials in secrets.yaml (user: admin). web_server: port: 80 auth: username: admin password: !secret web_password # Direct local control of the Shelly Pro 4PM master-power relay over the LAN. # Works even if Home Assistant is down — the KC868 talks straight to the Shelly. # Shelly Pro 4PM (Gen2) local RPC API; SW0 = gate-operator master power. http_request: useragent: esphome/drive-gate timeout: 6s time: - platform: sntp id: sntp_time timezone: "America/Chicago" servers: - pool.ntp.org # ── 1-Wire bus on the TEMP/HUM terminal (data = GPIO13) for DS18B20 temp sensor(s) ── # Add more DS18B20s on the same bus later; each needs its 'address:' (read from the boot log). one_wire: - platform: gpio pin: GPIO13 # ── Persistent globals ──────────────────────────────────────────────────────── globals: - id: auto_close_remaining type: int restore_value: no initial_value: "0" # ── v6 watchdog state ── - id: travel_start_ms # millis() when a limit released (motion began) type: uint32_t restore_value: no initial_value: "0" - id: travel_dir # 0=idle 1=opening 2=closing (set by limit release, ANY source) type: int restore_value: no initial_value: "0" - id: cmd_pending # 0=none 1=open 2=close (set only by cover commands) type: int restore_value: no initial_value: "0" - id: cmd_start_ms type: uint32_t restore_value: no initial_value: "0" - id: cmd_from_limit # command issued while sitting on the origin limit type: bool restore_value: no initial_value: "false" - id: travel_fault_flag type: bool restore_value: no initial_value: "false" - id: cmd_fault_flag type: bool restore_value: no initial_value: "false" # ── Configurable settings ───────────────────────────────────────────────────── number: - platform: template name: "Auto-Close Delay" id: auto_close_delay icon: "mdi:timer-outline" unit_of_measurement: "s" min_value: 0 # 0 = disabled max_value: 600 step: 5 initial_value: 0 # default OFF so this and the T329 DIP3 don't both auto-close optimistic: true restore_value: true entity_category: config # ── T329 voltage-monitor calibration (live, persisted — no reflash needed) ── # Read "T329 ADC Raw", divide your meter's V+ by it, type the result here. - platform: template name: "T329 V+ Multiplier (calibration)" id: t329_vplus_mult icon: "mdi:tune-variant" min_value: 0.1 max_value: 250 step: 0.01 initial_value: 70 optimistic: true restore_value: true mode: box entity_category: config # Volts above which "T329 Powered" reads ON. Tune live if needed. - platform: template name: "T329 Powered Threshold" id: t329_pwr_threshold icon: "mdi:flash-alert" unit_of_measurement: "V" min_value: 1 max_value: 14 step: 0.5 initial_value: 5 optimistic: true restore_value: true mode: box entity_category: config # ── Relays ──────────────────────────────────────────────────────────────────── switch: # R1 — OPEN trigger (GPIO2 -> T329 OP) - platform: gpio name: "Gate Open Relay" id: relay_open pin: 2 restore_mode: ALWAYS_OFF internal: true # R2 — CLOSE trigger (GPIO15 -> T329 CL) - platform: gpio name: "Gate Close Relay" id: relay_close pin: 15 restore_mode: ALWAYS_OFF internal: true # R3 — STOP trigger (GPIO5 -> T329 STP) - platform: gpio name: "Gate Stop Relay" id: relay_stop pin: 5 restore_mode: ALWAYS_OFF internal: true # R4 — IR-beam bypass (GPIO4 -> T329 FSW+COM) # ON = FSW held "clear" so the gate closes despite a wet/false-tripping beam. # WARN: gate will close through a real obstruction while active. Auto-off 10 min. - platform: gpio name: "IR Beam Bypass" id: ir_beam_bypass pin: 4 restore_mode: ALWAYS_OFF icon: "mdi:eye-off-outline" on_turn_on: - logger.log: level: WARN format: "IR beam bypass ENABLED - gate will close through obstacles" - delay: 600s - switch.turn_off: ir_beam_bypass - logger.log: "IR beam bypass auto-disabled after 10-minute timeout" # Software control lock — blocks HA / web / app gate triggers. # (Wall switch + RF remotes on the T329 ONE terminal are unaffected.) - platform: template name: "Gate Control Lock" id: gate_control_lock icon: "mdi:lock-outline" optimistic: true restore_mode: RESTORE_DEFAULT_OFF entity_category: config # ── Pulse scripts — one per direction; open/close honor the control lock ────── script: - id: pulse_open mode: single then: - if: condition: switch.is_on: gate_control_lock then: - logger.log: "OPEN blocked - control lock active" else: - switch.turn_on: relay_open - delay: 500ms - switch.turn_off: relay_open - id: pulse_close mode: single then: - if: condition: switch.is_on: gate_control_lock then: - logger.log: "CLOSE blocked - control lock active" else: - switch.turn_on: relay_close - delay: 500ms - switch.turn_off: relay_close - id: pulse_stop mode: single then: - switch.turn_on: relay_stop - delay: 500ms - switch.turn_off: relay_stop # v6: an intentional STOP cancels the travel & command watchdogs - lambda: |- id(travel_dir) = 0; id(cmd_pending) = 0; # ── Buttons — manual one-shots (great for bench-testing each relay) ─────────── button: - platform: template name: "Gate Open (pulse)" icon: "mdi:gate-open" entity_category: diagnostic on_press: - script.execute: pulse_open - platform: template name: "Gate Close (pulse)" icon: "mdi:gate" entity_category: diagnostic on_press: - script.execute: pulse_close - platform: template name: "Gate Stop (pulse)" icon: "mdi:stop-circle-outline" entity_category: diagnostic on_press: - script.execute: pulse_stop # ── Redundant master-power control (KC868 → Shelly Pro 4PM SW0, HA-independent) ── # The KC868 runs on its own 12V supply, so these still work with the gate # operator (and HA) powered down. Buttons (not a toggle) so a KC868 reboot # never cuts or restores gate power on its own. # T329 operator power = Shelly Pro 4PM switch id 0. Cutting it disables the gate # for security; the KC868 runs on its own supply (Shelly id 3) and stays up. - platform: template name: "Gate Operator Power — CUT" icon: "mdi:flash-off" on_press: - http_request.get: url: "http://192.168.1.50/rpc/Switch.Set?id=0&on=false" - platform: template name: "Gate Operator Power — RESTORE" icon: "mdi:flash" on_press: - http_request.get: url: "http://192.168.1.50/rpc/Switch.Set?id=0&on=true" # Clean software reboot of the KC868 (no power cycle needed). - platform: restart name: "Restart KC868" entity_category: diagnostic # ── Binary sensors ──────────────────────────────────────────────────────────── binary_sensor: # Connectivity status -> entity ends in "_status" so the fleet-wide # ESPHomeDeviceOffline Prometheus rule (".*drive_gate.*_status" == 0) covers it. - platform: status name: "Status" device_class: connectivity # IN1 (GPIO36) — OPEN limit (OPEN microswitch spare tab) - platform: gpio name: "Gate Open Limit" id: gate_open_limit device_class: opening pin: number: 36 inverted: false # spare-tab contact sense (was true on the T329 LIMIT-terminal tap) filters: - delayed_on: 100ms - delayed_off: 100ms on_press: - lambda: |- float d = id(auto_close_delay).state; if (d > 0) { id(auto_close_remaining) = (int)d; } // v6: opening completed — record travel time, clear watchdogs if (id(travel_dir) == 1) { float secs = (millis() - id(travel_start_ms)) / 1000.0; id(last_open_time).publish_state(secs); id(travel_fault_flag) = false; id(cmd_fault_flag) = false; } id(travel_dir) = 0; id(cmd_pending) = 0; on_release: - lambda: |- id(auto_close_remaining) = 0; // v6: leaving the open limit = closing started (any source: HA, RF, wall) id(travel_dir) = 2; id(travel_start_ms) = millis(); # IN2 (GPIO39) — CLOSE limit (CLOSE microswitch spare tab) - platform: gpio name: "Gate Close Limit" id: gate_close_limit device_class: opening pin: number: 39 inverted: false # spare-tab contact sense (was true on the T329 LIMIT-terminal tap) filters: - delayed_on: 100ms - delayed_off: 100ms on_press: - lambda: |- // v6: closing completed — record travel time, clear watchdogs if (id(travel_dir) == 2) { float secs = (millis() - id(travel_start_ms)) / 1000.0; id(last_close_time).publish_state(secs); id(travel_fault_flag) = false; id(cmd_fault_flag) = false; } id(travel_dir) = 0; id(cmd_pending) = 0; on_release: - lambda: |- // v6: leaving the close limit = opening started (any source: HA, RF, wall) id(travel_dir) = 1; id(travel_start_ms) = millis(); # IN3 (GPIO27) — Reed sensor (gate panel at the closed post) - platform: gpio name: "Gate Closed (Reed)" id: gate_reed_sensor device_class: door pin: number: 27 inverted: false # magnet aligned (gate closed) -> reports OFF -> 'door' class shows "Closed" filters: - delayed_on: 300ms - delayed_off: 300ms # (Beam monitor dropped: FSW idles at 7.3V, too high to tap onto a 3.3V input. # The T329 still enforces the beam in hardware. IN4/D4 (GPIO14) is now free.) # IR transmitter low-battery alarm, read off A4 (GPIO33, a 0-5V analog input). # See the ir_batt_adc sensor below for the wiring; on = batteries low. - platform: template name: "IR Transmitter Battery Low" id: ir_batt_low device_class: battery icon: "mdi:battery-alert" filters: - delayed_on: 3s - delayed_off: 3s lambda: 'return id(ir_batt_adc).state > 0.8;' # >0.8V on A4 = BATT LOW contact closed (tune when tested) # Chain fault: close limit reached but reed says panel NOT closed -> chain slipped/broke - platform: template name: "Gate Chain Fault" id: gate_chain_fault device_class: problem icon: "mdi:link-off" filters: - delayed_on: 5s # must persist 5s before alarming (ignore transient mismatch during travel) lambda: |- // Carriage reports "home" (close limit) but the panel isn't at the post (reed reads ON when NOT // detecting the magnet, after the inversion above) = chain slipped/broken return (id(gate_close_limit).state && id(gate_reed_sensor).state); # ── v6 self-monitoring faults ── # Travel Fault: motion began (a limit released) but no limit was reached in 35s. # Measured baseline 2026-07-16: open 20.4-23.5s, close 21.1-21.2s → 35s = margin. # NOTE: an RF-remote/wall-switch STOP mid-travel false-trips this (the KC868 # can't see those inputs); it self-clears on the next completed travel. - platform: template name: "Gate Travel Fault" id: gate_travel_fault device_class: problem icon: "mdi:timer-alert-outline" lambda: 'return id(travel_fault_flag);' # Command Fault: a cover open/close command was pulsed but the T329 never # acted on it (origin limit never released). Catches the 2026-07-16 failure # mode: CL pulse fired, relay clicked, gate never moved. - platform: template name: "Gate Command Fault" id: gate_command_fault device_class: problem icon: "mdi:gesture-tap-box" lambda: 'return id(cmd_fault_flag);' # Limit Switch Fault: both limits active at once = impossible state = wiring # or microswitch failure. 2s debounce. - platform: template name: "Limit Switch Fault" id: limit_switch_fault device_class: problem icon: "mdi:electric-switch" filters: - delayed_on: 2s lambda: 'return (id(gate_open_limit).state && id(gate_close_limit).state);' # T329 powered? Derived from the V+ supply-voltage monitor below. - platform: template name: "T329 Powered" id: t329_powered device_class: power icon: "mdi:power-plug" filters: - delayed_on: 1s - delayed_off: 3s lambda: |- return (id(t329_voltage).state > id(t329_pwr_threshold).state); // threshold is a live, tunable number # ── Analog: T329 supply-voltage monitor (LIVE — wired & calibrated 2026-06-28) ─ # External divider from T329 V+ down to the ADC, landed on terminal **A3 = GPIO32** # (a true 0-5V voltage input; A1/GPIO34 and A2/GPIO35 are 4-20mA CURRENT inputs — # do NOT move it there): # T329 V+ ── R_top ──┬── A3 terminal (GPIO32) # └── R_bottom ── GND/COM bus # The exact ratio doesn't matter — the "T329 V+ Multiplier" NUMBER (set live in # the web UI / HA, no reflash) is calibrated to the divider as built. # Calibrated multiplier in service: 9.5 (reads ~15.6V with the T329 idle). sensor: # Raw voltage at the AI1 pin (GPIO32), BEFORE calibration. This is what you use # to calibrate: multiplier = (real V+ on your meter) / (this raw value). # Use terminal A3 = GPIO32 = a real "Input 0-5V" voltage input (silk screen). # (A1=GPIO34 and A2=GPIO35 are 4-20mA CURRENT inputs — wrong type for a voltage.) - platform: adc pin: GPIO32 name: "T329 ADC Raw" id: t329_adc_raw device_class: voltage state_class: measurement attenuation: 12db update_interval: 2s samples: 16 unit_of_measurement: "V" accuracy_decimals: 4 entity_category: diagnostic # Calibrated supply voltage = raw x the live "T329 V+ Multiplier" number. # Tune the multiplier in the UI; this updates instantly, no reflash. - platform: template name: "T329 Supply Voltage" id: t329_voltage device_class: voltage state_class: measurement unit_of_measurement: "V" accuracy_decimals: 1 update_interval: 2s lambda: 'return id(t329_adc_raw).state * id(t329_vplus_mult).state;' # A4 = GPIO33 = "Input 0-5V". Reads the receiver's BATT LOW dry contact by # injecting a small voltage through it (it's analog, so a bare contact won't read): # board 3V3 ("3U" on the TEMP/HUM terminal) -> BATT LOW contact -> A4. # Batteries low -> contact closes -> voltage appears on A4; OK -> A4 ~0. # (If an open A4 floats noisily, add a 100k from A4 to AGND.) - platform: adc pin: GPIO33 name: "IR Batt ADC (A4)" id: ir_batt_adc attenuation: 12db update_interval: 5s samples: 16 accuracy_decimals: 3 unit_of_measurement: "V" entity_category: diagnostic # Enclosure temperature (DS18B20 on the 1-Wire bus above). With one sensor, no # address needed; for multiple, give each its own 'address:' from the boot log. - platform: dallas_temp name: "Gate Enclosure Temp" update_interval: 60s # ── v6 travel-time telemetry (published on each completed limit-to-limit run) ─ # Trend these for chain wear / motor degradation. Baseline 2026-07-16: # open 20.4-23.5s, close 21.1-21.2s. - platform: template name: "Last Open Time" id: last_open_time unit_of_measurement: "s" icon: "mdi:timer-outline" accuracy_decimals: 1 state_class: measurement update_interval: never entity_category: diagnostic - platform: template name: "Last Close Time" id: last_close_time unit_of_measurement: "s" icon: "mdi:timer-outline" accuracy_decimals: 1 state_class: measurement update_interval: never entity_category: diagnostic # ── Cover (template: each command fires exactly ONE relay pulse) ────────────── # The endstop platform fired stop_action before reversing, which made "close" # pulse two relays (stop+close) and made "stop" a no-op when idle. A template # cover maps each command 1:1 to its own relay. Position is derived from the # limit sensors (open limit -> open, close limit -> closed, neither -> hold). cover: - platform: template name: "Drive Gate" id: drive_gate device_class: gate lambda: |- if (id(gate_open_limit).state) return 1.0; // fully open -> Open button grays if (id(gate_close_limit).state) return 0.0; // fully closed -> Close button grays return 0.5; // partially open -> BOTH buttons stay active # v6: STOP-before-reverse. The T329 silently discards OP/CL pulses while the # motor is driving the other direction (proven live 2026-07-16). If neither # limit is active (= mid-travel), pulse STP, wait, THEN the direction pulse. open_action: - if: condition: and: - binary_sensor.is_off: gate_open_limit - binary_sensor.is_off: gate_close_limit then: - script.execute: pulse_stop - script.wait: pulse_stop - delay: 700ms - lambda: |- // v6: arm the command watchdog (only if the lock won't block the pulse) if (!id(gate_control_lock).state) { id(cmd_pending) = 1; id(cmd_start_ms) = millis(); id(cmd_from_limit) = id(gate_close_limit).state; } - script.execute: pulse_open close_action: - if: condition: and: - binary_sensor.is_off: gate_open_limit - binary_sensor.is_off: gate_close_limit then: - script.execute: pulse_stop - script.wait: pulse_stop - delay: 700ms - lambda: |- if (!id(gate_control_lock).state) { id(cmd_pending) = 2; id(cmd_start_ms) = millis(); id(cmd_from_limit) = id(gate_open_limit).state; } - script.execute: pulse_close stop_action: - script.execute: pulse_stop # ── Auto-close countdown (disabled until Auto-Close Delay > 0) ──────────────── interval: - interval: 1s then: - lambda: |- if (id(auto_close_remaining) <= 0) return; if (id(drive_gate).current_operation != cover::CoverOperation::COVER_OPERATION_IDLE) { id(auto_close_remaining) = 0; return; } id(auto_close_remaining) -= 1; if (id(auto_close_remaining) <= 0) { id(auto_close_remaining) = 0; auto call = id(drive_gate).make_call(); call.set_command_close(); call.perform(); } # ── v6 watchdog tick ── - interval: 1s then: - lambda: |- uint32_t now = millis(); // COMMAND watchdog: did the T329 act on our OP/CL pulse? if (id(cmd_pending) != 0) { bool moved = false; if (id(cmd_pending) == 1) { // open commanded moved = id(gate_open_limit).state || (id(cmd_from_limit) && !id(gate_close_limit).state); } else { // close commanded moved = id(gate_close_limit).state || (id(cmd_from_limit) && !id(gate_open_limit).state); } if (moved) { id(cmd_pending) = 0; // motion confirmed; travel watchdog takes over } else { // From a limit: motion should show within 10s. Mid-travel start: // no origin limit to watch, allow a full travel window (35s). uint32_t budget = id(cmd_from_limit) ? 10000 : 35000; if (now - id(cmd_start_ms) > budget) { id(cmd_fault_flag) = true; id(cmd_pending) = 0; ESP_LOGW("gate", "COMMAND FAULT: T329 did not act on the last open/close pulse"); } } } // TRAVEL watchdog: motion started but no limit reached within 35s if (id(travel_dir) != 0 && (now - id(travel_start_ms)) > 35000) { id(travel_fault_flag) = true; id(travel_dir) = 0; ESP_LOGW("gate", "TRAVEL FAULT: gate did not reach a limit within 35s of starting to move"); }