From 1fb2fb2ea5319155703c49a24703cf40fbcae55e Mon Sep 17 00:00:00 2001 From: markh Date: Fri, 14 Jul 2023 11:21:00 +1000 Subject: [PATCH] v0.1.2 uploads Align to docker image source --- Dockerfile | 3 +- HomeKitDevice.js | 229 +++ HomeKitHistory.js | 1684 ++++++++++------ Nest_accfactory.js | 3924 ++++++++++++++++++------------------ Nest_camera_connecting.jpg | Bin 74443 -> 61133 bytes Nest_camera_off.jpg | Bin 68048 -> 67148 bytes Nest_camera_offline.jpg | Bin 80903 -> 80706 bytes nexusstreamer.js | 531 ++--- package.json | 2 +- 9 files changed, 3534 insertions(+), 2839 deletions(-) create mode 100644 HomeKitDevice.js diff --git a/Dockerfile b/Dockerfile index b4c83b7..4bbb183 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ COPY package.json ./ COPY Nest_accfactory.js ./ COPY Nest_camera_*.jpg ./ COPY HomeKitHistory.js ./ +COPY HomeKitDevice.js ./ COPY nexusstreamer.js ./ # perform installation based on details in package.json @@ -27,4 +28,4 @@ LABEL org.opencontainers.image.title="Nest_accfactory" LABEL org.opencontainers.image.description="HomeKit integration for Nest devices based on HAP-NodeJS library" LABEL org.opencontainers.image.url="https://github.com/n0rt0nthec4t/Nest_accfactory" LABEL org.opencontainers.image.authors="n0rt0nthec4t@outlook.com" -LABEL org.opencontainers.image.version="v0.1.1" \ No newline at end of file +LABEL org.opencontainers.image.version="v0.1.2" \ No newline at end of file diff --git a/HomeKitDevice.js b/HomeKitDevice.js new file mode 100644 index 0000000..83b3705 --- /dev/null +++ b/HomeKitDevice.js @@ -0,0 +1,229 @@ +// HomeKitDevice class +// +// This is the base class for all HomeKit accessories we code to +// +// The deviceData structure should, at a minimum contain the following elements. These also need to be a "string" type +// mac_address +// serial_number +// software_version +// description +// location +// manufacturer +// model +// +// Following constants should be overridden in the module loading this class file +// +// HomeKitDevice.HOMEKITHISTORY - HomeKit History module +// +// Code version 13/7/2023 +// Mark Hulskamp + +"use strict"; + +// Define HAP-NodeJS requirements +var HAP = require("hap-nodejs"); + +// Define nodejs module requirements +var util = require("util"); + +class HomeKitDevice { + constructor(HomeKitAccessoryName, HomeKitPairingCode, HomeKitMDNSAdvertiser, uniqueUUIDForDevice, currentDeviceData, globalEventEmitter) { + this.eventEmitter = null; // Event emitter to use. Allow comms from other objects + + this.deviceUUID = uniqueUUIDForDevice; // Unique UUID for this device. Used for event messaging to this device4 + this.deviceData = currentDeviceData; // Current data for the device + + this.HomeKitAccessory = null; // HomeKit Accessory object + this.HomeKitManufacturerName = HomeKitAccessoryName; // HomeKit device manufacturer name. Used for logging output prefix also + this.HomeKitHistory = null; // History logging service + this.HomeKitPairingCode = HomeKitPairingCode; // HomeKit pairing code + + this.mDNSAdvertiser = HomeKitMDNSAdvertiser; // MDNS Provider to use for this device + + // Validate if globalEventEmitter object passed to us is an instance of EventEmitter + if (globalEventEmitter instanceof require("events").EventEmitter == true) { + this.eventEmitter = globalEventEmitter; // Store + + // Setup event listener to process "messages" to/from our device + this.eventEmitter.addListener(this.deviceUUID, this.#message.bind(this)); + } + } + + // Class functions + add(mDNSAdvertiseName, HomeKitAccessoryCategory, useHistoryService) { + if (typeof this.deviceData != "object" || typeof HAP.Accessory.Categories[HomeKitAccessoryCategory] == "undefined" || typeof mDNSAdvertiseName != "string" || typeof useHistoryService != "boolean" || + (this.deviceData.hasOwnProperty("mac_address") == false && typeof this.deviceData.mac_address != "string" && this.deviceData.mac_address == "") || + (this.deviceData.hasOwnProperty("serial_number") == false && typeof this.deviceData.serial_number != "string" && this.deviceData.serial_number == "") || + (this.deviceData.hasOwnProperty("software_version") == false && typeof this.deviceData.software_version != "string" && this.deviceData.software_version == "") || + (this.deviceData.hasOwnProperty("description") == false && typeof this.deviceData.description != "string" && this.deviceData.description == "") || + (this.deviceData.hasOwnProperty("location") == false && typeof this.deviceData.location != "string") || + (this.deviceData.hasOwnProperty("model") == false && typeof this.deviceData.model != "string" && this.deviceData.model == "") || + this.HomeKitAccessory != null || + mDNSAdvertiseName == "") { + + return; + } + + this.HomeKitAccessory = exports.accessory = new HAP.Accessory(mDNSAdvertiseName, HAP.uuid.generate("hap-nodejs:accessories:" + this.deviceData.manufacturer.toLowerCase() + "_" + this.deviceData.serial_number)); + this.HomeKitAccessory.username = this.deviceData.mac_address; + this.HomeKitAccessory.pincode = this.HomeKitPairingCode; + this.HomeKitAccessory.category = HomeKitAccessoryCategory; + this.HomeKitAccessory.getService(HAP.Service.AccessoryInformation).updateCharacteristic(HAP.Characteristic.Manufacturer, this.deviceData.manufacturer); + this.HomeKitAccessory.getService(HAP.Service.AccessoryInformation).updateCharacteristic(HAP.Characteristic.Model, this.deviceData.model); + this.HomeKitAccessory.getService(HAP.Service.AccessoryInformation).updateCharacteristic(HAP.Characteristic.SerialNumber, this.deviceData.serial_number); + this.HomeKitAccessory.getService(HAP.Service.AccessoryInformation).updateCharacteristic(HAP.Characteristic.FirmwareRevision, this.deviceData.software_version); + + if (useHistoryService == true && typeof HomeKitDevice.HOMEKITHISTORY != "undefined" && this.HomeKitHistory == null) { + // Setup logging service as requsted + this.HomeKitHistory = new HomeKitDevice.HOMEKITHISTORY(this.HomeKitAccessory, {}); + } + + try { + this.addHomeKitServices((this.deviceData.location == "" ? this.deviceData.description : this.deviceData.description + " - " + this.deviceData.location)); + } catch (error) { + this.#outputLogging("addHomeKitServices call for device '%s' on '%s' failed. Error was", this.HomeKitAccessory.displayName, this.HomeKitAccessory.username, error); + } + + this.update(this.deviceData, true); // perform an initial update using current data + + // Publish accessory on local network and push onto export array for HAP-NodeJS "accessory factory" + this.HomeKitAccessory.publish({username: this.HomeKitAccessory.username, pincode: this.HomeKitAccessory.pincode, category: this.HomeKitAccessory.category, advertiser: this.mDNSAdvertiser}); + this.#outputLogging("Advertising '%s (%s)' as '%s' to local network for HomeKit", (this.deviceData.location == "" ? this.deviceData.description : this.deviceData.description + " - " + this.deviceData.location), this.HomeKitAccessory.username, this.HomeKitAccessory.displayName); + } + + remove() { + this.#outputLogging("Device '%s' on '%s' has been removed", this.HomeKitAccessory.displayName, this.HomeKitAccessory.username); + + if (this.eventEmitter != null) { + // Remove listener for "messages" + this.eventEmitter.removeAllListeners(this.deviceUUID); + } + + try { + this.removeHomeKitServices(); + } catch (error) { + this.#outputLogging("removeHomeKitServices call for device '%s' on '%s' failed. Error was", this.HomeKitAccessory.displayName, this.HomeKitAccessory.username, error); + } + + this.HomeKitAccessory.unpublish(); + this.deviceData = null; + this.HomeKitAccessory = null; + this.eventEmitter = null; + this.HomeKitHistory = null; + + // Do we destroy this object?? + // this = null; + // delete this; + } + + update(updatedDeviceData, forceHomeKitUpdate) { + if (typeof updatedDeviceData != "object" || typeof forceHomeKitUpdate != "boolean") { + return; + } + + // Updated data may only contain selected fields, so we'll handle that here by taking our internally stored data + // and merge with the updates to ensure we have a complete data object + Object.entries(this.deviceData).forEach(([key, value]) => { + if (typeof updatedDeviceData[key] == "undefined") { + // Updated data doesn't have this key, so add it to our internally stored data + updatedDeviceData[key] = value; + } + }); + + // Check to see what data elements have changed + var changedObjectElements = {}; + Object.entries(updatedDeviceData).forEach(([key, value]) => { + if (JSON.stringify(updatedDeviceData[key]) !== JSON.stringify(this.deviceData[key])) { + changedObjectElements[key] = updatedDeviceData[key]; + } + }); + + // If we have any changed data elements OR we've been requested to force an update, do so + if (Object.keys(changedObjectElements).length != 0 || forceHomeKitUpdate == true) { + if (updatedDeviceData.hasOwnProperty("software_version") == true && updatedDeviceData.software_version != this.deviceData.software_version) { + // Update software version + this.HomeKitAccessory.getService(HAP.Service.AccessoryInformation).updateCharacteristic(HAP.Characteristic.FirmwareRevision, updatedDeviceData.software_version); + } + + if (updatedDeviceData.hasOwnProperty("online") == true && updatedDeviceData.online != this.deviceData.online) { + // Update online/offline status + this.#outputLogging("Device '%s' on '%s' is %s", this.HomeKitAccessory.displayName, this.HomeKitAccessory.username, (updatedDeviceData.online == true ? "online" : "offline")); + } + + try { + this.updateHomeKitServices(updatedDeviceData); // Pass updated data on for accessory to process as it needs + } catch (error) { + this.#outputLogging("updateHomeKitServices call for device '%s' on '%s' failed. Error was", this.HomeKitAccessory.displayName, this.HomeKitAccessory.username, error); + } + this.deviceData = updatedDeviceData; // Finally, update our internally stored data about the device + } + } + + set(keyValues) { + if (typeof keyValues != "object" || this.eventEmitter == null) { + return; + } + + // Send event with data to set + this.eventEmitter.emit(HomeKitDevice.SET, this.deviceUUID, keyValues); + } + + get() { + // <---- To Implement + } + + addHomeKitServices(serviceName) { + // <---- override in class which extends this class + } + + removeHomeKitServices() { + // <---- override in class which extends this class + } + + updateHomeKitServices(updatedDeviceData) { + // <---- override in class which extends this class + } + + messageHomeKitServices(messageType, messageData) { + // <---- override in class which extends this class + } + + #message(messageType, messageData) { + switch (messageType) { + case HomeKitDevice.UPDATE : { + this.update(messageData, false); // Got some device data, so process any updates + break; + } + + case HomeKitDevice.REMOVE : { + this.remove(); // Got message for device removal + break + } + + default : { + // This is not a message we know about, so pass onto accessory for it to perform any processing + try { + this.messageHomeKitServices(messageType, messageData); + } catch (error) { + this.#outputLogging("messageHomeKitServices call for device '%s' on '%s' failed. Error was", this.HomeKitAccessory.displayName, this.HomeKitAccessory.username, error); + } + break; + } + } + } + + #outputLogging(...outputMessage) { + var timeStamp = String(new Date().getFullYear()).padStart(4, "0") + "-" + String(new Date().getMonth() + 1).padStart(2, "0") + "-" + String(new Date().getDate()).padStart(2, "0") + " " + String(new Date().getHours()).padStart(2, "0") + ":" + String(new Date().getMinutes()).padStart(2, "0") + ":" + String(new Date().getSeconds()).padStart(2, "0"); + console.log(timeStamp + " [" + this.HomeKitManufacturerName + "] " + util.format(...outputMessage)); + } +} + +// Export defines for this module +HomeKitDevice.UPDATE = "HomeKitDevice.update"; // Device update message +HomeKitDevice.REMOVE = "HomeKitDevice.remove"; // Device remove message +HomeKitDevice.SET = "HomeKitDevice.set"; // Device set property message +HomeKitDevice.GET = "HomeKitDevice.get"; // Device get property message +HomeKitDevice.UNPUBLISH = "HomeKitDevice.unpublish"; // Device unpublish message +HomeKitDevice.HOMEKITHISTORY = undefined; // HomeKit History module +module.exports = HomeKitDevice; + diff --git a/HomeKitHistory.js b/HomeKitHistory.js index 9a7a0df..ee06f11 100644 --- a/HomeKitHistory.js +++ b/HomeKitHistory.js @@ -4,8 +4,9 @@ // todo (EveHome integration) // -- get history to show for motion when attached to a smoke sensor // -- get history to show for smoke when attached to a smoke sensor -// -- thermo schedules/additonal characteris +// -- thermo valve protection // -- Eve Degree/Weather2 history +// -- Eve Water guard history // // done // -- initial support for importing our history into EveHome @@ -21,15 +22,15 @@ // -- Debugging option // -- refactor class definition // -- fix for thermo history target temperatures +// -- thermo schedules +// -- updated history logging for outlet services +// -- inital support for Eve Water Guard // -// Version 28/6/2022 +// Version 3/7/2023 // Mark Hulskamp // Define HAP-NodeJS requirements -var HAPNodeJS = require("hap-nodejs"); -var Service = HAPNodeJS.Service; -var Characteristic = HAPNodeJS.Characteristic; -var HAPStorage = HAPNodeJS.HAPStorage; +var HAP = require("hap-nodejs"); // Define nodejs module requirements var util = require("util"); @@ -40,27 +41,27 @@ const MAX_HISTORY_SIZE = 16384; // 16k entries const EPOCH_OFFSET = 978307200; // Seconds since 1/1/1970 to 1/1/2001 const EVEHOME_MAX_STREAM = 11; // Maximum number of history events we can stream to EveHome at once +const DAYSOFWEEK = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; + // Create the history object class HomeKitHistory { constructor(HomeKitAccessory, optionalParams) { + this.maxEntries = MAX_HISTORY_SIZE; // used for rolling history. if 0, means no rollover + this.location = ""; + this.debug = false; // No debugging by default if (typeof (optionalParams) === "object") { this.maxEntries = optionalParams.maxEntries || MAX_HISTORY_SIZE; // used for rolling history. if 0, means no rollover this.location = optionalParams.location || ""; this.debug = optionalParams.debug || false; } - else { - this.maxEntries = MAX_HISTORY_SIZE; // used for rolling history. if 0, means no rollover - this.location = ""; - this.debug = false; // No debugging by default - } // Setup HomeKitHistory storage using HAP-NodeJS persist location // can be overridden by passing in location optional parameter this.storageKey = util.format("History.%s.json", HomeKitAccessory.username.replace(/:/g, "").toUpperCase()); - this.storage = HAPStorage.storage(); // Load storage from HAP-NodeJS. We'll use it's persist folder for storing history files + this.storage = HAP.HAPStorage.storage(); // Load storage from HAP-NodeJS. We'll use it's persist folder for storing history files this.historyData = this.storage.getItem(this.storageKey); if (typeof this.historyData != "object") { // Getting storage key didnt return an object, we'll assume no history present, so start new history for this accessory @@ -73,8 +74,6 @@ class HomeKitHistory { if (this.maxEntries != 0 && this.historyData.next >= this.maxEntries) { this.rolloverHistory(); } - - return this; // Return object to our service } @@ -100,28 +99,28 @@ class HomeKitHistory { timegap = 0; // Zero minimum time gap between entries } switch (service.UUID) { - case Service.GarageDoorOpener.UUID : { + case HAP.Service.GarageDoorOpener.UUID : { // Garage door history // entry.time => unix time in seconds // entry.status => 0 = closed, 1 = open historyEntry.status = entry.status; if (typeof entry.restart != "undefined") historyEntry.restart = entry.restart; - this.__addEntry(service.UUID, service.subtype, entry.time, timegap, historyEntry); + this.#addEntry(service.UUID, service.subtype, entry.time, timegap, historyEntry); break; } - case Service.MotionSensor.UUID : { + case HAP.Service.MotionSensor.UUID : { // Motion sensor history // entry.time => unix time in seconds // entry.status => 0 = motion cleared, 1 = motion detected historyEntry.status = entry.status; if (typeof entry.restart != "undefined") historyEntry.restart = entry.restart; - this.__addEntry(service.UUID, service.subtype, entry.time, timegap, historyEntry); + this.#addEntry(service.UUID, service.subtype, entry.time, timegap, historyEntry); break; } - case Service.Window.UUID : - case Service.WindowCovering.UUID : { + case HAP.Service.Window.UUID : + case HAP.Service.WindowCovering.UUID : { // Window and Window Covering history // entry.time => unix time in seconds // entry.status => 0 = closed, 1 = open @@ -129,12 +128,12 @@ class HomeKitHistory { historyEntry.status = entry.status; historyEntry.position = entry.position; if (typeof entry.restart != "undefined") historyEntry.restart = entry.restart; - this.__addEntry(service.UUID, service.subtype, entry.time, timegap, historyEntry); + this.#addEntry(service.UUID, service.subtype, entry.time, timegap, historyEntry); break; } - case Service.HeaterCooler.UUID : - case Service.Thermostat.UUID : { + case HAP.Service.HeaterCooler.UUID : + case HAP.Service.Thermostat.UUID : { // Thermostat and Heater/Cooler history // entry.time => unix time in seconds // entry.status => 0 = off, 1 = fan, 2 = heating, 3 = cooling, 4 = dehumidifying @@ -146,13 +145,13 @@ class HomeKitHistory { historyEntry.target = entry.target; historyEntry.humidity = entry.humidity; if (typeof entry.restart != "undefined") historyEntry.restart = entry.restart; - this.__addEntry(service.UUID, service.subtype, entry.time, timegap, historyEntry); + this.#addEntry(service.UUID, service.subtype, entry.time, timegap, historyEntry); break; } - case Service.EveAirPressureSensor.UUID : - case Service.AirQualitySensor.UUID : - case Service.TemperatureSensor.UUID : { + case HAP.Service.EveAirPressureSensor.UUID : + case HAP.Service.AirQualitySensor.UUID : + case HAP.Service.TemperatureSensor.UUID : { // Temperature sensor history // entry.time => unix time in seconds // entry.temperature => current temperature in degress C @@ -183,11 +182,11 @@ class HomeKitHistory { historyEntry.voc = entry.voc; historyEntry.pressure = entry.pressure; if (typeof entry.restart != "undefined") historyEntry.restart = entry.restart; - this.__addEntry(service.UUID, service.subtype, entry.time, timegap, historyEntry); + this.#addEntry(service.UUID, service.subtype, entry.time, timegap, historyEntry); break; } - case Service.Valve.UUID : { + case HAP.Service.Valve.UUID : { // Water valve history // entry.time => unix time in seconds // entry.status => 0 = valve closed, 1 = valve opened @@ -197,51 +196,63 @@ class HomeKitHistory { historyEntry.water = entry.water; historyEntry.duration = entry.duration; if (typeof entry.restart != "undefined") historyEntry.restart = entry.restart; - this.__addEntry(service.UUID, service.subtype, entry.time, timegap, historyEntry); + this.#addEntry(service.UUID, service.subtype, entry.time, timegap, historyEntry); break; } - case Characteristic.WaterLevel.UUID : { + case HAP.Characteristic.WaterLevel.UUID : { // Water level history // entry.time => unix time in seconds // entry.level => water level as percentage historyEntry.level = entry.level; if (typeof entry.restart != "undefined") historyEntry.restart = entry.restart; - this.__addEntry(service.UUID, 0, entry.time, timegap, historyEntry); // Characteristics dont have sub type, so we'll use 0 for it + this.#addEntry(service.UUID, 0, entry.time, timegap, historyEntry); // Characteristics dont have sub type, so we'll use 0 for it break; } - case Service.Outlet.UUID : { - // Power outlet + case HAP.Service.LeakSensor.UUID : { + // Leak sensor history + // entry.time => unix time in seconds + // entry.status => 0 = no leak, 1 = leak + historyEntry.status = entry.status; + if (typeof entry.restart != "undefined") historyEntry.restart = entry.restart; + this.#addEntry(service.UUID, 0, entry.time, timegap, historyEntry); // Characteristics dont have sub type, so we'll use 0 for it + break; + } + + case HAP.Service.Outlet.UUID : { + // Power outlet history // entry.time => unix time in seconds // entry.status => 0 = off, 1 = on - // entry.volts => current voltage in Vs - // entry.watts => current consumption in W's + // entry.volts => voltage in Vs + // entry.watts => watts in W's + // entry.amps => current in A's historyEntry.status = entry.status; historyEntry.volts = entry.volts; historyEntry.watts = entry.watts; + historyEntry.amps = entry.amps; if (typeof entry.restart != "undefined") historyEntry.restart = entry.restart; - this.__addEntry(service.UUID, service.subtype, entry.time, timegap, historyEntry); + this.#addEntry(service.UUID, service.subtype, entry.time, timegap, historyEntry); break; } - case Service.Doorbell.UUID : { + case HAP.Service.Doorbell.UUID : { // Doorbell press history // entry.time => unix time in seconds // entry.status => 0 = not pressed, 1 = doorbell pressed historyEntry.status = entry.status; if (typeof entry.restart != "undefined") historyEntry.restart = entry.restart; - this.__addEntry(service.UUID, service.subtype, entry.time, timegap, historyEntry); + this.#addEntry(service.UUID, service.subtype, entry.time, timegap, historyEntry); break; } - case Service.SmokeSensor.UUID : { + case HAP.Service.SmokeSensor.UUID : { // Smoke sensor history // entry.time => unix time in seconds // entry.status => 0 = smoke cleared, 1 = smoke detected historyEntry.status = entry.status; if (typeof historyEntry.restart != "undefined") historyEntry.restart = entry.restart; - this.__addEntry(service.UUID, service.subtype, entry.time, timegap, historyEntry); + this.#addEntry(service.UUID, service.subtype, entry.time, timegap, historyEntry); break; } } @@ -265,11 +276,11 @@ class HomeKitHistory { this.historyData.data.splice(this.maxEntries, this.historyData.data.length); this.historyData.rollover = Math.floor(new Date() / 1000); this.historyData.next = 0; - this.__updateHistoryTypes(); + this.#updateHistoryTypes(); this.storage.setItem(this.storageKey, this.historyData); } - __addEntry(type, sub, time, timegap, entry) { + #addEntry(type, sub, time, timegap, entry) { var historyEntry = {}; var recordEntry = true; // always record entry unless we dont need to historyEntry.time = time; @@ -415,7 +426,7 @@ class HomeKitHistory { return tempHistory.length; } - __updateHistoryTypes() { + #updateHistoryTypes() { // Builds the known history types and last entry in current history data // Might be time consuming..... this.historyData.types = []; @@ -445,100 +456,101 @@ class HomeKitHistory { if (typeof this.EveHome == "undefined" || (this.EveHome && this.EveHome.hasOwnProperty("service") == false)) { switch (service.UUID) { - case Service.Door.UUID : - case Service.Window.UUID : - case Service.GarageDoorOpener.UUID : { + case HAP.Service.Door.UUID : + case HAP.Service.Window.UUID : + case HAP.Service.GarageDoorOpener.UUID : { // treat these as EveHome Door but with inverse status for open/closed - var historyService = HomeKitAccessory.addService(Service.EveHomeHistory, "", 1); + var historyService = HomeKitAccessory.addService(HAP.Service.EveHomeHistory, "", 1); var tempHistory = this.getHistory(service.UUID, service.subtype); var historyreftime = (tempHistory.length == 0 ? (this.historyData.reset - EPOCH_OFFSET) : (tempHistory[0].time - EPOCH_OFFSET)); this.EveHome = {service: historyService, linkedservice: service, type: service.UUID, sub: service.subtype, evetype: "door", fields: "0601", entry: 0, count: tempHistory.length, reftime: historyreftime, send: 0}; - service.addCharacteristic(Characteristic.EveLastActivation); - service.addCharacteristic(Characteristic.EveOpenDuration); - service.addCharacteristic(Characteristic.EveClosedDuration); - service.addCharacteristic(Characteristic.EveTimesOpened); + service.addCharacteristic(HAP.Characteristic.EveLastActivation); + service.addCharacteristic(HAP.Characteristic.EveOpenDuration); + service.addCharacteristic(HAP.Characteristic.EveClosedDuration); + service.addCharacteristic(HAP.Characteristic.EveTimesOpened); // Setup initial values and callbacks for charateristics we are using - service.getCharacteristic(Characteristic.EveTimesOpened).updateValue(this.entryCount(this.EveHome.type, this.EveHome.sub, {status: 1})); // Count of entries based upon status = 1, opened - service.getCharacteristic(Characteristic.EveLastActivation).updateValue(this.__EveLastEventTime()); // time of last event in seconds since first event - service.getCharacteristic(Characteristic.EveTimesOpened).on("get", (callback) => { + service.updateCharacteristic(HAP.Characteristic.EveTimesOpened, this.entryCount(this.EveHome.type, this.EveHome.sub, {status: 1})); // Count of entries based upon status = 1, opened + service.updateCharacteristic(HAP.Characteristic.EveLastActivation, this.#EveLastEventTime()); // time of last event in seconds since first event + + service.getCharacteristic(HAP.Characteristic.EveTimesOpened).on("get", (callback) => { callback(null, this.entryCount(this.EveHome.type, this.EveHome.sub, {status: 1})); // Count of entries based upon status = 1, opened }); - service.getCharacteristic(Characteristic.EveLastActivation).on("get", (callback) => { - callback(null, this.__EveLastEventTime()); // time of last event in seconds since first event - }); + + service.getCharacteristic(HAP.Characteristic.EveLastActivation).on("get", (callback) => { + callback(null, this.#EveLastEventTime()); // time of last event in seconds since first event + }); + + this.#outputLogging("History", false, "History linked to EveHome app as '%s'", "Eve Door & Window"); break; } - case Service.ContactSensor.UUID : { + case HAP.Service.ContactSensor.UUID : { // treat these as EveHome Door - var historyService = HomeKitAccessory.addService(Service.EveHomeHistory, "", 1); + var historyService = HomeKitAccessory.addService(HAP.Service.EveHomeHistory, "", 1); var tempHistory = this.getHistory(service.UUID, service.subtype); var historyreftime = (tempHistory.length == 0 ? (this.historyData.reset - EPOCH_OFFSET) : (tempHistory[0].time - EPOCH_OFFSET)); this.EveHome = {service: historyService, linkedservice: service, type: service.UUID, sub: service.subtype, evetype: "contact", fields: "0601", entry: 0, count: tempHistory.length, reftime: historyreftime, send: 0}; - service.addCharacteristic(Characteristic.EveLastActivation); - service.addCharacteristic(Characteristic.EveOpenDuration); - service.addCharacteristic(Characteristic.EveClosedDuration); - service.addCharacteristic(Characteristic.EveTimesOpened); + service.addCharacteristic(HAP.Characteristic.EveLastActivation); + service.addCharacteristic(HAP.Characteristic.EveOpenDuration); + service.addCharacteristic(HAP.Characteristic.EveClosedDuration); + service.addCharacteristic(HAP.Characteristic.EveTimesOpened); // Setup initial values and callbacks for charateristics we are using - service.getCharacteristic(Characteristic.EveTimesOpened).updateValue(this.entryCount(this.EveHome.type, this.EveHome.sub, {status: 1})); // Count of entries based upon status = 1, opened - service.getCharacteristic(Characteristic.EveLastActivation).updateValue(this.__EveLastEventTime()); // time of last event in seconds since first event - service.getCharacteristic(Characteristic.EveTimesOpened).on("get", (callback) => { + service.updateCharacteristic(HAP.Characteristic.EveTimesOpened, this.entryCount(this.EveHome.type, this.EveHome.sub, {status: 1})); // Count of entries based upon status = 1, opened + service.updateCharacteristic(HAP.Characteristic.EveLastActivation, this.#EveLastEventTime()); // time of last event in seconds since first event + + service.getCharacteristic(HAP.Characteristic.EveTimesOpened).on("get", (callback) => { callback(null, this.entryCount(this.EveHome.type, this.EveHome.sub, {status: 1})); // Count of entries based upon status = 1, opened }); - service.getCharacteristic(Characteristic.EveLastActivation).on("get", (callback) => { - callback(null, this.__EveLastEventTime()); // time of last event in seconds since first event - }); + + service.getCharacteristic(HAP.Characteristic.EveLastActivation).on("get", (callback) => { + callback(null, this.#EveLastEventTime()); // time of last event in seconds since first event + }); + + this.#outputLogging("History", false, "History linked to EveHome app as '%s'", "Eve Door & Window"); break; } - case Service.WindowCovering.UUID : + case HAP.Service.WindowCovering.UUID : { // Treat as Eve MotionBlinds - var historyService = HomeKitAccessory.addService(Service.EveHomeHistory, "", 1); + var historyService = HomeKitAccessory.addService(HAP.Service.EveHomeHistory, "", 1); var tempHistory = this.getHistory(service.UUID, service.subtype); var historyreftime = (tempHistory.length == 0 ? (this.historyData.reset - EPOCH_OFFSET) : (tempHistory[0].time - EPOCH_OFFSET)); - this.EveHome = {service: historyService, linkedservice: service, type: service.UUID, sub: service.subtype, evetype: "blind", fields: "1802 1901", entry: 0, count: tempHistory.length, reftime: historyreftime, send: 0}; - service.addCharacteristic(Characteristic.EveGetConfiguration); - service.addCharacteristic(Characteristic.EveSetConfiguration); + this.EveHome = {service: historyService, linkedservice: service, type: service.UUID, sub: service.subtype, evetype: "blind", fields: "1702 1802 1901", entry: 0, count: tempHistory.length, reftime: historyreftime, send: 0}; + service.addCharacteristic(HAP.Characteristic.EveGetConfiguration); + service.addCharacteristic(HAP.Characteristic.EveSetConfiguration); //17 CurrentPosition //18 TargetPosition //19 PositionState - /* for (var index = 30; index < 115; index++) { - - uuid = "E863F1" + numberToEveHexString(index, 2) + "-079E-48FF-8F27-9C2605A29F52"; - eval(`Characteristic.EveTest`+ index + ` () {Characteristic.call(this, "Eve Test "+ index, uuid); this.setProps({format: Characteristic.Formats.DATA,perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY]});this.value = this.getDefaultValue();}`); - util.inherits(eval(`Characteristic.EveTest`+ index), Characteristic); - eval(`Characteristic.EveTest`+ index + `.UUID = uuid`); - service.addCharacteristic(eval(`Characteristic.EveTest`+ index)); - console.log(uuid) - + + /* var index = 80; + var uuid = "E863F1" + numberToEveHexString(index, 2) + "-079E-48FF-8F27-9C2605A29F52".toLocaleUpperCase(); + eval(`HAP.Characteristic.EveTest`+ index + ` =function() {HAP.Characteristic.call(this, "Eve Test "+ index, uuid); this.setProps({format: HAP.Characteristic.Formats.DATA,perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY]});this.value = this.getDefaultValue();}`); + util.inherits(eval(`HAP.Characteristic.EveTest`+ index), HAP.Characteristic); + eval(`HAP.Characteristic.EveTest`+ index + `.UUID = uuid`); + if (service.testCharacteristic(eval(`HAP.Characteristic.EveTest`+ index)) == false) { + service.addCharacteristic(eval(`HAP.Characteristic.EveTest`+ index)); + console.log(uuid) } */ - - this.productid = 0; - - service.getCharacteristic(Characteristic.EveGetConfiguration).on("get", (callback) => { - var value = util.format( - "0002 %s 0302 %s 9b04 %s 1e02 %s 0c", - numberToEveHexString(this.productid, 4), - numberToEveHexString(1300, 4), // firmware version (build xxxx) - numberToEveHexString(Math.floor(new Date() / 1000), 8), // "now" time - numberToEveHexString(this.productid, 4)); - - console.log("EveGetConfiguration", value); - this.productid = this.productid + 1; + service.getCharacteristic(HAP.Characteristic.EveGetConfiguration).on("get", (callback) => { + var value = util.format( + "0002 5500 0302 %s 9b04 %s 1e02 5500 0c", + numberToEveHexString(2979, 4), // firmware version (build xxxx) + numberToEveHexString(Math.floor(new Date() / 1000), 8)); // "now" time + callback(null, encodeEveData(value)); }); - service.getCharacteristic(Characteristic.EveSetConfiguration).on("set", (value, callback) => { + service.getCharacteristic(HAP.Characteristic.EveSetConfiguration).on("set", (value, callback) => { var processedData = {}; var valHex = decodeEveData(value); var index = 0; @@ -573,17 +585,27 @@ class HomeKitHistory { case "f3" : { // move window covering to set limits - // data - // 01c800 move up single press - // 02c800 move down single press - // 01d007 move up hold press - // 02d007 move down hold press - // 030000 stop from hold press + // xxyyyy - xx = move command (01 = up, 02 = down, 03 = stop), yyyy - distance/time/ticks/increment to move?? + var moveCommand = data.substring(0, 2); + var moveAmount = EveHexStringToNumber(data.substring(2)); + + console.log("move", moveCommand, moveAmount); + + var currentPosition = service.getCharacteristic(HAP.Characteristic.CurrentPosition).value; + if (data == "015802") { + currentPosition = currentPosition + 1; + } + if (data == "025802") { + currentPosition = currentPosition - 1; + } + console.log("move", currentPosition, data) + service.updateCharacteristic(HAP.Characteristic.CurrentPosition, currentPosition); + service.updateCharacteristic(HAP.Characteristic.TargetPosition, currentPosition); break; } default : { - this.debug && console.debug(getTimestamp() + " [HISTORY] Unknown Eve MotionBlinds command '%s' with data '%s'", command, data); + this.#outputLogging("History", true, "Unknown Eve MotionBlinds command '%s' with data '%s'", command, data); break; } } @@ -591,13 +613,15 @@ class HomeKitHistory { } callback(); }); + + this.#outputLogging("History", false, "History linked to EveHome app as '%s'", "Eve Motion Blinds"); break; } - case Service.HeaterCooler.UUID : - case Service.Thermostat.UUID : { + case HAP.Service.HeaterCooler.UUID : + case HAP.Service.Thermostat.UUID : { // treat these as EveHome Thermo - var historyService = HomeKitAccessory.addService(Service.EveHomeHistory, "", 1); + var historyService = HomeKitAccessory.addService(HAP.Service.EveHomeHistory, "", 1); var tempHistory = this.getHistory(service.UUID, service.subtype); var historyreftime = (tempHistory.length == 0 ? (this.historyData.reset - EPOCH_OFFSET) : (tempHistory[0].time - EPOCH_OFFSET)); @@ -610,67 +634,29 @@ class HomeKitHistory { this.EveThermoPersist.tempoffset = optionalParams.hasOwnProperty("EveThermo_tempoffset") ? optionalParams.EveThermo_tempoffset: -2.5; // Temperature offset. default -2.5 this.EveThermoPersist.enableschedule = optionalParams.hasOwnProperty("EveThermo_enableschedule") ? optionalParams.EveThermo_enableschedule : false; // Schedules on/off this.EveThermoPersist.pause = optionalParams.hasOwnProperty("EveThermo_pause") ? optionalParams.EveThermo_pause : false; // Paused on/off - this.EveThermoPersist.away = optionalParams.hasOwnProperty("EveThermo_away") ? optionalParams.EveThermo_away : false; // Vacation status - disabled ie: Home - this.EveThermoPersist.awaytemp = optionalParams.hasOwnProperty("EveThermo_awaytemp") ? optionalParams.EveThermo_awaytemp : 0xff; // Vacation temp disabled - this.EveThermoPersist.command1a = optionalParams.hasOwnProperty("EveThermo_command1a") ? optionalParams.EveThermo_command1a : ""; - this.EveThermoPersist.commandf4 = optionalParams.hasOwnProperty("EveThermo_commandf4") ? optionalParams.EveThermo_commandf4 : ""; - this.EveThermoPersist.commandfa = optionalParams.hasOwnProperty("EveThermo_commandfa") ? optionalParams.EveThermo_commandfa : ""; + this.EveThermoPersist.vacation = optionalParams.hasOwnProperty("EveThermo_vacation") ? optionalParams.EveThermo_vacation : false; // Vacation status - disabled ie: Home + this.EveThermoPersist.vacationtemp = optionalParams.hasOwnProperty("EveThermo_vacationtemp") ? optionalParams.EveThermo_vactiontemp : null; // Vacation temp disabled if null + this.EveThermoPersist.datetime = optionalParams.hasOwnProperty("EveThermo_datetime") ? optionalParams.EveThermo_datetime : new Date(); // Current date/time + this.EveThermoPersist.programs = optionalParams.hasOwnProperty("EveThermo_programs") ? optionalParams.EveThermo_programs : []; - service.addCharacteristic(Characteristic.EveValvePosition); // Needed to show history for thermostat heating modes (valve position) - service.addCharacteristic(Characteristic.EveFirmware); - service.addCharacteristic(Characteristic.EveProgramData); - service.addCharacteristic(Characteristic.EveProgramCommand); - if (service.testCharacteristic(Characteristic.StatusActive) === false) service.addCharacteristic(Characteristic.StatusActive); - if (service.testCharacteristic(Characteristic.CurrentTemperature) === false) service.addCharacteristic(Characteristic.CurrentTemperature); - if (service.testCharacteristic(Characteristic.TemperatureDisplayUnits) === false) service.addCharacteristic(Characteristic.TemperatureDisplayUnits); - if (service.testCharacteristic(Characteristic.LockPhysicalControls) == false) service.addCharacteristic(Characteristic.LockPhysicalControls); // Allows childlock toggle to be displayed in Eve App - - service.getCharacteristic(Characteristic.EveFirmware).updateValue(encodeEveData(util.format("2c %s be", numberToEveHexString(this.EveThermoPersist.firmware, 4)))); // firmware version (build xxxx))); - - // TODO - before enabling below need to workout: - // - mode graph to show - // - temperature unit setting - // - thermo 2020?? - service.getCharacteristic(Characteristic.EveProgramData).on("get", (callback) => { - // commands - // 11 - valve protection on/off - TODO - // 12 - temp offset - // 13 - schedules enabled/disabled - // 16 - Window/Door open status - // 100000 - open - // 000000 - close - // 14 - installation status - // c0,c8 = ok - // c1,c6,c9 = in-progress - // c2,c3,c4,c5 = error on removal - // c7 = not attached - // 19 - vacation mode - // 00ff - off - // 01 + "away temp" - enabled with vacation temp - // f4 - temperatures - // fa - programs for week - // 1a - default day program - - if (typeof optionalParams.GetCommand == "function") this.EveThermo = optionGetFunction(this.EveThermoPersist); // Fill in details we might want to be dynamic - - // Encode the temperature offset into an unsigned value - var tempOffset = this.EveThermoPersist.tempoffset * 10; - if (tempOffset < 127) tempOffset = tempOffset + 256; - - var value = util.format( - "12%s 13%s 14%s 19%s %s %s %s", - numberToEveHexString(tempOffset, 2), - this.EveThermoPersist.enableschedule == true ? "01" : "00", - service.getCharacteristic(Characteristic.StatusActive).value == true || optionalParams.EveThermo_attached == true ? "c0" : "c7", - this.EveThermoPersist.away == true ? "01" + numberToEveHexString(this.EveThermoPersist.awaytemp * 2, 2) : "00ff", // away status and temp - this.EveThermoPersist.commandf4, - this.EveThermoPersist.command1a, - this.EveThermoPersist.commandfa); + service.addCharacteristic(HAP.Characteristic.EveValvePosition); // Needed to show history for thermostat heating modes (valve position) + service.addCharacteristic(HAP.Characteristic.EveFirmware); + service.addCharacteristic(HAP.Characteristic.EveProgramData); + service.addCharacteristic(HAP.Characteristic.EveProgramCommand); + if (service.testCharacteristic(HAP.Characteristic.StatusActive) === false) service.addCharacteristic(HAP.Characteristic.StatusActive); + if (service.testCharacteristic(HAP.Characteristic.CurrentTemperature) === false) service.addCharacteristic(HAP.Characteristic.CurrentTemperature); + if (service.testCharacteristic(HAP.Characteristic.TemperatureDisplayUnits) === false) service.addCharacteristic(HAP.Characteristic.TemperatureDisplayUnits); + if (service.testCharacteristic(HAP.Characteristic.LockPhysicalControls) == false) service.addCharacteristic(HAP.Characteristic.LockPhysicalControls); // Allows childlock toggle to be displayed in Eve App - callback(null, encodeEveData(value)); + // Setup initial values and callbacks for charateristics we are using + service.updateCharacteristic(HAP.Characteristic.EveFirmware, encodeEveData(util.format("2c %s be", numberToEveHexString(this.EveThermoPersist.firmware, 4)))); // firmware version (build xxxx))); + + service.updateCharacteristic(HAP.Characteristic.EveProgramData, this.#EveThermoGetDetails(optionalParams.GetCommand)); + service.getCharacteristic(HAP.Characteristic.EveProgramData).on("get", (callback) => { + callback(null, this.#EveThermoGetDetails(optionalParams.GetCommand)); }); - service.getCharacteristic(Characteristic.EveProgramCommand).on("set", (value, callback) => { + service.getCharacteristic(HAP.Characteristic.EveProgramCommand).on("set", (value, callback) => { var programs = []; var scheduleTemps = []; var processedData = {}; @@ -678,7 +664,6 @@ class HomeKitHistory { var index = 0; while (index < valHex.length) { var command = valHex.substr(index, 2); - console.log("eve therm", valHex) index += 2; // skip over command value, and this is where data starts. switch(command) { case "00" : { @@ -750,28 +735,34 @@ class HomeKitHistory { case "19" : { // Vacation on/off, vacation temperature via HomeKit automation/scene - var awayStatus = valHex.substr(index, 2) == "01" ? true : false; - var awayTemp = parseInt(valHex.substr(index + 2, 2), 16) * 0.5; - this.EveThermoPersist.away = awayStatus; - this.EveThermoPersist.awaytemp = awayTemp; - processedData.away = {"status": this.EveThermoPersist.away, "temp": this.EveThermoPersist.awaytemp}; + this.EveThermoPersist.vacation = valHex.substr(index, 2) == "01" ? true : false; + this.EveThermoPersist.vacationtemp = (valHex.substr(index, 2) == "01" ? parseInt(valHex.substr(index + 2, 2), 16) * 0.5 : null); + processedData.vacation = {"status": this.EveThermoPersist.vacation, "temp": this.EveThermoPersist.vacationtemp}; index += 4; break; } case "f4" : { // Temperature Levels for schedule - this.EveThermoPersist.commandf4 = "f400" + valHex.substr(index, 6); // save the command string - var currentTemp = valHex.substr(index, 2) == "80" ? null : parseInt(valHex.substr(index, 2), 16) * 0.5; + var nowTemp = valHex.substr(index, 2) == "80" ? null : parseInt(valHex.substr(index, 2), 16) * 0.5; var ecoTemp = valHex.substr(index + 2, 2) == "80" ? null : parseInt(valHex.substr(index + 2, 2), 16) * 0.5; var comfortTemp = valHex.substr(index + 4, 2) == "80" ? null : parseInt(valHex.substr(index + 4, 2), 16) * 0.5; scheduleTemps = [ecoTemp, comfortTemp]; + processedData.scheduleTemps = {"eco": ecoTemp, "comfort": comfortTemp}; index += 6; break; } case "fc" : { - // Date/Time + // Date/Time mmhhDDMMYY + var months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + var minute = parseInt(valHex.substr(index, 2), 16); + var hour = parseInt(valHex.substr(index + 2, 2), 16); + var day = parseInt(valHex.substr(index + 4, 2), 16); + var month = parseInt(valHex.substr(index + 6, 2), 16); + var year = parseInt(valHex.substr(index + 8, 2), 16); + this.EveThermoPersist.datetime = new Date(months[month - 1] + " " + day + ", " + year + " " + hour + ":" + minute + ":00"); + processedData.datetime = this.EveThermoPersist.datetime; index += 10; break; } @@ -779,8 +770,6 @@ class HomeKitHistory { case "fa" : { // Programs (week - mon, tue, wed, thu, fri, sat, sun) // index += 112; - this.EveThermoPersist.commandfa = command + valHex.substr(index, 112); // save the command string - var daysofweek = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; for (var index2 = 0; index2 < 7; index2++) { var times = []; for (var index3 = 0; index3 < 4; index3++) { @@ -807,13 +796,15 @@ class HomeKitHistory { } if (start_offset != null && end_offset != null) { - times.push({"type" : "time", "offset": start_offset, "duration" : (end_offset - start_offset)}); + times.push({"start": start_offset, "duration" : (end_offset - start_offset), "ecotemp" : scheduleTemps.eco, "comforttemp" : scheduleTemps.comfort}); } index += 4; } - programs.push({"id": index2 + 1, "days": daysofweek[index2], "schedule": times, "temperature" : scheduleTemps}); + programs.push({"id": (programs.length + 1), "days": DAYSOFWEEK[index2], "schedule": times }); } - processedData.programs = programs; + + this.EveThermoPersist.programs = programs; + processedData.programs = this.EveThermoPersist.programs; break; } @@ -842,7 +833,7 @@ class HomeKitHistory { } default : { - this.debug && console.debug(getTimestamp() + " [HISTORY] Unknown Eve Thermo command '%s'", command); + this.#outputLogging("History", true, "Unknown Eve Thermo command '%s'", command); break } } @@ -852,98 +843,104 @@ class HomeKitHistory { if (typeof optionalParams.SetCommand == "function" && Object.keys(processedData).length != 0) optionalParams.SetCommand(processedData); callback(); }); + + this.#outputLogging("History", false, "History linked to EveHome app as '%s'", "Eve Thermo"); break; } - case Service.EveAirPressureSensor.UUID : { + case HAP.Service.EveAirPressureSensor.UUID : { // treat these as EveHome Weather (2015) - var historyService = HomeKitAccessory.addService(Service.EveHomeHistory, "", 1); + var historyService = HomeKitAccessory.addService(HAP.Service.EveHomeHistory, "", 1); var tempHistory = this.getHistory(service.UUID, service.subtype); var historyreftime = (tempHistory.length == 0 ? (this.historyData.reset - EPOCH_OFFSET) : (tempHistory[0].time - EPOCH_OFFSET)); - service.addCharacteristic(Characteristic.EveFirmware); - service.getCharacteristic(Characteristic.EveFirmware).updateValue(encodeEveData(util.format("01 %s be", numberToEveHexString(809, 4)))); // firmware version (build xxxx))); + service.addCharacteristic(HAP.Characteristic.EveFirmware); + service.updateCharacteristic(HAP.Characteristic.EveFirmware, encodeEveData(util.format("01 %s be", numberToEveHexString(809, 4)))); // firmware version (build xxxx))); this.EveHome = {service: historyService, linkedservice: service, type: service.UUID, sub: service.subtype, evetype: "weather", fields: "0102 0202 0302", entry: 0, count: tempHistory.length, reftime: historyreftime, send: 0}; + + this.#outputLogging("History", false, "History linked to EveHome app as '%s'", "Eve Weather"); break; } - case Service.AirQualitySensor.UUID : - case Service.TemperatureSensor.UUID : { + case HAP.Service.AirQualitySensor.UUID : + case HAP.Service.TemperatureSensor.UUID : { // treat these as EveHome Room(s) - var historyService = HomeKitAccessory.addService(Service.EveHomeHistory, "", 1); + var historyService = HomeKitAccessory.addService(HAP.Service.EveHomeHistory, "", 1); var tempHistory = this.getHistory(service.UUID, service.subtype); var historyreftime = (tempHistory.length == 0 ? (this.historyData.reset - EPOCH_OFFSET) : (tempHistory[0].time - EPOCH_OFFSET)); - service.addCharacteristic(Characteristic.EveFirmware); + service.addCharacteristic(HAP.Characteristic.EveFirmware); - if (service.UUID == Service.AirQualitySensor.UUID) { + if (service.UUID == HAP.Service.AirQualitySensor.UUID) { // Eve Room 2 (2018) - service.getCharacteristic(Characteristic.EveFirmware).updateValue(encodeEveData(util.format("27 %s be", numberToEveHexString(1416, 4)))); // firmware version (build xxxx))); + service.updateCharacteristic(HAP.Characteristic.EveFirmware, encodeEveData(util.format("27 %s be", numberToEveHexString(1416, 4)))); // firmware version (build xxxx))); this.EveHome = {service: historyService, linkedservice: service, type: service.UUID, sub: service.subtype, evetype: "room2", fields: "0102 0202 2202 2901 2501 2302 2801", entry: 0, count: tempHistory.length, reftime: historyreftime, send: 0}; - if (service.testCharacteristic(Characteristic.VOCDensity) == false) service.addCharacteristic(Characteristic.VOCDensity); + if (service.testCharacteristic(HAP.Characteristic.VOCDensity) == false) service.addCharacteristic(HAP.Characteristic.VOCDensity); // Need to ensure HomeKit accessory which has Air Quality service also has temperature & humidity services. - // Temperature service needs characteristic Characteristic.TemperatureDisplayUnits set to Characteristic.TemperatureDisplayUnits.CELSIUS + // Temperature service needs characteristic HAP.Characteristic.TemperatureDisplayUnits set to HAP.Characteristic.TemperatureDisplayUnits.CELSIUS } - if (service.UUID == Service.TemperatureSensor.UUID) { + if (service.UUID == HAP.Service.TemperatureSensor.UUID) { // Eve Room (2015) - service.getCharacteristic(Characteristic.EveFirmware).updateValue(encodeEveData(util.format("02 %s be", numberToEveHexString(1151, 4)))); // firmware version (build xxxx))); + service.updateCharacteristic(HAP.Characteristic.EveFirmware, encodeEveData(util.format("02 %s be", numberToEveHexString(1151, 4)))); // firmware version (build xxxx))); this.EveHome = {service: historyService, linkedservice: service, type: service.UUID, sub: service.subtype, evetype: "room", fields: "0102 0202 0402 0f03", entry: 0, count: tempHistory.length, reftime: historyreftime, send: 0}; - if (service.testCharacteristic(Characteristic.TemperatureDisplayUnits) == false) service.addCharacteristic(Characteristic.TemperatureDisplayUnits); // Needed to show history for temperature - service.getCharacteristic(Characteristic.TemperatureDisplayUnits).updateValue(Characteristic.TemperatureDisplayUnits.CELSIUS); // Temperature needs to be in Celsius + if (service.testCharacteristic(HAP.Characteristic.TemperatureDisplayUnits) == false) service.addCharacteristic(HAP.Characteristic.TemperatureDisplayUnits); // Needed to show history for temperature + service.updateCharacteristic(HAP.Characteristic.TemperatureDisplayUnits, HAP.Characteristic.TemperatureDisplayUnits.CELSIUS); // Temperature needs to be in Celsius } + + this.#outputLogging("History", false, "History linked to EveHome app as '%s'", "Eve Room"); break; } - case Service.MotionSensor.UUID : { + case HAP.Service.MotionSensor.UUID : { // treat these as EveHome Motion - var historyService = HomeKitAccessory.addService(Service.EveHomeHistory, "", 1); + var historyService = HomeKitAccessory.addService(HAP.Service.EveHomeHistory, "", 1); var tempHistory = this.getHistory(service.UUID, service.subtype); var historyreftime = (tempHistory.length == 0 ? (this.historyData.reset - EPOCH_OFFSET) : (tempHistory[0].time - EPOCH_OFFSET)); // Need some internal storage to track Eve Motion configuration from EveHome app this.EveMotionPersist = {}; this.EveMotionPersist.duration = optionalParams.hasOwnProperty("EveMotion_duration") ? optionalParams.EveMotion_duration : 5; // default 5 seconds - this.EveMotionPersist.sensitivity = optionalParams.hasOwnProperty("EveMotion_sensitivity") ? optionalParams.EveMotion_sensivity : Characteristic.EveSensitivity.HIGH; // default sensitivity + this.EveMotionPersist.sensitivity = optionalParams.hasOwnProperty("EveMotion_sensitivity") ? optionalParams.EveMotion_sensivity : HAP.Characteristic.EveSensitivity.HIGH; // default sensitivity this.EveMotionPersist.ledmotion = optionalParams.hasOwnProperty("EveMotion_ledmotion") ? optionalParams.EveMotion_ledmotion: false; // off this.EveHome = {service: historyService, linkedservice: service, type: service.UUID, sub: service.subtype, evetype: "motion", fields:"1301 1c01", entry: 0, count: tempHistory.length, reftime: historyreftime, send: 0}; - service.addCharacteristic(Characteristic.EveSensitivity); - service.addCharacteristic(Characteristic.EveDuration); - service.addCharacteristic(Characteristic.EveLastActivation); - //service.addCharacteristic(Characteristic.EveGetConfiguration); - //service.addCharacteristic(Characteristic.EveSetConfiguration); + service.addCharacteristic(HAP.Characteristic.EveSensitivity); + service.addCharacteristic(HAP.Characteristic.EveDuration); + service.addCharacteristic(HAP.Characteristic.EveLastActivation); + //service.addCharacteristic(HAP.Characteristic.EveGetConfiguration); + //service.addCharacteristic(HAP.Characteristic.EveSetConfiguration); // Setup initial values and callbacks for charateristics we are using - service.getCharacteristic(Characteristic.EveLastActivation).updateValue(this.__EveLastEventTime()); // time of last event in seconds since first event - service.getCharacteristic(Characteristic.EveLastActivation).on("get", (callback) => { - callback(null, this.__EveLastEventTime()); // time of last event in seconds since first event + service.updateCharacteristic(HAP.Characteristic.EveLastActivation, this.#EveLastEventTime()); // time of last event in seconds since first event + service.getCharacteristic(HAP.Characteristic.EveLastActivation).on("get", (callback) => { + callback(null, this.#EveLastEventTime()); // time of last event in seconds since first event }); - service.getCharacteristic(Characteristic.EveSensitivity).updateValue(this.EveMotionPersist.sensitivity); - service.getCharacteristic(Characteristic.EveSensitivity).on("get", (callback) => { + service.updateCharacteristic(HAP.Characteristic.EveSensitivity, this.EveMotionPersist.sensitivity); + service.getCharacteristic(HAP.Characteristic.EveSensitivity).on("get", (callback) => { callback(null, this.EveMotionPersist.sensitivity); }); - service.getCharacteristic(Characteristic.EveSensitivity).on("set", (value, callback) => { + service.getCharacteristic(HAP.Characteristic.EveSensitivity).on("set", (value, callback) => { this.EveMotionPersist.sensitivity = value; callback(); }); - service.getCharacteristic(Characteristic.EveDuration).updateValue(this.EveMotionPersist.duration); - service.getCharacteristic(Characteristic.EveDuration).on("get", (callback) => { + service.updateCharacteristic(HAP.Characteristic.EveDuration, this.EveMotionPersist.duration); + service.getCharacteristic(HAP.Characteristic.EveDuration).on("get", (callback) => { callback(null, this.EveMotionPersist.duration); }); - service.getCharacteristic(Characteristic.EveDuration).on("set", (value, callback) => { + service.getCharacteristic(HAP.Characteristic.EveDuration).on("set", (value, callback) => { this.EveMotionPersist.duration = value; callback(); }); - /*service.getCharacteristic(Characteristic.EveGetConfiguration).updateValue(encodeEveData("300100")); - service.getCharacteristic(Characteristic.EveGetConfiguration).on("get", (callback) => { + /*service.updateCharacteristic(HAP.Characteristic.EveGetConfiguration, encodeEveData("300100")); + service.getCharacteristic(HAP.Characteristic.EveGetConfiguration).on("get", (callback) => { var value = util.format( "0002 2500 0302 %s 9b04 %s 8002 ffff 1e02 2500 0c", numberToEveHexString(1144, 4), // firmware version (build xxxx) @@ -954,7 +951,7 @@ class HomeKitHistory { callback(null, encodeEveData(value)); }); - service.getCharacteristic(Characteristic.EveSetConfiguration).on("set", (value, callback) => { + service.getCharacteristic(HAP.Characteristic.EveSetConfiguration).on("set", (value, callback) => { var valHex = decodeEveData(value); var index = 0; while (index < valHex.length) { @@ -975,7 +972,7 @@ class HomeKitHistory { } default : { - this.debug && console.debug(getTimestamp() + " [HISTORY] Unknown Eve Motion command '%s' with data '%s'", command, data); + this.#outputLogging("History", true, "Unknown Eve Motion command '%s' with data '%s'", command, data); break; } } @@ -983,12 +980,14 @@ class HomeKitHistory { } callback(); }); */ + + this.#outputLogging("History", false, "History linked to EveHome app as '%s'", "Eve Motion"); break; } - case Service.SmokeSensor.UUID : { + case HAP.Service.SmokeSensor.UUID : { // treat these as EveHome Smoke - var historyService = HomeKitAccessory.addService(Service.EveHomeHistory, "", 1); + var historyService = HomeKitAccessory.addService(HAP.Service.EveHomeHistory, "", 1); var tempHistory = this.getHistory(service.UUID, service.subtype); var historyreftime = (tempHistory.length == 0 ? (this.historyData.reset - EPOCH_OFFSET) : (tempHistory[0].time - EPOCH_OFFSET)); // TODO = work out what the "signatures" need to be for an Eve Smoke @@ -1007,22 +1006,22 @@ class HomeKitHistory { this.EveSmokePersist.heattestpassed = optionalParams.hasOwnProperty("EveSmoke_heattestpassed") ? optionalParams.EveSmoke_heattestpassed: true; // Passed smoke test? this.EveSmokePersist.hushedstate = optionalParams.hasOwnProperty("EveSmoke_hushedstate") ? optionalParams.EveSmoke_hushedstate : false; // Alarms muted - service.addCharacteristic(Characteristic.EveGetConfiguration); - service.addCharacteristic(Characteristic.EveSetConfiguration); - service.addCharacteristic(Characteristic.EveDeviceStatus); + service.addCharacteristic(HAP.Characteristic.EveGetConfiguration); + service.addCharacteristic(HAP.Characteristic.EveSetConfiguration); + service.addCharacteristic(HAP.Characteristic.EveDeviceStatus); // Setup initial values and callbacks for charateristics we are using - service.getCharacteristic(Characteristic.EveDeviceStatus).updateValue(this.__EveSmokeGetDetails(optionalParams.GetCommand, Characteristic.EveDeviceStatus.UUID)); - service.getCharacteristic(Characteristic.EveDeviceStatus).on("get", (callback) => { - callback(null, this.__EveSmokeGetDetails(optionalParams.GetCommand, Characteristic.EveDeviceStatus.UUID)); + service.updateCharacteristic(HAP.Characteristic.EveDeviceStatus, this.#EveSmokeGetDetails(optionalParams.GetCommand, HAP.Characteristic.EveDeviceStatus)); + service.getCharacteristic(HAP.Characteristic.EveDeviceStatus).on("get", (callback) => { + callback(null, this.#EveSmokeGetDetails(optionalParams.GetCommand, HAP.Characteristic.EveDeviceStatus)); }); - service.getCharacteristic(Characteristic.EveGetConfiguration).updateValue(this.__EveSmokeGetDetails(optionalParams.GetCommand, Characteristic.EveGetConfiguration.UUID)); - service.getCharacteristic(Characteristic.EveGetConfiguration).on("get", (callback) => { - callback(null, this.__EveSmokeGetDetails(optionalParams.GetCommand, Characteristic.EveGetConfiguration.UUID)); + service.updateCharacteristic(HAP.Characteristic.EveGetConfiguration, this.#EveSmokeGetDetails(optionalParams.GetCommand, HAP.Characteristic.EveGetConfiguration)); + service.getCharacteristic(HAP.Characteristic.EveGetConfiguration).on("get", (callback) => { + callback(null, this.#EveSmokeGetDetails(optionalParams.GetCommand, HAP.Characteristic.EveGetConfiguration)); }); - service.getCharacteristic(Characteristic.EveSetConfiguration).on("set", (value, callback) => { + service.getCharacteristic(HAP.Characteristic.EveSetConfiguration).on("set", (value, callback) => { // Loop through set commands passed to us var processedData = {}; var valHex = decodeEveData(value); @@ -1047,7 +1046,7 @@ class HomeKitHistory { processedData.statusled = this.EveSmokePersist.statusled; } if (subCommand != 0x02 && subCommand != 0x05) { - this.debug && console.debug(getTimestamp() + " [HISTORY] Unknown Eve Smoke command '%s' with data '%s'", command, data); + this.#outputLogging("History", true, "Unknown Eve Smoke command '%s' with data '%s'", command, data); } break; } @@ -1055,11 +1054,12 @@ class HomeKitHistory { // case "41" : { // "59b8" - "b859" - 1011100001011001 17/3 // "8aa5" - "a58a" - 1010010110001010 18/3 + // "6ef7" - "f76e" - 30/5/23 // break; // } default : { - this.debug && console.debug(getTimestamp() + " [HISTORY] Unknown Eve Smoke command '%s' with data '%s'", command, data); + this.#outputLogging("History", true, "Unknown Eve Smoke command '%s' with data '%s'", command, data); break; } } @@ -1071,39 +1071,43 @@ class HomeKitHistory { callback(); }); + this.#outputLogging("History", false, "History linked to EveHome app as '%s'", "Eve Smoke"); break; } - case Service.Valve.UUID : - case Service.IrrigationSystem.UUID : { + case HAP.Service.Valve.UUID : + case HAP.Service.IrrigationSystem.UUID : { // treat an irrigation system as EveHome Aqua // Under this, any valve history will be presented under this. We dont log our History under irrigation service ID at all // TODO - see if we can add history per valve service under the irrigation system????. History service per valve??? - var historyService = HomeKitAccessory.addService(Service.EveHomeHistory, "", 1); - var tempHistory = this.getHistory(Service.Valve.UUID, (service.UUID == Service.IrrigationSystem.UUID ? null : service.subtype)); + var historyService = HomeKitAccessory.addService(HAP.Service.EveHomeHistory, "", 1); + var tempHistory = this.getHistory(HAP.Service.Valve.UUID, (service.UUID == HAP.Service.IrrigationSystem.UUID ? null : service.subtype)); var historyreftime = (tempHistory.length == 0 ? (this.historyData.reset - EPOCH_OFFSET) : (tempHistory[0].time - EPOCH_OFFSET)); - this.EveHome = {service: historyService, linkedservice: service, type: Service.Valve.UUID, sub: (service.UUID == Service.IrrigationSystem.UUID ? null : service.subtype), evetype: "aqua", fields: "1f01 2a08 2302", entry: 0, count: tempHistory.length, reftime: historyreftime, send: 0}; - service.addCharacteristic(Characteristic.EveGetConfiguration); - service.addCharacteristic(Characteristic.EveSetConfiguration); - if (service.testCharacteristic(Characteristic.LockPhysicalControls) == false) service.addCharacteristic(Characteristic.LockPhysicalControls); // Allows childlock toggle to be displayed in Eve App + this.EveHome = {service: historyService, linkedservice: service, type: HAP.Service.Valve.UUID, sub: (service.UUID == HAP.Service.IrrigationSystem.UUID ? null : service.subtype), evetype: "aqua", fields: "1f01 2a08 2302", entry: 0, count: tempHistory.length, reftime: historyreftime, send: 0}; + service.addCharacteristic(HAP.Characteristic.EveGetConfiguration); + service.addCharacteristic(HAP.Characteristic.EveSetConfiguration); + if (service.testCharacteristic(HAP.Characteristic.LockPhysicalControls) == false) service.addCharacteristic(HAP.Characteristic.LockPhysicalControls); // Allows childlock toggle to be displayed in Eve App // Need some internal storage to track Eve Aqua configuration from EveHome app this.EveAquaPersist = {}; this.EveAquaPersist.firmware = optionalParams.hasOwnProperty("EveAqua_firmware") ? optionalParams.EveAqua_firmware : 1208; // Firmware version this.EveAquaPersist.flowrate = optionalParams.hasOwnProperty("EveAqua_flowrate") ? optionalParams.EveAqua_flowrate : 18; // 18 L/Min default + this.EveAquaPersist.latitude = optionalParams.hasOwnProperty("EveAqua_latitude") ? optionalParams.EveAqua_latitude : 0.0; // Latitude + this.EveAquaPersist.longitude = optionalParams.hasOwnProperty("EveAqua_longitude") ? optionalParams.EveAqua_longitude : 0.0; // Longitude + this.EveAquaPersist.utcoffset = optionalParams.hasOwnProperty("EveAqua_utcoffset") ? optionalParams.EveAqua_utcoffset : (new Date().getTimezoneOffset() * -60); // UTC offset in seconds this.EveAquaPersist.enableschedule = optionalParams.hasOwnProperty("EveAqua_enableschedule") ? optionalParams.EveAqua_enableschedule : false; // Schedules on/off - this.EveAquaPersist.command44 = "441105" + (this.EveAquaPersist.enableschedule == true ? "03" : "02") + "00000000000000000000000000000"; // schedule status. on or off - this.EveAquaPersist.command45 = "4509050200000008000800"; // No Schedules defined - this.EveAquaPersist.command46 = "4609050000000f00000000"; // No days defined for schedules + this.EveAquaPersist.pause = optionalParams.hasOwnProperty("EveAqua_pause") ? optionalParams.EveAqua_pause : 0; // Day pause + this.EveAquaPersist.programs = optionalParams.hasOwnProperty("EveAqua_programs") ? optionalParams.EveAqua_programs : []; // Schedules // Setup initial values and callbacks for charateristics we are using - service.getCharacteristic(Characteristic.EveGetConfiguration).updateValue(this.__EveAquaGetDetails(optionalParams.GetCommand)); - service.getCharacteristic(Characteristic.EveGetConfiguration).on("get", (callback) => { - callback(null, this.__EveAquaGetDetails(optionalParams.GetCommand)); + service.updateCharacteristic(HAP.Characteristic.EveGetConfiguration, this.#EveAquaGetDetails(optionalParams.GetCommand)); + service.getCharacteristic(HAP.Characteristic.EveGetConfiguration).on("get", (callback) => { + callback(null, this.#EveAquaGetDetails(optionalParams.GetCommand)); }); - service.getCharacteristic(Characteristic.EveSetConfiguration).on("set", (value, callback) => { + + service.getCharacteristic(HAP.Characteristic.EveSetConfiguration).on("set", (value, callback) => { // Loop through set commands passed to us var programs = []; var processedData = {}; @@ -1118,7 +1122,7 @@ class HomeKitHistory { switch(command) { case "2e" : { // flow rate in L/Minute - this.EveAquaPersist.flowrate = ((EveHexStringToNumber(data) * 60) / 1000).toFixed(1); + this.EveAquaPersist.flowrate = Number(((EveHexStringToNumber(data) * 60) / 1000).toFixed(1)); processedData.flowrate = this.EveAquaPersist.flowrate; break; } @@ -1133,7 +1137,6 @@ class HomeKitHistory { case "44" : { // Schedules on/off and Timezone/location information var subCommand = EveHexStringToNumber(data.substr(2, 4)); - this.EveAquaPersist.command44 = command + valHex.substr(index + 2, 2) + data; this.EveAquaPersist.enableschedule = (subCommand & 0x01) == 0x01; // Bit 1 is schedule status on/off if ((subCommand & 0x10) == 0x10) this.EveAquaPersist.utcoffset = EveHexStringToNumber(data.substr(10, 8)) * 60; // Bit 5 is UTC offset in seconds if ((subCommand & 0x04) == 0x04) this.EveAquaPersist.latitude = EveHexStringToFloat(data.substr(18, 8), 7); // Bit 4 is lat/long information @@ -1153,17 +1156,42 @@ class HomeKitHistory { case "45" : { // Eve App Scheduling Programs - this.EveAquaPersist.command45 = command + valHex.substr(index + 2, 2) + data; var programcount = EveHexStringToNumber(data.substr(2, 2)); // Number of defined programs var unknown = EveHexStringToNumber(data.substr(4, 6)); // Unknown data for 6 bytes - for (var index2 = parseInt(data.substr(0, 2), 16) * 2; index2 < data.length; index2+=2) { - if (data.substr(index2, 2) == "0a" || data.substr(index2, 2) == "0b") { + var index2 = 14; // Program schedules start at offset 14 in data + var programs = []; + while (index2 < data.length) { + var scheduleSize = parseInt(data.substr(index2 + 2, 2), 16) * 8; + var schedule = data.substring(index2 + 4, index2 + 4 + scheduleSize); + + if (schedule != "") { var times = []; - for (var index3 = 0; index3 < parseInt(data.substr(index2 + 2, 2), 16) && parseInt(data.substr(index2 + 2, 2), 16) != 8; index3++) + for (var index3 = 0; index3 < schedule.length / 8; index3++) { + // schedules appear to be a 32bit word + // after swapping 16bit words + // 1st 16bits = end time + // 2nd 16bits = start time + // starttime decode + // bit 1-5 specific time or sunrise/sunset 05 = time, 07 = sunrise/sunset + // if sunrise/sunset + // bit 6, sunrise = 1, sunset = 0 + // bit 7, before = 1, after = 0 + // bit 8 - 16 - minutes for sunrise/sunset + // if time + // bit 6 - 16 - minutes from 00:00 + // + // endtime decode + // bit 1-5 specific time or sunrise/sunset 01 = time, 03 = sunrise/sunset + // if sunrise/sunset + // bit 6, sunrise = 1, sunset = 0 + // bit 7, before = 1, after = 0 + // bit 8 - 16 - minutes for sunrise/sunset + // if time + // bit 6 - 16 - minutes from 00:00 // decode start time - var start = parseInt(data.substr(index2 + 4 + (index3 * 8), 4).match(/[a-fA-F0-9]{2}/g).reverse().join(''), 16); + var start = parseInt(schedule.substring((index3 * 8), (index3 * 8) + 4).match(/[a-fA-F0-9]{2}/g).reverse().join(""), 16); var start_min = null; var start_hr = null; var start_offset = null; @@ -1178,9 +1206,9 @@ class HomeKitHistory { start_sunrise = ((start >>> 5) & 0x01); // 1 = sunrise, 0 = sunset start_offset = ((start >>> 6) & 0x01 ? ~((start >>> 7) * 60) + 1 : (start >>> 7) * 60); // offset from sunrise/sunset (plus/minus value) } - + // decode end time - var end = parseInt(data.substr(index2 + 4 + ((index3 * 8) + 4), 4).match(/[a-fA-F0-9]{2}/g).reverse().join(''), 16); + var end = parseInt(schedule.substring((index3 * 8) + 4, (index3 * 8) + 8).match(/[a-fA-F0-9]{2}/g).reverse().join(""), 16); var end_min = null; var end_hr = null; var end_offset = null; @@ -1194,26 +1222,33 @@ class HomeKitHistory { end_sunrise = ((end >>> 5) & 0x01); // 1 = sunrise, 0 = sunset end_offset = ((end >>> 6) & 0x01 ? ~((end >>> 7) * 60) + 1 : (end >>> 7) * 60); // offset from sunrise/sunset (plus/minus value) } - - times.push({"type" : (start_sunrise == null ? "time" : (start_sunrise ? "sunrise" : "sunset")), "offset": start_offset, "duration" : (end_offset - start_offset)}); + times.push({"start" : (start_sunrise == null ? start_offset : (start_sunrise ? "sunrise" : "sunset")), "duration" : (end_offset - start_offset), "offset": start_offset}); } programs.push({"id": (programs.length + 1), "days": [], "schedule": times}); - index2 += (index3 * 8); } + index2 = index2 + 4 + scheduleSize; // Move to next program } break; } case "46" : { // Eve App active days across programs - this.EveAquaPersist.command46 = command + valHex.substr(index + 2, 2) + data; + //var daynumber = (EveHexStringToNumber(data.substr(8, 6)) >>> 4); + + // bit masks for active days mapped to programm id + /* var mon = (daynumber & 0x7); + var tue = ((daynumber >>> 3) & 0x7) + var wed = ((daynumber >>> 6) & 0x7) + var thu = ((daynumber >>> 9) & 0x7) + var fri = ((daynumber >>> 12) & 0x7) + var sat = ((daynumber >>> 15) & 0x7) + var sun = ((daynumber >>> 18) & 0x7) */ var unknown = EveHexStringToNumber(data.substr(0, 6)); // Unknown data for first 6 bytes var daysbitmask = (EveHexStringToNumber(data.substr(8, 6)) >>> 4); - var daysofweek = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; programs.forEach(program => { - for (var index = 0; index < daysofweek.length; index++) { - if (((daysbitmask >>> (index * 3)) & 0x7) == program.id) { - program.days.push(daysofweek[index]); + for (var index2 = 0; index2 < DAYSOFWEEK.length; index2++) { + if (((daysbitmask >>> (index2 * 3)) & 0x7) == program.id) { + program.days.push(DAYSOFWEEK[index2]); } } }); @@ -1230,19 +1265,20 @@ class HomeKitHistory { case "4b" : { // Eve App suspension scene triggered from HomeKit - processedData.days = EveHexStringToNumber(data.substr(0, 8)) / 1440; // 1440 mins in a day + this.EveAquaPersist.pause = (EveHexStringToNumber(data.substr(0, 8)) / 1440) + 1; // 1440 mins in a day. Zero based day, so we add one + processedData.pause = this.EveAquaPersist.pause; break; } case "b1" : { - // Child lock on/off. Seems data packet is always same (0100), so inspect "Characteristic.LockPhysicalControls)" for actual status - this.EveAquaPersist.childlock = (service.getCharacteristic(Characteristic.LockPhysicalControls).value == Characteristic.CONTROL_LOCK_ENABLED ? true : false); + // Child lock on/off. Seems data packet is always same (0100), so inspect "HAP.Characteristic.LockPhysicalControls)" for actual status + this.EveAquaPersist.childlock = (service.getCharacteristic(HAP.Characteristic.LockPhysicalControls).value == HAP.Characteristic.CONTROL_LOCK_ENABLED ? true : false); processedData.childlock = this.EveAquaPersist.childlock; break; } default : { - this.debug && console.debug(getTimestamp() + " [HISTORY] Unknown Eve Aqua command '%s' with data '%s'", command, data); + this.#outputLogging("History", true, "Unknown Eve Aqua command '%s' with data '%s'", command, data); break; } } @@ -1253,57 +1289,176 @@ class HomeKitHistory { if (typeof optionalParams.SetCommand == "function" && Object.keys(processedData).length != 0) optionalParams.SetCommand(processedData); callback(); }); + + this.#outputLogging("History", false, "History linked to EveHome app as '%s'", "Eve Aqua"); break; } - case Service.Outlet.UUID : { + case HAP.Service.Outlet.UUID : { // treat these as EveHome energy // TODO - schedules - var historyService = HomeKitAccessory.addService(Service.EveHomeHistory, "", 1); + var historyService = HomeKitAccessory.addService(HAP.Service.EveHomeHistory, "", 1); var tempHistory = this.getHistory(service.UUID, service.subtype); var historyreftime = (tempHistory.length == 0 ? (this.historyData.reset - EPOCH_OFFSET) : (tempHistory[0].time - EPOCH_OFFSET)); - this.EveHome = {service: historyService, linkedservice: service, type: service.UUID, sub: service.subtype, evetype: "energy", fields: "0702 0e01", entry: 0, count: tempHistory.length, reftime: historyreftime, send: 0}; - service.addCharacteristic(Characteristic.EveVoltage); - service.addCharacteristic(Characteristic.EveElectricCurrent); - service.addCharacteristic(Characteristic.EveCurrentConsumption); - service.addCharacteristic(Characteristic.EveTotalConsumption); + this.EveHome = {service: historyService, linkedservice: service, type: service.UUID, sub: service.subtype, evetype: "energy", fields: "0702 0e01", entry: 0, count: tempHistory.length, reftime: historyreftime, send: 0}; + service.addCharacteristic(HAP.Characteristic.EveFirmware); + service.addCharacteristic(HAP.Characteristic.EveElectricalVoltage); + service.addCharacteristic(HAP.Characteristic.EveElectricalCurrent); + service.addCharacteristic(HAP.Characteristic.EveElectricalWattage); + service.addCharacteristic(HAP.Characteristic.EveTotalConsumption); + + // Setup initial values and callbacks for charateristics we are using + service.updateCharacteristic(HAP.Characteristic.EveFirmware, encodeEveData(util.format("29 %s be", numberToEveHexString(807, 4)))); // firmware version (build xxxx))); + + service.updateCharacteristic(HAP.Characteristic.EveElectricalCurrent, this.#EveEnergyGetDetails(optionalParams.GetCommand, HAP.Characteristic.EveElectricalCurrent)); + service.getCharacteristic(HAP.Characteristic.EveElectricalCurrent).on("get", (callback) => { + callback(null, this.#EveEnergyGetDetails(optionalParams.GetCommand, HAP.Characteristic.EveElectricalCurrent)); + }); + + service.updateCharacteristic(HAP.Characteristic.EveElectricalVoltage, this.#EveEnergyGetDetails(optionalParams.GetCommand, HAP.Characteristic.EveElectricalVoltage)); + service.getCharacteristic(HAP.Characteristic.EveElectricalVoltage).on("get", (callback) => { + callback(null, this.#EveEnergyGetDetails(optionalParams.GetCommand, HAP.Characteristic.EveElectricalVoltage)); + }); + + service.updateCharacteristic(HAP.Characteristic.EveElectricalWattage, this.#EveEnergyGetDetails(optionalParams.GetCommand, HAP.Characteristic.EveElectricalWattage)); + service.getCharacteristic(HAP.Characteristic.EveElectricalWattage).on("get", (callback) => { + callback(null, this.#EveEnergyGetDetails(optionalParams.GetCommand, HAP.Characteristic.EveElectricalWattage)); + }); + this.#outputLogging("History", false, "History linked to EveHome app as '%s'", "Eve Energy"); + break; + } + + + case HAP.Service.LeakSensor.UUID : { + // treat these as EveHome Water Guard + var historyService = HomeKitAccessory.addService(HAP.Service.EveHomeHistory, "", 1); + var tempHistory = this.getHistory(service.UUID, service.subtype); + var historyreftime = (tempHistory.length == 0 ? (this.historyData.reset - EPOCH_OFFSET) : (tempHistory[0].time - EPOCH_OFFSET)); + + service.addCharacteristic(HAP.Characteristic.EveGetConfiguration); + service.addCharacteristic(HAP.Characteristic.EveSetConfiguration); + + if (service.testCharacteristic(HAP.Characteristic.StatusFault) == false) service.addCharacteristic(HAP.Characteristic.StatusFault); + + // <---- Still need to detwermin signature fields + this.EveHome = {service: historyService, linkedservice: service, type: service.UUID, sub: service.subtype, evetype: "waterguard", fields: "xxxx", entry: 0, count: tempHistory.length, reftime: historyreftime, send: 0}; + + // Need some internal storage to track Eve Water Guard configuration from EveHome app + this.EveWaterGuardPersist = {}; + this.EveWaterGuardPersist.firmware = optionalParams.hasOwnProperty("EveWaterGuard_firmware") ? optionalParams.EveWaterGuard_firmware : 2866; // Firmware version + this.EveWaterGuardPersist.lastalarmtest = optionalParams.hasOwnProperty("EveWaterGuard_lastalarmtest") ? optionalParams.EveWaterGuard_lastalarmtest : 0; // Time in seconds of alarm test + this.EveWaterGuardPersist.muted = optionalParams.hasOwnProperty("EveWaterGuard_muted") ? optionalParams.EveWaterGuard_muted : false; // Leak alarms are not muted + // Setup initial values and callbacks for charateristics we are using - service.getCharacteristic(Characteristic.EveCurrentConsumption).updateValue(this.__EveEnergyCurrentPower()); - service.getCharacteristic(Characteristic.EveCurrentConsumption).on("get", (callback) => { - callback(null, this.__EveEnergyCurrentPower()); + service.updateCharacteristic(HAP.Characteristic.EveGetConfiguration, this.#EveWaterGuardGetDetails(optionalParams.GetCommand)); + service.getCharacteristic(HAP.Characteristic.EveGetConfiguration).on("get", (callback) => { + callback(null, this.#EveWaterGuardGetDetails(optionalParams.GetCommand)); + }); + + service.getCharacteristic(HAP.Characteristic.EveSetConfiguration).on("set", (value, callback) => { + var valHex = decodeEveData(value); + var index = 0; + while (index < valHex.length) { + // first byte is command in this data stream + // second byte is size of data for command + var command = valHex.substr(index, 2); + var size = parseInt(valHex.substr(index + 2, 2), 16) * 2; + var data = valHex.substr(index + 4, parseInt(valHex.substr(index + 2, 2), 16) * 2); + + console.log(command, data) + switch(command) { + case "4d" : { + // Alarm test + // b4 - start + // 00 - finished + break; + } + + case "4e" : { + // Mute alarm + // 00 - unmute alarm + // 01 - mute alarm + // 03 - alarm test + if (data == "03") { + // Simulate a leak test + service.updateCharacteristic(HAP.Characteristic.LeakDetected, HAP.Characteristic.LeakDetected.LEAK_DETECTED); + this.EveWaterGuardPersist.lastalarmtest = Math.floor(Date.now() / 1000); // Now time for last test + + setTimeout(() => { + // Clear our simulated leak test after 5 seconds + service.updateCharacteristic(HAP.Characteristic.LeakDetected, HAP.Characteristic.LeakDetected.LEAK_NOT_DETECTED); + }, 5000); + } + if (data == "00" || data == "01") { + this.EveWaterGuardPersist.muted = (data == "01" ? true : false); + } + break; + } + + default : { + this.#outputLogging("History", true, "Unknown Eve Water Guard command '%s' with data '%s'", command, data); + break; + } + } + index += (4 + size); // Move to next command accounting for header size of 4 bytes + }; + callback(); }); + + this.#outputLogging("History", false, "History linked to EveHome app as '%s'", "Eve Water Guard"); break; } } // Setup callbacks if our service successfully created - if (this.EveHome && this.EveHome.hasOwnProperty("service")) { - this.EveHome.service.getCharacteristic(Characteristic.EveResetTotal).on("get", (callback) => {callback(null, this.historyData.reset - EPOCH_OFFSET)}); // time since history reset - this.EveHome.service.getCharacteristic(Characteristic.EveHistoryStatus).on("get", this.__EveHistoryStatus.bind(this)); - this.EveHome.service.getCharacteristic(Characteristic.EveHistoryEntries).on("get", this.__EveHistoryEntries.bind(this)); - this.EveHome.service.getCharacteristic(Characteristic.EveHistoryRequest).on("set", this.__EveHistoryRequest.bind(this)); - this.EveHome.service.getCharacteristic(Characteristic.EveSetTime).on("set", this.__EveSetTime.bind(this)); + if (typeof this.EveHome == "object" && this.EveHome.hasOwnProperty("service") == true) { + this.EveHome.service.getCharacteristic(HAP.Characteristic.EveResetTotal).on("get", (callback) => {callback(null, this.historyData.reset - EPOCH_OFFSET)}); // time since history reset + this.EveHome.service.getCharacteristic(HAP.Characteristic.EveHistoryStatus).on("get", this.#EveHistoryStatus.bind(this)); + this.EveHome.service.getCharacteristic(HAP.Characteristic.EveHistoryEntries).on("get", this.#EveHistoryEntries.bind(this)); + this.EveHome.service.getCharacteristic(HAP.Characteristic.EveHistoryRequest).on("set", this.#EveHistoryRequest.bind(this)); + this.EveHome.service.getCharacteristic(HAP.Characteristic.EveSetTime).on("set", this.#EveSetTime.bind(this)); return this.EveHome.service; // Return service handle for our EveHome accessory service } } } - updateEveHome(service, optionalParams) { - if (this.EveHome && this.EveHome.hasOwnProperty("service")) { - switch (service.UUID) { - case Service.SmokeSensor.UUID : { - service.getCharacteristic(Characteristic.EveDeviceStatus).updateValue(this.__EveSmokeGetDetails(optionalParams.GetCommand, Characteristic.EveDeviceStatus.UUID)); - service.getCharacteristic(Characteristic.EveGetConfiguration).updateValue(this.__EveSmokeGetDetails(optionalParams.GetCommand, Characteristic.EveGetConfiguration.UUID)); - break; - } + updateEveHome(service, GetCommand) { + if (typeof this.EveHome != "object" || this.EveHome.hasOwnProperty("service") == false || typeof GetCommand != "function") { + return; + } + + switch (service.UUID) { + case HAP.Service.SmokeSensor.UUID : { + service.updateCharacteristic(HAP.Characteristic.EveDeviceStatus, this.#EveSmokeGetDetails(GetCommand, HAP.Characteristic.EveDeviceStatus)); + service.updateCharacteristic(HAP.Characteristic.EveGetConfiguration, this.#EveSmokeGetDetails(GetCommand, HAP.Characteristic.EveGetConfiguration)); + break; + } + + case HAP.Service.HeaterCooler.UUID : + case HAP.Service.Thermostat.UUID : { + service.updateCharacteristic(HAP.Characteristic.EveProgramCommand, this.#EveThermoGetDetails(GetCommand)); + break; + } + + case HAP.Service.Valve.UUID : + case HAP.Service.IrrigationSystem.UUID : { + service.updateCharacteristic(HAP.Characteristic.EveGetConfiguration, this.#EveAquaGetDetails(GetCommand)); + break; + } + + case HAP.Service.Outlet.UUID : { + service.updateCharacteristic(HAP.Characteristic.EveElectricalWattage, this.#EveEnergyGetDetails(GetCommand, HAP.Characteristic.EveElectricalWattage)); + service.updateCharacteristic(HAP.Characteristic.EveElectricalVoltage, this.#EveEnergyGetDetails(GetCommand, HAP.Characteristic.EveElectricalVoltage)); + service.updateCharacteristic(HAP.Characteristic.EveElectricalCurrent, this.#EveEnergyGetDetails(GetCommand, HAP.Characteristic.EveElectricalCurrent)); + break; } - } + } } - __EveLastEventTime() { + #EveLastEventTime() { // calculate time in seconds since first event to last event. If no history we'll use the current time as the last event time var historyEntry = this.lastHistory(this.EveHome.type, this.EveHome.sub); var lastTime = Math.floor(new Date() / 1000) - (this.EveHome.reftime + EPOCH_OFFSET); @@ -1313,8 +1468,91 @@ class HomeKitHistory { return lastTime; } - __EveAquaGetDetails(optionGetFunction) { + #EveThermoGetDetails(optionGetFunction) { + // returns an encoded value formatted for an Eve Thermo device + // + // TODO - before enabling below need to workout: + // - mode graph to show + // - temperature unit setting + // - thermo 2020?? + // + // commands + // 11 - valve protection on/off - TODO + // 12 - temp offset + // 13 - schedules enabled/disabled + // 16 - Window/Door open status + // 100000 - open + // 000000 - close + // 14 - installation status + // c0,c8 = ok + // c1,c6,c9 = in-progress + // c2,c3,c4,c5 = error on removal + // c7 = not attached + // 19 - vacation mode + // 00ff - off + // 01 + "away temp" - enabled with vacation temp + // f4 - temperatures + // fa - programs for week + // fc - date/time (mmhhDDMMYY) + // 1a - default day program?? + + if (typeof optionGetFunction == "function") this.EveThermoPersist = optionGetFunction(this.EveThermoPersist); // Fill in details we might want to be dynamic + + // Encode the temperature offset into an unsigned value + var tempOffset = this.EveThermoPersist.tempoffset * 10; + if (tempOffset < 127) { + tempOffset = tempOffset + 256; + } + + // Encode date/time + var tempDateTime = this.EveThermoPersist.datetime; + if (typeof this.EveThermoPersist.datetime == "object") { + tempDateTime = this.EveThermoPersist.datetime.getMinutes().toString(16).padStart(2, "0") + this.EveThermoPersist.datetime.getHours().toString(16).padStart(2, "0") + this.EveThermoPersist.datetime.getDate().toString(16).padStart(2, "0") + (this.EveThermoPersist.datetime.getMonth() + 1).toString(16).padStart(2, "0") + parseInt(this.EveThermoPersist.datetime.getFullYear().toString().substr(-2)).toString(16).padStart(2, "0"); + } + + // Encode program schedule and temperatures + // f4 = temps + // fa = schedule + const EMPTYSCHEDULE = "ffffffffffffffff"; + var encodedSchedule = [EMPTYSCHEDULE, EMPTYSCHEDULE, EMPTYSCHEDULE, EMPTYSCHEDULE, EMPTYSCHEDULE, EMPTYSCHEDULE, EMPTYSCHEDULE]; + var encodedTemperatures = "0000"; + if (typeof this.EveThermoPersist.programs == "object") { + var tempTemperatures = []; + Object.entries(this.EveThermoPersist.programs).forEach(([id, days]) => { + var temp = ""; + days.schedule.forEach(time => { + temp = temp + numberToEveHexString(Math.round(time.start / 600), 2) + numberToEveHexString(Math.round((time.start + time.duration) / 600), 2); + tempTemperatures.push(time.ecotemp, time.comforttemp); + }); + encodedSchedule[DAYSOFWEEK.indexOf(days.days.toLowerCase())] = temp.substring(0, EMPTYSCHEDULE.length) + EMPTYSCHEDULE.substring(temp.length, EMPTYSCHEDULE.length); + }); + var ecoTemp = tempTemperatures.length == 0 ? 0 : Math.min(...tempTemperatures); + var comfortTemp = tempTemperatures.length == 0 ? 0 : Math.max(...tempTemperatures); + encodedTemperatures = numberToEveHexString((ecoTemp / 0.5), 2) + numberToEveHexString((comfortTemp / 0.5), 2); + } + + var value = util.format( + "12%s 13%s 14%s 19%s fc%s f40000%s fa%s", + numberToEveHexString(tempOffset, 2), + this.EveThermoPersist.enableschedule == true ? "01" : "00", + this.EveThermoPersist.attached = this.EveThermoPersist.attached == true ? "c0" : "c7", + this.EveThermoPersist.vacation == true ? "01" + numberToEveHexString(this.EveThermoPersist.vacationtemp * 2, 2) : "00ff", // away status and temp + numberToEveHexString(tempDateTime, 6), + encodedTemperatures, + encodedSchedule[0] + encodedSchedule[1] + encodedSchedule[2] + encodedSchedule[3] + encodedSchedule[4] + encodedSchedule[5] + encodedSchedule[6]); + + return encodeEveData(value); + } + + #EveAquaGetDetails(optionGetFunction) { // returns an encoded value formatted for an Eve Aqua device for water usage and last water time + if (typeof optionGetFunction == "function") this.EveAquaPersist = optionGetFunction(this.EveAquaPersist); // Fill in details we might want to be dynamic + + if (Array.isArray(this.EveAquaPersist.programs) == false) { + // Ensure any program information is an array + this.EveAquaPersist.programs = []; + } + var tempHistory = this.getHistory(this.EveHome.type, this.EveHome.sub); // get flattened history array for easier processing // Calculate total water usage over history period @@ -1325,39 +1563,109 @@ class HomeKitHistory { totalWater += parseFloat(historyEntry.water); } }); - if (typeof optionGetFunction == "function") this.EveAquaPersist = optionGetFunction(this.EveAquaPersist); // Fill in details we might want to be dynamic + + // Encode program schedule + // 45 = schedules + // 46 = days of weeks for schedule; + const EMPTYSCHEDULE = "0800"; + var encodedSchedule = ""; + var daysbitmask = 0; + var temp45Command = ""; + var temp46Command = ""; + + this.EveAquaPersist.programs.forEach((program) => { + var tempEncodedSchedule = ""; + program.schedule.forEach((schedule) => { + // Encode absolute time (ie: not sunrise/sunset one) + if (typeof schedule.start == "number") { + tempEncodedSchedule = tempEncodedSchedule + numberToEveHexString(((schedule.start / 60) << 5) + 0x05, 4); + tempEncodedSchedule = tempEncodedSchedule + numberToEveHexString((((schedule.start + schedule.duration) / 60) << 5) + 0x01, 4); + } + if (typeof schedule.start == "string" && schedule.start == "sunrise") { + if (schedule.offset < 0) { + tempEncodedSchedule = tempEncodedSchedule + numberToEveHexString(((Math.abs(schedule.offset) / 60) << 7) + 0x67, 4); + tempEncodedSchedule = tempEncodedSchedule + numberToEveHexString((((Math.abs(schedule.offset) + schedule.duration) / 60) << 7) + 0x63, 4); + } + if (schedule.offset >= 0) { + tempEncodedSchedule = tempEncodedSchedule + numberToEveHexString(((schedule.offset / 60) << 7) + 0x27, 4); + tempEncodedSchedule = tempEncodedSchedule + numberToEveHexString((((schedule.offset + schedule.duration) / 60) << 7) + 0x23, 4); + } + } + if (typeof schedule.start == "string" && schedule.start == "sunset") { + if (schedule.offset < 0) { + tempEncodedSchedule = tempEncodedSchedule + numberToEveHexString(((Math.abs(schedule.offset) / 60) << 7) + 0x47, 4); + tempEncodedSchedule = tempEncodedSchedule + numberToEveHexString((((Math.abs(schedule.offset) + schedule.duration) / 60) << 7) + 0x43, 4); + } + if (schedule.offset >= 0) { + tempEncodedSchedule = tempEncodedSchedule + numberToEveHexString(((schedule.offset / 60) << 7) + 0x07, 4); + tempEncodedSchedule = tempEncodedSchedule + numberToEveHexString((((schedule.offset + schedule.duration) / 60) << 7) + 0x03, 4); + } + } + }); + encodedSchedule = encodedSchedule + numberToEveHexString((tempEncodedSchedule.length / 8) < 2 ? 10 : 11, 2) + numberToEveHexString(tempEncodedSchedule.length / 8, 2) + tempEncodedSchedule; + + // Encode days for this program + // Program ID is set in 3bit repeating sections + // sunsatfrithuwedtuemon + program.days.forEach((day) => { + daysbitmask = daysbitmask + (program.id << (DAYSOFWEEK.indexOf(day) * 3)); + }); + }); + + // Build the encoded schedules command to send back to Eve + temp45Command = "05" + numberToEveHexString(this.EveAquaPersist.programs.length + 1, 2) + "000000" + EMPTYSCHEDULE + encodedSchedule; + temp45Command = "45" + numberToEveHexString(temp45Command.length / 2, 2) + temp45Command; + + // Build the encoded days command to send back to Eve + // 00000 appears to always be 1b202c?? + temp46Command = "05" + "000000" + numberToEveHexString((daysbitmask << 4) + 0x0f, 6); + temp46Command = temp46Command.padEnd((daysbitmask == 0 ? 18 : 168), "0"); // Pad the command out to Eve's lengths + temp46Command = "46" + numberToEveHexString(temp46Command.length / 2, 2) + temp46Command; var value = util.format( - "0002 2300 0302 %s d004 %s 9b04 %s 2f0e %s 0000 2e02 %s %s %s %s 0000000000000000 1e02 2300 0c", + "0002 2300 0302 %s d004 %s 9b04 %s 2f0e %s 2e02 %s 441105 %s%s%s%s %s %s %s 0000000000000000 1e02 2300 0c", numberToEveHexString(this.EveAquaPersist.firmware, 4), // firmware version (build xxxx) numberToEveHexString(tempHistory.length != 0 ? tempHistory[tempHistory.length - 1].time : 0, 8), // time of last event, 0 if never watered numberToEveHexString(Math.floor(new Date() / 1000), 8), // "now" time - numberToEveHexString(Math.floor(totalWater * 1000), 16), // total water usage in ml (64bit value) + numberToEveHexString(Math.floor(totalWater * 1000), 20), // total water usage in ml (64bit value) numberToEveHexString(Math.floor((this.EveAquaPersist.flowrate * 1000) / 60), 4), // water flow rate (16bit value) - this.EveAquaPersist.command44, - this.EveAquaPersist.command45, - this.EveAquaPersist.command46); + numberToEveHexString(this.EveAquaPersist.enableschedule == true ? parseInt("10111", 2) : parseInt("10110", 2), 8), + numberToEveHexString(Math.floor(this.EveAquaPersist.utcoffset / 60), 8), + floatToEveHexString(this.EveAquaPersist.latitude, 8), + floatToEveHexString(this.EveAquaPersist.longitude, 8), + (this.EveAquaPersist.pause != 0 ? "4b04" + numberToEveHexString((this.EveAquaPersist.pause - 1) * 1440, 8) : ""), + temp45Command, + temp46Command); return encodeEveData(value); }; - __EveEnergyCurrentPower() { - // Use last history entry for currrent power consumption - var historyEntry = this.lastHistory(this.EveHome.type, this.EveHome.sub); - var lastWatts = 0; - if (historyEntry && Object.keys(historyEntry).length != 0) { - lastWatts = historyEntry.watts; + #EveEnergyGetDetails(optionGetFunction, returnForCharacteristic) { + var energyDetails = {}; + var returnValue = null; + + if (typeof optionGetFunction == "function") energyDetails = optionGetFunction(energyDetails); // Fill in details we might want to be dynamic + + if (returnForCharacteristic.UUID == HAP.Characteristic.EveElectricalWattage.UUID && energyDetails.hasOwnProperty("watts") == true && typeof energyDetails.watts == "number") { + returnValue = energyDetails.watts; + } + if (returnForCharacteristic.UUID == HAP.Characteristic.EveElectricalVoltage.UUID && energyDetails.hasOwnProperty("volts") == true && typeof energyDetails.volts == "number") { + returnValue = energyDetails.volts; } - return lastWatts; + if (returnForCharacteristic.UUID == HAP.Characteristic.EveElectricalCurrent.UUID && energyDetails.hasOwnProperty("amps") == true && typeof energyDetails.amps == "number") { + returnValue = energyDetails.amps; + } + + return returnValue; } - __EveSmokeGetDetails(optionGetFunction, returnForCharacteristic) { + #EveSmokeGetDetails(optionGetFunction, returnForCharacteristic) { // returns an encoded value formatted for an Eve Smoke device var returnValue = null; if (typeof optionGetFunction == "function") this.EveSmokePersist = optionGetFunction(this.EveSmokePersist); // Fill in details we might want to be dynamic - if (returnForCharacteristic == Characteristic.EveGetConfiguration.UUID) { + if (returnForCharacteristic.UUID == HAP.Characteristic.EveGetConfiguration.UUID) { var value = util.format( "0002 1800 0302 %s 9b04 %s 8608 %s 1e02 1800 0c", numberToEveHexString(this.EveSmokePersist.firmware, 4), // firmware version (build xxxx) @@ -1366,7 +1674,7 @@ class HomeKitHistory { returnValue = encodeEveData(value); } - if (returnForCharacteristic == Characteristic.EveDeviceStatus.UUID) { + if (returnForCharacteristic.UUID == HAP.Characteristic.EveDeviceStatus.UUID) { // Status bits // 0 = Smoked Detected // 1 = Heat Detected @@ -1380,7 +1688,7 @@ class HomeKitHistory { // 24 & 25 = alarms paused // 25 = alarm muted var value = 0x00000000; - if (this.EveHome.linkedservice.getCharacteristic(Characteristic.SmokeDetected).value == Characteristic.SmokeDetected.SMOKE_DETECTED) value |= (1 << 0); // 1st bit, smoke detected + if (this.EveHome.linkedservice.getCharacteristic(HAP.Characteristic.SmokeDetected).value == HAP.Characteristic.SmokeDetected.SMOKE_DETECTED) value |= (1 << 0); // 1st bit, smoke detected if (this.EveSmokePersist.heatstatus != 0) value |= (1 << 1); // 2th bit - heat detected if (this.EveSmokePersist.alarmtest == true) value |= (1 << 2); // 4th bit - alarm test running if (this.EveSmokePersist.smoketestpassed == false) value |= (1 << 5); // 5th bit - smoke test OK @@ -1394,7 +1702,20 @@ class HomeKitHistory { return returnValue; }; - __EveHistoryStatus(callback) { + #EveWaterGuardGetDetails(optionGetFunction) { + // returns an encoded value formatted for an Eve Water Guard + if (typeof optionGetFunction == "function") this.EveWaterGuardPersist = optionGetFunction(this.EveWaterGuardPersist); // Fill in details we might want to be dynamic + + var value = util.format( + "0002 5b00 0302 %s 9b04 %s 8608 %s 4e01 %s %s 1e02 5b00 0c", + numberToEveHexString(this.EveWaterGuardPersist.firmware, 4), // firmware version (build xxxx) + numberToEveHexString(Math.floor(new Date() / 1000), 8), // "now" time + numberToEveHexString(this.EveWaterGuardPersist.lastalarmtest, 8), // Not sure why 64bit value??? + numberToEveHexString(this.EveWaterGuardPersist.muted == true ? 1 : 0, 2)); // Alarm mute status + return encodeEveData(value); + }; + + #EveHistoryStatus(callback) { var tempHistory = this.getHistory(this.EveHome.type, this.EveHome.sub); // get flattened history array for easier processing var historyTime = (tempHistory.length == 0 ? Math.floor(new Date() / 1000) : tempHistory[tempHistory.length - 1].time); this.EveHome.reftime = (tempHistory.length == 0 ? (this.historyData.reset - EPOCH_OFFSET) : (tempHistory[0].time - EPOCH_OFFSET)); @@ -1404,17 +1725,17 @@ class HomeKitHistory { "%s 00000000 %s %s %s %s %s %s 000000000101", numberToEveHexString(historyTime - this.EveHome.reftime - EPOCH_OFFSET, 8), numberToEveHexString(this.EveHome.reftime, 8), // reference time (time of first history??) - numberToEveHexString(this.EveHome.fields.trim().match(/([\s]+)/g).length + 1, 2), // Calclate number of fields we have + numberToEveHexString(this.EveHome.fields.trim().match(/\S*[0-9]\S*/g).length, 2), // Calclate number of fields we have this.EveHome.fields.trim(), // Fields listed in string. Each field is seperated by spaces numberToEveHexString(this.EveHome.count, 4), // count of entries numberToEveHexString(this.maxEntries == 0 ? MAX_HISTORY_SIZE : this.maxEntries, 4), // history max size numberToEveHexString(1, 8)); // first entry callback(null, encodeEveData(value)); - // this.debug && console.debug(getTimestamp() + " [HISTORY] __EveHistoryStatus: history for '%s:%s' (%s) - Entries %s", this.EveHome.type, this.EveHome.sub, this.EveHome.evetype, this.EveHome.count); + // this.#outputLogging("History", true, "#EveHistoryStatus: history for '%s:%s' (%s) - Entries %s", this.EveHome.type, this.EveHome.sub, this.EveHome.evetype, this.EveHome.count); } - __EveHistoryEntries(callback) { + #EveHistoryEntries(callback) { // Streams our history data back to EveHome when requested var dataStream = ""; if (this.EveHome.entry <= this.EveHome.count && this.EveHome.send != 0) { @@ -1424,7 +1745,10 @@ class HomeKitHistory { var data = util.format( "%s 0100 0000 81 %s 0000 0000 00 0000", numberToEveHexString(this.EveHome.entry, 8), - numberToEveHexString(this.EveHome.reftime, 8)).replace(/ /g, ""); + numberToEveHexString(this.EveHome.reftime, 8)); + + // Format the data string, including calculating the number of "bytes" the data fits into + data = data.replace(/ /g, ""); dataStream += util.format("%s %s", (data.length / 2 + 1).toString(16), data); for (var i = 0; i < EVEHOME_MAX_STREAM; i++) { @@ -1434,6 +1758,7 @@ class HomeKitHistory { "%s %s", numberToEveHexString(this.EveHome.entry, 8), numberToEveHexString(historyEntry.time - this.EveHome.reftime - EPOCH_OFFSET, 8)); // Create the common header data for eve entry + switch (this.EveHome.evetype) { case "aqua" : { // 1f01 2a08 2302 @@ -1558,7 +1883,7 @@ class HomeKitHistory { numberToEveHexString(historyEntry.temperature * 100, 4), // temperature numberToEveHexString(historyEntry.humidity * 100, 4), // Humidity numberToEveHexString(tempTarget * 100, 4), // target temperature for heating - numberToEveHexString(historyEntry.status == 2 ? 100 : historyEntry.status == 1 ? 50 : 0, 2), // 0% valve position = off, 50% = cooling, 100% = heating + numberToEveHexString(historyEntry.status == 2 ? 100 : historyEntry.status == 3 ? 50 : 0, 2), // 0% valve position = off, 50% = cooling, 100% = heating numberToEveHexString(0, 2), // Thermo target numberToEveHexString(0, 2)); // Window open status 0 = window closed, 1 = open break; @@ -1587,9 +1912,15 @@ class HomeKitHistory { console.log("blinds history"); break; } + + case "waterguard" : { + // TODO - What do we send back?? + console.log("water guard history"); + break; + } } - // Format the data string, including calcuating the number of "bytes" the data fits into + // Format the data string, including calculating the number of "bytes" the data fits into data = data.replace(/ /g, ""); dataStream += util.format("%s%s", numberToEveHexString(data.length / 2 + 1, 2), data); @@ -1599,20 +1930,20 @@ class HomeKitHistory { } if (this.EveHome.entry > this.EveHome.count) { // No more history data to send back - // this.debug && console.debug(getTimestamp() + " [HISTORY] __EveHistoryEntries: sent '%s' entries to EveHome ('%s') for '%s:%s'", this.EveHome.send, this.EveHome.evetype, this.EveHome.type, this.EveHome.sub); + // this.#outputLogging("History", true, "#EveHistoryEntries: sent '%s' entries to EveHome ('%s') for '%s:%s'", this.EveHome.send, this.EveHome.evetype, this.EveHome.type, this.EveHome.sub); this.EveHome.send = 0; // no more to send dataStream += "00"; } } else { // We're not transferring any data back - // this.debug && console.debug(getTimestamp() + " [HISTORY] __EveHistoryEntries: do we ever get here.....???", this.EveHome.send, this.EveHome.evetype, this.EveHome.type, this.EveHome.sub, this.EveHome.entry); + // this.#outputLogging("History", true, "#EveHistoryEntries: do we ever get here.....???", this.EveHome.send, this.EveHome.evetype, this.EveHome.type, this.EveHome.sub, this.EveHome.entry); this.EveHome.send = 0; // no more to send dataStream = "00"; } callback(null, encodeEveData(dataStream)); } - __EveHistoryRequest(value, callback) { + #EveHistoryRequest(value, callback) { // Requesting history, starting at specific entry this.EveHome.entry = EveHexStringToNumber(decodeEveData(value).substring(4, 12)); // Starting entry if (this.EveHome.entry == 0) { @@ -1620,14 +1951,24 @@ class HomeKitHistory { } this.EveHome.send = (this.EveHome.count - this.EveHome.entry + 1); // Number of entries we're expected to send callback(); - // this.debug && console.debug(getTimestamp() + " [HISTORY] __EveHistoryRequest: requested address", this.EveHome.entry); + // this.#outputLogging("History", true, "#EveHistoryRequest: requested address", this.EveHome.entry); } - __EveSetTime(value, callback) { + #EveSetTime(value, callback) { // Time stamp from EveHome var timestamp = (EPOCH_OFFSET + EveHexStringToNumber(decodeEveData(value))); callback(); - // this.debug && console.debug(getTimestamp() + " [HISTORY] __EveSetTime: timestamp offset", new Date(timestamp * 1000)); + // this.#outputLogging("History", true, "#EveSetTime: timestamp offset", new Date(timestamp * 1000)); + } + + #outputLogging(accessoryName, useConsoleDebug, ...outputMessage) { + var timeStamp = String(new Date().getFullYear()).padStart(4, "0") + "-" + String(new Date().getMonth() + 1).padStart(2, "0") + "-" + String(new Date().getDate()).padStart(2, "0") + " " + String(new Date().getHours()).padStart(2, "0") + ":" + String(new Date().getMinutes()).padStart(2, "0") + ":" + String(new Date().getSeconds()).padStart(2, "0"); + if (useConsoleDebug == false) { + console.log(timeStamp + " [" + accessoryName + "] " + util.format(...outputMessage)); + } + if (useConsoleDebug == true) { + console.debug(timeStamp + " [" + accessoryName + "] " + util.format(...outputMessage)); + } } } @@ -1643,332 +1984,339 @@ function decodeEveData(data) { } // Converts a integer number into a string for EveHome, including formatting to byte width and reverse byte order -// handles upto 64bit values +// handles upto 128bit values function numberToEveHexString(number, bytes) { - if (typeof number != "number") return number; - var tempString = "0000000000000000" + Math.floor(number).toString(16); - tempString = tempString.slice(-1 * bytes).match(/[a-fA-F0-9]{2}/g).reverse().join(''); + if (typeof number != "number" || bytes > 32) { + return number; + } + var tempString = "00000000000000000000000000000000" + Math.floor(number).toString(16); + tempString = tempString.slice(-1 * bytes).match(/[a-fA-F0-9]{2}/g).reverse().join(""); return tempString; } // Converts a float number into a string for EveHome, including formatting to byte width and reverse byte order -// handles upto 64bit values +// handles upto 128bit values function floatToEveHexString(number, bytes) { - if (typeof number != "number") return number; + if (typeof number != "number" || bytes > 32) { + return number; + } var buf = Buffer.allocUnsafe(4); buf.writeFloatBE(number, 0); - var tempString = "0000000000000000" + buf.toString("hex"); - tempString = tempString.slice(-1 * bytes).match(/[a-fA-F0-9]{2}/g).reverse().join(''); + var tempString = "00000000000000000000000000000000" + buf.toString("hex"); + tempString = tempString.slice(-1 * bytes).match(/[a-fA-F0-9]{2}/g).reverse().join(""); return tempString; } // Converts Eve encoded hex string to number function EveHexStringToNumber(string) { if (typeof string != "string") return string; - var tempString = string.match(/[a-fA-F0-9]{2}/g).reverse().join(''); - return Number(`0x${tempString}`); // convert to number on return + var tempString = string.match(/[a-fA-F0-9]{2}/g).reverse().join(""); + return Number(parseInt(tempString, 16)); // convert to number on return } // Converts Eve encoded hex string to floating number with optional precision for result function EveHexStringToFloat(string, precision) { if (typeof string != "string") return string; - var tempString = string.match(/[a-fA-F0-9]{2}/g).reverse().join(''); + var tempString = string.match(/[a-fA-F0-9]{2}/g).reverse().join(""); var float = Buffer.from(tempString, "hex").readFloatBE(0); - return (precision != 0) ? float.toFixed(precision) : float; -} - -function getTimestamp() { - const pad = (n,s=2) => (`${new Array(s).fill(0)}${n}`).slice(-s); - const d = new Date(); - - return `${pad(d.getFullYear(),4)}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; + return Number((precision != 0) ? float.toFixed(precision) : float); } // Define HomeKit characteristics // Eve Reset Total -Characteristic.EveResetTotal = function() { - Characteristic.call(this, "Eve Reset Total", "E863F112-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveResetTotal = function() { + HAP.Characteristic.call(this, "Eve Reset Total", "E863F112-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.UINT32, - unit: Characteristic.Units.SECONDS, // since 2001/01/01 - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY, Characteristic.Perms.WRITE] + format: HAP.Characteristic.Formats.UINT32, + unit: HAP.Characteristic.Units.SECONDS, // since 2001/01/01 + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY, HAP.Characteristic.Perms.WRITE] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveResetTotal, Characteristic); -Characteristic.EveResetTotal.UUID = "E863F112-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveResetTotal, HAP.Characteristic); +HAP.Characteristic.EveResetTotal.UUID = "E863F112-079E-48FF-8F27-9C2605A29F52"; // EveHistoryStatus -Characteristic.EveHistoryStatus = function() { - Characteristic.call(this, "Eve History Status", "E863F116-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveHistoryStatus = function() { + HAP.Characteristic.call(this, "Eve History Status", "E863F116-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.DATA, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY, Characteristic.Perms.HIDDEN] + format: HAP.Characteristic.Formats.DATA, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY, HAP.Characteristic.Perms.HIDDEN] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveHistoryStatus, Characteristic); -Characteristic.EveHistoryStatus.UUID = "E863F116-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveHistoryStatus, HAP.Characteristic); +HAP.Characteristic.EveHistoryStatus.UUID = "E863F116-079E-48FF-8F27-9C2605A29F52"; // EveHistoryEntries -Characteristic.EveHistoryEntries = function() { - Characteristic.call(this, "Eve History Entries", "E863F117-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveHistoryEntries = function() { + HAP.Characteristic.call(this, "Eve History Entries", "E863F117-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.DATA, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY, Characteristic.Perms.HIDDEN] + format: HAP.Characteristic.Formats.DATA, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY, HAP.Characteristic.Perms.HIDDEN] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveHistoryEntries, Characteristic); -Characteristic.EveHistoryEntries.UUID = "E863F117-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveHistoryEntries, HAP.Characteristic); +HAP.Characteristic.EveHistoryEntries.UUID = "E863F117-079E-48FF-8F27-9C2605A29F52"; // EveHistoryRequest -Characteristic.EveHistoryRequest = function() { - Characteristic.call(this, "Eve History Request", "E863F11C-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveHistoryRequest = function() { + HAP.Characteristic.call(this, "Eve History Request", "E863F11C-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.DATA, - perms: [Characteristic.Perms.WRITE, Characteristic.Perms.HIDDEN] + format: HAP.Characteristic.Formats.DATA, + perms: [HAP.Characteristic.Perms.WRITE, HAP.Characteristic.Perms.HIDDEN] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveHistoryRequest, Characteristic); -Characteristic.EveHistoryRequest.UUID = "E863F11C-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveHistoryRequest, HAP.Characteristic); +HAP.Characteristic.EveHistoryRequest.UUID = "E863F11C-079E-48FF-8F27-9C2605A29F52"; // EveSetTime -Characteristic.EveSetTime = function() { - Characteristic.call(this, "EveHome SetTime", "E863F121-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveSetTime = function() { + HAP.Characteristic.call(this, "Eve SetTime", "E863F121-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.DATA, - perms: [Characteristic.Perms.WRITE, Characteristic.Perms.HIDDEN] + format: HAP.Characteristic.Formats.DATA, + perms: [HAP.Characteristic.Perms.WRITE, HAP.Characteristic.Perms.HIDDEN] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveSetTime, Characteristic); -Characteristic.EveSetTime.UUID = "E863F121-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveSetTime, HAP.Characteristic); +HAP.Characteristic.EveSetTime.UUID = "E863F121-079E-48FF-8F27-9C2605A29F52"; -Characteristic.EveValvePosition = function() { - Characteristic.call(this, "Eve Valve Position", "E863F12E-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveValvePosition = function() { + HAP.Characteristic.call(this, "Eve Valve Position", "E863F12E-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.UINT8, - unit: Characteristic.Units.PERCENTAGE, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + format: HAP.Characteristic.Formats.UINT8, + unit: HAP.Characteristic.Units.PERCENTAGE, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveValvePosition, Characteristic); -Characteristic.EveValvePosition.UUID = "E863F12E-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveValvePosition, HAP.Characteristic); +HAP.Characteristic.EveValvePosition.UUID = "E863F12E-079E-48FF-8F27-9C2605A29F52"; -Characteristic.EveLastActivation = function() { - Characteristic.call(this, "Eve Last Activation", "E863F11A-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveLastActivation = function() { + HAP.Characteristic.call(this, "Eve Last Activation", "E863F11A-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.UINT32, - unit: Characteristic.Units.SECONDS, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + format: HAP.Characteristic.Formats.UINT32, + unit: HAP.Characteristic.Units.SECONDS, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveLastActivation, Characteristic); -Characteristic.EveLastActivation.UUID = "E863F11A-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveLastActivation, HAP.Characteristic); +HAP.Characteristic.EveLastActivation.UUID = "E863F11A-079E-48FF-8F27-9C2605A29F52"; -Characteristic.EveTimesOpened = function() { - Characteristic.call(this, "Eve Times Opened", "E863F129-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveTimesOpened = function() { + HAP.Characteristic.call(this, "Eve Times Opened", "E863F129-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.UINT32, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + format: HAP.Characteristic.Formats.UINT32, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveTimesOpened, Characteristic); -Characteristic.EveTimesOpened.UUID = "E863F129-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveTimesOpened, HAP.Characteristic); +HAP.Characteristic.EveTimesOpened.UUID = "E863F129-079E-48FF-8F27-9C2605A29F52"; -Characteristic.EveClosedDuration = function() { - Characteristic.call(this, "Eve Closed Duration", "E863F118-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveClosedDuration = function() { + HAP.Characteristic.call(this, "Eve Closed Duration", "E863F118-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.UINT32, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + format: HAP.Characteristic.Formats.UINT32, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveClosedDuration, Characteristic); -Characteristic.EveClosedDuration.UUID = "E863F118-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveClosedDuration, HAP.Characteristic); +HAP.Characteristic.EveClosedDuration.UUID = "E863F118-079E-48FF-8F27-9C2605A29F52"; -Characteristic.EveOpenDuration = function() { - Characteristic.call(this, "Eve Opened Duration", "E863F119-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveOpenDuration = function() { + HAP.Characteristic.call(this, "Eve Opened Duration", "E863F119-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.UINT32, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + format: HAP.Characteristic.Formats.UINT32, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveOpenDuration, Characteristic); -Characteristic.EveOpenDuration.UUID = "E863F119-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveOpenDuration, HAP.Characteristic); +HAP.Characteristic.EveOpenDuration.UUID = "E863F119-079E-48FF-8F27-9C2605A29F52"; -Characteristic.EveProgramCommand = function() { - Characteristic.call(this, "Eve Program Command", "E863F12C-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveProgramCommand = function() { + HAP.Characteristic.call(this, "Eve Program Command", "E863F12C-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.DATA, - perms: [Characteristic.Perms.WRITE] + format: HAP.Characteristic.Formats.DATA, + perms: [HAP.Characteristic.Perms.WRITE] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveProgramCommand, Characteristic); -Characteristic.EveProgramCommand.UUID = "E863F12C-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveProgramCommand, HAP.Characteristic); +HAP.Characteristic.EveProgramCommand.UUID = "E863F12C-079E-48FF-8F27-9C2605A29F52"; -Characteristic.EveProgramData = function() { - Characteristic.call(this, "Eve Program Data", "E863F12F-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveProgramData = function() { + HAP.Characteristic.call(this, "Eve Program Data", "E863F12F-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.DATA, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + format: HAP.Characteristic.Formats.DATA, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveProgramData, Characteristic); -Characteristic.EveProgramData.UUID = "E863F12F-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveProgramData, HAP.Characteristic); +HAP.Characteristic.EveProgramData.UUID = "E863F12F-079E-48FF-8F27-9C2605A29F52"; -Characteristic.EveVoltage = function() { - Characteristic.call(this, "Eve Voltage", "E863F10A-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveElectricalVoltage = function() { + HAP.Characteristic.call(this, "Eve Voltage", "E863F10A-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.FLOAT, + format: HAP.Characteristic.Formats.FLOAT, unit: "V", - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveVoltage, Characteristic); -Characteristic.EveVoltage.UUID = "E863F10A-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveElectricalVoltage, HAP.Characteristic); +HAP.Characteristic.EveElectricalVoltage.UUID = "E863F10A-079E-48FF-8F27-9C2605A29F52"; -Characteristic.EveElectricCurrent = function() { - Characteristic.call(this, "Eve Current", "E863F126-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveElectricalCurrent = function() { + HAP.Characteristic.call(this, "Eve Current", "E863F126-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.FLOAT, + format: HAP.Characteristic.Formats.FLOAT, unit: "A", - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveElectricCurrent, Characteristic); -Characteristic.EveElectricCurrent.UUID = "E863F126-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveElectricalCurrent, HAP.Characteristic); +HAP.Characteristic.EveElectricalCurrent.UUID = "E863F126-079E-48FF-8F27-9C2605A29F52"; -Characteristic.EveTotalConsumption = function() { - Characteristic.call(this, "Eve Total Consumption", "E863F10C-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveTotalConsumption = function() { + HAP.Characteristic.call(this, "Eve Total Consumption", "E863F10C-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.FLOAT, + format: HAP.Characteristic.Formats.FLOAT, unit: 'kWh', - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveTotalConsumption, Characteristic); -Characteristic.EveTotalConsumption.UUID = "E863F10C-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveTotalConsumption, HAP.Characteristic); +HAP.Characteristic.EveTotalConsumption.UUID = "E863F10C-079E-48FF-8F27-9C2605A29F52"; -Characteristic.EveCurrentConsumption = function() { - Characteristic.call(this, "Eve Current Consumption", "E863F10D-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveElectricalWattage = function() { + HAP.Characteristic.call(this, "Eve Watts", "E863F10D-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.FLOAT, + format: HAP.Characteristic.Formats.FLOAT, unit: "W", - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveCurrentConsumption, Characteristic); -Characteristic.EveCurrentConsumption.UUID = "E863F10D-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveElectricalWattage, HAP.Characteristic); +HAP.Characteristic.EveElectricalWattage.UUID = "E863F10D-079E-48FF-8F27-9C2605A29F52"; -Characteristic.EveGetConfiguration = function() { - Characteristic.call(this, "Eve Get Configuration", "E863F131-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveGetConfiguration = function() { + HAP.Characteristic.call(this, "Eve Get Configuration", "E863F131-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.DATA, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + format: HAP.Characteristic.Formats.DATA, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveGetConfiguration, Characteristic); -Characteristic.EveGetConfiguration.UUID = "E863F131-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveGetConfiguration, HAP.Characteristic); +HAP.Characteristic.EveGetConfiguration.UUID = "E863F131-079E-48FF-8F27-9C2605A29F52"; -Characteristic.EveSetConfiguration = function() { - Characteristic.call(this, "Eve Set Configuration", "E863F11D-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveSetConfiguration = function() { + HAP.Characteristic.call(this, "Eve Set Configuration", "E863F11D-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.DATA, - perms: [Characteristic.Perms.WRITE, Characteristic.Perms.HIDDEN] + format: HAP.Characteristic.Formats.DATA, + perms: [HAP.Characteristic.Perms.WRITE, HAP.Characteristic.Perms.HIDDEN] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveSetConfiguration, Characteristic); -Characteristic.EveSetConfiguration.UUID = "E863F11D-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveSetConfiguration, HAP.Characteristic); +HAP.Characteristic.EveSetConfiguration.UUID = "E863F11D-079E-48FF-8F27-9C2605A29F52"; -Characteristic.EveFirmware = function() { - Characteristic.call(this, "Eve Firmware", "E863F11E-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveFirmware = function() { + HAP.Characteristic.call(this, "Eve Firmware", "E863F11E-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.DATA, - perms: [Characteristic.Perms.READ, Characteristic.Perms.WRITE, Characteristic.Perms.NOTIFY] + format: HAP.Characteristic.Formats.DATA, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.WRITE, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveFirmware, Characteristic); -Characteristic.EveFirmware.UUID = "E863F11E-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveFirmware, HAP.Characteristic); +HAP.Characteristic.EveFirmware.UUID = "E863F11E-079E-48FF-8F27-9C2605A29F52"; -Characteristic.EveSensitivity = function() { - Characteristic.call(this, "Eve Motion Sensitivity", "E863F120-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveSensitivity = function() { + HAP.Characteristic.call(this, "Eve Motion Sensitivity", "E863F120-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.UINT8, - perms: [Characteristic.Perms.READ, Characteristic.Perms.WRITE, Characteristic.Perms.NOTIFY], + format: HAP.Characteristic.Formats.UINT8, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.WRITE, HAP.Characteristic.Perms.NOTIFY], minValue: 0, maxValue: 7, validValues: [0, 4, 7] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveSensitivity, Characteristic); -Characteristic.EveSensitivity.UUID = "E863F120-079E-48FF-8F27-9C2605A29F52"; -Characteristic.EveSensitivity.HIGH = 0 -Characteristic.EveSensitivity.MEDIUM = 4 -Characteristic.EveSensitivity.LOW = 7 - -Characteristic.EveDuration = function() { - Characteristic.call(this, "Eve Motion Duration", "E863F12D-079E-48FF-8F27-9C2605A29F52"); +util.inherits(HAP.Characteristic.EveSensitivity, HAP.Characteristic); +HAP.Characteristic.EveSensitivity.UUID = "E863F120-079E-48FF-8F27-9C2605A29F52"; +HAP.Characteristic.EveSensitivity.HIGH = 0; +HAP.Characteristic.EveSensitivity.MEDIUM = 4; +HAP.Characteristic.EveSensitivity.LOW = 7; + +HAP.Characteristic.EveDuration = function() { + HAP.Characteristic.call(this, "Eve Motion Duration", "E863F12D-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.UINT16, - perms: [Characteristic.Perms.READ, Characteristic.Perms.WRITE, Characteristic.Perms.NOTIFY], + format: HAP.Characteristic.Formats.UINT16, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.WRITE, HAP.Characteristic.Perms.NOTIFY], minValue: 5, maxValue: 54000, validValues: [5, 10, 20, 30, 60, 120, 300, 600, 1200, 1800, 3600, 7200, 10800, 18000, 36000, 43200, 54000] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveDuration, Characteristic); -Characteristic.EveDuration.UUID = "E863F12D-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveDuration, HAP.Characteristic); +HAP.Characteristic.EveDuration.UUID = "E863F12D-079E-48FF-8F27-9C2605A29F52"; -Characteristic.EveDeviceStatus = function() { - Characteristic.call(this, "Eve Device Status", "E863F134-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveDeviceStatus = function() { + HAP.Characteristic.call(this, "Eve Device Status", "E863F134-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.UINT32, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + format: HAP.Characteristic.Formats.UINT32, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveDeviceStatus, Characteristic); -Characteristic.EveDeviceStatus.UUID = "E863F134-079E-48FF-8F27-9C2605A29F52"; - -Characteristic.EveAirPressure = function() { - Characteristic.call(this, "Eve Air Pressure", "E863F10F-079E-48FF-8F27-9C2605A29F52"); +util.inherits(HAP.Characteristic.EveDeviceStatus, HAP.Characteristic); +HAP.Characteristic.EveDeviceStatus.UUID = "E863F134-079E-48FF-8F27-9C2605A29F52"; +HAP.Characteristic.EveDeviceStatus.SMOKE_DETECTED = (1 << 0); +HAP.Characteristic.EveDeviceStatus.HEAT_DETECTED = (1 << 1); +HAP.Characteristic.EveDeviceStatus.ALARM_TEST_ACTIVE = (1 << 2); +HAP.Characteristic.EveDeviceStatus.SMOKE_SENSOR_ERROR = (1 << 5); +HAP.Characteristic.EveDeviceStatus.HEAT_SENSOR_ERROR = (1 << 7); +HAP.Characteristic.EveDeviceStatus.SMOKE_CHAMBER_ERROR = (1 << 9); +HAP.Characteristic.EveDeviceStatus.SMOKE_SENSOR_DEACTIVATED = (1 << 14); +HAP.Characteristic.EveDeviceStatus.FLASH_STATUS_LED = (1 << 15); +HAP.Characteristic.EveDeviceStatus.ALARM_PAUSED = (1 << 24); +HAP.Characteristic.EveDeviceStatus.ALARM_MUTED = (1 << 25); + +HAP.Characteristic.EveAirPressure = function() { + HAP.Characteristic.call(this, "Eve Air Pressure", "E863F10F-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.UINT16, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY], + format: HAP.Characteristic.Formats.UINT16, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY], unit: "hPa", minValue: 700, maxValue: 1100, }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveAirPressure, Characteristic); -Characteristic.EveAirPressure.UUID = "E863F10F-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveAirPressure, HAP.Characteristic); +HAP.Characteristic.EveAirPressure.UUID = "E863F10F-079E-48FF-8F27-9C2605A29F52"; -Characteristic.EveElevation = function() { - Characteristic.call(this, "Eve Elevation", "E863F130-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveElevation = function() { + HAP.Characteristic.call(this, "Eve Elevation", "E863F130-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.INT, - perms: [Characteristic.Perms.READ, Characteristic.Perms.WRITE, Characteristic.Perms.NOTIFY], + format: HAP.Characteristic.Formats.INT, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.WRITE, HAP.Characteristic.Perms.NOTIFY], unit: "m", minValue: -430, maxValue: 8850, @@ -1976,14 +2324,14 @@ Characteristic.EveElevation = function() { }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveElevation, Characteristic); -Characteristic.EveElevation.UUID = "E863F130-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveElevation, HAP.Characteristic); +HAP.Characteristic.EveElevation.UUID = "E863F130-079E-48FF-8F27-9C2605A29F52"; -Characteristic.EveVOCLevel = function() { - Characteristic.call(this, "VOC Level", "E863F10B-079E-48FF-8F27-9C2605A29F52"); +HAP.Characteristic.EveVOCLevel = function() { + HAP.Characteristic.call(this, "VOC Level", "E863F10B-079E-48FF-8F27-9C2605A29F52"); this.setProps({ - format: Characteristic.Formats.UINT16, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY], + format: HAP.Characteristic.Formats.UINT16, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY], unit: "ppm", minValue: 5, maxValue: 5000, @@ -1991,121 +2339,139 @@ Characteristic.EveVOCLevel = function() { }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.EveVOCLevel, Characteristic); -Characteristic.EveVOCLevel.UUID = "E863F10B-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Characteristic.EveVOCLevel, HAP.Characteristic); +HAP.Characteristic.EveVOCLevel.UUID = "E863F10B-079E-48FF-8F27-9C2605A29F52"; +HAP.Characteristic.EveWeatherTrend = function() { + HAP.Characteristic.call(this, "Eve Weather Trend", "E863F136-079E-48FF-8F27-9C2605A29F52"); + this.setProps({ + format: HAP.Characteristic.Formats.UINT8, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY], + minValue: 0, + maxValue: 15, + minStep: 1, + }); + this.value = this.getDefaultValue(); +} +util.inherits(HAP.Characteristic.EveWeatherTrend, HAP.Characteristic); +HAP.Characteristic.EveWeatherTrend.UUID = "E863F136-079E-48FF-8F27-9C2605A29F52"; +HAP.Characteristic.EveWeatherTrend.BLANK = 0; // also: 2, 8, 10 +HAP.Characteristic.EveWeatherTrend.SUN = 1; // also: 9 +HAP.Characteristic.EveWeatherTrend.CLOUDS_SUN = 3; // also: 11 +HAP.Characteristic.EveWeatherTrend.RAIN = 4; // also: 5, 6, 7 +HAP.Characteristic.EveWeatherTrend.RAIN_WIND = 12; // also: 13, 14, 15 // EveHomeHistory Service -Service.EveHomeHistory = function (displayName, subtype) { - Service.call(this, displayName, "E863F007-079E-48FF-8F27-9C2605A29F52", subtype); +HAP.Service.EveHomeHistory = function (displayName, subtype) { + HAP.Service.call(this, displayName, "E863F007-079E-48FF-8F27-9C2605A29F52", subtype); // Required Characteristics - this.addCharacteristic(Characteristic.EveResetTotal); - this.addCharacteristic(Characteristic.EveHistoryStatus); - this.addCharacteristic(Characteristic.EveHistoryEntries); - this.addCharacteristic(Characteristic.EveHistoryRequest); - this.addCharacteristic(Characteristic.EveSetTime); + this.addCharacteristic(HAP.Characteristic.EveResetTotal); + this.addCharacteristic(HAP.Characteristic.EveHistoryStatus); + this.addCharacteristic(HAP.Characteristic.EveHistoryEntries); + this.addCharacteristic(HAP.Characteristic.EveHistoryRequest); + this.addCharacteristic(HAP.Characteristic.EveSetTime); } -util.inherits(Service.EveHomeHistory, Service); -Service.EveHomeHistory.UUID = "E863F007-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Service.EveHomeHistory, HAP.Service); +HAP.Service.EveHomeHistory.UUID = "E863F007-079E-48FF-8F27-9C2605A29F52"; // Eve custom air pressure service -Service.EveAirPressureSensor = function(displayName, subtype) { - Service.call(this, displayName, "E863F00A-079E-48FF-8F27-9C2605A29F52", subtype); +HAP.Service.EveAirPressureSensor = function(displayName, subtype) { + HAP.Service.call(this, displayName, "E863F00A-079E-48FF-8F27-9C2605A29F52", subtype); // Required Characteristics - this.addCharacteristic(Characteristic.EveAirPressure); - this.addCharacteristic(Characteristic.EveElevation); + this.addCharacteristic(HAP.Characteristic.EveAirPressure); + this.addCharacteristic(HAP.Characteristic.EveElevation); } -util.inherits(Service.EveAirPressureSensor, Service); -Service.EveAirPressureSensor.UUID = "E863F00A-079E-48FF-8F27-9C2605A29F52"; +util.inherits(HAP.Service.EveAirPressureSensor, HAP.Service); +HAP.Service.EveAirPressureSensor.UUID = "E863F00A-079E-48FF-8F27-9C2605A29F52"; // Other UUIDs Eve Home recognises -Characteristic.ApparentTemperature = function() { - Characteristic.call(this, "ApparentTemperature", "C1283352-3D12-4777-ACD5-4734760F1AC8"); +HAP.Characteristic.ApparentTemperature = function() { + HAP.Characteristic.call(this, "ApparentTemperature", "C1283352-3D12-4777-ACD5-4734760F1AC8"); this.setProps({ - format: Characteristic.Formats.FLOAT, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY], - unit: Characteristic.Units.CELSIUS, + format: HAP.Characteristic.Formats.FLOAT, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY], + unit: HAP.Characteristic.Units.CELSIUS, minValue: -40, maxValue: 100, minStep: 0.1 }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.ApparentTemperature, Characteristic); -Characteristic.ApparentTemperature.UUID = "C1283352-3D12-4777-ACD5-4734760F1AC8"; +util.inherits(HAP.Characteristic.ApparentTemperature, HAP.Characteristic); +HAP.Characteristic.ApparentTemperature.UUID = "C1283352-3D12-4777-ACD5-4734760F1AC8"; -Characteristic.CloudCover = function() { - Characteristic.call(this, "Cloud Cover", "64392FED-1401-4F7A-9ADB-1710DD6E3897"); +HAP.Characteristic.CloudCover = function() { + HAP.Characteristic.call(this, "Cloud Cover", "64392FED-1401-4F7A-9ADB-1710DD6E3897"); this.setProps({ - format: Characteristic.Formats.UINT8, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY], - unit: Characteristic.Units.PERCENTAGE, + format: HAP.Characteristic.Formats.UINT8, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY], + unit: HAP.Characteristic.Units.PERCENTAGE, minValue: 0, maxValue: 100 }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.CloudCover, Characteristic); -Characteristic.CloudCover.UUID = "64392FED-1401-4F7A-9ADB-1710DD6E3897"; +util.inherits(HAP.Characteristic.CloudCover, HAP.Characteristic); +HAP.Characteristic.CloudCover.UUID = "64392FED-1401-4F7A-9ADB-1710DD6E3897"; -Characteristic.Condition = function() { - Characteristic.call(this, "Condition", "CD65A9AB-85AD-494A-B2BD-2F380084134D"); +HAP.Characteristic.Condition = function() { + HAP.Characteristic.call(this, "Condition", "CD65A9AB-85AD-494A-B2BD-2F380084134D"); this.setProps({ - format: Characteristic.Formats.STRING, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + format: HAP.Characteristic.Formats.STRING, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.Condition, Characteristic); -Characteristic.Condition.UUID = "CD65A9AB-85AD-494A-B2BD-2F380084134D"; +util.inherits(HAP.Characteristic.Condition, HAP.Characteristic); +HAP.Characteristic.Condition.UUID = "CD65A9AB-85AD-494A-B2BD-2F380084134D"; -Characteristic.ConditionCategory = function() { - Characteristic.call(this, "Condition Category", "CD65A9AB-85AD-494A-B2BD-2F380084134C"); +HAP.Characteristic.ConditionCategory = function() { + HAP.Characteristic.call(this, "Condition Category", "CD65A9AB-85AD-494A-B2BD-2F380084134C"); this.setProps({ - format: Characteristic.Formats.UINT8, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY], + format: HAP.Characteristic.Formats.UINT8, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY], minValue: 0, maxValue: 9 }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.ConditionCategory, Characteristic); -Characteristic.ConditionCategory.UUID = "CD65A9AB-85AD-494A-B2BD-2F380084134C"; +util.inherits(HAP.Characteristic.ConditionCategory, HAP.Characteristic); +HAP.Characteristic.ConditionCategory.UUID = "CD65A9AB-85AD-494A-B2BD-2F380084134C"; -Characteristic.DewPoint = function() { - Characteristic.call(this, "Dew Point", "095C46E2-278E-4E3C-B9E7-364622A0F501"); +HAP.Characteristic.DewPoint = function() { + HAP.Characteristic.call(this, "Dew Point", "095C46E2-278E-4E3C-B9E7-364622A0F501"); this.setProps({ - format: Characteristic.Formats.FLOAT, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY], - unit: Characteristic.Units.CELSIUS, + format: HAP.Characteristic.Formats.FLOAT, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY], + unit: HAP.Characteristic.Units.CELSIUS, minValue: -40, maxValue: 100, minStep: 0.1 }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.DewPoint, Characteristic); -Characteristic.DewPoint.UUID = "095C46E2-278E-4E3C-B9E7-364622A0F501"; +util.inherits(HAP.Characteristic.DewPoint, HAP.Characteristic); +HAP.Characteristic.DewPoint.UUID = "095C46E2-278E-4E3C-B9E7-364622A0F501"; -Characteristic.ForecastDay = function() { - Characteristic.call(this, "Day", "57F1D4B2-0E7E-4307-95B5-808750E2C1C7"); +HAP.Characteristic.ForecastDay = function() { + HAP.Characteristic.call(this, "Day", "57F1D4B2-0E7E-4307-95B5-808750E2C1C7"); this.setProps({ - format: Characteristic.Formats.STRING, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + format: HAP.Characteristic.Formats.STRING, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.ForecastDay, Characteristic); -Characteristic.ForecastDay.UUID = "57F1D4B2-0E7E-4307-95B5-808750E2C1C7"; +util.inherits(HAP.Characteristic.ForecastDay, HAP.Characteristic); +HAP.Characteristic.ForecastDay.UUID = "57F1D4B2-0E7E-4307-95B5-808750E2C1C7"; -Characteristic.MaximumWindSpeed = function() { - Characteristic.call(this, "Maximum Wind Speed", "6B8861E5-D6F3-425C-83B6-069945FFD1F1"); +HAP.Characteristic.MaximumWindSpeed = function() { + HAP.Characteristic.call(this, "Maximum Wind Speed", "6B8861E5-D6F3-425C-83B6-069945FFD1F1"); this.setProps({ - format: Characteristic.Formats.FLOAT, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY], + format: HAP.Characteristic.Formats.FLOAT, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY], unit: "km/h", minValue: 0, maxValue: 150, @@ -2113,203 +2479,203 @@ Characteristic.MaximumWindSpeed = function() { }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.MaximumWindSpeed, Characteristic); -Characteristic.MaximumWindSpeed.UUID = "6B8861E5-D6F3-425C-83B6-069945FFD1F1"; +util.inherits(HAP.Characteristic.MaximumWindSpeed, HAP.Characteristic); +HAP.Characteristic.MaximumWindSpeed.UUID = "6B8861E5-D6F3-425C-83B6-069945FFD1F1"; -Characteristic.MinimumTemperature = function() { - Characteristic.call(this, "Minimum Temperature", "707B78CA-51AB-4DC9-8630-80A58F07E41"); +HAP.Characteristic.MinimumTemperature = function() { + HAP.Characteristic.call(this, "Minimum Temperature", "707B78CA-51AB-4DC9-8630-80A58F07E41"); this.setProps({ - format: Characteristic.Formats.FLOAT, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY], - unit: Characteristic.Units.CELSIUS, + format: HAP.Characteristic.Formats.FLOAT, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY], + unit: HAP.Characteristic.Units.CELSIUS, minValue: -40, maxValue: 100, minStep: 0.1 }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.MinimumTemperature, Characteristic); -Characteristic.MinimumTemperature.UUID = "707B78CA-51AB-4DC9-8630-80A58F07E41"; +util.inherits(HAP.Characteristic.MinimumTemperature, HAP.Characteristic); +HAP.Characteristic.MinimumTemperature.UUID = "707B78CA-51AB-4DC9-8630-80A58F07E41"; -Characteristic.ObservationStation = function() { - Characteristic.call(this, "Observation Station", "D1B2787D-1FC4-4345-A20E-7B5A74D693ED"); +HAP.Characteristic.ObservationStation = function() { + HAP.Characteristic.call(this, "Observation Station", "D1B2787D-1FC4-4345-A20E-7B5A74D693ED"); this.setProps({ - format: Characteristic.Formats.STRING, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + format: HAP.Characteristic.Formats.STRING, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.ObservationStation, Characteristic); -Characteristic.ObservationStation.UUID = "D1B2787D-1FC4-4345-A20E-7B5A74D693ED"; +util.inherits(HAP.Characteristic.ObservationStation, HAP.Characteristic); +HAP.Characteristic.ObservationStation.UUID = "D1B2787D-1FC4-4345-A20E-7B5A74D693ED"; -Characteristic.ObservationTime = function() { - Characteristic.call(this, "Observation Time", "234FD9F1-1D33-4128-B622-D052F0C402AF"); +HAP.Characteristic.ObservationTime = function() { + HAP.Characteristic.call(this, "Observation Time", "234FD9F1-1D33-4128-B622-D052F0C402AF"); this.setProps({ - format: Characteristic.Formats.STRING, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + format: HAP.Characteristic.Formats.STRING, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.ObservationTime, Characteristic); -Characteristic.ObservationTime.UUID = "234FD9F1-1D33-4128-B622-D052F0C402AF"; +util.inherits(HAP.Characteristic.ObservationTime, HAP.Characteristic); +HAP.Characteristic.ObservationTime.UUID = "234FD9F1-1D33-4128-B622-D052F0C402AF"; -Characteristic.Ozone = function() { - Characteristic.call(this, "Ozone", "BBEFFDDD-1BCD-4D75-B7CD-B57A90A04D13"); +HAP.Characteristic.Ozone = function() { + HAP.Characteristic.call(this, "Ozone", "BBEFFDDD-1BCD-4D75-B7CD-B57A90A04D13"); this.setProps({ - format: Characteristic.Formats.UINT8, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY], + format: HAP.Characteristic.Formats.UINT8, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY], unit: "DU", minValue: 0, maxValue: 500 }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.Ozone, Characteristic); -Characteristic.Ozone.UUID = "BBEFFDDD-1BCD-4D75-B7CD-B57A90A04D13"; +util.inherits(HAP.Characteristic.Ozone, HAP.Characteristic); +HAP.Characteristic.Ozone.UUID = "BBEFFDDD-1BCD-4D75-B7CD-B57A90A04D13"; -Characteristic.Rain = function() { - Characteristic.call(this, "Rain", "F14EB1AD-E000-4EF4-A54F-0CF07B2E7BE7"); +HAP.Characteristic.Rain = function() { + HAP.Characteristic.call(this, "Rain", "F14EB1AD-E000-4EF4-A54F-0CF07B2E7BE7"); this.setProps({ - format: Characteristic.Formats.BOOL, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + format: HAP.Characteristic.Formats.BOOL, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.Rain, Characteristic); -Characteristic.Rain.UUID = "F14EB1AD-E000-4EF4-A54F-0CF07B2E7BE7"; +util.inherits(HAP.Characteristic.Rain, HAP.Characteristic); +HAP.Characteristic.Rain.UUID = "F14EB1AD-E000-4EF4-A54F-0CF07B2E7BE7"; -Characteristic.RainLastHour = function() { - Characteristic.call(this, "Rain Last Hour", "10C88F40-7EC4-478C-8D5A-BD0C3CCE14B7"); +HAP.Characteristic.RainLastHour = function() { + HAP.Characteristic.call(this, "Rain Last Hour", "10C88F40-7EC4-478C-8D5A-BD0C3CCE14B7"); this.setProps({ - format: Characteristic.Formats.UINT16, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY], + format: HAP.Characteristic.Formats.UINT16, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY], unit: "mm", minValue: 0, maxValue: 200 }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.RainLastHour, Characteristic); -Characteristic.RainLastHour.UUID = "10C88F40-7EC4-478C-8D5A-BD0C3CCE14B7"; +util.inherits(HAP.Characteristic.RainLastHour, HAP.Characteristic); +HAP.Characteristic.RainLastHour.UUID = "10C88F40-7EC4-478C-8D5A-BD0C3CCE14B7"; -Characteristic.TotalRain = function() { - Characteristic.call(this, "Total Rain", "CCC04890-565B-4376-B39A-3113341D9E0F"); +HAP.Characteristic.TotalRain = function() { + HAP.Characteristic.call(this, "Total Rain", "CCC04890-565B-4376-B39A-3113341D9E0F"); this.setProps({ - format: Characteristic.Formats.UINT16, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY], + format: HAP.Characteristic.Formats.UINT16, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY], unit: "mm", minValue: 0, maxValue: 2000 }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.TotalRain, Characteristic); -Characteristic.TotalRain.UUID = "CCC04890-565B-4376-B39A-3113341D9E0F"; +util.inherits(HAP.Characteristic.TotalRain, HAP.Characteristic); +HAP.Characteristic.TotalRain.UUID = "CCC04890-565B-4376-B39A-3113341D9E0F"; -Characteristic.RainProbability = function() { - Characteristic.call(this, "Rain Probability", "FC01B24F-CF7E-4A74-90DB-1B427AF1FFA3"); +HAP.Characteristic.RainProbability = function() { + HAP.Characteristic.call(this, "Rain Probability", "FC01B24F-CF7E-4A74-90DB-1B427AF1FFA3"); this.setProps({ - format: Characteristic.Formats.UINT8, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY], - unit: Characteristic.Units.PERCENTAGE, + format: HAP.Characteristic.Formats.UINT8, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY], + unit: HAP.Characteristic.Units.PERCENTAGE, minValue: 0, maxValue: 100 }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.RainProbability, Characteristic); -Characteristic.RainProbability.UUID = "FC01B24F-CF7E-4A74-90DB-1B427AF1FFA3"; +util.inherits(HAP.Characteristic.RainProbability, HAP.Characteristic); +HAP.Characteristic.RainProbability.UUID = "FC01B24F-CF7E-4A74-90DB-1B427AF1FFA3"; -Characteristic.Snow = function() { - Characteristic.call(this, "Snow", "F14EB1AD-E000-4CE6-BD0E-384F9EC4D5DD"); +HAP.Characteristic.Snow = function() { + HAP.Characteristic.call(this, "Snow", "F14EB1AD-E000-4CE6-BD0E-384F9EC4D5DD"); this.setProps({ - format: Characteristic.Formats.BOOL, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + format: HAP.Characteristic.Formats.BOOL, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.Snow, Characteristic); -Characteristic.Snow.UUID = "F14EB1AD-E000-4CE6-BD0E-384F9EC4D5DD"; +util.inherits(HAP.Characteristic.Snow, HAP.Characteristic); +HAP.Characteristic.Snow.UUID = "F14EB1AD-E000-4CE6-BD0E-384F9EC4D5DD"; -Characteristic.SolarRadiation = function() { - Characteristic.call(this, "Solar Radiation", "1819A23E-ECAB-4D39-B29A-7364D299310B"); +HAP.Characteristic.SolarRadiation = function() { + HAP.Characteristic.call(this, "Solar Radiation", "1819A23E-ECAB-4D39-B29A-7364D299310B"); this.setProps({ - format: Characteristic.Formats.UINT16, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY], + format: HAP.Characteristic.Formats.UINT16, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY], unit: "W/m²", minValue: 0, maxValue: 2000 }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.SolarRadiation, Characteristic); -Characteristic.SolarRadiation.UUID = "1819A23E-ECAB-4D39-B29A-7364D299310B"; +util.inherits(HAP.Characteristic.SolarRadiation, HAP.Characteristic); +HAP.Characteristic.SolarRadiation.UUID = "1819A23E-ECAB-4D39-B29A-7364D299310B"; -Characteristic.SunriseTime = function() { - Characteristic.call(this, "Sunrise", "0D96F60E-3688-487E-8CEE-D75F05BB3008"); +HAP.Characteristic.SunriseTime = function() { + HAP.Characteristic.call(this, "Sunrise", "0D96F60E-3688-487E-8CEE-D75F05BB3008"); this.setProps({ - format: Characteristic.Formats.STRING, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + format: HAP.Characteristic.Formats.STRING, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.SunriseTime, Characteristic); -Characteristic.SunriseTime.UUID = "0D96F60E-3688-487E-8CEE-D75F05BB3008"; +util.inherits(HAP.Characteristic.SunriseTime, HAP.Characteristic); +HAP.Characteristic.SunriseTime.UUID = "0D96F60E-3688-487E-8CEE-D75F05BB3008"; -Characteristic.SunsetTime = function() { - Characteristic.call(this, "Sunset", "3DE24EE0-A288-4E15-A5A8-EAD2451B727C"); +HAP.Characteristic.SunsetTime = function() { + HAP.Characteristic.call(this, "Sunset", "3DE24EE0-A288-4E15-A5A8-EAD2451B727C"); this.setProps({ - format: Characteristic.Formats.STRING, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + format: HAP.Characteristic.Formats.STRING, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.SunsetTime, Characteristic); -Characteristic.SunsetTime.UUID = "3DE24EE0-A288-4E15-A5A8-EAD2451B727C"; +util.inherits(HAP.Characteristic.SunsetTime, HAP.Characteristic); +HAP.Characteristic.SunsetTime.UUID = "3DE24EE0-A288-4E15-A5A8-EAD2451B727C"; -Characteristic.UVIndex = function() { - Characteristic.call(this, "UV Index", "05BA0FE0-B848-4226-906D-5B64272E05CE"); +HAP.Characteristic.UVIndex = function() { + HAP.Characteristic.call(this, "UV Index", "05BA0FE0-B848-4226-906D-5B64272E05CE"); this.setProps({ - format: Characteristic.Formats.UINT8, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY], + format: HAP.Characteristic.Formats.UINT8, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY], minValue: 0, maxValue: 16 }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.UVIndex, Characteristic); -Characteristic.UVIndex.UUID = "05BA0FE0-B848-4226-906D-5B64272E05CE"; +util.inherits(HAP.Characteristic.UVIndex, HAP.Characteristic); +HAP.Characteristic.UVIndex.UUID = "05BA0FE0-B848-4226-906D-5B64272E05CE"; -Characteristic.Visibility = function() { - Characteristic.call(this, "Visibility", "D24ECC1E-6FAD-4FB5-8137-5AF88BD5E857"); +HAP.Characteristic.Visibility = function() { + HAP.Characteristic.call(this, "Visibility", "D24ECC1E-6FAD-4FB5-8137-5AF88BD5E857"); this.setProps({ - format: Characteristic.Formats.UINT8, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY], + format: HAP.Characteristic.Formats.UINT8, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY], unit: "km", minValue: 0, maxValue: 100 }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.Visibility, Characteristic); -Characteristic.Visibility.UUID = "D24ECC1E-6FAD-4FB5-8137-5AF88BD5E857"; +util.inherits(HAP.Characteristic.Visibility, HAP.Characteristic); +HAP.Characteristic.Visibility.UUID = "D24ECC1E-6FAD-4FB5-8137-5AF88BD5E857"; -Characteristic.WindDirection = function() { - Characteristic.call(this, "Wind Direction", "46F1284C-1912-421B-82F5-EB75008B167E"); +HAP.Characteristic.WindDirection = function() { + HAP.Characteristic.call(this, "Wind Direction", "46F1284C-1912-421B-82F5-EB75008B167E"); this.setProps({ - format: Characteristic.Formats.STRING, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] + format: HAP.Characteristic.Formats.STRING, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.WindDirection, Characteristic); -Characteristic.WindDirection.UUID = "46F1284C-1912-421B-82F5-EB75008B167E"; +util.inherits(HAP.Characteristic.WindDirection, HAP.Characteristic); +HAP.Characteristic.WindDirection.UUID = "46F1284C-1912-421B-82F5-EB75008B167E"; -Characteristic.WindSpeed = function() { - Characteristic.call(this, "Wind Speed", "49C8AE5A-A3A5-41AB-BF1F-12D5654F9F41"); +HAP.Characteristic.WindSpeed = function() { + HAP.Characteristic.call(this, "Wind Speed", "49C8AE5A-A3A5-41AB-BF1F-12D5654F9F41"); this.setProps({ - format: Characteristic.Formats.FLOAT, - perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY], + format: HAP.Characteristic.Formats.FLOAT, + perms: [HAP.Characteristic.Perms.READ, HAP.Characteristic.Perms.NOTIFY], unit: "km/h", minValue: 0, maxValue: 150, @@ -2317,7 +2683,7 @@ Characteristic.WindSpeed = function() { }); this.value = this.getDefaultValue(); } -util.inherits(Characteristic.WindSpeed, Characteristic); -Characteristic.WindSpeed.UUID = "49C8AE5A-A3A5-41AB-BF1F-12D5654F9F41"; +util.inherits(HAP.Characteristic.WindSpeed, HAP.Characteristic); +HAP.Characteristic.WindSpeed.UUID = "49C8AE5A-A3A5-41AB-BF1F-12D5654F9F41"; module.exports = HomeKitHistory; \ No newline at end of file diff --git a/Nest_accfactory.js b/Nest_accfactory.js index 886b8f3..26aa570 100644 --- a/Nest_accfactory.js +++ b/Nest_accfactory.js @@ -10,44 +10,26 @@ // // The accessory supports connection to Nest using a Nest account OR a Google (migrated Nest account) account. // -// Code version 01/03/2023 +// Code version 10/7/2023 // Mark Hulskamp -module.exports = accessories = []; - "use strict"; // Define HAP-NodeJS requirements -var HAPNodeJS = require("hap-nodejs"); -var Accessory = HAPNodeJS.Accessory; -var Service = HAPNodeJS.Service; -var Characteristic = HAPNodeJS.Characteristic; -var uuid = HAPNodeJS.uuid; -var DoorbellController = HAPNodeJS.DoorbellController; -var CameraController = HAPNodeJS.CameraController; -var SRTPCryptoSuites = HAPNodeJS.SRTPCryptoSuites; -var HDSProtocolSpecificErrorReason = HAPNodeJS.HDSProtocolSpecificErrorReason; -var H264Profile = HAPNodeJS.H264Profile; -var H264Level = HAPNodeJS.H264Level; -var AudioStreamingCodecType = HAPNodeJS.AudioStreamingCodecType; -var AudioStreamingSamplerate = HAPNodeJS.AudioStreamingSamplerate; -var AudioRecordingCodecType = HAPNodeJS.AudioRecordingCodecType; -var AudioRecordingSamplerate = HAPNodeJS.AudioRecordingSamplerate; -var VideoCodecType = HAPNodeJS.VideoCodecType; -var MediaContainerType = HAPNodeJS.MediaContainerType; -var MDNSAdvertiser = HAPNodeJS.MDNSAdvertiser; +var HAP = require("hap-nodejs"); // Define external library requirements var axios = require("axios"); try { - // Easier installation of ffmpeg binaries we support - var ffmpegPath = require("ffmpeg-for-homebridge"); -} catch(error) { + // Easier installation of ffmpeg binaries that support the libraries we use for doorbells/cameras + var pathToFFMPEG = require("ffmpeg-for-homebridge"); +} catch (error) { // ffmpeg-for-homebridge isn't installed, so we'll assume ffmpeg will be available in path - var ffmpegPath = undefined; + var pathToFFMPEG = undefined; } // Define nodejs module requirements +var util = require("util"); var EventEmitter = require("events"); var dgram = require("dgram"); var net = require("net"); @@ -59,167 +41,26 @@ var {spawnSync} = require("child_process"); // Define our external module requirements var HomeKitHistory = require("./HomeKitHistory"); var NexusStreamer = require("./nexusstreamer"); +var HomeKitDevice = require("./HomeKitDevice"); +HomeKitDevice.HOMEKITHISTORY = HomeKitHistory; // History module for the device +const ACCESSORYNAME = "Nest"; // Used for manufacturer name of HomeKit device +const ACCESSORYPINCODE = "031-45-154"; // HomeKit pairing code // Define constants -const ACCESSORYNAME = "Nest"; // Used for manufacturer name of HomeKit device -const ACCESSORYPINCODE = "031-45-154"; // HomeKit pairing code const LOWBATTERYLEVEL = 10; // Low battery level percentage const FFMPEGLIBARIES = ["libspeex", "libx264", "libfdk-aac"]; // List of ffmpeg libraries we require for doorbell/camera(s) -const NESTDEVICETYPE = { - THERMOSTAT : "thermostat", - TEMPSENSOR : "temperature", - SMOKESENSOR : "protect", - CAMERA : "camera", - DOORBELL : "doorbell", - WEATHER : "weather", - LOCK : "lock", // yet to implement - ALARM : "alarm" // yet to implement -} - - -// HomeKitDevice class -// -// All HomeKit accessories will be derived from this class -// -// The deviceData structure should, at a minimum contain the following elements -// mac_address -// serial_number -// software_version -// description -// location -const DEVICEEVENT = { - UPDATE : "dataUpdate", - REMOVE : "deviceRemove" -} - -class HomeKitDevice { - constructor(uuid, deviceData, eventEmitter) { - this.deviceData = deviceData; // Current data for the device - this.HomeKitAccessory = null; // HomeKit Accessory object - this.events = eventEmitter; // Event emitter to use. Should be a "globally" defined one to allow comms from other objects - this.historyService = null; // History logging service - this.deviceUUID = uuid; // Unique UUID for this device. Used for event messaging to this device - - // Setup event listener to process "messages" to our device - this.events.addListener(this.deviceUUID, this.#message.bind(this)); - } - - // Class functions - add(deviceTypeName, deviceTypeModel, HomeKitCategory, enableHistory) { - if (this.HomeKitAccessory == null) { - this.HomeKitAccessory = exports.accessory = new Accessory(deviceTypeName, uuid.generate("hap-nodejs:accessories:" + ACCESSORYNAME.toLowerCase() + "_" + this.deviceData.serial_number)); - this.HomeKitAccessory.username = this.deviceData.mac_address; - this.HomeKitAccessory.pincode = ACCESSORYPINCODE; - this.HomeKitAccessory.category = HomeKitCategory; - this.HomeKitAccessory.getService(Service.AccessoryInformation).setCharacteristic(Characteristic.Manufacturer, ACCESSORYNAME); - this.HomeKitAccessory.getService(Service.AccessoryInformation).setCharacteristic(Characteristic.Model, deviceTypeModel); - this.HomeKitAccessory.getService(Service.AccessoryInformation).setCharacteristic(Characteristic.SerialNumber, this.deviceData.serial_number); - this.HomeKitAccessory.getService(Service.AccessoryInformation).setCharacteristic(Characteristic.FirmwareRevision, this.deviceData.software_version); - - if (enableHistory == true && this.historyService == null) { - // Setup logging service as requsted - this.historyService = new HomeKitHistory(this.HomeKitAccessory, {}); - } - - if (typeof this.addHomeKitServices == "function") { - // We have a class function defined for setting up HomeKit services - this.addHomeKitServices((this.deviceData.location == "" ? this.deviceData.description : this.deviceData.description + " - " + this.deviceData.location)); - } - - this.update(this.deviceData, true); // perform an initial update using current data - - var accessoryIndex = accessories.findIndex(({username}) => username === this.deviceData.mac_address); - if (accessoryIndex == -1) accessories.push(this.HomeKitAccessory); // Push onto export array for HAP-NodeJS "accessory factory" - this.HomeKitAccessory.publish({username: this.HomeKitAccessory.username, pincode: this.HomeKitAccessory.pincode, category: this.HomeKitAccessory.category, advertiser: config.mDNS}); // Publish accessory on local network - } - } - - remove() { - this.events.removeAllListeners(this.deviceUUID); // Remove listener for "messages" - if (typeof this.removeHomeKitServices == "function") { - // We have a class function defined for removal of HomeKit services - this.removeHomeKitServices(); - } - this.HomeKitAccessory.unpublish(); - console.log("Removed %s Device '%s' on '%s'", ACCESSORYNAME, this.HomeKitAccessory.displayName, this.HomeKitAccessory.username); - - // Clear out object properties - var accessoryIndex = accessories.findIndex(({username}) => username === this.deviceData.mac_address); - if (accessoryIndex != -1) accessories.splice(accessoryIndex, 1); - - this.deviceData = null; - this.HomeKitAccessory = null; - this.events = null; - this.historyService = null; - - // Do we destroy this object?? - // this = null; - // delete this; - } - - update(deviceData, force) { - if (typeof deviceData == "object") { - // Updated data may only contain selected fields, so we'll handle that here by taking our internally stored data - // and merge with the updates to ensure we have a complete data object - Object.entries(this.deviceData).forEach(([key, value]) => { - if (typeof deviceData[key] == "undefined") { - // Update data doesnt have this key, so add it our internal data - deviceData[key] = value; - } - }); - - // Check to see what data elements have changed - var changes = {}; - Object.entries(deviceData).forEach(([key, value]) => { - if (JSON.stringify(deviceData[key]) !== JSON.stringify(this.deviceData[key])) { - changes[key] = deviceData[key]; - } - }); - - // If we have any changed data elements OR we've been requested to force an update, do so - if (Object.keys(changes).length != 0 || force == true) { - // If there is a function "updateHomeKitServices" defined in the object, call this. - if (typeof this.updateHomeKitServices == "function") { - this.updateHomeKitServices(deviceData); - } - - // Finally, update our internally stored data to match what we were sent in - this.deviceData = deviceData; - } - } - } - - set(keyvalues) { - if (typeof keyvalues == "object") this.events.emit(SYSTEMEVENT.SET, this.deviceUUID, keyvalues); - } - - get() { - // <---- To Implement - } - - #message(type, messageData) { - // Handle event "messages" for this device and perform appropriate action - if (type == DEVICEEVENT.UPDATE) { - this.update(messageData, false); // Got some device data, so process any updates - } - if (type == DEVICEEVENT.REMOVE) { - this.remove(); // Got message for device removal - } - } -} // Nest Thermostat -class ThermostatClass extends HomeKitDevice { - constructor(deviceData, eventEmitter) { - super(deviceData.nest_device_structure, deviceData, eventEmitter); - - this.ThermostatService = null; // HomeKit service for this thermostat - this.BatteryService = null; // Status of Nest Thermostat battery - this.OccupancyService = null; // Status of Away/Home - this.HumidityService = null; // Seperate humidity sensor - this.FanService = null; // Fan service - this.updatingHomeKit = false; // Flag if were doing an HomeKit update or not +class NestThermostat extends HomeKitDevice { + constructor(currentDeviceData, globalEventEmitter) { + super(ACCESSORYNAME, ACCESSORYPINCODE, config.mDNS, currentDeviceData.device_uuid, currentDeviceData, globalEventEmitter); + + this.thermostatService = null; // HomeKit service for this thermostat + this.batteryService = null; // Status of Nest Thermostat battery + this.occupancyService = null; // Status of Away/Home + this.humidityService = null; // Seperate humidity sensor + this.fanService = null; // Fan service this.previous_target_temperature_type = null; // Track previous target tempersture type } @@ -227,522 +68,668 @@ class ThermostatClass extends HomeKitDevice { // Class functions addHomeKitServices(serviceName) { // Add this thermostat to the "master" accessory and set properties - this.ThermostatService = this.HomeKitAccessory.addService(Service.Thermostat, "Thermostat", 1); - this.ThermostatService.addCharacteristic(Characteristic.StatusActive); // Used to indicate active temperature - this.ThermostatService.addCharacteristic(Characteristic.StatusFault); // Used to indicate Nest online or not - this.ThermostatService.addCharacteristic(Characteristic.LockPhysicalControls); // Setting can only be accessed via Eve App (or other 3rd party). - this.deviceData.has_air_filter && this.ThermostatService.addCharacteristic(Characteristic.FilterChangeIndication); // Add characteristic if has air filter - this.deviceData.has_humidifier && this.ThermostatService.addCharacteristic(Characteristic.TargetRelativeHumidity); // Add characteristic if has dehumidifier + this.thermostatService = this.HomeKitAccessory.addService(HAP.Service.Thermostat, "Thermostat", 1); + this.thermostatService.addCharacteristic(HAP.Characteristic.StatusActive); // Used to indicate active temperature + this.thermostatService.addCharacteristic(HAP.Characteristic.StatusFault); // Used to indicate Nest online or not + this.thermostatService.addCharacteristic(HAP.Characteristic.LockPhysicalControls); // Setting can only be accessed via Eve App (or other 3rd party). + this.deviceData.has_air_filter && this.thermostatService.addCharacteristic(HAP.Characteristic.FilterChangeIndication); // Add characteristic if has air filter + this.deviceData.has_humidifier && this.thermostatService.addCharacteristic(HAP.Characteristic.TargetRelativeHumidity); // Add characteristic if has dehumidifier // Add battery service to display battery level - this.BatteryService = this.HomeKitAccessory.addService(Service.BatteryService, "", 1); + this.batteryService = this.HomeKitAccessory.addService(HAP.Service.BatteryService, "", 1); // Seperate humidity sensor if configured todo so - if (this.deviceData.humiditySensor && this.deviceData.humiditySensor == true) { - this.HumidityService = this.HomeKitAccessory.addService(Service.HumiditySensor, "Humidity", 1); // Humidity will be listed under seperate sensor - this.HumidityService.addCharacteristic(Characteristic.StatusFault); + if (typeof this.deviceData.HumiditySensor == "boolean" && this.deviceData.HumiditySensor == true) { + this.humidityService = this.HomeKitAccessory.addService(HAP.Service.HumiditySensor, "Humidity", 1); // Humidity will be listed under seperate sensor + this.humidityService.addCharacteristic(HAP.Characteristic.StatusFault); } else { - this.ThermostatService.addCharacteristic(Characteristic.CurrentRelativeHumidity); // Humidity will be listed under thermostat only + this.thermostatService.addCharacteristic(HAP.Characteristic.CurrentRelativeHumidity); // Humidity will be listed under thermostat only } // Add home/away status as an occupancy sensor - this.OccupancyService = this.HomeKitAccessory.addService(Service.OccupancySensor, "Occupancy", 1); - this.OccupancyService.addCharacteristic(Characteristic.StatusFault); + this.occupancyService = this.HomeKitAccessory.addService(HAP.Service.OccupancySensor, "Occupancy", 1); + this.occupancyService.addCharacteristic(HAP.Characteristic.StatusFault); // Limit prop ranges if (this.deviceData.can_cool == false && this.deviceData.can_heat == true) { // Can heat only, so set values allowed for mode off/heat - this.ThermostatService.getCharacteristic(Characteristic.TargetHeatingCoolingState).setProps({validValues: [Characteristic.TargetHeatingCoolingState.OFF, Characteristic.TargetHeatingCoolingState.HEAT]}); + this.thermostatService.getCharacteristic(HAP.Characteristic.TargetHeatingCoolingState).setProps({validValues: [HAP.Characteristic.TargetHeatingCoolingState.OFF, HAP.Characteristic.TargetHeatingCoolingState.HEAT]}); } else if (this.deviceData.can_cool == true && this.deviceData.can_heat == false) { // Can cool only - this.ThermostatService.getCharacteristic(Characteristic.TargetHeatingCoolingState).setProps({validValues: [Characteristic.TargetHeatingCoolingState.OFF, Characteristic.TargetHeatingCoolingState.COOL]}); + this.thermostatService.getCharacteristic(HAP.Characteristic.TargetHeatingCoolingState).setProps({validValues: [HAP.Characteristic.TargetHeatingCoolingState.OFF, HAP.Characteristic.TargetHeatingCoolingState.COOL]}); } else if (this.deviceData.can_cool == true && this.deviceData.can_heat == true) { // heat and cool - this.ThermostatService.getCharacteristic(Characteristic.TargetHeatingCoolingState).setProps({validValues: [Characteristic.TargetHeatingCoolingState.OFF, Characteristic.TargetHeatingCoolingState.HEAT, Characteristic.TargetHeatingCoolingState.COOL, Characteristic.TargetHeatingCoolingState.AUTO]}); + this.thermostatService.getCharacteristic(HAP.Characteristic.TargetHeatingCoolingState).setProps({validValues: [HAP.Characteristic.TargetHeatingCoolingState.OFF, HAP.Characteristic.TargetHeatingCoolingState.HEAT, HAP.Characteristic.TargetHeatingCoolingState.COOL, HAP.Characteristic.TargetHeatingCoolingState.AUTO]}); } else { // only off mode - this.ThermostatService.getCharacteristic(Characteristic.TargetHeatingCoolingState).setProps({validValues: [Characteristic.TargetHeatingCoolingState.OFF]}); + this.thermostatService.getCharacteristic(HAP.Characteristic.TargetHeatingCoolingState).setProps({validValues: [HAP.Characteristic.TargetHeatingCoolingState.OFF]}); } // Add fan service if Nest supports a fan if (this.deviceData.has_fan == true) { - this.FanService = this.HomeKitAccessory.addService(Service.Fan, "Fan", 1); - this.FanService.getCharacteristic(Characteristic.On).on("set", this.setFan.bind(this)); + this.fanService = this.HomeKitAccessory.addService(HAP.Service.Fan, "Fan", 1); + this.fanService.getCharacteristic(HAP.Characteristic.On).on("set", (value, callback) => {this.setFan(value, callback); }); + this.fanService.getCharacteristic(HAP.Characteristic.On).on("get", (callback) => {callback(null, this.deviceData.fan_state); }); } // Set default ranges - based on celsuis ranges - this.ThermostatService.setCharacteristic(Characteristic.TemperatureDisplayUnits, Characteristic.TemperatureDisplayUnits.CELSIUS); - this.ThermostatService.getCharacteristic(Characteristic.CurrentTemperature).setProps({minStep: 0.5}); - this.ThermostatService.getCharacteristic(Characteristic.TargetTemperature).setProps({minStep: 0.5, minValue: 9, maxValue: 32}); - this.ThermostatService.getCharacteristic(Characteristic.CoolingThresholdTemperature).setProps({minStep: 0.5, minValue: 9, maxValue: 32}); - this.ThermostatService.getCharacteristic(Characteristic.HeatingThresholdTemperature).setProps({minStep: 0.5, minValue: 9, maxValue: 32}); - - // Setup set callbacks for characteristics - this.ThermostatService.getCharacteristic(Characteristic.TemperatureDisplayUnits).on("set", this.setDisplayUnits.bind(this)); - this.ThermostatService.getCharacteristic(Characteristic.TargetHeatingCoolingState).on("set", this.setMode.bind(this)); - this.ThermostatService.getCharacteristic(Characteristic.TargetTemperature).on("set", (value, callback) => {this.setTemperature(Characteristic.TargetTemperature, value, callback)}); - this.ThermostatService.getCharacteristic(Characteristic.CoolingThresholdTemperature).on("set", (value, callback) => {this.setTemperature(Characteristic.CoolingThresholdTemperature, value, callback)}); - this.ThermostatService.getCharacteristic(Characteristic.HeatingThresholdTemperature).on("set", (value, callback) => {this.setTemperature(Characteristic.HeatingThresholdTemperature, value, callback)}); - this.ThermostatService.getCharacteristic(Characteristic.LockPhysicalControls).on("set", (value, callback) => {this.setChildlock("", value, callback)}); - - this.HomeKitAccessory.setPrimaryService(this.ThermostatService); + this.thermostatService.updateCharacteristic(HAP.Characteristic.TemperatureDisplayUnits, HAP.Characteristic.TemperatureDisplayUnits.CELSIUS); + this.thermostatService.getCharacteristic(HAP.Characteristic.CurrentTemperature).setProps({minStep: 0.5}); + this.thermostatService.getCharacteristic(HAP.Characteristic.TargetTemperature).setProps({minStep: 0.5, minValue: 9, maxValue: 32}); + this.thermostatService.getCharacteristic(HAP.Characteristic.CoolingThresholdTemperature).setProps({minStep: 0.5, minValue: 9, maxValue: 32}); + this.thermostatService.getCharacteristic(HAP.Characteristic.HeatingThresholdTemperature).setProps({minStep: 0.5, minValue: 9, maxValue: 32}); + + // Setup callbacks for characteristics + this.thermostatService.getCharacteristic(HAP.Characteristic.TemperatureDisplayUnits).on("set", (value, callback) => {this.setDisplayUnit(value, callback); }); + this.thermostatService.getCharacteristic(HAP.Characteristic.TargetHeatingCoolingState).on("set", (value, callback) => {this.setMode(value, callback); }); + this.thermostatService.getCharacteristic(HAP.Characteristic.TargetTemperature).on("set", (value, callback) => {this.setTemperature(HAP.Characteristic.TargetTemperature, value, callback)}); + this.thermostatService.getCharacteristic(HAP.Characteristic.CoolingThresholdTemperature).on("set", (value, callback) => {this.setTemperature(HAP.Characteristic.CoolingThresholdTemperature, value, callback)}); + this.thermostatService.getCharacteristic(HAP.Characteristic.HeatingThresholdTemperature).on("set", (value, callback) => {this.setTemperature(HAP.Characteristic.HeatingThresholdTemperature, value, callback)}); + this.thermostatService.getCharacteristic(HAP.Characteristic.LockPhysicalControls).on("set", (value, callback) => {this.setChildlock("", value, callback)}); + + this.thermostatService.getCharacteristic(HAP.Characteristic.TemperatureDisplayUnits).on("get", (callback) => {callback(null, this.deviceData.temperature_scale == "C" ? HAP.Characteristic.TemperatureDisplayUnits.CELSIUS : HAP.Characteristic.TemperatureDisplayUnits.FAHRENHEIT); }); + this.thermostatService.getCharacteristic(HAP.Characteristic.TargetTemperature).on("get", (callback) => {callback(null, this.getTemperature(HAP.Characteristic.TargetTemperature, null)); }); + this.thermostatService.getCharacteristic(HAP.Characteristic.CoolingThresholdTemperature).on("get", (callback) => {callback(null, this.getTemperature(HAP.Characteristic.CoolingThresholdTemperature, null)); }); + this.thermostatService.getCharacteristic(HAP.Characteristic.HeatingThresholdTemperature).on("get", (callback) => {callback(null, this.getTemperature(HAP.Characteristic.HeatingThresholdTemperature, null)); }); + this.thermostatService.getCharacteristic(HAP.Characteristic.TargetHeatingCoolingState).on("get", (callback) => {callback(null, this.getMode(null)); }); + this.thermostatService.getCharacteristic(HAP.Characteristic.LockPhysicalControls).on("get", (callback) => {callback(null, this.deviceData.temperature_lock == true ? HAP.Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED : HAP.Characteristic.LockPhysicalControls.CONTROL_LOCK_DISABLED); }); + + this.HomeKitAccessory.setPrimaryService(this.thermostatService); // Setup linkage to EveHome app if configured todo so - this.deviceData.EveApp && this.historyService && this.historyService.linkToEveHome(this.HomeKitAccessory, this.ThermostatService, {debug: config.debug.includes("HISTORY")}); + if (this.deviceData.EveApp == true && this.HomeKitHistory != null) { + this.HomeKitHistory.linkToEveHome(this.HomeKitAccessory, this.thermostatService, {GetCommand: this.#EveHomeGetCommand.bind(this), + SetCommand: this.#EveHomeSetCommand.bind(this), + debug: config.debug.includes("HISTORY") + }); + } - console.log("Setup Nest Thermostat '%s' on '%s'", serviceName, this.HomeKitAccessory.username, (this.HumidityService != null ? "with seperate humidity sensor" : "")); - this.deviceData.externalCool && console.log(" += using external cooling module"); - this.deviceData.externalHeat && console.log(" += using external heating module"); - this.deviceData.externalFan && console.log(" += using external fan module"); - this.deviceData.externalDehumidifier && console.log(" += using external dehumidification module"); + outputLogging(ACCESSORYNAME, false, "Setup Nest Thermostat '%s'", serviceName, (this.humidityService != null ? "with seperate humidity sensor" : "")); + this.deviceData.ExternalCool && outputLogging(ACCESSORYNAME, false, " += using external cooling module"); + this.deviceData.ExternalHeat && outputLogging(ACCESSORYNAME, false, " += using external heating module"); + this.deviceData.ExternalFan && outputLogging(ACCESSORYNAME, false, " += using external fan module"); + this.deviceData.ExternalDehumidifier && outputLogging(ACCESSORYNAME, false, " += using external dehumidification module"); } - setFan(value, callback) { - this.updatingHomeKit = true; - - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Set fan on thermostat '%s' to '%s'", this.deviceData.mac_address, (value == true ? "On" : "Off")); - this.set({["device." + this.deviceData.nest_device_structure.split(".")[1]] : {"fan_timer_timeout" : (value == false ? 0 : this.deviceData.fan_duration + Math.floor(new Date() / 1000))} }); - this.FanService.updateCharacteristic(Characteristic.On, value); + setFan(fanState, callback) { + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Set fan on thermostat '%s' to '%s'", this.deviceData.mac_address, (fanState == true ? "On" : "Off")); + this.set({["device"] : {"fan_timer_timeout" : (fanState == false ? 0 : this.deviceData.fan_duration + Math.floor(new Date() / 1000))} }); + this.fanService.updateCharacteristic(HAP.Characteristic.On, fanState); if (typeof callback === "function") callback(); // do callback if defined - this.updatingHomeKit = false; } - setDisplayUnits(value, callback) { - this.updatingHomeKit = true; - - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Set temperature units on thermostat '%s' to '%s'", this.deviceData.mac_address, (value == Characteristic.TemperatureDisplayUnits.CELSIUS ? "°C" : "°F")); - this.set({["device." + this.deviceData.nest_device_structure.split(".")[1]] : {"temperature_scale" : (value == Characteristic.TemperatureDisplayUnits.CELSIUS ? "C" : "F") }}); - this.ThermostatService.updateCharacteristic(Characteristic.TemperatureDisplayUnits, value); + setDisplayUnit(temperatureUnit, callback) { + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Set temperature units on thermostat '%s' to '%s'", this.deviceData.mac_address, (temperatureUnit == HAP.Characteristic.TemperatureDisplayUnits.CELSIUS ? "°C" : "°F")); + this.set({["device"] : {"temperature_scale" : (temperatureUnit == HAP.Characteristic.TemperatureDisplayUnits.CELSIUS ? "C" : "F") }}); + this.thermostatService.updateCharacteristic(HAP.Characteristic.TemperatureDisplayUnits, temperatureUnit); if (typeof callback === "function") callback(); // do callback if defined - this.updatingHomeKit = false; } - setMode(value, callback) { - this.updatingHomeKit = true; + setMode(thermostatMode, callback) { + if (thermostatMode == this.thermostatService.getCharacteristic(HAP.Characteristic.TargetHeatingCoolingState).value) { + if (typeof callback === "function") callback(); // do callback if defined + return; + } - if (value != this.ThermostatService.getCharacteristic(Characteristic.TargetHeatingCoolingState).value) { - // Only change heating/cooling mode if change requested is different than current HomeKit state - var tempMode = ""; - var tempValue = null; + // Only change heating/cooling mode if change requested is different than current HomeKit state + var tempMode = ""; + var tempValue = null; - if (value == Characteristic.TargetHeatingCoolingState.HEAT && this.deviceData.can_heat == true) { - tempMode = "heat"; - tempValue = Characteristic.TargetHeatingCoolingState.HEAT; - } - if (value == Characteristic.TargetHeatingCoolingState.COOL && this.deviceData.can_cool == true) { + if (thermostatMode == HAP.Characteristic.TargetHeatingCoolingState.HEAT && this.deviceData.can_heat == true) { + tempMode = "heat"; + tempValue = HAP.Characteristic.TargetHeatingCoolingState.HEAT; + } + if (thermostatMode == HAP.Characteristic.TargetHeatingCoolingState.COOL && this.deviceData.can_cool == true) { + tempMode = "cool"; + tempValue = HAP.Characteristic.TargetHeatingCoolingState.COOL; + } + if (thermostatMode == HAP.Characteristic.TargetHeatingCoolingState.AUTO) { + // Workaround for "Hey Siri, turn on my thermostat". Appears to automatically request mode as "auto", but we need to see what Nest device supports + if (this.deviceData.can_cool == true && this.deviceData.can_heat == true) { + tempMode = "range"; + tempValue = HAP.Characteristic.TargetHeatingCoolingState.AUTO; + } else if (this.deviceData.can_cool == true && this.deviceData.can_heat == false) { tempMode = "cool"; - tempValue = Characteristic.TargetHeatingCoolingState.COOL; - } - if (value == Characteristic.TargetHeatingCoolingState.AUTO) { - // Workaround for "Hey Siri, turn on my thermostat". Appears to automatically request mode as "auto", but we need to see what Nest device supports - if (this.deviceData.can_cool == true && this.deviceData.can_heat == true) { - tempMode = "range"; - tempValue = Characteristic.TargetHeatingCoolingState.AUTO; - } else if (this.deviceData.can_cool == true && this.deviceData.can_heat == false) { - tempMode = "cool"; - tempValue = Characteristic.TargetHeatingCoolingState.COOL; - } else if (this.deviceData.can_cool == false && this.deviceData.can_heat == true) { - tempMode = "heat"; - tempValue = Characteristic.TargetHeatingCoolingState.HEAT; - } else { - tempMode = "off" - tempValue = Characteristic.TargetHeatingCoolingState.OFF; - } - } - if (value == Characteristic.TargetHeatingCoolingState.OFF) { - tempMode = "off"; - tempValue = Characteristic.TargetHeatingCoolingState.OFF; + tempValue = HAP.Characteristic.TargetHeatingCoolingState.COOL; + } else if (this.deviceData.can_cool == false && this.deviceData.can_heat == true) { + tempMode = "heat"; + tempValue = HAP.Characteristic.TargetHeatingCoolingState.HEAT; + } else { + tempMode = "off" + tempValue = HAP.Characteristic.TargetHeatingCoolingState.OFF; } + } + if (thermostatMode == HAP.Characteristic.TargetHeatingCoolingState.OFF) { + tempMode = "off"; + tempValue = HAP.Characteristic.TargetHeatingCoolingState.OFF; + } - if (tempValue != null && tempMode != "") { - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Set thermostat on '%s' to '%s'", this.deviceData.mac_address, tempMode); - this.set({["shared." + this.deviceData.nest_device_structure.split(".")[1]] : {"target_temperature_type" : tempMode, "target_change_pending" : true} }); - this.ThermostatService.updateCharacteristic(Characteristic.TargetHeatingCoolingState, tempValue); - - if (this.previous_target_temperature_type == "range" && (tempMode == "heat" || tempMode == "cool")) { - // If switching from range to heat/cool, update HomeKit using previous target temp - this.ThermostatService.updateCharacteristic(Characteristic.TargetTemperature, this.deviceData.target_temperature); - } + if (tempValue != null && tempMode != "") { + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Set thermostat on '%s' to '%s'", this.deviceData.mac_address, tempMode); + this.set({["shared"] : {"target_temperature_type" : tempMode, "target_change_pending" : true} }); + this.thermostatService.updateCharacteristic(HAP.Characteristic.TargetHeatingCoolingState, tempValue); + + if (this.previous_target_temperature_type == "range" && (tempMode == "heat" || tempMode == "cool")) { + // If switching from range to heat/cool, update HomeKit using previous target temp + this.thermostatService.updateCharacteristic(HAP.Characteristic.TargetTemperature, this.deviceData.target_temperature); } } if (typeof callback === "function") callback(); // do callback if defined - this.updatingHomeKit = false; } - setTemperature(characteristic, value, callback) { - this.updatingHomeKit = true; + getMode(callback) { + var currentMode = undefined; - if (characteristic == Characteristic.TargetTemperature && this.ThermostatService.getCharacteristic(Characteristic.TargetHeatingCoolingState).value != Characteristic.TargetHeatingCoolingState.AUTO) { - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Set thermostat %s temperature on '%s' to '%s °C'", (this.ThermostatService.getCharacteristic(Characteristic.TargetHeatingCoolingState).value == Characteristic.TargetHeatingCoolingState.HEAT ? "heating" : "cooling"), this.deviceData.mac_address, value); - this.set({["shared." + this.deviceData.nest_device_structure.split(".")[1]] : {"target_temperature": value, "target_change_pending" : true} }); + if (this.deviceData.hvac_mode.toUpperCase() == "HEAT" || (this.deviceData.hvac_mode.toUpperCase() == "ECO" && this.deviceData.target_temperature_type.toUpperCase() == "HEAT")) { + // heating mode, either eco or normal; + currentMode = HAP.Characteristic.TargetHeatingCoolingState.HEAT; + } + if (this.deviceData.hvac_mode.toUpperCase() == "COOL" || (this.deviceData.hvac_mode.toUpperCase() == "ECO" && this.deviceData.target_temperature_type.toUpperCase() == "COOL")) { + // cooling mode, either eco or normal + currentMode = HAP.Characteristic.TargetHeatingCoolingState.COOL; } - if (characteristic == Characteristic.HeatingThresholdTemperature && this.ThermostatService.getCharacteristic(Characteristic.TargetHeatingCoolingState).value == Characteristic.TargetHeatingCoolingState.AUTO) { - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Set maximum heating temperature on thermostat '%s' to '%s °C'", this.deviceData.mac_address, value); - this.set({["shared." + this.deviceData.nest_device_structure.split(".")[1]] : {"target_temperature_low": value, "target_change_pending" : true} }); + if (this.deviceData.hvac_mode.toUpperCase() == "RANGE" || (this.deviceData.hvac_mode.toUpperCase() == "ECO" && this.deviceData.target_temperature_type.toUpperCase() == "RANGE")) { + // range mode, either eco or normal + currentMode = HAP.Characteristic.TargetHeatingCoolingState.AUTO; } - if (characteristic == Characteristic.CoolingThresholdTemperature && this.ThermostatService.getCharacteristic(Characteristic.TargetHeatingCoolingState).value == Characteristic.TargetHeatingCoolingState.AUTO) { - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Set minimum cooling temperature on thermostat '%s' to '%s °C'", this.deviceData.mac_address, value); - this.set({["shared." + this.deviceData.nest_device_structure.split(".")[1]] : {"target_temperature_high": value, "target_change_pending" : true} }); + if (this.deviceData.hvac_mode.toUpperCase() == "OFF") { + // off mode. + currentMode = HAP.Characteristic.TargetHeatingCoolingState.OFF; } + + if (typeof callback === "function") callback(null, currentMode); // do callback if defined + return currentMode; + } + + setTemperature(characteristic, temperature, callback) { + if (typeof characteristic == "function" && characteristic.hasOwnProperty("UUID") == true) { + if (characteristic.UUID == HAP.Characteristic.TargetTemperature.UUID && this.thermostatService.getCharacteristic(HAP.Characteristic.TargetHeatingCoolingState).value != HAP.Characteristic.TargetHeatingCoolingState.AUTO) { + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Set thermostat %s temperature on '%s' to '%s °C'", (this.thermostatService.getCharacteristic(HAP.Characteristic.TargetHeatingCoolingState).value == HAP.Characteristic.TargetHeatingCoolingState.HEAT ? "heating" : "cooling"), this.deviceData.mac_address, temperature); + this.set({["shared"] : {"target_temperature": temperature, "target_change_pending" : true} }); + } + if (characteristic.UUID == HAP.Characteristic.HeatingThresholdTemperature.UUID && this.thermostatService.getCharacteristic(HAP.Characteristic.TargetHeatingCoolingState).value == HAP.Characteristic.TargetHeatingCoolingState.AUTO) { + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Set maximum heating temperature on thermostat '%s' to '%s °C'", this.deviceData.mac_address, temperature); + this.set({["shared"] : {"target_temperature_low": temperature, "target_change_pending" : true} }); + } + if (characteristic.UUID == HAP.Characteristic.CoolingThresholdTemperature.UUID && this.thermostatService.getCharacteristic(HAP.Characteristic.TargetHeatingCoolingState).value == HAP.Characteristic.TargetHeatingCoolingState.AUTO) { + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Set minimum cooling temperature on thermostat '%s' to '%s °C'", this.deviceData.mac_address, temperature); + this.set({["shared"] : {"target_temperature_high": temperature, "target_change_pending" : true} }); + } - this.ThermostatService.updateCharacteristic(characteristic, value); // Update HomeKit with value + this.thermostatService.updateCharacteristic(characteristic, temperature); // Update HomeKit with value + } if (typeof callback === "function") callback(); // do callback if defined - this.updatingHomeKit = false; + } + + getTemperature(characteristic, callback) { + var currentTemperature = undefined; + + if (typeof characteristic == "function" && characteristic.hasOwnProperty("UUID") == true) { + if (characteristic.UUID == HAP.Characteristic.TargetTemperature.UUID) { + currentTemperature = this.deviceData.target_temperature; + } + if (characteristic.UUID == HAP.Characteristic.HeatingThresholdTemperature.UUID) { + currentTemperature = this.deviceData.target_temperature_low; + } + if (characteristic.UUID == HAP.Characteristic.CoolingThresholdTemperature.UUID) { + currentTemperature = this.deviceData.target_temperature_high; + } + } + if (typeof callback === "function") callback(null, currentTemperature); // do callback if defined + return currentTemperature; } setChildlock(pin, value, callback) { - this.updatingHomeKit = true; // TODO - pincode setting when turning on. Writes to device.xxxxxxxx.temperature_lock_pin_hash. How is the hash calculated??? // Do we set temperature range limits when child lock on?? - this.ThermostatService.updateCharacteristic(Characteristic.LockPhysicalControls, value); // Update HomeKit with value - if (value == Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED) { + this.thermostatService.updateCharacteristic(HAP.Characteristic.LockPhysicalControls, value); // Update HomeKit with value + if (value == HAP.Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED) { // Set pin hash???? } - if (value == Characteristic.LockPhysicalControls.CONTROL_LOCK_DISABLED) { + if (value == HAP.Characteristic.LockPhysicalControls.CONTROL_LOCK_DISABLED) { // Clear pin hash???? } - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Setting Childlock on '%s' to '%s'", this.deviceData.mac_address, (value == Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED ? "Enabled" : "Disabled")); - this.set({["device." + this.deviceData.nest_device_structure.split(".")[1]] : {"temperature_lock" : (value == Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED ? true : false) } }); + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Setting Childlock on '%s' to '%s'", this.deviceData.mac_address, (value == HAP.Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED ? "Enabled" : "Disabled")); + this.set({["device"] : {"temperature_lock" : (value == HAP.Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED ? true : false) } }); if (typeof callback === "function") callback(); // do callback if defined - this.updatingHomeKit = false; } - updateHomeKitServices(deviceData) { + updateHomeKitServices(updatedDeviceData) { + if (typeof updatedDeviceData != "object" || this.thermostatService == null || this.batteryService == null || this.occupancyService == null) { + return; + } + var historyEntry = {}; - if (this.updatingHomeKit == false) { - if (this.ThermostatService != null && this.BatteryService != null && this.OccupancyService != null) { - this.HomeKitAccessory.getService(Service.AccessoryInformation).updateCharacteristic(Characteristic.FirmwareRevision, deviceData.software_version); // Update firmware version - this.ThermostatService.updateCharacteristic(Characteristic.TemperatureDisplayUnits, deviceData.temperature_scale.toUpperCase() == "C" ? Characteristic.TemperatureDisplayUnits.CELSIUS : Characteristic.TemperatureDisplayUnits.FAHRENHEIT); - this.ThermostatService.updateCharacteristic(Characteristic.CurrentTemperature, deviceData.active_temperature); - this.ThermostatService.updateCharacteristic(Characteristic.StatusFault, (deviceData.online == true && deviceData.removed_from_base == false) ? Characteristic.StatusFault.NO_FAULT : Characteristic.StatusFault.GENERAL_FAULT); // If Nest isn't online or removed from base, report in HomeKit - this.ThermostatService.updateCharacteristic(Characteristic.LockPhysicalControls, deviceData.temperature_lock == true ? Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED : Characteristic.LockPhysicalControls.CONTROL_LOCK_DISABLED); - this.ThermostatService.updateCharacteristic(Characteristic.FilterChangeIndication, (deviceData.has_air_filter && deviceData.filter_replacement_needed == true ? Characteristic.FilterChangeIndication.CHANGE_FILTER : Characteristic.FilterChangeIndication.FILTER_OK)); - this.ThermostatService.updateCharacteristic(Characteristic.StatusActive, (deviceData.active_rcs_sensor != "" ? false : true)); // Using a temperature sensor as active temperature? - - // Battery status if defined. Since Nest needs 3.6 volts to turn on, we'll use that as the lower limit. Havent seen battery level above 3.9ish, so assume 3.9 is upper limit - var tempBatteryLevel = __scale(deviceData.battery_level, 3.6, 3.9, 0, 100); - this.BatteryService.updateCharacteristic(Characteristic.BatteryLevel, tempBatteryLevel); - this.BatteryService.updateCharacteristic(Characteristic.StatusLowBattery, tempBatteryLevel > LOWBATTERYLEVEL ? Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL : Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW); - this.BatteryService.updateCharacteristic(Characteristic.ChargingState, (deviceData.battery_level > this.deviceData.battery_level && this.deviceData.battery_level != 0 ? true : false) ? Characteristic.ChargingState.CHARGING : Characteristic.ChargingState.NOT_CHARGING); - - // Update for away/home status. Away = no occupancy detected, Home = Occupancy Detected - this.OccupancyService.updateCharacteristic(Characteristic.OccupancyDetected, deviceData.away == true ? Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED : Characteristic.OccupancyDetected.OCCUPANCY_DETECTED); - this.OccupancyService.updateCharacteristic(Characteristic.StatusFault, (deviceData.online == true && deviceData.removed_from_base == false) ? Characteristic.StatusFault.NO_FAULT : Characteristic.StatusFault.GENERAL_FAULT); // If Nest isn't online or removed from base, report in HomeKit - - // Update seperate humidity sensor if configured todo so - if (this.HumidityService != null) { - this.HumidityService.updateCharacteristic(Characteristic.CurrentRelativeHumidity, deviceData.current_humidity); // Humidity will be listed under seperate sensor - this.HumidityService.updateCharacteristic(Characteristic.StatusFault, (deviceData.online == true && deviceData.removed_from_base == false) ? Characteristic.StatusFault.NO_FAULT : Characteristic.StatusFault.GENERAL_FAULT); // If Nest isn't online or removed from base, report in HomeKit - } else { - this.ThermostatService.updateCharacteristic(Characteristic.CurrentRelativeHumidity, deviceData.current_humidity); // Humidity will be listed under thermostat only - } + this.thermostatService.updateCharacteristic(HAP.Characteristic.TemperatureDisplayUnits, updatedDeviceData.temperature_scale.toUpperCase() == "C" ? HAP.Characteristic.TemperatureDisplayUnits.CELSIUS : HAP.Characteristic.TemperatureDisplayUnits.FAHRENHEIT); + this.thermostatService.updateCharacteristic(HAP.Characteristic.CurrentTemperature, updatedDeviceData.active_temperature); + this.thermostatService.updateCharacteristic(HAP.Characteristic.StatusFault, (updatedDeviceData.online == true && updatedDeviceData.removed_from_base == false) ? HAP.Characteristic.StatusFault.NO_FAULT : HAP.Characteristic.StatusFault.GENERAL_FAULT); // If Nest isn't online or removed from base, report in HomeKit + this.thermostatService.updateCharacteristic(HAP.Characteristic.LockPhysicalControls, updatedDeviceData.temperature_lock == true ? HAP.Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED : HAP.Characteristic.LockPhysicalControls.CONTROL_LOCK_DISABLED); + this.thermostatService.updateCharacteristic(HAP.Characteristic.FilterChangeIndication, (updatedDeviceData.has_air_filter && updatedDeviceData.filter_replacement_needed == true ? HAP.Characteristic.FilterChangeIndication.CHANGE_FILTER : HAP.Characteristic.FilterChangeIndication.FILTER_OK)); + this.thermostatService.updateCharacteristic(HAP.Characteristic.StatusActive, (updatedDeviceData.active_rcs_sensor != "" ? false : true)); // Using a temperature sensor as active temperature? + + // Battery status if defined. Since Nest needs 3.6 volts to turn on, we'll use that as the lower limit. Havent seen battery level above 3.9ish, so assume 3.9 is upper limit + var tempBatteryLevel = scaleValue(updatedDeviceData.battery_level, 3.6, 3.9, 0, 100); + this.batteryService.updateCharacteristic(HAP.Characteristic.BatteryLevel, tempBatteryLevel); + this.batteryService.updateCharacteristic(HAP.Characteristic.StatusLowBattery, tempBatteryLevel > LOWBATTERYLEVEL ? HAP.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL : HAP.Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW); + this.batteryService.updateCharacteristic(HAP.Characteristic.ChargingState, (updatedDeviceData.battery_level > this.deviceData.battery_level && this.deviceData.battery_level != 0 ? true : false) ? HAP.Characteristic.ChargingState.CHARGING : HAP.Characteristic.ChargingState.NOT_CHARGING); + + // Update for away/home status. Away = no occupancy detected, Home = Occupancy Detected + this.occupancyService.updateCharacteristic(HAP.Characteristic.OccupancyDetected, updatedDeviceData.away == true ? HAP.Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED : HAP.Characteristic.OccupancyDetected.OCCUPANCY_DETECTED); + this.occupancyService.updateCharacteristic(HAP.Characteristic.StatusFault, (updatedDeviceData.online == true && updatedDeviceData.removed_from_base == false) ? HAP.Characteristic.StatusFault.NO_FAULT : HAP.Characteristic.StatusFault.GENERAL_FAULT); // If Nest isn't online or removed from base, report in HomeKit + + // Update seperate humidity sensor if configured todo so + if (this.humidityService != null) { + this.humidityService.updateCharacteristic(HAP.Characteristic.CurrentRelativeHumidity, updatedDeviceData.current_humidity); // Humidity will be listed under seperate sensor + this.humidityService.updateCharacteristic(HAP.Characteristic.StatusFault, (updatedDeviceData.online == true && updatedDeviceData.removed_from_base == false) ? HAP.Characteristic.StatusFault.NO_FAULT : HAP.Characteristic.StatusFault.GENERAL_FAULT); // If Nest isn't online or removed from base, report in HomeKit + } else { + this.thermostatService.updateCharacteristic(HAP.Characteristic.CurrentRelativeHumidity, updatedDeviceData.current_humidity); // Humidity will be listed under thermostat only + } - // Check for fan setup change on thermostat - if (deviceData.has_fan != this.deviceData.has_fan) { - if (deviceData.has_fan == false && this.deviceData.has_fan == true && this.FanService == null) { - // Fan has been added - this.FanService = this.HomeKitAccessory.addService(Service.Fan, "Fan", 1); - this.FanService.getCharacteristic(Characteristic.On).on("set", this.setFan.bind(this)); - } - if (deviceData.has_fan == true && this.deviceData.has_fan == false && this.FanService != null) { - // Fan has been removed - this.HomeKitAccessory.removeService(this.FanService); - this.FanService = null; - } - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Fan setup on thermostat '%s' has changed. Fan was", this.deviceData.mac_address, (this.FanService == null ? "removed" : "added")); - } + // Check for fan setup change on thermostat + if (updatedDeviceData.has_fan != this.deviceData.has_fan) { + if (updatedDeviceData.has_fan == false && this.deviceData.has_fan == true && this.fanService == null) { + // Fan has been added + this.fanService = this.HomeKitAccessory.addService(HAP.Service.Fan, "Fan", 1); + this.fanService.getCharacteristic(HAP.Characteristic.On).on("set", (value, callback) => {this.setFan(value, callback); }); + this.fanService.getCharacteristic(HAP.Characteristic.On).on("get", (callback) => {callback(null, this.deviceData.fan_state); }); + } + if (updatedDeviceData.has_fan == true && this.deviceData.has_fan == false && this.fanService != null) { + // Fan has been removed + this.HomeKitAccessory.removeService(this.fanService); + this.fanService = null; + } + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Fan setup on thermostat '%s' has changed. Fan was", this.deviceData.mac_address, (this.fanService == null ? "removed" : "added")); + } - if ((deviceData.can_cool != this.deviceData.can_cool) || (deviceData.can_heat != this.deviceData.can_heat)) { - // Heating and/cooling setup has changed on thermostat + if ((updatedDeviceData.can_cool != this.deviceData.can_cool) || (updatedDeviceData.can_heat != this.deviceData.can_heat)) { + // Heating and/cooling setup has changed on thermostat - // Limit prop ranges - if (deviceData.can_cool == false && deviceData.can_heat == true) - { - // Can heat only, so set values allowed for mode off/heat - this.ThermostatService.getCharacteristic(Characteristic.TargetHeatingCoolingState).setProps({validValues: [Characteristic.TargetHeatingCoolingState.OFF, Characteristic.TargetHeatingCoolingState.HEAT]}); - } - if (deviceData.can_cool == true && deviceData.can_heat == false) { - // Can cool only - this.ThermostatService.getCharacteristic(Characteristic.TargetHeatingCoolingState).setProps({validValues: [Characteristic.TargetHeatingCoolingState.OFF, Characteristic.TargetHeatingCoolingState.COOL]}); - } - if (deviceData.can_cool == true && deviceData.can_heat == true) { - // heat and cool - this.ThermostatService.getCharacteristic(Characteristic.TargetHeatingCoolingState).setProps({validValues: [Characteristic.TargetHeatingCoolingState.OFF, Characteristic.TargetHeatingCoolingState.HEAT, Characteristic.TargetHeatingCoolingState.COOL, Characteristic.TargetHeatingCoolingState.AUTO]}); - } - if (deviceData.can_cool == false && deviceData.can_heat == false) { - // only off mode - this.ThermostatService.getCharacteristic(Characteristic.TargetHeatingCoolingState).setProps({validValues: [Characteristic.TargetHeatingCoolingState.OFF]}); - } - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Heating/cooling setup on thermostat on '%s' has changed", this.deviceData.mac_address); - } + // Limit prop ranges + if (updatedDeviceData.can_cool == false && updatedDeviceData.can_heat == true) + { + // Can heat only, so set values allowed for mode off/heat + this.thermostatService.getCharacteristic(HAP.Characteristic.TargetHeatingCoolingState).setProps({validValues: [HAP.Characteristic.TargetHeatingCoolingState.OFF, HAP.Characteristic.TargetHeatingCoolingState.HEAT]}); + } + if (updatedDeviceData.can_cool == true && updatedDeviceData.can_heat == false) { + // Can cool only + this.thermostatService.getCharacteristic(HAP.Characteristic.TargetHeatingCoolingState).setProps({validValues: [HAP.Characteristic.TargetHeatingCoolingState.OFF, HAP.Characteristic.TargetHeatingCoolingState.COOL]}); + } + if (updatedDeviceData.can_cool == true && updatedDeviceData.can_heat == true) { + // heat and cool + this.thermostatService.getCharacteristic(HAP.Characteristic.TargetHeatingCoolingState).setProps({validValues: [HAP.Characteristic.TargetHeatingCoolingState.OFF, HAP.Characteristic.TargetHeatingCoolingState.HEAT, HAP.Characteristic.TargetHeatingCoolingState.COOL, HAP.Characteristic.TargetHeatingCoolingState.AUTO]}); + } + if (updatedDeviceData.can_cool == false && updatedDeviceData.can_heat == false) { + // only off mode + this.thermostatService.getCharacteristic(HAP.Characteristic.TargetHeatingCoolingState).setProps({validValues: [HAP.Characteristic.TargetHeatingCoolingState.OFF]}); + } + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Heating/cooling setup on thermostat on '%s' has changed", this.deviceData.mac_address); + } - // Update current mode temperatures - if (deviceData.target_temperature_type != this.deviceData.target_temperature_type) { - // track target temperature type changes - this.previous_target_temperature_type = this.deviceData.target_temperature_type; - } - if (deviceData.hvac_mode.toUpperCase() == "HEAT" || (deviceData.hvac_mode.toUpperCase() == "ECO" && deviceData.target_temperature_type.toUpperCase() == "HEAT")) { - // heating mode, either eco or normal - this.ThermostatService.updateCharacteristic(Characteristic.HeatingThresholdTemperature, deviceData.target_temperature_low); - this.ThermostatService.updateCharacteristic(Characteristic.CoolingThresholdTemperature, deviceData.target_temperature_high); - this.ThermostatService.updateCharacteristic(Characteristic.TargetTemperature, deviceData.target_temperature); - this.ThermostatService.updateCharacteristic(Characteristic.TargetHeatingCoolingState, Characteristic.TargetHeatingCoolingState.HEAT); - historyEntry.target = {low: 0, high: deviceData.target_temperature}; // single target temperature for heating limit - } - if (deviceData.hvac_mode.toUpperCase() == "COOL" || (deviceData.hvac_mode.toUpperCase() == "ECO" && deviceData.target_temperature_type.toUpperCase() == "COOL")) { - // cooling mode, either eco or normal - this.ThermostatService.updateCharacteristic(Characteristic.HeatingThresholdTemperature, deviceData.target_temperature_low); - this.ThermostatService.updateCharacteristic(Characteristic.CoolingThresholdTemperature, deviceData.target_temperature_high); - this.ThermostatService.updateCharacteristic(Characteristic.TargetTemperature, deviceData.target_temperature); - this.ThermostatService.updateCharacteristic(Characteristic.TargetHeatingCoolingState, Characteristic.TargetHeatingCoolingState.COOL); - historyEntry.target = {low: deviceData.target_temperature, high: 0}; // single target temperature for cooling limit - } - if (deviceData.hvac_mode.toUpperCase() == "RANGE" || (deviceData.hvac_mode.toUpperCase() == "ECO" && deviceData.target_temperature_type.toUpperCase() == "RANGE")) { - // range mode, either eco or normal - this.ThermostatService.updateCharacteristic(Characteristic.HeatingThresholdTemperature, deviceData.target_temperature_low); - this.ThermostatService.updateCharacteristic(Characteristic.CoolingThresholdTemperature, deviceData.target_temperature_high); - this.ThermostatService.updateCharacteristic(Characteristic.TargetHeatingCoolingState, Characteristic.TargetHeatingCoolingState.AUTO); - historyEntry.target = {low: deviceData.target_temperature_low, high: deviceData.target_temperature_high}; // target temperature range - } - if (deviceData.hvac_mode.toUpperCase() == "OFF") { - // off mode. - this.ThermostatService.updateCharacteristic(Characteristic.HeatingThresholdTemperature, deviceData.target_temperature_low); - this.ThermostatService.updateCharacteristic(Characteristic.CoolingThresholdTemperature, deviceData.target_temperature_high); - this.ThermostatService.updateCharacteristic(Characteristic.TargetTemperature, deviceData.target_temperature); - this.ThermostatService.updateCharacteristic(Characteristic.TargetHeatingCoolingState, Characteristic.TargetHeatingCoolingState.OFF); - historyEntry.target = {low: 0, high: 0}; // thermostat off, so no target temperatures - } - - // Update current state - if (deviceData.hvac_state.toUpperCase() == "HEATING") { - if (this.deviceData.hvac_state.toUpperCase() == "COOLING" && typeof deviceData.externalCool == "object") { - // Switched to heating mode and external cooling external code was being used, so stop cooling via cooling external code - if (typeof deviceData.externalCool.off == "function") deviceData.externalCool.off(config.debug.includes(Debugging.EXTERNAL)); - } - if ((this.deviceData.hvac_state.toUpperCase() != "HEATING" || deviceData.target_temperature != this.deviceData.target_temperature) && typeof deviceData.externalHeat == "object") { - // Switched to heating mode and external heating external code is being used - // Start heating via heating external code OR adjust heating target temperature due to change - if (typeof deviceData.externalHeat.heat == "function") deviceData.externalHeat.heat(deviceData.target_temperature, config.debug.includes(Debugging.EXTERNAL)); - } - this.ThermostatService.updateCharacteristic(Characteristic.CurrentHeatingCoolingState, Characteristic.CurrentHeatingCoolingState.HEAT); - historyEntry.status = 2; // heating - } - if (deviceData.hvac_state.toUpperCase() == "COOLING") { - if (this.deviceData.hvac_state.toUpperCase() == "HEATING" && typeof deviceData.externalHeat == "object") { - // Switched to cooling mode and external heating external code was being used, so stop heating via heating external code - if (typeof deviceData.externalHeat.off == "function") deviceData.externalHeat.off(config.debug.includes(Debugging.EXTERNAL)); - } - if ((this.deviceData.hvac_state.toUpperCase() != "COOLING" || deviceData.target_temperature != this.deviceData.target_temperature) && typeof deviceData.externalCool == "object") { - // Switched to cooling mode and external cooling external code is being used - // Start cooling via cooling external code OR adjust cooling target temperature due to change - if (typeof deviceData.externalCool.cool == "function") deviceData.externalCool.cool(deviceData.target_temperature, config.debug.includes(Debugging.EXTERNAL)); - } - this.ThermostatService.updateCharacteristic(Characteristic.CurrentHeatingCoolingState, Characteristic.CurrentHeatingCoolingState.COOL); - historyEntry.status = 3; // cooling - } - if (deviceData.hvac_state.toUpperCase() == "OFF") { - if (this.deviceData.hvac_state.toUpperCase() == "COOLING" && typeof deviceData.externalCool == "object") { - // Switched to off mode and external cooling external code was being used, so stop cooling via cooling external code - if (typeof deviceData.externalCool.off == "function") deviceData.externalCool.off(config.debug.includes(Debugging.EXTERNAL)); - } - if (this.deviceData.hvac_state.toUpperCase() == "HEATING" && typeof deviceData.externalHeat == "object") { - // Switched to off mode and external heating external code was being used, so stop heating via heating external code - if (typeof deviceData.externalHeat.heat == "function") deviceData.externalHeat.off(config.debug.includes(Debugging.EXTERNAL)); - } - this.ThermostatService.updateCharacteristic(Characteristic.CurrentHeatingCoolingState, Characteristic.CurrentHeatingCoolingState.OFF); - historyEntry.status = 0; // off - } - if (this.FanService != null) { - if (this.deviceData.fan_state == false && deviceData.fan_state == true && typeof deviceData.externalFan == "object") { - // Fan mode was switched on and external fan external code is being used, so start fan via fan external code - if (typeof deviceData.externalFan.fan == "function") deviceData.externalFan.fan(0, config.debug.includes(Debugging.EXTERNAL)); // Fan speed will be auto + // Update current mode temperatures + if (updatedDeviceData.target_temperature_type != this.deviceData.target_temperature_type) { + // track target temperature type changes + this.previous_target_temperature_type = this.deviceData.target_temperature_type; + } + if (updatedDeviceData.hvac_mode.toUpperCase() == "HEAT" || (updatedDeviceData.hvac_mode.toUpperCase() == "ECO" && updatedDeviceData.target_temperature_type.toUpperCase() == "HEAT")) { + // heating mode, either eco or normal + this.thermostatService.updateCharacteristic(HAP.Characteristic.HeatingThresholdTemperature, updatedDeviceData.target_temperature_low); + this.thermostatService.updateCharacteristic(HAP.Characteristic.CoolingThresholdTemperature, updatedDeviceData.target_temperature_high); + this.thermostatService.updateCharacteristic(HAP.Characteristic.TargetTemperature, updatedDeviceData.target_temperature); + this.thermostatService.updateCharacteristic(HAP.Characteristic.TargetHeatingCoolingState, HAP.Characteristic.TargetHeatingCoolingState.HEAT); + historyEntry.target = {low: 0, high: updatedDeviceData.target_temperature}; // single target temperature for heating limit + } + if (updatedDeviceData.hvac_mode.toUpperCase() == "COOL" || (updatedDeviceData.hvac_mode.toUpperCase() == "ECO" && updatedDeviceData.target_temperature_type.toUpperCase() == "COOL")) { + // cooling mode, either eco or normal + this.thermostatService.updateCharacteristic(HAP.Characteristic.HeatingThresholdTemperature, updatedDeviceData.target_temperature_low); + this.thermostatService.updateCharacteristic(HAP.Characteristic.CoolingThresholdTemperature, updatedDeviceData.target_temperature_high); + this.thermostatService.updateCharacteristic(HAP.Characteristic.TargetTemperature, updatedDeviceData.target_temperature); + this.thermostatService.updateCharacteristic(HAP.Characteristic.TargetHeatingCoolingState, HAP.Characteristic.TargetHeatingCoolingState.COOL); + historyEntry.target = {low: updatedDeviceData.target_temperature, high: 0}; // single target temperature for cooling limit + } + if (updatedDeviceData.hvac_mode.toUpperCase() == "RANGE" || (updatedDeviceData.hvac_mode.toUpperCase() == "ECO" && updatedDeviceData.target_temperature_type.toUpperCase() == "RANGE")) { + // range mode, either eco or normal + this.thermostatService.updateCharacteristic(HAP.Characteristic.HeatingThresholdTemperature, updatedDeviceData.target_temperature_low); + this.thermostatService.updateCharacteristic(HAP.Characteristic.CoolingThresholdTemperature, updatedDeviceData.target_temperature_high); + this.thermostatService.updateCharacteristic(HAP.Characteristic.TargetHeatingCoolingState, HAP.Characteristic.TargetHeatingCoolingState.AUTO); + historyEntry.target = {low: updatedDeviceData.target_temperature_low, high: updatedDeviceData.target_temperature_high}; // target temperature range + } + if (updatedDeviceData.hvac_mode.toUpperCase() == "OFF") { + // off mode. + this.thermostatService.updateCharacteristic(HAP.Characteristic.HeatingThresholdTemperature, updatedDeviceData.target_temperature_low); + this.thermostatService.updateCharacteristic(HAP.Characteristic.CoolingThresholdTemperature, updatedDeviceData.target_temperature_high); + this.thermostatService.updateCharacteristic(HAP.Characteristic.TargetTemperature, updatedDeviceData.target_temperature); + this.thermostatService.updateCharacteristic(HAP.Characteristic.TargetHeatingCoolingState, HAP.Characteristic.TargetHeatingCoolingState.OFF); + historyEntry.target = {low: 0, high: 0}; // thermostat off, so no target temperatures + } + + // Update current state + if (updatedDeviceData.hvac_state.toUpperCase() == "HEATING") { + if (this.deviceData.hvac_state.toUpperCase() == "COOLING" && typeof updatedDeviceData.ExternalCool == "object") { + // Switched to heating mode and external cooling external code was being used, so stop cooling via cooling external code + if (typeof updatedDeviceData.ExternalCool.off == "function") updatedDeviceData.ExternalCool.off(config.debug.includes(Debugging.EXTERNAL)); + } + if ((this.deviceData.hvac_state.toUpperCase() != "HEATING" || updatedDeviceData.target_temperature != this.deviceData.target_temperature) && typeof updatedDeviceData.ExternalHeat == "object") { + // Switched to heating mode and external heating external code is being used + // Start heating via heating external code OR adjust heating target temperature due to change + if (typeof updatedDeviceData.ExternalHeat.heat == "function") updatedDeviceData.ExternalHeat.heat(updatedDeviceData.target_temperature, config.debug.includes(Debugging.EXTERNAL)); + } + this.thermostatService.updateCharacteristic(HAP.Characteristic.CurrentHeatingCoolingState, HAP.Characteristic.CurrentHeatingCoolingState.HEAT); + historyEntry.status = 2; // heating + } + if (updatedDeviceData.hvac_state.toUpperCase() == "COOLING") { + if (this.deviceData.hvac_state.toUpperCase() == "HEATING" && typeof updatedDeviceData.ExternalHeat == "object") { + // Switched to cooling mode and external heating external code was being used, so stop heating via heating external code + if (typeof updatedDeviceData.ExternalHeat.off == "function") updatedDeviceData.ExternalHeat.off(config.debug.includes(Debugging.EXTERNAL)); + } + if ((this.deviceData.hvac_state.toUpperCase() != "COOLING" || updatedDeviceData.target_temperature != this.deviceData.target_temperature) && typeof updatedDeviceData.ExternalCool == "object") { + // Switched to cooling mode and external cooling external code is being used + // Start cooling via cooling external code OR adjust cooling target temperature due to change + if (typeof updatedDeviceData.ExternalCool.cool == "function") updatedDeviceData.ExternalCool.cool(updatedDeviceData.target_temperature, config.debug.includes(Debugging.EXTERNAL)); + } + this.thermostatService.updateCharacteristic(HAP.Characteristic.CurrentHeatingCoolingState, HAP.Characteristic.CurrentHeatingCoolingState.COOL); + historyEntry.status = 3; // cooling + } + if (updatedDeviceData.hvac_state.toUpperCase() == "OFF") { + if (this.deviceData.hvac_state.toUpperCase() == "COOLING" && typeof updatedDeviceData.ExternalCool == "object") { + // Switched to off mode and external cooling external code was being used, so stop cooling via cooling external code + if (typeof updatedDeviceData.ExternalCool.off == "function") updatedDeviceData.ExternalCool.off(config.debug.includes(Debugging.EXTERNAL)); + } + if (this.deviceData.hvac_state.toUpperCase() == "HEATING" && typeof updatedDeviceData.ExternalHeat == "object") { + // Switched to off mode and external heating external code was being used, so stop heating via heating external code + if (typeof updatedDeviceData.ExternalHeat.heat == "function") updatedDeviceData.ExternalHeat.off(config.debug.includes(Debugging.EXTERNAL)); + } + this.thermostatService.updateCharacteristic(HAP.Characteristic.CurrentHeatingCoolingState, HAP.Characteristic.CurrentHeatingCoolingState.OFF); + historyEntry.status = 0; // off + } + if (this.fanService != null) { + if (this.deviceData.fan_state == false && updatedDeviceData.fan_state == true && typeof updatedDeviceData.ExternalFan == "object") { + // Fan mode was switched on and external fan external code is being used, so start fan via fan external code + if (typeof updatedDeviceData.ExternalFan.fan == "function") updatedDeviceData.ExternalFan.fan(0, config.debug.includes(Debugging.EXTERNAL)); // Fan speed will be auto + } + if (this.deviceData.fan_state == true && updatedDeviceData.fan_state == false && typeof updatedDeviceData.ExternalFan == "object") { + // Fan mode was switched off and external fan external code was being used, so stop fan via fan external code + if (typeof updatedDeviceData.ExternalFan.off == "function") updatedDeviceData.ExternalFan.off(config.debug.includes(Debugging.EXTERNAL)); + } + this.fanService.updateCharacteristic(HAP.Characteristic.On, updatedDeviceData.fan_state); // fan status on or off + //historyEntry.status = 1; // fan <-- TODO in history + } + + // Log thermostat metrics to history only if changed to previous recording + if (this.HomeKitHistory != null) { + var tempEntry = this.HomeKitHistory.lastHistory(this.thermostatService); + if (tempEntry == null || (typeof tempEntry == "object" && tempEntry.status != historyEntry.status || tempEntry.temperature != updatedDeviceData.active_temperature || JSON.stringify(tempEntry.target) !== JSON.stringify(historyEntry.target) || tempEntry.humidity != updatedDeviceData.current_humidity)) { + this.HomeKitHistory.addHistory(this.thermostatService, {time: Math.floor(new Date() / 1000), status: historyEntry.status, temperature: updatedDeviceData.active_temperature, target: historyEntry.target, humidity: updatedDeviceData.current_humidity}); + } + } + + // Notify Eve App of device status changes if linked + if (this.HomeKitHistory != null && this.deviceData.EveApp == true) { + // Update our internal data with properties Eve will need to process + this.deviceData.online == updatedDeviceData.online; + this.deviceData.removed_from_base == updatedDeviceData.removed_from_base; + this.deviceData.vacation_mode = updatedDeviceData.vacation_mode; + this.deviceData.hvac_mode = updatedDeviceData.hvac_mode; + this.deviceData.target_temperature_type = updatedDeviceData.target_temperature_type; + this.deviceData.schedules = updatedDeviceData.schedules; + this.deviceData.schedule_mode = updatedDeviceData.schedule_mode; + this.HomeKitHistory.updateEveHome(this.thermostatService, this.#EveHomeGetCommand.bind(this)); + } + } + + #EveHomeGetCommand(EveHomeGetData) { + // Pass back extra data for Eve Thermo "get" process command + // Data will already be an object, our only job is to add/modify to it + //EveHomeGetData.enableschedule = optionalParams.hasOwnProperty("EveThermo_enableschedule") ? optionalParams.EveThermo_enableschedule : false; // Schedules on/off + EveHomeGetData.attached = (this.deviceData.online == true && this.deviceData.removed_from_base == false); + EveHomeGetData.vacation = this.deviceData.vacation_mode; // Vaction mode on/off + EveHomeGetData.vacationtemp = (this.deviceData.vacation_mode == true ? EveHomeGetData.vacationtemp : null); + EveHomeGetData.datetime = new Date(); // Current date/time for encoding + EveHomeGetData.programs = []; // No programs yet, we'll process this below + if (this.deviceData.schedule_mode.toUpperCase() == "HEAT" || this.deviceData.schedule_mode.toUpperCase() == "RANGE") { + const DAYSOFWEEK = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; + + Object.entries(this.deviceData.schedules).forEach(([day, schedules]) => { + var tempSchedule = []; + var tempTemperatures = []; + Object.entries(schedules).reverse().forEach(([id, schedule]) => { + if (schedule.entry_type == "setpoint" && (schedule.type == "HEAT" || schedule.type == "RANGE")) { + tempSchedule.push({"start" : schedule.time, "duration" : 0, "offset" : schedule.touched_tzo, "temperature" : schedule.hasOwnProperty("temp-min") == true ? schedule["temp-min"] : schedule.temp}); + tempTemperatures.push(schedule.hasOwnProperty("temp-min") == true ? schedule["temp-min"] : schedule.temp); } - if (this.deviceData.fan_state == true && deviceData.fan_state == false && typeof deviceData.externalFan == "object") { - // Fan mode was switched off and external fan external code was being used, so stop fan via fan external code - if (typeof deviceData.externalFan.off == "function") deviceData.externalFan.off(config.debug.includes(Debugging.EXTERNAL)); + }); + + // Sort the schedule array by start time + tempSchedule = tempSchedule.sort((a, b) => { + if (a.start < b.start) { + return -1; } - this.FanService.updateCharacteristic(Characteristic.On, deviceData.fan_state); // fan status on or off - historyEntry.status = 1; // fan - } + }); - // Log thermostat metrics to history only if changed to previous recording - if (this.historyService != null) { - var tempEntry = this.historyService.lastHistory(this.ThermostatService); - if (tempEntry == null || (typeof tempEntry == "object" && tempEntry.status != historyEntry.status || tempEntry.temperature != deviceData.active_temperature || JSON.stringify(tempEntry.target) != JSON.stringify(historyEntry.target) || tempEntry.humidity != deviceData.current_humidity)) { - this.historyService.addHistory(this.ThermostatService, {time: Math.floor(new Date() / 1000), status: historyEntry.status, temperature: deviceData.active_temperature, target: historyEntry.target, humidity: deviceData.current_humidity}); + var ecoTemp = tempTemperatures.length == 0 ? 0 : Math.min(...tempTemperatures); + var comfortTemp = tempTemperatures.length == 0 ? 0 : Math.max(...tempTemperatures); + var program = []; + program.days = DAYSOFWEEK[day]; + program.schedule = []; + var lastTime = 86400; // seconds in a day + Object.entries(tempSchedule).reverse().forEach(([id, schedule]) => { + if (schedule.temperature == comfortTemp) { + // We only want to add the schedule time if its using the "max" temperature + program.schedule.push({"start" : schedule.start, "duration" : (lastTime - schedule.start), "ecotemp" : ecoTemp, "comforttemp" : comfortTemp}); } - } - } + lastTime = schedule.start; + }); + EveHomeGetData.programs.push(program); + }); + } + + return EveHomeGetData; + } + + #EveHomeSetCommand(EveHomeSetData) { + if (typeof EveHomeSetData != "object") { + return; } + + if (EveHomeSetData.hasOwnProperty("vacation") == true) { + this.deviceData.vacation_mode = EveHomeSetData.vacation.status; + this.set({["structure"] : {"vacation_mode" : this.deviceData.vacation_mode } }); + } + if (EveHomeSetData.hasOwnProperty("programs") == true) { + EveHomeSetData.programs.forEach((day) => { + // Convert into Nest thermostat schedule format and set. Need to work this out + //this.set({["schedule"] : {"days" : {6 : { "temp" : 17 , "time" : 13400, touched_at: Date.now()}} }}); + }); + } + } } // Nest Temperature Sensors -class TempSensorClass extends HomeKitDevice { - constructor(deviceData, eventEmitter) { - super(deviceData.nest_device_structure, deviceData, eventEmitter); +class NestTemperatureSensor extends HomeKitDevice { + constructor(currentDeviceData, globalEventEmitter) { + super(ACCESSORYNAME, ACCESSORYPINCODE, config.mDNS, currentDeviceData.device_uuid, currentDeviceData, globalEventEmitter); - this.TemperatureService = null; // HomeKit service for this temperature sensor - this.BatteryService = null; // Status of Nest Temperature Sensor Battery + this.temperatureService = null; // HomeKit service for this temperature sensor + this.batteryService = null; // HomeKit service for battery status } // Class functions addHomeKitServices(serviceName) { // Add this temperature sensor to the "master" accessory and set properties - this.TemperatureService = this.HomeKitAccessory.addService(Service.TemperatureSensor, serviceName, 1); - this.TemperatureService.addCharacteristic(Characteristic.StatusActive); - this.TemperatureService.addCharacteristic(Characteristic.StatusFault); + this.temperatureService = this.HomeKitAccessory.addService(HAP.Service.TemperatureSensor, "Temperature", 1); + this.temperatureService.addCharacteristic(HAP.Characteristic.StatusActive); + this.temperatureService.addCharacteristic(HAP.Characteristic.StatusFault); // Add battery service to display battery level - this.BatteryService = this.HomeKitAccessory.addService(Service.BatteryService, "", 1); - this.BatteryService.updateCharacteristic(Characteristic.ChargingState, Characteristic.ChargingState.NOT_CHARGEABLE); // dont charge as run off battery + this.batteryService = this.HomeKitAccessory.addService(HAP.Service.BatteryService, "", 1); + this.batteryService.updateCharacteristic(HAP.Characteristic.ChargingState, HAP.Characteristic.ChargingState.NOT_CHARGEABLE); // Battery isnt charageable // Setup linkage to EveHome app if configured todo so - this.deviceData.EveApp && this.historyService && this.historyService.linkToEveHome(this.HomeKitAccessory, this.TemperatureService, {debug: config.debug.includes("HISTORY")}); + if (this.deviceData.EveApp == true && this.HomeKitHistory != null) { + this.HomeKitHistory.linkToEveHome(this.HomeKitAccessory, this.temperatureService, {debug: config.debug.includes("HISTORY")}); + } - console.log("Setup Nest Temperature Sensor '%s' on '%s'", serviceName, this.HomeKitAccessory.username); + outputLogging(ACCESSORYNAME, false, "Setup Nest Temperature Sensor '%s'", serviceName); } - updateHomeKitServices(deviceData) { - if (this.TemperatureService != null && this.BatteryService != null) { - this.TemperatureService.updateCharacteristic(Characteristic.StatusFault, (deviceData.online == true ? Characteristic.StatusFault.NO_FAULT : Characteristic.StatusFault.GENERAL_FAULT)); // If Nest isn't online, report in HomeKit - - // Is this sensor providing the active temperature for a thermostat - this.TemperatureService.updateCharacteristic(Characteristic.StatusActive, deviceData.active_sensor); + updateHomeKitServices(updatedDeviceData) { + if (typeof updatedDeviceData != "object" || this.temperatureService == null || this.batteryService == null) { + return; + } - // Update temperature - this.TemperatureService.updateCharacteristic(Characteristic.CurrentTemperature, deviceData.current_temperature); + this.temperatureService.updateCharacteristic(HAP.Characteristic.StatusFault, (updatedDeviceData.online == true ? HAP.Characteristic.StatusFault.NO_FAULT : HAP.Characteristic.StatusFault.GENERAL_FAULT)); // If Nest isn't online, report in HomeKit - // Update battery level - var tempBatteryLevel = __scale(deviceData.battery_level, 0, 100, 0, 100); - this.BatteryService.updateCharacteristic(Characteristic.BatteryLevel, tempBatteryLevel); - this.BatteryService.updateCharacteristic(Characteristic.StatusLowBattery, tempBatteryLevel > LOWBATTERYLEVEL ? Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL : Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW); + // Is this sensor providing the active temperature for a thermostat + this.temperatureService.updateCharacteristic(HAP.Characteristic.StatusActive, updatedDeviceData.active_sensor); - // Log temperture to history only if changed to previous recording - if (deviceData.current_temperature != this.deviceData.current_temperature) { - this.historySevice && this.historyService.addHistory(this.TemperatureService, {time: Math.floor(new Date() / 1000), temperature: deviceData.current_temperature}); - } + // Update temperature + this.temperatureService.updateCharacteristic(HAP.Characteristic.CurrentTemperature, updatedDeviceData.current_temperature); + + // Update battery level and status + var tempBatteryLevel = scaleValue(updatedDeviceData.battery_level, 0, 100, 0, 100); + this.batteryService.updateCharacteristic(HAP.Characteristic.BatteryLevel, tempBatteryLevel); + this.batteryService.updateCharacteristic(HAP.Characteristic.StatusLowBattery, tempBatteryLevel > LOWBATTERYLEVEL ? HAP.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL : HAP.Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW); + + // Log temperature to history only if changed to previous recording + if (this.HomeKitHistory != null && updatedDeviceData.current_temperature != this.deviceData.current_temperature) { + this.HomeKitHistory.addHistory(this.temperatureService, {time: Math.floor(new Date() / 1000), temperature: updatedDeviceData.current_temperature}); } } } // Nest Protect -class SmokeSensorClass extends HomeKitDevice { - constructor(deviceData, eventEmitter) { - super(deviceData.nest_device_structure, deviceData, eventEmitter); +class NestProtect extends HomeKitDevice { + constructor(currentDeviceData, globalEventEmitter) { + super(ACCESSORYNAME, ACCESSORYPINCODE, config.mDNS, currentDeviceData.device_uuid, currentDeviceData, globalEventEmitter); - this.SmokeService = null; // HomeKit service for this smoke sensor - this.COService = null; // HomeKit service for this CO sensor - this.BatteryService = null; // Status of Nest Protect Sensor Battery - this.MotionService = null; // Status of Nest Protect motion sensor + this.smokeService = null; // HomeKit service for this smoke sensor + this.carbonMonoxideService = null; // HomeKit service for this carbon monoxide sensor + this.batteryService = null; // Status of Nest Protect Sensor Battery + this.motionService = null; // Status of Nest Protect motion sensor } // Class functions addHomeKitServices(serviceName) { // Add this smoke sensor & CO sensor to the "master" accessory and set properties - this.SmokeService = this.HomeKitAccessory.addService(Service.SmokeSensor, "Smoke", 1); - this.SmokeService.addCharacteristic(Characteristic.StatusActive); - this.SmokeService.addCharacteristic(Characteristic.StatusFault); + this.smokeService = this.HomeKitAccessory.addService(HAP.Service.SmokeSensor, "Smoke", 1); + this.smokeService.addCharacteristic(HAP.Characteristic.StatusActive); + this.smokeService.addCharacteristic(HAP.Characteristic.StatusFault); - this.COService = this.HomeKitAccessory.addService(Service.CarbonMonoxideSensor, "Carbon Monoxide", 1); - this.COService.addCharacteristic(Characteristic.StatusActive); - this.COService.addCharacteristic(Characteristic.StatusFault); + this.carbonMonoxideService = this.HomeKitAccessory.addService(HAP.Service.CarbonMonoxideSensor, "Carbon Monoxide", 1); + this.carbonMonoxideService.addCharacteristic(HAP.Characteristic.StatusActive); + this.carbonMonoxideService.addCharacteristic(HAP.Characteristic.StatusFault); // Add battery service to display battery level - this.BatteryService = this.HomeKitAccessory.addService(Service.BatteryService, "", 1); - this.BatteryService.updateCharacteristic(Characteristic.ChargingState, Characteristic.ChargingState.NOT_CHARGEABLE); // dont charge as run off battery + this.batteryService = this.HomeKitAccessory.addService(HAP.Service.BatteryService, "", 1); + this.batteryService.updateCharacteristic(HAP.Characteristic.ChargingState, HAP.Characteristic.ChargingState.NOT_CHARGEABLE); // Batteries are non-rechargeable // Add motion sensor if supported (only on wired versions) if (this.deviceData.wired_or_battery == 0) { - this.MotionService = this.HomeKitAccessory.addService(Service.MotionSensor, "Motion", 1); - this.MotionService.addCharacteristic(Characteristic.StatusActive); - this.MotionService.addCharacteristic(Characteristic.StatusFault); + this.motionService = this.HomeKitAccessory.addService(HAP.Service.MotionSensor, "Motion", 1); + this.motionService.addCharacteristic(HAP.Characteristic.StatusActive); + this.motionService.addCharacteristic(HAP.Characteristic.StatusFault); } - this.HomeKitAccessory.setPrimaryService(this.SmokeService); + this.HomeKitAccessory.setPrimaryService(this.smokeService); // Setup linkage to EveHome app if configured todo so - this.deviceData.EveApp && this.historyService && this.historyService.linkToEveHome(this.HomeKitAccessory, this.SmokeService, {GetCommand: this.#EveHomeGetCommand.bind(this), - SetCommand: this.#EveHomeSetCommand.bind(this), - EveSmoke_lastalarmtest: this.deviceData.latest_alarm_test, - EveSmoke_alarmtest: this.deviceData.self_test_in_progress, - EveSmoke_heatstatus: this.deviceData.heat_status, - EveSmoke_hushedstate: this.deviceData.hushed_state, - EveSmoke_statusled: this.deviceData.ntp_green_led, - EveSmoke_smoketestpassed: this.deviceData.smoke_test_passed, - EveSmoke_heattestpassed: this.deviceData.heat_test_passed, - debug: config.debug.includes("HISTORY") - }); - - console.log("Setup Nest Protect '%s' on '%s'", serviceName, this.HomeKitAccessory.username, (this.MotionService != null ? "with motion sensor" : "")); + if (this.deviceData.EveApp == true && this.HomeKitHistory != null) { + this.HomeKitHistory.linkToEveHome(this.HomeKitAccessory, this.smokeService, {GetCommand: this.#EveHomeGetCommand.bind(this), + SetCommand: this.#EveHomeSetCommand.bind(this), + EveSmoke_lastalarmtest: this.deviceData.latest_alarm_test, + EveSmoke_alarmtest: this.deviceData.self_test_in_progress, + EveSmoke_heatstatus: this.deviceData.heat_status, + EveSmoke_hushedstate: this.deviceData.hushed_state, + EveSmoke_statusled: this.deviceData.ntp_green_led, + EveSmoke_smoketestpassed: this.deviceData.smoke_test_passed, + EveSmoke_heattestpassed: this.deviceData.heat_test_passed, + debug: config.debug.includes("HISTORY") + }); + } + + outputLogging(ACCESSORYNAME, false, "Setup Nest Protect '%s'", serviceName, (this.motionService != null ? "with motion sensor" : "")); } - updateHomeKitServices(deviceData) { - if (this.SmokeService != null && this.COService != null && this.BatteryService != null) { - this.HomeKitAccessory.getService(Service.AccessoryInformation).setCharacteristic(Characteristic.FirmwareRevision, deviceData.software_version); - this.SmokeService.updateCharacteristic(Characteristic.StatusActive, (deviceData.online == true && deviceData.removed_from_base == false ? true : false)); // If Nest isn't online or removed from base, report in HomeKit - this.SmokeService.updateCharacteristic(Characteristic.StatusFault, ((deviceData.online == true && deviceData.removed_from_base == false) && (Math.floor(new Date() / 1000) <= deviceData.replacement_date) ? Characteristic.StatusFault.NO_FAULT : Characteristic.StatusFault.GENERAL_FAULT)); // General fault if replacement date past or Nest isn't online or removed from base - this.COService.updateCharacteristic(Characteristic.StatusActive, (deviceData.online == true && deviceData.removed_from_base == false ? true : false)); // If Nest isn't online or removed from base, report in HomeKit - this.COService.updateCharacteristic(Characteristic.StatusFault, ((deviceData.online == true && deviceData.removed_from_base == false) && (Math.floor(new Date() / 1000) <= deviceData.replacement_date) ? Characteristic.StatusFault.NO_FAULT : Characteristic.StatusFault.GENERAL_FAULT)); // General fault if replacement date past or Nest isn't online or removed from base - - if (this.MotionService != null) { - // Motion detect if auto_away = false. Not supported on battery powered Nest Protects - this.MotionService.updateCharacteristic(Characteristic.StatusActive, (deviceData.online == true && deviceData.removed_from_base == false ? true : false)); // If Nest isn't online or removed from base, report in HomeKit - this.MotionService.updateCharacteristic(Characteristic.StatusFault, ((deviceData.online == true && deviceData.removed_from_base == false) && (Math.floor(new Date() / 1000) <= deviceData.replacement_date) ? Characteristic.StatusFault.NO_FAULT : Characteristic.StatusFault.GENERAL_FAULT)); // General fault if replacement date past or Nest isn't online or removed from base - this.MotionService.updateCharacteristic(Characteristic.MotionDetected, deviceData.away == false ? true : false); - - // Log motion to history only if changed to previous recording - if (deviceData.away != this.deviceData.away) { - this.historySevice && this.historyService.addHistory(this.MotionService, {time: Math.floor(new Date() / 1000), status: deviceData.away == false ? 1 : 0}); - } - } + updateHomeKitServices(updatedDeviceData) { + if (typeof updatedDeviceData != "object" || this.smokeService == null || this.carbonMonoxideService == null || this.batteryService == null) { + return; + } - // Update battery details - var tempBatteryLevel = __scale(deviceData.battery_level, 0, 5400, 0, 100); - this.BatteryService.updateCharacteristic(Characteristic.BatteryLevel, tempBatteryLevel); - this.BatteryService.updateCharacteristic(Characteristic.StatusLowBattery, (tempBatteryLevel > LOWBATTERYLEVEL && deviceData.battery_health_state == 0 && ((deviceData.line_power_present == true && deviceData.wired_or_battery == 0) || (deviceData.line_power_present == false && deviceData.wired_or_battery == 1))) ? Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL : Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW); + this.smokeService.updateCharacteristic(HAP.Characteristic.StatusActive, (updatedDeviceData.online == true && updatedDeviceData.removed_from_base == false ? true : false)); // If Nest isn't online or removed from base, report in HomeKit + this.smokeService.updateCharacteristic(HAP.Characteristic.StatusFault, ((updatedDeviceData.online == true && updatedDeviceData.removed_from_base == false) && (Math.floor(new Date() / 1000) <= updatedDeviceData.replacement_date) ? HAP.Characteristic.StatusFault.NO_FAULT : HAP.Characteristic.StatusFault.GENERAL_FAULT)); // General fault if replacement date past or Nest isn't online or removed from base + this.carbonMonoxideService.updateCharacteristic(HAP.Characteristic.StatusActive, (updatedDeviceData.online == true && updatedDeviceData.removed_from_base == false ? true : false)); // If Nest isn't online or removed from base, report in HomeKit + this.carbonMonoxideService.updateCharacteristic(HAP.Characteristic.StatusFault, ((updatedDeviceData.online == true && updatedDeviceData.removed_from_base == false) && (Math.floor(new Date() / 1000) <= updatedDeviceData.replacement_date) ? HAP.Characteristic.StatusFault.NO_FAULT : HAP.Characteristic.StatusFault.GENERAL_FAULT)); // General fault if replacement date past or Nest isn't online or removed from base - // Update smoke and CO detected status 'ok': 0, 'warning': 1, 'emergency': 2 - this.SmokeService.updateCharacteristic(Characteristic.SmokeDetected, deviceData.smoke_status == 0 ? Characteristic.SmokeDetected.SMOKE_NOT_DETECTED : Characteristic.SmokeDetected.SMOKE_DETECTED); - this.COService.updateCharacteristic(Characteristic.CarbonMonoxideDetected, deviceData.co_status == 0 ? Characteristic.CarbonMonoxideDetected.CO_LEVELS_NORMAL : Characteristic.CarbonMonoxideDetected.CO_LEVELS_ABNORMAL); + if (this.motionService != null) { + // Motion detect if auto_away = false. Not supported on battery powered Nest Protects + this.motionService.updateCharacteristic(HAP.Characteristic.StatusActive, (updatedDeviceData.online == true && updatedDeviceData.removed_from_base == false ? true : false)); // If Nest isn't online or removed from base, report in HomeKit + this.motionService.updateCharacteristic(HAP.Characteristic.StatusFault, ((updatedDeviceData.online == true && updatedDeviceData.removed_from_base == false) && (Math.floor(new Date() / 1000) <= updatedDeviceData.replacement_date) ? HAP.Characteristic.StatusFault.NO_FAULT : HAP.Characteristic.StatusFault.GENERAL_FAULT)); // General fault if replacement date past or Nest isn't online or removed from base + this.motionService.updateCharacteristic(HAP.Characteristic.MotionDetected, updatedDeviceData.away == false ? true : false); + + // Log motion to history only if changed to previous recording + if (this.HomeKitHistory != null &&updatedDeviceData.away != this.deviceData.away) { + this.HomeKitHistory.addHistory(this.motionService, {time: Math.floor(new Date() / 1000), status: updatedDeviceData.away == false ? 1 : 0}); + } + } - // Notify Eve App of device status changes??? - this.deviceData.EveApp && this.historySevice && this.historyService.updateEveHome(this.SmokeService, {GetCommand: this.#EveHomeGetCommand.bind(this)}); + // Update battery level and status + var tempBatteryLevel = scaleValue(updatedDeviceData.battery_level, 0, 5400, 0, 100); + this.batteryService.updateCharacteristic(HAP.Characteristic.BatteryLevel, tempBatteryLevel); + this.batteryService.updateCharacteristic(HAP.Characteristic.StatusLowBattery, (tempBatteryLevel > LOWBATTERYLEVEL && updatedDeviceData.battery_health_state == 0 && ((updatedDeviceData.line_power_present == true && updatedDeviceData.wired_or_battery == 0) || (updatedDeviceData.line_power_present == false && updatedDeviceData.wired_or_battery == 1))) ? HAP.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL : HAP.Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW); + + // Update smoke and carbonmonoxide detected status 'ok': 0, 'warning': 1, 'emergency': 2 + this.smokeService.updateCharacteristic(HAP.Characteristic.SmokeDetected, updatedDeviceData.smoke_status == 2 ? HAP.Characteristic.SmokeDetected.SMOKE_DETECTED : HAP.Characteristic.SmokeDetected.SMOKE_NOT_DETECTED); + this.carbonMonoxideService.updateCharacteristic(HAP.Characteristic.CarbonMonoxideDetected, updatedDeviceData.co_status == 2 ? HAP.Characteristic.CarbonMonoxideDetected.CO_LEVELS_ABNORMAL : HAP.Characteristic.CarbonMonoxideDetected.CO_LEVELS_NORMAL); + + // Notify Eve App of device status changes if linked + if (this.deviceData.EveApp == true && this.HomeKitHistory != null) { + // Update our internal data with properties Eve will need to process + this.deviceData.latest_alarm_test = updatedDeviceData.latest_alarm_test; + this.deviceData.self_test_in_progress = updatedDeviceData.self_test_in_progress; + this.deviceData.heat_status = updatedDeviceData.heat_status; + this.deviceData.ntp_green_led = updatedDeviceData.ntp_green_led; + this.deviceData.smoke_test_passed = updatedDeviceData.smoke_test_passed; + this.deviceData.heat_test_passed = updatedDeviceData.heat_test_passed; + this.HomeKitHistory.updateEveHome(this.smokeService, this.#EveHomeGetCommand.bind(this)); } } - #EveHomeGetCommand(data) { - // Pass back extra data for Eve Smoke "get" process command - // Data will already be an object, our only job is to add to it - data.lastalarmtest = this.deviceData.latest_alarm_test; - data.alarmtest = this.deviceData.self_test_in_progress; - data.heatstatus = this.deviceData.heat_status; - data.statusled = this.deviceData.ntp_green_led; - data.smoketestpassed = this.deviceData.smoke_test_passed; - data.heattestpassed = this.deviceData.heat_test_passed; - data.hushedstate = this.deviceData.hushed_state; - return data; + #EveHomeGetCommand(EveHomeGetData) { + // Pass back extra data for Eve Thermo "get" process command + // Data will already be an object, our only job is to add/modify to it + EveHomeGetData.lastalarmtest = this.deviceData.latest_alarm_test; + EveHomeGetData.alarmtest = this.deviceData.self_test_in_progress; + EveHomeGetData.heatstatus = this.deviceData.heat_status; + EveHomeGetData.statusled = this.deviceData.ntp_green_led; + EveHomeGetData.smoketestpassed = this.deviceData.smoke_test_passed; + EveHomeGetData.heattestpassed = this.deviceData.heat_test_passed; + EveHomeGetData.hushedstate = this.deviceData.hushed_state; + return EveHomeGetData; } - #EveHomeSetCommand(processed) { - if (processed.hasOwnProperty("alarmtest")) { - //console.log("Eve Smoke Alarm test", (processed.alarmtest == true ? "start" : "stop")); + #EveHomeSetCommand(EveHomeSetData) { + if (typeof EveHomeSetData != "object") { + return; } - if (processed.hasOwnProperty("statusled")) { - this.deviceData.ntp_green_led = processed.statusled; // Do quick status update as setting nest values does take sometime - this.set({["topaz." + this.deviceData.nest_device_structure.split(".")[1]] : {"ntp_green_led_enable" : processed.statusled} }); + + if (EveHomeSetData.hasOwnProperty("alarmtest")) { + //outputLogging(ACCESSORYNAME, false, "Eve Smoke Alarm test", (EveHomeSetData.alarmtest == true ? "start" : "stop")); + } + if (EveHomeSetData.hasOwnProperty("statusled")) { + this.deviceData.ntp_green_led = EveHomeSetData.statusled; // Do quick status update as setting Nest values does take sometime + this.set({["topaz"] : {"ntp_green_led_enable" : EveHomeSetData.statusled} }); } } } @@ -774,12 +761,12 @@ const CAMERACONNECTING264FILE = "Nest_camera_connecting.h264"; // Camera connec const MP4BOX = "mp4box"; // MP4 box fragement event for HKSV recording const EXPECTEDVIDEORATE = 30; // FPS we should expect doorbells/cameras to output at -class CameraClass extends HomeKitDevice { - constructor(deviceData, eventEmitter) { - super(deviceData.nest_device_structure, deviceData, eventEmitter); +class NestCameraDoorbell extends HomeKitDevice { + constructor(currentDeviceData, globalEventEmitter) { + super(ACCESSORYNAME, ACCESSORYPINCODE, config.mDNS, currentDeviceData.device_uuid, currentDeviceData, globalEventEmitter); this.controller = null; // HomeKit Camera/Doorbell controller service - this.MotionServices = []; // Status of Nest Hello/Cam(s) motion sensor(s) + this.motionServices = []; // Status of Nest Hello/Cam(s) motion sensor(s) this.snapshotEvent = { type: "", time: 0, @@ -794,48 +781,55 @@ class CameraClass extends HomeKitDevice { this.motionTimer = null; // Cooldown timer for motion events this.audioTalkback = false; // Do we support audio talkback this.NexusStreamer = null; // Object for the Nexus Streamer. Created when adding doorbell/camera + this.chimeService = null; // HomeKit "switch" service for enabling/disabling indoor chime // HKSV stuff - this.HKSVRecordingConfig = {}; // HomeKit Secure Video recording configuration + this.HKSVRecordingConfiguration = {}; // HomeKit Secure Video recording configuration this.HKSVRecorder = { record: false, // Tracks updateRecordingActive. default is not recording, but HomeKit will select the current state ffmpeg: null, // ffmpeg process for recording - mp4boxes: [], // array of processed mp4boxes produced during recording video: null, // video input stream audio: null, // audio input stream id: null, // HKSV Recording ID time: 0 // Time to record from in buffer, 0 means from start of buffer }; - // Load "camera offline" jpg into a buffer - this.camera_offline_h264_jpg = null; - if (fs.existsSync(__dirname + "/" + CAMERAOFFLINEJPGFILE)) { - this.camera_offline_h264_jpg = fs.readFileSync(__dirname + "/" + CAMERAOFFLINEJPGFILE); - } - - // Load "camera switched off" jpg into a buffer - this.camera_off_h264_jpg = null; - if (fs.existsSync(__dirname + "/" + CAMERAOFFJPGFILE)) { - this.camera_off_h264_jpg = fs.readFileSync(__dirname + "/" + CAMERAOFFJPGFILE); - } - - this.set({["quartz." + this.deviceData.nest_device_structure.split(".")[1]] : {"watermark.enabled" : false} }); // "Try" to turn off Nest watermark in video stream + this.set({["quartz"] : {"watermark.enabled" : false}}); // "Try" to turn off Nest watermark in video stream } // Class functions addHomeKitServices(serviceName) { + if (this.deviceData.capabilities.includes("detectors.on_camera") == true) { + // We have a capability of motion sensing on camera/doorbell + // Zone id of 0 is the main sensor zone on camera/doorbell + var tempService = this.HomeKitAccessory.addService(HAP.Service.MotionSensor, "Motion", 0); + tempService.updateCharacteristic(HAP.Characteristic.MotionDetected, false); // No motion in creation + this.motionServices.push({"service": tempService, "id": 0}); + + if (this.deviceData.HKSV == false && typeof this.deviceData.activity_zones == "object") { + // Setup any additional Motion service(s) for camera/doorbell activity zones as required if HKSV disabled + this.deviceData.activity_zones.filter(zone => {return zone.id != 0}).forEach((zone) => { + var tempService = this.HomeKitAccessory.addService(HAP.Service.MotionSensor, zone.name, zone.id); + tempService.updateCharacteristic(HAP.Characteristic.MotionDetected, false); // No motion in creation + this.motionServices.push({"service": tempService, "id": zone.id}); + }); + } + } + var options = { cameraStreamCount: 2, // HomeKit requires at least 2 streams, but 1 is also just fine delegate: this, // Our class is the delgate for handling streaming/images streamingOptions: { - supportedCryptoSuites: [SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80], + supportedCryptoSuites: [HAP.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80], video: { resolutions: [ - [1920, 1080, 30], // width, height, framerate + // width, height, framerate + [3840, 2160, 30], // 4K + [1920, 1080, 30], // 1080p [1600, 1200, 30], // Native res of Nest Hello [1280, 960, 30], - [1280, 720, 30], + [1280, 720, 30], // 720p [1024, 768, 30], [640, 480, 30], [640, 360, 30], @@ -844,61 +838,45 @@ class CameraClass extends HomeKitDevice { [320, 240, 30], [320, 240, 15], // Apple Watch requires this configuration (Apple Watch also seems to required OPUS @16K) [320, 180, 30], + [320, 180, 15], ], codec: { - profiles : [H264Profile.MAIN], // Use H264Profile.MAIN only as that appears what the Nest video stream is at?? - levels: [H264Level.LEVEL3_1, H264Level.LEVEL3_2, H264Level.LEVEL4_0], + profiles : [HAP.H264Profile.MAIN], // Use HAP.H264Profile.MAIN only as that appears what the Nest video stream is at?? + levels: [HAP.H264Level.LEVEL3_1, HAP.H264Level.LEVEL3_2, HAP.H264Level.LEVEL4_0], }, }, audio : { - twoWayAudio: (this.deviceData.capabilities.includes("audio.speaker") && this.deviceData.capabilities.includes("audio.microphone")) ? true : false, // If both speaker & microphone capabilities, then we support twoway audio + twoWayAudio: (this.deviceData.capabilities.includes("audio.speaker") == true && this.deviceData.capabilities.includes("audio.microphone") == true) ? true : false, // If both speaker & microphone capabilities, then we support twoway audio codecs: [ { - type: AudioStreamingCodecType.AAC_ELD, - samplerate: AudioStreamingSamplerate.KHZ_16 + type: HAP.AudioStreamingCodecType.AAC_ELD, + samplerate: HAP.AudioStreamingSamplerate.KHZ_16 }, ], }, } }; - if (this.deviceData.capabilities.includes("detectors.on_camera")) { - // We have a capability of motion sensing on camera/doorbell - // Zone id of 0 is the main sensor zone on camera/doorbell - var tempService = this.HomeKitAccessory.addService(Service.MotionSensor, "Motion", 0); - tempService.updateCharacteristic(Characteristic.MotionDetected, false); // No motion in creation - this.MotionServices.push({"service": tempService, "id": 0}) - - if (this.deviceData.HKSV == false) { - // Setup any additional Motion service(s) for camera/doorbell activity zones as required if HKSV disabled - this.deviceData.activity_zones && this.deviceData.activity_zones.forEach(zone => { - if (zone.id != 0) { - var tempService = this.HomeKitAccessory.addService(Service.MotionSensor, zone.name, zone.id); - tempService.updateCharacteristic(Characteristic.MotionDetected, false); // No motion in creation - this.MotionServices.push({"service": tempService, "id": zone.id}) - } - }); - } - } - if (this.deviceData.HKSV == true) { - // Setup HomeKit secure video + // Setup HomeKit secure video options options.recording = { delegate: this, // Our class will also handle stream recording options: { mediaContainerConfiguration: [ { fragmentLength: 4000, - type: MediaContainerType.FRAGMENTED_MP4 + type: HAP.MediaContainerType.FRAGMENTED_MP4 } ], prebufferLength: 4000, // Seems to always be 4000??? video: { resolutions: [ - [1920, 1080, 30], // width, height, framerate + // width, height, framerate + [3840, 2160, 30], // 4K + [1920, 1080, 30], // 1080p [1600, 1200, 30], // Native res of Nest Hello [1280, 960, 30], - [1280, 720, 30], + [1280, 720, 30], // 720p [1024, 768, 30], [640, 480, 30], [640, 360, 30], @@ -907,82 +885,151 @@ class CameraClass extends HomeKitDevice { [320, 240, 30], [320, 240, 15], // Apple Watch requires this configuration (Apple Watch also seems to required OPUS @16K) [320, 180, 30], + [320, 180, 15], ], parameters: { - profiles : [H264Profile.MAIN], // Use H264Profile.MAIN only as that appears what the Nest video stream is at?? - levels: [H264Level.LEVEL3_1, H264Level.LEVEL3_2, H264Level.LEVEL4_0], + profiles : [HAP.H264Profile.MAIN], // Use HAP.H264Profile.MAIN only as that appears what the Nest video stream is at?? + levels: [HAP.H264Level.LEVEL3_1, HAP.H264Level.LEVEL3_2, HAP.H264Level.LEVEL4_0], }, - type: VideoCodecType.H264 + type: HAP.VideoCodecType.H264 }, audio : { codecs: [ { - type: AudioRecordingCodecType.AAC_ELD, - samplerate: AudioRecordingSamplerate.KHZ_16 + type: HAP.AudioRecordingCodecType.AAC_ELD, + samplerate: HAP.AudioRecordingSamplerate.KHZ_16 }, ], } } }; - if (this.MotionServices[0] && this.MotionServices[0].service != null) { + if (typeof this.motionServices[0] == "object") { options.sensors = { - motion: this.MotionServices[0].service //motion service + motion: this.motionServices[0].service //motion service }; } } // Setup HomeKit camera/doorbell controller - this.controller = this.deviceData.device_type == NESTDEVICETYPE.DOORBELL ? new DoorbellController(options) : new CameraController(options); + this.controller = this.deviceData.device_type == NestDeviceType.DOORBELL ? new HAP.DoorbellController(options) : new HAP.CameraController(options); this.HomeKitAccessory.configureController(this.controller); + + // Setup additional HomeKit characteristics we'll need + if (typeof this.controller.doorbellService == "object") { + this.controller.doorbellService.addCharacteristic(HAP.Characteristic.StatusActive); + } + if (typeof this.controller.cameraService == "object") { + this.controller.cameraService.addCharacteristic(HAP.Characteristic.StatusActive); + } - if (this.deviceData.HKSV == true) { - // extra setup for HKSV after created services - this.deviceData.capabilities.includes("irled") && this.controller.recordingManagement.operatingModeService.addOptionalCharacteristic(Characteristic.NightVision); + if (typeof this.controller.doorbellService == "object" && this.deviceData.capabilities.includes("indoor_chime") == true && this.deviceData.hasOwnProperty("indoor_chime_switch") && this.deviceData.indoor_chime_switch == true) { + // Add service to allow automation and enabling/disabling indoor chiming. This needs to be explically enabled via a configuration option for the device + //"Option.indoor_chime_switch" : true + this.chimeService = this.HomeKitAccessory.addService(HAP.Service.Switch, "Indoor Chime", 1); + + // Setup set callback for this swicth service + this.chimeService.getCharacteristic(HAP.Characteristic.On).on("set", (value, callback) => { + if (value != this.deviceData.properties["doorbell.indoor_chime.enabled"]) { + // only change indoor chime status value if different than on-device + outputLogging(ACCESSORYNAME, true, "Indoor chime on '%s' was turned", this.deviceData.mac_address, (value == true ? "on" : "off")); + this.set({["quartz"] : {"doorbell.indoor_chime.enabled" : value}}); + } + callback(); + }); + + this.chimeService.getCharacteristic(HAP.Characteristic.On).on("get", (callback) => { + callback(null, this.deviceData.properties["doorbell.indoor_chime.enabled"] == true ? true : false); + }); + } + + // Create streamer object. used for buffering, streaming and recording + this.NexusStreamer = new NexusStreamer(this.HomeKitAccessory.UUID, nest.cameraAPI.token, nest.tokenType, this.deviceData, config.debug.includes(Debugging.NEXUS)); + + // extra setup for HKSV after services created + if (this.deviceData.HKSV == true && typeof this.controller.recordingManagement.recordingManagementService == "object" && this.deviceData.capabilities.includes("statusled") == true) { + this.controller.recordingManagement.operatingModeService.addOptionalCharacteristic(HAP.Characteristic.CameraOperatingModeIndicator); // Setup set callbacks for characteristics - this.deviceData.capabilities.includes("irled") && this.controller.recordingManagement.operatingModeService.getCharacteristic(Characteristic.NightVision).on("set", (value, callback) => { + this.controller.recordingManagement.operatingModeService.getCharacteristic(HAP.Characteristic.CameraOperatingModeIndicator).on("set", (value, callback) => { + // 0 = auto, 1 = low, 2 = high + // We'll use high mode for led on and low for led off + var setValue = (value == HAP.Characteristic.CameraOperatingModeIndicator.ENABLE ? 2 : 1); + if (setValue != this.deviceData.properties["statusled.brightness"]) { + // only change status led value if different than on-device + outputLogging(ACCESSORYNAME, true, "Recording status LED on '%s' was turned", this.deviceData.mac_address, (value == HAP.Characteristic.CameraOperatingModeIndicator.ENABLE ? "on" : "off")); + this.set({["quartz"] : {"statusled.brightness" : setValue}}); + } + callback(); + }); + + this.controller.recordingManagement.operatingModeService.getCharacteristic(HAP.Characteristic.CameraOperatingModeIndicator).on("get", (callback) => { + callback(null, this.deviceData.properties["statusled.brightness"] != 1 ? HAP.Characteristic.CameraOperatingModeIndicator.ENABLE : HAP.Characteristic.CameraOperatingModeIndicator.DISABLE); + }); + } + if (this.deviceData.HKSV == true && typeof this.controller.recordingManagement.recordingManagementService == "object" && this.deviceData.capabilities.includes("irled") == true) { + this.controller.recordingManagement.operatingModeService.addOptionalCharacteristic(HAP.Characteristic.NightVision); + + this.controller.recordingManagement.operatingModeService.getCharacteristic(HAP.Characteristic.NightVision).on("set", (value, callback) => { var setValue = (value == true ? "auto_on" : "always_off"); - if (setValue.toUpperCase() != this.deviceData.properties["irled.state"].toUpperCase()) { + if (setValue != this.deviceData.properties["irled.state"]) { // only change IRLed status value if different than on-device - this.set({["quartz." + this.deviceData.nest_device_structure.split(".")[1]] : {"irled.state" : setValue} }); + outputLogging(ACCESSORYNAME, true, "Night vision on '%s' was turned", this.deviceData.mac_address, (value == true ? "on" : "off")); + this.set({["quartz"] : {"irled.state" : setValue}}); } callback(); }); - this.deviceData.capabilities.includes("audio.microphone") && this.controller.recordingManagement.recordingManagementService.getCharacteristic(Characteristic.RecordingAudioActive).on("set", (value, callback) => { - var setValue = (value == Characteristic.RecordingAudioActive.ENABLE ? true : false); + this.controller.recordingManagement.operatingModeService.getCharacteristic(HAP.Characteristic.NightVision).on("get", (callback) => { + callback(null, this.deviceData.properties["irled.state"] != "always_off" ? true : false); + }); + } + + if (this.deviceData.HKSV == true && typeof this.controller.recordingManagement.recordingManagementService == "object" && this.deviceData.capabilities.includes("audio.microphone") == true) { + this.controller.recordingManagement.recordingManagementService.getCharacteristic(HAP.Characteristic.RecordingAudioActive).on("set", (value, callback) => { + var setValue = (value == HAP.Characteristic.RecordingAudioActive.ENABLE ? true : false); if (setValue != this.deviceData.properties["audio.enabled"]) { // only change audio recording value if different than on-device - this.set({["quartz." + this.deviceData.nest_device_structure.split(".")[1]] : {"audio.enabled" : setValue} }); + outputLogging(ACCESSORYNAME, true, "Audio recording on '%s' was turned", this.deviceData.mac_address, (value == HAP.Characteristic.RecordingAudioActive.ENABLE ? "on" : "off")); + this.set({["quartz"] : {"audio.enabled" : setValue}}); } callback(); }); - - this.controller.recordingManagement.operatingModeService.getCharacteristic(Characteristic.HomeKitCameraActive).on("set", (value, callback) => { - if (value != this.controller.recordingManagement.operatingModeService.getCharacteristic(Characteristic.HomeKitCameraActive).value) { + + this.controller.recordingManagement.recordingManagementService.getCharacteristic(HAP.Characteristic.RecordingAudioActive).on("get", (callback) => { + callback(null, this.deviceData.properties["audio.enabled"] == true ? HAP.Characteristic.RecordingAudioActive.ENABLE : HAP.Characteristic.RecordingAudioActive.DISABLE); + }); + } + + if (this.deviceData.HKSV == true && typeof this.controller.recordingManagement.recordingManagementService == "object") { + this.controller.recordingManagement.operatingModeService.getCharacteristic(HAP.Characteristic.HomeKitCameraActive).on("set", (value, callback) => { + if (value != this.controller.recordingManagement.operatingModeService.getCharacteristic(HAP.Characteristic.HomeKitCameraActive).value) { // Make sure only updating status if HomeKit value *actually changes* - var setValue = (value == Characteristic.HomeKitCameraActive.ON); + var setValue = (value == HAP.Characteristic.HomeKitCameraActive.ON ? true : false); if (setValue != this.deviceData.streaming_enabled) { // Camera state does not reflect HKSV requested state, so fix - this.set({["quartz." + this.deviceData.nest_device_structure.split(".")[1]] : {"streaming.enabled" : setValue} }); + outputLogging(ACCESSORYNAME, true, "Camera on '%s' was turned", this.deviceData.mac_address, (value == HAP.Characteristic.HomeKitCameraActive.ON ? "on" : "off")); + this.set({["quartz"] : {"streaming.enabled" : setValue}}); } - if (setValue == false) { + if (setValue == false && typeof this.motionServices[0].service == "object") { // Clear any inflight motion - this.MotionServices[0].service.updateCharacteristic(Characteristic.MotionDetected, false); + this.motionServices[0].service.updateCharacteristic(HAP.Characteristic.MotionDetected, false); } } callback(); }); - } - this.NexusStreamer = new NexusStreamer(this.HomeKitAccessory.UUID, nest.cameraAPI.token, nest.tokenType, this.deviceData, config.debug.includes(Debugging.NEXUS)); // Create streamer object. used for buffering, streaming and recording + this.controller.recordingManagement.operatingModeService.getCharacteristic(HAP.Characteristic.HomeKitCameraActive).on("get", (callback) => { + callback(null, this.deviceData.streaming_enabled == true ? HAP.Characteristic.HomeKitCameraActive.ON : HAP.Characteristic.HomeKitCameraActive.OFF); + }); + } // Setup linkage to EveHome app if configured todo so. We'll log motion history on the main motion service - this.deviceData.EveApp && this.MotionServices[0] && this.historyService && this.historyService.linkToEveHome(this.HomeKitAccessory, this.MotionServices[0].service, {debug: config.debug.includes("HISTORY")}); // Link to Eve Home if we have atleast the main montion service + if (this.deviceData.EveApp == true && this.HomeKitHistory != null && typeof this.motionServices[0].service == "object") { + this.HomeKitHistory.linkToEveHome(this.HomeKitAccessory, this.motionServices[0].service, {debug: config.debug.includes("HISTORY")}); // Link to Eve Home if we have atleast the main montion service + } - console.log("Setup %s '%s' on '%s'", this.HomeKitAccessory.displayName, serviceName, this.HomeKitAccessory.username, this.deviceData.HKSV == true ? "with HomeKit Secure Video" : this.MotionServices.length >= 1 ? "with motion sensor(s)" : ""); - console.log("Nest Aware subscription for '%s' is", this.HomeKitAccessory.username, (this.deviceData.nest_aware == true ? "active" : "not active")) + outputLogging(ACCESSORYNAME, false, "Setup %s '%s'", this.HomeKitAccessory.displayName, serviceName, this.deviceData.HKSV == true ? "with HomeKit Secure Video" : this.motionServices.length >= 1 ? "with motion sensor(s)" : ""); } removeHomeKitServices() { @@ -990,272 +1037,270 @@ class CameraClass extends HomeKitDevice { clearTimeout(this.doorbellTimer); clearTimeout(this.motionTimer); this.NexusStreamer && this.NexusStreamer.stopBuffering(); // Stop any buffering + this.HomeKitAccessory.removeController(this.controller); } // Taken and adapted from https://github.com/hjdhjd/homebridge-unifi-protect/blob/eee6a4e379272b659baa6c19986d51f5bf2cbbbc/src/protect-ffmpeg-record.ts - async *handleRecordingStreamRequest(streamId) { - // Should only be recording if motion detected - // Seems sometimes when starting up, HAP-nodeJS or HomeKit triggers this even when motion isn't occuring - if (this.MotionServices[0].service.getCharacteristic(Characteristic.MotionDetected).value == true) { - // Audio if enabled on doorbell/camera && audio recording configured for HKSV - var includeAudio = (this.deviceData.audio_enabled == true && this.controller.recordingManagement.recordingManagementService.getCharacteristic(Characteristic.RecordingAudioActive).value == Characteristic.RecordingAudioActive.ENABLE); - var recordCodec = this.deviceData.H264EncoderRecord; // Codec to use for H264 encoding when recording - - // Build our ffmpeg command string for the video stream - var ffmpeg = "-hide_banner" - // + " -fflags +discardcorrupt" - //+ " -use_wallclock_as_timestamps 1" - + " -f h264 -an -thread_queue_size 1024 -copytb 1 -i pipe:0" // Video data only on stdin - + (includeAudio == true ? " -f aac -vn -thread_queue_size 1024 -i pipe:3" : ""); // Audio data only on extra pipe created in spawn command + async *handleRecordingStreamRequest(HKSVRecordingStreamID) { + if (this.motionServices[0].service.getCharacteristic(HAP.Characteristic.MotionDetected).value == false) { + // Should only be recording if motion detected. Sometimes when starting up, HAP-nodeJS or HomeKit triggers this even when motion isn't occuring + return; + } - // Build our video command for ffmpeg - var ffmpegVideo = " -map 0:v" // stdin, the first input is video data - + " -max_muxing_queue_size 9999" - + " -codec:v " + recordCodec; - - if (recordCodec == VideoCodecs.LIBX264 || recordCodec == VideoCodecs.H264_OMX || recordCodec == VideoCodecs.H264_V4L2M2M) { - // Configure for libx264 (software encoder) or H264_omx (RPI Hardware enccoder) - ffmpegVideo = ffmpegVideo - + " -pix_fmt yuv420p" - + (recordCodec != VideoCodecs.H264_V4L2M2M ? " -profile:v " + ((this.HKSVRecordingConfig.videoCodec.parameters.profile == H264Profile.HIGH) ? "high" : (this.HKSVRecordingConfig.videoCodec.parameters.profile == H264Profile.MAIN) ? "main" : "baseline") : "") - + (recordCodec == VideoCodecs.LIBX264 ? " -level:v " + ((this.HKSVRecordingConfig.videoCodec.parameters.level == H264Level.LEVEL4_0) ? "4.0" : (this.HKSVRecordingConfig.videoCodec.parameters.level == H264Level.LEVEL3_2) ? "3.2" : "3.1") : "") - + (recordCodec == VideoCodecs.LIBX264 ? " -preset veryfast" : "") - + " -b:v " + this.HKSVRecordingConfig.videoCodec.parameters.bitRate + "k" - + " -filter:v fps=" + this.HKSVRecordingConfig.videoCodec.resolution[2]; // convert to framerate HomeKit has requested - } - - ffmpegVideo = ffmpegVideo - + " -force_key_frames expr:gte\(t,n_forced*" + this.HKSVRecordingConfig.videoCodec.parameters.iFrameInterval / 1000 + "\)" - // + " -fflags +genpts+discardcorrupt" - + " -fflags +nobuffer" - //+ " -reset_timestamps 1" - + " -movflags frag_keyframe+empty_moov+default_base_moof" + // Audio if enabled on doorbell/camera && audio recording configured for HKSV + var includeAudio = (this.deviceData.audio_enabled == true && this.controller.recordingManagement.recordingManagementService.getCharacteristic(HAP.Characteristic.RecordingAudioActive).value == HAP.Characteristic.RecordingAudioActive.ENABLE); + var recordCodec = this.deviceData.H264EncoderRecord; // Codec to use for H264 encoding when recording + + // Build our ffmpeg commandline for the video stream + var commandLine = "-hide_banner -nostats" + // + " -fflags +discardcorrupt" + //+ " -use_wallclock_as_timestamps 1" + + " -f h264 -an -thread_queue_size 1024 -copytb 1 -i pipe:0" // Video data only on stdin + + (includeAudio == true ? " -f aac -vn -thread_queue_size 1024 -i pipe:3" : ""); // Audio data only on extra pipe created in spawn command + + commandLine = commandLine + + " -map 0:v" // stdin, the first input is video data + + " -max_muxing_queue_size 9999" + + " -codec:v " + recordCodec; + + if (recordCodec == VideoCodecs.LIBX264 || recordCodec == VideoCodecs.H264_OMX || recordCodec == VideoCodecs.H264_V4L2M2M) { + // Configure for libx264 (software encoder) or H264_omx (RPI Hardware enccoder) + commandLine = commandLine + + " -pix_fmt yuv420p" + + (recordCodec != VideoCodecs.H264_V4L2M2M ? " -profile:v " + ((this.HKSVRecordingConfiguration.videoCodec.parameters.profile == HAP.H264Profile.HIGH) ? "high" : (this.HKSVRecordingConfiguration.videoCodec.parameters.profile == HAP.H264Profile.MAIN) ? "main" : "baseline") : "") + + (recordCodec == VideoCodecs.LIBX264 ? " -level:v " + ((this.HKSVRecordingConfiguration.videoCodec.parameters.level == HAP.H264Level.LEVEL4_0) ? "4.0" : (this.HKSVRecordingConfiguration.videoCodec.parameters.level == HAP.H264Level.LEVEL3_2) ? "3.2" : "3.1") : "") + + (recordCodec == VideoCodecs.LIBX264 ? " -preset veryfast" : "") + + " -b:v " + this.HKSVRecordingConfiguration.videoCodec.parameters.bitRate + "k" + + " -filter:v fps=" + this.HKSVRecordingConfiguration.videoCodec.resolution[2]; // convert to framerate HomeKit has requested + } - // We have seperate video and audio streams that need to be muxed together if audio recording enabled - var ffmpegAudio = ""; // No audio yet command yet - if (includeAudio == true) { - var audioSampleRates = ["8", "16", "24", "32", "44.1", "48"]; + commandLine = commandLine + + " -force_key_frames expr:gte\(t,n_forced*" + this.HKSVRecordingConfiguration.videoCodec.parameters.iFrameInterval / 1000 + "\)" + // + " -fflags +genpts+discardcorrupt" + + " -fflags +nobuffer" + //+ " -reset_timestamps 1" + + " -movflags frag_keyframe+empty_moov+default_base_moof" + + // We have seperate video and audio streams that need to be muxed together if audio recording enabled + if (includeAudio == true) { + var audioSampleRates = ["8", "16", "24", "32", "44.1", "48"]; + + commandLine = commandLine + + " -map 1:a" // pipe:3, the second input is audio data + + " -codec:a " + AudioCodecs.LIBFDK_AAC + + " -profile:a aac_eld" // this.HKSVRecordingConfiguration.audioCodec.type == HAP.AudioRecordingCodecType.AAC_ELD + + " -ar " + audioSampleRates[this.HKSVRecordingConfiguration.audioCodec.samplerate] + "k" + + " -b:a " + this.HKSVRecordingConfiguration.audioCodec.bitrate + "k" + + " -ac " + this.HKSVRecordingConfiguration.audioCodec.audioChannels; + } - ffmpegAudio = " -map 1:a" // pipe:3, the second input is audio data - + " -codec:a " + AudioCodecs.LIBFDK_AAC - + " -profile:a aac_eld" // this.HKSVRecordingConfig.audioCodec.type == AudioRecordingCodecType.AAC_ELD - + " -ar " + audioSampleRates[this.HKSVRecordingConfig.audioCodec.samplerate] + "k" - + " -b:a " + this.HKSVRecordingConfig.audioCodec.bitrate + "k" - + " -ac " + this.HKSVRecordingConfig.audioCodec.audioChannels; + commandLine = commandLine + + " -f mp4" // output is an mp4 + + " -avoid_negative_ts make_zero" + + " pipe:1"; // output to stdout + + this.HKSVRecorder.ffmpeg = spawn(pathToFFMPEG || "ffmpeg", commandLine.split(" "), { env: process.env, stdio: ["pipe", "pipe", "pipe", "pipe"] }); // Extra pipe, #3 for audio data + + this.HKSVRecorder.video = this.HKSVRecorder.ffmpeg.stdin; // Video data on stdio pipe for ffmpeg + this.HKSVRecorder.audio = (includeAudio == true ? this.HKSVRecorder.ffmpeg.stdio[3] : null); // Audio data on extra pipe for ffmpeg or null if audio recording disabled + + // Process FFmpeg output and parse out the fMP4 stream it's generating for HomeKit Secure Video. + var pendingData = Buffer.alloc(0); + var mp4segment = {header: Buffer.alloc(0), size: 0, type: "", data: Buffer.alloc(0)}; + var mp4boxes = []; + this.HKSVRecorder.ffmpeg.stdout.on("data", (data) => { + // If we have anything left from the last buffer we processed, prepend it to this buffer. + if (pendingData.length > 0) { + data = Buffer.concat([pendingData, data]); + pendingData = Buffer.alloc(0); } - - var ffmpegOutput = " -f mp4" // output is an mp4 - + " -avoid_negative_ts make_zero" - + " pipe:1"; // output to stdout - - // Build our completed ffmpeg commandline - var ffmpegCommand = ffmpeg + ffmpegVideo + ffmpegAudio + ffmpegOutput; - - this.HKSVRecorder.mp4boxes = []; - this.HKSVRecorder.ffmpeg = spawn(ffmpegPath || "ffmpeg", ffmpegCommand.split(" "), { env: process.env, stdio: ["pipe", "pipe", "pipe", "pipe"] }); // Extra pipe, #3 for audio data - //config.debug.includes(Debugging.HKSV) && console.debug(getTimestamp() + " [NEST] ffmpeg recording command is %s", ffmpegCommand); - - this.HKSVRecorder.video = this.HKSVRecorder.ffmpeg.stdin; // Video data on stdio pipe for ffmpeg - this.HKSVRecorder.audio = (includeAudio == true ? this.HKSVRecorder.ffmpeg.stdio[3] : null); // Audio data on extra pipe for ffmpeg or null if audio recording disabled - - // Process FFmpeg output and parse out the fMP4 stream it's generating for HomeKit Secure Video. - var pendingData = Buffer.alloc(0); - var mp4segment = {header: Buffer.alloc(0), size: 0, type: "", data: Buffer.alloc(0)}; - this.HKSVRecorder.ffmpeg.stdout.on("data", (data) => { - // If we have anything left from the last buffer we processed, prepend it to this buffer. - if (pendingData.length > 0) { - data = Buffer.concat([pendingData, data]); - pendingData = Buffer.alloc(0); + for(;;) { + if (data.length < 8) { + // We need a minimum size of data for the mp4box header, save what we have for the next buffer for processing. + pendingData = data; + break; } - for(;;) { - if (data.length < 8) { - // We need a minimum size of data for the mp4box header, save what we have for the next buffer for processing. - pendingData = data; - break; - } - - if (mp4segment.header.length == 0) { - // First 8 bytes will be the mp4box header, we need to parse this, 4 bytes are the data size, and next 4 is the box type - mp4segment.header = data.slice(0, 8); // Save the mp4box header - mp4segment.size = data.slice(0, 4).readUInt32BE(0); // Size of mp4box, includers header and data. Can be split over multiple data streams - mp4segment.type = data.slice(4, 8).toString(); // Type of mp4box - } - if (mp4segment.size > data.length) { - // If we don't have enough data in this buffer for the full mp4box, save what we have for the next buffer we see and append it there. - pendingData = data; - break; - } + if (mp4segment.header.length == 0) { + // First 8 bytes will be the mp4box header, we need to parse this, 4 bytes are the data size, and next 4 is the box type + mp4segment.header = data.slice(0, 8); // Save the mp4box header + mp4segment.size = data.slice(0, 4).readUInt32BE(0); // Size of mp4box, includers header and data. Can be split over multiple data streams + mp4segment.type = data.slice(4, 8).toString(); // Type of mp4box + } + if (mp4segment.size > data.length) { + // If we don't have enough data in this buffer for the full mp4box, save what we have for the next buffer we see and append it there. + pendingData = data; + break; + } - mp4segment.data = data.slice(mp4segment.header.length, mp4segment.size); // Get box data from combined buffer + mp4segment.data = data.slice(mp4segment.header.length, mp4segment.size); // Get box data from combined buffer - // Add it to our queue to be pushed out through the generator function. - this.HKSVRecorder.mp4boxes.push({ header: mp4segment.header, type: mp4segment.type, data: mp4segment.data }); - this.events.emit(this.deviceData.nest_device_structure + MP4BOX); + // Add it to our queue to be pushed out through the generator function. + mp4boxes.push({ header: mp4segment.header, type: mp4segment.type, data: mp4segment.data }); + this.eventEmitter.emit(this.deviceData.device_uuid + MP4BOX); - // If there's anything left in the buffer, move us to the new box and let's keep iterating. - data = data.slice(mp4segment.size); - mp4segment = {header: Buffer.alloc(0), size: 0, type: "", data: Buffer.alloc(0)}; + // If there's anything left in the buffer, move us to the new box and let's keep iterating. + data = data.slice(mp4segment.size); + mp4segment = {header: Buffer.alloc(0), size: 0, type: "", data: Buffer.alloc(0)}; - if (data.length === 0) { - // There's no more data in this buffer to parse, so exit loop - break; - } + if (data.length === 0) { + // There's no more data in this buffer to parse, so exit loop + break; } - }); + } + }); - this.HKSVRecorder.ffmpeg.on("exit", (code, signal) => { - this.HKSVRecorder.audio && this.HKSVRecorder.audio.end(); // Tidy up our created extra pipe - if (signal != "SIGKILL") { - config.debug.includes(Debugging.FFMPEG) && console.debug(getTimestamp() + " [FFMPEG] ffmpeg recorder process exited", code, signal); - } - }); + this.HKSVRecorder.ffmpeg.on("exit", (code, signal) => { + this.HKSVRecorder.audio && this.HKSVRecorder.audio.end(); // Tidy up our created extra pipe + if (signal != "SIGKILL") { + config.debug.includes(Debugging.FFMPEG) && outputLogging(ACCESSORYNAME, true, "FFmpeg recorder process exited", code, signal); + } + }); - this.HKSVRecorder.ffmpeg.on("error", (error) => { - config.debug.includes(Debugging.FFMPEG) && console.debug(getTimestamp() + " [FFMPEG] ffmpeg recorder process error", error); - }); + this.HKSVRecorder.ffmpeg.on("error", (error) => { + config.debug.includes(Debugging.FFMPEG) && outputLogging(ACCESSORYNAME, true, "FFmpeg recorder process error", error); + }); - // ffmpeg outputs to stderr - this.HKSVRecorder.ffmpeg.stderr.on("data", (data) => { - if (data.toString().includes("frame=") == false) { - // Monitor ffmpeg output while testing. Use "ffmpeg as a debug option" - config.debug.includes(Debugging.FFMPEG) && console.debug(getTimestamp() + " [FFMPEG]", data.toString()); - } - }); + // ffmpeg outputs to stderr + this.HKSVRecorder.ffmpeg.stderr.on("data", (data) => { + if (data.toString().includes("frame=") == false) { + // Monitor ffmpeg output while testing. Use "ffmpeg as a debug option" + config.debug.includes(Debugging.FFMPEG) && outputLogging(ACCESSORYNAME, true, data.toString()); + } + }); - this.NexusStreamer.startRecordStream("HKSV" + streamId, this.HKSVRecorder.ffmpeg, this.HKSVRecorder.video, this.HKSVRecorder.audio, true, 0); - config.debug.includes(Debugging.HKSV) && console.debug(getTimestamp() + " [HKSV] Recording started on '%s' %s %s", this.deviceData.mac_address, (includeAudio == true ? "with audio" : "without audio"), (recordCodec != VideoCodecs.COPY ? "using H264 encoder " + recordCodec : "")); + this.NexusStreamer.startRecordStream("HKSV" + HKSVRecordingStreamID, this.HKSVRecorder.ffmpeg, this.HKSVRecorder.video, this.HKSVRecorder.audio, true, 0); + config.debug.includes(Debugging.HKSV) && outputLogging(ACCESSORYNAME, true, "Recording started on '%s' %s %s", this.deviceData.mac_address, (includeAudio == true ? "with audio" : "without audio"), (recordCodec != VideoCodecs.COPY ? "using H264 encoder " + recordCodec : "")); - // Loop generating either FTYP/MOOV box pairs or MOOF/MDAT box pairs for HomeKit Secure Video. - // Exit when the recorder process is nolonger running - // HAP-NodeJS can cancel this async generator function when recording completes also - var segment = []; - for(;;) { - if (this.HKSVRecorder.ffmpeg == null) { - // ffmpeg recorder process isn't running, so finish up the loop - break; - } - - if (this.HKSVRecorder.mp4boxes.length == 0) { - // since the ffmpeg recorder process hasn't notified us of any mp4 fragment boxes, wait until there are some - await EventEmitter.once(this.events, this.deviceData.nest_device_structure + MP4BOX); - } + // Loop generating MOOF/MDAT box pairs for HomeKit Secure Video. + // HAP-NodeJS cancels this async generator function when recording completes also + var segment = []; + for(;;) { + if (this.HKSVRecorder.ffmpeg == null) { + // ffmpeg recorder process isn't running, so finish up the loop + break; + } - var mp4box = this.HKSVRecorder.mp4boxes.shift(); - if (typeof mp4box != "object") { - // Not an mp4 fragment box, so try again - continue; - } + if (mp4boxes.length == 0) { + // since the ffmpeg recorder process hasn't notified us of any mp4 fragment boxes, wait until there are some + await EventEmitter.once(this.eventEmitter, this.deviceData.device_uuid + MP4BOX); + } + + var mp4box = mp4boxes.shift(); + if (typeof mp4box != "object") { + // Not an mp4 fragment box, so try again + continue; + } - // Queue up this fragment mp4 box to send back to HomeKit - segment.push(mp4box.header, mp4box.data); + // Queue up this fragment mp4 segment + segment.push(mp4box.header, mp4box.data); - if (mp4box.type === "moov" || mp4box.type === "mdat") { - yield { - data: Buffer.concat(segment), - isLast: (this.MotionServices[0].service.getCharacteristic(Characteristic.MotionDetected).value == false || this.HKSVRecorder.ffmpeg == null) - }; - segment = []; - } + if (mp4box.type === "moov" || mp4box.type === "mdat") { + yield {data: Buffer.concat(segment), isLast: false}; + segment = []; } } } - closeRecordingStream(streamId, reason) { - this.NexusStreamer.stopRecordStream("HKSV" + streamId); // Stop the associated recording stream + closeRecordingStream(HKSVRecordingStreamID, closeReason) { + this.NexusStreamer.stopRecordStream("HKSV" + HKSVRecordingStreamID); // Stop the associated recording stream this.HKSVRecorder.ffmpeg && this.HKSVRecorder.ffmpeg.kill("SIGKILL"); // Kill the ffmpeg recorder process this.HKSVRecorder.ffmpeg = null; // No more ffmpeg process - this.HKSVRecorder.mp4boxes = []; // Clear mp4box array this.HKSVRecorder.video = null; // No more video stream handle this.HKSVRecorder.audio = null; // No more audio stream handle - this.events.emit(this.deviceData.nest_device_structure + MP4BOX); // This will ensure we clean up out of our segment generator - this.events.removeAllListeners(this.deviceData.nest_device_structure + MP4BOX); // Tidy up our event listeners + this.eventEmitter.emit(this.deviceData.device_uuid + MP4BOX); // This will ensure we cleanly exit out from our segment generator + this.eventEmitter.removeAllListeners(this.deviceData.device_uuid + MP4BOX); // Tidy up our event listeners if (config.debug.includes(Debugging.HKSV) == true) { // Log recording finished messages depending on reason - if (reason == HDSProtocolSpecificErrorReason.NORMAL) { - console.debug(getTimestamp() + " [HKSV] Recording completed on '%s'", this.deviceData.mac_address); + if (closeReason == HAP.HDSProtocolSpecificErrorReason.NORMAL) { + outputLogging(ACCESSORYNAME, true, "Recording completed on '%s'", this.deviceData.mac_address); } else { - console.debug(getTimestamp() + " [HKSV] Recording completed with error on '%s'. Reason was '%s'", this.deviceData.mac_address, HDSProtocolSpecificErrorReason[reason]); + outputLogging(ACCESSORYNAME, true, "Recording completed with error on '%s'. Reason was '%s'", this.deviceData.mac_address, HAP.HDSProtocolSpecificErrorReason[closeReason]); } } } - acknowledgeStream(streamId) { - this.closeRecordingStream(streamId, HDSProtocolSpecificErrorReason.NORMAL); - } - - updateRecordingActive(active) { + updateRecordingActive(enableHKSVRecordings) { // We'll use the change here to determine if we start/stop any buffering. // Also track the HomeKit status here as gets called multiple times with no change - // Might be fixed in HAP-NodeJS 11.x or later, but we'll keep out internal check - if (active != this.HKSVRecorder.record) { - if (active == true && this.deviceData.HKSVPreBuffer > 0) { - // Start a buffering stream for this camera/doorbell. Ensures motion captures all video on motion trigger - // Required due to data delays by on prem Nest to cloud to HomeKit accessory to iCloud etc - // Make sure have appropriate bandwidth!!! - config.debug.includes(Debugging.HKSV) && console.debug(getTimestamp() + " [HKSV] Pre-buffering started for '%s'", this.deviceData.mac_address); - this.NexusStreamer.startBuffering(this.deviceData.HKSVPreBuffer); - } - if (active == false) { - this.NexusStreamer.stopBuffering(); - config.debug.includes(Debugging.HKSV) && console.debug(getTimestamp() + " [HKSV] Pre-buffering stopped for '%s'", this.deviceData.mac_address); - } + // Might be fixed in HAP-NodeJS 11.x or later, but we'll keep our internal check + if (enableHKSVRecordings == this.HKSVRecorder.record || typeof this.NexusStreamer != "object") { + return; } - this.HKSVRecorder.record = active; + + if (enableHKSVRecordings == true && this.deviceData.HKSVPreBuffer > 0) { + // Start a buffering stream for this camera/doorbell. Ensures motion captures all video on motion trigger + // Required due to data delays by on prem Nest to cloud to HomeKit accessory to iCloud etc + // Make sure have appropriate bandwidth!!! + config.debug.includes(Debugging.HKSV) && outputLogging(ACCESSORYNAME, true, "Pre-buffering started for '%s'", this.deviceData.mac_address); + this.NexusStreamer.startBuffering(this.deviceData.HKSVPreBuffer); + } + if (enableHKSVRecordings == false) { + this.NexusStreamer.stopBuffering(); + config.debug.includes(Debugging.HKSV) && outputLogging(ACCESSORYNAME, true, "Pre-buffering stopped for '%s'", this.deviceData.mac_address); + } + + this.HKSVRecorder.record = enableHKSVRecordings; } - updateRecordingConfiguration(configuration) { - this.HKSVRecordingConfig = configuration; // Store the recording configuration HKSV has provided + updateRecordingConfiguration(HKSVRecordingConfiguration) { + this.HKSVRecordingConfiguration = HKSVRecordingConfiguration; // Store the recording configuration HKSV has provided } - async handleSnapshotRequest(request, callback) { + async handleSnapshotRequest(snapshotRequestDetails, callback) { + // snapshotRequestDetails.reason == ResourceRequestReason.PERIODIC + // snapshotRequestDetails.reason == ResourceRequestReason.EVENT + // Get current image from doorbell/camera - var image = Buffer.alloc(0); // Empty buffer + var imageBuffer = Buffer.alloc(0); // Empty buffer - if (this.deviceData.HKSV == true && this.NexusStreamer != null) { + if (this.deviceData.HKSV == true && typeof this.NexusStreamer == "object") { // Since HKSV is enabled, try getting a snapshot image from the buffer // If no buffering running, the image buffer will still be empty. We can try the old method if that fails - image = await this.NexusStreamer.getBufferSnapshot(ffmpegPath); - } - if (image.length == 0) { - if (this.deviceData.streaming_enabled == true && this.deviceData.online == true) { - if (this.deviceData.HKSV == false && this.snapshotEvent.type != "" && this.snapshotEvent.done == false) { - // Grab event snapshot from doorbell/camera stream for a non-HKSV camera - await axios.get(this.deviceData.nexus_api_nest_domain_host + "/event_snapshot/" + this.deviceData.camera_uuid + "/" + this.snapshotEvent.id + "?crop_type=timeline&width=" + request.width, {responseType: "arraybuffer", headers: {"user-agent": USERAGENT, "accept" : "*/*", [nest.cameraAPI.key] : nest.cameraAPI.value + nest.cameraAPI.token}, timeout: NESTAPITIMEOUT, retry: 3 /*, retryDelay: 2000 */}) - .then(response => { - if (response.status == 200) { - this.snapshotEvent.done = true; // Successfully got the snapshot for the event - image = response.data; - } - }) - .catch(error => { - }); - } - if (image.length == 0) { - // Still empty image buffer, so try old method for a direct grab - await axios.get(this.deviceData.nexus_api_http_server_url + "/get_image?uuid=" + this.deviceData.camera_uuid + "&cachebuster=" + Math.floor(new Date() / 1000), {responseType: "arraybuffer", headers: {"user-agent": USERAGENT, "accept" : "*/*", [nest.cameraAPI.key] : nest.cameraAPI.value + nest.cameraAPI.token}, timeout: NESTAPITIMEOUT/*, retry: 3, retryDelay: 2000 */}) - .then(response => { - if (response.status == 200) { - image = response.data; - } - }) - .catch(error => { - }); - } - } + imageBuffer = await this.NexusStreamer.getBufferSnapshot(pathToFFMPEG); + } - if (this.deviceData.streaming_enabled == false && this.deviceData.online == true) { - // Return "camera switched off" jpg to image buffer - image = this.camera_off_h264_jpg; + if (this.deviceData.streaming_enabled == true && this.deviceData.online == true && imageBuffer.length == 0) { + if (this.deviceData.HKSV == false && this.snapshotEvent.type != "" && this.snapshotEvent.done == false) { + // Grab event snapshot from doorbell/camera stream for a non-HKSV camera + await axios.get(this.deviceData.nexus_api_nest_domain_host + "/event_snapshot/" + this.deviceData.device_uuid.split(".")[1] + "/" + this.snapshotEvent.id + "?crop_type=timeline&width=" + snapshotRequestDetails.width + "&cachebuster=" + Math.floor(new Date() / 1000), {responseType: "arraybuffer", headers: {"referer": REFERER, "user-agent": USERAGENT, "accept" : "*/*", [nest.cameraAPI.key] : nest.cameraAPI.value + nest.cameraAPI.token}, timeout: 3000}) + .then((response) => { + if (typeof response.status != "number" || response.status != 200) { + throw new Error("Nest Camera API snapshot failed with error"); + } + + this.snapshotEvent.done = true; // Successfully got the snapshot for the event + imageBuffer = response.data; + }) + .catch((error) => { + }); } - - if (this.deviceData.online == false) { - // Return "camera offline" jpg to image buffer - image = this.camera_offline_h264_jpg; + if (imageBuffer.length == 0) { + // Still empty image buffer, so try old method for a direct grab + await axios.get(this.deviceData.nexus_api_nest_domain_host + "/get_image?uuid=" + this.deviceData.device_uuid.split(".")[1] + "&width=" + snapshotRequestDetails.width, {responseType: "arraybuffer", headers: {"referer": REFERER, "user-agent": USERAGENT, "accept" : "*/*", [nest.cameraAPI.key] : nest.cameraAPI.value + nest.cameraAPI.token}, timeout: 3000}) + .then((response) => { + if (typeof response.status != "number" || response.status != 200) { + throw new Error("Nest Camera API snapshot failed with error"); + } + + imageBuffer = response.data; + }) + .catch((error) => { + }); } } - callback(null, image); + if (this.deviceData.streaming_enabled == false && this.deviceData.online == true && fs.existsSync(__dirname + "/" + CAMERAOFFJPGFILE) == true) { + // Return "camera switched off" jpg to image buffer + imageBuffer = fs.readFileSync(__dirname + "/" + CAMERAOFFJPGFILE); + } + + if (this.deviceData.online == false && fs.existsSync(__dirname + "/" + CAMERAOFFLINEJPGFILE) == true) { + // Return "camera offline" jpg to image buffer + imageBuffer = fs.readFileSync(__dirname + "/" + CAMERAOFFLINEJPGFILE); + } + + callback((imageBuffer.length == 0 ? "No Camera/Doorbell snapshot obtained" : null), imageBuffer); } async prepareStream(request, callback) { @@ -1267,7 +1312,7 @@ class CameraClass extends HomeKitDevice { localVideoPort: await this.#getPort(), videoCryptoSuite: request.video.srtpCryptoSuite, videoSRTP: Buffer.concat([request.video.srtp_key, request.video.srtp_salt]), - videoSSRC: CameraController.generateSynchronisationSource(), + videoSSRC: HAP.CameraController.generateSynchronisationSource(), audioPort: request.audio.port, localAudioPort: await this.#getPort(), @@ -1275,7 +1320,7 @@ class CameraClass extends HomeKitDevice { rptSplitterPort: await this.#getPort(), audioCryptoSuite: request.video.srtpCryptoSuite, audioSRTP: Buffer.concat([request.audio.srtp_key, request.audio.srtp_salt]), - audioSSRC: CameraController.generateSynchronisationSource(), + audioSSRC: HAP.CameraController.generateSynchronisationSource(), rtpSplitter: null, ffmpeg: [], // Array of ffmpeg process we create for streaming video/audio and audio talkback @@ -1285,7 +1330,7 @@ class CameraClass extends HomeKitDevice { // Build response back to HomeKit with our details var response = { - address: ip.address("public", request.addressVersion), // ip Address version must match + address: ip.address("public", request.addressVersion), // IP Address version must match video: { port: sessionInfo.localVideoPort, ssrc: sessionInfo.videoSSRC, @@ -1304,340 +1349,340 @@ class CameraClass extends HomeKitDevice { } async handleStreamRequest(request, callback) { - // called when HomeKit asks stream to start/stop/reconfigure - switch (request.type) { - case "start" : { - this.ongoingSessions[request.sessionID] = this.pendingSessions[request.sessionID]; // Move our pending session to ongoing session - delete this.pendingSessions[request.sessionID]; // remove this pending session information - - var includeAudio = (this.deviceData.audio_enabled == true); - - // Build our ffmpeg command string for the video stream - var ffmpeg = "-hide_banner" - + " -use_wallclock_as_timestamps 1" - + " -f h264 -an -thread_queue_size 1024 -copytb 1 -i pipe:0" // Video data only on stdin - + (includeAudio == true ? " -f aac -vn -thread_queue_size 1024 -i pipe:3" : ""); // Audio data only on extra pipe created in spawn command - - // Build our video command for ffmpeg - var ffmpegVideo = " -map 0:v" // stdin, the first input is video data - + " -max_muxing_queue_size 9999" - + " -codec:v copy" - + " -fflags +nobuffer" - + " -payload_type " + request.video.pt - + " -ssrc " + this.ongoingSessions[request.sessionID].videoSSRC - + " -f rtp" - + " -avoid_negative_ts make_zero" - + " -srtp_out_suite " + SRTPCryptoSuites[this.ongoingSessions[request.sessionID].videoCryptoSuite] + " -srtp_out_params " + this.ongoingSessions[request.sessionID].videoSRTP.toString("base64") - + " srtp://" + this.ongoingSessions[request.sessionID].address + ":" + this.ongoingSessions[request.sessionID].videoPort + "?rtcpport=" + this.ongoingSessions[request.sessionID].videoPort + "&localrtcpport=" + this.ongoingSessions[request.sessionID].localVideoPort + "&pkt_size=" + request.video.mtu; - - // We have seperate video and audio streams that need to be muxed together if audio enabled - var ffmpegAudio = ""; // No audio yet command yet - if (includeAudio == true) { - ffmpegAudio = " -map 1:a" // pipe:3, the second input is audio data - + " -codec:a " + AudioCodecs.LIBFDK_AAC - + " -profile:a aac_eld" // request.codec == "ACC-eld" - + " -flags +global_header" - + " -ar " + request.audio.sample_rate + "k" - + " -b:a " + request.audio.max_bit_rate + "k" - + " -ac " + request.audio.channel - + " -payload_type " + request.audio.pt - + " -ssrc " + this.ongoingSessions[request.sessionID].audioSSRC - + " -f rtp" - + " -srtp_out_suite " + SRTPCryptoSuites[this.ongoingSessions[request.sessionID].audioCryptoSuite] + " -srtp_out_params " + this.ongoingSessions[request.sessionID].audioSRTP.toString("base64") - + " srtp://" + this.ongoingSessions[request.sessionID].address + ":" + this.ongoingSessions[request.sessionID].audioPort + "?rtcpport=" + this.ongoingSessions[request.sessionID].audioPort + "&localrtcpport=" + this.ongoingSessions[request.sessionID].localAudioPort + "&pkt_size=188"; - } - - // Build our completed ffmpeg commandline - var ffmpegCommand = ffmpeg + ffmpegVideo + ffmpegAudio; + // called when HomeKit asks to start/stop/reconfigure a camera/doorbell stream + if (request.type == HAP.StreamRequestTypes.START) { + this.ongoingSessions[request.sessionID] = this.pendingSessions[request.sessionID]; // Move our pending session to ongoing session + delete this.pendingSessions[request.sessionID]; // remove this pending session information - // Start our ffmpeg streaming process and stream from nexus - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Live stream started on '%s'", this.deviceData.mac_address); - var ffmpegStreaming = spawn(ffmpegPath || "ffmpeg", ffmpegCommand.split(" "), { env: process.env, stdio: ["pipe", "pipe", "pipe", "pipe"] }); // Extra pipe, #3 for audio data - this.NexusStreamer && this.NexusStreamer.startLiveStream(request.sessionID, ffmpegStreaming.stdin, (includeAudio == true && ffmpegStreaming.stdio[3] ? ffmpegStreaming.stdio[3] : null), false); + var includeAudio = (this.deviceData.audio_enabled == true); - // ffmpeg console output is via stderr - ffmpegStreaming.stderr.on("data", (data) => { - // If ffmpeg is slow to start frames, produces "slow to respond" error from HAP-NodeJS - if (typeof callback == "function") { - callback(); // Notify HomeKit we've started video stream - callback = null; // Signal we've done the callback by clearing it - } - if (data.toString().includes("frame=") == false) { - // Monitor ffmpeg output while testing. Use "ffmpeg as a debug option" - config.debug.includes(Debugging.FFMPEG) && console.debug(getTimestamp() + " [FFMPEG]", data.toString()); - } - }); - - ffmpegStreaming.on("exit", (code, signal) => { - if (signal != "SIGKILL" || signal == null) { - config.debug.includes(Debugging.FFMPEG) && console.debug(getTimestamp() + " [FFMPEG] Audio/Video streaming processes stopped", code, signal); - if (typeof callback == "function") callback(new Error("ffmpeg process creation failed!")); - callback = null; // Signal we've done the callback by clearing it - this.controller.forceStopStreamingSession(request.sessionID); - } - }); + // Build our ffmpeg command string for the video stream + var commandLine = "-hide_banner -nostats" + + " -use_wallclock_as_timestamps 1" + + " -f h264 -an -thread_queue_size 1024 -copytb 1 -i pipe:0" // Video data only on stdin + + (includeAudio == true ? " -f aac -vn -thread_queue_size 1024 -i pipe:3" : ""); // Audio data only on extra pipe created in spawn command + + // Build our video command for ffmpeg + commandLine = commandLine + + " -map 0:v" // stdin, the first input is video data + + " -max_muxing_queue_size 9999" + + " -codec:v copy" + + " -fflags +nobuffer" + + " -payload_type " + request.video.pt + + " -ssrc " + this.ongoingSessions[request.sessionID].videoSSRC + + " -f rtp" + + " -avoid_negative_ts make_zero" + + " -srtp_out_suite " + HAP.SRTPCryptoSuites[this.ongoingSessions[request.sessionID].videoCryptoSuite] + " -srtp_out_params " + this.ongoingSessions[request.sessionID].videoSRTP.toString("base64") + + " srtp://" + this.ongoingSessions[request.sessionID].address + ":" + this.ongoingSessions[request.sessionID].videoPort + "?rtcpport=" + this.ongoingSessions[request.sessionID].videoPort + "&localrtcpport=" + this.ongoingSessions[request.sessionID].localVideoPort + "&pkt_size=" + request.video.mtu; - // We only create the the rtpsplitter and ffmpeg processs if twoway audio is supported AND audio enabled on doorbell/camera - var ffmpegAudioTalkback = null; // No ffmpeg process for return audio yet - if (includeAudio == true && this.audioTalkback == true) { - // Setup RTP splitter for two/away audio - this.ongoingSessions[request.sessionID].rtpSplitter = dgram.createSocket("udp4"); - this.ongoingSessions[request.sessionID].rtpSplitter.on("error", (error) => { - this.ongoingSessions[request.sessionID].rtpSplitter.close(); - }); - this.ongoingSessions[request.sessionID].rtpSplitter.on("message", (message) => { - var payloadType = (message.readUInt8(1) & 0x7f); - if (payloadType == request.audio.pt) { - // Audio payload type from HomeKit should match our payload type for audio - if (message.length > 50) { - // Only send on audio data if we have a longer audio packet. (not sure it makes any difference, as under iOS 15 packets are roughly same length) - this.ongoingSessions[request.sessionID].rtpSplitter.send(message, this.ongoingSessions[request.sessionID].audioTalkbackPort); - } - } else { - this.ongoingSessions[request.sessionID].rtpSplitter.send(message, this.ongoingSessions[request.sessionID].localAudioPort); - // Send RTCP to return audio as a heartbeat - this.ongoingSessions[request.sessionID].rtpSplitter.send(message, this.ongoingSessions[request.sessionID].audioTalkbackPort); - } - }); - this.ongoingSessions[request.sessionID].rtpSplitter.bind(this.ongoingSessions[request.sessionID].rptSplitterPort); - - // Build ffmpeg command - var ffmpegCommand = "-hide_banner" - + " -protocol_whitelist pipe,udp,rtp,file,crypto" - + " -f sdp" - + " -codec:a " + AudioCodecs.LIBFDK_AAC - + " -i pipe:0" - + " -map 0:a" - + " -codec:a " + AudioCodecs.LIBSPEEX - + " -frames_per_packet 4" - + " -vad 1" // testing to filter background noise? - + " -ac 1" - + " -ar " + request.audio.sample_rate + "k" - + " -f data pipe:1"; - - ffmpegAudioTalkback = spawn(ffmpegPath || "ffmpeg", ffmpegCommand.split(" "), { env: process.env }); - ffmpegAudioTalkback.on("error", (error) => { - config.debug.includes(Debugging.FFMPEG) && console.debug(getTimestamp() + " [FFMPEG] Failed to start Nest camera talkback audio process", error.message); - if (typeof callback == "function") callback(new Error("ffmpeg process creation failed!")); - callback = null; // Signal we've done the callback by clearing it - }); + // We have seperate video and audio streams that need to be muxed together if audio enabled + if (includeAudio == true) { + commandLine = commandLine + + " -map 1:a" // pipe:3, the second input is audio data + + " -codec:a " + AudioCodecs.LIBFDK_AAC + + " -profile:a aac_eld" // request.codec == "ACC-eld" + + " -flags +global_header" + + " -ar " + request.audio.sample_rate + "k" + + " -b:a " + request.audio.max_bit_rate + "k" + + " -ac " + request.audio.channel + + " -payload_type " + request.audio.pt + + " -ssrc " + this.ongoingSessions[request.sessionID].audioSSRC + + " -f rtp" + + " -srtp_out_suite " + HAP.SRTPCryptoSuites[this.ongoingSessions[request.sessionID].audioCryptoSuite] + " -srtp_out_params " + this.ongoingSessions[request.sessionID].audioSRTP.toString("base64") + + " srtp://" + this.ongoingSessions[request.sessionID].address + ":" + this.ongoingSessions[request.sessionID].audioPort + "?rtcpport=" + this.ongoingSessions[request.sessionID].audioPort + "&localrtcpport=" + this.ongoingSessions[request.sessionID].localAudioPort + "&pkt_size=188"; + } - ffmpegAudioTalkback.stderr.on("data", (data) => { - if (data.toString().includes("size=") == false) { - // Monitor ffmpeg output while testing. Use "ffmpeg as a debug option" - config.debug.includes(Debugging.FFMPEG) && console.debug(getTimestamp() + " [FFMPEG]", data.toString()); - } - }); + // Start our ffmpeg streaming process and stream from nexus + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Live stream started on '%s'", this.deviceData.mac_address); + var ffmpegStreaming = spawn(pathToFFMPEG || "ffmpeg", commandLine.split(" "), { env: process.env, stdio: ["pipe", "pipe", "pipe", "pipe"] }); // Extra pipe, #3 for audio data + this.NexusStreamer && this.NexusStreamer.startLiveStream(request.sessionID, ffmpegStreaming.stdin, (includeAudio == true && ffmpegStreaming.stdio[3] ? ffmpegStreaming.stdio[3] : null), false); - // Write out SDP configuration - // Tried to align the SDP configuration to what HomeKit has sent us in its audio request details - ffmpegAudioTalkback.stdin.write("v=0\n" - + "o=- 0 0 IN " + (this.ongoingSessions[request.sessionID].ipv6 ? "IP6" : "IP4") + " " + this.ongoingSessions[request.sessionID].address + "\n" - + "s=Nest Audio Talkback\n" - + "c=IN " + (this.ongoingSessions[request.sessionID].ipv6 ? "IP6" : "IP4") + " " + this.ongoingSessions[request.sessionID].address + "\n" - + "t=0 0\n" - + "m=audio " + this.ongoingSessions[request.sessionID].audioTalkbackPort + " RTP/AVP " + request.audio.pt + "\n" - + "b=AS:" + request.audio.max_bit_rate + "\n" - + "a=ptime:" + request.audio.packet_time + "\n" - + "a=rtpmap:" + request.audio.pt + " MPEG4-GENERIC/" + (request.audio.sample_rate * 1000) + "/1\n" - + "a=fmtp:" + request.audio.pt + " profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=F8F0212C00BC00\n" - + "a=crypto:1 " + SRTPCryptoSuites[this.ongoingSessions[request.sessionID].audioCryptoSuite] + " inline:" + this.ongoingSessions[request.sessionID].audioSRTP.toString("base64")); - ffmpegAudioTalkback.stdin.end(); - - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Audio talkback stream started for '%s'", this.deviceData.mac_address); - this.NexusStreamer && this.NexusStreamer.startTalkStream(request.sessionID, ffmpegAudioTalkback.stdout); + // ffmpeg console output is via stderr + ffmpegStreaming.stderr.on("data", (data) => { + if (data.toString().includes("frame=") == false) { + // Monitor ffmpeg output while testing. Use "ffmpeg as a debug option" + config.debug.includes(Debugging.FFMPEG) && outputLogging(ACCESSORYNAME, true, data.toString()); } + }); - // Store our ffmpeg sessions - ffmpegStreaming && this.ongoingSessions[request.sessionID].ffmpeg.push(ffmpegStreaming); // Store ffmpeg process ID - ffmpegAudioTalkback && this.ongoingSessions[request.sessionID].ffmpeg.push(ffmpegAudioTalkback); // Store ffmpeg audio return process ID - this.ongoingSessions[request.sessionID].video = request.video; // Cache the video request details - this.ongoingSessions[request.sessionID].audio = request.audio; // Cache the audio request details - break; - } - - case "stop" : { - if (typeof this.ongoingSessions[request.sessionID] == "object") { - this.NexusStreamer && this.NexusStreamer.stopTalkStream(request.sessionID); - this.NexusStreamer && this.NexusStreamer.stopLiveStream(request.sessionID); - this.ongoingSessions[request.sessionID].rtpSplitter && this.ongoingSessions[request.sessionID].rtpSplitter.close(); - this.ongoingSessions[request.sessionID].ffmpeg && this.ongoingSessions[request.sessionID].ffmpeg.forEach(ffmpeg => { - ffmpeg && ffmpeg.kill("SIGKILL"); // Kill this ffmpeg process - }); + ffmpegStreaming.on("exit", (code, signal) => { + if (signal != "SIGKILL" || signal == null) { + config.debug.includes(Debugging.FFMPEG) && outputLogging(ACCESSORYNAME, true, "FFmpeg Audio/Video streaming processes stopped", code, signal); this.controller.forceStopStreamingSession(request.sessionID); - delete this.ongoingSessions[request.sessionID]; // this session has finished - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Live stream stopped on '%s'", this.deviceData.mac_address); } - callback(); - break; - } + }); - case "reconfigure" : { - // todo - implement??? - //config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Reconfiguration request for live stream on '%s'", this.deviceData.mac_address); - callback(); - break; - } - } - } + // We only create the the rtpsplitter and ffmpeg processs if twoway audio is supported AND audio enabled on doorbell/camera + var ffmpegAudioTalkback = null; // No ffmpeg process for return audio yet + if (includeAudio == true && this.audioTalkback == true) { + // Setup RTP splitter for two/away audio + this.ongoingSessions[request.sessionID].rtpSplitter = dgram.createSocket("udp4"); + this.ongoingSessions[request.sessionID].rtpSplitter.bind(this.ongoingSessions[request.sessionID].rptSplitterPort); + + this.ongoingSessions[request.sessionID].rtpSplitter.on("error", (error) => { + this.ongoingSessions[request.sessionID].rtpSplitter.close(); + }); + + this.ongoingSessions[request.sessionID].rtpSplitter.on("message", (message) => { + var payloadType = (message.readUInt8(1) & 0x7f); + if (payloadType == request.audio.pt) { + // Audio payload type from HomeKit should match our payload type for audio + if (message.length > 50) { + // Only send on audio data if we have a longer audio packet. (not sure it makes any difference, as under iOS 15 packets are roughly same length) + this.ongoingSessions[request.sessionID].rtpSplitter.send(message, this.ongoingSessions[request.sessionID].audioTalkbackPort); + } + } else { + this.ongoingSessions[request.sessionID].rtpSplitter.send(message, this.ongoingSessions[request.sessionID].localAudioPort); + // Send RTCP to return audio as a heartbeat + this.ongoingSessions[request.sessionID].rtpSplitter.send(message, this.ongoingSessions[request.sessionID].audioTalkbackPort); + } + }); - updateHomeKitServices(deviceData) { - this.HomeKitAccessory.getService(Service.AccessoryInformation).updateCharacteristic(Characteristic.FirmwareRevision, deviceData.software_version); // Update firmware version - this.controller.setSpeakerMuted(deviceData.audio_enabled == false ? true : false); // if audio is disabled, we'll mute speaker + // Build ffmpeg command + var commandLine = "-hide_banner -nostats" + + " -protocol_whitelist pipe,udp,rtp,file,crypto" + + " -f sdp" + + " -codec:a " + AudioCodecs.LIBFDK_AAC + + " -i pipe:0" + + " -map 0:a" + + " -codec:a " + AudioCodecs.LIBSPEEX + + " -frames_per_packet 4" + + " -vad 1" // testing to filter background noise? + + " -ac 1" + + " -ar " + request.audio.sample_rate + "k" + + " -f data pipe:1"; + + ffmpegAudioTalkback = spawn(pathToFFMPEG || "ffmpeg", commandLine.split(" "), { env: process.env }); + ffmpegAudioTalkback.on("error", (error) => { + config.debug.includes(Debugging.FFMPEG) && outputLogging(ACCESSORYNAME, true, "FFmpeg failed to start Nest camera talkback audio process", error.message); + }); - if (deviceData.HKSV == true) { - // Update camera off/on status for HKSV from Nest - this.controller.recordingManagement.operatingModeService.updateCharacteristic(Characteristic.ManuallyDisabled, (deviceData.streaming_enabled == true ? Characteristic.ManuallyDisabled.ENABLED : Characteristic.ManuallyDisabled.DISABLED)); + ffmpegAudioTalkback.stderr.on("data", (data) => { + if (data.toString().includes("size=") == false) { + // Monitor ffmpeg output while testing. Use "ffmpeg as a debug option" + config.debug.includes(Debugging.FFMPEG) && outputLogging(ACCESSORYNAME, true, data.toString()); + } + }); - // TODO: If bugs fixed in HAPNodeJS and/or HomeKit for HKSV the below will work correcly - if (deviceData.capabilities.includes("status") == true) { - //this.controller.recordingManagement.operatingModeService.updateCharacteristic(Characteristic.CameraOperatingModeIndicator, Characteristic.CameraOperatingModeIndicator.ENABLE); // Always enabled for Nest? - } - if (deviceData.capabilities.includes("irled") == true) { - // Set nightvision status in HomeKit - this.controller.recordingManagement.operatingModeService.updateCharacteristic(Characteristic.NightVision, (deviceData.properties["irled.state"] && deviceData.properties["irled.state"].toUpperCase() == "ALWAYS_OFF" ? false : true)); + // Write out SDP configuration + // Tried to align the SDP configuration to what HomeKit has sent us in its audio request details + ffmpegAudioTalkback.stdin.write("v=0\n" + + "o=- 0 0 IN " + (this.ongoingSessions[request.sessionID].ipv6 ? "IP6" : "IP4") + " " + this.ongoingSessions[request.sessionID].address + "\n" + + "s=Nest Audio Talkback\n" + + "c=IN " + (this.ongoingSessions[request.sessionID].ipv6 ? "IP6" : "IP4") + " " + this.ongoingSessions[request.sessionID].address + "\n" + + "t=0 0\n" + + "m=audio " + this.ongoingSessions[request.sessionID].audioTalkbackPort + " RTP/AVP " + request.audio.pt + "\n" + + "b=AS:" + request.audio.max_bit_rate + "\n" + + "a=ptime:" + request.audio.packet_time + "\n" + + "a=rtpmap:" + request.audio.pt + " MPEG4-GENERIC/" + (request.audio.sample_rate * 1000) + "/1\n" + + "a=fmtp:" + request.audio.pt + " profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=F8F0212C00BC00\n" + + "a=crypto:1 " + HAP.SRTPCryptoSuites[this.ongoingSessions[request.sessionID].audioCryptoSuite] + " inline:" + this.ongoingSessions[request.sessionID].audioSRTP.toString("base64")); + ffmpegAudioTalkback.stdin.end(); + + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Audio talkback stream started for '%s'", this.deviceData.mac_address); + this.NexusStreamer && this.NexusStreamer.startTalkStream(request.sessionID, ffmpegAudioTalkback.stdout); } + + // Store our ffmpeg sessions + ffmpegStreaming && this.ongoingSessions[request.sessionID].ffmpeg.push(ffmpegStreaming); // Store ffmpeg process ID + ffmpegAudioTalkback && this.ongoingSessions[request.sessionID].ffmpeg.push(ffmpegAudioTalkback); // Store ffmpeg audio return process ID + this.ongoingSessions[request.sessionID].video = request.video; // Cache the video request details + this.ongoingSessions[request.sessionID].audio = request.audio; // Cache the audio request details } - // Update any camera details if we have a Nexus streamer object created - this.NexusStreamer && this.NexusStreamer.update(nest.cameraAPI.token, nest.tokenType, deviceData); + if (request.type == HAP.StreamRequestTypes.STOP && typeof this.ongoingSessions[request.sessionID] == "object") { + this.NexusStreamer && this.NexusStreamer.stopTalkStream(request.sessionID); + this.NexusStreamer && this.NexusStreamer.stopLiveStream(request.sessionID); + this.ongoingSessions[request.sessionID].rtpSplitter && this.ongoingSessions[request.sessionID].rtpSplitter.close(); + this.ongoingSessions[request.sessionID].ffmpeg && this.ongoingSessions[request.sessionID].ffmpeg.forEach((ffmpeg) => { + ffmpeg && ffmpeg.kill("SIGKILL"); // Kill this ffmpeg process + }); + this.controller.forceStopStreamingSession(request.sessionID); + delete this.ongoingSessions[request.sessionID]; // this session has finished + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Live stream stopped on '%s'", this.deviceData.mac_address); + } - // If both speaker & microphone capabilities, then we support twoway audio - this.audioTalkback = (deviceData.capabilities.includes("audio.speaker") && deviceData.capabilities.includes("audio.microphone")) ? true : false; + if (request.type == HAP.StreamRequestTypes.RECONFIGURE && typeof this.ongoingSessions[request.sessionID] == "object") { + // todo - implement??? + //config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Reconfiguration request for live stream on '%s'", this.deviceData.mac_address); + } + + if (typeof callback === "function") callback(); // do callback if defined + } - if (deviceData.nest_aware != this.deviceData.nest_aware) { - // Nest aware subscription status has changed - console.log("Nest Aware subscription for '%s' is", this.HomeKitAccessory.username, (deviceData.nest_aware == true ? "active" : "not active")) + updateHomeKitServices(updatedDeviceData) { + if (typeof updatedDeviceData != "object" || typeof this.controller != "object" || typeof this.NexusStreamer != "object" || this.NexusStreamer == null) { + return; } - // For non-HKSV enabled devices, we process activity zone changes - if (deviceData.HKSV == false && (JSON.stringify(deviceData.activity_zones) != this.deviceData.activity_zones)) { + // For non-HKSV enabled devices, we will process any activity zone changes to add or remove any motion services + if (updatedDeviceData.HKSV == false && (JSON.stringify(updatedDeviceData.activity_zones) !== JSON.stringify(this.deviceData.activity_zones))) { // Check to see if any activity zones were added - deviceData.activity_zones.forEach(zone => { - if (zone.id != 0) { - var index = this.MotionServices.findIndex( ({ id }) => id == zone.id); - if (index == -1) { - // Zone doesn't have an associated motion sensor, so add one - var tempService = this.HomeKitAccessory.addService(Service.MotionSensor, zone.name, zone.id); - this.MotionServices.push({"service": tempService, "id": zone.id}) - } else { - // found an associated motion sensor for this zone, so update name - this.MotionServices[index].service.updateCharacteristic(Characteristic.Name, zone.name); - } + updatedDeviceData.activity_zones.filter(zone => {return zone.id != 0}).forEach((zone) => { + var index = this.motionServices.findIndex( ({ id }) => id == zone.id); + if (index == -1) { + // Zone doesn't have an associated motion sensor, so add one + var tempService = this.HomeKitAccessory.addService(HAP.Service.MotionSensor, zone.name, zone.id); + this.motionServices.push({"service": tempService, "id": zone.id}) + } else { + // found an associated motion sensor for this zone, so update name + this.motionServices[index].service.updateCharacteristic(HAP.Characteristic.Name, zone.name); } }); // Check to see if any activity zones were removed - this.MotionServices.forEach((motionService, index) => { - if (motionService.id != 0) { - if (deviceData.activity_zones.findIndex( ({ id }) => id == motionService.id) == -1) { - // Motion service we created doesn't appear in zone list anymore, so assume deleted - this.HomeKitAccessory.removeService(motionService.service); - this.MotionServices.splice(index, 1); - } + this.motionServices.filter(service => {return service.id != 0}).forEach((service, index) => { + if (updatedDeviceData.activity_zones.findIndex( ({ id }) => id == service.id) == -1) { + // Motion service we created doesn't appear in zone list anymore, so assume deleted + this.HomeKitAccessory.removeService(service.service); + this.motionServices.splice(index, 1); } }); } + if (updatedDeviceData.HKSV == true && typeof this.controller.recordingManagement.operatingModeService == "object") { + // Update camera off/on status for HKSV + this.controller.recordingManagement.operatingModeService.updateCharacteristic(HAP.Characteristic.ManuallyDisabled, (updatedDeviceData.streaming_enabled == true ? HAP.Characteristic.ManuallyDisabled.ENABLED : HAP.Characteristic.ManuallyDisabled.DISABLED)); + + if (updatedDeviceData.capabilities.includes("statusled") == true && typeof updatedDeviceData.properties["statusled.brightness"] == "number") { + // Set camera recording indicator. This cannot be turned off on Nest Cameras/Doorbells + // 0 = auto + // 1 = low + // 2 = high + this.controller.recordingManagement.operatingModeService.updateCharacteristic(HAP.Characteristic.CameraOperatingModeIndicator, (updatedDeviceData.properties["statusled.brightness"] != 1 ? HAP.Characteristic.CameraOperatingModeIndicator.ENABLE : HAP.Characteristic.CameraOperatingModeIndicator.DISABLE)); + } + if (updatedDeviceData.capabilities.includes("irled") == true && typeof updatedDeviceData.properties["irled.state"] == "string") { + // Set nightvision status in HomeKit + this.controller.recordingManagement.operatingModeService.updateCharacteristic(HAP.Characteristic.NightVision, (updatedDeviceData.properties["irled.state"] != "always_off" ? true : false)); + } + } + + if (updatedDeviceData.HKSV == true && typeof this.controller.recordingManagement.recordingManagementService == "object") { + if (typeof updatedDeviceData.properties["audio.enabled"] == "boolean") { + // Update recording audio status + this.controller.recordingManagement.recordingManagementService.updateCharacteristic(HAP.Characteristic.RecordingAudioActive, updatedDeviceData.properties["audio.enabled"] == true ? HAP.Characteristic.RecordingAudioActive.ENABLE : HAP.Characteristic.RecordingAudioActive.DISABLE); + } + } + + // Update online status of Doorbell/Camera in HomeKit + if (typeof this.controller.doorbellService == "object") { + this.controller.doorbellService.updateCharacteristic(HAP.Characteristic.StatusActive, updatedDeviceData.online); + } + if (typeof this.controller.cameraService == "object") { + this.controller.cameraService.updateCharacteristic(HAP.Characteristic.StatusActive, updatedDeviceData.online); + } + + // If we have a service enabled to allow switching on/off indoor chime, update + if (this.chimeService != null && updatedDeviceData.properties.hasOwnProperty("doorbell.indoor_chime.enabled") == true) { + this.chimeService.updateCharacteristic(HAP.Characteristic.On, updatedDeviceData.properties["doorbell.indoor_chime.enabled"]); + } + + this.audioTalkback = (updatedDeviceData.capabilities.includes("audio.speaker") == true && updatedDeviceData.capabilities.includes("audio.microphone") == true) ? true : false; // If both speaker & microphone capabilities, then we support twoway audio + this.controller.setSpeakerMuted(updatedDeviceData.audio_enabled == false ? true : false); // if audio is disabled, we'll mute speaker + this.NexusStreamer.update(nest.cameraAPI.token, nest.tokenType, updatedDeviceData); // Notify the Nexus object of any camera detail updates that it might need to know about + // Process alerts, most recent first // For HKSV, we're interested in doorbell and motion events // For non-HKSV, we're interested in doorbell, face and person events (maybe sound and package later) - deviceData.alerts.reverse().forEach(async event => { + updatedDeviceData.alerts.reverse().forEach((event) => { // Handle doorbell event, should always be handled first - // We'll always process a doorbell press event regardless of Characteristic.HomeKitCameraActive state in HKSV - if (typeof this.controller.doorbellService == "object" && event.types.includes("doorbell") == true) { - if (this.doorbellTimer == null) { - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Doorbell pressed on '%s'", this.deviceData.mac_address); - - // Cooldown for doorbell button being pressed (filters out constant pressing for time period) - // Start this before we process further - this.doorbellTimer = setTimeout(() => { - this.snapshotEvent = {type: "", time: 0, id: 0, done: false}; // Clear snapshot event image after timeout - this.doorbellTimer = null; // No doorbell timer active - }, deviceData.doorbellCooldown); + // We'll always process a doorbell press event regardless of HAP.Characteristic.HomeKitCameraActive state in HKSV + if (typeof this.controller.doorbellService == "object" && event.types.includes("doorbell") == true && this.doorbellTimer == null) { + // Cooldown for doorbell button being pressed (filters out constant pressing for time period) + // Start this before we process further + this.doorbellTimer = setTimeout(() => { + this.snapshotEvent = {type: "", time: 0, id: 0, done: false}; // Clear snapshot event image after timeout + this.doorbellTimer = null; // No doorbell timer active + }, this.deviceData.DoorbellCooldown); - if (event.types.includes("motion") == false) { - // No motion event with the doorbell alert, so add one to support any HKSV recording - event.types.push("motion"); - } - - this.snapshotEvent = {type: "ring", time: event.playback_time, id : event.id, done: false}; // needed for a HKSV enabled doorbell??? - this.controller.ringDoorbell(); // Finally "ring" doorbell - this.historySevice && this.historyService.addHistory(this.controller.doorbellService, {time: Math.floor(new Date() / 1000), status: 1}); // Doorbell pressed history - this.historySevice && this.historyService.addHistory(this.controller.doorbellService, {time: Math.floor(new Date() / 1000), status: 0}); // Doorbell un-pressed history + if (event.types.includes("motion") == false) { + // No motion event with the doorbell alert, add one to trigger HKSV recording + event.types.push("motion"); } - } - if (this.MotionServices.length >= 1) { - // We have at least one motion sensor service, so allows us to proceed here + this.snapshotEvent = {type: "ring", time: event.playback_time, id : event.id, done: false}; // needed for a HKSV enabled doorbell??? - // Handle motion event only for HKSV enabled camera. We will use this to trigger the starting of the HKSV recording - // Motion is only activated if configured via Characteristic.HomeKitCameraActive == 1 (on) - if (deviceData.HKSV == true && event.types.includes("motion") == true) { - - this.HKSVRecorder.time = event.playback_time; // Timestamp for playback from Nest for the detected motion + if (updatedDeviceData.properties.hasOwnProperty("doorbell.indoor_chime.enabled") == true && updatedDeviceData.properties["doorbell.indoor_chime.enabled"] == false) { + // Indoor chime is disabled, so we won't "ring" the doorbell + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Doorbell pressed on '%s' but indoor chime is silenced", this.deviceData.mac_address); + } + if (updatedDeviceData.properties.hasOwnProperty("doorbell.indoor_chime.enabled") == true && updatedDeviceData.properties["doorbell.indoor_chime.enabled"] == true) { + // Indoor chime is enabled, so "ring" the doorbel + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Doorbell pressed on '%s'", this.deviceData.mac_address); + this.controller.ringDoorbell(); + } - if (this.controller.recordingManagement.operatingModeService.getCharacteristic(Characteristic.HomeKitCameraActive).value == Characteristic.HomeKitCameraActive.ON) { - if (this.MotionServices[0].service.getCharacteristic(Characteristic.MotionDetected).value != true) { - // Make sure if motion detected, the motion sensor is still active - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Motion started on '%s'", this.deviceData.mac_address); - this.MotionServices[0].service.updateCharacteristic(Characteristic.MotionDetected, true); // Trigger motion - this.historySevice && this.historyService.addHistory(this.MotionServices[0].service, {time: Math.floor(new Date() / 1000), status: 1}); // Motion started for history - } + if (this.HomeKitHistory != null) { + this.HomeKitHistory.addHistory(this.controller.doorbellService, {time: Math.floor(new Date() / 1000), status: 1}); // Doorbell pressed history + this.HomeKitHistory.addHistory(this.controller.doorbellService, {time: Math.floor(new Date() / 1000), status: 0}); // Doorbell un-pressed history + } + } - clearTimeout(this.motionTimer); // Clear any motion active timer so we can extend - this.motionTimer = setTimeout(() => { - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Motion ended on '%s'", this.deviceData.mac_address); - this.MotionServices[0].service.updateCharacteristic(Characteristic.MotionDetected, false); // clear motion - this.historySevice && this.historyService.addHistory(this.MotionServices[0].service, {time: Math.floor(new Date() / 1000), status: 0}); // Motion ended for history - this.motionTimer = null; // No motion timer active - }, deviceData.motionCooldown); + // Handle motion event only for HKSV enabled camera. We will use this to trigger the starting of the HKSV recording + // Motion is only activated if configured via HAP.Characteristic.HomeKitCameraActive == 1 (on) + if (updatedDeviceData.HKSV == true && event.types.includes("motion") == true) { + this.HKSVRecorder.time = event.playback_time; // Timestamp for playback from Nest for the detected motion + + if (this.controller.recordingManagement.operatingModeService.getCharacteristic(HAP.Characteristic.HomeKitCameraActive).value == HAP.Characteristic.HomeKitCameraActive.ON) { + if (this.motionServices[0].service.getCharacteristic(HAP.Characteristic.MotionDetected).value != true) { + // Make sure if motion detected, the motion sensor is still active + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Motion started on '%s'", this.deviceData.mac_address); + this.motionServices[0].service.updateCharacteristic(HAP.Characteristic.MotionDetected, true); // Trigger motion + this.HomeKitHistory && this.HomeKitHistory.addHistory(this.motionServices[0].service, {time: Math.floor(new Date() / 1000), status: 1}); // Motion started for history } + + clearTimeout(this.motionTimer); // Clear any motion active timer so we can extend + this.motionTimer = setTimeout(() => { + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Motion ended on '%s'", this.deviceData.mac_address); + this.motionServices[0].service.updateCharacteristic(HAP.Characteristic.MotionDetected, false); // clear motion + this.HomeKitHistory && this.HomeKitHistory.addHistory(this.motionServices[0].service, {time: Math.floor(new Date() / 1000), status: 0}); // Motion ended for history + this.motionTimer = null; // No motion timer active + }, this.deviceData.MotionCooldown); } + } - // Handle person/face event for non HKSV enabled cameras and only those marked as important - // We also treat a "face" event the same as a person event ie: if have a face, you have a person - if (deviceData.HKSV == false && (event.types.includes("person") == true || event.types.includes("face") == true)) { - if (event.is_important == true && this.doorbellTimer == null && this.personTimer == null) { - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Person detected on '%s'", this.deviceData.mac_address); - - // Cooldown for person being detected - // Start this before we process further - this.personTimer = setTimeout(() => { - this.snapshotEvent = {type: "", time: 0, id: 0, done: false}; // Clear snapshot event image after timeout - this.historySevice && this.historyService.addHistory(this.MotionServices[0].service, {time: Math.floor(new Date() / 1000), status: 0}); // Motion ended for history - this.MotionServices.forEach((motionService, index) => { - motionService.service.updateCharacteristic(Characteristic.MotionDetected, false); // clear any motion - }); - this.personTimer = null; // No person timer active - }, deviceData.personCooldown); - - // Check which zone triggered the person alert and update associated motion sensor(s) - this.historySevice && this.historyService.addHistory(this.MotionServices[0].service, {time: Math.floor(new Date() / 1000), status: 1}); // Motion started for history - this.snapshotEvent = {type: "person", time: event.playback_time, id : event.id, done: false}; - event.zone_ids.forEach(zoneID => { - var index = this.MotionServices.findIndex( ({ id }) => id == zoneID); - if (index != -1) { - this.MotionServices[index].service.updateCharacteristic(Characteristic.MotionDetected, true); // Trigger motion for matching zone - } + // Handle person/face event for non HKSV enabled cameras and only those marked as important + // We also treat a "face" event the same as a person event ie: if you have a face, you have a person + if (updatedDeviceData.HKSV == false && (event.types.includes("person") == true || event.types.includes("face") == true)) { + if (event.is_important == true && this.doorbellTimer == null && this.personTimer == null) { + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Person detected on '%s'", this.deviceData.mac_address); + + // Cooldown for person being detected + // Start this before we process further + this.personTimer = setTimeout(() => { + this.snapshotEvent = {type: "", time: 0, id: 0, done: false}; // Clear snapshot event image after timeout + this.HomeKitHistory && this.HomeKitHistory.addHistory(this.motionServices[0].service, {time: Math.floor(new Date() / 1000), status: 0}); // Motion ended for history + this.motionServices.forEach((motionService, index) => { + motionService.service.updateCharacteristic(HAP.Characteristic.MotionDetected, false); // clear any motion }); - } + this.personTimer = null; // No person timer active + }, this.deviceData.PersonCooldown); + + // Check which zone triggered the person alert and update associated motion sensor(s) + this.HomeKitHistory && this.HomeKitHistory.addHistory(this.motionServices[0].service, {time: Math.floor(new Date() / 1000), status: 1}); // Motion started for history + this.snapshotEvent = {type: "person", time: event.playback_time, id : event.id, done: false}; + event.zone_ids.forEach((zoneID) => { + var index = this.motionServices.findIndex( ({ id }) => id == zoneID); + if (index != -1) { + this.motionServices[index].service.updateCharacteristic(HAP.Characteristic.MotionDetected, true); // Trigger motion for matching zone + } + }); } + } - // Handle motion event for non HKSV enabled cameras - // TODO - if (deviceData.HKSV == false && event.types.includes("motion") == true) { - } + // Handle motion event for non HKSV enabled cameras + if (updatedDeviceData.HKSV == false && event.types.includes("motion") == true) { + // <---- To implement } // Handle package event for non HKSV enabled cameras - // TODO - if (deviceData.HKSV == false && event.types.includes("package") == true) { + if (updatedDeviceData.HKSV == false && event.types.includes("package") == true) { + // <---- To implement } // Handle sound event for non HKSV enabled cameras - // TODO - if (deviceData.HKSV == false && event.types.includes("sound") == true) { + if (updatedDeviceData.HKSV == false && event.types.includes("sound") == true) { + // <---- To implement } }); } @@ -1658,66 +1703,70 @@ class CameraClass extends HomeKitDevice { } -// Create weather object -class WeatherClass extends HomeKitDevice { - constructor(deviceData, eventEmitter) { - super(deviceData.nest_device_structure, deviceData, eventEmitter) +// Nest "virtual" weather +class NestWeather extends HomeKitDevice { + constructor(currentDeviceData, globalEventEmitter) { + super(ACCESSORYNAME, ACCESSORYPINCODE, config.mDNS, currentDeviceData.device_uuid, currentDeviceData, globalEventEmitter); - this.BatteryService = null; + this.batteryService = null; this.airPressureService = null; - this.TemperatureService = null; - this.HumidityService = null; + this.temperatureService = null; + this.humidityService = null; } // Class functions addHomeKitServices(serviceName) { - this.TemperatureService = this.HomeKitAccessory.addService(Service.TemperatureSensor, serviceName, 1); - this.airPressureService = this.HomeKitAccessory.addService(Service.EveAirPressureSensor, "", 1); - this.HumidityService = this.HomeKitAccessory.addService(Service.HumiditySensor, serviceName, 1); - this.BatteryService = this.HomeKitAccessory.addService(Service.BatteryService, "", 1); - this.BatteryService.updateCharacteristic(Characteristic.ChargingState, Characteristic.ChargingState.NOT_CHARGEABLE); // Really not chargeable ;-) + this.temperatureService = this.HomeKitAccessory.addService(HAP.Service.TemperatureSensor, "Temperature", 1); + this.airPressureService = this.HomeKitAccessory.addService(HAP.Service.EveAirPressureSensor, "", 1); + this.humidityService = this.HomeKitAccessory.addService(HAP.Service.HumiditySensor, "Humidity", 1); + this.batteryService = this.HomeKitAccessory.addService(HAP.Service.BatteryService, "", 1); + this.batteryService.updateCharacteristic(HAP.Characteristic.ChargingState, HAP.Characteristic.ChargingState.NOT_CHARGEABLE); // Really not chargeable ;-) // Add custom weather characteristics - this.TemperatureService.addCharacteristic(Characteristic.ForecastDay); - this.TemperatureService.addCharacteristic(Characteristic.ObservationStation); - this.TemperatureService.addCharacteristic(Characteristic.Condition); - this.TemperatureService.addCharacteristic(Characteristic.WindDirection); - this.TemperatureService.addCharacteristic(Characteristic.WindSpeed); - this.TemperatureService.addCharacteristic(Characteristic.SunriseTime); - this.TemperatureService.addCharacteristic(Characteristic.SunsetTime); + this.temperatureService.addCharacteristic(HAP.Characteristic.ForecastDay); + this.temperatureService.addCharacteristic(HAP.Characteristic.ObservationStation); + this.temperatureService.addCharacteristic(HAP.Characteristic.Condition); + this.temperatureService.addCharacteristic(HAP.Characteristic.WindDirection); + this.temperatureService.addCharacteristic(HAP.Characteristic.WindSpeed); + this.temperatureService.addCharacteristic(HAP.Characteristic.SunriseTime); + this.temperatureService.addCharacteristic(HAP.Characteristic.SunsetTime); - this.HomeKitAccessory.setPrimaryService(this.TemperatureService); + this.HomeKitAccessory.setPrimaryService(this.temperatureService); // Setup linkage to EveHome app if configured todo so - this.deviceData.EveApp && this.historyService && this.historyService.linkToEveHome(this.HomeKitAccessory, this.airPressureService, {debug: config.debug.includes("HISTORY")}); + if (this.deviceData.EveApp == true && this.HomeKitHistory != null) { + this.HomeKitHistory.linkToEveHome(this.HomeKitAccessory, this.airPressureService, {debug: config.debug.includes("HISTORY")}); + } - console.log("Setup Nest virtual weather station '%s' on '%s'", serviceName, this.HomeKitAccessory.username); + outputLogging(ACCESSORYNAME, false, "Setup Nest virtual weather station '%s'", serviceName); } - updateHomeKitServices(deviceData) { - if (this.TemperatureService != null && this.HumidityService != null && this.BatteryService != null && this.airPressureService != null) { - this.BatteryService.updateCharacteristic(Characteristic.BatteryLevel, 100); // Always %100 - this.BatteryService.updateCharacteristic(Characteristic.StatusLowBattery, Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL); - - this.TemperatureService.updateCharacteristic(Characteristic.CurrentTemperature, deviceData.current_temperature); - this.HumidityService.updateCharacteristic(Characteristic.CurrentRelativeHumidity, deviceData.current_humidity); - this.airPressureService.getCharacteristic(Characteristic.EveAirPressure, 0); - this.airPressureService.updateCharacteristic(Characteristic.EveElevation, 610); - - // Update custom characteristics - this.TemperatureService.updateCharacteristic(Characteristic.ForecastDay, deviceData.forecast); - this.TemperatureService.updateCharacteristic(Characteristic.ObservationStation, deviceData.station); - this.TemperatureService.updateCharacteristic(Characteristic.Condition, deviceData.condition); - this.TemperatureService.updateCharacteristic(Characteristic.WindDirection, deviceData.wind_direction); - this.TemperatureService.updateCharacteristic(Characteristic.WindSpeed, deviceData.wind_speed); - this.TemperatureService.updateCharacteristic(Characteristic.SunriseTime, new Date(deviceData.sunrise * 1000).toLocaleTimeString()); - this.TemperatureService.updateCharacteristic(Characteristic.SunsetTime, new Date(deviceData.sunset * 1000).toLocaleTimeString()); + updateHomeKitServices(updatedDeviceData) { + if (typeof updatedDeviceData != "object" || this.temperatureService == null || this.humidityService == null || this.batteryService == null || this.airPressureService == null) { + return; + } - // Record history - if ((deviceData.current_temperature != this.deviceData.current_temperature) || (deviceData.current_humidity != this.deviceData.current_humidity)) { - this.historySevice && this.historyService.addHistory(this.airPressureService, {time: Math.floor(new Date() / 1000), temperature: deviceData.current_temperature, humidity: deviceData.current_humidity, pressure: 0}, 300); - } + this.batteryService.updateCharacteristic(HAP.Characteristic.BatteryLevel, 100); // Always %100 + this.batteryService.updateCharacteristic(HAP.Characteristic.StatusLowBattery, HAP.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL); + + this.temperatureService.updateCharacteristic(HAP.Characteristic.CurrentTemperature, updatedDeviceData.current_temperature); + this.humidityService.updateCharacteristic(HAP.Characteristic.CurrentRelativeHumidity, updatedDeviceData.current_humidity); + this.airPressureService.getCharacteristic(HAP.Characteristic.EveAirPressure, 0); + //this.airPressureService.updateCharacteristic(HAP.Characteristic.EveElevation, 610); // Need to get this programatically based on location? + + // Update custom characteristics + this.temperatureService.updateCharacteristic(HAP.Characteristic.ForecastDay, updatedDeviceData.forecast); + this.temperatureService.updateCharacteristic(HAP.Characteristic.ObservationStation, updatedDeviceData.station); + this.temperatureService.updateCharacteristic(HAP.Characteristic.Condition, updatedDeviceData.condition); + this.temperatureService.updateCharacteristic(HAP.Characteristic.WindDirection, updatedDeviceData.wind_direction); + this.temperatureService.updateCharacteristic(HAP.Characteristic.WindSpeed, updatedDeviceData.wind_speed); + this.temperatureService.updateCharacteristic(HAP.Characteristic.SunriseTime, new Date(updatedDeviceData.sunrise * 1000).toLocaleTimeString()); + this.temperatureService.updateCharacteristic(HAP.Characteristic.SunsetTime, new Date(updatedDeviceData.sunset * 1000).toLocaleTimeString()); + + if (this.HomeKitHistory != null) { + // Record history every 5mins + this.HomeKitHistory.addHistory(this.airPressureService, {time: Math.floor(new Date() / 1000), temperature: updatedDeviceData.current_temperature, humidity: updatedDeviceData.current_humidity, pressure: 0}, 300); } } } @@ -1725,30 +1774,39 @@ class WeatherClass extends HomeKitDevice { // NestSystem class // -// Handles access to/from the Nest system +// Handles access to/from the Nest system API const CAMERAALERTPOLLING = 2000; // Camera alerts polling timer const CAMERAZONEPOLLING = 30000; // Camera zones changes polling timer const WEATHERPOLLING = 300000; // Refresh weather data every 5mins -const SUBSCRIBETIMEOUT = 120000; // Timeout for no subscription data +const SUBSCRIBETIMEOUT = (5 * 60 * 1000) // Timeout for no subscription data const NESTAPITIMEOUT = 10000; // Calls to Nest API timeout const USERAGENT = "Nest/5.69.0 (iOScom.nestlabs.jasper.release) os=15.6"; // User Agent string -const NESTAPIHOST = "https://home.nest.com"; // Root URL for Nest system API const REFERER = "https://home.nest.com" // Which hist is "actually" doing the request const CAMERAAPIHOST = "https://webapi.camera.home.nest.com"; // Root URL for Camera system API +const TEMPSENSORONLINETIMEOUT = (3600 * 4); // Temp sensor online reporting timeout -const SYSTEMEVENT = { - SET : "setElement", - GET : "getElement", - NEW : "newDevice" +const SystemEvent = { + ADD : "addDevice", + REMOVE : "removeDevice" +} + +const NestDeviceType = { + THERMOSTAT : "thermostat", + TEMPSENSOR : "temperature", + SMOKESENSOR : "protect", + CAMERA : "camera", + DOORBELL : "doorbell", + WEATHER : "weather", + LOCK : "lock", // yet to implement + ALARM : "alarm" // yet to implement } class NestSystem { - constructor(token, tokenType, eventEmitter) { + constructor(token, tokenType, globalEventEmitter) { this.initialToken = token; // Inital token to access Nest system this.tokenType = tokenType; // Type of account we authorised to Nest with this.nestAPIToken = ""; // Access token for Nest API requests - this.tokenExpire = null; // Time when token expires (in Unix timestamp) this.tokenTimer = null; // Handle for token refresh timer this.cameraAPI = {key: "", value: "", token: ""}; // Header Keys for camera API calls this.transport_url = ""; // URL for Nest API requests @@ -1756,180 +1814,196 @@ class NestSystem { this.userID = ""; // User ID this.rawData = {}; // Full copy of nest structure data this.abortController = new AbortController(); // Abort controller object - this.events = eventEmitter; // Global event emitter + this.eventEmitter = globalEventEmitter; // Global event emitter this.subscribePollingTimers = []; // Array of polling timers where we cannot do subscribe requests - this.startTime = null; // Time we started the object. used to filter out old alerts + this.startTime = Math.floor(new Date() / 1000); // Time we started the object. used to filter out old alerts // Setup event processing for set/get properties - this.events.addListener(SYSTEMEVENT.SET, this.#set.bind(this)); - this.events.addListener(SYSTEMEVENT.GET, this.#get.bind(this)); - - // Time we create this object. Used to filter camera alert events out before this started - this.startTime = Math.floor(new Date() / 1000); + this.eventEmitter.addListener(HomeKitDevice.SET, this.#set.bind(this)); + this.eventEmitter.addListener(HomeKitDevice.GET, this.#get.bind(this)); } // Class functions async connect() { // Connect to Nest. We support the Nest session token and Google cookie methods - var tempToken = ""; - this.tokenExpire = null; // Get below - if (this.tokenType == "google") { + var tempToken = ""; + var tokenExpire = null; + + if (this.tokenType == "google" && typeof this.initialToken == "object" && this.initialToken.hasOwnProperty("issuetoken") && this.initialToken.hasOwnProperty("cookie")) { // Google cookie method as refresh token method no longer supported by Google since October 2022 // Instructions from homebridge_nest or homebridge_nest_cam to obtain this - console.debug(getTimestamp() + " [NEST] Performing Google account authorisation"); + outputLogging(ACCESSORYNAME, false, "Performing Google account authorisation"); await axios.get(this.initialToken.issuetoken, {headers: {"user-agent": USERAGENT, "cookie": this.initialToken.cookie, "referer": "https://accounts.google.com/o/oauth2/iframe", "Sec-Fetch-Mode": "cors", "X-Requested-With": "XmlHttpRequest"} }) .then(async (response) => { - if (response.status == 200) { - await axios.post("https://nestauthproxyservice-pa.googleapis.com/v1/issue_jwt", "embed_google_oauth_access_token=true&expire_after=3600s&google_oauth_access_token=" + response.data.access_token + "&policy_id=authproxy-oauth-policy", {headers: {"referer": REFERER,"user-agent": USERAGENT, "Authorization": "Bearer " + response.data.access_token} }) - .then(async (response) => { - tempToken = response.data.jwt; - this.tokenType = "google"; // Google account - this.tokenExpire = Math.floor(new Date(response.data.claims.expirationTime) / 1000); // Token expiry, should be 1hr - this.cameraAPI.key = "Authorization"; // We'll put this in API header calls for cameras - this.cameraAPI.value = "Basic "; // NOTE: space at end of string. Required - this.cameraAPI.token = response.data.jwt; // We'll put this in API header calls for cameras - }) + if (typeof response.status != "number" || response.status != 200) { + throw new Error("Google API Authorisation failed with error"); } + + var tokentemp = response.data.access_token; + await axios.post("https://nestauthproxyservice-pa.googleapis.com/v1/issue_jwt", "embed_google_oauth_access_token=true&expire_after=3600s&google_oauth_access_token=" + response.data.access_token + "&policy_id=authproxy-oauth-policy", {headers: {"referer": REFERER,"user-agent": USERAGENT, "Authorization": "Bearer " + response.data.access_token} }) + .then((response) => { + if (typeof response.status != "number" || response.status != 200) { + throw new Error("Google Camera API Token get failed with error"); + } + + tempToken = response.data.jwt; + tokenExpire = Math.floor(new Date(response.data.claims.expirationTime) / 1000); // Token expiry, should be 1hr + this.tokenType = "google"; // Google account + this.cameraAPI.key = "Authorization"; // We'll put this in API header calls for cameras + this.cameraAPI.value = "Basic "; // NOTE: space at end of string. Required + this.cameraAPI.token = response.data.jwt; // We'll put this in API header calls for cameras + }) + .catch((error) => { + }); }) - .catch(error => { + .catch((error) => { }); } - if (this.tokenType == "nest") { + if (this.tokenType == "nest" && typeof this.initialToken == "string") { // Nest session token method. Get WEBSITE2 cookie for use with camera API calls if needed later - console.debug(getTimestamp() + " [NEST] Performing Nest account authorisation"); + outputLogging(ACCESSORYNAME, false, "Performing Nest account authorisation"); await axios.post(CAMERAAPIHOST + "/api/v1/login.login_nest", Buffer.from("access_token=" + this.initialToken, "utf8"), {withCredentials: true, headers: {"referer": REFERER, "Content-Type": "application/x-www-form-urlencoded", "user-agent": USERAGENT} }) .then((response) => { - if (response.status == 200 && response.data && response.data.status == 0) { - tempToken = this.initialToken; // Since we got camera details, this is a good token to use - this.tokenType = "nest"; // Nest account - this.cameraAPI.key = "cookie"; // We'll put this in API header calls for cameras - this.cameraAPI.value = "website_2="; - this.cameraAPI.token = response.data.items[0].session_token; // We'll put this in API header calls for cameras + if (typeof response.status != "number" || response.status != 200 || typeof response.data.status != "number" || response.data.status != 0) { + throw new Error("Nest API Authorisation failed with error"); } + + tempToken = this.initialToken; // Since we got camera details, this is a good token to use + tokenExpire = Math.floor(Date.now() / 1000) + (3600 * 24); // 24hrs expiry from now + this.tokenType = "nest"; // Nest account + this.cameraAPI.key = "cookie"; // We'll put this in API header calls for cameras + this.cameraAPI.value = "website_2="; + this.cameraAPI.token = response.data.items[0].session_token; // We'll put this in API header calls for cameras }) - .catch(error => { + .catch((error) => { }); } - if (tempToken != "") { - // We have a token, so open Nest session to get further details we require - await axios.get(NESTAPIHOST + "/session", {headers: {"user-agent": USERAGENT, "Authorization": "Basic " + tempToken} }) - .then((response) => { - if (response.status == 200) { - this.transport_url = response.data.urls.transport_url; - this.weather_url = response.data.urls.weather_url; - this.userID = response.data.userid; + if (tempToken == "" || tokenExpire == null) { + // We didn't obtain a token for either access via Nest AND/OR Google account, so authorisation failed + outputLogging(ACCESSORYNAME, false, "Account authorisation failed"); + return; + } + + // We have a token, so open Nest session to get further details we require + await axios.get("https://home.nest.com/session", {headers: {"user-agent": USERAGENT, "Authorization": "Basic " + tempToken} }) + .then((response) => { + if (typeof response.status != "number" || response.status != 200) { + throw new Error("Nest Session API get failed with error"); + } + + this.transport_url = response.data.urls.transport_url; + this.weather_url = response.data.urls.weather_url; + this.userID = response.data.userid; + this.nestAPIToken = tempToken; // Since we've successfully gotten Nest user data, store token for later. Means valid token + + // Set timeout for token expiry refresh + clearInterval(this.tokenTimer) + this.tokenTimer = setTimeout(async () => { + outputLogging(ACCESSORYNAME, false, "Performing token expiry refresh"); + this.connect(); + }, (tokenExpire - Math.floor(Date.now() / 1000) - 60) * 1000); // Refresh just before token expiry + outputLogging(ACCESSORYNAME, false, "Successfully authorised"); + }) + .catch((error) => { + }); + } + + async getData() { + if (typeof this.nestAPIToken != "string" || typeof this.transport_url != "string" || typeof this.userID != "string" || + this.nestAPIToken == "" || this.transport_url == "" || this.userID == "") { + return; + } + + await axios.get(this.transport_url + "/v3/mobile/user." + this.userID, {headers: {"content-type": "application/json", "user-agent": USERAGENT, "Authorization": "Basic " + this.nestAPIToken}, data: ""}) + .then(async (response) => { + if (typeof response.status != "number" || response.status != 200) { + throw new Error("Nest API HTTP get failed with error"); + } + + this.rawData = response.data; // Used to generate subscribed versions/times + + // Fetch other details for any doorbells/cameras we have, such as activity zones etc. We'll merge this into the Nest structure for processing + this.rawData.quartz && await Promise.all(Object.entries(this.rawData.quartz).map(async ([nestStructureID]) => { + this.rawData.quartz[nestStructureID].nexus_api_nest_domain_host = this.rawData.quartz[nestStructureID].nexus_api_http_server_url.replace(/dropcam.com/ig, "camera.home.nest.com"); // avoid extra API call to get this detail by simple domain name replace + this.rawData.quartz[nestStructureID].activity_zones = []; // no activity zones yet + this.rawData.quartz[nestStructureID].alerts = []; // no active alerts yet + this.rawData.quartz[nestStructureID].properties = []; // no properties yet - if (this.tokenExpire == null) { - this.tokenExpire = Math.floor(Date.now() / 1000) + (3600 * 24); // 24hrs expiry from now + // Get doorbell/camera activity zone details + await axios.get(this.rawData.quartz[nestStructureID].nexus_api_nest_domain_host + "/cuepoint_category/" + nestStructureID, {headers: {"user-agent": USERAGENT, [this.cameraAPI.key] : this.cameraAPI.value + this.cameraAPI.token}, responseType: "json", timeout: NESTAPITIMEOUT, retry: 3, retryDelay: 1000}) + .then(async (response) => { + if (typeof response.status != "number" || response.status != 200) { + throw new Error("Nest Camera API HTTP get failed with error"); } - - this.nestAPIToken = tempToken; // Since we've successfully gotten Nest user data, store token for later. Means valid token - - // Set timeout for token expiry refresh - clearInterval(this.tokenTimer) - this.tokenTimer = setTimeout(async () => { - console.debug(getTimestamp() + " [NEST] Performing token expiry refresh"); - this.connect(); - }, (this.tokenExpire - Math.floor(Date.now() / 1000) - 60) * 1000); // Refresh just before token expiry - console.debug(getTimestamp() + " [NEST] Successfully authorised to Nest"); - } - }) - .catch(error => { - }); - } else { - console.debug(getTimestamp() + " [NEST] Authorisation to Nest failed"); - } - return tempToken; - } + + // Insert activity zones into the nest structure + response.data.forEach((zone) => { + if (zone.type.toUpperCase() == "ACTIVITY" || zone.type.toUpperCase() == "REGION") { + this.rawData.quartz[nestStructureID].activity_zones.push({"id" : zone.id, "name" : this.#validateHomeKitName(zone.label), "hidden" : zone.hidden, "uri" : zone.nexusapi_image_uri}); + } + }); + }) + .catch((error) => { + }); - async getData() { - if (this.nestAPIToken != "" && this.transport_url != "" && this.userID != "") { - await axios.get(this.transport_url + "/v3/mobile/user." + this.userID, {headers: {"content-type": "application/json", "user-agent": USERAGENT, "Authorization": "Basic " + this.nestAPIToken}, data: ""}) - .then(async (response)=> { - if (response.status == 200) { - this.rawData = response.data; // Used to generate subscribed versions/times - - // Fetch other details for any doorbells/cameras we have, such as activity zones etc. We'll merge this into the Nest structure for processing - this.rawData.quartz && await Promise.all(Object.entries(this.rawData.quartz).map(async ([deviceID, camera]) => { - this.rawData.quartz[deviceID].nexus_api_nest_domain_host = camera.nexus_api_http_server_url.replace(/dropcam.com/ig, "camera.home.nest.com"); // avoid extra API call to get this detail by simple domain name replace - this.rawData.quartz[deviceID].activity_zones = []; // no activity zones yet - this.rawData.quartz[deviceID].alerts = []; // no active alerts yet - this.rawData.quartz[deviceID].properties = []; // no properties yet - - // Get doorbell/camera activity zone details - await axios.get(this.rawData.quartz[deviceID].nexus_api_nest_domain_host + "/cuepoint_category/" + deviceID, {headers: {"user-agent": USERAGENT, [this.cameraAPI.key] : this.cameraAPI.value + this.cameraAPI.token}, responseType: "json", timeout: NESTAPITIMEOUT, retry: 3, retryDelay: 1000}) - .then(async (response) => { - if (response.status && response.status == 200) { - // Insert activity zones into the nest structure - response.data.forEach(zone => { - if (zone.type.toUpperCase() == "ACTIVITY" || zone.type.toUpperCase() == "REGION") { - this.rawData.quartz[deviceID].activity_zones.push({"id" : zone.id, "name" : this.#makeValidHomeKitName(zone.label), "hidden" : zone.hidden, "uri" : zone.nexusapi_image_uri}); - } - }); - } - }) - .catch(error => { - }); + // Get doorbell/camera properties + await axios.get(CAMERAAPIHOST + "/api/cameras.get_with_properties?uuid=" + nestStructureID, {headers: {"user-agent": USERAGENT, "Referer" : REFERER, [this.cameraAPI.key] : this.cameraAPI.value + this.cameraAPI.token}, responseType: "json", timeout: NESTAPITIMEOUT, retry: 3, retryDelay: 1000}) + .then((response) => { + if (typeof response.status != "number" || response.status != 200) { + throw new Error("Nest Camera API HTTP get failed with error"); + } - // Get doorbell/camera properties - await axios.get(CAMERAAPIHOST + "/api/cameras.get_with_properties?uuid=" + deviceID, {headers: {"user-agent": USERAGENT, "Referer" : REFERER, [this.cameraAPI.key] : this.cameraAPI.value + this.cameraAPI.token}, responseType: "json", timeout: NESTAPITIMEOUT, retry: 3, retryDelay: 1000}) - .then((response) => { - if (response.status && response.status == 200) { - // Insert extra camera properties. We need this information to use with HomeKit Secure Video - this.rawData.quartz[deviceID].properties = response.data.items[0].properties; - } - }) - .catch(error => { - }); - })); - - // Get weather data. We'll merge this into the Nest structure for processing - this.rawData.structure && await Promise.all(Object.entries(this.rawData.structure).map(async ([structureID, structureData]) => { - this.rawData.structure[structureID].weather = {}; // We'll store Weather data will be here - await axios.get(this.weather_url + structureData.latitude + "," + structureData.longitude, {headers: {"user-agent": USERAGENT, timeout: 10000}}) - .then(response => { - if (response.status == 200) { - this.rawData.structure[structureID].weather.current_temperature = response.data[structureData.latitude + "," + structureData.longitude].current.temp_c; - this.rawData.structure[structureID].weather.current_humidity = response.data[structureData.latitude + "," + structureData.longitude].current.humidity; - this.rawData.structure[structureID].weather.condition = response.data[structureData.latitude + "," + structureData.longitude].current.condition; - this.rawData.structure[structureID].weather.wind_direction = response.data[structureData.latitude + "," + structureData.longitude].current.wind_dir; - this.rawData.structure[structureID].weather.wind_speed = (response.data[structureData.latitude + "," + structureData.longitude].current.wind_mph * 1.609344); // convert to km/h - this.rawData.structure[structureID].weather.sunrise = response.data[structureData.latitude + "," + structureData.longitude].current.sunrise; - this.rawData.structure[structureID].weather.sunset = response.data[structureData.latitude + "," + structureData.longitude].current.sunset; - this.rawData.structure[structureID].weather.station = response.data[structureData.latitude + "," + structureData.longitude].location.short_name; - this.rawData.structure[structureID].weather.forecast = response.data[structureData.latitude + "," + structureData.longitude].forecast.daily[0].condition; - } - }) - .catch(error => { - }); - })); - } - else { - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Failed to get Nest data. HTTP status returned", response.status); - } - }) - .catch(error => { - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Nest data get failed with error", error.message); - }); - } + // Insert extra camera properties into the nest structure. We need information from this to use with HomeKit Secure Video + this.rawData.quartz[nestStructureID].properties = response.data.items[0].properties; + }) + .catch((error) => { + }); + })); + + // Get weather data. We'll merge this into the Nest structure for processing + this.rawData.structure && await Promise.all(Object.entries(this.rawData.structure).map(async ([nestStructureID, structureData]) => { + this.rawData.structure[nestStructureID].weather = {}; // We'll store Weather data will be here + await axios.get(this.weather_url + structureData.latitude + "," + structureData.longitude, {headers: {"user-agent": USERAGENT, timeout: 10000}}) + .then((response) => { + if (typeof response.status != "number" || response.status != 200) { + throw new Error("Nest Weather API HTTP get failed with error"); + } + + this.rawData.structure[nestStructureID].weather.current_temperature = response.data[structureData.latitude + "," + structureData.longitude].current.temp_c; + this.rawData.structure[nestStructureID].weather.current_humidity = response.data[structureData.latitude + "," + structureData.longitude].current.humidity; + this.rawData.structure[nestStructureID].weather.condition = response.data[structureData.latitude + "," + structureData.longitude].current.condition; + this.rawData.structure[nestStructureID].weather.wind_direction = response.data[structureData.latitude + "," + structureData.longitude].current.wind_dir; + this.rawData.structure[nestStructureID].weather.wind_speed = (response.data[structureData.latitude + "," + structureData.longitude].current.wind_mph * 1.609344); // convert to km/h + this.rawData.structure[nestStructureID].weather.sunrise = response.data[structureData.latitude + "," + structureData.longitude].current.sunrise; + this.rawData.structure[nestStructureID].weather.sunset = response.data[structureData.latitude + "," + structureData.longitude].current.sunset; + this.rawData.structure[nestStructureID].weather.station = response.data[structureData.latitude + "," + structureData.longitude].location.short_name; + this.rawData.structure[nestStructureID].weather.forecast = response.data[structureData.latitude + "," + structureData.longitude].forecast.daily[0].condition; + }) + .catch((error) => { + }); + })); + }) + .catch((error) => { + }); } processData() { var devices = {}; - this.rawData.device && Object.entries(this.rawData.device).forEach(([deviceID, thermostat]) => { + this.rawData.device && Object.entries(this.rawData.device).forEach(([nestStructureID, thermostat]) => { // process thermostats thermostat.serial_number = thermostat.serial_number.toUpperCase(); // ensure serial numbers are in upper case var tempMACAddress = thermostat.mac_address.toUpperCase(); tempMACAddress = tempMACAddress.substring(0,2) + ":" + tempMACAddress.substring(2,4) + ":" + tempMACAddress.substring(4,6) + ":" + tempMACAddress.substring(6,8) + ":" + tempMACAddress.substring(8,10) + ":" + tempMACAddress.substring(10,12); - + var tempDevice = {}; - tempDevice.excluded = config.excludedDevices.includes(thermostat.serial_number); // Mark device as excluded or not - tempDevice.device_type = NESTDEVICETYPE.THERMOSTAT; // nest thermostat - tempDevice.nest_device_structure = "device." + deviceID; + tempDevice.excluded = (config.deviceOptions.Global.Exclude == true && (typeof config.deviceOptions[thermostat.serial_number] == "undefined" || typeof config.deviceOptions[thermostat.serial_number] == "object" && typeof config.deviceOptions[thermostat.serial_number].Exclude == "undefined")) || (typeof config.deviceOptions[thermostat.serial_number] == "object" && typeof config.deviceOptions[thermostat.serial_number].Exclude == "boolean" && config.deviceOptions[thermostat.serial_number].Exclude == true); // Mark device as excluded or not + tempDevice.device_type = NestDeviceType.THERMOSTAT; // nest thermostat + tempDevice.device_uuid = "device." + nestStructureID; + tempDevice.manufacturer = ACCESSORYNAME; tempDevice.software_version = (typeof thermostat.current_version != "undefined" ? thermostat.current_version.replace(/-/g, ".") : "0.0.0"); tempDevice.mac_address = tempMACAddress; // Our created MAC address tempDevice.current_humidity = thermostat.current_humidity; @@ -1944,20 +2018,25 @@ class NestSystem { tempDevice.leaf = thermostat.leaf; tempDevice.can_cool = this.rawData.shared[thermostat.serial_number].can_cool; tempDevice.can_heat = this.rawData.shared[thermostat.serial_number].can_heat; - tempDevice.description = this.rawData.shared[thermostat.serial_number].hasOwnProperty("name") ? this.#makeValidHomeKitName(this.rawData.shared[thermostat.serial_number].name) : ""; + tempDevice.description = this.rawData.shared[thermostat.serial_number].hasOwnProperty("name") ? this.#validateHomeKitName(this.rawData.shared[thermostat.serial_number].name) : ""; tempDevice.target_temperature_type = this.rawData.shared[thermostat.serial_number].target_temperature_type; tempDevice.target_change_pending = this.rawData.shared[thermostat.serial_number].target_change_pending; - tempDevice.target_temperature = this.#adjustTemperature(this.rawData.shared[thermostat.serial_number].target_temperature, "C", "C"); - tempDevice.backplate_temperature = this.#adjustTemperature(thermostat.backplate_temperature, "C", "C"); + tempDevice.target_temperature = this.#adjustTemperature(this.rawData.shared[thermostat.serial_number].target_temperature, "C", "C", true); + tempDevice.backplate_temperature = this.#adjustTemperature(thermostat.backplate_temperature, "C", "C", true); tempDevice.temperature_lock = thermostat.temperature_lock; tempDevice.temperature_lock_pin_hash = thermostat.temperature_lock_pin_hash; + tempDevice.model = "Thermostat"; + if (thermostat.serial_number.substring(0,2) == "15") tempDevice.model = "Thermostat E"; // Nest Thermostat E + if (thermostat.serial_number.substring(0,2) == "09") tempDevice.model = "Thermostat 3rd Generation"; // Nest Thermostat 3rd Gen + if (thermostat.serial_number.substring(0,2) == "02") tempDevice.model = "Thermostat 2nd Generation"; // Nest Thermostat 2nd Gen + if (thermostat.serial_number.substring(0,2) == "01") tempDevice.model = "Thermostat 1st Generation"; // Nest Thermostat 1st Gen - // TWork our the current mode on the thermostat + // Work out the current mode on the thermostat if (thermostat.eco.mode.toUpperCase() == "AUTO-ECO" || thermostat.eco.mode.toUpperCase() == "MANUAL-ECO") { // thermostat is running in "eco" mode, we'll override the target temps to be that of eco mode ones // also define a new hvac mode of "eco" - tempDevice.target_temperature_high = this.#adjustTemperature(thermostat.away_temperature_high, "C", "C"); - tempDevice.target_temperature_low = this.#adjustTemperature(thermostat.away_temperature_low, "C", "C"); + tempDevice.target_temperature_high = this.#adjustTemperature(thermostat.away_temperature_high, "C", "C", true); + tempDevice.target_temperature_low = this.#adjustTemperature(thermostat.away_temperature_low, "C", "C", true); if (thermostat.away_temperature_high_enabled == true && thermostat.away_temperature_low_enabled == true) { // eco range tempDevice.hvac_mode = "eco"; @@ -1983,8 +2062,8 @@ class NestSystem { } else { // Just a normal mode, ie: not eco type - tempDevice.target_temperature_high = this.#adjustTemperature(this.rawData.shared[thermostat.serial_number].target_temperature_high, "C", "C"); - tempDevice.target_temperature_low = this.#adjustTemperature(this.rawData.shared[thermostat.serial_number].target_temperature_low, "C", "C"); + tempDevice.target_temperature_high = this.#adjustTemperature(this.rawData.shared[thermostat.serial_number].target_temperature_high, "C", "C", true); + tempDevice.target_temperature_low = this.#adjustTemperature(this.rawData.shared[thermostat.serial_number].target_temperature_low, "C", "C", true); tempDevice.hvac_mode = this.rawData.shared[thermostat.serial_number].target_temperature_type; } @@ -2023,9 +2102,9 @@ class NestSystem { // Get device location name tempDevice.location = ""; - this.rawData.where[this.rawData.link[thermostat.serial_number].structure.split(".")[1]].wheres.forEach(where => { + this.rawData.where[this.rawData.link[thermostat.serial_number].structure.split(".")[1]].wheres.forEach((where) => { if (thermostat.where_id == where.where_id) { - tempDevice.location = this.#makeValidHomeKitName(where.name); + tempDevice.location = this.#validateHomeKitName(where.name); } }); @@ -2037,16 +2116,38 @@ class NestSystem { tempDevice.away = this.rawData.structure[this.rawData.link[thermostat.serial_number].structure.split(".")[1]].away; // away status tempDevice.vacation_mode = this.rawData.structure[this.rawData.link[thermostat.serial_number].structure.split(".")[1]].vacation_mode; // vacation mode - tempDevice.home_name = this.#makeValidHomeKitName(this.rawData.structure[this.rawData.link[thermostat.serial_number].structure.split(".")[1]].name); // Home name - tempDevice.structureID = this.rawData.link[thermostat.serial_number].structure.split(".")[1]; // structure ID + tempDevice.home_name = this.#validateHomeKitName(this.rawData.structure[this.rawData.link[thermostat.serial_number].structure.split(".")[1]].name); // Home name + tempDevice.belongs_to_structure = this.rawData.link[thermostat.serial_number].structure.split(".")[1]; // structure ID tempDevice.active_rcs_sensor = ""; - tempDevice.active_temperature = this.#adjustTemperature(thermostat.backplate_temperature, "C", "C"); // already adjusted temperature + tempDevice.active_temperature = this.#adjustTemperature(thermostat.backplate_temperature, "C", "C", true); // already adjusted temperature tempDevice.linked_rcs_sensors = []; + // Hot water + tempDevice.hot_water_active = thermostat.hot_water_active; + tempDevice.hot_water_boiling_state = thermostat.hot_water_boiling_state; + tempDevice.has_hot_water_temperature = thermostat.has_hot_water_temperature; + tempDevice.has_hot_water_control = tempDevice.has_hot_water_control; + // Get associated schedules tempDevice.schedules = {}; + tempDevice.schedule_mode = ""; if (typeof this.rawData.schedule[thermostat.serial_number] == "object") { + Object.entries(this.rawData.schedule[thermostat.serial_number].days).forEach(([day, schedules]) => { + Object.entries(schedules).forEach(([id, schedule]) => { + // Foix up tempaeratures in the schedule + if (schedule.hasOwnProperty("temp") == true) { + schedule.temp = this.#adjustTemperature(schedule.temp, "C", "C", true); + } + if (schedule.hasOwnProperty("temp-min") == true) { + schedule["temp-min"] = this.#adjustTemperature(schedule["temp-min"], "C", "C", true); + } + if (schedule.hasOwnProperty("temp-min") == true) { + schedule["temp-max"] = this.#adjustTemperature(schedule["temp-max"], "C", "C", true); + } + }); + }); tempDevice.schedules = this.rawData.schedule[thermostat.serial_number].days; + tempDevice.schedule_mode = this.rawData.schedule[thermostat.serial_number].schedule_mode; } // Air filter details @@ -2056,25 +2157,25 @@ class NestSystem { tempDevice.filter_replacement_threshold_sec = thermostat.filter_replacement_threshold_sec; // Insert any extra options we've read in from configuration file - tempDevice.EveApp = config.EveApp; // Global config option for EveHome App integration. Gets overriden below for thermostat devices - config.extraOptions[thermostat.serial_number] && Object.entries(config.extraOptions[thermostat.serial_number]).forEach(([key, value]) => { + tempDevice.EveApp = config.deviceOptions.Global.EveApp; // Global config option for EveHome App integration. Gets overriden below for thermostat devices + config.deviceOptions[thermostat.serial_number] && Object.entries(config.deviceOptions[thermostat.serial_number]).forEach(([key, value]) => { tempDevice[key] = value; }); // Even if this thermostat is excluded, we need to process any associated temperature sensors - this.rawData.rcs_settings[thermostat.serial_number].associated_rcs_sensors.forEach(sensor => { + this.rawData.rcs_settings[thermostat.serial_number].associated_rcs_sensors.forEach((sensor) => { this.rawData.kryptonite[sensor.split(".")[1]].associated_thermostat = thermostat.serial_number; var sensorInfo = this.rawData.kryptonite[sensor.split(".")[1]]; sensorInfo.serial_number = sensorInfo.serial_number.toUpperCase(); - if (typeof tempDevice == "object" && config.excludedDevices.includes(sensorInfo.serial_number) == false) { + if (typeof tempDevice == "object" && (config.deviceOptions.Global.Exclude == true && (typeof config.deviceOptions[sensorInfo.serial_number] == "undefined" || typeof config.deviceOptions[sensorInfo.serial_number] == "object" && typeof config.deviceOptions[sensorInfo.serial_number].Exclude == "undefined")) || (typeof config.deviceOptions[sensorInfo.serial_number] == "object" && typeof config.deviceOptions[sensorInfo.serial_number].Exclude == "boolean" && config.deviceOptions[sensorInfo.serial_number].Exclude == true)) { // Associated temperature sensor isn't excluded tempDevice.linked_rcs_sensors.push(sensorInfo.serial_number); // Is this sensor the active one? If so, get some details about it if (this.rawData.rcs_settings[thermostat.serial_number].active_rcs_sensors.includes(sensor)) { tempDevice.active_rcs_sensor = sensorInfo.serial_number; - tempDevice.active_temperature = this.#adjustTemperature(sensorInfo.current_temperature, "C", "C"); + tempDevice.active_temperature = this.#adjustTemperature(sensorInfo.current_temperature, "C", "C", true); } } }); @@ -2082,30 +2183,32 @@ class NestSystem { devices[thermostat.serial_number] = tempDevice; // Store processed device }); - this.rawData.kryptonite && Object.entries(this.rawData.kryptonite).forEach(([deviceID, sensor]) => { - // Process temperature sensors. Needs to be done AFTER thermostat as we insert some extra details in there + this.rawData.kryptonite && Object.entries(this.rawData.kryptonite).forEach(([nestStructureID, sensor]) => { + // Process temperature sensors. Needs to be done AFTER thermostat as we inserted some extra details in there sensor.serial_number = sensor.serial_number.toUpperCase(); // ensure serial numbers are in upper case var tempMACAddress = "18B430" + this.#crc24(sensor.serial_number).toUpperCase(); // Use a Nest Labs prefix for first 6 digits, followed by a CRC24 based off serial number for last 6 digits. tempMACAddress = tempMACAddress.substring(0,2) + ":" + tempMACAddress.substring(2,4) + ":" + tempMACAddress.substring(4,6) + ":" + tempMACAddress.substring(6,8) + ":" + tempMACAddress.substring(8,10) + ":" + tempMACAddress.substring(10,12); var tempDevice = {}; - tempDevice.excluded = config.excludedDevices.includes(sensor.serial_number); // Mark device as excluded or not - tempDevice.device_type = NESTDEVICETYPE.TEMPSENSOR; // nest temperature sensor - tempDevice.nest_device_structure = "kryptonite." + deviceID; + tempDevice.excluded = (config.deviceOptions.Global.Exclude == true && (typeof config.deviceOptions[sensor.serial_number] == "undefined" || typeof config.deviceOptions[sensor.serial_number] == "object" && typeof config.deviceOptions[sensor.serial_number].Exclude == "undefined")) || (typeof config.deviceOptions[sensor.serial_number] == "object" && typeof config.deviceOptions[sensor.serial_number].Exclude == "boolean" && config.deviceOptions[sensor.serial_number].Exclude == true); // Mark device as excluded or not + tempDevice.device_type = NestDeviceType.TEMPSENSOR; // nest temperature sensor + tempDevice.device_uuid = "kryptonite." + nestStructureID; + tempDevice.manufacturer = ACCESSORYNAME; tempDevice.serial_number = sensor.serial_number; - tempDevice.description = sensor.hasOwnProperty("description") ? this.#makeValidHomeKitName(sensor.description) : ""; + tempDevice.description = sensor.hasOwnProperty("description") ? this.#validateHomeKitName(sensor.description) : ""; tempDevice.mac_address = tempMACAddress; // Our created MAC address tempDevice.battery_level = sensor.battery_level; tempDevice.software_version = "1.0"; - tempDevice.current_temperature = this.#adjustTemperature(sensor.current_temperature, "C", "C"); - tempDevice.active_sensor = this.rawData.rcs_settings[sensor.associated_thermostat].active_rcs_sensors.includes("kryptonite." + deviceID); + tempDevice.model = "Temperature Sensor"; + tempDevice.current_temperature = this.#adjustTemperature(sensor.current_temperature, "C", "C", true); + tempDevice.active_sensor = this.rawData.rcs_settings[sensor.associated_thermostat].active_rcs_sensors.includes("kryptonite." + nestStructureID); tempDevice.associated_thermostat = sensor.associated_thermostat; // Get device location name tempDevice.location = ""; - this.rawData.where[sensor.structure_id].wheres.forEach(where => { + this.rawData.where[sensor.structure_id].wheres.forEach((where) => { if (sensor.where_id == where.where_id) { - tempDevice.location = this.#makeValidHomeKitName(where.name); + tempDevice.location = this.#validateHomeKitName(where.name); } }); @@ -2115,29 +2218,30 @@ class NestSystem { tempDevice.location = ""; // Clear location name } - tempDevice.online = (Math.floor(new Date() / 1000) - sensor.last_updated_at) < (3600 * 3) ? true : false; // online status. allow upto 3hrs for reporting before report sensor offline - tempDevice.home_name = this.#makeValidHomeKitName(this.rawData.structure[sensor.structure_id].name); // Home name - tempDevice.structureID = sensor.structure_id; // structure ID + tempDevice.online = (Math.floor(new Date() / 1000) - sensor.last_updated_at) < TEMPSENSORONLINETIMEOUT ? true : false; // online status for reporting before report sensor offline + tempDevice.home_name = this.#validateHomeKitName(this.rawData.structure[sensor.structure_id].name); // Home name + tempDevice.belongs_to_structure = sensor.structure_id; // structure ID // Insert any extra options we've read in from configuration file for this device - tempDevice.EveApp = config.EveApp; // Global config option for EveHome App integration. Gets overriden below for temperature sensor devices - config.extraOptions[sensor.serial_number] && Object.entries(config.extraOptions[sensor.serial_number]).forEach(([key, value]) => { + tempDevice.EveApp = config.deviceOptions.Global.EveApp; // Global config option for EveHome App integration. Gets overriden below for temperature sensor devices + config.deviceOptions[sensor.serial_number] && Object.entries(config.deviceOptions[sensor.serial_number]).forEach(([key, value]) => { tempDevice[key] = value; }); devices[sensor.serial_number] = tempDevice; // Store processed device }); - this.rawData.topaz && Object.entries(this.rawData.topaz).forEach(([deviceID, protect]) => { + this.rawData.topaz && Object.entries(this.rawData.topaz).forEach(([nestStructureID, protect]) => { // Process smoke detectors protect.serial_number = protect.serial_number.toUpperCase(); // ensure serial numbers are in upper case var tempMACAddress = protect.wifi_mac_address.toUpperCase(); tempMACAddress = tempMACAddress.substring(0,2) + ":" + tempMACAddress.substring(2,4) + ":" + tempMACAddress.substring(4,6) + ":" + tempMACAddress.substring(6,8) + ":" + tempMACAddress.substring(8,10) + ":" + tempMACAddress.substring(10,12); var tempDevice = {}; - tempDevice.excluded = config.excludedDevices.includes(protect.serial_number); // Mark device as excluded or not - tempDevice.device_type = NESTDEVICETYPE.SMOKESENSOR; // nest protect - tempDevice.nest_device_structure = "topaz." + deviceID; + tempDevice.excluded = (config.deviceOptions.Global.Exclude == true && (typeof config.deviceOptions[protect.serial_number] == "undefined" || typeof config.deviceOptions[protect.serial_number] == "object" && typeof config.deviceOptions[protect.serial_number].Exclude == "undefined")) || (typeof config.deviceOptions[protect.serial_number] == "object" && typeof config.deviceOptions[protect.serial_number].Exclude == "boolean" && config.deviceOptions[protect.serial_number].Exclude == true); // Mark device as excluded or not + tempDevice.device_type = NestDeviceType.SMOKESENSOR; // nest protect + tempDevice.device_uuid = "topaz." + nestStructureID; + tempDevice.manufacturer = ACCESSORYNAME; tempDevice.serial_number = protect.serial_number; tempDevice.line_power_present = protect.line_power_present; tempDevice.wired_or_battery = protect.wired_or_battery; @@ -2160,19 +2264,25 @@ class NestSystem { tempDevice.removed_from_base = protect.removed_from_base; tempDevice.latest_alarm_test = protect.latest_manual_test_end_utc_secs; tempDevice.self_test_in_progress = this.rawData.safety[protect.structure_id].manual_self_test_in_progress; - tempDevice.description = protect.hasOwnProperty("description") ? this.#makeValidHomeKitName(protect.description) : ""; + tempDevice.description = protect.hasOwnProperty("description") ? this.#validateHomeKitName(protect.description) : ""; tempDevice.software_version = (typeof protect.software_version != "undefined" ? protect.software_version.replace(/-/g, ".") : "0.0.0"); tempDevice.ui_color_state = "grey"; tempDevice.topaz_hush_key = this.rawData.structure[protect.structure_id].topaz_hush_key; if (protect.battery_health_state == 0 && protect.co_status == 0 && protect.smoke_status == 0) tempDevice.ui_color_state = "green"; if (protect.battery_health_state != 0 || protect.co_status == 1 || protect.smoke_status == 1) tempDevice.ui_color_state = "yellow"; if (protect.co_status == 2 || protect.smoke_status == 2) tempDevice.ui_color_state = "red"; + + tempDevice.model = "Protect"; + if (protect.serial_number.substring(0,2) == "06") tempDevice.model = "Protect 2nd Generation"; // Nest Protect 2nd Gen + if (protect.serial_number.substring(0,2) == "05") tempDevice.model = "Protect 1st Generation"; // Nest Protect 1st Gen + if (protect.wired_or_battery == 0) tempDevice.model = tempDevice.model + " (wired)"; // Mains powered + if (protect.wired_or_battery == 1) tempDevice.model = tempDevice.model + " (battery)"; // Battery powered // Get device location name tempDevice.location = ""; - this.rawData.where[protect.structure_id].wheres.forEach(where => { + this.rawData.where[protect.structure_id].wheres.forEach((where) => { if (protect.where_id == where.where_id) { - tempDevice.location = this.#makeValidHomeKitName(where.name); + tempDevice.location = this.#validateHomeKitName(where.name); } }); @@ -2184,35 +2294,35 @@ class NestSystem { tempDevice.away = protect.auto_away; // away status tempDevice.vacation_mode = this.rawData.structure[protect.structure_id].vacation_mode; // vacation mode - tempDevice.home_name = this.#makeValidHomeKitName(this.rawData.structure[protect.structure_id].name); // Home name - tempDevice.structureID = protect.structure_id; // structure ID + tempDevice.home_name = this.#validateHomeKitName(this.rawData.structure[protect.structure_id].name); // Home name + tempDevice.belongs_to_structure = protect.structure_id; // structure ID // Insert any extra options we've read in from configuration file for this device - tempDevice.EveApp = config.EveApp; // Global config option for EveHome App integration. Gets overriden below for protect devices - config.extraOptions[protect.serial_number] && Object.entries(config.extraOptions[protect.serial_number]).forEach(([key, value]) => { + tempDevice.EveApp = config.deviceOptions.Global.EveApp; // Global config option for EveHome App integration. Gets overriden below for protect devices + config.deviceOptions[protect.serial_number] && Object.entries(config.deviceOptions[protect.serial_number]).forEach(([key, value]) => { tempDevice[key] = value; }); devices[protect.serial_number] = tempDevice; // Store processed device }); - this.rawData.quartz && Object.entries(this.rawData.quartz).forEach(([deviceID, camera]) => { + this.rawData.quartz && Object.entries(this.rawData.quartz).forEach(([nestStructureID, camera]) => { // Process doorbell/cameras camera.serial_number = camera.serial_number.toUpperCase(); // ensure serial numbers are in upper case var tempMACAddress = camera.mac_address.toUpperCase(); tempMACAddress = tempMACAddress.substring(0,2) + ":" + tempMACAddress.substring(2,4) + ":" + tempMACAddress.substring(4,6) + ":" + tempMACAddress.substring(6,8) + ":" + tempMACAddress.substring(8,10) + ":" + tempMACAddress.substring(10,12); var tempDevice = {}; - tempDevice.excluded = config.excludedDevices.includes(camera.serial_number); // Mark device as excluded or not - tempDevice.device_type = camera.camera_type == 12 ? NESTDEVICETYPE.DOORBELL : NESTDEVICETYPE.CAMERA; // nest doorbell or camera - tempDevice.nest_device_structure = "quartz." + deviceID; + tempDevice.excluded = (config.deviceOptions.Global.Exclude == true && (typeof config.deviceOptions[camera.serial_number] == "undefined" || typeof config.deviceOptions[camera.serial_number] == "object" && typeof config.deviceOptions[camera.serial_number].Exclude == "undefined")) || (typeof config.deviceOptions[camera.serial_number] == "object" && typeof config.deviceOptions[camera.serial_number].Exclude == "boolean" && config.deviceOptions[camera.serial_number].Exclude == true); // Mark device as excluded or not + tempDevice.device_type = camera.camera_type == 12 ? NestDeviceType.DOORBELL : NestDeviceType.CAMERA; // nest doorbell or camera + tempDevice.device_uuid = "quartz." + nestStructureID; + tempDevice.manufacturer = ACCESSORYNAME; tempDevice.serial_number = camera.serial_number; tempDevice.software_version = (typeof camera.software_version != "undefined" ? camera.software_version.replace(/-/g, ".") : "0.0.0"); - tempDevice.model = camera.model; // Full model name ie "Nest Doorbell (wired)" etc + tempDevice.model = camera.model.replace(/nest\s*/ig, ""); // We'll use doorbell/camera model description that Nest supplies tempDevice.mac_address = tempMACAddress; // Our created MAC address; tempDevice.last_disconnect_reason = (typeof camera.last_disconnect_reason != "undefined" ? camera.last_disconnect_reason : ""); - tempDevice.description = camera.hasOwnProperty("description") ? this.#makeValidHomeKitName(camera.description) : ""; - tempDevice.camera_uuid = deviceID; // Can generate from .nest_device_structure anyway + tempDevice.description = camera.hasOwnProperty("description") ? this.#validateHomeKitName(camera.description) : ""; tempDevice.nest_aware = (typeof camera.cvr_enrolled != "undefined" ? ((camera.cvr_enrolled.toUpperCase() != "NONE") ? true : false) : false); // Does user have an active Nest aware subscription tempDevice.direct_nexustalk_host = camera.direct_nexustalk_host; tempDevice.websocket_nexustalk_host = camera.websocket_nexustalk_host; @@ -2228,9 +2338,9 @@ class NestSystem { // Get device location name tempDevice.location = ""; - this.rawData.where[camera.structure_id].wheres.forEach(where => { + this.rawData.where[camera.structure_id].wheres.forEach((where) => { if (camera.where_id == where.where_id) { - tempDevice.location = this.#makeValidHomeKitName(where.name); + tempDevice.location = this.#validateHomeKitName(where.name); } }); @@ -2242,19 +2352,19 @@ class NestSystem { tempDevice.away = this.rawData.structure[camera.structure_id].away; // away status tempDevice.vacation_mode = this.rawData.structure[camera.structure_id].vacation_mode; // vacation mode - tempDevice.home_name = this.#makeValidHomeKitName(this.rawData.structure[camera.structure_id].name); // Home name - tempDevice.structureID = camera.structure_id; // structure ID + tempDevice.home_name = this.#validateHomeKitName(this.rawData.structure[camera.structure_id].name); // Home name + tempDevice.belongs_to_structure = camera.structure_id; // structure ID // Insert any extra options we've read in from configuration file for this device - tempDevice.EveApp = config.EveApp; // Global config option for EveHome App integration. Gets overriden below for specific doorbell/camera - tempDevice.HKSV = config.HKSV; // Global config option for HomeKit Secure Video. Gets overriden below for specific doorbell/camera - tempDevice.H264EncoderRecord = config.H264EncoderRecord; // Global config option for using H264EncoderRecord. Gets overriden below for specific doorbell/camera - tempDevice.H264EncoderLive = config.H264EncoderLive; // Global config option for using H264EncoderLive. Gets overriden below for specific doorbell/camera - tempDevice.HKSVPreBuffer = config.HKSVPreBuffer; // Global config option for HKSV pre buffering size. Gets overriden below for specific doorbell/camera - tempDevice.doorbellCooldown = config.doorbellCooldown; // Global default for doorbell press cooldown. Gets overriden below for specific doorbell/camera - tempDevice.motionCooldown = config.motionCooldown; // Global default for motion detected cooldown. Gets overriden below for specific doorbell/camera - tempDevice.personCooldown = config.personCooldown; // Global default for person detected cooldown. Gets overriden below for specific doorbell/camera - config.extraOptions[camera.serial_number] && Object.entries(config.extraOptions[camera.serial_number]).forEach(([key, value]) => { + tempDevice.EveApp = config.deviceOptions.Global.EveApp; // Global config option for EveHome App integration. Gets overriden below for specific doorbell/camera + tempDevice.HKSV = config.deviceOptions.Global.HKSV; // Global config option for HomeKit Secure Video. Gets overriden below for specific doorbell/camera + tempDevice.H264EncoderRecord = config.deviceOptions.Global.H264EncoderRecord; // Global config option for using H264EncoderRecord. Gets overriden below for specific doorbell/camera + tempDevice.H264EncoderLive = config.deviceOptions.Global.H264EncoderLive; // Global config option for using H264EncoderLive. Gets overriden below for specific doorbell/camera + tempDevice.HKSVPreBuffer = config.deviceOptions.Global.HKSVPreBuffer; // Global config option for HKSV pre buffering size. Gets overriden below for specific doorbell/camera + tempDevice.DoorbellCooldown = config.deviceOptions.Global.DoorbellCooldown; // Global default for doorbell press cooldown. Gets overriden below for specific doorbell/camera + tempDevice.MotionCooldown = config.deviceOptions.Global.MotionCooldown; // Global default for motion detected cooldown. Gets overriden below for specific doorbell/camera + tempDevice.PersonCooldown = config.deviceOptions.Global.PersonCooldown; // Global default for person detected cooldown. Gets overriden below for specific doorbell/camera + config.deviceOptions[camera.serial_number] && Object.entries(config.deviceOptions[camera.serial_number]).forEach(([key, value]) => { tempDevice[key] = value; }); @@ -2262,18 +2372,19 @@ class NestSystem { }); // Make up a virtual weather station data - this.rawData.structure && Object.entries(this.rawData.structure).forEach(([deviceID, structure]) => { + this.rawData.structure && Object.entries(this.rawData.structure).forEach(([nestStructureID, structure]) => { // Process structure - var tempMACAddress = "18B430" + this.#crc24(deviceID).toUpperCase(); // Use a Nest Labs prefix for first 6 digits, followed by a CRC24 based off structure for last 6 digits. + var tempMACAddress = "18B430" + this.#crc24(nestStructureID).toUpperCase(); // Use a Nest Labs prefix for first 6 digits, followed by a CRC24 based off structure for last 6 digits. var serial_number = tempMACAddress; // Serial number will be the mac address we've created tempMACAddress = tempMACAddress.substring(0,2) + ":" + tempMACAddress.substring(2,4) + ":" + tempMACAddress.substring(4,6) + ":" + tempMACAddress.substring(6,8) + ":" + tempMACAddress.substring(8,10) + ":" + tempMACAddress.substring(10,12); var tempDevice = {}; - tempDevice.excluded = (config.weather == false); // Mark device as excluded or not - tempDevice.device_type = NESTDEVICETYPE.WEATHER; + tempDevice.excluded = (config.weather == false); // Mark device as excluded or not + tempDevice.device_type = NestDeviceType.WEATHER; tempDevice.mac_address = tempMACAddress; - tempDevice.nest_device_structure = "structure." + deviceID; - tempDevice.description = this.#makeValidHomeKitName(structure.location); + tempDevice.device_uuid = "structure." + nestStructureID; + tempDevice.manufacturer = ACCESSORYNAME; + tempDevice.description = this.#validateHomeKitName(structure.location); tempDevice.location = ""; tempDevice.serial_number = serial_number; tempDevice.software_version = "1.0.0"; @@ -2283,6 +2394,7 @@ class NestSystem { tempDevice.state = structure.state; tempDevice.latitude = structure.latitude; tempDevice.longitude = structure.longitude; + tempDevice.model = "Weather"; // Process data we inserted tempDevice.current_temperature = structure.weather.current_temperature; @@ -2296,8 +2408,8 @@ class NestSystem { tempDevice.forecast = structure.weather.forecast; // Insert any extra options we've read in from configuration file for this device - tempDevice.EveApp = config.EveApp; // Global config option for EveHome App integration. Gets overriden below for weather - config.extraOptions[serial_number] && Object.entries(config.extraOptions[serial_number]).forEach(([key, value]) => { + tempDevice.EveApp = config.deviceOptions.Global.EveApp; // Global config option for EveHome App integration. Gets overridden below for weather + config.deviceOptions[serial_number] && Object.entries(config.deviceOptions[serial_number]).forEach(([key, value]) => { tempDevice[key] = value; }); @@ -2308,219 +2420,224 @@ class NestSystem { } async subscribe() { + const SUBSCRIBEKEYS = ["structure", "where", "safety", "device", "shared", "track", "link", "rcs_settings", "schedule", "kryptonite", "topaz", "widget_track", "quartz"]; + var subscribeAgainTimeout = 500; // 500ms default before we subscribe again - // Build subscripton object for data we want to track - var subscribeData = {objects: []}; - var requiredObjects = ["structure", "where", "safety", "device", "shared", "track", "link", "rcs_settings", "schedule", "kryptonite", "topaz", "widget_track", "quartz"]; - Object.entries(this.rawData).forEach(([mainKey, subKey]) => { - if (requiredObjects.includes(mainKey) == true) { - Object.entries(this.rawData[mainKey]).forEach(([subKey]) => { - subscribeData.objects.push({"object_key" : mainKey + "." + subKey, "object_revision" : this.rawData[mainKey][subKey]["$version"], "object_timestamp": this.rawData[mainKey][subKey]["$timestamp"]}); + // Build subscription object for data we want to track + var subscriptionData = {objects: []}; + Object.entries(this.rawData).filter(([filterKey]) => SUBSCRIBEKEYS.includes(filterKey)).forEach(([mainKey]) => { + Object.entries(this.rawData[mainKey]).forEach(([subKey]) => { + subscriptionData.objects.push({"object_key" : mainKey + "." + subKey, "object_revision" : this.rawData[mainKey][subKey]["$version"], "object_timestamp": this.rawData[mainKey][subKey]["$timestamp"]}); + + if (mainKey == "quartz" && this.subscribePollingTimers.findIndex( ({ nestDevice, type }) => (nestDevice === mainKey + "." + subKey && type === "alerts")) == -1) { + // Need todo something special for cameras to get alerts and zone changes + // We'll setup polling loop here if not already running + var tempTimer = setInterval(() => { + // Do doorbell/camera alerts + this.rawData.quartz[subKey] && axios.get(this.rawData.quartz[subKey].nexus_api_nest_domain_host + "/cuepoint/" + subKey + "/2?start_time=" + Math.floor((Date.now() / 1000) - 30), {headers: {"user-agent": USERAGENT, "Referer" : REFERER, [this.cameraAPI.key] : this.cameraAPI.value + this.cameraAPI.token}, responseType: "json", timeout: CAMERAALERTPOLLING, retry: 3, retryDelay: 1000}) + .then((response) => { + if (typeof response.status != "number" || response.status != 200) { + throw new Error("Nest Camera API HTTP get failed with error"); + } - if (mainKey == "quartz") { - // Need todo something special for cameras to get alerts and zone changes - // We'll setup polling loop here if not already running - if (this.subscribePollingTimers.findIndex( ({ nestDevice, type }) => (nestDevice === mainKey + "." + subKey && type === "alerts")) == -1) { - var tempTimer = setInterval(() => { - // Do doorbell/camera alerts - this.rawData.quartz[subKey] && axios.get(this.rawData.quartz[subKey].nexus_api_nest_domain_host + "/cuepoint/" + subKey + "/2?start_time=" + Math.floor((Date.now() / 1000) - 30), {headers: {"user-agent": USERAGENT, "Referer" : REFERER, [this.cameraAPI.key] : this.cameraAPI.value + this.cameraAPI.token}, responseType: "json", timeout: CAMERAALERTPOLLING, retry: 3, retryDelay: 1000}) - .then((response) => { - if (response.status == 200) { - // Filter out any alerts which occured before we started this accessory - response.data = response.data.filter(alert => (Math.floor(alert.start_time / 1000) >= this.startTime)); - - // Fix up alert zone id's - // Appears if no Nest Aware subscription, the zone_id in the associated alert is left blank - // We'll assign the alert zone id to '0' ie: main zone in this case - response.data.forEach(alert => { - if (alert.zone_ids.length == 0) { - alert.zone_ids = [0]; // Default zone ID ie: main zone - } - }); + // Filter out any alerts which occured before we started this accessory + response.data = response.data.filter(alert => (Math.floor(alert.start_time / 1000) >= this.startTime)); + + // Fix up alert zone id's + // Appears if no Nest Aware subscription, the zone_id in the associated alert is left blank + // We'll assign the alert zone id to '0' ie: main zone in this case + response.data.forEach((alert) => { + if (alert.zone_ids.length == 0) { + alert.zone_ids = [0]; // Default zone ID ie: main zone + } + }); + + // Insert alerts into the Nest structure, then notify device + this.rawData.quartz[subKey].alerts = response.data; - // Insert alerts into the Nest structure, then notify device - this.rawData.quartz[subKey].alerts = response.data; - - this.events.emit("quartz." + subKey, DEVICEEVENT.UPDATE, {alerts: this.rawData.quartz[subKey].alerts}); - } - }) - .catch(error => { - }); - }, CAMERAALERTPOLLING); - this.subscribePollingTimers.push({nestDevice: "quartz." + subKey, type: "alerts", timer: tempTimer}); - } + this.eventEmitter.emit("quartz." + subKey, HomeKitDevice.UPDATE, {alerts: this.rawData.quartz[subKey].alerts}); + }) + .catch((error) => { + }); + }, CAMERAALERTPOLLING); + this.subscribePollingTimers.push({nestDevice: "quartz." + subKey, type: "alerts", timer: tempTimer}); + } - if (this.subscribePollingTimers.findIndex( ({ nestDevice, type }) => (nestDevice === mainKey + "." + subKey && type === "zones")) == -1) { - var tempTimer = setInterval(() => { - // Do doorbell/camera zones - this.rawData.quartz[subKey] && axios.get(this.rawData.quartz[subKey].nexus_api_nest_domain_host + "/cuepoint_category/" + subKey, {headers: {"user-agent": USERAGENT, "Referer" : REFERER, [this.cameraAPI.key] : this.cameraAPI.value + this.cameraAPI.token}, responseType: "json", timeout: CAMERAZONEPOLLING, retry: 3, retryDelay: 1000}) - .then((response) => { - if (response.status == 200) { - var tempZones = []; - response.data.forEach(zone => { - if (zone.hidden == false && (zone.type.toUpperCase() == "ACTIVITY" || zone.type.toUpperCase() == "REGION")) { - tempZones.push({"id": zone.id, "name" : this.#makeValidHomeKitName(zone.label), "hidden" : zone.hidden, "uri" : zone.nexusapi_image_uri}); - } - }); - - // Insert activity zones into the Nest structure, then notify device - this.rawData.quartz[subKey].activity_zones = tempZones; - - this.events.emit("quartz." + subKey, DEVICEEVENT.UPDATE, {activity_zones: this.rawData.quartz[subKey].activity_zones}); - } - }) - .catch(error => { - }); - }, CAMERAZONEPOLLING); - this.subscribePollingTimers.push({nestDevice: "quartz." + subKey, type: "zones", timer: tempTimer}); - } - } + if (mainKey == "quartz" && this.subscribePollingTimers.findIndex( ({ nestDevice, type }) => (nestDevice === mainKey + "." + subKey && type === "zones")) == -1) { + var tempTimer = setInterval(() => { + // Do doorbell/camera zones + this.rawData.quartz[subKey] && axios.get(this.rawData.quartz[subKey].nexus_api_nest_domain_host + "/cuepoint_category/" + subKey, {headers: {"user-agent": USERAGENT, "Referer" : REFERER, [this.cameraAPI.key] : this.cameraAPI.value + this.cameraAPI.token}, responseType: "json", timeout: CAMERAZONEPOLLING, retry: 3, retryDelay: 1000}) + .then((response) => { + if (typeof response.status != "number" || response.status != 200) { + throw new Error("Nest Camera API HTTP get failed with error"); + } - if (mainKey == "structure") { - if (this.subscribePollingTimers.findIndex( ({ nestDevice, type }) => (nestDevice === mainKey + "." + subKey && type === "weather")) == -1) { - var tempTimer = setInterval(() => { - if (typeof this.rawData.structure[subKey].weather != "object") this.rawData.structure[subKey].weather = {}; // Weather data will be here - axios.get(this.weather_url + this.rawData.structure[subKey].latitude + "," + this.rawData.structure[subKey].longitude, {headers: {"user-agent": USERAGENT, timeout: 10000}}) - .then(response => { - if (response.status == 200) { - this.rawData.structure[subKey].weather.current_temperature = response.data[this.rawData.structure[subKey].latitude + "," + this.rawData.structure[subKey].longitude].current.temp_c; - this.rawData.structure[subKey].weather.current_humidity = response.data[this.rawData.structure[subKey].latitude + "," + this.rawData.structure[subKey].longitude].current.humidity; - this.rawData.structure[subKey].weather.condition = response.data[this.rawData.structure[subKey].latitude + "," + this.rawData.structure[subKey].longitude].current.condition; - this.rawData.structure[subKey].weather.wind_direction = response.data[this.rawData.structure[subKey].latitude + "," + this.rawData.structure[subKey].longitude].current.wind_dir; - this.rawData.structure[subKey].weather.wind_speed = (response.data[this.rawData.structure[subKey].latitude + "," + this.rawData.structure[subKey].longitude].current.wind_mph * 1.609344); // convert to km/h - this.rawData.structure[subKey].weather.sunrise = response.data[this.rawData.structure[subKey].latitude + "," + this.rawData.structure[subKey].longitude].current.sunrise; - this.rawData.structure[subKey].weather.sunset = response.data[this.rawData.structure[subKey].latitude + "," + this.rawData.structure[subKey].longitude].current.sunset; - this.rawData.structure[subKey].weather.station = response.data[this.rawData.structure[subKey].latitude + "," + this.rawData.structure[subKey].longitude].location.short_name; - this.rawData.structure[subKey].weather.forecast = response.data[this.rawData.structure[subKey].latitude + "," + this.rawData.structure[subKey].longitude].forecast.daily[0].condition; - - this.events.emit("structure." + subKey, DEVICEEVENT.UPDATE, {weather: this.rawData.structure[subKey].weather}); - } - }) - .catch(error => { - }); - }, WEATHERPOLLING); - this.subscribePollingTimers.push({nestDevice: "structure." + subKey, type: "weather", timer: tempTimer}); - } - } - }); - } + var tempZones = []; + response.data.forEach((zone) => { + if (zone.hidden == false && (zone.type.toUpperCase() == "ACTIVITY" || zone.type.toUpperCase() == "REGION")) { + tempZones.push({"id": zone.id, "name" : this.#validateHomeKitName(zone.label), "hidden" : zone.hidden, "uri" : zone.nexusapi_image_uri}); + } + }); + + // Insert activity zones into the Nest structure, then notify device + this.rawData.quartz[subKey].activity_zones = tempZones; + + this.eventEmitter.emit("quartz." + subKey, HomeKitDevice.UPDATE, {activity_zones: this.rawData.quartz[subKey].activity_zones}); + }) + .catch((error) => { + }); + }, CAMERAZONEPOLLING); + this.subscribePollingTimers.push({nestDevice: "quartz." + subKey, type: "zones", timer: tempTimer}); + } + + if (mainKey == "structure" && this.subscribePollingTimers.findIndex( ({ nestDevice, type }) => (nestDevice === mainKey + "." + subKey && type === "weather")) == -1) { + var tempTimer = setInterval(() => { + if (typeof this.rawData.structure[subKey].weather != "object") this.rawData.structure[subKey].weather = {}; // Weather data will be here + axios.get(this.weather_url + this.rawData.structure[subKey].latitude + "," + this.rawData.structure[subKey].longitude, {headers: {"user-agent": USERAGENT, timeout: 10000}}) + .then((response) => { + if (typeof response.status != "number" || response.status != 200) { + throw new Error("Nest Weather API HTTP get failed with error"); + } + + this.rawData.structure[subKey].weather.current_temperature = response.data[this.rawData.structure[subKey].latitude + "," + this.rawData.structure[subKey].longitude].current.temp_c; + this.rawData.structure[subKey].weather.current_humidity = response.data[this.rawData.structure[subKey].latitude + "," + this.rawData.structure[subKey].longitude].current.humidity; + this.rawData.structure[subKey].weather.condition = response.data[this.rawData.structure[subKey].latitude + "," + this.rawData.structure[subKey].longitude].current.condition; + this.rawData.structure[subKey].weather.wind_direction = response.data[this.rawData.structure[subKey].latitude + "," + this.rawData.structure[subKey].longitude].current.wind_dir; + this.rawData.structure[subKey].weather.wind_speed = (response.data[this.rawData.structure[subKey].latitude + "," + this.rawData.structure[subKey].longitude].current.wind_mph * 1.609344); // convert to km/h + this.rawData.structure[subKey].weather.sunrise = response.data[this.rawData.structure[subKey].latitude + "," + this.rawData.structure[subKey].longitude].current.sunrise; + this.rawData.structure[subKey].weather.sunset = response.data[this.rawData.structure[subKey].latitude + "," + this.rawData.structure[subKey].longitude].current.sunset; + this.rawData.structure[subKey].weather.station = response.data[this.rawData.structure[subKey].latitude + "," + this.rawData.structure[subKey].longitude].location.short_name; + this.rawData.structure[subKey].weather.forecast = response.data[this.rawData.structure[subKey].latitude + "," + this.rawData.structure[subKey].longitude].forecast.daily[0].condition; + + this.eventEmitter.emit("structure." + subKey, HomeKitDevice.UPDATE, {weather: this.rawData.structure[subKey].weather}); + }) + .catch((error) => { + }); + }, WEATHERPOLLING); + this.subscribePollingTimers.push({nestDevice: "structure." + subKey, type: "weather", timer: tempTimer}); + } + }); }); - // Do subscription for the data we need from the Nest structure.. Timeout after 2mins if no data received, and if timed-out, rinse and repeat :-) + + // Do subscription for the data we need from the Nest structure. + // We set a Timeout after a certain period of no data received + // If timed-out, rinse and repeat :-) var addRemoveDevices = []; axios({ method: "post", url: this.transport_url + "/v6/subscribe", - data: JSON.stringify(subscribeData), + data: JSON.stringify(subscriptionData), headers: {"user-agent": USERAGENT, "Authorization": "Basic " + this.nestAPIToken}, responseType: "json", timeout: SUBSCRIBETIMEOUT, signal: this.abortController.signal }) .then(async (response) => { - if (response.status && response.status == 200) { - // Got subscribed update, so merge and process them - response.data.objects && await Promise.all(response.data.objects.map(async (updatedData) => { - var mainKey = updatedData.object_key.split(".")[0]; - var subKey = updatedData.object_key.split(".")[1]; - - // See if we have a structure change and the "swarm" property list has changed, seems to indicated a new or removed device(s) - if (mainKey == "structure" && updatedData.value.swarm && this.rawData[mainKey][subKey].swarm.toString() !== updatedData.value.swarm.toString()) { - var oldDeviceList = this.rawData[mainKey][subKey].swarm.toString().split(",").map(String); - var newDeviceList = updatedData.value.swarm.toString().split(",").map(String); - for (var index in oldDeviceList) { - if (newDeviceList.includes(oldDeviceList[index]) == false && oldDeviceList[index] != "") { - addRemoveDevices.push({"nestDevice": oldDeviceList[index], "action" : "remove"}); // Removed device - } - } - for (index in newDeviceList) { - if (oldDeviceList.includes(newDeviceList[index]) == false && newDeviceList[index] != "") { - addRemoveDevices.push({"nestDevice": newDeviceList[index], "action" : "add"}); // Added device - } + if (typeof response.status != "number" || response.status != 200) { + throw new Error("Nest API HTTP subscribe failed with error"); + } + + // Got subscribed update data, so merge and process them + response.data.objects && await Promise.all(response.data.objects.map(async (updatedData) => { + var mainKey = updatedData.object_key.split(".")[0]; + var subKey = updatedData.object_key.split(".")[1]; + + // See if we have a structure change and the "swarm" property list has changed, seems to indicated a new or removed device(s) + if (mainKey == "structure" && updatedData.value.swarm && this.rawData[mainKey][subKey].swarm.toString() !== updatedData.value.swarm.toString()) { + var oldDeviceList = this.rawData[mainKey][subKey].swarm.toString().split(",").map(String); + var newDeviceList = updatedData.value.swarm.toString().split(",").map(String); + for (var index in oldDeviceList) { + if (newDeviceList.includes(oldDeviceList[index]) == false && oldDeviceList[index] != "") { + addRemoveDevices.push({"nestDevice": oldDeviceList[index], "action" : "remove"}); // Removed device } - addRemoveDevices = addRemoveDevices.sort((a, b) => a - b); // filter out duplicates - } else { - // Update internally saved Nest structure for the remaining changed key/value pairs - for (const [fieldKey, fieldValue] of Object.entries(updatedData.value)) { - this.rawData[mainKey][subKey][fieldKey] = fieldValue; + } + for (index in newDeviceList) { + if (oldDeviceList.includes(newDeviceList[index]) == false && newDeviceList[index] != "") { + addRemoveDevices.push({"nestDevice": newDeviceList[index], "action" : "add"}); // Added device } + } + addRemoveDevices = addRemoveDevices.sort((a, b) => a - b); // filter out duplicates + } else { + // Update internally saved Nest structure for the remaining changed key/value pairs + for (const [fieldKey, fieldValue] of Object.entries(updatedData.value)) { + this.rawData[mainKey][subKey][fieldKey] = fieldValue; + } - if (mainKey == "quartz") { - // We've had a "quartz" structure change, so need to update doorbell/camera properties - await axios.get(CAMERAAPIHOST + "/api/cameras.get_with_properties?uuid=" + subKey, {headers: {"user-agent": USERAGENT, "Referer" : REFERER, [this.cameraAPI.key] : this.cameraAPI.value + this.cameraAPI.token}, responseType: "json", timeout: NESTAPITIMEOUT}) - .then((response) => { - if (response.status && response.status == 200) { - this.rawData[mainKey][subKey].properties = response.data.items[0].properties; - } - }) - .catch(error => { - }); - } + if (mainKey == "quartz") { + // We've had a "quartz" structure change, so need to update doorbell/camera properties + await axios.get(CAMERAAPIHOST + "/api/cameras.get_with_properties?uuid=" + subKey, {headers: {"user-agent": USERAGENT, "Referer" : REFERER, [this.cameraAPI.key] : this.cameraAPI.value + this.cameraAPI.token}, responseType: "json", timeout: NESTAPITIMEOUT}) + .then((response) => { + if (typeof response.status != "number" || response.status != 200) { + throw new Error("Nest Camera API HTTP get failed with error"); + } - // Update verion and timestamp of this structure element for future subscribe calls - this.rawData[mainKey][subKey]["$version"] = updatedData.object_revision; - this.rawData[mainKey][subKey]["$timestamp"] = updatedData.object_timestamp; + this.rawData[mainKey][subKey].properties = response.data.items[0].properties; + }) + .catch((error) => { + }); } - })); - - if (addRemoveDevices.length > 0) { - // Change in devices via an addition or removal, so get current Nest structure data before we process any device changes - await this.getData(); + + // Update verion and timestamp of this structure element for future subscribe calls + this.rawData[mainKey][subKey]["$version"] = updatedData.object_revision; + this.rawData[mainKey][subKey]["$timestamp"] = updatedData.object_timestamp; } + })); + + if (addRemoveDevices.length > 0) { + // Change in devices via an addition or removal, so get current Nest structure data before we process any device changes + await this.getData(); + } - // Process any device updates and additions here - Object.entries(this.processData()).forEach(([deviceID, deviceData]) => { - var addRemoveIndex = addRemoveDevices.findIndex( ({ nestDevice }) => nestDevice === deviceData.nest_device_structure) - if (addRemoveIndex == -1) { - // Send current data to the HomeKit accessory for processing - // The accessory will determine if data has changed compared to what it has stored - this.events.emit(deviceData.nest_device_structure, DEVICEEVENT.UPDATE, deviceData); - } else if (addRemoveIndex != -1 && addRemoveDevices[addRemoveIndex].action == "add") { - // Device addition to process - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Detected additional Nest device"); - this.events.emit(SYSTEMEVENT.NEW, deviceData); // new device, so process addition to HomeKit - } - }); + // Process any device updates and additions here + Object.entries(this.processData()).forEach(([deviceID, deviceData]) => { + var addRemoveIndex = addRemoveDevices.findIndex( ({ nestDevice }) => nestDevice === deviceData.device_uuid) + if (addRemoveIndex == -1) { + // Send current data to the HomeKit accessory for processing + // The accessory will determine if data has changed compared to what it has stored + this.eventEmitter.emit(deviceData.device_uuid, HomeKitDevice.UPDATE, deviceData); + } else if (addRemoveIndex != -1 && addRemoveDevices[addRemoveIndex].action == "add") { + // Device addition to process + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Detected additional Nest device"); + this.eventEmitter.emit(SystemEvent.ADD, deviceData); // new device, so process addition to HomeKit + } + }); - // Process any device removals here - addRemoveDevices.forEach(device => { - if (device.action == "remove") { - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Detected removal of Nest device"); + // Process any device removals here + addRemoveDevices.forEach((device) => { + if (device.action == "remove") { + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Detected removal of Nest device"); - // Remove any polling timers that might have been associated with this device - this.subscribePollingTimers.forEach(pollingTimer => { - if (pollingTimer.nestDevice == device.nestDevice) { - clearInterval(pollingTimer.timer) - } - }); + // Remove any polling timers that might have been associated with this device + this.subscribePollingTimers.forEach((pollingTimer) => { + if (pollingTimer.nestDevice == device.nestDevice) { + clearInterval(pollingTimer.timer) + } + }); - this.events.emit(device.nestDevice, DEVICEEVENT.REMOVE, {}); // this will handle removal without knowing previous data for device - } - }); - } - else { - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Nest subscription failed. HTTP status returned", response.status); - } + this.eventEmitter.emit(device.nestDevice, HomeKitDevice.REMOVE, {}); // this will handle removal without knowing previous data for device + } + }); }) .catch((error) => { if (axios.isCancel(error) == false && error.code !== "ECONNABORTED" && error.code !== "ETIMEDOUT") { if (error.response && error.response.status == 404) { // URL not found subscribeAgainTimeout = 5000; // Since bad URL, try again after 5 seconds - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Nest subscription failed. URL not found"); + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Nest subscription failed. URL not found"); } else if (error.response && error.response.status == 400) { // bad subscribe subscribeAgainTimeout = 5000; // Since bad subscribe, try again after 5 seconds - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Nest subscription failed. Bad subscription data"); + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Nest subscription failed. Bad subscription data"); } else if (error.response && error.response.status == 502) { // gateway error subscribeAgainTimeout = 10000; // Since bad gateway error, try again after 10 seconds - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Nest subscription failed. Bad gateway"); + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Nest subscription failed. Bad gateway"); } else { // Other unknown error subscribeAgainTimeout = 5000; // Try again afer 5 seconds - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Nest subscription failed with error", error); + config.debug.includes(Debugging.NEST) && outputLogging(ACCESSORYNAME, true, "Nest subscription failed with error"); } } }) @@ -2530,57 +2647,90 @@ class NestSystem { }); } - async #set(nestStructure, keyvalues) { - var retValue = false; - if (this.nestAPIToken != "" && this.transport_url != "") { - await Promise.all(Object.entries(keyvalues).map(async ([structureKey, structureValues]) => { - if (structureKey.split(".")[0].toUpperCase() == "QUARTZ") { - // request is to set a doorbell/camera property. Handle here - await Promise.all(Object.entries(structureValues).map(async ([key, value]) => { - await axios.post(CAMERAAPIHOST + "/api/dropcams.set_properties", [key] + "=" + value + "&uuid=" + structureKey.split(".")[1], {headers: {"content-type": "application/x-www-form-urlencoded", "user-agent": USERAGENT, "Referer" : REFERER, [this.cameraAPI.key] : this.cameraAPI.value + this.cameraAPI.token}, responseType: "json", timeout: NESTAPITIMEOUT}) - .then((response) => { - if (response.status == 200 && response.data.status == 0) { - retValue = true; // successfully set Nest camera value - } - }) - .catch(error => { - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Failed to set Nest Camera element with error", error.message); - }); - })); - } - if (structureKey.split(".")[0].toUpperCase() != "QUARTZ") { - // request is to set a Nest device structure element. Handle here - var put = {objects: []}; - Object.entries(structureValues).forEach(([key, value]) => { - put.objects.push({"object_key" : structureKey, "op" : "MERGE", "value": {[key]: value}}); - }); - await axios.post(this.transport_url + "/v5/put", JSON.stringify(put), {headers: {"user-agent": USERAGENT, "Authorization": "Basic " + this.nestAPIToken} }) - .then(response => { - if (response.status == 200) { - retValue = true; // successfully set Nest structure value + async #set(deviceUUID, keyValues) { + if (typeof deviceUUID != "string" || typeof keyValues != "object" || typeof this.nestAPIToken != "string" || typeof this.transport_url != "string" || + deviceUUID == "" || this.nestAPIToken == "" || this.transport_url == "") { + return; + } + + await Promise.all(Object.entries(keyValues).map(async ([nestStuctureKey, nestStructureValues]) => { + if (nestStuctureKey == "quartz") { + // request is to set a doorbell/camera property. Handle here + await Promise.all(Object.entries(nestStructureValues).map(async ([key, value]) => { + await axios.post(CAMERAAPIHOST + "/api/dropcams.set_properties", [key] + "=" + value + "&uuid=" + deviceUUID.split(".")[1], {headers: {"content-type": "application/x-www-form-urlencoded", "user-agent": USERAGENT, "Referer" : REFERER, [this.cameraAPI.key] : this.cameraAPI.value + this.cameraAPI.token}, responseType: "json"}) + .then((response) => { + if (typeof response.status != "number" || response.status != 200 || typeof response.data.status != "number" || response.data.status != 0) { + throw new Error("Nest Camera API HTTP post failed with error"); } }) - .catch(error => { - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Failed to set Nest structure element with error", error.message); - }); - } - })); - } - return retValue; + .catch((error) => { + }); + })); + } + + if (nestStuctureKey != "quartz") { + // request is to set a Nest device structure element. Handle here + var put = {objects: []}; + Object.entries(nestStructureValues).forEach(([key, value]) => { + if (nestStuctureKey == "structure" && typeof this.rawData.structure[this.rawData.link[deviceUUID.split(".")[1]].structure.split(".")[1]] == "object") { + put.objects.push({"object_key" : this.rawData.link[deviceUUID.split(".")[1]].structure, "op" : "MERGE", "value": {[key]: value}}); + } + if (nestStuctureKey != "structure") { + put.objects.push({"object_key" : nestStuctureKey + "." + deviceUUID.split(".")[1], "op" : "MERGE", "value": {[key]: value}}); + } + }); + await axios.post(this.transport_url + "/v5/put", JSON.stringify(put), {headers: {"user-agent": USERAGENT, "Authorization": "Basic " + this.nestAPIToken} }) + .then((response) => { + if (typeof response.status != "number" || response.status != 200) { + throw new Error("Nest API HTTP post failed with error"); + } + }) + .catch((error) => { + }); + } + })); } - async #get() { - // <- To Implement + async #get(deviceUUID) { + // <---- To Implement + } + + #adjustTemperature(temperature, currentTemperatureUnit, targetTemperatureUnit, round) { + // Converts temperatures between C/F and vice-versa. + // Also rounds temperatures to 0.5 increments for C and 1.0 for F + if (targetTemperatureUnit == "C" || targetTemperatureUnit == "c" || targetTemperatureUnit == HAP.Characteristic.TemperatureDisplayUnits.CELSIUS) { + if (currentTemperatureUnit == "F" || currentTemperatureUnit == "f" || currentTemperatureUnit == HAP.Characteristic.TemperatureDisplayUnits.FAHRENHEIT) { + // convert from F to C + temperature = (temperature * 9 / 5) + 32; + } + if (round == true) { + // round to nearest 0.5C + temperature = Math.round(temperature * 2) / 2; + } + } + + if (targetTemperatureUnit == "F" || targetTemperatureUnit == "f" || targetTemperatureUnit == HAP.Characteristic.TemperatureDisplayUnits.FAHRENHEIT) { + if (currentTemperatureUnit == "C" || currentTemperatureUnit == "c" || currentTemperatureUnit == HAP.Characteristic.TemperatureDisplayUnits.CELSIUS) { + // convert from C to F + temperature = (temperature - 32) * 5 / 9; + } + if (round == true) { + // round to nearest 1F + temperature = Math.round(temperature); + } + } + + return temperature; } - #makeValidHomeKitName(name) { - // Strip invalid characters to conform to HomeKit requirements - // Ensure only letters or numbers at beginning/end of string - return name.replace(/[^A-Za-z0-9 ,.-]/g, "").replace(/^[^a-zA-Z0-9]*/g, "").replace(/[^a-zA-Z0-9]+$/g, ""); + #validateHomeKitName(nameToMakeValid) { + // Strip invalid characters to meet HomeKit naming requirements + // Ensure only letters or numbers are at the beginning AND/OR end of string + return nameToMakeValid.replace(/[^A-Za-z0-9 ,.-]/g, "").replace(/^[^a-zA-Z0-9]*/g, "").replace(/[^a-zA-Z0-9]+$/g, ""); } - #crc24(value) { - var hashTable = [ + #crc24(valueToHash) { + var crc24HashTable = [ 0x000000, 0x864cfb, 0x8ad50d, 0x0c99f6, 0x93e6e1, 0x15aa1a, 0x1933ec, 0x9f7f17, 0xa18139, 0x27cdc2, 0x2b5434, 0xad18cf, 0x3267d8, 0xb42b23, 0xb8b2d5, 0x3efe2e, 0xc54e89, 0x430272, 0x4f9b84, 0xc9d77f, 0x56a868, 0xd0e493, 0xdc7d65, 0x5a319e, @@ -2614,35 +2764,12 @@ class NestSystem { 0xe37b16, 0x6537ed, 0x69ae1b, 0xefe2e0, 0x709df7, 0xf6d10c, 0xfa48fa, 0x7c0401, 0x42fa2f, 0xc4b6d4, 0xc82f22, 0x4e63d9, 0xd11cce, 0x575035, 0x5bc9c3, 0xdd8538 ] - var crc = 0xb704ce; // init crc24 hash; - var buffer = Buffer.from(value); // convert value into buffer for processing - for (var index = 0; index < value.length; index++) { - crc = (hashTable[((crc >> 16) ^ buffer[index]) & 0xff] ^ (crc << 8)) & 0xffffff; - } - return crc.toString(16); // return crc24 as hex string - } - - #adjustTemperature(temp_in, unit_in, unit_out) { - // Converts temperatures between C/F and vice-versa. - // Also rounds temperatures to 0.5 increments for C and 1.0 for F - var adjustedTemperature = temp_in; - - if (unit_in != unit_out) { - if ((unit_in == "C" || unit_in == "c" || unit_in == Characteristic.TemperatureDisplayUnits.CELSIUS) && (unit_out == "F" || unit_out == "f" || unit_out == Characteristic.TemperatureDisplayUnits.FAHRENHEIT)) { - // convert from C to F - adjustedTemperature = (temp_in * 9 / 5) + 32; - } - - if ((unit_in == "F" || unit_in == "f" || unit_in == Characteristic.TemperatureDisplayUnits.FAHRENHEIT) && (unit_out == "C" || unit_out == "c" || unit_out == Characteristic.TemperatureDisplayUnits.CELSIUS)) { - // convert from F to C - adjustedTemperature = (temp_in - 32) * 5 / 9 - } + var crc24 = 0xb704ce; // init crc24 hash; + valueToHash = Buffer.from(valueToHash); // convert value into buffer for processing + for (var index = 0; index < valueToHash.length; index++) { + crc24 = (crc24HashTable[((crc24 >> 16) ^ valueToHash[index]) & 0xff] ^ (crc24 << 8)) & 0xffffff; } - - if (unit_out == "C" || unit_out == "c" || unit_out == Characteristic.TemperatureDisplayUnits.CELSIUS) adjustedTemperature = Math.round(adjustedTemperature * 2) / 2; // round to neartest 0.5 - if (unit_out == "F" || unit_out == "f" || unit_out == Characteristic.TemperatureDisplayUnits.FAHRENHEIT) adjustedTemperature = Math.round(adjustedTemperature); // round to neartest 1 - - return adjustedTemperature; + return crc24.toString(16); // return crc24 as hex string } } @@ -2652,7 +2779,7 @@ class NestSystem { // Handles system configuration file const CONFIGURATIONFILE = "Nest_config.json"; // Default configuration file name, located in current directory -// Available debugging ouput options +// Available debugging output options const Debugging = { NEST : "nest", NEXUS : "nexus", @@ -2664,279 +2791,238 @@ const Debugging = { } class Configuration { - constructor(configFile) { + constructor(configurationFile) { this.loaded = false; // Have we loaded a configuration + this.configurationFile = ""; // Saved configuration file path/name once loaded this.debug = ""; // Enable debug output, off by default this.token = ""; // Token to access Nest system. Can be either a session token or google cookie token this.tokenType = ""; // Type of token we're using, either be "nest" or "google" this.weather = false; // Create a virtual weather station using Nest weather data - this.HKSV = false; // Enable HKSV for all camera/doorbells, no by default - this.HKSVPreBuffer = 15000; // Milliseconds seconds to hold in buffer. default is 15secs. using 0 disables pre-buffer - this.doorbellCooldown = 60000; // Default cooldown period for doorbell button press (1min/60secs) - this.motionCooldown = 60000; // Default cooldown period for motion detected (1min/60secs) - this.personCooldown = 120000; // Default cooldown person for person detected (2mins/120secs) - this.H264EncoderRecord = VideoCodecs.LIBX264; // Default H264 Encoder for HKSV recording - this.H264EncoderLive = VideoCodecs.COPY; // Default H264 Encoder for HomeKit/HKSV live video - this.mDNS = MDNSAdvertiser.BONJOUR; // Default mDNS advertiser for HAP-NodeJS library - this.EveApp = true; // Intergration with evehome app - this.excludedDevices = []; // Array of excluded devices (by serial number) - this.extraOptions = {}; // Extra options per device to inject into Nest data structure + this.mDNS = HAP.MDNSAdvertiser.BONJOUR; // Default mDNS advertiser for HAP-NodeJS library + this.deviceOptions = {}; // Configuration options per device. Key of // Load configuration - if (fs.existsSync(configFile) == true) { - try { - var config = JSON.parse(fs.readFileSync(configFile)); - this.loaded = true; // Loaded - } catch (error) { - // Error loading JSON, means config invalid - console.log("Error in JSON file '%s'", configFile); + if (configurationFile == "" || fs.existsSync(configurationFile) == false) { + return; + } + + try { + var config = JSON.parse(fs.readFileSync(configurationFile)); + this.loaded = true; // Loaded + this.configurationFile = configurationFile; // Save the name + + // Global default options for all devices + this.deviceOptions.Global = {}; + this.deviceOptions.Global.HKSV = false; // Enable HKSV for all camera/doorbells. HKSV is disabled by default + this.deviceOptions.Global.HKSVPreBuffer = 15000; // Milliseconds seconds to hold in buffer. default is 15secs. using 0 disables pre-buffer + this.deviceOptions.Global.DoorbellCooldown = 60000; // Default cooldown period for doorbell button press (1min/60secs) + this.deviceOptions.Global.MotionCooldown = 60000; // Default cooldown period for motion detected (1min/60secs) + this.deviceOptions.Global.PersonCooldown = 120000; // Default cooldown person for person detected (2mins/120secs) + this.deviceOptions.Global.H264EncoderRecord = VideoCodecs.LIBX264; // Default H264 Encoder for HKSV recording + this.deviceOptions.Global.H264EncoderLive = VideoCodecs.COPY; // Default H264 Encoder for HomeKit/HKSV live video + this.deviceOptions.Global.EveApp = true; // Integration with evehome app + this.deviceOptions.Global.Exclude = false; // By default, we don't exclude all devices + + if (config.hasOwnProperty("SessionToken") == true && typeof config.SessionToken == "string") { + this.tokenType = "nest"; + this.token = config.SessionToken; // Nest accounts Session token to use for Nest API calls } + if (config.hasOwnProperty("GoogleToken") == true && typeof config.GoogleToken == "object" && + config.GoogleToken.hasOwnProperty("issuetoken") == true && config.GoogleToken.hasOwnProperty("cookie") == true) { - config && Object.entries(config).forEach(([key, value]) => { - // Process configuration items - key = key.toUpperCase(); // Make key uppercase. Saves doing every time below - if (key == "SESSIONTOKEN" && typeof value == "string") { - this.token = value; // Nest accounts Session token to use for Nest calls - this.tokenType = "nest"; - } - if (key == "REFRESHTOKEN" && typeof value == "string") { - // NO LONGER SUPPORTED BY GOOGLE - console.log("Google account access via refreshToken method is no longer supported by Google. Please use the Google Cookie method"); - } - if (key == "GOOGLETOKEN" && typeof value == "object") { - this.tokenType = "google"; - this.token = {}; // Google cookies token to use for Nest calls - Object.entries(value).forEach(([key, value]) => { - if (key.toUpperCase() == "ISSUETOKEN") this.token["issuetoken"] = value; - if (key.toUpperCase() == "COOKIE") this.token["cookie"] = value; - }); - if (this.token.hasOwnProperty("issuetoken") == false || this.token.hasOwnProperty("cookie") == false) { - this.token = ""; // Not a valid Google cookie token - this.tokenType = ""; - } - } - if (key == "WEATHER" && typeof value == "boolean") this.weather = value; // Virtual weather station - if (key == "DEBUG" && typeof value == "boolean" && value == true) this.debug = "nest,nexus,hksv"; // Debugging output will Nest, HKSV and NEXUS - if (key == "DEBUG" && typeof value == "string") { - // Comma delimited string for what we output in debugging - // nest, hksv, ffmpeg, nexus, external are valid options in the string - if (value.toUpperCase().includes("NEST") == true) this.debug += Debugging.NEST; - if (value.toUpperCase().includes("NEXUS") == true) this.debug += Debugging.NEXUS; - if (value.toUpperCase().includes("HKSV") == true) this.debug += Debugging.HKSV; - if (value.toUpperCase().includes("FFMPEG") == true) this.debug += Debugging.FFMPEG; - if (value.toUpperCase().includes("EXTERNAL") == true) this.debug += Debugging.EXTERNAL; - if (value.toUpperCase().includes("HISTORY") == true) this.debug += Debugging.HISTORY; - if (value.toUpperCase().includes("WEATHER") == true) this.debug += Debugging.WEATHER; - } - if (key == "HKSV" && typeof value == "boolean") this.HKSV = value; // Global HomeKit Secure Video? - if (key == "MDNS" && typeof value == "string") { - if (value.toUpperCase() == "CIAO") this.mDNS = MDNSAdvertiser.CIAO; // Use ciao as the mDNS advertiser - if (value.toUpperCase() == "BONJOUR") this.mDNS = MDNSAdvertiser.BONJOUR; // Use bonjour as the mDNS advertiser - if (value.toUpperCase() == "AVAHI") this.mDNS = MDNSAdvertiser.AVAHI; // Use avahi as the mDNS advertiser - } - if (key == "H264ENCODER" && typeof value == "string") { - if (value.toUpperCase() == "LIBX264") { - this.H264EncoderRecord = VideoCodecs.LIBX264; // Use libx264, software encoder - this.H264EncoderLive = VideoCodecs.LIBX264; // Use libx264, software encoder - } - if (value.toUpperCase() == "H264_OMX") { - this.H264EncoderRecord = VideoCodecs.H264_OMX; // Use the older RPI hardware h264 encoder - this.H264EncoderLive = VideoCodecs.H264_OMX; // Use the older RPI hardware h264 encoder - } - if (value.toUpperCase() == "COPY") { - this.H264EncoderRecord = VideoCodecs.COPY; // Copy the stream directly - this.H264EncoderLive = VideoCodecs.COPY; // Copy the stream directly - } - } - if (key == "H264RECORDENCODER" && typeof value == "string") { - if (value.toUpperCase() == "LIBX264") this.H264EncoderRecord = VideoCodecs.LIBX264; // Use libx264, software encoder - if (value.toUpperCase() == "H264_OMX") this.H264EncoderRecord = VideoCodecs.H264_OMX; // Use the older RPI hardware h264 encoder - if (value.toUpperCase() == "COPY") this.H264EncoderRecord = VideoCodecs.COPY; // Copy the stream directly - } - if (key == "H264STREAMENCODER" && typeof value == "string") { - if (value.toUpperCase() == "LIBX264") this.H264EncoderLive = VideoCodecs.LIBX264; // Use libx264, software encoder - if (value.toUpperCase() == "H264_OMX") this.H264EncoderLive = VideoCodecs.H264_OMX; // Use the older RPI hardware h264 encoder - if (value.toUpperCase() == "COPY") this.H264EncoderLive = VideoCodecs.COPY; // Copy the stream directly - } - if (key == "HKSVPREBUFFER" && typeof value == "number") { - if (value < 1000) value = value * 1000; // If less than 1000, assume seconds value passed in, so convert to milliseconds - this.HKSVPreBuffer = value; // Global HKSV pre-buffer sizing - } - if (key == "DOORBELLCOOLDOWN" && typeof value == "number") { - if (value < 1000) value = value * 1000; // If less than 1000, assume seconds value passed in, so convert to milliseconds - this.doorbellCooldown = value; // Global doorbell press cooldown time - } - if (key == "EVEAPP" && typeof value == "boolean") this.EveApp = value; // Evehome app integration - if (key == "MOTIONCOOLDOWN" && typeof value == "number") { - if (value < 1000) value = value * 1000; // If less than 1000, assume seconds value passed in, so convert to milliseconds - this.motionCooldown = value; // Global motion detected cooldown time + this.tokenType = "google"; + this.token = {}; + this.token.issuetoken = config.GoogleToken.issuetoken; // Google issue token to use for Nest API calls + this.token.cookie = config.GoogleToken.cookie; // Google cookie to use for Nest API calls + } + if (config.hasOwnProperty("Weather") == true && typeof config.Weather == "boolean") { + this.weather = config.Weather; // Virtual weather station + } + if (config.hasOwnProperty("mDNS") == true && typeof config.mDNS == "string") { + if (config.mDNS.toUpperCase() == "CIAO") this.mDNS = HAP.MDNSAdvertiser.CIAO; // Use ciao as the mDNS advertiser + if (config.mDNS.toUpperCase() == "BONJOUR") this.mDNS = HAP.MDNSAdvertiser.BONJOUR; // Use bonjour as the mDNS advertiser + if (config.mDNS.toUpperCase() == "AVAHI") this.mDNS = HAP.MDNSAdvertiser.AVAHI; // Use avahi as the mDNS advertiser + } + if (config.hasOwnProperty("Debug") == true && typeof config.Debug == "boolean" && config.Debug == true) this.debug = "nest,nexus,hksv"; // Debugging output will Nest, HKSV and NEXUS + if (config.hasOwnProperty("Debug") == true && typeof config.Debug == "string") { + // Comma delimited string for what we output in debugging + // nest, hksv, ffmpeg, nexus, external are valid options in the string + if (config.Debug.toUpperCase().includes("NEST") == true) this.debug += Debugging.NEST; + if (config.Debug.toUpperCase().includes("NEXUS") == true) this.debug += Debugging.NEXUS; + if (config.Debug.toUpperCase().includes("HKSV") == true) this.debug += Debugging.HKSV; + if (config.Debug.toUpperCase().includes("FFMPEG") == true) this.debug += Debugging.FFMPEG; + if (config.Debug.toUpperCase().includes("EXTERNAL") == true) this.debug += Debugging.EXTERNAL; + if (config.Debug.toUpperCase().includes("HISTORY") == true) this.debug += Debugging.HISTORY; + if (config.Debug.toUpperCase().includes("WEATHER") == true) this.debug += Debugging.WEATHER; + } + if (config.hasOwnProperty("HKSV") == true && typeof config.HKSV == "boolean") this.deviceOptions.Global.HKSV = config.HKSV; // Global HomeKit Secure Video + if (config.hasOwnProperty("EveApp") == true && typeof config.EveApp == "boolean") this.deviceOptions.Global.EveApp = config.EveApp; // Global Evehome app integration + if (config.hasOwnProperty("Exclude") == true && typeof config.Exclude == "boolean") this.deviceOptions.Global.Exclude = config.Exclude; // Exclude all devices by default + if (config.hasOwnProperty("HKSVPreBuffer") == true && typeof config.HKSVPreBuffer == "number") { + if (config.HKSVPreBuffer < 1000) config.HKSVPreBuffer = config.HKSVPreBuffer * 1000; // If less than 1000, assume seconds value passed in, so convert to milliseconds + this.deviceOptions.Global.HKSVPreBuffer = config.HKSVPreBuffer; // Global HKSV pre-buffer sizing + } + if (config.hasOwnProperty("DoorbellCooldown") == true && typeof config.DoorbellCooldown == "number") { + if (config.DoorbellCooldown < 1000) config.DoorbellCooldown = config.DoorbellCooldown * 1000; // If less than 1000, assume seconds value passed in, so convert to milliseconds + this.deviceOptions.Global.DoorbellCooldown = config.DoorbellCooldown; // Global doorbell press cooldown time + } + if (config.hasOwnProperty("MotionCooldown") == true && typeof config.MotionCooldown == "number") { + if (config.MotionCooldown < 1000) config.MotionCooldown = config.MotionCooldown * 1000; // If less than 1000, assume seconds value passed in, so convert to milliseconds + this.deviceOptions.Global.MotionCooldown = config.MotionCooldown; // Global motion detected cooldown time + } + if (config.hasOwnProperty("PersonCooldown") == true && typeof config.PersonCooldown == "number") { + if (config.PersonCooldown < 1000) config.PersonCooldown = config.PersonCooldown * 1000; // If less than 1000, assume seconds value passed in, so convert to milliseconds + this.deviceOptions.Global.PersonCooldown = config.PersonCooldown; // Global person detected cooldown time + } + if (config.hasOwnProperty("H264Encoder") == true && typeof config.H264Encoder == "string") { + if (config.H264Encoder.toUpperCase() == "LIBX264") { + this.deviceOptions.Global.H264EncoderRecord = VideoCodecs.LIBX264; // Use libx264, software encoder + this.deviceOptions.Global.H264EncoderLive = VideoCodecs.LIBX264; // Use libx264, software encoder } - if (key == "PERSONCOOLDOWN" && typeof value == "number") { - if (value < 1000) value = value * 1000; // If less than 1000, assume seconds value passed in, so convert to milliseconds - this.personCooldown = value; // Global person detected cooldown time + if (config.H264Encoder.toUpperCase() == "COPY") { + this.deviceOptions.Global.H264EncoderRecord = VideoCodecs.COPY; // Copy the stream directly + this.deviceOptions.Global.H264EncoderLive = VideoCodecs.COPY; // Copy the stream directly } + } + if (config.hasOwnProperty("H264EncoderRecord") == true && typeof config.H264EncoderRecord == "string") { + if (config.H264EncoderRecord.toUpperCase() == "LIBX264") this.deviceOptions.Global.H264EncoderRecord = VideoCodecs.LIBX264; // Use libx264, software encoder + if (config.H264EncoderRecord.toUpperCase() == "H264_OMX") this.deviceOptions.Global.H264EncoderRecord = VideoCodecs.H264_OMX; // Use the older RPI hardware h264 encoder + if (config.H264EncoderRecord.toUpperCase() == "COPY") this.deviceOptions.Global.H264EncoderRecord = VideoCodecs.COPY; // Copy the stream directly + } + if (config.hasOwnProperty("H264EncoderLive") == true && typeof config.H264EncoderLive == "string") { + if (config.H264EncoderLive.toUpperCase() == "LIBX264") this.deviceOptions.Global.H264EncoderLive = VideoCodecs.LIBX264; // Use libx264, software encoder + if (config.H264EncoderLive.toUpperCase() == "H264_OMX") this.deviceOptions.Global.H264EncoderLive = VideoCodecs.H264_OMX; // Use the older RPI hardware h264 encoder + if (config.H264EncoderLive.toUpperCase() == "COPY") this.deviceOptions.Global.H264EncoderLive = VideoCodecs.COPY; // Copy the stream directly + } + + config && Object.entries(config).forEach(([key, value]) => { if (typeof value == "object") { // Assume since key value is an object, its a device configuration for matching serial number - this.extraOptions[key] = {}; + this.deviceOptions[key] = {}; Object.entries(value).forEach(([subKey, value]) => { - if (subKey.toUpperCase() == "EXCLUDE" && typeof value == "boolean" && value == true) this.excludedDevices.push(key); // Push this devices serial number onto our list - if (subKey.toUpperCase() == "HKSV" && typeof value == "boolean") this.extraOptions[key]["HKSV"] = value; // HomeKit Secure Video for this device? - if (subKey.toUpperCase() == "EVEAPP" && typeof value == "boolean") this.extraOptions[key]["EveApp"] = value; // Evehome app integration - if (subKey.toUpperCase() == "H264ENCODER" && typeof value == "string") { + if (subKey == "Exclude" && typeof value == "boolean") this.deviceOptions[key].Exclude = value; // Exclude or un-exclude the device + if (subKey == "HKSV" && typeof value == "boolean") this.deviceOptions[key].HKSV = value; // HomeKit Secure Video for this device? + if (subKey == "EveApp" && typeof value == "boolean") this.deviceOptions[key].EveApp = value; // Evehome app integration + if (subKey == "H264Encoder" && typeof value == "string") { // Legacy option. Replaced by H264EncoderRecord and H264EncoderLive if (value.toUpperCase() == "LIBX264") { - this.extraOptions[key]["H264EncoderRecord"] = VideoCodecs.LIBX264; // Use libx264, software encoder - this.extraOptions[key]["H264EncoderLive"] = VideoCodecs.LIBX264; // Use libx264, software encoder + this.deviceOptions[key].H264EncoderRecord = VideoCodecs.LIBX264; // Use libx264, software encoder + this.deviceOptions[key].H264EncoderLive = VideoCodecs.LIBX264; // Use libx264, software encoder } if (value.toUpperCase() == "H264_OMX") { - this.extraOptions[key]["H264EncoderRecord"] = VideoCodecs.H264_OMX; // Use the older RPI hardware h264 encoder - this.extraOptions[key]["H264EncoderLive"] = VideoCodecs.H264_OMX; // Use the older RPI hardware h264 encoder + this.deviceOptions[key].H264EncoderRecord = VideoCodecs.H264_OMX; // Use the older RPI hardware h264 encoder + this.deviceOptions[key].H264EncoderLive = VideoCodecs.H264_OMX; // Use the older RPI hardware h264 encoder } } - if (subKey.toUpperCase() == "H264RECORDENCODER" && typeof value == "string") { - if (value.toUpperCase() == "LIBX264") this.extraOptions[key]["H264EncoderRecord"] = VideoCodecs.LIBX264; // Use libx264, software encoder - if (value.toUpperCase() == "H264_OMX") this.extraOptions[key]["H264EncoderRecord"] = VideoCodecs.H264_OMX; // Use the older RPI hardware h264 encoder + if (subKey == "H264EncoderRecord" && typeof value == "string") { + if (value.toUpperCase() == "LIBX264") this.deviceOptions[key].H264EncoderRecord = VideoCodecs.LIBX264; // Use libx264, software encoder + if (value.toUpperCase() == "H264_OMX") this.deviceOptions[key].H264EncoderRecord = VideoCodecs.H264_OMX; // Use the older RPI hardware h264 encoder } - if (subKey.toUpperCase() == "H264STREAMENCODER" && typeof value == "string") { - if (value.toUpperCase() == "LIBX264") this.extraOptions[key]["H264EncoderLive"] = VideoCodecs.LIBX264; // Use libx264, software encoder - if (value.toUpperCase() == "H264_OMX") this.extraOptions[key]["H264EncoderLive"] = VideoCodecs.H264_OMX; // Use the older RPI hardware h264 encoder + if (subKey == "H264EncoderLive" && typeof value == "string") { + if (value.toUpperCase() == "LIBX264") this.deviceOptions[key].H264EncoderLive = VideoCodecs.LIBX264; // Use libx264, software encoder + if (value.toUpperCase() == "H264_OMX") this.deviceOptions[key].H264EncoderLive = VideoCodecs.H264_OMX; // Use the older RPI hardware h264 encoder } - if (subKey.toUpperCase() == "HKSVPREBUFFER" && typeof value == "number") { + if (subKey == "HKSVPreBuffer" && typeof value == "number") { if (value < 1000) value = value * 1000; // If less than 1000, assume seconds value passed in, so convert to milliseconds - this.extraOptions[key]["HKSVPreBuffer"] = value; // HKSV pre-buffer sizing for this device + this.deviceOptions[key].HKSVPreBuffer = value; // HKSV pre-buffer sizing for this device } - if (subKey.toUpperCase() == "DOORBELLCOOLDOWN" && typeof value == "number") { + if (subKey == "DoorbellCooldown" && typeof value == "number") { if (value < 1000) value = value * 1000; // If less than 1000, assume seconds value passed in, so convert to milliseconds - this.extraOptions[key]["doorbellCooldown"] = value; // Doorbell press cooldown time for this device + this.deviceOptions[key].DoorbellCooldown = value; // Doorbell press cooldown time for this device } - if (subKey.toUpperCase() == "MOTIONCOOLDOWN" && typeof value == "number") { + if (subKey == "MotionCooldown" && typeof value == "number") { if (value < 1000) value = value * 1000; // If less than 1000, assume seconds value passed in, so convert to milliseconds - this.extraOptions[key]["motionCooldown"] = value; // Motion detected cooldown time for this device + this.deviceOptions[key].MotionCooldown = value; // Motion detected cooldown time for this device } - if (subKey.toUpperCase() == "PERSONCOOLDOWN" && typeof value == "number") { + if (subKey == "PersonCooldown" && typeof value == "number") { if (value < 1000) value = value * 1000; // If less than 1000, assume seconds value passed in, so convert to milliseconds - this.extraOptions[key]["personCooldown"] = value; // Person detected cooldown time for this device + this.deviceOptions[key].PersonCooldown = value; // Person detected cooldown time for this device } - if (subKey.toUpperCase() == "HUMIDITYSENSOR" && typeof value == "boolean") this.extraOptions[key]["humiditySensor"] = value; // Seperate humidity sensor for this device. Only valid for thermostats - if (subKey.toUpperCase() == "EXTERNALCOOL" && typeof value == "string") { + if (subKey == "HumiditySensor" && typeof value == "boolean") this.deviceOptions[key].HumiditySensor = value; // Seperate humidity sensor for this device. Only valid for thermostats + if (subKey == "ExternalCool" && typeof value == "string") { try { if (value.indexOf("/") == -1) value = __dirname + "/" + value; // Since no directory paths in the filename, pre-append the current path - this.extraOptions[key]["externalCool"] = require(value); // Try to load external library for thermostat to perform cooling function + this.deviceOptions[key].ExternalCool = require(value); // Try to load external library for thermostat to perform cooling function } catch (error) { // do nothing } } - if (subKey.toUpperCase() == "EXTERNALHEAT" && typeof value == "string") { + if (subKey == "ExternalHeat" && typeof value == "string") { try { if (value.indexOf("/") == -1) value = __dirname + "/" + value; // Since no directory paths in the filename, pre-append the current path - this.extraOptions[key]["externalHeat"] = require(value); // Try to load external library for thermostat to perform heating function + this.deviceOptions[key].ExternalHeat = require(value); // Try to load external library for thermostat to perform heating function } catch (error) { // do nothing } } - if (subKey.toUpperCase() == "EXTERNALFAN" && typeof value == "string") { + if (subKey == "ExternalFan" && typeof value == "string") { try { if (value.indexOf("/") == -1) value = __dirname + "/" + value; // Since no directory paths in the filename, pre-append the current path - this.extraOptions[key]["externalFan"] = require(value); // Try to load external library for thermostat to perform fan function + this.deviceOptions[key].ExternalFan = require(value); // Try to load external library for thermostat to perform fan function } catch (error) { // do nothing } } - if (subKey.toUpperCase() == "EXTERNALDEHUMIDIFIER" && typeof value == "string") { + if (subKey == "ExternalDehumidifier" && typeof value == "string") { try { if (value.indexOf("/") == -1) value = __dirname + "/" + value; // Since no directory paths in the filename, pre-append the current path - this.extraOptions[key]["externalDehumidifier"] = require(value); // Try to load external library for thermostat to perform dehumidifier function + this.deviceOptions[key].ExternalDehumidifier = require(value); // Try to load external library for thermostat to perform dehumidifier function } catch (error) { // do nothing } } - if (subKey.split(".")[0].toUpperCase() == "OPTION" && subKey.split(".")[1]) { + if (subKey.split(".")[0] == "Option" && subKey.split(".")[1]) { // device options we'll insert into the Nest data for non excluded devices // also allows us to override existing Nest data for the device, such as MAC address etc - this.extraOptions[key][subKey.split(".")[1]] = value; + this.deviceOptions[key][subKey.split(".")[1]] = value; } }); - - // Remove any extra options if the device is marked as excluded - if (this.excludedDevices.includes(key) == true) { - delete this.extraOptions[key]; - } } }); } + catch (error) { + } } } // General functions -function __scale(num, in_min, in_max, out_min, out_max) { - // Scales a number between range 1, to range 2 - if (num > in_max) num = in_max; - if (num < in_min) num = in_min; - return ((num - in_min) * (out_max - out_min) / (in_max - in_min)) + out_min; -} - function processDeviceforHomeKit(deviceData) { // adding device into HomeKit based on Nest device types, ignoring excluded devices - if (deviceData.excluded == false) { - switch (deviceData.device_type) { - case NESTDEVICETYPE.THERMOSTAT : { - // Nest Thermostat - var tempModel = "Thermostat"; - if (deviceData.serial_number.substring(0,2) == "15") tempModel = tempModel + " E"; // Nest Thermostat E - if (deviceData.serial_number.substring(0,2) == "09") tempModel = tempModel + " 3rd Generation"; // Nest Thermostat 3rd Gen - if (deviceData.serial_number.substring(0,2) == "02") tempModel = tempModel + " 2nd Generation"; // Nest Thermostat 2nd Gen - if (deviceData.serial_number.substring(0,2) == "01") tempModel = tempModel + " 1st Generation"; // Nest Thermostat 1st Gen - - var tempDevice = new ThermostatClass(deviceData, eventEmitter); - tempDevice.add("Nest Thermostat", tempModel, Accessory.Categories.THERMOSTAT, true); - break; - } - - case NESTDEVICETYPE.TEMPSENSOR : { - // Nest Temperature Sensor - var tempModel = "Temperature Sensor"; - if (deviceData.serial_number.substring(0,2) == "22") tempModel = tempModel + " 1st Generation"; // Nest Temperature Sensor 1st Gen - - var tempDevice = new TempSensorClass(deviceData, eventEmitter); - tempDevice.add("Nest Temperature Sensor", tempModel, Accessory.Categories.SENSOR, true); - break; - } - - case NESTDEVICETYPE.SMOKESENSOR : { - // Nest Protect - var tempModel = "Protect"; - if (deviceData.serial_number.substring(0,2) == "06") tempModel = tempModel + " 2nd Generation"; // Nest Protect 2nd Gen - if (deviceData.serial_number.substring(0,2) == "05") tempModel = tempModel + " 1st Generation"; // Nest Protect 1st Gen - if (deviceData.wired_or_battery == 0) tempModel = tempModel + " (wired)"; // Mains powered - if (deviceData.wired_or_battery == 1) tempModel = tempModel + " (battery)"; // Battery powered + if (typeof deviceData != "object" || deviceData.excluded == true) { + return; + } - var tempDevice = new SmokeSensorClass(deviceData, eventEmitter); - tempDevice.add("Nest Protect", tempModel, Accessory.Categories.SENSOR, true); - break; - } + if (deviceData.device_type == NestDeviceType.THERMOSTAT) { + // Nest Thermostat + var tempDevice = new NestThermostat(deviceData, eventEmitter); + tempDevice.add("Nest Thermostat", HAP.Accessory.Categories.THERMOSTAT, true); + } - case NESTDEVICETYPE.CAMERA : - case NESTDEVICETYPE.DOORBELL : { - // Nest Hello and Nest Cam(s) - // Basically the same - var tempModel = deviceData.model.replace(/nest\s*/ig, ""); // We'll use doorbell/camera model description that Nest supplies + if (deviceData.device_type == NestDeviceType.TEMPSENSOR) { + // Nest Temperature Sensor + var tempDevice = new NestTemperatureSensor(deviceData, eventEmitter); + tempDevice.add("Nest Temperature Sensor", HAP.Accessory.Categories.SENSOR, true); + } - var tempDevice = new CameraClass(deviceData, eventEmitter); - tempDevice.add("Nest " + tempModel.replace(/\s*(?:\([^()]*\))/ig, ""), tempModel, (deviceData.device_type == NESTDEVICETYPE.DOORBELL ? Accessory.Categories.VIDEO_DOORBELL : Accessory.Categories.IP_CAMERA), true) - break; - } + if (deviceData.device_type == NestDeviceType.SMOKESENSOR) { + // Nest Protect + var tempDevice = new NestProtect(deviceData, eventEmitter); + tempDevice.add("Nest Protect", HAP.Accessory.Categories.SENSOR, true); + } - case NESTDEVICETYPE.WEATHER : { - // "Virtual" weather station - if (config.weather == true) { - var tempDevice = new WeatherClass(deviceData, eventEmitter); - tempDevice.add("Nest Weather", "Weather", Accessory.Categories.SENSOR, true); - } - break; - } - } + if (deviceData.device_type == NestDeviceType.CAMERA || deviceData.device_type == NestDeviceType.DOORBELL) { + // Nest Hello and Nest Cam(s) + // Basically the same + var tempDevice = new NestCameraDoorbell(deviceData, eventEmitter); + tempDevice.add("Nest " + deviceData.model.replace(/\s*(?:\([^()]*\))/ig, ""), (deviceData.device_type == NestDeviceType.DOORBELL ? HAP.Accessory.Categories.VIDEO_DOORBELL : HAP.Accessory.Categories.IP_CAMERA), true) } + + if (deviceData.device_type == NestDeviceType.WEATHER) { + // "Virtual" weather station + var tempDevice = new NestWeather(deviceData, eventEmitter); + tempDevice.add("Nest Weather", HAP.Accessory.Categories.SENSOR, true); + } } // Below taken from https://lifesaver.codes/answer/adding-retry-parameter @@ -2970,95 +3056,103 @@ axios.interceptors.response.use(undefined, function axiosRetryInterceptor(err) { }); }); -function isFfmpegValid(validLibraries) { - // Validates if the ffmpeg binary has been complied to support the required libraries - var isValid = false; // Not valid yet - var output = spawnSync(ffmpegPath || "ffmpeg", ["-version"], { env: process.env }); - if (output.stdout != null) { - var foundLibaries = 0; - validLibraries.forEach((library) => { - if (output.stdout.toString().includes(library) == true) { - foundLibaries++; // One more found library - } - }); - isValid = (foundLibaries == validLibraries.length); +function scaleValue(value, sourceRangeMin, sourceRangeMax, targetRangeMin, targetRangeMax) { + if (value < sourceRangeMin) value = sourceRangeMin; + if (value > sourceRangeMax) value = sourceRangeMax; + return (value - sourceRangeMin) * (targetRangeMax - targetRangeMin) / (sourceRangeMax - sourceRangeMin) + targetRangeMin; +} + +function validateFFMPEGBinary() { + // Validates if the ffmpeg binary has been complied to support the required libraries we need for doorbell/camera support + var ffmpegProcess = spawnSync(pathToFFMPEG || "ffmpeg", ["-version"], { env: process.env }); + if (ffmpegProcess.stdout == null) { + // Since we didn't get a standard output handle, we'll assume the ffmpeg binarie is missing AND/OR failed to start correctly + return; } - return isValid; + + var matchingLibraries = 0; + FFMPEGLIBARIES.forEach((libraryName) => { + if (ffmpegProcess.stdout.toString().includes("--enable-"+libraryName) == true) { + matchingLibraries++; // One more found library + } + }); + return (matchingLibraries == FFMPEGLIBARIES.length); } -function getTimestamp() { - const pad = (n,s=2) => (`${new Array(s).fill(0)}${n}`).slice(-s); - const d = new Date(); - - return `${pad(d.getFullYear(),4)}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +function outputLogging(accessoryName, useConsoleDebug, ...outputMessage) { + var timeStamp = String(new Date().getFullYear()).padStart(4, "0") + "-" + String(new Date().getMonth() + 1).padStart(2, "0") + "-" + String(new Date().getDate()).padStart(2, "0") + " " + String(new Date().getHours()).padStart(2, "0") + ":" + String(new Date().getMinutes()).padStart(2, "0") + ":" + String(new Date().getSeconds()).padStart(2, "0"); + if (useConsoleDebug == false) { + console.log(timeStamp + " [" + accessoryName + "] " + util.format(...outputMessage)); + } + if (useConsoleDebug == true) { + console.debug(timeStamp + " [" + accessoryName + "] " + util.format(...outputMessage)); + } } - + // Startup code -// Check to see if a configuration file was passed into use -var configFile = __dirname + "/" + CONFIGURATIONFILE; -if (process.argv.slice(2).length == 1) { // We only support/process one argument - configFile = process.argv.slice(2)[0]; // Extract the file name from the argument passed in - if (configFile.indexOf("/") == -1) configFile = __dirname + "/" + configFile; +var eventEmitter = new EventEmitter(); // Need a global event emitter. Will be used for messaging between our classes we create +outputLogging(ACCESSORYNAME, false, "Starting " + __filename + " using HAP-NodeJS library v" + HAP.HAPLibraryVersion()); + +// Validate ffmpeg if present and if so, does it include the required libraries to support doorbells/cameras +if (validateFFMPEGBinary() == false) { + // ffmpeg binary doesn't support the required libraries we require + outputLogging(ACCESSORYNAME, false, "The FFmpeg binary '%s' does not support the required libraries for doorbell and/or camera usage", (pathToFFMPEG || "ffmpeg")); + outputLogging(ACCESSORYNAME, false, "FFmpeg needs to be complied to include the following libraries:", FFMPEGLIBARIES); + outputLogging(ACCESSORYNAME, false, "Exiting."); + return; } -console.log("Starting " + __filename + " using HAP-NodeJS library v" + HAPNodeJS.HAPLibraryVersion()); -console.log("Configuration will be read from '%s'", configFile); - -// Create h264 frames for camera off/offline dynamically in video streams. Only required for non-HKSV video devices -var ffmpegCommand = "-hide_banner -loop 1 -i " + __dirname + "/" + CAMERAOFFLINEJPGFILE + " -vframes 1 -r " + EXPECTEDVIDEORATE + " -y -f h264 -profile:v main " + __dirname + "/" + CAMERAOFFLINEH264FILE; -spawnSync(ffmpegPath || "ffmpeg", ffmpegCommand.split(" "), { env: process.env }); -var ffmpegCommand = "-hide_banner -loop 1 -i " + __dirname + "/" + CAMERAOFFJPGFILE + " -vframes 1 -r " + EXPECTEDVIDEORATE + " -y -f h264 -profile:v main " + __dirname + "/" + CAMERAOFFH264FILE; -spawnSync(ffmpegPath || "ffmpeg", ffmpegCommand.split(" "), { env: process.env }); -var ffmpegCommand = "-hide_banner -loop 1 -i " + __dirname + "/" + CAMERACONNECTINGJPGFILE + " -vframes 1 -r " + EXPECTEDVIDEORATE + " -y -f h264 -profile:v main " + __dirname + "/" + CAMERACONNECTING264FILE; -spawnSync(ffmpegPath || "ffmpeg", ffmpegCommand.split(" "), { env: process.env }); - -// Need a global event emitter. Will be used for message between our classes we create -var eventEmitter = new EventEmitter(); - -var config = new Configuration(configFile); // Load configuration details from specified file. -if (config.loaded == true && config.token != "") { - console.log("Devices will be advertised to HomeKit using '%s' mDNS provider", config.mDNS); - var nest = new NestSystem(config.token, config.tokenType, eventEmitter); - nest.connect() // Initiate connection to Nest System APIs with either the specified session or refresh tokens - .then(() => { - if (nest.nestAPIToken != "") { - config.debug.includes(Debugging.NEST) && console.debug(getTimestamp() + " [NEST] Getting active devices from Nest"); - nest.getData() // Get of devices we have in our Nest structure - .then(() => { - if (typeof nest.rawData.quartz != "object" || (typeof nest.rawData.quartz == "object" && isFfmpegValid(FFMPEGLIBARIES) == true)) { - // We don't have "quartz" object key, OR we have a "quartz" key AND a valid ffmpeg binary - // Means we've validated the ffmpeg binary being used supports the required libraries we need for streaming and/or record - - // Process any discovered Nest devices into HomeKit - Object.entries(nest.processData()).forEach(([deviceID, deviceData]) => { - processDeviceforHomeKit(deviceData); - }); +// Create h264 frames for camera off/offline dynamically in video streams. +// Only required for non-HKSV video devices, but we'll still create at startup +var commandLine = "-hide_banner -loop 1 -i " + __dirname + "/" + CAMERAOFFLINEJPGFILE + " -vframes 1 -r " + EXPECTEDVIDEORATE + " -y -f h264 -profile:v main " + __dirname + "/" + CAMERAOFFLINEH264FILE; +spawnSync(pathToFFMPEG || "ffmpeg", commandLine.split(" "), { env: process.env }); +var commandLine = "-hide_banner -loop 1 -i " + __dirname + "/" + CAMERAOFFJPGFILE + " -vframes 1 -r " + EXPECTEDVIDEORATE + " -y -f h264 -profile:v main " + __dirname + "/" + CAMERAOFFH264FILE; +spawnSync(pathToFFMPEG || "ffmpeg", commandLine.split(" "), { env: process.env }); +var commandLine = "-hide_banner -loop 1 -i " + __dirname + "/" + CAMERACONNECTINGJPGFILE + " -vframes 1 -r " + EXPECTEDVIDEORATE + " -y -f h264 -profile:v main " + __dirname + "/" + CAMERACONNECTING264FILE; +spawnSync(pathToFFMPEG || "ffmpeg", commandLine.split(" "), { env: process.env }); + +// Check to see if a configuration file was passed into use and validate if present +var configurationFile = __dirname + "/" + CONFIGURATIONFILE; +if (process.argv.slice(2).length == 1) { // We only support/process one argument + configurationFile = process.argv.slice(2)[0]; // Extract the file name from the argument passed in + if (configurationFile.indexOf("/") == -1) { + configurationFile = __dirname + "/" + configurationFile; + } +} +if (fs.existsSync(configurationFile) == false) { + // Configuration file, either by default name or specified on commandline is missing + outputLogging(ACCESSORYNAME, false, "Specified configuration '%s' cannot be found", configurationFile); + outputLogging(ACCESSORYNAME, false, "Exiting."); + return; +} - nest.events.addListener(SYSTEMEVENT.NEW, processDeviceforHomeKit); // Notifications for any device additions in Nest structure - nest.subscribe(); // Start subscription - } else { - // ffmpeg binary doesn't support the required libraries we require - console.log("The ffmpeg binary '%s' does not support the required libraries for doorbell and/or camera usage", (ffmpegPath || "ffmpeg")); - console.log("Required libraries in ffmpeg are", FFMPEGLIBARIES); - } - }); - } - }); +// Have a configuration file, now load the configuration options +outputLogging(ACCESSORYNAME, false, "Configuration will be read from '%s'", configurationFile); +var config = new Configuration(configurationFile); // Load configuration details from specified file. +if (config.loaded == false || config.token == "") { + outputLogging(ACCESSORYNAME, false, "Configuration file '%s' contains invalid options", configurationFile); + outputLogging(ACCESSORYNAME, false, "Exiting."); + return; } +var nest = new NestSystem(config.token, config.tokenType, eventEmitter); +nest.connect() // Initiate connection to Nest System APIs with either the specified session or refresh tokens +.then(() => { + if (nest.nestAPIToken != "") { + outputLogging(ACCESSORYNAME, false, "Getting list of devices assigned to account"); + nest.getData() // Get data for devices we have in our Nest structure + .then(() => { + // Process any discovered Nest devices into HomeKit + outputLogging(ACCESSORYNAME, false, "Devices will be advertised to HomeKit using '%s' mDNS provider", config.mDNS); + + Object.entries(nest.processData()).forEach(([deviceID, deviceData]) => { + processDeviceforHomeKit(deviceData); + }); -// Exit/cleanup code when/if process stopped -// Taken from HAP-NodeJS core.js/ts code -var signals = {"SIGINT" : 2, "SIGTERM" : 15 }; -Object.keys(signals).forEach((signal) => { - process.on(signal, () => { - for (var index = 0; index < accessories.length; index++) { - accessories[index].unpublish(); - } - - setTimeout(() => { - process.exit(128 + signals[signal]); - }, 1000); - }); + outputLogging(ACCESSORYNAME, false, "Starting subscription for device updates, additions and/or removals"); + nest.eventEmitter.addListener(SystemEvent.ADD, processDeviceforHomeKit); // Notifications for any device additions in Nest structure + nest.subscribe(); // Start subscription + }); + } }); \ No newline at end of file diff --git a/Nest_camera_connecting.jpg b/Nest_camera_connecting.jpg index 6f920202d3c19978fbfaf180bdbe7cf81fb99611..23f995847cd94be073a390a0b62a4250d9db5931 100644 GIT binary patch delta 19085 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 F0061Fwr&6b delta 32843 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 diff --git a/Nest_camera_off.jpg b/Nest_camera_off.jpg index e92efe6b7fdf62858e88bd3b1d98a41305bfdc67..446bd9825c084a102eece2c181db6a077815bf4d 100644 GIT binary patch delta 22098 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 diff --git a/Nest_camera_offline.jpg b/Nest_camera_offline.jpg index 7214e149b7cf1409711728b0b54661ae52671115..a48f118bee906399e10590ef275a537ad647085f 100644 GIT binary patch delta 26426 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 H3MTwF;yx&e delta 26474 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 Ui;k%x73$z87tAXTl!_<)8*KPo5dZ)H diff --git a/nexusstreamer.js b/nexusstreamer.js index 29cd07a..2969bca 100644 --- a/nexusstreamer.js +++ b/nexusstreamer.js @@ -36,12 +36,14 @@ var protoBuf = require("pbf"); // Proto buffer // Define nodejs module requirements +var util = require("util"); var fs = require("fs"); var tls = require("tls"); var EventEmitter = require("events"); var {spawn} = require("child_process"); // Define constants +const DEFAULTBUFFERTIME = 15000; // Default time in milliseconds to hold in buffer const PINGINTERVAL = 15000; // 15 seconds between each ping to nexus server while stream active const TIMERINTERVAL = 1000; // 1 second const CAMERAOFFLINEH264FILE = "Nest_camera_offline.h264"; // Camera offline H264 frame file @@ -197,12 +199,12 @@ const AACMONO48000BLANK = Buffer.from([ // NeuxsStreamer object class NexusStreamer { - constructor(deviceID, cameraToken, tokenType, deviceData, debug) { + constructor(HomeKitAccessoryUUID, cameraToken, tokenType, deviceData, enableDebugging) { this.camera = deviceData; // Current camera data this.buffer = {active: false, size: 0, buffer: [], streams: []}; // Buffer and stream details - this.socket = null; + this.tcpSocket = null; this.host = null; // No intial host to connect to this.pendingHost = null; this.nexusvideo = {channel_id: -1, start_time: 0, sample_rate: 0, packet_time: 0}; @@ -215,7 +217,7 @@ class NexusStreamer { this.timer = null; // Internal timer handle this.pingtimer = null; // Ping timer handle this.sessionID = null; // no session ID yet.. We'll assign a random one when we connect to the nexus stream - this.deviceID = deviceID; // HomeKit accessory UUID + this.HomeKitAccessoryUUID = HomeKitAccessoryUUID; // HomeKit accessory UUID // Get access token and set token type this.cameraToken = cameraToken; @@ -224,13 +226,13 @@ class NexusStreamer { this.playingBack = false; // If we're playing back nexus data this.talking = false; // If "talk" is happening - this.debug = typeof debug == "boolean" ? debug : false; // debug status + this.enableDebugging = typeof enableDebugging == "boolean" ? enableDebugging : false; // debug status // buffer for camera offline image in .h264 frame this.camera_offline_h264_frame = null; if (fs.existsSync(__dirname + "/" + CAMERAOFFLINEH264FILE)) { this.camera_offline_h264_frame = fs.readFileSync(__dirname + "/" + CAMERAOFFLINEH264FILE); - // remove any H264 NALU from being of any video data. We do this as they are added later when output by our ffmpeg router + // remove any H264 NALU from beginning of any video data. We do this as they are added later when output by our ffmpeg router if (this.camera_offline_h264_frame.indexOf(H264NALUnit) == 0) { this.camera_offline_h264_frame = this.camera_offline_h264_frame.slice(H264NALUnit.length); } @@ -240,7 +242,7 @@ class NexusStreamer { this.camera_off_h264_frame = null; if (fs.existsSync(__dirname + "/" + CAMERAOFFH264FILE)) { this.camera_off_h264_frame = fs.readFileSync(__dirname + "/" + CAMERAOFFH264FILE); - // remove any H264 NALU from being of any video data. We do this as they are added later when output by our ffmpeg router + // remove any H264 NALU from beginning of any video data. We do this as they are added later when output by our ffmpeg router if (this.camera_off_h264_frame.indexOf(H264NALUnit) == 0) { this.camera_off_h264_frame = this.camera_off_h264_frame.slice(H264NALUnit.length); } @@ -250,7 +252,7 @@ class NexusStreamer { this.camera_connecting_h264_frame = null; if (fs.existsSync(__dirname + "/" + CAMERACONNECTING264FILE)) { this.camera_connecting_h264_frame = fs.readFileSync(__dirname + "/" + CAMERACONNECTING264FILE); - // remove any H264 NALU from being of any video data. We do this as they are added later when output by our ffmpeg router + // remove any H264 NALU from beginning of any video data. We do this as they are added later when output by our ffmpeg router if (this.camera_connecting_h264_frame.indexOf(H264NALUnit) == 0) { this.camera_connecting_h264_frame = this.camera_connecting_h264_frame.slice(H264NALUnit.length); } @@ -259,18 +261,18 @@ class NexusStreamer { // Class functions - startBuffering(milliseconds) { + startBuffering(bufferingTimeMilliseconds) { // We only support one buffering stream per Nexus object ie: per camera - if (typeof milliseconds == "undefined") { - milliseconds = 15000; // Wasnt specified how much streaming time we hold in our buffer, so default to 15 seconds + if (typeof bufferingTimeMilliseconds == "undefined") { + bufferingTimeMilliseconds = DEFAULTBUFFERTIME; // Wasnt specified how much streaming time we hold in our buffer, so default to 15 seconds } - this.buffer.maxTime = milliseconds; - this.buffer.active = (milliseconds > 0 ? true : false); // Start the buffer if buffering size > 0 + this.buffer.maxTime = bufferingTimeMilliseconds; + this.buffer.active = (bufferingTimeMilliseconds > 0 ? true : false); // Start the buffer if buffering size > 0 this.buffer.buffer = []; // empty buffer - if (this.socket == null) { + if (this.tcpSocket == null) { this.#connect(this.camera.direct_nexustalk_host); } - this.debug && console.debug(getTimestamp() + " [NEXUS] Started buffering from '%s' with size of '%s'", (this.host == null ? this.camera.direct_nexustalk_host : this.host), milliseconds); + this.#outputLogging("Nest", true, "Started buffering from '%s' with size of '%s'", (this.host == null ? this.camera.direct_nexustalk_host : this.host), bufferingTimeMilliseconds); } startLiveStream(sessionID, videoStream, audioStream, alignToSPSFrame) { @@ -282,9 +284,8 @@ class NexusStreamer { // EPIPE errors?? }); - if (this.buffer.active == false && this.socket == null) { + if (this.buffer.active == false && this.tcpSocket == null) { // We not doing any buffering and there isnt an active socket connection, so startup connection to nexus - this.debug && console.debug(getTimestamp() + " [NEXUS] Starting connection to '%s'", this.camera.direct_nexustalk_host); this.#connect(this.camera.direct_nexustalk_host); } @@ -292,7 +293,7 @@ class NexusStreamer { this.buffer.streams.push({type: "live", id: sessionID, video: videoStream, audio: audioStream, aligned: (typeof alignToSPSFrame == "undefined" || alignToSPSFrame == true ? false : true)}); // finally, we've started live stream - this.debug && console.debug(getTimestamp() + " [NEXUS] Started live stream from '%s'", (this.host == null ? this.camera.direct_nexustalk_host : this.host)); + this.#outputLogging("Nest", true, "Started live stream from '%s'", (this.host == null ? this.camera.direct_nexustalk_host : this.host)); } startRecordStream(sessionID, ffmpegRecord, videoStream, audioStream, alignToSPSFrame, fromTime) { @@ -304,13 +305,12 @@ class NexusStreamer { // EPIPE errors?? }); - if (this.buffer.active == false && this.socket == null) { + if (this.buffer.active == false && this.tcpSocket == null) { // We not doing any buffering and/or there isnt an active socket connection, so startup connection to nexus - this.debug && console.debug(getTimestamp() + " [NEXUS] Starting connection to '%s'", this.camera.direct_nexustalk_host); this.#connect(this.camera.direct_nexustalk_host); } - // Output from the request time position in the buffer until one index before the end of buffer + // Output from the requested time position in the buffer until one index before the end of buffer if (this.buffer.active == true) { var sentElements = 0; var doneAlign = (typeof alignToSPSFrame == "undefined" || alignToSPSFrame == true ? false : true); @@ -332,38 +332,41 @@ class NexusStreamer { } } } - this.debug && console.debug(getTimestamp() + " [NEXUS] Recording stream '%s' requested buffered data first. Sent '%s' buffered elements", sessionID, sentElements); + this.#outputLogging("Nest", true, "Recording stream '%s' requested buffered data first. Sent '%s' buffered elements", sessionID, sentElements); } // Add video/audio streams for our ffmpeg router to handle outputting to this.buffer.streams.push({type: "record", id: sessionID, record: ffmpegRecord, video: videoStream, audio: audioStream, aligned: doneAlign}); // Finally we've started the recording stream - this.debug && console.debug(getTimestamp() + " [NEXUS] Started recording stream from '%s'", (this.host == null ? this.camera.direct_nexustalk_host : this.host)); + this.#outputLogging("Nest", true, "Started recording stream from '%s'", (this.host == null ? this.camera.direct_nexustalk_host : this.host)); } startTalkStream(sessionID, talkbackStream) { // Setup talkback audio stream if configured - if (talkbackStream != null) { - var index = this.buffer.streams.findIndex(({ id }) => id == sessionID); - if (index != -1) { - this.buffer.streams[index].audioTimeout = null; // NO timeout - - talkbackStream.on("error", (error) => { - // EPIPE errors?? - }); - - talkbackStream.on("data", (data) => { - // Received audio data to send onto nexus for output to doorbell/camera - this.#AudioPayload(data); - - clearTimeout(this.buffer.streams[index].audioTimeout); // Clear return audio timeout - this.buffer.streams[index].audioTimeout = setTimeout(() => { - // no audio received in 500ms, so mark end of stream - this.#AudioPayload(Buffer.from([])); - }, 500); - }); - } + if (talkbackStream == null) { + + return; + } + + var index = this.buffer.streams.findIndex(({ id }) => id == sessionID); + if (index != -1) { + this.buffer.streams[index].audioTimeout = null; // NO timeout + + talkbackStream.on("error", (error) => { + // EPIPE errors?? + }); + + talkbackStream.on("data", (data) => { + // Received audio data to send onto nexus for output to doorbell/camera + this.#AudioPayload(data); + + clearTimeout(this.buffer.streams[index].audioTimeout); // Clear return audio timeout + this.buffer.streams[index].audioTimeout = setTimeout(() => { + // no audio received in 500ms, so mark end of stream + this.#AudioPayload(Buffer.from([])); + }, 500); + }); } } @@ -378,7 +381,7 @@ class NexusStreamer { // Request to stop a recording stream var index = this.buffer.streams.findIndex(({ type, id }) => type == "record" && id == sessionID); if (index != -1) { - this.debug && console.debug(getTimestamp() + " [NEXUS] Stopped recording stream from '%s'", (this.host == null ? this.camera.direct_nexustalk_host : this.host)); + this.#outputLogging("Nest", true, "Stopped recording stream from '%s'", (this.host == null ? this.camera.direct_nexustalk_host : this.host)); this.buffer.streams.splice(index, 1); // remove this object } @@ -393,7 +396,7 @@ class NexusStreamer { // Request to stop an active live stream var index = this.buffer.streams.findIndex(({ type, id }) => type == "live" && id == sessionID); if (index != -1) { - this.debug && console.debug(getTimestamp() + " [NEXUS] Stopped live stream from '%s'", (this.host == null ? this.camera.direct_nexustalk_host : this.host)); + this.#outputLogging("Nest", true, "Stopped live stream from '%s'", (this.host == null ? this.camera.direct_nexustalk_host : this.host)); this.buffer.streams[index].timeout && clearTimeout(this.buffer.streams[index].timeout); // Clear any active return audio timer this.buffer.streams.splice(index, 1); // remove this object } @@ -408,7 +411,7 @@ class NexusStreamer { stopBuffering() { if (this.buffer.active == true) { // we have a buffer session, so close it down - this.debug && console.debug(getTimestamp() + " [NEXUS] Stopped buffering from '%s'", (this.host == null ? this.camera.direct_nexustalk_host : this.host)); + this.#outputLogging("Nest", true, "Stopped buffering from '%s'", (this.host == null ? this.camera.direct_nexustalk_host : this.host)); this.buffer.buffer = null; // Clean up first this.buffer.active = false; // No buffer running now } @@ -420,68 +423,72 @@ class NexusStreamer { } } - update(cameraToken, tokenType, deviceData) { - if (typeof deviceData == "object") { - if (cameraToken != this.cameraToken || tokenType != this.tokenType) { - // access token has changed and/or token type has changed, so re-authorise - this.tokenType = tokenType; // Update token type - this.cameraToken = cameraToken; // Update token - this.#Authenticate(true); // Update authorisation only - } + update(cameraToken, tokenType, updatedDeviceData) { + if (typeof updatedDeviceData != "object") { + return; + } + + if (cameraToken != this.cameraToken || tokenType != this.tokenType) { + // access token has changed and/or token type has changed, so re-authorise + this.tokenType = tokenType; // Update token type + this.cameraToken = cameraToken; // Update token + this.#Authenticate(true); // Update authorisation only + } - if ((this.camera.online != deviceData.online) || (this.camera.streaming_enabled != deviceData.streaming_enabled)) { - // Online status or streaming status has changed has changed - this.camera.online = deviceData.online; - this.camera.streaming_enabled = deviceData.streaming_enabled; - if ((this.camera.online == false || this.camera.streaming_enabled == false) && this.socket != null) { - this.debug && console.debug(getTimestamp() + " [NEXUS] Camera went offline"); - this.#close(true); // as offline or streaming not enabled, close socket - } - if ((this.camera.online == true && this.camera.streaming_enabled == true) && (this.socket == null && (this.buffer.active == true || this.buffer.streams.length > 0))) { - this.#connect(this.camera.direct_nexustalk_host); // Connect to Nexus for stream - } + if ((this.camera.online != updatedDeviceData.online) || (this.camera.streaming_enabled != updatedDeviceData.streaming_enabled)) { + // Online status or streaming status has changed has changed + this.camera.online = updatedDeviceData.online; + this.camera.streaming_enabled = updatedDeviceData.streaming_enabled; + this.camera.direct_nexustalk_host = updatedDeviceData.direct_nexustalk_host + if (this.camera.online == false || this.camera.streaming_enabled == false) { + this.#close(true); // as offline or streaming not enabled, close socket } - - if (this.camera.direct_nexustalk_host != deviceData.direct_nexustalk_host) { - this.debug && console.debug(getTimestamp() + " [NEXUS] Updated Nexusstreamer host '%s'", deviceData.direct_nexustalk_host); - this.pendingHost = deviceData.direct_nexustalk_host; + if ((this.camera.online == true && this.camera.streaming_enabled == true) && (this.tcpSocket == null && (this.buffer.active == true || this.buffer.streams.length > 0))) { + this.#connect(this.camera.direct_nexustalk_host); // Connect to Nexus for stream } + } - this.camera = deviceData; // Update our internally stored copy of the camera details + if (this.camera.direct_nexustalk_host != updatedDeviceData.direct_nexustalk_host) { + this.#outputLogging("Nest", true, "Updated Nexusstreamer host '%s'", updatedDeviceData.direct_nexustalk_host); + this.pendingHost = updatedDeviceData.direct_nexustalk_host; } + + this.camera = updatedDeviceData; // Update our internally stored copy of the camera details } - getBufferSnapshot = async function(ffmpegPath) { - var image = Buffer.alloc(0); // Empty buffer + async getBufferSnapshot(pathToFFMPEG) { + if (this.buffer.active == false) { + return Buffer.alloc(0); // Empty buffer; + }; - if (this.buffer.active == true) { - // Setup our ffmpeg process for conversion of h264 image frame to jpg image - var ffmpegCommand = "-hide_banner -f h264 -i pipe:0 -vframes 1 -f image2pipe pipe:1"; - var ffmpeg = spawn(ffmpegPath || "ffmpeg", ffmpegCommand.split(" "), { env: process.env }); + // Setup our ffmpeg process for conversion of h264 image frame to jpg image + var imageSnapshot = Buffer.alloc(0); // Empty buffer + var commandLine = "-hide_banner -f h264 -i pipe:0 -vframes 1 -f image2pipe pipe:1"; + var ffmpegProcess = spawn(pathToFFMPEG || "ffmpeg", commandLine.split(" "), { env: process.env }); - ffmpeg.stdout.on("data", (data) => { - image = Buffer.concat([image, data]); // Append image data to return buffer - }); + ffmpegProcess.stdout.on("data", (data) => { + imageSnapshot = Buffer.concat([imageSnapshot, data]); // Append image data to return buffer + }); - var done = false; - for (var index = this.buffer.buffer.length - 1; index >= 0 && done == false; index--) { - if (this.buffer.buffer[index].type == "video" && this.buffer.buffer[index].data[0] && ((this.buffer.buffer[index].data[0] & 0x1f) == H264FrameTypes.SPS) == true) { - // Found last H264 SPS frame from end of buffer - // The buffer should now have a buffer sequence of SPS, PPS and IDR - // Maybe need to refine to search from this position for the PPS and then from there, to the IDR? - if (index <= this.buffer.buffer.length - 3) { - ffmpeg.stdin.write(Buffer.concat([H264NALUnit, this.buffer.buffer[index].data])); // SPS - ffmpeg.stdin.write(Buffer.concat([H264NALUnit, this.buffer.buffer[index + 1].data])); // PPS assuming - ffmpeg.stdin.write(Buffer.concat([H264NALUnit, this.buffer.buffer[index + 2].data])); // IDR assuming - done = true; // finished outputting to ffmpeg process - } + var done = false; + for (var index = this.buffer.buffer.length - 1; index >= 0 && done == false; index--) { + if (this.buffer.buffer[index].type == "video" && this.buffer.buffer[index].data[0] && ((this.buffer.buffer[index].data[0] & 0x1f) == H264FrameTypes.SPS) == true) { + // Found last H264 SPS frame from end of buffer + // The buffer should now have a buffer sequence of SPS, PPS and IDR + // Maybe need to refine to search from this position for the PPS and then from there, to the IDR? + if (index <= this.buffer.buffer.length - 3) { + ffmpegProcess.stdin.write(Buffer.concat([H264NALUnit, this.buffer.buffer[index].data])); // SPS + ffmpegProcess.stdin.write(Buffer.concat([H264NALUnit, this.buffer.buffer[index + 1].data])); // PPS assuming + ffmpegProcess.stdin.write(Buffer.concat([H264NALUnit, this.buffer.buffer[index + 2].data])); // IDR assuming + done = true; // finished outputting to ffmpeg process } } - - ffmpeg.stdin.end(); // No more output from our search loop, so mark end to ffmpeg - await EventEmitter.once(ffmpeg, "exit"); // Wait until childprocess (ffmpeg) has issued exit event } - return image; + + ffmpegProcess.stdin.end(); // No more output from our search loop, so mark end to ffmpeg + await EventEmitter.once(ffmpegProcess, "exit"); // Wait until childprocess (ffmpeg) has issued exit event + + return imageSnapshot; } #connect(host) { @@ -489,7 +496,7 @@ class NexusStreamer { this.sessionID = null; // No session ID yet if (this.camera.streaming_enabled == true && this.camera.online == true) { - if (typeof host == "undefined") { + if (typeof host == "undefined" || host == null) { // No host parameter passed in, so we'll set this to our internally stored host host = this.host; } @@ -499,11 +506,13 @@ class NexusStreamer { this.pendingHost = null; } - this.socket = tls.connect({host: host, port: 1443}, () => { + this.#outputLogging("Nest", true, "Starting connection to '%s'", host); + + this.tcpSocket = tls.connect({host: host, port: 1443}, () => { // Opened connection to Nexus server, so now need to authenticate ourselves this.host = host; // update internal host name since we've connected - this.debug && console.debug(getTimestamp() + " [NEXUS] Connection established to '%s'", host); - this.socket.setKeepAlive(true); // Keep socket connection alive + this.#outputLogging("Nest", true, "Connection established to '%s'", host); + this.tcpSocket.setKeepAlive(true); // Keep socket connection alive this.#Authenticate(false); this.pingtimer = setInterval(() => { @@ -513,39 +522,35 @@ class NexusStreamer { }, PINGINTERVAL); }); - this.socket.on("error", (error) => { + this.tcpSocket.on("error", (error) => { // Catch any socket errors to avoid code quitting // Our "close" handler will try reconnecting if needed - //this.debug && console.debug(getTimestamp() + " [NEXUS] Stocket error", error); + //this.#outputLogging("Nest", true, "Stocket error", error); }); - this.socket.on("end", () => { - //this.debug && console.debug(getTimestamp() + " [NEXUS] Stocket ended", this.playingBack); + this.tcpSocket.on("end", () => { + //this.#outputLogging("Nest", true, "Stocket ended", this.playingBack); }); - this.socket.on("data", (data) => { + this.tcpSocket.on("data", (data) => { this.#handleNexusData(data); }); - this.socket.on("close", (hadError) => { + this.tcpSocket.on("close", (hadError) => { var normalClose = this.weDidClose; // Cache this, so can reset it below before we take action clearInterval(this.pingtimer); // Clear ping timer this.playingBack = false; // Playback ended as socket is closed this.authorised = false; // Since connection close, we can't be authorised anymore - this.socket = null; // Clear socket object + this.tcpSocket = null; // Clear socket object this.sessionID = null; // Not an active session anymore this.weDidClose = false; // Reset closed flag - if (normalClose == true) { - // We've closed the socket gracefully, means we don't need to reconnect - this.debug && console.debug(getTimestamp() + " [NEXUS] Connection closed to '%s'", host); - } + this.#outputLogging("Nest", true, "Connection closed to '%s'", host); if (normalClose == false && (this.buffer.active == true || this.buffer.streams.length > 0)) { // We still have either active buffering occuring or output streams running // so attempt to restart connection to existing host - this.debug && console.debug(getTimestamp() + " [NEXUS] Connection was closed to '%s'. Attempting reconnection", host); this.#connect(host); } }); @@ -570,7 +575,7 @@ class NexusStreamer { //this.#ffmpegRouter("video", this.camera_connecting_h264_frame); //this.#ffmpegRouter("audio", AACMONO48000BLANK); } - if (this.camera_offline_h264_frame && this.socket == null) { + if (this.camera_offline_h264_frame && this.tcpSocket == null) { // Seems we cant access the video stream as we have an empty connection, so feed in our custom h264 frame for playback // We'll use the camera off h264 frame //this.#ffmpegRouter("video", this.camera_offline_h264_frame); @@ -581,43 +586,48 @@ class NexusStreamer { #close(sendStop) { // Close an authenicated socket stream gracefully - if (this.socket != null) { + if (this.tcpSocket != null) { if (sendStop == true) { // Send a notifcation to nexus we're finished playback this.#stopNexusData(); } - this.socket.destroy(); + this.tcpSocket.destroy(); } - this.socket = null; + this.tcpSocket = null; this.sessionID = null; // Not an active session anymore this.pendingMessages = []; // No more pending messages this.weDidClose = true; // Flag we did the socket close } #startNexusData() { - if (this.camera.streaming_enabled == true && this.camera.online == true) { - // Attempt to use camera's stream profile or use default - var otherProfiles = []; - this.camera.capabilities.forEach((element) => { - if (element.startsWith("streaming.cameraprofile")) { - var profile = element.replace("streaming.cameraprofile.", ""); - if (otherProfiles.indexOf(profile, 0) == -1 && StreamProfile.VIDEO_H264_2MBIT_L40 != StreamProfile[profile]) { - // Profile isn't the primary profile, and isn't in the others list, so add it - otherProfiles.push(StreamProfile[profile]); - } + if (this.camera.streaming_enabled == false || this.camera.online == false) { + return; + } + + // Attempt to use camera's stream profile or use default + var otherProfiles = []; + this.camera.capabilities.forEach((element) => { + if (element.startsWith("streaming.cameraprofile")) { + var profile = element.replace("streaming.cameraprofile.", ""); + if (otherProfiles.indexOf(profile, 0) == -1 && StreamProfile.VIDEO_H264_2MBIT_L40 != StreamProfile[profile]) { + // Profile isn't the primary profile, and isn't in the others list, so add it + otherProfiles.push(StreamProfile[profile]); } - }); - - if (this.camera.audio_enabled == true) otherProfiles.push(StreamProfile.AUDIO_AAC); // Include AAC if audio enabled on camera + } + }); - var startBuffer = new protoBuf(); - startBuffer.writeVarintField(1, Math.floor(Math.random() * (100 - 1) + 1)); // Random session ID bwteen 1 and 100); // Session ID - startBuffer.writeVarintField(2, StreamProfile.VIDEO_H264_2MBIT_L40); // Default profile. ie: high quality - otherProfiles.forEach(otherProfile => { - startBuffer.writeVarintField(6, otherProfile); // Other supported profiles - }); - this.#sendMessage(PacketType.START_PLAYBACK, startBuffer.finish()); + if (this.camera.audio_enabled == true) { + otherProfiles.push(StreamProfile.AUDIO_AAC); // Include AAC if audio enabled on camera } + + var startBuffer = new protoBuf(); + startBuffer.writeVarintField(1, Math.floor(Math.random() * (100 - 1) + 1)); // Random session ID bwteen 1 and 100); // Session ID + startBuffer.writeVarintField(2, StreamProfile.VIDEO_H264_2MBIT_L40); // Default profile. ie: high quality + otherProfiles.forEach(otherProfile => { + startBuffer.writeVarintField(6, otherProfile); // Other supported profiles + }); + + this.#sendMessage(PacketType.START_PLAYBACK, startBuffer.finish()); } #stopNexusData() { @@ -658,37 +668,36 @@ class NexusStreamer { #processMessages() { // Send any pending messages that might have accumulated while socket pending etc - if (this.pendingMessages && this.pendingMessages.length > 0) { - for (let message = this.pendingMessages.shift(); message; message = this.pendingMessages.shift()) { - this.#sendMessage(message.type, message.buffer); - } + if (typeof this.pendingMessages != "object" || this.pendingMessages.length == 0) { + return; + } + + for (let pendingMessage = this.pendingMessages.shift(); pendingMessage; pendingMessage = this.pendingMessages.shift()) { + this.#sendMessage(pendingMessage.messageType, pendingMessage.messageData); } } - #sendMessage(type, buffer) { - if (this.socket != null) { - if ((this.socket.readyState != "open") || (type !== PacketType.HELLO && this.authorised == false)) { - this.pendingMessages.push({type, buffer}); - return; - } + #sendMessage(messageType, messageData) { + if (this.tcpSocket == null || this.tcpSocket.readyState != "open" || (messageType !== PacketType.HELLO && this.authorised == false)) { + this.pendingMessages.push({messageType, messageData}); + return; + } - var requestBuffer; - if (type === 0xcd) { - // Long packet - requestBuffer = Buffer.alloc(5); - requestBuffer[0] = type; - requestBuffer.writeUInt32BE(buffer.length, 1); - } else { - requestBuffer = Buffer.alloc(3); - requestBuffer[0] = type; - requestBuffer.writeUInt16BE(buffer.length, 1); - } - requestBuffer = Buffer.concat([requestBuffer, Buffer.from(buffer)]); - // write our composed message to the socket - this.socket.write(requestBuffer, () => { - // Message sent. Dont do anything? - }); + if (messageType !== PacketType.LONG_PLAYBACK_PACKET) { + var messageHeader = Buffer.alloc(3); + messageHeader[0] = messageType; + messageHeader.writeUInt16BE(messageData.length, 1); } + if (messageType === PacketType.LONG_PLAYBACK_PACKET) { + var messageHeader = Buffer.alloc(5); + messageHeader[0] = messageType; + messageHeader.writeUInt32BE(messageData.length, 1); + } + + // write our composed message to the socket + this.tcpSocket.write(Buffer.concat([messageHeader, Buffer.from(messageData)]), () => { + // Message sent. Dont do anything? + }); } #Authenticate(reauthorise) { @@ -700,27 +709,27 @@ class NexusStreamer { if (this.tokenType == "nest") { tokenBuffer.writeStringField(1, this.cameraToken); // Tag 1, session token, Nest auth accounts - helloBuffer.writeStringField(4, this.cameraToken); // session token, Nest auth accounts + helloBuffer.writeStringField(4, this.cameraToken); // Tag 4, session token, Nest auth accounts } if (this.tokenType == "google") { tokenBuffer.writeStringField(4, this.cameraToken); // Tag 4, olive token, Google auth accounts - helloBuffer.writeBytesField(12, tokenBuffer.finish()); // olive token, Google auth accounts + helloBuffer.writeBytesField(12, tokenBuffer.finish()); // Tag 12, olive token, Google auth accounts } if (typeof reauthorise == "boolean" && reauthorise == true) { // Request to re-authorise only - this.debug && console.debug(getTimestamp() + " [NEXUS] Re-authentication requested to '%s'", this.host); + this.#outputLogging("Nest", true, "Re-authentication requested to '%s'", this.host); this.#sendMessage(PacketType.AUTHORIZE_REQUEST, tokenBuffer.finish()); } else { // This isnt a re-authorise request, so perform "Hello" packet - this.debug && console.debug(getTimestamp() + " [NEXUS] Performing authentication to '%s'", this.host); + this.#outputLogging("Nest", true, "Performing authentication to '%s'", this.host); helloBuffer.writeVarintField(1, ProtocolVersion.VERSION_3); - helloBuffer.writeStringField(2, this.camera.camera_uuid); - helloBuffer.writeBooleanField(3, false); // Doesnt required a connect camera - helloBuffer.writeStringField(6, this.deviceID); // Random UUID v4 device ID - //helloBuffer.writeStringField(7, "Nest/5.69.0 (iOScom.nestlabs.jasper.release) os=15.6"); - //helloBuffer.writeVarintField(9, ClientType.IOS); - helloBuffer.writeStringField(7, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6 Safari/605.1.15"); - helloBuffer.writeVarintField(9, ClientType.WEB); + helloBuffer.writeStringField(2, this.camera.device_uuid.split(".")[1]); // UUID should be "quartz.xxxxxx". We want the xxxxxx part + helloBuffer.writeBooleanField(3, false); // Doesnt required a connected camera + helloBuffer.writeStringField(6, this.HomeKitAccessoryUUID); // UUID v4 device ID + helloBuffer.writeStringField(7, "Nest/5.69.0 (iOScom.nestlabs.jasper.release) os=15.6"); + helloBuffer.writeVarintField(9, ClientType.IOS); + //helloBuffer.writeStringField(7, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6 Safari/605.1.15"); + //helloBuffer.writeVarintField(9, ClientType.WEB); this.#sendMessage(PacketType.HELLO, helloBuffer.finish()); } } @@ -736,9 +745,7 @@ class NexusStreamer { this.#sendMessage(PacketType.AUDIO_PAYLOAD, audioBuffer.finish()); } - #handleRedirect(payload) { - var redirectToHost = ""; - + #handleRedirect(payload) { if (typeof payload == "object") { // Payload parameter is an object, we'll assume its a payload packet // Decode redirect packet to determine new host @@ -747,22 +754,24 @@ class NexusStreamer { else if (tag === 2) obj.is_transcode = protoBuf.readBoolean(); }, {new_host: "", is_transcode: false}); - redirectToHost = packet.new_host; + var redirectToHost = packet.new_host; } if (typeof payload == "string") { // Payload parameter is a string, we'll assume this is a direct hostname - redirectToHost = payload; + var redirectToHost = payload; } - if (redirectToHost != "") { - this.debug && console.debug(getTimestamp() + " [NEXUS] Redirect requested from '%s' to '%s'", this.host, redirectToHost); - - // Setup listener for socket close event. Once socket is closed, we'll perform the redirect - this.socket && this.socket.on("close", (hasError) => { - this.#connect(redirectToHost); // Connect to new host - }); - this.#close(true); // Close existing socket + if (typeof redirectToHost != "string" || redirectToHost == "") { + return; } + + this.#outputLogging("Nest", true, "Redirect requested from '%s' to '%s'", this.host, redirectToHost); + + // Setup listener for socket close event. Once socket is closed, we'll perform the redirect + this.tcpSocket && this.tcpSocket.on("close", (hasError) => { + this.#connect(redirectToHost); // Connect to new host + }); + this.#close(true); // Close existing socket } #handlePlaybackBegin(payload) { @@ -799,7 +808,7 @@ class NexusStreamer { this.buffer.buffer = []; this.playingBack = true; this.sessionID = packet.session_id; - this.debug && console.debug(getTimestamp() + " [NEXUS] Playback started from '%s' with session ID '%s'", this.host, this.sessionID); + this.#outputLogging("Nest", true, "Playback started from '%s' with session ID '%s'", this.host, this.sessionID); } #handlePlaybackPacket(payload) { @@ -842,15 +851,15 @@ class NexusStreamer { if (this.playingBack == true && packet.reason == 0) { // Normal playback ended ie: when we stopped playback - this.debug && console.debug(getTimestamp() + " [NEXUS] Playback ended on '%s'", this.host); + this.#outputLogging("Nest", true, "Playback ended on '%s'", this.host); } if (packet.reason != 0) { // Error during playback, so we'll attempt to restart by reconnection to host - this.debug && console.debug(getTimestamp() + " [NEXUS] Playback ended on '%s' with error '%s'. Attempting reconnection", this.host, packet.reason); + this.#outputLogging("Nest", true, "Playback ended on '%s' with error '%s'. Attempting reconnection", this.host, packet.reason); // Setup listener for socket close event. Once socket is closed, we'll perform the re-connection - this.socket && this.socket.on("close", (hasError) => { + this.tcpSocket && this.tcpSocket.on("close", (hasError) => { this.#connect(this.host); // try reconnection to existing host }); this.#close(false); // Close existing socket @@ -871,7 +880,7 @@ class NexusStreamer { this.#Authenticate(true); // Update authorisation only } else { // NexusStreamer Error, packet.message contains the message - this.debug && console.debug(getTimestamp() + " [NEXUS] Error", packet.message); + this.#outputLogging("Nest", true, "Error", packet.message); } } @@ -884,7 +893,7 @@ class NexusStreamer { else if (tag === 4) obj.device_id = protoBuf.readString(); }, {user_id: "", session_id: 0, quick_action_id: 0, device_id: ""}); - this.debug && console.debug(getTimestamp() + " [NEXUS] Talkback started on '%s'", packet.device_id); + this.#outputLogging("Nest", true, "Talkback started on '%s'", packet.device_id); this.talking = true; // Talk back has started } @@ -897,95 +906,91 @@ class NexusStreamer { else if (tag === 4) obj.device_id = protoBuf.readString(); }, {user_id: "", session_id: 0, quick_action_id: 0, device_id: ""}); - this.debug && console.debug(getTimestamp() + " [NEXUS] Talkback ended on '%s'", packet.device_id); + this.#outputLogging("Nest", true, "Talkback ended on '%s'", packet.device_id); this.talking = false; // Talk back has stopped } #handleNexusData(data) { // Process the rawdata from our socket connection and convert into nexus packets to take action against this.pendingBuffer = (this.pendingBuffer == null ? data : Buffer.concat([this.pendingBuffer, data])); - if (this.pendingBuffer.length >= 3) { + if (this.pendingBuffer.length < 3) { // Ensure we have a minimun length in the buffer to read header details - var type = this.pendingBuffer.readUInt8(); - var headerLength = 3; - var length = this.pendingBuffer.readUInt16BE(1); - - if (type == PacketType.LONG_PLAYBACK_PACKET) { - // Adjust header size and data length based upon packet type - headerLength = 5; - length = this.pendingBuffer.readUInt32BE(1); - } + return; + } - var payloadEndPosition = length + headerLength; - if (this.pendingBuffer.length >= payloadEndPosition) { - var payload = new protoBuf(this.pendingBuffer.slice(headerLength, payloadEndPosition)); - switch (type) { - case PacketType.OK : { - this.authorised = true; // OK message, means we're connected and authorised to Nexus - this.#processMessages(); // process any pending messages - this.#startNexusData(); // start processing data - break; - } - - case PacketType.ERROR : { - this.#handleNexusError(payload); - break; - } - - case PacketType.PLAYBACK_BEGIN : { - this.#handlePlaybackBegin(payload); - break; - } - - case PacketType.PLAYBACK_END : { - this.#handlePlaybackEnd(payload); - break; - } - - case PacketType.LONG_PLAYBACK_PACKET : - case PacketType.PLAYBACK_PACKET : { - this.#handlePlaybackPacket(payload); - break; - } + var packetType = this.pendingBuffer.readUInt8(); - case PacketType.REDIRECT : { - this.#handleRedirect(payload); - break; - } + var headerSizeInBytes = 3; + var dataSizeInBytes = this.pendingBuffer.readUInt16BE(1); - case PacketType.TALKBACK_BEGIN : { - this.#handleTalkbackBegin(payload); - break; - } + if (packetType == PacketType.LONG_PLAYBACK_PACKET) { + headerSizeInBytes = 5; + dataSizeInBytes = this.pendingBuffer.readUInt32BE(1); + } - case PacketType.TALKBACK_END : { - this.#handleTalkbackEnd(payload); - break; - } + var protoBufPayloadSize = headerSizeInBytes + dataSizeInBytes; + if (this.pendingBuffer.length < protoBufPayloadSize) { + return; + } - default: { - this.debug && console.debug(getTimestamp() + " [NEXUS] Data packet type '%s'", type); - break - } - } - var remainingData = this.pendingBuffer.slice(payloadEndPosition); - this.pendingBuffer = null; - if (remainingData.length > 0) { - this.#handleNexusData(remainingData); // Maybe not do this recursive??? - } - } + var protoBufPayload = new protoBuf(this.pendingBuffer.slice(headerSizeInBytes, protoBufPayloadSize)); + if (packetType == PacketType.OK) { + this.authorised = true; // OK message, means we're connected and authorised to Nexus + this.#processMessages(); // process any pending messages + this.#startNexusData(); // start processing data + } + + if (packetType == PacketType.ERROR) { + this.#handleNexusError(protoBufPayload); + } + + if (packetType == PacketType.PLAYBACK_BEGIN) { + this.#handlePlaybackBegin(protoBufPayload); + } + + if (packetType == PacketType.PLAYBACK_END) { + this.#handlePlaybackEnd(protoBufPayload); + } + + if (packetType == PacketType.PLAYBACK_PACKET || packetType == PacketType.LONG_PLAYBACK_PACKET) { + this.#handlePlaybackPacket(protoBufPayload); + } + + if (packetType == PacketType.REDIRECT) { + this.#handleRedirect(protoBufPayload); + } + + if (packetType == PacketType.TALKBACK_BEGIN) { + this.#handleTalkbackBegin(protoBufPayload); + } + + if (packetType == PacketType.TALKBACK_END) { + this.#handleTalkbackEnd(protoBufPayload); + } + + if (packetType == PacketType.PING) { + } + + var remainingData = this.pendingBuffer.slice(protoBufPayloadSize); + this.pendingBuffer = null; + if (remainingData.length > 0) { + this.#handleNexusData(remainingData); // Maybe not do this recursive??? } } -} + #outputLogging(accessoryName, useConsoleDebug, ...outputMessage) { + if (this.enableDebugging == false) { + return; + } -// General functions -function getTimestamp() { - const pad = (n,s=2) => (`${new Array(s).fill(0)}${n}`).slice(-s); - const d = new Date(); - - return `${pad(d.getFullYear(),4)}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; + var timeStamp = String(new Date().getFullYear()).padStart(4, "0") + "-" + String(new Date().getMonth() + 1).padStart(2, "0") + "-" + String(new Date().getDate()).padStart(2, "0") + " " + String(new Date().getHours()).padStart(2, "0") + ":" + String(new Date().getMinutes()).padStart(2, "0") + ":" + String(new Date().getSeconds()).padStart(2, "0"); + if (useConsoleDebug == false) { + console.log(timeStamp + " [" + accessoryName + "] " + util.format(...outputMessage)); + } + if (useConsoleDebug == true) { + console.debug(timeStamp + " [" + accessoryName + "] " + util.format(...outputMessage)); + } + } } - module.exports = NexusStreamer; \ No newline at end of file diff --git a/package.json b/package.json index f8caad6..9843f0a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nest_accfactory", "description": "HomeKit integration for Nest devices based on HAP-NodeJS library", - "version": "0.1.1", + "version": "0.1.2", "main": "Nest_accfactory.js", "repository": { "type": "git",