UNPKG

garagedoor-accfactory

Version:

HomeKit garage door opener system using HAP-NodeJS library

436 lines (356 loc) 16.4 kB
// Part of garagedoor-accfactory // Mark Hulskamp 'use strict'; // Define external module requirements import GPIO from 'rpio'; // Define nodejs module requirements import { setTimeout, setInterval } 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 = '2025.06.22'; // Code version static DOOR_EVENT = 'DOOREVENT'; // Door status event tag // 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; #doorStatusTimer = undefined; constructor(accessory, api, log, deviceData) { super(accessory, api, log, 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 this.doorService = this.addHKService(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 specifed 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 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); } if (this.#validGPIOPin(this.deviceData?.closedSensor) === true) { // Door closed sensor 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); } if (this.#validGPIOPin(this.deviceData?.openSensor) === true) { // Door open sensor 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); } if (this.#validGPIOPin(this.deviceData?.obstructionSensor) === true) { // Door obstruction sensor 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, ); this.addHKCharacteristic(this.doorService, this.hap.Characteristic.ObstructionDetected, { initialValue: this.hasObstruction() === 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.addHKCharacteristic(this.doorService, this.hap.Characteristic.CurrentDoorState, { initialValue: this.hap.Characteristic.CurrentDoorState[initial.toUpperCase()], onGet: () => { let key = (this.getDoorPosition() || 'stopped').toUpperCase(); return this.hap.Characteristic.CurrentDoorState[key] !== undefined ? this.hap.Characteristic.CurrentDoorState[key] : this.hap.Characteristic.CurrentDoorState.STOPPED; }, }); this.addHKCharacteristic(this.doorService, this.hap.Characteristic.TargetDoorState, { initialValue: initial === GarageDoor.OPENED ? this.hap.Characteristic.TargetDoorState.OPEN : this.hap.Characteristic.TargetDoorState.CLOSED, onSet: (value) => { this.setDoorPosition(value); }, }); this.addHKCharacteristic(this.doorService, this.hap.Characteristic.StatusFault, { initialValue: this.hap.Characteristic.StatusFault.NO_FAULT, }); // Setup linkage to EveHome app if configured todo so this.setupEveHomeLink(this.doorService); // Push initial state to HomeKit to prevent stale status this.message(GarageDoor.DOOR_EVENT, { status: this.currentDoorStatus }); // Kick off polling loop this.#pollDoorStatus(); this.#doorStatusTimer = setInterval(() => { this.#pollDoorStatus(); }, DOOR_STATUS_INTERVAL); } setDoorPosition(position) { let target = position === this.hap.Characteristic.TargetDoorState.OPEN ? GarageDoor.OPEN : GarageDoor.CLOSE; if (this.currentDoorStatus === target) { this?.log?.debug?.('Door "%s" already %s', this.deviceData.description, target); return; } let behavior = typeof this.deviceData?.buttonBehavior === 'string' ? this.deviceData.buttonBehavior : 'stop-then-reverse'; let isReversal = (this.currentDoorStatus === GarageDoor.OPENING && target === GarageDoor.CLOSE) || (this.currentDoorStatus === GarageDoor.CLOSING && target === GarageDoor.OPEN); if (isReversal) { this?.log?.info?.('Reversing door "%s" from %s to %s', this.deviceData.description, this.currentDoorStatus, target); this.#lastMovementDirection = target; this.#lastDoorStatus = target === GarageDoor.OPEN ? GarageDoor.CLOSED : GarageDoor.OPENED; if (behavior === 'auto-reverse' || behavior === 'always-toggle') { this.pressButton(1); } else { this.pressButton(2); // stop, then reverse } return; } // Normal operation this.pressButton(); } getDoorPosition() { return this.currentDoorStatus; } async pressButton(times = 1) { if (this.#validGPIOPin(this.deviceData?.pushButton) !== true) { return; } for (let i = 0; i < times; i++) { GPIO.write(this.deviceData.pushButton, GPIO.HIGH); await new Promise((resolve) => setTimeout(resolve, PUSHBUTTON_DELAY)); GPIO.write(this.deviceData.pushButton, GPIO.LOW); 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); } isOpen() { let openStatus = undefined; if (this.#validGPIOPin(this.deviceData?.openSensor) === true) { openStatus = GPIO.read(this.deviceData.openSensor) === GPIO.HIGH ? true : false; // If high on sensor, means door is opened } return openStatus; } isClosed() { let closeStatus = undefined; if (this.#validGPIOPin(this.deviceData?.closedSensor) === true) { closeStatus = GPIO.read(this.deviceData.closedSensor) === GPIO.HIGH ? true : false; // If high on sensor, means door is closed } return closeStatus; } hasObstruction() { let obstructionStatus = undefined; if (this.#validGPIOPin(this.deviceData?.obstructionSensor) === true) { obstructionStatus = GPIO.read(this.deviceData.obstructionSensor) === GPIO.HIGH ? true : false; // If high, obstruction detected } 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.addHistory(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.addHistory(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; if (direction !== GarageDoor.OPENING && direction !== GarageDoor.CLOSING) { direction = GarageDoor.CLOSING; } if (this.currentDoorStatus !== direction) { this.currentDoorStatus = direction; this.#lastMovementDirection = direction; this.doorService.updateCharacteristic( this.hap.Characteristic.CurrentDoorState, this.hap.Characteristic.CurrentDoorState[direction.toUpperCase()], ); this.doorService.updateCharacteristic( this.hap.Characteristic.TargetDoorState, this.hap.Characteristic.TargetDoorState[direction.toUpperCase() === 'OPENING' ? 'OPEN' : 'CLOSED'], ); 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.addHistory(this.doorService, { status: 1 }, { timegap: 2 }); this?.log?.debug?.('Door "%s" has stopped moving', this.deviceData.description); } return; } if (state === 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.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.doorService.updateCharacteristic(this.hap.Characteristic.ObstructionDetected, false); if (this.#lastObstructionStatus === true) { this?.log?.success?.('Door "%s" obstruction cleared', this.deviceData.description); } this.#lastObstructionStatus = false; return; } } } #pollDoorStatus() { // Check obstruction if canfigured up front sensor if defined if (this.#validGPIOPin(this.deviceData?.obstructionSensor) === true) { let obstructed = this.hasObstruction() === true; this.message(GarageDoor.DOOR_EVENT, { status: obstructed ? GarageDoor.OBSTRUCTION : GarageDoor.CLEAR, }); } let doorClosed = this.isClosed() === true; let doorOpen = this.isOpen() === true; // Door is 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; } // Door is 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 is moving (neither sensor triggered) if (this.#moveStartedTime === undefined) { this.#moveStartedTime = Date.now(); } let duration = Date.now() - this.#moveStartedTime; let direction = GarageDoor.CLOSING; // Infer movement direction by *previous physical state* if (this.#lastDoorStatus === GarageDoor.CLOSED) { direction = GarageDoor.OPENING; } else if (this.#lastDoorStatus === GarageDoor.OPENED) { direction = GarageDoor.CLOSING; } else if (this.#lastMovementDirection === GarageDoor.OPENING) { direction = GarageDoor.OPENING; } // Timeout fallback if sensor fails to confirm status if (direction === GarageDoor.OPENING && this.isOpen() !== true && duration >= this.deviceData.openTime * 1000) { this.#lastDoorStatus = GarageDoor.OPENED; this.#lastMovementDirection = GarageDoor.CLOSING; this.#moveStartedTime = undefined; this?.log?.warn?.( 'Door "%s" assumed open after %ds (open sensor not triggered)', this.deviceData.description, this.deviceData.openTime, ); this.message(GarageDoor.DOOR_EVENT, { status: GarageDoor.OPENED }); return; } if (direction === GarageDoor.CLOSING && this.isClosed() !== true && duration >= this.deviceData.closeTime * 1000) { this.#lastDoorStatus = GarageDoor.CLOSED; this.#lastMovementDirection = GarageDoor.OPENING; this.#moveStartedTime = undefined; this?.log?.warn?.( 'Door "%s" assumed closed after %ds (closed sensor not triggered)', this.deviceData.description, this.deviceData.closeTime, ); this.message(GarageDoor.DOOR_EVENT, { status: GarageDoor.CLOSED }); return; } this.message(GarageDoor.DOOR_EVENT, { status: GarageDoor.MOVING, direction: direction, duration: duration, }); } #validGPIOPin(pin) { return isNaN(pin) === false && Number(pin) >= GarageDoor.MIN_GPIO_PIN && Number(pin) <= GarageDoor.MAX_GPIO_PIN; } }