homebridge-th10-switch
Version:
HomeBridge Plugin to create a HomeKit WiFi switch using a Sonoff TH10 or TH16 Smart WiFi Switch running Tasmota firmware and with an attached DS18B20 temperature sensor.
356 lines (315 loc) • 15 kB
JavaScript
// TH10/TH16 WiFi Switch Plugin
// Copyright (c) James Pearce, 2020
// Last updated March 2021
//
// Version 1:
// - Creates a power outlet device, with embedded contact sensor (for temperature alerts, high or low, and
// embedded temperature sensor.
// - Note: Supports a single attached DS18B20 sesnor currently. These devices follow a one-wire protocol so it may
// be possible to connect two sensors, e.g. in a Fridge Freezer type appliance, to monitor both cavities in future.
//
// Version 1.01:
// - Extended temperature range from -99 to +99*C
//
// Version 1.02:
// - Corrected bitwise and, which caused error flags to cycle
//
// Version 1.03:
// - Fixed pollTimer config
// - Corrected return from alert logic
//
// globals and imports
var request = require('request');
// HomeKit API registration
module.exports = (api) => {
api.registerAccessory('TH10Switch', TH10Switch);
}
class TH10Switch {
constructor(log, config, api) {
this.log = log;
this.config = config;
this.api = api;
this.Service = this.api.hap.Service;
this.Characteristic = this.api.hap.Characteristic;
this.name = config.name || 'My Appliance';
this.th10IpAddress = config.th10IpAddress;
this.th10StatusLocation = config.th10StatusLocation || '/cm?cmnd=status%208';
this.th10OutletStatusLocation = config.th10OutletStatusLocation || '/cm?cmnd=power';
this.th10OnLocation = config.th10OnLocation || '/cm?cmnd=power%20on';
this.th10OffLocation = config.th10OffLocation || '/cm?cmnd=power%20on';
this.pollTimer = config.pollInterval || 60; //default poll interval = 60 seconds
this.alertCount = config.alertCount || 0; // number of consecutive alerts recorded before raising HomeKit alert status
this.alertLowTemperature = config.alertLowTemperature || -5;
this.alertHighTemperature = config.alertHighTemperature || 80;
this.hysteresis = config.hysteresis || 3;
this.state = {
contactSensorState: 0,
temperature: 0,
outlet1On: 0,
outlet1InUse: 0,
outlet1Locked: 0,
alerts: 0,
highalerts: 0,
lowalerts: 0
};
// Create the services
this.outlet1Service = new this.Service.Outlet(this.outletName,"Outlet1"); // controls the relay
this.contactSensor = new this.Service.ContactSensor(this.name); // reports open/close according to registered alert state
this.temperatureService = new this.Service.TemperatureSensor(); // reports the measured temperature within the appliance
// create an information service...
this.informationService = new this.Service.AccessoryInformation()
.setCharacteristic(this.Characteristic.Manufacturer, "James Pearce")
.setCharacteristic(this.Characteristic.Model, "Sonoff TH10/TH16 WiFi Switch")
.setCharacteristic(this.Characteristic.SerialNumber, "N/App");
this.outlet1Service
.getCharacteristic(this.Characteristic.On)
.on('get', this.getOutlet1State.bind(this))
.on('set', this.setOutlet1State.bind(this));
this.outlet1Service
.getCharacteristic(this.Characteristic.OutletInUse)
.on('get', this.getOutlet1InUse.bind(this));
this.contactSensor
.setCharacteristic(this.Characteristic.Name, this.name)
.getCharacteristic(this.Characteristic.ContactSensorState)
.on('get', this.getContactState.bind(this));
this.temperatureService
.setCharacteristic(this.Characteristic.Name, "Current Temperature")
.getCharacteristic(this.Characteristic.CurrentTemperature)
.on('get', this.getTemperature.bind(this));
} // constructor
// mandatory getServices function tells HomeBridge how to use this object
getServices() {
var accessory = this;
var Characteristic = this.Characteristic;
var command;
accessory.log.debug(accessory.name + ': Invoked getServices');
// Initialise the plugin ahead of any function call with static configured IP address
// we need to update HomeKit that this device can report temperatures below zero degrees:
this.temperatureService.getCharacteristic(Characteristic.CurrentTemperature).props.minValue = -99;
this.temperatureService.getCharacteristic(Characteristic.CurrentTemperature).props.maxValue = 99;
// ... and collect the data from the device...
accessory.pollTH10State();
// and retrun the services to HomeBridge
return [
accessory.informationService,
accessory.outlet1Service,
accessory.contactSensor,
accessory.temperatureService,
];
} // getServices()/
getOutlet1State(callback) {
var accessory = this;
accessory.log.debug('Outlet (', accessory.outlet1Name, ') State: ', accessory.state.outlet1On);
callback(null, accessory.state.outlet1On);
}
setOutlet1State(on, callback) {
var accessory = this;
var Characteristic = this.Characteristic;
accessory.log.debug('setOutlet1State: ', on);
accessory.pollState(); // (re)start polling timer
if (on) {
accessory.log('Outlet 1 Power on requested');
} else {
accessory.log('Outlet 1 Power off requested');
}
if (accessory.outlet1Locked) {
accessory.log('Error: Outlet 1 is locked. Command not sent to device.');
if (callback) callback(new Error('Error: Outlet 1 is locked (' + accessory.name + ')'), accessory.state.outlet1On);
} else {
var URI = "http://" + accessory.th10IpAddress;
if (on) {
URI = URI + accessory.th10OnLocation;
} else {
URI = URI + accessory.th10OffLocation;
}
accessory.log.debug("Calling: " + URI);
try {
request(URI, function(error, response, body) {
accessory.log.debug(body);
if (!error) {
try {
var sonoff_reply = JSON.parse(body);
try {
accessory.log.debug(sonoff_reply);
var outletState = sonoff_reply.POWER;
if ((outletState == "ON") && (on)) {
accessory.state.outlet1On = 1;
accessory.state.outlet1InUse = 1;
} else if ((outletState = "OFF") && (!on)) {
accessory.state.outlet1On = 0;
accessory.state.outlet1InUse = 0;
}
// update the object characteristics with the change
accessory.log.debug('setOutlet1State command completed without error.');
accessory.outlet1Service.updateCharacteristic(Characteristic.OutletInUse, accessory.state.outlet1InUse);
} catch(err) {
accessory.log('Device did not return expected status ("POWER":"ON" or "POWER":"OFF")');
accessory.log(err);
}
} catch(err) {
accessory.log('Device did not return valid JSON (' + sonoff_reply + ')');
accessory.log(err);
}
} // if (!error)
} ); // request
} catch(err) {
accessory.log('Error communicating with device');
accessory.log(err);
}
if (callback) {
callback(null, accessory.state.outlet1On);
}
} // if accessory.outlet1Locked
} // setOutlet1State
getOutlet1InUse(callback) {
var accessory = this;
accessory.log.debug('Outlet 1 (', accessory.outlet1Name, ') In Use: ' + accessory.state.outlet1InUse);
callback(null, accessory.state.outlet1InUse);
}
getTemperature(callback) {
var accessory = this;
accessory.log.debug('Current temperature: ', accessory.state.temperature);
callback(null, (accessory.state.temperature));
}
getContactState(callback) {
var accessory = this;
accessory.log.debug('Contact State (=Temperature Alert flag): ', accessory.state.contactSensorState);
callback(null, accessory.state.contactSensorState);
}
pollTH10State(callback) {
// Background status polling function.
var accessory = this;
var Characteristic = this.Characteristic;
var URI = "http://" + accessory.th10IpAddress + accessory.th10StatusLocation;
accessory.log.debug("pollState: Retrieving current device state from " + URI);
try {
request(URI, function(error, response, body) {
accessory.log.debug(body);
if (!error) {
try {
var sonoff_reply = JSON.parse(body);
try {
accessory.log.debug(sonoff_reply);
var temperature = parseFloat(sonoff_reply.StatusSNS.DS18B20.Temperature);
accessory.state.temperature = temperature;
// process high temperature alarm states
if ((accessory.state.contactSensorState == 0) && (temperature >= accessory.alertHighTemperature)) {
accessory.log("WARNING: High Temperature Alert Threshold " + accessory.alertHighTemperature + "*C exceeded.");
accessory.state.highalerts += 1;
if (accessory.state.highalerts == accessory.alertCount) {
// consecutive alert count reached: raise alarm in HomeKit
accessory.log("WARNING: Alert count exceeded for high temperature events; raising alarm in HomeKit");
}
} else if ((accessory.state.contactSensorState == 1) && (accessory.state.highalerts > 0) &&
(temperature <= (accessory.alertHighTemperature - accessory.hysteresis))) {
accessory.log("INFORMATION: Previous high temperature alert condition cleared, reported temperature is " + temperature + "*C.");
accessory.state.highalerts = 0;
} else if ((accessory.state.contactSensorState == 0) && (accessory.state.highalerts > 0)) {
// something else happened but we didn't log an alert state so clear the counter
accessory.log.debug("INFORMATION: Temperature reported withn normal range, clearning high temperature alert count");
accessory.state.alerts = 0;
}
// process low temperature alarm states
if ((accessory.state.contactSensorState == 0) && (temperature <= accessory.alertLowTemperature)) {
accessory.log("WARNING: Low Temperature Alert Threshold " + accessory.alertLowTemperature + "*C passed.");
accessory.state.lowalerts += 1;
if (accessory.state.lowalerts == accessory.alertCount) {
// consecutive alert count reached: raise alarm in HomeKit
accessory.log("WARNING: Alert count exceeded for low temperature events; raising alarm in HomeKit");
}
} else if ((accessory.state.contactSensorState == 1) && (accessory.state.lowalerts > 0) &&
(temperature >= (accessory.alertLowTemperature + accessory.hysteresis))) {
accessory.log("INFORMATION: Previous low temperature alert condition cleared, reported temperature is " + temperature + "*C.");
accessory.state.lowalerts = 0;
} else if ((accessory.state.contactSensorState == 0) && (accessory.state.lowalerts > 0)) {
// something else happened by we didn't log an alert state so clear the counter
accessory.log.debug("INFORMATION: Temperature reported withn normal range, clearning low temperature alert count");
accessory.state.lowalerts = 0;
}
if ((accessory.state.highalerts >= accessory.alertCount) || (accessory.state.lowalerts >= accessory.alertCount)) {
accessory.state.contactSensorState = 1; // raise alert flag
} else {
accessory.state.contactSensorState = 0; // clear alert flag
}
accessory.log.debug("pollTH10State: Updating accessory state...");
accessory.contactSensor.updateCharacteristic(Characteristic.ContactSensorState, accessory.state.contactSensorState);
accessory.temperatureService.updateCharacteristic(Characteristic.CurrentTemperature, accessory.state.temperature);
} catch(err) {
accessory.log('Could not convert data to number (' + sonoff_reply.StatusSNS.DS18B20.Temperature + ')');
accessory.log(err);
}
} catch(err) {
accessory.log('Invalid json received from device collecting temperature data:' + body);
accessory.log(err);
}
}
});
} catch (err) {
accessory.log(accessory.name+": Did not receive a valid response from device collecting temperature data: " + err.message);
accessory.log(err);
}
URI = "http://" + accessory.th10IpAddress + accessory.th10OutletStatusLocation;
accessory.log.debug("pollState: Retrieving current device outlet state from " + URI);
try {
request(URI, function(error, response, body) {
accessory.log.debug(body);
if (!error) {
try {
var sonoff_reply = JSON.parse(body);
try {
accessory.log.debug(sonoff_reply);
var outletState = sonoff_reply.POWER;
if (outletState == "ON") {
accessory.state.outlet1On = 1;
accessory.state.outlet1InUse = 1;
} else if (outletState = "OFF") {
accessory.state.outlet1On = 0;
accessory.state.outlet1InUse = 0;
}
// update the object characteristics with the change
accessory.log.debug("pollUpsState: Updating outlet states...");
accessory.outlet1Service.updateCharacteristic(Characteristic.On, accessory.state.outlet1On);
accessory.outlet1Service.updateCharacteristic(Characteristic.OutletInUse, accessory.state.outlet1InUse);
} catch(err) {
accessory.log('Device did not return expected status ("POWER":"ON" or "POWER":"OFF")');
accessory.log(err);
}
} catch(err) {
accessory.log('Invalid json received from device collecting temperature data:' + body);
accessory.log(err);
}
} // if
});
} catch(err) {
accessory.log('Error communicating with device');
accessory.log(err);
}
if (callback) {
callback(null, accessory.state.outlet1On);
}
accessory.pollState(); // (re)start polling timer
} // pollTH10State
/**
* Polling function
*/
pollState = function() {
var accessory = this;
var Characteristic = this.Characteristic;
// Clear any existing timer
if (accessory.stateTimer) {
clearTimeout(accessory.stateTimer);
accessory.stateTimer = null;
}
// define the new poll function
accessory.stateTimer = setTimeout(
function() {
accessory.pollTH10State(function(err, CurrentDeviceState) {
if (err) {
accessory.log(err);
return;
}
})
}, accessory.pollTimer * 1000
);
} // pollState
} // class FreezerAlarm