@vandubois/homebridge-virtual-switch
Version:
Creation of virtual switches, triggered independently and by keywords appearing in the Homebridge log file.
327 lines • 15.8 kB
JavaScript
import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js';
import { HomebridgeVirtualSwitchesAccessory } from './platformAccessory.js';
import { spawn } from 'child_process';
import stripAnsi from 'strip-ansi';
import fs from 'fs';
import path from 'path';
export class HomebridgeVirtualSwitchesPlatform {
log;
config;
api;
Service;
Characteristic;
accessories = [];
accessoryInstances = new Map();
tailProcesses = new Map();
timerStates = new Map();
// Declare the properties
spawn;
stripAnsi;
constructor(log, config, api) {
this.log = log;
this.config = config;
this.api = api;
this.Service = api.hap.Service;
this.Characteristic = api.hap.Characteristic;
this.log.debug('DEBUG: Finished initializing platform.');
this.api.on('didFinishLaunching', () => {
this.log.debug('DEBUG: Executed didFinishLaunching callback');
this.loadDependencies().then(() => {
this.discoverDevices();
}).catch((error) => {
this.log.error('ERROR: Failed to load dependencies:', error);
});
});
}
async loadDependencies() {
try {
const childProcess = await import('child_process');
this.spawn = childProcess.spawn;
}
catch (error) {
this.log.error('ERROR: Failed to load child_process module:', error);
throw error;
}
try {
const stripAnsiModule = await import('strip-ansi');
this.stripAnsi = stripAnsiModule.default;
}
catch (error) {
this.log.error('ERROR: Failed to load strip-ansi module:', error);
throw error;
}
}
configureAccessory(accessory) {
this.log.info('Loading accessory from cache:', accessory.displayName);
this.accessories.push(accessory);
}
discoverDevices() {
// Get configured devices from config
const devices = Array.isArray(this.config.devices) ? this.config.devices : [];
// Create a set of configured device UUIDs for quick lookup
const configuredUUIDs = new Set(devices.map(device => this.api.hap.uuid.generate(device.Name)));
// First, remove accessories that are no longer in the config
const accessoriesToRemove = this.accessories.filter(accessory => !configuredUUIDs.has(accessory.UUID));
if (accessoriesToRemove.length > 0) {
this.log.info(`Removing ${accessoriesToRemove.length} unconfigured accessories`);
for (const accessory of accessoriesToRemove) {
const uuid = accessory.UUID;
// Stop any running tail processes
if (this.tailProcesses.has(uuid)) {
const tailProcess = this.tailProcesses.get(uuid);
if (tailProcess) {
tailProcess.kill();
this.tailProcesses.delete(uuid);
}
}
// Clean up timer states
this.clearTimerState(accessory.displayName);
// Remove from accessory instances
this.accessoryInstances.delete(uuid);
// Remove the index from our accessories array
const index = this.accessories.indexOf(accessory);
if (index > -1) {
this.accessories.splice(index, 1);
}
}
// Unregister from HomeKit
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessoriesToRemove);
}
for (const deviceConfig of devices) {
if (!deviceConfig.Name || deviceConfig.Name.trim() === '') {
this.log.info('ERROR: Switch with missing "Switch Name" found in this Plugin Config.');
this.log.info('ERROR: Give it a "Switch Name" in your "Homebridge Virtual Switches Plugin Config".');
//continue; // Skip this device but continue processing others
}
// Ensure all properties are passed through
const device = {
...deviceConfig,
Keywords: Array.isArray(deviceConfig.Keywords) ? deviceConfig.Keywords : [], // Only override Keywords to ensure it's an array
};
this.log.debug('DEBUG: Device config:', JSON.stringify(device));
const uuid = this.api.hap.uuid.generate(device.Name);
const existingAccessory = this.accessories.find(accessory => accessory.UUID === uuid);
// Load last switch state if RememberSwitchSate = true
let lastState = null;
if (deviceConfig.RememberState) {
lastState = this.loadSwitchState(device.Name);
}
// Load persistent timer state if TimerPersistant = true
let timerState = null;
if (deviceConfig.TimerPersistent) {
timerState = this.loadTimerState(device.Name);
if (timerState && timerState.isRunning) {
this.log.debug(`DEBUG: Loaded persistent timer state for "${device.Name}"`);
}
}
if (existingAccessory) {
this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName);
existingAccessory.context.device = { ...device, lastState, timerState };
this.api.updatePlatformAccessories([existingAccessory]);
const accessoryInstance = new HomebridgeVirtualSwitchesAccessory(this, existingAccessory);
this.accessoryInstances.set(uuid, accessoryInstance);
}
else {
this.log.info('Adding new accessory:', device.Name);
const accessory = new this.api.platformAccessory(device.Name, uuid);
accessory.context.device = { ...device, lastState, timerState };
const accessoryInstance = new HomebridgeVirtualSwitchesAccessory(this, accessory);
this.accessoryInstances.set(uuid, accessoryInstance);
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
}
// Start log monitoring with the specified delay after startup
if (device.UseLogFile) {
let startupDelay = 0;
if (device.UseCustomStartupDelay) {
if (device.UseCustomStartupDelay) {
// Convert days, hours, minutes and seconds to milliseconds
startupDelay = (((((device.StartupDelayDays * 24 + device.StartupDelayHours) * 60) + device.StartupDelayMinutes) * 60 + device.StartupDelaySeconds) * 1000);
const timeStr = `${device.StartupDelayDays}d ${device.StartupDelayHours}h ${device.StartupDelayMinutes}m ${device.StartupDelaySeconds}s`;
this.log.debug(`DEBUG: Using startup delay set in day/hr/min/sec for "${device.Name}": ${startupDelay}ms (${timeStr})`);
}
else {
// Use the default startupDelay
startupDelay = device.StartupDelay;
this.log.debug(`DEBUG: Using startup delay set in milliseconds for "${device.Name}": ${startupDelay}ms`);
}
}
setTimeout(() => {
this.startLogMonitoring(device, uuid);
}, startupDelay);
}
}
}
startLogMonitoring(device, uuid) {
if (!this.spawn) {
this.log.error('Cannot start log monitoring: child_process module not loaded');
return;
}
const logFilePath = device.LogFilePath || '/var/lib/homebridge/homebridge.log';
this.log.info(`Starting to monitor log file at: ${logFilePath} for switch "${device.Name}"`);
// Use tail -f to monitor the log file
const tail = spawn('tail', ['-f', logFilePath]);
tail.stdout.on('data', (data) => {
const line = data.toString().trim();
this.checkKeywords(line, uuid);
//if (!this.isPluginLogMessage(line)) {
// this.checkKeywords(line, uuid);
//}
});
tail.stderr.on('data', (data) => {
this.log.error(`Error from tail process for "${device.Name}": ${data.toString()}`);
});
tail.on('close', (code) => {
this.log.info(`Tail process for "${device.Name}" exited with code ${code}`);
});
this.tailProcesses.set(uuid, tail);
}
checkKeywords(line, uuid) {
const accessoryInstance = this.accessoryInstances.get(uuid);
if (!accessoryInstance) {
return;
}
const device = accessoryInstance.accessory.context.device;
if (!device) {
return;
}
if (this.isPluginLogMessage(line, device)) {
return;
}
const cleanedLine = this.escapeSpecialChars(this.removeAnsiCodes(line).toLowerCase());
const processedKeywords = device.Keywords.map((keyword) => this.escapeSpecialChars(this.removeAnsiCodes(keyword).toLowerCase()));
processedKeywords.some((keyword) => {
if (cleanedLine.includes(keyword)) {
this.log.debug(`DEBUG: Keyword match ("${keyword}") found for switch "${device.Name}"`);
//const foundKeyword = keyword;
//this.log.debug(`DEBUG: Keyword match ("${foundKeyword}") found for switch "${device.Name}"`);
accessoryInstance.triggerSwitch();
return true;
}
return false;
});
}
// Helper method to check if the line is generated by this plugin and by a switch that monitors a log file (needed when debugging)
isPluginLogMessage(line, device) {
// Check if the line is a debug or error message from this plugin
if ((line.includes('DEBUG:') || line.includes('ERROR: ')) &&
(line.includes('homebridge-virtual-switch') || line.includes('HomebridgeVirtualSwitches'))) {
return true; // Exclude debug and error messages from checking for keywords
}
if (!device.UseLogFile) {
return false; // Don't exclude any lines for switches not using log file monitoring
}
// Exclude lines that contain both the plugin name and the specific switch name
return (line.includes('homebridge-virtual-switch') || line.includes('HomebridgeVirtualSwitches'))
&& line.includes(device.Name);
}
// Helper method to escape special characters in keywords
escapeSpecialChars(keyword) {
return keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
//Helper method to remove ANSI escape codes from a string
removeAnsiCodes(text) {
if (!this.stripAnsi) {
this.log.warn('strip-ansi module not loaded, ANSI codes will not be removed');
return text;
}
return stripAnsi(text);
}
// Add this helper method to save the state of a switch
saveSwitchState(name, state) {
const stateFilePath = path.join(this.api.user.storagePath(), `${name}_state.json`);
fs.writeFileSync(stateFilePath, JSON.stringify({ state }), 'utf8');
}
// Add this helper method to load the state of a switch
loadSwitchState(name) {
try {
const stateFilePath = path.join(this.api.user.storagePath(), `${name}_state.json`);
if (fs.existsSync(stateFilePath)) {
const data = fs.readFileSync(stateFilePath, 'utf8');
const parsed = JSON.parse(data);
return parsed.state;
}
}
catch (error) {
this.log.error(`ERROR: Failed to load state for switch "${name}":`, error);
}
return null;
}
// Add new methods for persistent timer management
saveTimerState(name, targetTime, isRunning) {
const timerStatePath = path.join(this.api.user.storagePath(), `${name}_timer.json`);
fs.writeFileSync(timerStatePath, JSON.stringify({ targetTime, isRunning }), 'utf8');
this.timerStates.set(name, { targetTime, isRunning });
}
loadTimerState(name) {
try {
const timerStatePath = path.join(this.api.user.storagePath(), `${name}_timer.json`);
if (fs.existsSync(timerStatePath)) {
const data = fs.readFileSync(timerStatePath, 'utf8');
const parsed = JSON.parse(data);
this.timerStates.set(name, parsed);
return parsed;
}
}
catch (error) {
this.log.error(`ERROR: Failed to load timer state for switch "${name}":`, error);
}
return null;
}
clearTimerState(name) {
const timerStatePath = path.join(this.api.user.storagePath(), `${name}_timer.json`);
if (fs.existsSync(timerStatePath)) {
fs.unlinkSync(timerStatePath);
}
this.timerStates.delete(name);
}
// Helper to calculate the target time of Persistent switches
calculateTargetTime(device) {
const now = Date.now();
if (device.TimerPersistent) {
if (device.UseCustomTime) {
// Convert days, hours, minutes, and seconds to milliseconds
const milliseconds = (((((device.TimeDays * 24 + device.TimeHours) * 60) +
device.TimeMinutes) * 60 + device.TimeSeconds) * 1000);
const targetTime = now + milliseconds;
const targetDate = new Date(targetTime);
this.log.debug(`DEBUG: Persistent timer for "${device.Name}" will run until: ${targetDate.toLocaleString()}`);
return { targetTime, duration: milliseconds };
}
else {
// Use the simple Time value (already in milliseconds)
const targetTime = now + device.Time;
const targetDate = new Date(targetTime);
this.log.debug(`DEBUG: Persistent timer for "${device.Name}" will run until: ${targetDate.toLocaleString()}`);
return { targetTime, duration: device.Time };
}
}
else {
// Handle non-persistent switches
if (device.UseCustomTime) {
// Convert days, hours, minutes, and seconds to milliseconds
const milliseconds = (((((device.TimeDays * 24 + device.TimeHours) * 60) +
device.TimeMinutes) * 60 + device.TimeSeconds) * 1000);
const targetTime = 0; // Non-persistent switches don't need a target time
const targetDate = new Date(now + milliseconds);
this.log.debug(`DEBUG: Non-persistent timer for "${device.Name}" will run for ${milliseconds}ms until: ${targetDate.toLocaleString()}`);
return { targetTime, duration: milliseconds };
}
else {
// Use the simple Time value (already in milliseconds)
const targetTime = 0; // Non-persistent switches don't need a target time
const duration = device.Time;
const targetDate = new Date(now + duration);
this.log.debug(`DEBUG: Non-persistent timer for "${device.Name}" will run for ${duration}ms until: ${targetDate.toLocaleString()}`);
return { targetTime, duration };
}
}
}
// Check if target time has been reached
hasReachedTargetTime(targetTime) {
if (targetTime === 0) {
return false;
}
return Date.now() >= targetTime;
}
}
//# sourceMappingURL=platform.js.map