garagedoor-accfactory
Version:
HomeKit garage door opener system using HAP-NodeJS library
376 lines (335 loc) • 15.6 kB
JavaScript
// Garage Door Accessory (HAP-NodeJS)
// Part of garagedoor-accfactory
//
// Provides standalone HomeKit garage door accessories using HAP-NodeJS.
// Supports multiple doors, GPIO-based control/sensing, optional Eve history,
// and a schema-driven Web UI for configuration and monitoring.
//
// Features:
// - Multiple garage doors via `doors[]` configuration
// - GPIO-based push button, open/closed sensors, optional obstruction sensor
// - Configurable timing and button behaviour
// - HomeKit pairing per door (unique username per accessory)
// - Optional EveHome history integration
// - Built-in Web UI (HomeKitUI) with schema-driven dynamic configuration
// - Live log streaming and status monitoring
//
// Configuration:
// - JSON-based configuration file (default: GarageDoor.json)
// - Schema-driven UI (config.schema.json)
// - Supports runtime editing via Web UI (restart required for structural changes)
//
// Usage:
// node dist/index.js [optional-config.json]
//
// Notes:
// - GPIO pin ranges are validated per device (see GarageDoor.MIN/MAX_GPIO_PIN)
// - Changes to doors (add/remove) require restart to take effect
// - Web UI is optional and enabled via options.webUIPort
//
// Example Hardware:
// - Pimoroni Automation pHAT
// https://shop.pimoroni.com/products/automation-phat
// Example GPIO mapping:
// GPIO26 Input 1
// GPIO20 Input 2
// GPIO21 Input 3
// GPIO5 Output 1
// GPIO12 Output 2
// GPIO6 Output 3
// GPIO16 Relay 1
//
// Code Version 2026.05.10
// Mark Hulskamp
;
// Define HAP-NodeJS module requirements
import HAP from '@homebridge/hap-nodejs';
// Define nodejs module requirements
import process from 'node:process';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { Buffer } from 'node:buffer';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
// Import our modules
import GarageDoor from './door.js';
import HomeKitDevice from './HomeKitDevice.js';
HomeKitDevice.PLUGIN_NAME = 'garagedoor-accfactory';
HomeKitDevice.PLATFORM_NAME = 'GarageDoorAccfactory';
import HomeKitHistory from './HomeKitHistory.js';
HomeKitDevice.EVEHOME = HomeKitHistory;
import HomeKitUI from './HomeKitUI.js';
import Logger from './logger.js';
const log = Logger.withPrefix(HomeKitDevice.PLATFORM_NAME);
HomeKitDevice.LOGGER = log; // Setup a reference to our logger for use in our HomeKitDevice module
// Define constants
const { version } = createRequire(import.meta.url)('../package.json'); // Import the package.json file to get the version number
const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Make a defined for JS __dirname
const ACCESSORY_PINCODE = '031-45-154'; // Default HomeKit pairing code
const CONFIGURATION_FILE = 'GarageDoor.json'; // Default configuration file name
const CONFIG_SCHEMA_FILE = path.join(__dirname, './config.schema.json');
// General helper functions
function loadConfiguration(filename) {
if (typeof filename !== 'string' || filename === '' || fs.existsSync(filename) === false) {
return;
}
let config = undefined;
try {
let loadedConfig = JSON.parse(fs.readFileSync(filename, 'utf8').trim());
config = {
doors: [],
options: {
debug: false,
eveHistory: true,
hkPairingCode: ACCESSORY_PINCODE,
webUIPort: 0,
webUIBearerToken: '',
},
};
Object.entries(loadedConfig).forEach(([key, value]) => {
if (key === 'doors' && Array.isArray(value) === true) {
// Validate doors section
let unnamedCount = 1;
value.forEach((door) => {
let tempDoor = {
hkUsername:
typeof door?.hkUsername === 'string' && door.hkUsername !== ''
? door.hkUsername.trim()
: crypto
.randomBytes(6)
.toString('hex')
.toUpperCase()
.split(/(..)/)
.filter((s) => s)
.join(':'),
name:
typeof door?.name === 'string' && door.name !== ''
? HomeKitDevice.makeValidHKName(door.name.trim())
: 'Door ' + unnamedCount++,
manufacturer: typeof door?.manufacturer === 'string' && door.manufacturer !== '' ? door.manufacturer.trim() : '',
model: typeof door?.model === 'string' && door.model !== '' ? door.model.trim() : '',
serialNumber:
typeof door?.serialNumber === 'string' && door.serialNumber !== ''
? door.serialNumber.trim()
: crc32(crypto.randomUUID().toUpperCase()).toString(),
pushButton:
Number.isFinite(Number(door?.pushButton)) === true &&
Number(door.pushButton) >= GarageDoor.MIN_GPIO_PIN &&
Number(door.pushButton) <= GarageDoor.MAX_GPIO_PIN
? Number(door.pushButton)
: undefined,
closedSensor:
Number.isFinite(Number(door?.closedSensor)) === true &&
Number(door.closedSensor) >= GarageDoor.MIN_GPIO_PIN &&
Number(door.closedSensor) <= GarageDoor.MAX_GPIO_PIN
? Number(door.closedSensor)
: undefined,
openSensor:
Number.isFinite(Number(door?.openSensor)) === true &&
Number(door.openSensor) >= GarageDoor.MIN_GPIO_PIN &&
Number(door.openSensor) <= GarageDoor.MAX_GPIO_PIN
? Number(door.openSensor)
: undefined,
obstructionSensor:
Number.isFinite(Number(door?.obstructionSensor)) === true &&
Number(door.obstructionSensor) >= GarageDoor.MIN_GPIO_PIN &&
Number(door.obstructionSensor) <= GarageDoor.MAX_GPIO_PIN
? Number(door.obstructionSensor)
: undefined,
openTime:
Number.isFinite(Number(door?.openTime)) === true && Number(door.openTime) >= 0 && Number(door.openTime) <= 300
? Number(door.openTime)
: 30,
closeTime:
Number.isFinite(Number(door?.closeTime)) === true && Number(door.closeTime) >= 0 && Number(door.closeTime) <= 300
? Number(door.closeTime)
: 30,
buttonBehavior:
typeof door?.buttonBehavior === 'string' &&
['stop-then-reverse', 'auto-reverse', 'always-toggle'].includes(door.buttonBehavior)
? door.buttonBehavior
: 'stop-then-reverse',
};
config.doors.push(tempDoor);
});
}
if (key === 'options' && Array.isArray(value) === false && typeof value === 'object') {
config.options.debug = value?.debug === true;
config.options.eveHistory = value?.eveHistory === true;
config.options.hkPairingCode =
HomeKitDevice.HK_PIN_3_2_3.test(value?.hkPairingCode) === true || HomeKitDevice.HK_PIN_4_4.test(value?.hkPairingCode) === true
? value.hkPairingCode
: ACCESSORY_PINCODE;
config.options.webUIPort =
Number.isFinite(Number(value?.webUIPort)) === true && Number(value.webUIPort) > 0 && Number(value.webUIPort) <= 65535
? Number(value.webUIPort)
: 0;
config.options.webUIBearerToken = typeof value?.webUIBearerToken === 'string' ? value.webUIBearerToken.trim() : '';
}
});
// Write config backout!!
fs.writeFileSync(filename, JSON.stringify(config, null, 2) + '\n');
// eslint-disable-next-line no-unused-vars
} catch (error) {
// Empty
}
return config;
}
function crc32(valueToHash) {
let crc32HashTable = [
0x000000000, 0x077073096, -0x11f19ed4, -0x66f6ae46, 0x0076dc419, 0x0706af48f, -0x169c5acb, -0x619b6a5d, 0x00edb8832, 0x079dcb8a4,
-0x1f2a16e2, -0x682d2678, 0x009b64c2b, 0x07eb17cbd, -0x1847d2f9, -0x6f40e26f, 0x01db71064, 0x06ab020f2, -0xc468eb8, -0x7b41be22,
0x01adad47d, 0x06ddde4eb, -0xb2b4aaf, -0x7c2c7a39, 0x0136c9856, 0x0646ba8c0, -0x29d0686, -0x759a3614, 0x014015c4f, 0x063066cd9,
-0x5f0c29d, -0x72f7f20b, 0x03b6e20c8, 0x04c69105e, -0x2a9fbe1c, -0x5d988e8e, 0x03c03e4d1, 0x04b04d447, -0x2df27a03, -0x5af54a95,
0x035b5a8fa, 0x042b2986c, -0x2444362a, -0x534306c0, 0x032d86ce3, 0x045df5c75, -0x2329f231, -0x542ec2a7, 0x026d930ac, 0x051de003a,
-0x3728ae80, -0x402f9eea, 0x021b4f4b5, 0x056b3c423, -0x30456a67, -0x47425af1, 0x02802b89e, 0x05f058808, -0x39f3264e, -0x4ef416dc,
0x02f6f7c87, 0x058684c11, -0x3e9ee255, -0x4999d2c3, 0x076dc4190, 0x001db7106, -0x672ddf44, -0x102aefd6, 0x071b18589, 0x006b6b51f,
-0x60401b5b, -0x17472bcd, 0x07807c9a2, 0x00f00f934, -0x69f65772, -0x1ef167e8, 0x07f6a0dbb, 0x0086d3d2d, -0x6e9b9369, -0x199ca3ff,
0x06b6b51f4, 0x01c6c6162, -0x7a9acf28, -0xd9dffb2, 0x06c0695ed, 0x01b01a57b, -0x7df70b3f, -0xaf03ba9, 0x065b0d9c6, 0x012b7e950,
-0x74414716, -0x3467784, 0x062dd1ddf, 0x015da2d49, -0x732c830d, -0x42bb39b, 0x04db26158, 0x03ab551ce, -0x5c43ff8c, -0x2b44cf1e,
0x04adfa541, 0x03dd895d7, -0x5b2e3b93, -0x2c290b05, 0x04369e96a, 0x0346ed9fc, -0x529877ba, -0x259f4730, 0x044042d73, 0x033031de5,
-0x55f5b3a1, -0x22f28337, 0x05005713c, 0x0270241aa, -0x41f4eff0, -0x36f3df7a, 0x05768b525, 0x0206f85b3, -0x46992bf7, -0x319e1b61,
0x05edef90e, 0x029d9c998, -0x4f2f67de, -0x3828574c, 0x059b33d17, 0x02eb40d81, -0x4842a3c5, -0x3f459353, -0x12477ce0, -0x65404c4a,
0x003b6e20c, 0x074b1d29a, -0x152ab8c7, -0x622d8851, 0x004db2615, 0x073dc1683, -0x1c9cf4ee, -0x6b9bc47c, 0x00d6d6a3e, 0x07a6a5aa8,
-0x1bf130f5, -0x6cf60063, 0x00a00ae27, 0x07d079eb1, -0xff06cbc, -0x78f75c2e, 0x01e01f268, 0x06906c2fe, -0x89da8a3, -0x7f9a9835,
0x0196c3671, 0x06e6b06e7, -0x12be48a, -0x762cd420, 0x010da7a5a, 0x067dd4acc, -0x6462091, -0x71411007, 0x017b7be43, 0x060b08ed5,
-0x29295c18, -0x5e2e6c82, 0x038d8c2c4, 0x04fdff252, -0x2e44980f, -0x5943a899, 0x03fb506dd, 0x048b2364b, -0x27f2d426, -0x50f5e4b4,
0x036034af6, 0x041047a60, -0x209f103d, -0x579820ab, 0x0316e8eef, 0x04669be79, -0x349e4c74, -0x43997ce6, 0x0256fd2a0, 0x05268e236,
-0x33f3886b, -0x44f4b8fd, 0x0220216b9, 0x05505262f, -0x3a45c442, -0x4d42f4d8, 0x02bb45a92, 0x05cb36a04, -0x3d280059, -0x4a2f30cf,
0x02cd99e8b, 0x05bdeae1d, -0x649b3d50, -0x139c0dda, 0x0756aa39c, 0x0026d930a, -0x63f6f957, -0x14f1c9c1, 0x072076785, 0x005005713,
-0x6a40b57e, -0x1d4785ec, 0x07bb12bae, 0x00cb61b38, -0x6d2d7165, -0x1a2a41f3, 0x07cdcefb7, 0x00bdbdf21, -0x792c2d2c, -0xe2b1dbe,
0x068ddb3f8, 0x01fda836e, -0x7e41e933, -0x946d9a5, 0x06fb077e1, 0x018b74777, -0x77f7a51a, -0xf09590, 0x066063bca, 0x011010b5c,
-0x709a6101, -0x79d5197, 0x0616bffd3, 0x0166ccf45, -0x5ff51d88, -0x28f22d12, 0x04e048354, 0x03903b3c2, -0x5898d99f, -0x2f9fe909,
0x04969474d, 0x03e6e77db, -0x512e95b6, -0x2629a524, 0x040df0b66, 0x037d83bf0, -0x564351ad, -0x2144613b, 0x047b2cf7f, 0x030b5ffe9,
-0x42420de4, -0x35453d76, 0x053b39330, 0x024b4a3a6, -0x452fc9fb, -0x3228f96d, 0x054de5729, 0x023d967bf, -0x4c9985d2, -0x3b9eb548,
0x05d681b02, 0x02a6f2b94, -0x4bf441c9, -0x3cf3715f, 0x05a05df1b, 0x02d02ef8d,
];
let crc32 = 0xffffffff; // init crc32 hash;
valueToHash = Buffer.from(valueToHash); // convert value into buffer for processing
for (var index = 0; index < valueToHash.length; index++) {
crc32 = (crc32HashTable[(crc32 ^ valueToHash[index]) & 0xff] ^ (crc32 >>> 8)) & 0xffffffff;
}
crc32 ^= 0xffffffff;
return crc32 >>> 0; // return crc32
}
// Startup code
log.success(HomeKitDevice.PLUGIN_NAME + ' v' + version + ' (HAP v' + HAP.HAPLibraryVersion() + ') (Node v' + process.versions.node + ')');
// Check to see if a configuration file was passed into use and validate if present
let configurationFile = path.resolve(__dirname, CONFIGURATION_FILE);
let argFile = process.argv[2];
if (typeof argFile === 'string') {
configurationFile = path.isAbsolute(argFile) ? argFile : path.resolve(process.cwd(), argFile);
}
if (fs.existsSync(configurationFile) === false) {
// Configuration file, either by default name or specified on commandline is missing
log.error('Specified configuration "%s" cannot be found', configurationFile);
process.exit(1);
}
// Have a configuration file, now load the configuration options
let config = loadConfiguration(configurationFile);
if (config === undefined) {
log.error('Configuration "%s" contains invalid JSON or structure', configurationFile);
process.exit(1);
}
// Check to see we have atleast ONE door defined
if (config.doors.length < 1) {
log.info('Configuration file does not have any doors defined. Please review configuration');
process.exit(1);
}
log.info('Loaded configuration from "%s"', configurationFile);
// Enable debugging if configured
if (config?.options?.debug === true) {
Logger.setDebugEnabled();
log.warn('Debugging has been enabled');
}
// For each door in our configuration, create the HomeKit accessory
let accessories = [];
for (let door of config.doors) {
let deviceData = {
hkPairingCode: config.options.hkPairingCode,
hkUsername: door.hkUsername,
serialNumber: door.serialNumber,
softwareVersion: version,
manufacturer: door.manufacturer,
model: door.model,
description: (door.manufacturer + ' ' + door.model).trim() || 'Garage Door',
eveHistory: config.options.eveHistory,
pushButton: door.pushButton,
openSensor: door.openSensor,
closedSensor: door.closedSensor,
obstructionSensor: door.obstructionSensor,
openTime: door.openTime,
closeTime: door.closeTime,
buttonBehavior: door.buttonBehavior,
};
let tempDevice = new GarageDoor(undefined, HAP, deviceData);
let accessory = await tempDevice.add('Garage Door', HAP.Categories.GARAGE_DOOR_OPENER, true);
accessories.push(accessory);
}
// Start HomeKit Web UI if configured to do so
let ui = undefined;
if (config.options.webUIPort > 0) {
ui = new HomeKitUI({
name: 'Garage Door',
version,
host: '0.0.0.0',
port: config.options.webUIPort,
auth: {
enabled: typeof config.options.webUIBearerToken === 'string' && config.options.webUIBearerToken !== '',
bearerToken: config.options.webUIBearerToken,
},
configFile: configurationFile,
schemaFile: CONFIG_SCHEMA_FILE,
accessories,
hap: HAP,
log,
logger: Logger,
pages: [
{
id: 'doors',
title: 'Garage Doors',
svg:
'<svg viewBox="0 0 24 24">' +
'<path d="M3 11.5 12 4l9 7.5"/>' + // roof
'<path d="M5 10.5V21h14V10.5"/>' + // frame
'<path d="M8 14h8"/>' + // door line 1
'<path d="M8 17h8"/>' + // door line 2
'</svg>',
schemaPath: 'doors',
},
{
id: 'options',
title: 'Options',
icon: 'settings',
schemaPath: 'options',
},
],
onRestart: async () => {
await shutdown('restart', 1);
},
});
await ui.start();
}
// Handle process shutdown
let shuttingDown = false;
async function shutdown(signal, exitCode = 0) {
if (shuttingDown === true) {
return;
}
shuttingDown = true;
log.warn('Received %s, shutting down gracefully...', signal);
await HomeKitDevice.shutdown();
if (ui !== undefined) {
ui.stop().catch(() => {
// Empty
});
}
process.exit(exitCode);
}
// Register process signal handlers for graceful shutdown
['SIGINT', 'SIGTERM'].forEach((signal) => {
process.once(signal, () => {
shutdown(signal, 0);
});
});