UNPKG

garagedoor-accfactory

Version:

HomeKit garage door opener system using HAP-NodeJS library

672 lines (576 loc) 26 kB
// GarageDoor // Part of garagedoor-accfactory // // Handles HomeKit garage door integration using GPIO-connected // relay outputs and optional door position sensors. // // Features: // - HomeKit GarageDoorOpener service support // - GPIO-controlled push button/relay activation // - Optional open/closed end-stop sensors // - Optional obstruction sensor support // - Timed movement fallback for sensorless installations // - Door movement direction inference and reversal handling // - HomeKit status synchronisation and Eve history support // // Supports both: // - Fully sensor-based installations // - Timed-only installations using openTime/closeTime // // Lifecycle hooks used: // - onAdd() // - onMessage() // - onTimer() // - onShutdown() // // Note: // - Timed-only installations cannot recover true physical door state after process restart // - Sensor-based installations can still use timing as fallback for movement inference if desired // - GPIO pin numbers must be specified in BCM mode (not physical pin numbers) // // Code version 2026.05.07 // Mark Hulskamp 'use strict'; // Define external module requirements import GPIO from 'rpio'; // Define nodejs module requirements import { setTimeout } from 'node:timers'; // Import our modules import HomeKitDevice from './HomeKitDevice.js'; // Define constants const PUSHBUTTON_DELAY = 500; const DOOR_STATUS_INTERVAL = 1000; export default class GarageDoor extends HomeKitDevice { static TYPE = 'GarageDoor'; static VERSION = '2026.05.07'; // Code version static DOOR_EVENT = 'door-event'; // Door status event tag static TIMER_DOOR_STATUS_POLL = 'door-status-poll'; // Timer handle for door status polling // Define door states static OPEN = 'open'; static OPENED = 'opened'; static CLOSE = 'close'; static CLOSED = 'closed'; static OPENING = 'opening'; static CLOSING = 'closing'; static STOPPED = 'stopped'; static MOVING = 'moving'; static OBSTRUCTION = 'obstruction'; static CLEAR = 'clear'; static UNKNOWN = 'unknown'; static FAULT = 'fault'; // GPIO pin min/max static MIN_GPIO_PIN = 0; static MAX_GPIO_PIN = 26; doorService = undefined; // HomeKit service for this garage door currentDoorStatus = undefined; // Internal data only for this class #lastMovementDirection = undefined; // Track last inferred direction (OPENING or CLOSING) #lastDoorStatus = undefined; #lastObstructionStatus = undefined; #moveStartedTime = undefined; #obstructionDetected = false; constructor(accessory, api, deviceData) { super(accessory, api, deviceData); // Init the GPIO (rpio) library. This only needs to be done once before using library functions GPIO.init({ gpiomem: true, mapping: 'gpio' }); this.currentDoorStatus = GarageDoor.STOPPED; this.#lastDoorStatus = GarageDoor.UNKNOWN; } // Class functions onAdd() { // Setup the garagedoor service if not already present on the accessory and link it to the Eve app if configured to do so this.doorService = this.addService(this.hap.Service.GarageDoorOpener, '', 1, {}); this.doorService.setPrimaryService(); // Setup GPIO pins if (this.#validGPIOPin(this.deviceData?.pushButton) === false) { // Invalid pushbutton pin specified this?.log?.warn?.('No valid relay pin specified for door open/close button on "%s"', this.deviceData.description); this?.log?.warn?.('We will be unable to operate garage door'); } if (this.#validGPIOPin(this.deviceData?.pushButton) === true) { // Push button try { GPIO.open(this.deviceData.pushButton, GPIO.OUTPUT, GPIO.LOW); this?.log?.debug?.('Setup open/close relay on "%s" using GPIO pin "%s"', this.deviceData.description, this.deviceData.pushButton); } catch (error) { this?.log?.error?.('Failed to setup pushButton GPIO pin "%s": %s', this.deviceData.pushButton, String(error)); } } if (this.#validGPIOPin(this.deviceData?.closedSensor) === true) { // Door closed sensor try { GPIO.open(this.deviceData.closedSensor, GPIO.INPUT, GPIO.PULL_DOWN); this.postSetupDetail('Door closed sensor'); this?.log?.debug?.( 'Setup closed door sensor on "%s" using GPIO pin "%s"', this.deviceData.description, this.deviceData.closedSensor, ); } catch (error) { this?.log?.error?.('Failed to setup closedSensor GPIO pin "%s": %s', this.deviceData.closedSensor, String(error)); } } if (this.#validGPIOPin(this.deviceData?.openSensor) === true) { // Door open sensor try { GPIO.open(this.deviceData.openSensor, GPIO.INPUT, GPIO.PULL_DOWN); this.postSetupDetail('Door open sensor'); this?.log?.debug?.('Setup open door sensor on "%s" using GPIO pin "%s"', this.deviceData.description, this.deviceData.openSensor); } catch (error) { this?.log?.error?.('Failed to setup openSensor GPIO pin "%s": %s', this.deviceData.openSensor, String(error)); } } if (this.#validGPIOPin(this.deviceData?.obstructionSensor) === true) { // Door obstruction sensor try { GPIO.open(this.deviceData.obstructionSensor, GPIO.INPUT, GPIO.PULL_DOWN); this.postSetupDetail('Obstruction sensor'); this?.log?.debug?.( 'Setup obstruction sensor on "%s" using GPIO pin "%s"', this.deviceData.description, this.deviceData.obstructionSensor, ); } catch (error) { this?.log?.error?.('Failed to setup obstructionSensor GPIO pin "%s": %s', this.deviceData.obstructionSensor, String(error)); } this.#obstructionDetected = this.hasObstruction() === true; this.addCharacteristic(this.doorService, this.hap.Characteristic.ObstructionDetected, { initialValue: this.#obstructionDetected === true, }); } let initial = GarageDoor.STOPPED; // Infer physical initial state if (this.isClosed() === true) { initial = GarageDoor.CLOSED; this.#lastMovementDirection = GarageDoor.OPENING; this.postSetupDetail('Door is closed'); } if (this.isClosed() !== true && this.isOpen() === true) { initial = GarageDoor.OPENED; this.#lastMovementDirection = GarageDoor.CLOSING; this.postSetupDetail('Door is open'); } if (initial === GarageDoor.STOPPED) { this.#lastMovementDirection = GarageDoor.OPENING; this.postSetupDetail('Door is between opened/closed'); } this.currentDoorStatus = initial; this.#lastDoorStatus = initial; // Setup characteristics this.addCharacteristic(this.doorService, this.hap.Characteristic.CurrentDoorState, { initialValue: this.#mapCurrentDoorState(initial), onGet: () => this.#mapCurrentDoorState(this.getDoorPosition()), }); this.addCharacteristic(this.doorService, this.hap.Characteristic.TargetDoorState, { initialValue: initial === GarageDoor.OPENED ? this.hap.Characteristic.TargetDoorState.OPEN : this.hap.Characteristic.TargetDoorState.CLOSED, onSet: async (value) => { await this.setDoorPosition(value); }, }); this.addCharacteristic(this.doorService, this.hap.Characteristic.StatusFault, { initialValue: this.hap.Characteristic.StatusFault.NO_FAULT, }); // Push initial state to HomeKit to prevent stale status this.message(GarageDoor.DOOR_EVENT, { status: this.currentDoorStatus }); // Start periodic door status polling via parent class timer system this.addTimer(GarageDoor.TIMER_DOOR_STATUS_POLL, { delay: 0, interval: DOOR_STATUS_INTERVAL, }); } async setDoorPosition(position) { // Map HomeKit target to direction and final state let targetDir = position === this.hap.Characteristic.TargetDoorState.OPEN ? GarageDoor.OPEN : GarageDoor.CLOSE; let targetFinal = targetDir === GarageDoor.OPEN ? GarageDoor.OPENED : GarageDoor.CLOSED; let behavior = typeof this.deviceData?.buttonBehavior === 'string' ? this.deviceData.buttonBehavior : 'stop-then-reverse'; if (this.currentDoorStatus === GarageDoor.FAULT) { this?.log?.warn?.('Door "%s" is reporting a fault so ignoring request for "%s"', this.deviceData.description, targetDir); return; } if (this.#obstructionDetected === true || this.hasObstruction() === true) { this.#obstructionDetected = true; this?.log?.warn?.('Door "%s" is reporting an obstruction so ignoring request for "%s"', this.deviceData.description, targetDir); return; } // Already fully there, no action needed if (this.currentDoorStatus === targetFinal) { this?.log?.debug?.('Door "%s" already %s', this.deviceData.description, targetDir); return; } // Already moving toward requested direction, no action needed (prevents accidental STOP) if ( (this.currentDoorStatus === GarageDoor.OPENING && targetDir === GarageDoor.OPEN) || (this.currentDoorStatus === GarageDoor.CLOSING && targetDir === GarageDoor.CLOSE) || (this.currentDoorStatus === GarageDoor.MOVING && this.#lastMovementDirection === targetDir) ) { this.#lastMovementDirection = targetDir; this?.log?.debug?.('Door "%s" already moving toward %s', this.deviceData.description, targetFinal); return; } // Moving the wrong way, reversal using configured behavior if ( (this.currentDoorStatus === GarageDoor.OPENING && targetDir === GarageDoor.CLOSE) || (this.currentDoorStatus === GarageDoor.CLOSING && targetDir === GarageDoor.OPEN) || (this.currentDoorStatus === GarageDoor.MOVING && this.#lastMovementDirection && this.#lastMovementDirection !== targetDir) ) { this?.log?.info?.('Reversing door "%s" from %s to %s (%s)', this.deviceData.description, this.currentDoorStatus, targetDir, behavior); this.#lastMovementDirection = targetDir; this.#lastDoorStatus = targetFinal === GarageDoor.OPENED ? GarageDoor.CLOSED : GarageDoor.OPENED; if (behavior === 'auto-reverse' || behavior === 'always-toggle') { await this.#pressButton(1); // single press } else { await this.#pressButton(2); // stop-then-reverse: explicit double press } return; } // From STOPPED/UNKNOWN or resting opposite final state , set baseline then go if ( this.currentDoorStatus === GarageDoor.STOPPED || this.currentDoorStatus === GarageDoor.UNKNOWN || this.currentDoorStatus === (targetFinal === GarageDoor.OPENED ? GarageDoor.CLOSED : GarageDoor.OPENED) ) { this.#lastMovementDirection = targetDir; this.#lastDoorStatus = targetFinal === GarageDoor.OPENED ? GarageDoor.CLOSED : GarageDoor.OPENED; this?.log?.debug?.('Starting door "%s" toward %s', this.deviceData.description, targetFinal); await this.#pressButton(1); return; } // Fallback: press once toward desired state this.#lastMovementDirection = targetDir; this.#lastDoorStatus = targetFinal === GarageDoor.OPENED ? GarageDoor.CLOSED : GarageDoor.OPENED; await this.#pressButton(1); } getDoorPosition() { return this.currentDoorStatus; } isOpen() { let openStatus = undefined; if (this.#validGPIOPin(this.deviceData?.openSensor) === true) { try { openStatus = GPIO.read(this.deviceData.openSensor) === GPIO.HIGH; } catch (error) { this?.log?.warn?.('Error reading openSensor GPIO pin "%s": %s', this.deviceData.openSensor, String(error)); } } return openStatus; } isClosed() { let closeStatus = undefined; if (this.#validGPIOPin(this.deviceData?.closedSensor) === true) { try { closeStatus = GPIO.read(this.deviceData.closedSensor) === GPIO.HIGH; } catch (error) { this?.log?.warn?.('Error reading closedSensor GPIO pin "%s": %s', this.deviceData.closedSensor, String(error)); } } return closeStatus; } hasObstruction() { let obstructionStatus = undefined; if (this.#validGPIOPin(this.deviceData?.obstructionSensor) === true) { try { obstructionStatus = GPIO.read(this.deviceData.obstructionSensor) === GPIO.HIGH; } catch (error) { this?.log?.warn?.('Error reading obstructionSensor GPIO pin "%s": %s', this.deviceData.obstructionSensor, String(error)); } } return obstructionStatus; } onMessage(type, message) { if (type === GarageDoor.DOOR_EVENT && typeof message?.status === 'string') { const state = message.status; if (state === GarageDoor.CLOSED) { this.doorService.updateCharacteristic(this.hap.Characteristic.StatusFault, this.hap.Characteristic.StatusFault.NO_FAULT); this.doorService.updateCharacteristic(this.hap.Characteristic.CurrentDoorState, this.hap.Characteristic.CurrentDoorState.CLOSED); this.doorService.updateCharacteristic(this.hap.Characteristic.TargetDoorState, this.hap.Characteristic.TargetDoorState.CLOSED); if (this.currentDoorStatus !== GarageDoor.CLOSED) { this.currentDoorStatus = GarageDoor.CLOSED; this.history(this.doorService, { status: 0 }, { timegap: 2 }); this?.log?.success?.('Door "%s" is closed', this.deviceData.description); } return; } if (state === GarageDoor.OPENED) { this.doorService.updateCharacteristic(this.hap.Characteristic.StatusFault, this.hap.Characteristic.StatusFault.NO_FAULT); this.doorService.updateCharacteristic(this.hap.Characteristic.CurrentDoorState, this.hap.Characteristic.CurrentDoorState.OPEN); this.doorService.updateCharacteristic(this.hap.Characteristic.TargetDoorState, this.hap.Characteristic.TargetDoorState.OPEN); if (this.currentDoorStatus !== GarageDoor.OPENED) { this.currentDoorStatus = GarageDoor.OPENED; this.history(this.doorService, { status: 1 }, { timegap: 2 }); this?.log?.warn?.('Door "%s" is open', this.deviceData.description); } return; } if (message.status === GarageDoor.MOVING) { let direction = message.direction; // Normalise direction if (direction !== GarageDoor.OPENING && direction !== GarageDoor.CLOSING) { direction = this.#lastMovementDirection; } if (direction !== GarageDoor.OPENING && direction !== GarageDoor.CLOSING) { direction = GarageDoor.CLOSING; // final fallback only if we truly have nothing } // Only log if direction changed let directionChanged = this.currentDoorStatus !== direction; // Even if internal state matches, it's usually worth pushing HK updates to avoid stale UI this.currentDoorStatus = direction; this.#lastMovementDirection = direction; this.doorService.updateCharacteristic(this.hap.Characteristic.CurrentDoorState, this.#mapCurrentDoorState(direction)); this.doorService.updateCharacteristic( this.hap.Characteristic.TargetDoorState, direction === GarageDoor.OPENING ? this.hap.Characteristic.TargetDoorState.OPEN : this.hap.Characteristic.TargetDoorState.CLOSED, ); if (directionChanged === true) { this?.log?.debug?.('Door "%s" is %s', this.deviceData.description, direction); } return; } if (state === GarageDoor.STOPPED) { this.doorService.updateCharacteristic(this.hap.Characteristic.StatusFault, this.hap.Characteristic.StatusFault.NO_FAULT); this.doorService.updateCharacteristic(this.hap.Characteristic.CurrentDoorState, this.hap.Characteristic.CurrentDoorState.STOPPED); if (this.currentDoorStatus !== GarageDoor.STOPPED) { this.currentDoorStatus = GarageDoor.STOPPED; this.history(this.doorService, { status: 1 }, { timegap: 2 }); this?.log?.debug?.('Door "%s" has stopped moving', this.deviceData.description); } return; } if (state === GarageDoor.FAULT) { this.currentDoorStatus = GarageDoor.FAULT; this.doorService.updateCharacteristic(this.hap.Characteristic.StatusFault, this.hap.Characteristic.StatusFault.GENERAL_FAULT); this.doorService.updateCharacteristic(this.hap.Characteristic.CurrentDoorState, this.hap.Characteristic.CurrentDoorState.STOPPED); this?.log?.error?.('Door "%s" is reporting fault with sensors', this.deviceData.description); return; } if (state === GarageDoor.OBSTRUCTION) { this.#obstructionDetected = true; this.doorService.updateCharacteristic(this.hap.Characteristic.ObstructionDetected, true); if (this.#lastObstructionStatus === false) { this?.log?.warn?.('Door "%s" is reporting an obstruction', this.deviceData.description); } this.#lastObstructionStatus = true; return; } if (state === GarageDoor.CLEAR) { this.#obstructionDetected = false; this.doorService.updateCharacteristic(this.hap.Characteristic.ObstructionDetected, false); if (this.#lastObstructionStatus === true) { this?.log?.success?.('Door "%s" obstruction has cleared', this.deviceData.description); } this.#lastObstructionStatus = false; return; } } } async onTimer(message) { // Handle timer events dispatched by the parent class timer system if (message?.timer === GarageDoor.TIMER_DOOR_STATUS_POLL) { this.#pollDoorStatus(); } } async onShutdown() { // Clean up GPIO pins on shutdown if (this.#validGPIOPin(this.deviceData?.pushButton) === true) { try { this?.log?.debug?.('Closing pushButton GPIO pin: %s', this.deviceData.pushButton); GPIO.close(this.deviceData.pushButton); } catch (error) { this?.log?.debug?.('Error closing pushButton GPIO pin: %s', String(error)); } } if (this.#validGPIOPin(this.deviceData?.closedSensor) === true) { try { this?.log?.debug?.('Closing closedSensor GPIO pin: %s', this.deviceData.closedSensor); GPIO.close(this.deviceData.closedSensor); } catch (error) { this?.log?.debug?.('Error closing closedSensor GPIO pin: %s', String(error)); } } if (this.#validGPIOPin(this.deviceData?.openSensor) === true) { try { this?.log?.debug?.('Closing openSensor GPIO pin: %s', this.deviceData.openSensor); GPIO.close(this.deviceData.openSensor); } catch (error) { this?.log?.debug?.('Error closing openSensor GPIO pin: %s', String(error)); } } if (this.#validGPIOPin(this.deviceData?.obstructionSensor) === true) { try { this?.log?.debug?.('Closing obstructionSensor GPIO pin: %s', this.deviceData.obstructionSensor); GPIO.close(this.deviceData.obstructionSensor); } catch (error) { this?.log?.debug?.('Error closing obstructionSensor GPIO pin: %s', String(error)); } } } #pollDoorStatus() { // Check obstruction sensor this.message(GarageDoor.DOOR_EVENT, { status: this.hasObstruction() === true ? GarageDoor.OBSTRUCTION : GarageDoor.CLEAR, }); let doorClosed = this.isClosed() === true; let doorOpen = this.isOpen() === true; if (doorClosed === true && doorOpen === true) { this.#lastDoorStatus = GarageDoor.FAULT; this.#lastMovementDirection = undefined; this.#moveStartedTime = undefined; this.message(GarageDoor.DOOR_EVENT, { status: GarageDoor.FAULT }); return; } // Fully closed if (doorClosed === true && doorOpen === false) { if (this.currentDoorStatus !== GarageDoor.CLOSED) { this.#lastDoorStatus = GarageDoor.CLOSED; this.#lastMovementDirection = GarageDoor.OPENING; this.#moveStartedTime = undefined; this.message(GarageDoor.DOOR_EVENT, { status: GarageDoor.CLOSED }); } return; } // Fully open if (doorOpen === true && doorClosed === false) { if (this.currentDoorStatus !== GarageDoor.OPENED) { this.#lastDoorStatus = GarageDoor.OPENED; this.#lastMovementDirection = GarageDoor.CLOSING; this.#moveStartedTime = undefined; this.message(GarageDoor.DOOR_EVENT, { status: GarageDoor.OPENED }); } return; } // Door in motion or mid-way if (this.#moveStartedTime === undefined) { this.#moveStartedTime = Date.now(); } // If previously STOPPED, suppress further direction inference until next change if (this.#lastDoorStatus === GarageDoor.STOPPED) { return; } let duration = Date.now() - this.#moveStartedTime; let direction = GarageDoor.CLOSING; // Infer direction if (this.#lastDoorStatus === GarageDoor.CLOSED) { direction = GarageDoor.OPENING; } if (this.#lastDoorStatus === GarageDoor.OPENED) { direction = GarageDoor.CLOSING; } if (this.#lastMovementDirection === GarageDoor.OPENING) { direction = GarageDoor.OPENING; } // Timeout fallback for OPENING if (direction === GarageDoor.OPENING && duration >= this.deviceData.openTime * 1000) { this.#moveStartedTime = undefined; if (doorOpen === true && doorClosed === false) { this.#lastDoorStatus = GarageDoor.OPENED; this.#lastMovementDirection = GarageDoor.CLOSING; this.message(GarageDoor.DOOR_EVENT, { status: GarageDoor.OPENED }); } else if (this.#validGPIOPin(this.deviceData?.openSensor) !== true) { this?.log?.debug?.( 'Door "%s" assumed open after configured open time (%ds)', this.deviceData.description, this.deviceData.openTime, ); this.#lastDoorStatus = GarageDoor.OPENED; this.#lastMovementDirection = GarageDoor.CLOSING; this.message(GarageDoor.DOOR_EVENT, { status: GarageDoor.OPENED }); } else { this?.log?.warn?.( 'Door "%s" stopped before open sensor triggered (timeout %ds)', this.deviceData.description, this.deviceData.openTime, ); this.#lastDoorStatus = GarageDoor.STOPPED; this.#lastMovementDirection = undefined; this.message(GarageDoor.DOOR_EVENT, { status: GarageDoor.STOPPED }); } return; } // Timeout fallback for CLOSING if (direction === GarageDoor.CLOSING && duration >= this.deviceData.closeTime * 1000) { this.#moveStartedTime = undefined; if (doorClosed === true && doorOpen === false) { this.#lastDoorStatus = GarageDoor.CLOSED; this.#lastMovementDirection = GarageDoor.OPENING; this.message(GarageDoor.DOOR_EVENT, { status: GarageDoor.CLOSED }); } else if (this.#validGPIOPin(this.deviceData?.closedSensor) !== true) { this?.log?.debug?.( 'Door "%s" assumed closed after configured close time (%ds)', this.deviceData.description, this.deviceData.closeTime, ); this.#lastDoorStatus = GarageDoor.CLOSED; this.#lastMovementDirection = GarageDoor.OPENING; this.message(GarageDoor.DOOR_EVENT, { status: GarageDoor.CLOSED }); } else { this?.log?.warn?.( 'Door "%s" stopped before closed sensor triggered (timeout %ds)', this.deviceData.description, this.deviceData.closeTime, ); this.#lastDoorStatus = GarageDoor.STOPPED; this.#lastMovementDirection = undefined; this.message(GarageDoor.DOOR_EVENT, { status: GarageDoor.STOPPED }); } return; } // Door has since stabilized (late sensor update) if (doorOpen === true && doorClosed === false) { if (this.currentDoorStatus !== GarageDoor.OPENED) { this.#lastDoorStatus = GarageDoor.OPENED; this.#lastMovementDirection = GarageDoor.CLOSING; this.#moveStartedTime = undefined; this.message(GarageDoor.DOOR_EVENT, { status: GarageDoor.OPENED }); } return; } if (doorClosed === true && doorOpen === false) { if (this.currentDoorStatus !== GarageDoor.CLOSED) { this.#lastDoorStatus = GarageDoor.CLOSED; this.#lastMovementDirection = GarageDoor.OPENING; this.#moveStartedTime = undefined; this.message(GarageDoor.DOOR_EVENT, { status: GarageDoor.CLOSED }); } return; } // Still in motion this.message(GarageDoor.DOOR_EVENT, { status: GarageDoor.MOVING, direction: direction, duration: duration, }); } #validGPIOPin(pin) { return Number.isFinite(Number(pin)) === true && Number(pin) >= GarageDoor.MIN_GPIO_PIN && Number(pin) <= GarageDoor.MAX_GPIO_PIN; } #mapCurrentDoorState(state) { return state === GarageDoor.OPENED ? this.hap.Characteristic.CurrentDoorState.OPEN : state === GarageDoor.CLOSED ? this.hap.Characteristic.CurrentDoorState.CLOSED : state === GarageDoor.OPENING ? this.hap.Characteristic.CurrentDoorState.OPENING : state === GarageDoor.CLOSING ? this.hap.Characteristic.CurrentDoorState.CLOSING : this.hap.Characteristic.CurrentDoorState.STOPPED; } async #pressButton(times = 1) { if (this.#validGPIOPin(this.deviceData?.pushButton) !== true) { return; } for (let i = 0; i < times; i++) { try { GPIO.write(this.deviceData.pushButton, GPIO.HIGH); await new Promise((resolve) => setTimeout(resolve, PUSHBUTTON_DELAY)); GPIO.write(this.deviceData.pushButton, GPIO.LOW); } catch (error) { this?.log?.error?.('Error writing to pushButton GPIO pin "%s": %s', this.deviceData.pushButton, String(error)); return; } if (i + 1 < times) { await new Promise((resolve) => setTimeout(resolve, PUSHBUTTON_DELAY)); } } this?.log?.debug?.('Button pressed %d time(s) for Door "%s"', times, this.deviceData.description); } }