UNPKG

homebridge-http-sensors-switches

Version:

This plugin communicates with your devices over HTTP or MQTT. Currently it supports Light Bulb, Switches, Outlets, Fan, Garage Door, Shades / Blinds, Temperature/Humidity, Motion, Contact and Occupancy sensor, Door, Sprinkler, Valve, Air Quality, Smoke, C

423 lines 20.8 kB
import { HttpsAgentManager } from './lib/HttpsAgentManager.js'; import axios from 'axios'; import mqtt from 'mqtt'; import { SharedPolling } from './lib/SharedPolling.js'; // Include shared polling library import { discordWebHooks } from './lib/discordWebHooks.js'; import { getNestedValue } from './lib/utilities.js'; import { sensorConfig } from './platformSensorGenericSettings.js'; /** * Platform Accessory * An instance of this class is created for each accessory your platform registers * Each accessory may expose multiple services of different service types. */ export class platformSensorGeneric { platform; accessory; sensorService; mqttClient; sharedPollingInstance; isReachable = true; // Track if the device is reachable enableLogging = true; // Security, Self Signed Certificates rules ignoreHttpsCertErrors = false; trustedCert; // Ensure backward compatibility for shared polling sharedPolling = false; // Default to false sharedPollingId = ''; // Default to empty sharedPollingInterval = 60000; deviceId = ''; deviceType = ''; deviceName = ''; deviceManufacturer = ''; deviceModel = ''; deviceSerialNumber = ''; deviceFirmwareVersion = ''; urlStatus = ''; mqttReconnectInterval = ''; mqttBroker = ''; mqttPort = ''; mqttUsername = ''; mqttPassword = ''; updateInterval = 60000; discordWebhook = ''; discordUsername = ''; discordAvatar = ''; discordMessage = ''; paramNames = {}; mqttTopics = {}; SensorStates = {}; SensorStatusRanges = {}; httpsAgentManager; constructor(platform, accessory) { this.platform = platform; this.accessory = accessory; const device = this.accessory.context.device; this.deviceType = device.deviceType; this.deviceName = device.deviceName || 'NoName'; this.deviceManufacturer = device.deviceManufacturer || 'Stergo'; this.deviceModel = device.deviceModel || 'Sensor'; this.deviceSerialNumber = device.deviceSerialNumber || accessory.UUID; this.deviceFirmwareVersion = device.deviceFirmwareVersion || '0.0'; // From Config this.enableLogging = device.enableLogging; // Security, Self Signed Certificates rules this.ignoreHttpsCertErrors = device.ignoreHttpsCertErrors || false; this.trustedCert = device.trustedCert || undefined; this.urlStatus = device.urlStatus; this.updateInterval = device.updateInterval || 60000; // Default update interval is 300 seconds this.mqttReconnectInterval = device.mqttReconnectInterval || 60; // 60 sec default this.mqttBroker = device.mqttBroker; this.mqttPort = device.mqttPort; this.mqttUsername = device.mqttUsername; this.mqttPassword = device.mqttPassword; // Check if deviceType is set and if configuration exists for the current deviceType if (!this.deviceType) { this.platform.log.warn('Device type is NOT SET.'); return; } const config = sensorConfig[this.deviceType]; if (!config) { this.platform.log.warn(`Device type ${this.deviceType} is not supported.`); return; } // -------------------------------------------------------------------------------- // Read device configuration and initialize properties dynamically from Settings // Initialize paramNames and mqttTopics dynamically config.paramNames.forEach((paramNameKey) => { // Explicitly cast paramNameKey to a valid key of config.states const paramKey = `paramName${config.states[paramNameKey]?.param}`; // Add "paramName" prefix if (!paramKey) { this.platform.log.warn(`Param not found for ${paramNameKey} in states.`); return; } const paramValue = device[paramKey]?.toString() || ''; this.platform.log.debug(`Param Key: ${paramKey}, Value: ${paramValue}`); this.paramNames[paramNameKey] = paramValue; // Populate mqttTopics dynamically (using "mqtt" prefix for keys) const deviceMqttKey = `mqtt${config.states[paramNameKey]?.topic}`; // Add "mqtt" prefix const mqttValue = device[deviceMqttKey]?.toString() || ''; this.platform.log.debug(`MQTT Key: ${deviceMqttKey}, Value: ${mqttValue}`); this.mqttTopics[paramNameKey] = mqttValue; }); // Initialize SensorStates and SensorStatusRanges dynamically Object.entries(config.sensors).forEach(([sensorKey, sensorConfig]) => { this.SensorStates[sensorKey] = sensorConfig.defaultValue; this.SensorStatusRanges[sensorKey] = sensorConfig.range; }); // --------------------------------------------------------------------------------- this.discordWebhook = device.discordWebhook; this.discordUsername = device.discordUsername || 'StergoSmart'; this.discordAvatar = device.discordAvatar || 'https://raw.githubusercontent.com/homebridge/branding/latest/logos/homebridge-color-round-stylized.png'; this.discordMessage = device.discordMessage; this.httpsAgentManager = new HttpsAgentManager(this.trustedCert, this.ignoreHttpsCertErrors, this.urlStatus); // Ensure backward compatibility for shared polling this.sharedPolling = device.sharedPolling ?? false; // Default shared polling to false this.sharedPollingId = device.sharedPollingId ?? ''; // Default shared polling group ID to an empty string this.sharedPollingInterval = device.sharedPollingInterval ?? 60000; // Set the polling interval to 60 sec or from config value if (this.sharedPolling && this.sharedPollingId) { const sharedPollingInstance = SharedPolling.registerPolling(this.sharedPollingId, this.urlStatus, this.platform, this.sharedPollingInterval, // Set the polling interval to 60 sec or from config value this.httpsAgentManager); // Subscribe to data updates sharedPollingInstance.on('dataUpdated', (data) => { this.isReachable = true; // ✅ Mark as reachable this.updateSensorStatusFromSharedData(data); }); sharedPollingInstance.on('dataError', () => { this.isReachable = false; // ❌ Mark as unreachable }); } else if (this.urlStatus) { this.getSensorState(); setInterval(this.getSensorState.bind(this), this.updateInterval); } if (this.urlStatus || this.mqttBroker) { // Set accessory information this.accessory.getService(this.platform.Service.AccessoryInformation) .setCharacteristic(this.platform.Characteristic.Manufacturer, this.deviceManufacturer) .setCharacteristic(this.platform.Characteristic.Model, this.deviceModel) .setCharacteristic(this.platform.Characteristic.FirmwareRevision, this.deviceFirmwareVersion) .setCharacteristic(this.platform.Characteristic.SerialNumber, this.deviceSerialNumber); // Define a mapping between device types and their corresponding services const serviceMappings = { OccupancySensor: this.platform.Service.OccupancySensor, Battery: this.platform.Service.Battery, SmokeSensor: this.platform.Service.SmokeSensor, CarbonDioxideSensor: this.platform.Service.CarbonDioxideSensor, AirQualitySensor: this.platform.Service.AirQualitySensor, MotionSensor: this.platform.Service.MotionSensor, ContactSensor: this.platform.Service.ContactSensor, LightSensor: this.platform.Service.LightSensor, // Add more sensors as needed }; const serviceConstructor = serviceMappings[this.deviceType]; if (!serviceConstructor) { throw new Error(`Unsupported device type: ${this.deviceType}`); } // Dynamically create or retrieve the service instance this.sensorService = this.accessory.getService(serviceConstructor) || this.accessory.addService(new serviceConstructor(this.deviceName, this.deviceSerialNumber)); // Set the service name, this is what is displayed as the default name on the Home app this.sensorService.setCharacteristic(this.platform.Characteristic.Name, accessory.context.device.deviceName); if (this.urlStatus) { this.getStateDefinition().forEach(({ state, param }) => { if (param) { const characteristic = this.platform.Characteristic[state]; this.sensorService .getCharacteristic(characteristic) .on('get', this.wrapGetHandler(state)); } }); } // We can now use MQTT if (this.mqttBroker) { this.initMQTT(); } } } /** * Wraps the get handler for a characteristic to handle device state retrieval. * @param state The state key to retrieve. * @returns A function that handles the get request. */ wrapGetHandler(state) { return (callback) => { if (!this.isReachable) { callback(new this.platform.api.hap.HapStatusError(-70402 /* this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */)); return; } callback(null, this.SensorStates[state]); }; } // Silly function :) getStatus(isOn) { return isOn ? 'ON' : 'OFF'; } getStateDefinition() { const config = sensorConfig[this.deviceType]?.states; if (!config) { this.platform.log.warn(`${this.deviceName}: No configuration found for device type: ${this.deviceType}`); } return Object.entries(config).map(([state, stateConfig]) => ({ state, param: this.paramNames[state], topic: this.mqttTopics[state], webhook: stateConfig.webhook, })); } updateSensorStatusFromSharedData(data) { this.processSensorState(data, true); } async getSensorState() { if (!this.urlStatus) { this.platform.log.warn(`${this.deviceName}: Ignoring request; No status URL defined.`); return; } try { this.isReachable = true; // ✅ Mark as reachable const httpsAgent = this.httpsAgentManager?.getAgent(); // Use centralized HTTPS agent if applicable const response = await axios.get(this.urlStatus, { timeout: 8000, httpsAgent }); const data = response.data; this.processSensorState(data, false); } catch (error) { this.isReachable = false; // ❌ Mark as unreachable // 🔔 Notify HomeKit of communication failure if (this.sensorService) { this.getStateDefinition().forEach(({ state }) => { const characteristic = this.platform.Characteristic[state]; this.sensorService.updateCharacteristic(characteristic, new this.platform.api.hap.HapStatusError(-70402 /* this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */)); }); } const axiosError = error; if (axios.isAxiosError(axiosError)) { this.platform.log.warn(`${this.deviceName}: Axios error while fetching JSON:`, axiosError.message); } else { this.platform.log.warn(`${this.deviceName}: Unknown error occurred while fetching JSON.`); } } } processSensorState(data, isSharedData) { if (!data) { this.platform.log.warn(`${this.deviceName}: No data available for ${isSharedData ? 'shared data update' : 'fetching JSON state'}.`); return; } this.getStateDefinition().forEach(({ state, param, webhook }) => { if (!param) { if (this.enableLogging) { this.platform.log.debug(`${this.deviceName}: Parameter for ${state} is not configured. Skipping.`); } return; } const rawValue = getNestedValue(data, param, 'number'); let value; if (typeof rawValue === 'number') { value = rawValue; } else if (typeof rawValue === 'boolean') { value = rawValue ? 1 : 0; // Convert boolean to number } else { value = undefined; // Treat invalid types as undefined } if (value === undefined) { if (this.enableLogging) { this.platform.log.warn(`${this.deviceName}: Parameter '${param}' not found in JSON for state ${state}.`); } return; } // 🔧 Apply optional transform (safe version) const def = sensorConfig[this.deviceType] && sensorConfig[this.deviceType].sensors && sensorConfig[this.deviceType].sensors[state]; if (def && typeof def.transform === 'function') { value = def.transform(value); } const range = this.SensorStatusRanges[state]; if (Array.isArray(range) && range.length === 2 && typeof range[0] === 'number' && typeof range[1] === 'number' && value >= range[0] && value <= range[1]) { if (this.enableLogging && this.SensorStates[state] !== value) { this.platform.log.info(`${this.deviceName}: ${state} SET to: ${value}`); } this.SensorStates[state] = value; const characteristic = this.platform.Characteristic[state]; this.sensorService.updateCharacteristic(characteristic, value); if (webhook && value === 1) { this.initDiscordWebhooks(state); } } else if (this.enableLogging) { this.platform.log.warn(`${this.deviceName}: Received invalid ${state} value: ${value} (valid range: ${range[0]} to ${range[1]}).`); } }); } initMQTT() { // Define an empty array to hold the subscribed topics const mqttSubscribedTopics = []; // Prepare MQTT connection options const mqttOptions = { keepalive: 10, protocol: 'mqtt', host: this.mqttBroker, port: Number(this.mqttPort), clientId: this.deviceName, clean: true, username: this.mqttUsername, password: this.mqttPassword, rejectUnauthorized: false, reconnectPeriod: Number(this.mqttReconnectInterval) * 1000, }; // Use getStateDefinition to dynamically fetch topics and push them into the array this.getStateDefinition().forEach(({ topic }) => { if (typeof topic === 'string' && topic.trim().length > 0) { mqttSubscribedTopics.push(topic); // Push valid topic } }); // Initialize MQTT client this.mqttClient = mqtt.connect(mqttOptions); this.mqttClient.on('connect', () => { this.isReachable = true; // ✅ Mark as reachable if (this.enableLogging) { this.platform.log.info(this.deviceName, ': MQTT Connected'); } this.mqttClient.subscribe(mqttSubscribedTopics, (err) => { if (!err) { if (this.enableLogging) { this.platform.log.info(this.deviceName, ': Subscribed to: ', mqttSubscribedTopics.toString()); } } else { // Need to insert error handler this.platform.log.warn(this.deviceName, ': Subscribing problem: ', err.toString()); } }); }); // Handle incoming MQTT messages this.mqttClient.on('message', (topic, message) => { this.getStateDefinition().forEach(({ state, topic: stateTopic, webhook }) => { if (stateTopic === topic) { // Match incoming topic const value = message.toString(); let newValue; // Handle binary and numeric ranges dynamically const [min, max] = this.SensorStatusRanges[state]; if (min === 0 && max === 1) { const normalizedValue = value.trim().toLowerCase(); newValue = ['1', 'true'].includes(normalizedValue) ? 1 : 0; } else { newValue = Number(value); // Numeric range } // 🔧 Apply optional transform (safe version for initMQTT) let def; if (sensorConfig[this.deviceType] && sensorConfig[this.deviceType].sensors && sensorConfig[this.deviceType].sensors[state]) { def = sensorConfig[this.deviceType].sensors[state]; } if (def && typeof def.transform === 'function') { newValue = def.transform(newValue); } // Validate against SensorStatusRanges if (newValue >= min && newValue <= max) { this.SensorStates[state] = newValue; // Update state value if (this.enableLogging) { this.platform.log.info(`${this.deviceName}: ${state} set to: ${newValue}`); } // Update Homebridge characteristic const characteristic = this.platform.Characteristic[state]; this.sensorService.updateCharacteristic(characteristic, newValue); this.isReachable = true; // Trigger webhook if `webhook` is true and `newValue === 1` if (webhook && newValue === 1) { this.initDiscordWebhooks(state); } } else { if (this.enableLogging) { this.platform.log.warn(`${this.deviceName}: Invalid value for ${state}: ${newValue} (must be between ${min} and ${max})`); } } } }); }); // Additional event handlers for connection state this.mqttClient.on('offline', () => { this.isReachable = false; // ❌ Mark as unreachable this.platform.log.debug(this.deviceName, ': Client is offline'); }); this.mqttClient.on('reconnect', () => { this.platform.log.debug(this.deviceName, ': Reconnecting...'); }); this.mqttClient.on('close', () => { this.isReachable = false; // ❌ Mark as unreachable this.platform.log.debug(this.deviceName, ': Connection closed'); }); // Enhanced error handling this.mqttClient.on('error', (err) => { this.isReachable = false; this.platform.log.warn(this.deviceName, ': Connection error:', err); this.platform.log.warn(this.deviceName, ': Reconnecting in: ', this.mqttReconnectInterval, ' seconds.'); }); } initDiscordWebhooks(state) { // Check if WebHook URL is configured if (!this.discordWebhook) { return; } // Prepare a dynamic message including the passed state const message = `${this.deviceName}: ${state} - ${this.discordMessage} ${this.getStatus(!!this.SensorStates[state])}`; const discord = new discordWebHooks(this.discordWebhook, this.discordUsername, this.discordAvatar, message); discord.discordSimpleSend().then((result) => { if (this.enableLogging) { this.platform.log.info(`${this.deviceName}: Webhook sent successfully - `, result); } }).catch((error) => { if (this.enableLogging) { this.platform.log.warn(`${this.deviceName}: Failed to send webhook - `, error.message); } }); } } //# sourceMappingURL=platformSensorGenericServices.js.map