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
558 lines • 27.7 kB
JavaScript
import { SharedPolling } from './lib/SharedPolling.js'; // Include shared polling library
import { getNestedValue } from './lib/utilities.js'; // Include utility function for nested value retrieval
import { discordWebHooks } from './lib/discordWebHooks.js'; // Include Discord webhook library
import { deviceConfig } from './platformGenericDeviceSettings.js'; // Include device settings
import { HttpsAgentManager } from './lib/HttpsAgentManager.js';
import axios from 'axios';
import mqtt from 'mqtt';
/**
* 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 platformGenericDevice {
platform;
accessory;
service;
mqttClient;
sharedPollingInstance;
isReachable = true; // Track if the device is reachable
// Device and configuration properties
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 = 5000;
deviceId = '';
deviceType = '';
deviceName = '';
deviceManufacturer = '';
deviceModel = '';
deviceSerialNumber = '';
deviceFirmwareVersion = '';
urlStatus = '';
urlDeviceControl = '';
methodUpdate = false;
mqttReconnectInterval = '';
mqttBroker = '';
mqttPort = '';
mqttUsername = '';
mqttPassword = '';
discordWebhook = '';
discordUsername = '';
discordAvatar = '';
discordMessage = '';
paramNames = {};
mqttTopics = {};
DeviceStates = {};
DeviceStatusRanges = {};
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 || 'Fan';
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.urlDeviceControl = device.urlDeviceControl;
this.methodUpdate = device.methodUpdate;
this.mqttReconnectInterval = device.mqttReconnectInterval || 60; // 60 sec default
this.mqttBroker = device.mqttBroker;
this.mqttPort = device.mqttPort;
this.mqttUsername = device.mqttUsername;
this.mqttPassword = device.mqttPassword;
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;
// 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 = deviceConfig[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 DeviceStates and DeviceStatusRanges dynamically
Object.entries(config.sensors).forEach(([sensorKey, deviceConfig]) => {
this.DeviceStates[sensorKey] = deviceConfig.defaultValue;
this.DeviceStatusRanges[sensorKey] = deviceConfig.range;
});
// ---------------------------------------------------------------------------------
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 ?? 5000; // Set the polling interval to 5 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 5 sec or from config value
this.httpsAgentManager);
// Subscribe to data updates
sharedPollingInstance.on('dataUpdated', (data) => {
this.isReachable = true; // ✅ Mark as reachable
this.updateDeviceStatusFromSharedData(data);
});
sharedPollingInstance.on('dataError', () => {
this.isReachable = false; // ❌ Mark as unreachable
});
}
else if (this.urlStatus) {
this.getDeviceState();
setInterval(this.getDeviceState.bind(this), 5000);
}
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 = {
Fan: this.platform.Service.Fanv2,
DoorOpener: this.platform.Service.Door,
GarageDoorOpener: this.platform.Service.GarageDoorOpener,
Window: this.platform.Service.Window,
WindowCovering: this.platform.Service.WindowCovering,
Valve: this.platform.Service.Valve,
// 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.service = 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.service.setCharacteristic(this.platform.Characteristic.Name, accessory.context.device.deviceName);
if (this.urlDeviceControl || this.urlStatus) {
// Register handlers for the characteristics
this.getStateDefinition().forEach(({ state, param, setHandler }) => {
const characteristic = this.platform.Characteristic[state];
if (param) { // Ensure the parameter is valid
if (setHandler) {
this.service.getCharacteristic(characteristic)
.on('set', this.wrapSetHandler(state));
}
this.service
.getCharacteristic(characteristic)
.on('get', this.wrapGetHandler(state));
}
});
}
// We can now use MQTT
// Left to a user to decide if they want to use MQTT or not also to run in parallel with HTTP - not recommended
if (this.mqttBroker) {
this.initMQTT();
this.getStateDefinition().forEach(({ state, topic, setHandler }) => {
if (topic && setHandler) { // Ensure topic is valid and setHandler is enabled
const characteristic = this.platform.Characteristic[state];
this.service.getCharacteristic(characteristic)
.on('set', (value, callback) => {
// Dynamically bind the publishMQTTmessage for each state
this.publishMQTTmessage(state, value, callback);
});
}
});
}
}
}
/**
* 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.DeviceStates[state]);
};
}
/**
* Wraps the set handler for a characteristic to handle device state updates.
* @param state The state key to update.
* @returns A function that handles the set request.
*/
wrapSetHandler(state) {
return (value, callback) => {
if (!this.isReachable) {
callback(new this.platform.api.hap.HapStatusError(-70402 /* this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */));
return;
}
this.setDeviceState(state, value, callback);
};
}
getStateDefinition() {
const config = deviceConfig[this.deviceType]?.states;
if (!config) {
this.platform.log.warn(`${this.deviceName}: No configuration found for device type: ${this.deviceType}`);
return [];
}
return Object.entries(config).map(([state, stateConfig]) => ({
state,
param: this.paramNames[state],
topic: this.mqttTopics[state],
webhook: stateConfig.webhook,
setHandler: stateConfig.setHandler,
fromConfig: stateConfig.fromConfig,
}));
}
// Silly function :)
getStatus(isOn) {
return isOn ? 'ON' : 'OFF';
}
updateDeviceStatusFromSharedData(data) {
this.processGetDeviceStatusData(data, true);
}
async getDeviceState() {
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 HTTPS agent if applicable
const response = await axios.get(this.urlStatus, { timeout: 8000, httpsAgent });
const data = response.data;
// this.platform.log.debug(`${this.deviceName}: Fetched JSON data:`, data);
this.processGetDeviceStatusData(data, false);
}
catch (error) {
this.isReachable = false; // ❌ Mark as unreachable
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.`);
}
}
}
processGetDeviceStatusData(data, isSharedData) {
if (!data) {
this.platform.log.warn(`${this.deviceName}: No data available for ${isSharedData ? 'shared data update' : 'fetching device state'}.`);
return;
}
this.getStateDefinition().forEach(({ state, param, webhook, fromConfig }) => {
if (!param) {
if (this.enableLogging) {
this.platform.log.debug(`${this.deviceName}: Parameter for ${state} is not configured. Skipping.`);
}
return;
}
// Skip JSON parsing if value is from config
if (fromConfig) {
const paramKey = 'paramName' + state;
const value = this.accessory.context.device[paramKey];
const range = this.DeviceStatusRanges[state];
if (Array.isArray(range) && range.length === 2 && typeof range[0] === 'number' && typeof range[1] === 'number' &&
value >= range[0] && value <= range[1]) {
const characteristic = this.platform.Characteristic[state];
const currentValue = this.service.getCharacteristic(characteristic).value;
if (currentValue !== value) {
this.service.updateCharacteristic(characteristic, value);
if (this.enableLogging) {
this.platform.log.info(`${this.deviceName}: ${state} (from config) SET to: ${value}`);
}
}
}
else if (this.enableLogging) {
this.platform.log.warn(`${this.deviceName}: Configured value for ${state} is invalid: ${value} (expected range: ${range[0]} to ${range[1]})`);
}
return;
}
// Otherwise, parse from live JSON
const rawValue = getNestedValue(data, param, 'number');
let value;
if (typeof rawValue === 'number') {
value = rawValue;
}
else if (typeof rawValue === 'boolean') {
value = rawValue ? 1 : 0;
}
else {
value = undefined;
}
if (value === undefined) {
if (this.enableLogging) {
this.platform.log.warn(`${this.deviceName}: Parameter '${param}' not found in JSON for state ${state}.`);
}
return;
}
const range = this.DeviceStatusRanges[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.DeviceStates[state] !== value) {
this.platform.log.info(`${this.deviceName}: ${state} SET to: ${value}`);
if (this.discordWebhook && webhook) {
this.initDiscordWebhooks(state);
}
}
this.DeviceStates[state] = value;
const characteristic = this.platform.Characteristic[state];
this.service.updateCharacteristic(characteristic, value);
}
else if (this.enableLogging) {
this.platform.log.warn(`${this.deviceName}: Received invalid ${state} value: ${value} (valid range: ${range[0]} to ${range[1]}).`);
}
});
}
async setDeviceState(what, value, callback) {
const previousValue = this.DeviceStates[what]; // Save the current state value
this.DeviceStates[what] = value; // Update the state dynamically
try {
this.isReachable = true; // ✅ Mark as reachable
const url = this.urlDeviceControl; // Base URL for fan control
if (!url) {
this.platform.log.warn(this.deviceName, ': No Fan control URL defined.');
//callback(new Error('No Fan control URL defined.'));
this.DeviceStates[what] = previousValue; // Revert to the previous state
return;
}
const characteristicDefinition = this.getStateDefinition().find((def) => def.state === what);
if (!characteristicDefinition) {
this.platform.log.warn(this.deviceName, `: Unknown fan state: ${what}`);
//callback(null);
this.DeviceStates[what] = previousValue; // Revert to the previous state
return;
}
const { state, param, topic } = characteristicDefinition;
if (!param) {
this.platform.log.warn(this.deviceName, `: Ignoring request; No parameter defined for ${state}.`);
//callback(new Error(`No parameter defined for ${state}.`));
this.DeviceStates[what] = previousValue; // Revert to the previous state
return;
}
// Determine HTTP method based on methodUpdate (true = GET, false = POST)
const method = this.methodUpdate ? 'GET' : 'POST';
// Logging the characteristic value change
this.platform.log.debug(this.deviceName, `: Setting ${state} to:`, value);
// Update HomeKit characteristic
const characteristic = this.platform.Characteristic[state];
this.service.updateCharacteristic(characteristic, value);
// Construct URL for GET requests with only the updated value
const modifiedUrl = method === 'GET' ? `${url}?${param}=${encodeURIComponent(value)}` : url;
this.platform.log.debug(this.deviceName, `: Setting ${state} URL:`, modifiedUrl);
// Prepare Axios request options
const axiosOptions = {
method,
url: modifiedUrl, // Use the modified URL for GET requests
headers: {
'Content-Type': 'application/json',
},
...(method === 'POST' && { data: { [param]: this.DeviceStates[state] } }), // Include data only for POST
};
// Make Axios request
await axios(axiosOptions);
// Handle optional MQTT topic publishing
if (topic && this.mqttClient) {
this.mqttClient.publish(topic, String(value), { qos: 1, retain: true }, (err) => {
if (err && this.enableLogging) {
this.platform.log.warn(this.deviceName, `: Failed to publish MQTT message for ${state}:`, err.message);
}
else if (this.enableLogging) {
this.platform.log.debug(this.deviceName, `: MQTT message published for ${state} successfully.`);
}
});
}
// Initialize Discord Webhook if configured
if (this.discordWebhook) {
this.initDiscordWebhooks(state);
}
// Log success and call callback
callback(null);
if (this.enableLogging) {
this.platform.log.info('Success: ', this.deviceName, ` is: ${this.getStatus(!!this.DeviceStates[what])}`);
}
}
catch (error) {
this.isReachable = false; // ❌ Mark as unreachable
// Handle errors: Revert state and log the issue
this.DeviceStates[what] = previousValue; // Revert to the previous state
if (error instanceof Error) {
this.platform.log.warn(this.deviceName, `: Axios error for ${what}:`, error.message);
}
else {
this.platform.log.warn(this.deviceName, `: An unknown error occurred while setting ${what}.`);
}
callback(error); // Notify failure
}
}
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;
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, err.toString());
}
});
});
this.mqttClient.on('message', (topic, message) => {
const matched = this.getStateDefinition().find(({ topic: stateTopic }) => stateTopic === topic);
if (!matched) {
if (this.enableLogging) {
this.platform.log.warn(`${this.deviceName}: Received MQTT message for unknown topic: ${topic}`);
}
return;
}
const { state } = matched;
const value = message.toString();
const [min, max] = this.DeviceStatusRanges[state];
let newValue;
// Handle binary and numeric ranges dynamically
if (min === 0 && max === 1) {
const normalizedValue = value.trim().toLowerCase();
newValue = ['1', 'true'].includes(normalizedValue) ? 1 : 0;
}
else {
newValue = Number(value);
}
// Validate and update
if (newValue >= min && newValue <= max) {
if (this.DeviceStates[state] !== newValue) {
this.DeviceStates[state] = newValue;
const characteristic = this.platform.Characteristic[state];
this.service.updateCharacteristic(characteristic, newValue);
if (this.enableLogging) {
this.platform.log.info(`${this.deviceName}: ${state} set to: ${newValue}`);
}
}
// Mark device as reachable on valid message
this.isReachable = true;
}
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;
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;
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.');
});
}
publishMQTTmessage(what, value, callback) {
const definition = this.getStateDefinition().find(({ state }) => state === what);
if (!definition || !definition.topic) {
// Log warning if no matching definition or topic is found
this.platform.log.warn(`${this.deviceName}: No valid topic for state: ${what}`);
callback(new Error(`No valid topic for state: ${what}`));
return;
}
const topic = definition.topic; // Use topic from definition
const message = String(value); // Convert value to a string for publishing
if (this.enableLogging) {
this.platform.log.info(`${this.deviceName}: Publishing ${what} to topic ${topic} with value: ${message}`);
}
// Publish MQTT message
this.mqttClient.publish(topic, message, { qos: 1, retain: true }, (err) => {
if (err) {
this.platform.log.warn(`${this.deviceName}: Failed to publish message for ${what}:`, err);
}
else {
this.platform.log.debug(`${this.deviceName}: Message for ${what} published successfully`);
}
});
// Callback to indicate success
callback(null);
}
initDiscordWebhooks(state) {
// Prepare a dynamic message including the passed state
const message = `${this.deviceName}: ${state} - ${this.discordMessage} ${this.getStatus(!!this.DeviceStates[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=platformGenericDeviceServices.js.map